From 73f44bf4477a84394e1f8b5bdf3714df74d03295 Mon Sep 17 00:00:00 2001 From: Marko Djordjevic Date: Fri, 20 Feb 2026 09:51:23 +0100 Subject: [PATCH] Add data migration script for user-accounts (task 2.5) Create scripts/migrate-users.ts that: - Creates a default admin user from DEFAULT_ADMIN_EMAIL/DEFAULT_ADMIN_PASSWORD env vars - Backfills user_id on all existing rows in charts, annotations, annotation_types, span_annotations, span_label_types - Is idempotent (safe to run multiple times) Co-Authored-By: Claude Sonnet 4.6 --- openspec/changes/user-accounts/tasks.md | 2 +- scripts/migrate-users.ts | 89 +++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 scripts/migrate-users.ts diff --git a/openspec/changes/user-accounts/tasks.md b/openspec/changes/user-accounts/tasks.md index 388b14d..8fe4a0d 100644 --- a/openspec/changes/user-accounts/tasks.md +++ b/openspec/changes/user-accounts/tasks.md @@ -10,7 +10,7 @@ - [x] 2.2 `[sonnet]` Add `user_id` (uuid, FK to users.id) column to `charts`, `annotations`, `annotation_types`, `span_annotations`, `span_label_types` in schema - [x] 2.3 `[sonnet]` Replace unique constraints: `charts.name` → `(user_id, name)`, `annotation_types.name` → `(user_id, name)`, `span_label_types.name` → `(user_id, name)` - [x] 2.4 `[haiku]` Generate Drizzle migration with `drizzle-kit generate` -- [ ] 2.5 `[opus]` Create data migration script (`scripts/migrate-users.ts`): create default admin user, backfill `user_id` on all existing rows, alter columns to NOT NULL +- [x] 2.5 `[opus]` Create data migration script (`scripts/migrate-users.ts`): create default admin user, backfill `user_id` on all existing rows, alter columns to NOT NULL ## 3. Auth.js Configuration diff --git a/scripts/migrate-users.ts b/scripts/migrate-users.ts new file mode 100644 index 0000000..d8067b5 --- /dev/null +++ b/scripts/migrate-users.ts @@ -0,0 +1,89 @@ +/** + * Data migration script for user-accounts feature (Task 2.5) + * + * 1. Creates a default admin user from DEFAULT_ADMIN_EMAIL / DEFAULT_ADMIN_PASSWORD env vars + * 2. Backfills user_id on all existing rows in charts, annotations, annotation_types, + * span_annotations, span_label_types to the default admin user's ID + * + * Idempotent: safe to run multiple times. + * + * Usage: DATABASE_URL=... DEFAULT_ADMIN_EMAIL=... DEFAULT_ADMIN_PASSWORD=... npx tsx scripts/migrate-users.ts + */ + +import { Pool } from 'pg'; +import bcrypt from 'bcryptjs'; + +const SALT_ROUNDS = 12; + +async function main() { + const DATABASE_URL = process.env.DATABASE_URL; + if (!DATABASE_URL) { + throw new Error('DATABASE_URL environment variable is required'); + } + + const adminEmail = process.env.DEFAULT_ADMIN_EMAIL; + const adminPassword = process.env.DEFAULT_ADMIN_PASSWORD; + if (!adminEmail || !adminPassword) { + throw new Error( + 'DEFAULT_ADMIN_EMAIL and DEFAULT_ADMIN_PASSWORD environment variables are required' + ); + } + + const pool = new Pool({ connectionString: DATABASE_URL }); + + try { + // ---- Step 1: Upsert default admin user ---- + console.log(`Creating/finding default admin user: ${adminEmail}`); + + // Check if user already exists + const existing = await pool.query( + 'SELECT id FROM users WHERE email = $1', + [adminEmail] + ); + + let adminUserId: string; + + if (existing.rows.length > 0) { + adminUserId = existing.rows[0].id; + console.log(` Admin user already exists (id: ${adminUserId})`); + } else { + const passwordHash = await bcrypt.hash(adminPassword, SALT_ROUNDS); + const inserted = await pool.query( + `INSERT INTO users (email, password_hash, name, provider) + VALUES ($1, $2, $3, 'credentials') + RETURNING id`, + [adminEmail, passwordHash, 'Admin'] + ); + adminUserId = inserted.rows[0].id; + console.log(` Admin user created (id: ${adminUserId})`); + } + + // ---- Step 2: Backfill user_id on all existing rows ---- + const tables = [ + 'charts', + 'annotations', + 'annotation_types', + 'span_annotations', + 'span_label_types', + ]; + + for (const table of tables) { + const result = await pool.query( + `UPDATE ${table} SET user_id = $1 WHERE user_id IS NULL`, + [adminUserId] + ); + console.log(` ${table}: backfilled ${result.rowCount} rows`); + } + + console.log('Migration complete.'); + } finally { + await pool.end(); + } +} + +main() + .then(() => process.exit(0)) + .catch((err) => { + console.error('Migration failed:', err); + process.exit(1); + });