import Database from 'better-sqlite3'; import { encrypt_api_key } from '../services/apiKeyCrypto'; function runMigrations(db: Database.Database): void { db.exec('CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)'); const versionRow = db.prepare('SELECT version FROM schema_version').get() as { version: number } | undefined; let currentVersion = versionRow?.version ?? 0; if (currentVersion === 0) { const hasUnsplash = db.prepare( "SELECT 1 FROM pragma_table_info('users') WHERE name = 'unsplash_api_key'" ).get(); if (hasUnsplash) { currentVersion = 19; db.prepare('INSERT INTO schema_version (version) VALUES (?)').run(currentVersion); console.log('[DB] Schema already up-to-date, setting version to', currentVersion); } else { db.prepare('INSERT INTO schema_version (version) VALUES (?)').run(0); } } const migrations: Array<() => void> = [ () => db.exec('ALTER TABLE users ADD COLUMN unsplash_api_key TEXT'), () => db.exec('ALTER TABLE users ADD COLUMN openweather_api_key TEXT'), () => db.exec('ALTER TABLE places ADD COLUMN duration_minutes INTEGER DEFAULT 60'), () => db.exec('ALTER TABLE places ADD COLUMN notes TEXT'), () => db.exec('ALTER TABLE places ADD COLUMN image_url TEXT'), () => db.exec("ALTER TABLE places ADD COLUMN transport_mode TEXT DEFAULT 'walking'"), () => db.exec('ALTER TABLE days ADD COLUMN title TEXT'), () => db.exec("ALTER TABLE reservations ADD COLUMN status TEXT DEFAULT 'pending'"), () => db.exec('ALTER TABLE trip_files ADD COLUMN reservation_id INTEGER REFERENCES reservations(id) ON DELETE SET NULL'), () => db.exec("ALTER TABLE reservations ADD COLUMN type TEXT DEFAULT 'other'"), () => db.exec('ALTER TABLE trips ADD COLUMN cover_image TEXT'), () => db.exec("ALTER TABLE day_notes ADD COLUMN icon TEXT DEFAULT '📝'"), () => db.exec('ALTER TABLE trips ADD COLUMN is_archived INTEGER DEFAULT 0'), () => db.exec('ALTER TABLE categories ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL'), () => db.exec('ALTER TABLE users ADD COLUMN avatar TEXT'), () => db.exec('ALTER TABLE users ADD COLUMN oidc_sub TEXT'), () => db.exec('ALTER TABLE users ADD COLUMN oidc_issuer TEXT'), () => db.exec('ALTER TABLE users ADD COLUMN last_login DATETIME'), () => { const schema = db.prepare("SELECT sql FROM sqlite_master WHERE name = 'budget_items'").get() as { sql: string } | undefined; if (schema?.sql?.includes('NOT NULL DEFAULT 1')) { db.exec(` CREATE TABLE budget_items_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, category TEXT NOT NULL DEFAULT 'Other', name TEXT NOT NULL, total_price REAL NOT NULL DEFAULT 0, persons INTEGER DEFAULT NULL, days INTEGER DEFAULT NULL, note TEXT, sort_order INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); INSERT INTO budget_items_new SELECT * FROM budget_items; DROP TABLE budget_items; ALTER TABLE budget_items_new RENAME TO budget_items; `); } }, () => { try { db.exec('ALTER TABLE day_accommodations ADD COLUMN check_in TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } try { db.exec('ALTER TABLE day_accommodations ADD COLUMN check_out TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } try { db.exec('ALTER TABLE day_accommodations ADD COLUMN confirmation TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { try { db.exec('ALTER TABLE places ADD COLUMN end_time TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { try { db.exec('ALTER TABLE day_assignments ADD COLUMN reservation_status TEXT DEFAULT \'none\''); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } try { db.exec('ALTER TABLE day_assignments ADD COLUMN reservation_notes TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } try { db.exec('ALTER TABLE day_assignments ADD COLUMN reservation_datetime TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } try { db.exec(` UPDATE day_assignments SET reservation_status = (SELECT reservation_status FROM places WHERE places.id = day_assignments.place_id), reservation_notes = (SELECT reservation_notes FROM places WHERE places.id = day_assignments.place_id), reservation_datetime = (SELECT reservation_datetime FROM places WHERE places.id = day_assignments.place_id) WHERE place_id IN (SELECT id FROM places WHERE reservation_status IS NOT NULL AND reservation_status != 'none') `); console.log('[DB] Migrated reservation data from places to day_assignments'); } catch (e: unknown) { console.error('[DB] Migration 22 data copy error:', e instanceof Error ? e.message : e); } }, () => { try { db.exec('ALTER TABLE reservations ADD COLUMN assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE SET NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { db.exec(` CREATE TABLE IF NOT EXISTS assignment_participants ( id INTEGER PRIMARY KEY AUTOINCREMENT, assignment_id INTEGER NOT NULL REFERENCES day_assignments(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, UNIQUE(assignment_id, user_id) ) `); }, () => { db.exec(` CREATE TABLE IF NOT EXISTS collab_notes ( id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, category TEXT DEFAULT 'General', title TEXT NOT NULL, content TEXT, color TEXT DEFAULT '#6366f1', pinned INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS collab_polls ( id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, question TEXT NOT NULL, options TEXT NOT NULL, multiple INTEGER DEFAULT 0, closed INTEGER DEFAULT 0, deadline TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS collab_poll_votes ( id INTEGER PRIMARY KEY AUTOINCREMENT, poll_id INTEGER NOT NULL REFERENCES collab_polls(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, option_index INTEGER NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(poll_id, user_id, option_index) ); CREATE TABLE IF NOT EXISTS collab_messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, text TEXT NOT NULL, reply_to INTEGER REFERENCES collab_messages(id) ON DELETE SET NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_collab_notes_trip ON collab_notes(trip_id); CREATE INDEX IF NOT EXISTS idx_collab_polls_trip ON collab_polls(trip_id); CREATE INDEX IF NOT EXISTS idx_collab_messages_trip ON collab_messages(trip_id); `); try { db.prepare("INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES ('collab', 'Collab', 'Notes, polls, and live chat for trip collaboration', 'trip', 'Users', 1, 6)").run(); } catch (err: any) { console.warn('[migrations] Non-fatal migration step failed:', err); } }, () => { try { db.exec('ALTER TABLE day_assignments ADD COLUMN assignment_time TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } try { db.exec('ALTER TABLE day_assignments ADD COLUMN assignment_end_time TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } try { db.exec(` UPDATE day_assignments SET assignment_time = (SELECT place_time FROM places WHERE places.id = day_assignments.place_id), assignment_end_time = (SELECT end_time FROM places WHERE places.id = day_assignments.place_id) `); } catch (err: any) { console.warn('[migrations] Non-fatal migration step failed:', err); } }, () => { db.exec(` CREATE TABLE IF NOT EXISTS budget_item_members ( id INTEGER PRIMARY KEY AUTOINCREMENT, budget_item_id INTEGER NOT NULL REFERENCES budget_items(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, paid INTEGER NOT NULL DEFAULT 0, UNIQUE(budget_item_id, user_id) ); CREATE INDEX IF NOT EXISTS idx_budget_item_members_item ON budget_item_members(budget_item_id); CREATE INDEX IF NOT EXISTS idx_budget_item_members_user ON budget_item_members(user_id); `); }, () => { db.exec(` CREATE TABLE IF NOT EXISTS collab_message_reactions ( id INTEGER PRIMARY KEY AUTOINCREMENT, message_id INTEGER NOT NULL REFERENCES collab_messages(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, emoji TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(message_id, user_id, emoji) ); CREATE INDEX IF NOT EXISTS idx_collab_reactions_msg ON collab_message_reactions(message_id); `); }, () => { try { db.exec('ALTER TABLE collab_messages ADD COLUMN deleted INTEGER DEFAULT 0'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { try { db.exec('ALTER TABLE trip_files ADD COLUMN note_id INTEGER REFERENCES collab_notes(id) ON DELETE SET NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } try { db.exec('ALTER TABLE collab_notes ADD COLUMN website TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { try { db.exec('ALTER TABLE reservations ADD COLUMN reservation_end_time TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { try { db.exec('ALTER TABLE places ADD COLUMN osm_id TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { try { db.exec('ALTER TABLE trip_files ADD COLUMN uploaded_by INTEGER REFERENCES users(id) ON DELETE SET NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } try { db.exec('ALTER TABLE trip_files ADD COLUMN starred INTEGER DEFAULT 0'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } try { db.exec('ALTER TABLE trip_files ADD COLUMN deleted_at TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { try { db.exec('ALTER TABLE reservations ADD COLUMN accommodation_id INTEGER REFERENCES day_accommodations(id) ON DELETE SET NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } try { db.exec('ALTER TABLE reservations ADD COLUMN metadata TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { db.exec(`CREATE TABLE IF NOT EXISTS invite_tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT, token TEXT UNIQUE NOT NULL, max_uses INTEGER NOT NULL DEFAULT 1, used_count INTEGER NOT NULL DEFAULT 0, expires_at TEXT, created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`); }, () => { try { db.exec('ALTER TABLE users ADD COLUMN mfa_enabled INTEGER DEFAULT 0'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } try { db.exec('ALTER TABLE users ADD COLUMN mfa_secret TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { db.exec(`CREATE TABLE IF NOT EXISTS packing_category_assignees ( id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, category_name TEXT NOT NULL, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, UNIQUE(trip_id, category_name, user_id) )`); }, () => { db.exec(`CREATE TABLE IF NOT EXISTS packing_templates ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`); db.exec(`CREATE TABLE IF NOT EXISTS packing_template_categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, template_id INTEGER NOT NULL REFERENCES packing_templates(id) ON DELETE CASCADE, name TEXT NOT NULL, sort_order INTEGER NOT NULL DEFAULT 0 )`); // Recreate items table with category_id FK (replaces old template_id-based schema) try { db.exec('DROP TABLE IF EXISTS packing_template_items'); } catch (err: any) { console.warn('[migrations] Non-fatal migration step failed:', err); } db.exec(`CREATE TABLE packing_template_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, category_id INTEGER NOT NULL REFERENCES packing_template_categories(id) ON DELETE CASCADE, name TEXT NOT NULL, sort_order INTEGER NOT NULL DEFAULT 0 )`); }, () => { db.exec(`CREATE TABLE IF NOT EXISTS packing_bags ( id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, name TEXT NOT NULL, color TEXT NOT NULL DEFAULT '#6366f1', weight_limit_grams INTEGER, sort_order INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`); try { db.exec('ALTER TABLE packing_items ADD COLUMN weight_grams INTEGER'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } try { db.exec('ALTER TABLE packing_items ADD COLUMN bag_id INTEGER REFERENCES packing_bags(id) ON DELETE SET NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { db.exec(`CREATE TABLE IF NOT EXISTS visited_countries ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, country_code TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(user_id, country_code) )`); }, () => { db.exec(`CREATE TABLE IF NOT EXISTS bucket_list ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL, lat REAL, lng REAL, country_code TEXT, notes TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`); }, () => { // Configurable weekend days try { db.exec("ALTER TABLE vacay_plans ADD COLUMN weekend_days TEXT DEFAULT '0,6'"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { // Immich integration try { db.exec("ALTER TABLE users ADD COLUMN immich_url TEXT"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } try { db.exec("ALTER TABLE users ADD COLUMN immich_api_key TEXT"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } db.exec(`CREATE TABLE IF NOT EXISTS trip_photos ( id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, immich_asset_id TEXT NOT NULL, shared INTEGER NOT NULL DEFAULT 1, added_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(trip_id, user_id, immich_asset_id) )`); // Add memories addon try { db.prepare("INSERT INTO addons (id, name, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)").run('memories', 'Photos', 'trip', 'Image', 0, 7); } catch (err: any) { console.warn('[migrations] Non-fatal migration step failed:', err); } }, () => { // Allow files to be linked to multiple reservations/assignments db.exec(`CREATE TABLE IF NOT EXISTS file_links ( id INTEGER PRIMARY KEY AUTOINCREMENT, file_id INTEGER NOT NULL REFERENCES trip_files(id) ON DELETE CASCADE, reservation_id INTEGER REFERENCES reservations(id) ON DELETE CASCADE, assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE CASCADE, place_id INTEGER REFERENCES places(id) ON DELETE CASCADE, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(file_id, reservation_id), UNIQUE(file_id, assignment_id), UNIQUE(file_id, place_id) )`); }, () => { // Add day_plan_position to reservations for persistent transport ordering in day timeline try { db.exec('ALTER TABLE reservations ADD COLUMN day_plan_position REAL DEFAULT NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { // Add paid_by_user_id to budget_items for expense tracking / settlement try { db.exec('ALTER TABLE budget_items ADD COLUMN paid_by_user_id INTEGER REFERENCES users(id)'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { // Add target_date to bucket_list for optional visit planning try { db.exec('ALTER TABLE bucket_list ADD COLUMN target_date TEXT DEFAULT NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { // Notification preferences per user db.exec(`CREATE TABLE IF NOT EXISTS notification_preferences ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, notify_trip_invite INTEGER DEFAULT 1, notify_booking_change INTEGER DEFAULT 1, notify_trip_reminder INTEGER DEFAULT 1, notify_vacay_invite INTEGER DEFAULT 1, notify_photos_shared INTEGER DEFAULT 1, notify_collab_message INTEGER DEFAULT 1, notify_packing_tagged INTEGER DEFAULT 1, notify_webhook INTEGER DEFAULT 0, UNIQUE(user_id) )`); }, () => { // Add missing notification preference columns for existing tables try { db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_vacay_invite INTEGER DEFAULT 1'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } try { db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_photos_shared INTEGER DEFAULT 1'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } try { db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_collab_message INTEGER DEFAULT 1'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } try { db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_packing_tagged INTEGER DEFAULT 1'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { // Public share links for read-only trip access db.exec(`CREATE TABLE IF NOT EXISTS share_tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, token TEXT NOT NULL UNIQUE, created_by INTEGER NOT NULL REFERENCES users(id), share_map INTEGER DEFAULT 1, share_bookings INTEGER DEFAULT 1, share_packing INTEGER DEFAULT 0, share_budget INTEGER DEFAULT 0, share_collab INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`); }, () => { // Add permission columns to share_tokens try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_map INTEGER DEFAULT 1'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_bookings INTEGER DEFAULT 1'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_packing INTEGER DEFAULT 0'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_budget INTEGER DEFAULT 0'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_collab INTEGER DEFAULT 0'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { // Audit log db.exec(` CREATE TABLE IF NOT EXISTS audit_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, action TEXT NOT NULL, resource TEXT, details TEXT, ip TEXT ); CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC); `); }, () => { // MFA backup/recovery codes try { db.exec('ALTER TABLE users ADD COLUMN mfa_backup_codes TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, // MCP long-lived API tokens () => db.exec(` CREATE TABLE IF NOT EXISTS mcp_tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL, token_hash TEXT NOT NULL, token_prefix TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_used_at DATETIME ) `), // MCP addon entry () => { try { db.prepare("INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)") .run('mcp', 'MCP', 'Model Context Protocol for AI assistant integration', 'integration', 'Terminal', 0, 12); } catch (err: any) { console.warn('[migrations] Non-fatal migration step failed:', err); } }, // Index on mcp_tokens.token_hash () => db.exec(` CREATE UNIQUE INDEX IF NOT EXISTS idx_mcp_tokens_hash ON mcp_tokens(token_hash) `), // Ensure MCP addon type is 'integration' () => { try { db.prepare("UPDATE addons SET type = 'integration' WHERE id = 'mcp'").run(); } catch (err: any) { console.warn('[migrations] Non-fatal migration step failed:', err); } }, () => { try { db.exec('ALTER TABLE places ADD COLUMN route_geometry TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { try { db.exec('ALTER TABLE users ADD COLUMN must_change_password INTEGER DEFAULT 0'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { try { db.exec('ALTER TABLE trips ADD COLUMN reminder_days INTEGER DEFAULT 3'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, // Encrypt any plaintext oidc_client_secret left in app_settings () => { const row = db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_client_secret'").get() as { value: string } | undefined; if (row?.value && !row.value.startsWith('enc:v1:')) { db.prepare("UPDATE app_settings SET value = ? WHERE key = 'oidc_client_secret'").run(encrypt_api_key(row.value)); } }, // Encrypt any plaintext smtp_pass left in app_settings () => { const row = db.prepare("SELECT value FROM app_settings WHERE key = 'smtp_pass'").get() as { value: string } | undefined; if (row?.value && !row.value.startsWith('enc:v1:')) { db.prepare("UPDATE app_settings SET value = ? WHERE key = 'smtp_pass'").run(encrypt_api_key(row.value)); } }, // Encrypt any plaintext immich_api_key values in the users table () => { const rows = db.prepare( "SELECT id, immich_api_key FROM users WHERE immich_api_key IS NOT NULL AND immich_api_key != '' AND immich_api_key NOT LIKE 'enc:v1:%'" ).all() as { id: number; immich_api_key: string }[]; for (const row of rows) { db.prepare('UPDATE users SET immich_api_key = ? WHERE id = ?').run(encrypt_api_key(row.immich_api_key), row.id); } }, () => { try { db.exec('ALTER TABLE budget_items ADD COLUMN expense_date TEXT DEFAULT NULL'); } catch {} }, () => { db.exec(` CREATE TABLE IF NOT EXISTS trip_album_links ( id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, immich_album_id TEXT NOT NULL, album_name TEXT NOT NULL DEFAULT '', sync_enabled INTEGER NOT NULL DEFAULT 1, last_synced_at DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(trip_id, user_id, immich_album_id) ); CREATE INDEX IF NOT EXISTS idx_trip_album_links_trip ON trip_album_links(trip_id); `); }, () => { db.exec(` CREATE TABLE IF NOT EXISTS notifications ( id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL CHECK(type IN ('simple', 'boolean', 'navigate')), scope TEXT NOT NULL CHECK(scope IN ('trip', 'user', 'admin')), target INTEGER NOT NULL, sender_id INTEGER REFERENCES users(id) ON DELETE SET NULL, recipient_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, title_key TEXT NOT NULL, title_params TEXT DEFAULT '{}', text_key TEXT NOT NULL, text_params TEXT DEFAULT '{}', positive_text_key TEXT, negative_text_key TEXT, positive_callback TEXT, negative_callback TEXT, response TEXT CHECK(response IN ('positive', 'negative')), navigate_text_key TEXT, navigate_target TEXT, is_read INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_notifications_recipient ON notifications(recipient_id, is_read, created_at DESC); CREATE INDEX IF NOT EXISTS idx_notifications_recipient_created ON notifications(recipient_id, created_at DESC); `); }, () => { // Normalize trip_photos to provider-based schema used by current routes const tripPhotosExists = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'trip_photos'").get(); if (!tripPhotosExists) { db.exec(` CREATE TABLE trip_photos ( id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, asset_id TEXT NOT NULL, provider TEXT NOT NULL DEFAULT 'immich', shared INTEGER NOT NULL DEFAULT 1, added_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(trip_id, user_id, asset_id, provider) ); CREATE INDEX IF NOT EXISTS idx_trip_photos_trip ON trip_photos(trip_id); `); } else { const columns = db.prepare("PRAGMA table_info('trip_photos')").all() as Array<{ name: string }>; const names = new Set(columns.map(c => c.name)); const assetSource = names.has('asset_id') ? 'asset_id' : (names.has('immich_asset_id') ? 'immich_asset_id' : null); if (assetSource) { const providerExpr = names.has('provider') ? "CASE WHEN provider IS NULL OR provider = '' THEN 'immich' ELSE provider END" : "'immich'"; const sharedExpr = names.has('shared') ? 'COALESCE(shared, 1)' : '1'; const addedAtExpr = names.has('added_at') ? 'COALESCE(added_at, CURRENT_TIMESTAMP)' : 'CURRENT_TIMESTAMP'; db.exec(` CREATE TABLE trip_photos_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, asset_id TEXT NOT NULL, provider TEXT NOT NULL DEFAULT 'immich', shared INTEGER NOT NULL DEFAULT 1, added_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(trip_id, user_id, asset_id, provider) ); `); db.exec(` INSERT OR IGNORE INTO trip_photos_new (trip_id, user_id, asset_id, provider, shared, added_at) SELECT trip_id, user_id, ${assetSource}, ${providerExpr}, ${sharedExpr}, ${addedAtExpr} FROM trip_photos WHERE ${assetSource} IS NOT NULL AND TRIM(${assetSource}) != '' `); db.exec('DROP TABLE trip_photos'); db.exec('ALTER TABLE trip_photos_new RENAME TO trip_photos'); db.exec('CREATE INDEX IF NOT EXISTS idx_trip_photos_trip ON trip_photos(trip_id)'); } } }, () => { // Normalize trip_album_links to provider + album_id schema used by current routes const linksExists = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'trip_album_links'").get(); if (!linksExists) { db.exec(` CREATE TABLE trip_album_links ( id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, provider TEXT NOT NULL, album_id TEXT NOT NULL, album_name TEXT NOT NULL DEFAULT '', sync_enabled INTEGER NOT NULL DEFAULT 1, last_synced_at DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(trip_id, user_id, provider, album_id) ); CREATE INDEX IF NOT EXISTS idx_trip_album_links_trip ON trip_album_links(trip_id); `); } else { const columns = db.prepare("PRAGMA table_info('trip_album_links')").all() as Array<{ name: string }>; const names = new Set(columns.map(c => c.name)); const albumIdSource = names.has('album_id') ? 'album_id' : (names.has('immich_album_id') ? 'immich_album_id' : null); if (albumIdSource) { const providerExpr = names.has('provider') ? "CASE WHEN provider IS NULL OR provider = '' THEN 'immich' ELSE provider END" : "'immich'"; const albumNameExpr = names.has('album_name') ? "COALESCE(album_name, '')" : "''"; const syncEnabledExpr = names.has('sync_enabled') ? 'COALESCE(sync_enabled, 1)' : '1'; const lastSyncedExpr = names.has('last_synced_at') ? 'last_synced_at' : 'NULL'; const createdAtExpr = names.has('created_at') ? 'COALESCE(created_at, CURRENT_TIMESTAMP)' : 'CURRENT_TIMESTAMP'; db.exec(` CREATE TABLE trip_album_links_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, provider TEXT NOT NULL, album_id TEXT NOT NULL, album_name TEXT NOT NULL DEFAULT '', sync_enabled INTEGER NOT NULL DEFAULT 1, last_synced_at DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(trip_id, user_id, provider, album_id) ); `); db.exec(` INSERT OR IGNORE INTO trip_album_links_new (trip_id, user_id, provider, album_id, album_name, sync_enabled, last_synced_at, created_at) SELECT trip_id, user_id, ${providerExpr}, ${albumIdSource}, ${albumNameExpr}, ${syncEnabledExpr}, ${lastSyncedExpr}, ${createdAtExpr} FROM trip_album_links WHERE ${albumIdSource} IS NOT NULL AND TRIM(${albumIdSource}) != '' `); db.exec('DROP TABLE trip_album_links'); db.exec('ALTER TABLE trip_album_links_new RENAME TO trip_album_links'); db.exec('CREATE INDEX IF NOT EXISTS idx_trip_album_links_trip ON trip_album_links(trip_id)'); } } }, () => { // Add Synology credential columns for existing databases try { db.exec('ALTER TABLE users ADD COLUMN synology_url TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } try { db.exec('ALTER TABLE users ADD COLUMN synology_username TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } try { db.exec('ALTER TABLE users ADD COLUMN synology_password TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } try { db.exec('ALTER TABLE users ADD COLUMN synology_sid TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; } }, () => { // Seed Synology Photos provider and fields in existing databases try { db.prepare(` INSERT INTO photo_providers (id, name, description, icon, enabled, config, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, description = excluded.description, icon = excluded.icon, enabled = excluded.enabled, config = excluded.config, sort_order = excluded.sort_order `).run( 'synologyphotos', 'Synology Photos', 'Synology Photos integration with separate account settings', 'Image', 0, JSON.stringify({ settings_get: '/integrations/synologyphotos/settings', settings_put: '/integrations/synologyphotos/settings', status_get: '/integrations/synologyphotos/status', test_post: '/integrations/synologyphotos/test', }), 1, ); } catch (err: any) { if (!err.message?.includes('no such table')) throw err; } try { const insertField = db.prepare(` INSERT INTO photo_provider_fields (provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(provider_id, field_key) DO UPDATE SET label = excluded.label, input_type = excluded.input_type, placeholder = excluded.placeholder, required = excluded.required, secret = excluded.secret, settings_key = excluded.settings_key, payload_key = excluded.payload_key, sort_order = excluded.sort_order `); insertField.run('synologyphotos', 'synology_url', 'Server URL', 'url', 'https://synology.example.com', 1, 0, 'synology_url', 'synology_url', 0); insertField.run('synologyphotos', 'synology_username', 'Username', 'text', 'Username', 1, 0, 'synology_username', 'synology_username', 1); insertField.run('synologyphotos', 'synology_password', 'Password', 'password', 'Password', 1, 1, null, 'synology_password', 2); } catch (err: any) { if (!err.message?.includes('no such table')) throw err; } }, ]; if (currentVersion < migrations.length) { for (let i = currentVersion; i < migrations.length; i++) { console.log(`[DB] Running migration ${i + 1}/${migrations.length}`); try { db.transaction(() => migrations[i]())(); } catch (err) { console.error(`[migrations] FATAL: Migration ${i + 1} failed, rolled back:`, err); process.exit(1); } db.prepare('UPDATE schema_version SET version = ?').run(i + 1); } console.log(`[DB] Migrations complete — schema version ${migrations.length}`); } } export { runMigrations };