screentinker/scripts/migrate-multitenancy.js

290 lines
13 KiB
JavaScript

#!/usr/bin/env node
// Phase 1 multitenancy migration runner.
//
// Adds: organizations, organization_members, workspaces, workspace_members,
// workspace_invites tables.
// Adds: workspace_id columns to every resource table; organization_id,
// acting_user_id, was_acting_as to activity_log; reseller billing
// metadata columns to workspaces (added at table create time).
// Backfills: one organization per existing user, one default workspace per
// org (or one workspace per existing team), all resource rows
// get the user's default workspace_id, activity_log gets both
// workspace_id and organization_id. Roles migrate: superadmin
// -> platform_admin, legacy 'admin' -> 'user'.
// Idempotent: tracked by schema_migrations row 'phase5_multitenancy_backfill'.
// Re-running is a no-op.
//
// Usage:
// node scripts/migrate-multitenancy.js [--dry-run]
//
// --dry-run prints what would happen and rolls the transaction back, so the
// DB is unchanged on disk. Without the flag the migration commits.
'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.
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() {
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);
}
console.log(`[migrate] mode=${dryRun ? 'DRY RUN' : 'COMMIT'}`);
console.log(`[migrate] db=${config.dbPath}`);
// 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 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 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_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 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'))
);
`);
// 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 */ }
}
// 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);
`);
// 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 userDefaultWs = new Map(); // user_id -> workspace_id
const userToOrg = new Map(); // user_id -> organization_id
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'];
function table_has_col(t, c) {
return db.prepare(`PRAGMA table_info(${t})`).all().some(x => x.name === c);
}
const stats = { orgs: 0, workspaces: 0, org_members: 0, ws_members: 0, role_changes: { sa: 0, adm: 0 }, backfill: {} };
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);
}
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();
console.log('[migrate] committed');
} catch (e) {
if (e.message === '__DRY_RUN_ROLLBACK__') {
console.log('[migrate] rolled back (dry run)');
} else {
console.error('[migrate] FAILED:', e.message);
process.exit(1);
}
}
console.log('---summary---');
console.log(JSON.stringify(stats, null, 2));
db.close();