const Database = require('better-sqlite3'); const fs = require('fs'); const path = require('path'); const config = require('../config'); const dbDir = path.dirname(config.dbPath); if (!fs.existsSync(dbDir)) fs.mkdirSync(dbDir, { recursive: true }); const db = new Database(config.dbPath); // Enable WAL mode and foreign keys db.pragma('journal_mode = WAL'); db.pragma('foreign_keys = ON'); // Run schema 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', 'ALTER TABLE devices ADD COLUMN user_id TEXT REFERENCES users(id)', 'ALTER TABLE content ADD COLUMN user_id TEXT REFERENCES users(id)', "ALTER TABLE users ADD COLUMN plan_id TEXT DEFAULT 'free'", 'ALTER TABLE users ADD COLUMN stripe_customer_id TEXT', 'ALTER TABLE users ADD COLUMN stripe_subscription_id TEXT', "ALTER TABLE users ADD COLUMN subscription_status TEXT DEFAULT 'active'", 'ALTER TABLE users ADD COLUMN subscription_ends INTEGER', // Layout & zone support on devices and assignments 'ALTER TABLE devices ADD COLUMN layout_id TEXT', 'ALTER TABLE devices ADD COLUMN timezone TEXT DEFAULT \'UTC\'', 'ALTER TABLE devices ADD COLUMN wall_id TEXT', 'ALTER TABLE devices ADD COLUMN team_id TEXT', 'ALTER TABLE assignments ADD COLUMN zone_id TEXT', 'ALTER TABLE assignments ADD COLUMN widget_id TEXT', // Team support on content 'ALTER TABLE content ADD COLUMN team_id TEXT', // Device notes 'ALTER TABLE devices ADD COLUMN notes TEXT', // Email settings on users "ALTER TABLE users ADD COLUMN email_alerts INTEGER DEFAULT 1", // Content folders 'ALTER TABLE content ADD COLUMN folder TEXT', // Device orientation and default content "ALTER TABLE devices ADD COLUMN orientation TEXT DEFAULT 'landscape'", 'ALTER TABLE devices ADD COLUMN default_content_id TEXT', // Audio control per assignment "ALTER TABLE assignments ADD COLUMN muted INTEGER DEFAULT 0", // Trial tracking "ALTER TABLE users ADD COLUMN trial_started INTEGER", "ALTER TABLE users ADD COLUMN trial_plan TEXT DEFAULT 'pro'", // Stripe price IDs on plans "ALTER TABLE plans ADD COLUMN stripe_price_monthly TEXT", "ALTER TABLE plans ADD COLUMN stripe_price_yearly TEXT", // Last login tracking "ALTER TABLE users ADD COLUMN last_login INTEGER", // Phase 2: every device gets a playlist, schedules can override with a playlist "ALTER TABLE devices ADD COLUMN playlist_id TEXT REFERENCES playlists(id) ON DELETE SET NULL", "ALTER TABLE schedules ADD COLUMN playlist_id TEXT REFERENCES playlists(id) ON DELETE SET NULL", "ALTER TABLE playlists ADD COLUMN is_auto_generated INTEGER NOT NULL DEFAULT 0", // Device authentication token "ALTER TABLE devices ADD COLUMN device_token TEXT", // Phase 3: playlist publish/draft state "ALTER TABLE playlists ADD COLUMN status TEXT NOT NULL DEFAULT 'draft'", "ALTER TABLE playlists ADD COLUMN published_snapshot TEXT", // Phase 4: group scheduling (column add only — full migration with CHECK below) "ALTER TABLE schedules ADD COLUMN group_id TEXT REFERENCES device_groups(id) ON DELETE SET NULL", // Hierarchical content folders (per-user) `CREATE TABLE IF NOT EXISTS content_folders ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, parent_id TEXT REFERENCES content_folders(id) ON DELETE CASCADE, name TEXT NOT NULL, created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) )`, "CREATE INDEX IF NOT EXISTS idx_content_folders_user ON content_folders(user_id, parent_id)", "ALTER TABLE content ADD COLUMN folder_id TEXT REFERENCES content_folders(id) ON DELETE SET NULL", "CREATE INDEX IF NOT EXISTS idx_content_folder ON content(folder_id)", // Group-level playlist: when set, devices added to the group inherit it. "ALTER TABLE device_groups ADD COLUMN playlist_id TEXT REFERENCES playlists(id) ON DELETE SET NULL", // Wall-level playlist: video walls now play a playlist (not just one content). "ALTER TABLE video_walls ADD COLUMN playlist_id TEXT REFERENCES playlists(id) ON DELETE SET NULL", // Free-form canvas layout: walls store a player rect; member devices store // their own rect. Coordinates are in arbitrary canvas units (effectively px). "ALTER TABLE video_walls ADD COLUMN player_x REAL", "ALTER TABLE video_walls ADD COLUMN player_y REAL", "ALTER TABLE video_walls ADD COLUMN player_width REAL", "ALTER TABLE video_walls ADD COLUMN player_height REAL", "ALTER TABLE video_wall_devices ADD COLUMN canvas_x REAL", "ALTER TABLE video_wall_devices ADD COLUMN canvas_y REAL", "ALTER TABLE video_wall_devices ADD COLUMN canvas_width REAL", "ALTER TABLE video_wall_devices ADD COLUMN canvas_height REAL", // Phase 2.2c: content_folders gets workspace_id. Phase 1 missed this table. "ALTER TABLE content_folders ADD COLUMN workspace_id TEXT REFERENCES workspaces(id)", "CREATE INDEX IF NOT EXISTS idx_content_folders_workspace ON content_folders(workspace_id)", // Phase 2 zone_id regression fix: playlist_items needs zone_id so the // multi-zone-layout assignment feature works. The Phase 2 assignments-> // playlist_items conversion (migrateAssignmentsToPlaylists) dropped this // column. Column ADD is idempotent via the surrounding try/catch loop. "ALTER TABLE playlist_items ADD COLUMN zone_id TEXT REFERENCES layout_zones(id) ON DELETE SET NULL", // Slice 1: idempotency guard for the one-time signup welcome/admin emails. // Non-null = this user has already been handled, so we never double-send. // New signups are stamped with the real unix-seconds time the send block ran // (see services/signupEmails.js). The paired backfill below stamps every // pre-existing user with the sentinel value 1, so that a future "IS NULL" // sweep/nudge can't mistake the legacy user base for un-welcomed accounts and // blast all of them. Sentinel 1 (vs a real timestamp) also lets a later // deliberate campaign tell "backfilled, never emailed" apart from "genuinely // sent at