mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
319 lines
15 KiB
JavaScript
319 lines
15 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.
|
|
//
|
|
// Two invocation modes:
|
|
// 1. CLI: node scripts/migrate-multitenancy.js [--dry-run]
|
|
// 2. In-process: require('./scripts/migrate-multitenancy').runMigration({ db })
|
|
//
|
|
// 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');
|
|
// 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 MIGRATION_ID = 'phase5_multitenancy_backfill';
|
|
|
|
function alreadyApplied(db) {
|
|
try {
|
|
return !!db.prepare('SELECT 1 FROM schema_migrations WHERE id = ?').get(MIGRATION_ID);
|
|
} catch { return false; }
|
|
}
|
|
|
|
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;
|
|
|
|
try {
|
|
if (alreadyApplied(db)) {
|
|
logger.log('[migrate] already applied - nothing to do');
|
|
return { skipped: true };
|
|
}
|
|
|
|
logger.log(`[migrate] mode=${dryRun ? 'DRY RUN' : 'COMMIT'}`);
|
|
logger.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();
|
|
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;
|
|
}
|
|
} finally {
|
|
if (ownDb) db.close();
|
|
}
|
|
}
|
|
|
|
// 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));
|
|
}
|
|
process.exit(0);
|
|
} catch (e) {
|
|
console.error('[migrate] FAILED:', e.message);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
module.exports = { runMigration };
|