diff --git a/scripts/migrate-multitenancy.js b/scripts/migrate-multitenancy.js index 1e442c3..cbc7ad6 100644 --- a/scripts/migrate-multitenancy.js +++ b/scripts/migrate-multitenancy.js @@ -14,276 +14,305 @@ // Idempotent: tracked by schema_migrations row 'phase5_multitenancy_backfill'. // Re-running is a no-op. // -// Usage: -// node scripts/migrate-multitenancy.js [--dry-run] +// Two invocation modes: +// 1. CLI: node scripts/migrate-multitenancy.js [--dry-run] +// 2. In-process: require('./scripts/migrate-multitenancy').runMigration({ db }) // -// --dry-run prints what would happen and rolls the transaction back, so the -// DB is unchanged on disk. Without the flag the migration commits. +// In-process mode is used by server/db/database.js on startup so self-hosters +// who pull latest and restart don't have to remember to run the script +// manually. 'use strict'; const path = require('path'); const SERVER_DIR = path.resolve(__dirname, '..', 'server'); -process.chdir(SERVER_DIR); -// Resolve modules relative to server/ where the deps live, not relative to this script's dir. +// Resolve modules relative to server/ where the deps live, not relative to +// this script's dir. Works both for CLI invocation and when require'd from +// database.js - Node resolves modules relative to the required file's own +// __dirname, not the caller's. const resolveFromServer = (name) => require.resolve(name, { paths: [SERVER_DIR] }); const Database = require(resolveFromServer('better-sqlite3')); const { v4: uuidv4 } = require(resolveFromServer('uuid')); const config = require(path.join(SERVER_DIR, 'config')); -const dryRun = process.argv.includes('--dry-run'); const MIGRATION_ID = 'phase5_multitenancy_backfill'; -const db = new Database(config.dbPath); -db.pragma('journal_mode = WAL'); -db.pragma('foreign_keys = ON'); - -function alreadyApplied() { +function alreadyApplied(db) { try { return !!db.prepare('SELECT 1 FROM schema_migrations WHERE id = ?').get(MIGRATION_ID); } catch { return false; } } -if (alreadyApplied()) { - console.log('[migrate] already applied - nothing to do'); - process.exit(0); -} +function runMigration({ db: existingDb = null, dryRun = false, logger = console } = {}) { + const db = existingDb || (() => { + const d = new Database(config.dbPath); + d.pragma('journal_mode = WAL'); + d.pragma('foreign_keys = ON'); + return d; + })(); + const ownDb = !existingDb; -console.log(`[migrate] mode=${dryRun ? 'DRY RUN' : 'COMMIT'}`); -console.log(`[migrate] db=${config.dbPath}`); + try { + if (alreadyApplied(db)) { + logger.log('[migrate] already applied - nothing to do'); + return { skipped: true }; + } -// 1. New tables (idempotent). -db.exec(` - CREATE TABLE IF NOT EXISTS organizations ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - slug TEXT UNIQUE, - owner_user_id TEXT NOT NULL REFERENCES users(id), - plan_id TEXT DEFAULT 'free' REFERENCES plans(id), - stripe_customer_id TEXT, - stripe_subscription_id TEXT, - subscription_status TEXT DEFAULT 'active', - subscription_ends INTEGER, - grace_period_ends INTEGER, - locked_at INTEGER, - default_brand_name TEXT, - default_logo_url TEXT, - default_primary_color TEXT, - created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), - updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) - ); + logger.log(`[migrate] mode=${dryRun ? 'DRY RUN' : 'COMMIT'}`); + logger.log(`[migrate] db=${config.dbPath}`); - CREATE TABLE IF NOT EXISTS organization_members ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - role TEXT NOT NULL DEFAULT 'org_admin', - invited_by TEXT REFERENCES users(id), - joined_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), - UNIQUE(organization_id, user_id) - ); + // 1. New tables (idempotent). + db.exec(` + CREATE TABLE IF NOT EXISTS organizations ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + slug TEXT UNIQUE, + owner_user_id TEXT NOT NULL REFERENCES users(id), + plan_id TEXT DEFAULT 'free' REFERENCES plans(id), + stripe_customer_id TEXT, + stripe_subscription_id TEXT, + subscription_status TEXT DEFAULT 'active', + subscription_ends INTEGER, + grace_period_ends INTEGER, + locked_at INTEGER, + default_brand_name TEXT, + default_logo_url TEXT, + default_primary_color TEXT, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) + ); - CREATE TABLE IF NOT EXISTS workspaces ( - id TEXT PRIMARY KEY, - organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, - name TEXT NOT NULL, - slug TEXT, - created_by TEXT REFERENCES users(id), - billing_type TEXT DEFAULT 'client_billable', - billing_notes TEXT, - billing_contact_email TEXT, - billing_contract_ref TEXT, - created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), - updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), - UNIQUE(organization_id, slug) - ); + CREATE TABLE IF NOT EXISTS organization_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'org_admin', + invited_by TEXT REFERENCES users(id), + joined_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + UNIQUE(organization_id, user_id) + ); - CREATE TABLE IF NOT EXISTS workspace_members ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - role TEXT NOT NULL DEFAULT 'workspace_viewer', - invited_by TEXT REFERENCES users(id), - joined_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), - UNIQUE(workspace_id, user_id) - ); + CREATE TABLE IF NOT EXISTS workspaces ( + id TEXT PRIMARY KEY, + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + name TEXT NOT NULL, + slug TEXT, + created_by TEXT REFERENCES users(id), + billing_type TEXT DEFAULT 'client_billable', + billing_notes TEXT, + billing_contact_email TEXT, + billing_contract_ref TEXT, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + UNIQUE(organization_id, slug) + ); - CREATE TABLE IF NOT EXISTS workspace_invites ( - id TEXT PRIMARY KEY, - workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, - email TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'workspace_viewer', - invited_by TEXT NOT NULL REFERENCES users(id), - expires_at INTEGER NOT NULL, - created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) - ); -`); + CREATE TABLE IF NOT EXISTS workspace_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'workspace_viewer', + invited_by TEXT REFERENCES users(id), + joined_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + UNIQUE(workspace_id, user_id) + ); -// 2. Additive columns (idempotent: ignore 'duplicate column' errors). -const alters = [ - 'ALTER TABLE devices ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)', - 'ALTER TABLE content ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)', - 'ALTER TABLE playlists ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)', - 'ALTER TABLE layouts ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)', - 'ALTER TABLE widgets ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)', - 'ALTER TABLE schedules ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)', - 'ALTER TABLE video_walls ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)', - 'ALTER TABLE device_groups ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)', - 'ALTER TABLE white_labels ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)', - 'ALTER TABLE kiosk_pages ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)', - 'ALTER TABLE alert_configs ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)', - 'ALTER TABLE activity_log ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)', - 'ALTER TABLE activity_log ADD COLUMN organization_id TEXT REFERENCES organizations(id)', - 'ALTER TABLE activity_log ADD COLUMN acting_user_id TEXT REFERENCES users(id)', - 'ALTER TABLE activity_log ADD COLUMN was_acting_as INTEGER DEFAULT 0', -]; -for (const sql of alters) { - try { db.exec(sql); } catch (e) { /* column exists */ } -} + CREATE TABLE IF NOT EXISTS workspace_invites ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + email TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'workspace_viewer', + invited_by TEXT NOT NULL REFERENCES users(id), + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) + ); + `); -// 3. Indexes. -db.exec(` - CREATE INDEX IF NOT EXISTS idx_devices_workspace ON devices(workspace_id); - CREATE INDEX IF NOT EXISTS idx_content_workspace ON content(workspace_id); - CREATE INDEX IF NOT EXISTS idx_playlists_workspace ON playlists(workspace_id); - CREATE INDEX IF NOT EXISTS idx_video_walls_workspace ON video_walls(workspace_id); - CREATE INDEX IF NOT EXISTS idx_workspaces_organization ON workspaces(organization_id); - CREATE INDEX IF NOT EXISTS idx_workspace_members_user ON workspace_members(user_id); - CREATE INDEX IF NOT EXISTS idx_organization_members_user ON organization_members(user_id); -`); + // 2. Additive columns (idempotent: ignore 'duplicate column' errors). + const alters = [ + 'ALTER TABLE devices ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)', + 'ALTER TABLE content ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)', + 'ALTER TABLE playlists ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)', + 'ALTER TABLE layouts ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)', + 'ALTER TABLE widgets ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)', + 'ALTER TABLE schedules ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)', + 'ALTER TABLE video_walls ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)', + 'ALTER TABLE device_groups ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)', + 'ALTER TABLE white_labels ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)', + 'ALTER TABLE kiosk_pages ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)', + 'ALTER TABLE alert_configs ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)', + 'ALTER TABLE activity_log ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)', + 'ALTER TABLE activity_log ADD COLUMN organization_id TEXT REFERENCES organizations(id)', + 'ALTER TABLE activity_log ADD COLUMN acting_user_id TEXT REFERENCES users(id)', + 'ALTER TABLE activity_log ADD COLUMN was_acting_as INTEGER DEFAULT 0', + ]; + for (const sql of alters) { + try { db.exec(sql); } catch (e) { /* column exists */ } + } -// 4. Backfill (single transaction). -const users = db.prepare('SELECT * FROM users').all(); -const teams = db.prepare('SELECT * FROM teams').all(); -const teamMembers = db.prepare('SELECT * FROM team_members').all(); + // 3. Indexes. + db.exec(` + CREATE INDEX IF NOT EXISTS idx_devices_workspace ON devices(workspace_id); + CREATE INDEX IF NOT EXISTS idx_content_workspace ON content(workspace_id); + CREATE INDEX IF NOT EXISTS idx_playlists_workspace ON playlists(workspace_id); + CREATE INDEX IF NOT EXISTS idx_video_walls_workspace ON video_walls(workspace_id); + CREATE INDEX IF NOT EXISTS idx_workspaces_organization ON workspaces(organization_id); + CREATE INDEX IF NOT EXISTS idx_workspace_members_user ON workspace_members(user_id); + CREATE INDEX IF NOT EXISTS idx_organization_members_user ON organization_members(user_id); + `); -const userDefaultWs = new Map(); // user_id -> workspace_id -const userToOrg = new Map(); // user_id -> organization_id + // 4. Backfill (single transaction). + const users = db.prepare('SELECT * FROM users').all(); + const teams = db.prepare('SELECT * FROM teams').all(); + const teamMembers = db.prepare('SELECT * FROM team_members').all(); -const RESOURCE_TABLES_WITH_TEAM_ID = ['devices', 'content', 'layouts', 'widgets', 'video_walls']; -const RESOURCE_TABLES_NO_TEAM_ID = ['playlists', 'schedules', 'device_groups', 'white_labels', 'kiosk_pages', 'alert_configs']; + const userDefaultWs = new Map(); // user_id -> workspace_id + const userToOrg = new Map(); // user_id -> organization_id -function table_has_col(t, c) { - return db.prepare(`PRAGMA table_info(${t})`).all().some(x => x.name === c); -} + const RESOURCE_TABLES_WITH_TEAM_ID = ['devices', 'content', 'layouts', 'widgets', 'video_walls']; + const RESOURCE_TABLES_NO_TEAM_ID = ['playlists', 'schedules', 'device_groups', 'white_labels', 'kiosk_pages', 'alert_configs']; -const stats = { orgs: 0, workspaces: 0, org_members: 0, ws_members: 0, role_changes: { sa: 0, adm: 0 }, backfill: {} }; + function table_has_col(t, c) { + return db.prepare(`PRAGMA table_info(${t})`).all().some(x => x.name === c); + } -const backfill = db.transaction(() => { - for (const u of users) { - const orgId = uuidv4(); - const orgName = (u.name && u.name.trim()) ? `${u.name}'s organization` : `${u.email}'s organization`; - db.prepare(`INSERT INTO organizations ( - id, name, owner_user_id, plan_id, - stripe_customer_id, stripe_subscription_id, - subscription_status, subscription_ends - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run( - orgId, orgName, u.id, u.plan_id || 'free', - u.stripe_customer_id || null, u.stripe_subscription_id || null, - u.subscription_status || 'active', u.subscription_ends || null - ); - stats.orgs++; - db.prepare(`INSERT INTO organization_members (organization_id, user_id, role) VALUES (?, ?, 'org_owner')`).run(orgId, u.id); - stats.org_members++; - userToOrg.set(u.id, orgId); + const stats = { orgs: 0, workspaces: 0, org_members: 0, ws_members: 0, role_changes: { sa: 0, adm: 0 }, backfill: {} }; - const ownedTeams = teams.filter(t => t.owner_id === u.id); - let defaultWsId; - if (ownedTeams.length === 0) { - defaultWsId = uuidv4(); - db.prepare(`INSERT INTO workspaces (id, organization_id, name, created_by) VALUES (?, ?, 'Default', ?)`).run(defaultWsId, orgId, u.id); - stats.workspaces++; - db.prepare(`INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'workspace_admin')`).run(defaultWsId, u.id); - stats.ws_members++; - } else { - // Re-use each team's id as the workspace id so existing FKs and bookmarks survive. - for (const t of ownedTeams) { - db.prepare(`INSERT INTO workspaces (id, organization_id, name, created_by) VALUES (?, ?, ?, ?)`).run(t.id, orgId, t.name, t.owner_id); - stats.workspaces++; - const tms = teamMembers.filter(m => m.team_id === t.id); - let ownerSeen = false; - for (const m of tms) { - if (m.user_id === t.owner_id) ownerSeen = true; - const wsRole = - m.role === 'owner' ? 'workspace_admin' : - m.role === 'editor' ? 'workspace_editor' : - 'workspace_viewer'; - db.prepare(`INSERT INTO workspace_members (workspace_id, user_id, role, invited_by, joined_at) VALUES (?, ?, ?, ?, ?)`) - .run(t.id, m.user_id, wsRole, m.invited_by || null, m.joined_at || Math.floor(Date.now() / 1000)); - stats.ws_members++; - } - if (!ownerSeen) { - db.prepare(`INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'workspace_admin')`).run(t.id, t.owner_id); + const backfill = db.transaction(() => { + for (const u of users) { + const orgId = uuidv4(); + const orgName = (u.name && u.name.trim()) ? `${u.name}'s organization` : `${u.email}'s organization`; + db.prepare(`INSERT INTO organizations ( + id, name, owner_user_id, plan_id, + stripe_customer_id, stripe_subscription_id, + subscription_status, subscription_ends + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run( + orgId, orgName, u.id, u.plan_id || 'free', + u.stripe_customer_id || null, u.stripe_subscription_id || null, + u.subscription_status || 'active', u.subscription_ends || null + ); + stats.orgs++; + db.prepare(`INSERT INTO organization_members (organization_id, user_id, role) VALUES (?, ?, 'org_owner')`).run(orgId, u.id); + stats.org_members++; + userToOrg.set(u.id, orgId); + + const ownedTeams = teams.filter(t => t.owner_id === u.id); + let defaultWsId; + if (ownedTeams.length === 0) { + defaultWsId = uuidv4(); + db.prepare(`INSERT INTO workspaces (id, organization_id, name, created_by) VALUES (?, ?, 'Default', ?)`).run(defaultWsId, orgId, u.id); + stats.workspaces++; + db.prepare(`INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'workspace_admin')`).run(defaultWsId, u.id); stats.ws_members++; + } else { + // Re-use each team's id as the workspace id so existing FKs and bookmarks survive. + for (const t of ownedTeams) { + db.prepare(`INSERT INTO workspaces (id, organization_id, name, created_by) VALUES (?, ?, ?, ?)`).run(t.id, orgId, t.name, t.owner_id); + stats.workspaces++; + const tms = teamMembers.filter(m => m.team_id === t.id); + let ownerSeen = false; + for (const m of tms) { + if (m.user_id === t.owner_id) ownerSeen = true; + const wsRole = + m.role === 'owner' ? 'workspace_admin' : + m.role === 'editor' ? 'workspace_editor' : + 'workspace_viewer'; + db.prepare(`INSERT INTO workspace_members (workspace_id, user_id, role, invited_by, joined_at) VALUES (?, ?, ?, ?, ?)`) + .run(t.id, m.user_id, wsRole, m.invited_by || null, m.joined_at || Math.floor(Date.now() / 1000)); + stats.ws_members++; + } + if (!ownerSeen) { + db.prepare(`INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'workspace_admin')`).run(t.id, t.owner_id); + stats.ws_members++; + } + } + defaultWsId = ownedTeams + .slice() + .sort((a, b) => (a.created_at || 0) - (b.created_at || 0))[0].id; } + userDefaultWs.set(u.id, defaultWsId); } - defaultWsId = ownedTeams - .slice() - .sort((a, b) => (a.created_at || 0) - (b.created_at || 0))[0].id; + + for (const t of RESOURCE_TABLES_WITH_TEAM_ID) { + if (!table_has_col(t, 'workspace_id')) { stats.backfill[t] = 'skipped (no workspace_id col)'; continue; } + const rows = db.prepare(`SELECT id, user_id, team_id FROM ${t} WHERE workspace_id IS NULL`).all(); + const upd = db.prepare(`UPDATE ${t} SET workspace_id = ? WHERE id = ?`); + let filled = 0; + for (const r of rows) { + const wsId = r.team_id || userDefaultWs.get(r.user_id); + if (wsId) { upd.run(wsId, r.id); filled++; } + } + stats.backfill[t] = `${filled}/${rows.length}`; + } + for (const t of RESOURCE_TABLES_NO_TEAM_ID) { + if (!table_has_col(t, 'workspace_id')) { stats.backfill[t] = 'skipped (no workspace_id col)'; continue; } + const rows = db.prepare(`SELECT id, user_id FROM ${t} WHERE workspace_id IS NULL`).all(); + const upd = db.prepare(`UPDATE ${t} SET workspace_id = ? WHERE id = ?`); + let filled = 0; + for (const r of rows) { + const wsId = userDefaultWs.get(r.user_id); + if (wsId) { upd.run(wsId, r.id); filled++; } + } + stats.backfill[t] = `${filled}/${rows.length}`; + } + + const aRows = db.prepare(`SELECT id, user_id FROM activity_log WHERE workspace_id IS NULL OR organization_id IS NULL`).all(); + const aUpd = db.prepare(`UPDATE activity_log SET workspace_id = ?, organization_id = ? WHERE id = ?`); + let aFilled = 0; + for (const r of aRows) { + const wsId = r.user_id ? (userDefaultWs.get(r.user_id) || null) : null; + const orgId = r.user_id ? (userToOrg.get(r.user_id) || null) : null; + aUpd.run(wsId, orgId, r.id); + if (wsId || orgId) aFilled++; + } + stats.backfill.activity_log = `${aFilled}/${aRows.length} (NULLs are anonymous platform events)`; + + // Role migration. + stats.role_changes.sa = db.prepare(`SELECT COUNT(*) AS n FROM users WHERE role = 'superadmin'`).get().n; + stats.role_changes.adm = db.prepare(`SELECT COUNT(*) AS n FROM users WHERE role = 'admin'`).get().n; + db.prepare(`UPDATE users SET role = 'platform_admin' WHERE role = 'superadmin'`).run(); + db.prepare(`UPDATE users SET role = 'user' WHERE role = 'admin'`).run(); + + db.prepare('INSERT OR IGNORE INTO schema_migrations (id) VALUES (?)').run(MIGRATION_ID); + + if (dryRun) { + // Force rollback by throwing - better-sqlite3 db.transaction() reverts everything. + throw new Error('__DRY_RUN_ROLLBACK__'); + } + }); + + try { + backfill(); + logger.log('[migrate] committed'); + return { ok: true, stats }; + } catch (e) { + if (e.message === '__DRY_RUN_ROLLBACK__') { + logger.log('[migrate] rolled back (dry run)'); + return { ok: true, dryRun: true, stats }; + } + throw e; } - userDefaultWs.set(u.id, defaultWsId); + } finally { + if (ownDb) db.close(); } +} - for (const t of RESOURCE_TABLES_WITH_TEAM_ID) { - if (!table_has_col(t, 'workspace_id')) { stats.backfill[t] = 'skipped (no workspace_id col)'; continue; } - const rows = db.prepare(`SELECT id, user_id, team_id FROM ${t} WHERE workspace_id IS NULL`).all(); - const upd = db.prepare(`UPDATE ${t} SET workspace_id = ? WHERE id = ?`); - let filled = 0; - for (const r of rows) { - const wsId = r.team_id || userDefaultWs.get(r.user_id); - if (wsId) { upd.run(wsId, r.id); filled++; } +// CLI wrapper - only fires when invoked as 'node scripts/migrate-multitenancy.js'. +// When required from server/db/database.js the CLI block is skipped. +if (require.main === module) { + process.chdir(SERVER_DIR); + const dryRun = process.argv.includes('--dry-run'); + try { + const result = runMigration({ dryRun }); + if (result.stats) { + console.log('---summary---'); + console.log(JSON.stringify(result.stats, null, 2)); } - stats.backfill[t] = `${filled}/${rows.length}`; - } - for (const t of RESOURCE_TABLES_NO_TEAM_ID) { - if (!table_has_col(t, 'workspace_id')) { stats.backfill[t] = 'skipped (no workspace_id col)'; continue; } - const rows = db.prepare(`SELECT id, user_id FROM ${t} WHERE workspace_id IS NULL`).all(); - const upd = db.prepare(`UPDATE ${t} SET workspace_id = ? WHERE id = ?`); - let filled = 0; - for (const r of rows) { - const wsId = userDefaultWs.get(r.user_id); - if (wsId) { upd.run(wsId, r.id); filled++; } - } - stats.backfill[t] = `${filled}/${rows.length}`; - } - - const aRows = db.prepare(`SELECT id, user_id FROM activity_log WHERE workspace_id IS NULL OR organization_id IS NULL`).all(); - const aUpd = db.prepare(`UPDATE activity_log SET workspace_id = ?, organization_id = ? WHERE id = ?`); - let aFilled = 0; - for (const r of aRows) { - const wsId = r.user_id ? (userDefaultWs.get(r.user_id) || null) : null; - const orgId = r.user_id ? (userToOrg.get(r.user_id) || null) : null; - aUpd.run(wsId, orgId, r.id); - if (wsId || orgId) aFilled++; - } - stats.backfill.activity_log = `${aFilled}/${aRows.length} (NULLs are anonymous platform events)`; - - // Role migration. - stats.role_changes.sa = db.prepare(`SELECT COUNT(*) AS n FROM users WHERE role = 'superadmin'`).get().n; - stats.role_changes.adm = db.prepare(`SELECT COUNT(*) AS n FROM users WHERE role = 'admin'`).get().n; - db.prepare(`UPDATE users SET role = 'platform_admin' WHERE role = 'superadmin'`).run(); - db.prepare(`UPDATE users SET role = 'user' WHERE role = 'admin'`).run(); - - db.prepare('INSERT OR IGNORE INTO schema_migrations (id) VALUES (?)').run(MIGRATION_ID); - - if (dryRun) { - // Force rollback by throwing - better-sqlite3 db.transaction() reverts everything. - throw new Error('__DRY_RUN_ROLLBACK__'); - } -}); - -try { - backfill(); - console.log('[migrate] committed'); -} catch (e) { - if (e.message === '__DRY_RUN_ROLLBACK__') { - console.log('[migrate] rolled back (dry run)'); - } else { + process.exit(0); + } catch (e) { console.error('[migrate] FAILED:', e.message); process.exit(1); } } -console.log('---summary---'); -console.log(JSON.stringify(stats, null, 2)); -db.close(); +module.exports = { runMigration }; diff --git a/server/db/database.js b/server/db/database.js index 7167002..2fa54ff 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -16,6 +16,50 @@ db.pragma('foreign_keys = ON'); const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8'); db.exec(schema); +// Auto-apply Phase 1 multi-tenancy migration if not yet applied. Without this +// a self-hoster who pulls latest and restarts hits a crash in +// migrateFolderWorkspaceIds (queries workspaces table that doesn't exist). +// Pre-existing data is snapshotted to db/remote_display.pre-migration-.db +// before the migration runs - clear restore path on failure. Fresh installs +// run against empty data (creates tables, no rows to backfill). +function ensureMultitenancyMigration() { + let applied = false; + try { + applied = !!db.prepare( + "SELECT 1 FROM schema_migrations WHERE id = 'phase5_multitenancy_backfill'" + ).get(); + } catch { /* schema_migrations may not exist yet; treat as not applied */ } + if (applied) return; + + console.warn('[boot] Multi-tenancy schema not present - applying migration...'); + const ts = new Date().toISOString().replace(/[:.]/g, '-'); + const snapshotPath = path.join(dbDir, `remote_display.pre-migration-${ts}.db`); + try { + db.pragma('wal_checkpoint(TRUNCATE)'); + fs.copyFileSync(config.dbPath, snapshotPath); + console.warn(`[boot] Pre-migration snapshot: ${snapshotPath}`); + } catch (e) { + console.error(`[boot] Snapshot failed: ${e.message}`); + process.exit(1); + } + + try { + const { runMigration } = require('../../scripts/migrate-multitenancy'); + runMigration({ db }); + console.warn('[boot] Migration complete, continuing startup'); + } catch (e) { + console.error(`[boot] Migration FAILED: ${e.message}`); + console.error(`[boot] Restore with: cp ${snapshotPath} ${config.dbPath}`); + process.exit(1); + } +} + +// Note: ensureMultitenancyMigration() is called LATER, after the inline +// migrations array has added team_id and workspace_id columns. The Phase 1 +// migration script reads team_id from resource tables during its backfill +// loop, so those columns must exist first. Definition kept here near the +// top so the auto-migration logic is easy to find when reading the file. + // Migrations for existing databases const migrations = [ 'ALTER TABLE content ADD COLUMN remote_url TEXT', @@ -330,6 +374,12 @@ function migrateGroupSchedules() { migrateGroupSchedules(); +// Phase 1 multi-tenancy migration (auto-applies if not yet run). Must come +// AFTER the inline migrations above so that team_id / workspace_id columns +// exist on resource tables - the Phase 1 backfill loop reads team_id and +// updates workspace_id. +ensureMultitenancyMigration(); + // Phase 2.2c migration: backfill content_folders.workspace_id from owner's // default workspace. The ALTER lives in the migrations array above; this // one-shot populates the column for any rows that pre-date it. @@ -339,6 +389,16 @@ function migrateFolderWorkspaceIds() { const already = db.prepare('SELECT 1 FROM schema_migrations WHERE id = ?').get(PHASE6_MIGRATION_ID); if (already) return; + // Belt-and-suspenders: if multi-tenancy tables aren't present (auto-runner + // somehow skipped), skip cleanly instead of crashing on the JOIN below. + const hasWorkspaces = db.prepare( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='workspaces'" + ).get(); + if (!hasWorkspaces) { + console.warn('migrateFolderWorkspaceIds: workspaces table missing, skipping'); + return; + } + // Check the column exists before trying to backfill. (Defensive: on a fresh // install the schema.sql defines content_folders without the column, the // ALTER above adds it, and we proceed; but if anything went sideways we @@ -385,6 +445,16 @@ function backfillActivityLogWorkspace() { const already = db.prepare('SELECT 1 FROM schema_migrations WHERE id = ?').get(PHASE_2_2_ACTIVITY_STOP_ID); if (already) return; + // Belt-and-suspenders: if multi-tenancy tables aren't present (auto-runner + // somehow skipped), skip cleanly instead of crashing on workspace_members. + const hasMembers = db.prepare( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='workspace_members'" + ).get(); + if (!hasMembers) { + console.warn('backfillActivityLogWorkspace: workspace_members table missing, skipping'); + return; + } + const viaDevice = db.prepare(` UPDATE activity_log SET workspace_id = ( SELECT workspace_id FROM devices WHERE devices.id = activity_log.device_id