Files
TREK/server/src/db/migrations.ts
T
jubnl 9789c51d4f fix(naver-import): address PR #495 review issues
- SSRF: validate user-supplied URLs with checkSsrf() before fetch in
  both importNaverList and importGoogleList; upgrade naver.me substring
  check to exact hostname comparison to prevent bypass
- i18n: add missing places.importNaverList key to de.ts and es.ts
- migration: switch Naver addon seed to INSERT OR IGNORE to preserve
  admin customizations on re-runs; restore budget_category_order
  CREATE TABLE to its original formatting
- route: remove redundant cast after type-narrowing guard in naver-list handler
- component: hoist provider ternary above try/catch in handleListImport
- tests: add four new Naver import cases (502, empty list, no-coords,
  canonical URL skipping redirect fetch)
2026-04-15 04:48:39 +02:00

1631 lines
79 KiB
TypeScript

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);
}
}
type Migration = (() => void) | { raw: () => void };
const migrations: Migration[] = [
() => 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, sort_order)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
description = excluded.description,
icon = excluded.icon,
enabled = excluded.enabled,
sort_order = excluded.sort_order
`).run(
'synologyphotos',
'Synology Photos',
'Synology Photos integration with separate account settings',
'Image',
0,
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', 'providerUrl', 'url', 'https://synology.example.com', 1, 0, 'synology_url', 'synology_url', 0);
insertField.run('synologyphotos', 'synology_username', 'providerUsername', 'text', 'Username', 1, 0, 'synology_username', 'synology_username', 1);
insertField.run('synologyphotos', 'synology_password', 'providerPassword', 'password', 'Password', 1, 1, null, 'synology_password', 2);
} catch (err: any) {
if (!err.message?.includes('no such table')) throw err;
}
},
() => {
// Remove the stored config column from photo_providers now that it is generated from provider id.
const columns = db.prepare("PRAGMA table_info('photo_providers')").all() as Array<{ name: string }>;
const names = new Set(columns.map(c => c.name));
if (!names.has('config')) return;
db.exec('ALTER TABLE photo_providers DROP COLUMN config');
},
() => {
const columns = db.prepare("PRAGMA table_info('trip_photos')").all() as Array<{ name: string }>;
const names = new Set(columns.map(c => c.name));
if (names.has('asset_id') && !names.has('immich_asset_id')) return;
db.exec('ALTER TABLE `trip_photos` RENAME COLUMN immich_asset_id TO asset_id');
db.exec('ALTER TABLE `trip_photos` ADD COLUMN provider TEXT NOT NULL DEFAULT "immich"');
db.exec('ALTER TABLE `trip_album_links` ADD COLUMN provider TEXT NOT NULL DEFAULT "immich"');
db.exec('ALTER TABLE `trip_album_links` RENAME COLUMN immich_album_id TO album_id');
},
() => {
// Track which album link each photo was synced from
try { db.exec("ALTER TABLE trip_photos ADD COLUMN album_link_id INTEGER REFERENCES trip_album_links(id) ON DELETE SET NULL DEFAULT NULL"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
db.exec('CREATE INDEX IF NOT EXISTS idx_trip_photos_album_link ON trip_photos(album_link_id)');
},
// Migration 68: Todo items
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS todo_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
name TEXT NOT NULL,
checked INTEGER DEFAULT 0,
category TEXT,
sort_order INTEGER DEFAULT 0,
due_date TEXT,
description TEXT,
assigned_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
priority INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_todo_items_trip_id ON todo_items(trip_id);
CREATE TABLE IF NOT EXISTS todo_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)
);
`);
},
() => {
try {db.exec("UPDATE addons SET enabled = 0 WHERE id = 'memories'");} catch (err) {}
},
// Migration 69: Place region cache for sub-national Atlas regions
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS place_regions (
place_id INTEGER PRIMARY KEY REFERENCES places(id) ON DELETE CASCADE,
country_code TEXT NOT NULL,
region_code TEXT NOT NULL,
region_name TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_place_regions_country ON place_regions(country_code);
CREATE INDEX IF NOT EXISTS idx_place_regions_region ON place_regions(region_code);
`);
},
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS visited_regions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
region_code TEXT NOT NULL,
region_name TEXT NOT NULL,
country_code TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, region_code)
);
CREATE INDEX IF NOT EXISTS idx_visited_regions_country ON visited_regions(country_code);
`);
},
// Migration 71: Normalized per-user per-channel notification preferences
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS notification_channel_preferences (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
event_type TEXT NOT NULL,
channel TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (user_id, event_type, channel)
);
CREATE INDEX IF NOT EXISTS idx_ncp_user ON notification_channel_preferences(user_id);
`);
// Migrate data from old notification_preferences table (may not exist on fresh installs)
const tableExists = (db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='notification_preferences'").get() as { name: string } | undefined) != null;
const oldPrefs: Array<Record<string, number>> = tableExists
? db.prepare('SELECT * FROM notification_preferences').all() as Array<Record<string, number>>
: [];
const eventCols: Record<string, string> = {
trip_invite: 'notify_trip_invite',
booking_change: 'notify_booking_change',
trip_reminder: 'notify_trip_reminder',
vacay_invite: 'notify_vacay_invite',
photos_shared: 'notify_photos_shared',
collab_message: 'notify_collab_message',
packing_tagged: 'notify_packing_tagged',
};
const insert = db.prepare(
'INSERT OR IGNORE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, ?)'
);
const insertMany = db.transaction((rows: Array<[number, string, string, number]>) => {
for (const [userId, eventType, channel, enabled] of rows) {
insert.run(userId, eventType, channel, enabled);
}
});
for (const row of oldPrefs) {
const userId = row.user_id as number;
const webhookEnabled = (row.notify_webhook as number) ?? 0;
const rows: Array<[number, string, string, number]> = [];
for (const [eventType, col] of Object.entries(eventCols)) {
const emailEnabled = (row[col] as number) ?? 1;
// Only insert if disabled (no row = enabled is our default)
if (!emailEnabled) rows.push([userId, eventType, 'email', 0]);
if (!webhookEnabled) rows.push([userId, eventType, 'webhook', 0]);
}
if (rows.length > 0) insertMany(rows);
}
// Copy existing single-channel setting to new plural key
db.exec(`
INSERT OR IGNORE INTO app_settings (key, value)
SELECT 'notification_channels', value FROM app_settings WHERE key = 'notification_channel';
`);
},
// Migration 72: Drop the old notification_preferences table (data migrated to notification_channel_preferences in migration 71)
() => {
db.exec('DROP TABLE IF EXISTS notification_preferences;');
},
// Migration 73: Add reservation_id to budget_items for linking budget entries to reservations
() => {
try { db.exec('ALTER TABLE budget_items ADD COLUMN reservation_id INTEGER REFERENCES reservations(id) ON DELETE SET NULL DEFAULT NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
},
// Migration 74: Add quantity to packing_items + user_id to packing_bags + bag_members table
() => {
try { db.exec('ALTER TABLE packing_items ADD COLUMN quantity INTEGER NOT NULL DEFAULT 1'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
try { db.exec('ALTER TABLE packing_bags ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL DEFAULT NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
db.exec(`
CREATE TABLE IF NOT EXISTS packing_bag_members (
bag_id INTEGER NOT NULL REFERENCES packing_bags(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (bag_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_packing_bag_members_bag ON packing_bag_members(bag_id);
`);
// Migrate existing single user_id to bag_members
const bagsWithUser = db.prepare('SELECT id, user_id FROM packing_bags WHERE user_id IS NOT NULL').all() as { id: number; user_id: number }[];
const ins = db.prepare('INSERT OR IGNORE INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)');
for (const b of bagsWithUser) ins.run(b.id, b.user_id);
},
// Migration: Per-day positions for multi-day reservations
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS reservation_day_positions (
reservation_id INTEGER NOT NULL REFERENCES reservations(id) ON DELETE CASCADE,
day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
position REAL NOT NULL,
PRIMARY KEY (reservation_id, day_id)
);
`);
// Migrate existing global positions to per-day entries
const reservations = db.prepare('SELECT id, trip_id, reservation_time, reservation_end_time, day_plan_position FROM reservations WHERE day_plan_position IS NOT NULL').all() as any[];
const ins = db.prepare('INSERT OR IGNORE INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, ?)');
for (const r of reservations) {
const startDate = r.reservation_time?.split('T')[0];
const endDate = r.reservation_end_time?.split('T')[0] || startDate;
if (!startDate) continue;
const matchingDays = db.prepare('SELECT id FROM days WHERE trip_id = ? AND date >= ? AND date <= ?').all(r.trip_id, startDate, endDate) as { id: number }[];
for (const d of matchingDays) ins.run(r.id, d.id, r.day_plan_position);
}
},
// Migration: Budget category ordering
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS budget_category_order (
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
category TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (trip_id, category)
);
`);
// Seed existing categories with alphabetical order
const rows = db.prepare('SELECT DISTINCT trip_id, category FROM budget_items ORDER BY trip_id, category').all() as { trip_id: number; category: string }[];
const ins = db.prepare('INSERT OR IGNORE INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?)');
let lastTripId = -1;
let idx = 0;
for (const r of rows) {
if (r.trip_id !== lastTripId) { lastTripId = r.trip_id; idx = 0; }
ins.run(r.trip_id, r.category, idx++);
}
},
// Migration: Naver list import addon (default off)
() => {
try {
db.prepare(`
INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run('naver_list_import', 'Naver List Import', 'Import places from shared Naver Maps lists', 'trip', 'Link2', 0, 13);
} catch (err: any) {
console.warn('[migrations] Non-fatal migration step failed:', err);
}
},
// Migration: OAuth 2.1 clients, consents, and tokens for MCP
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS oauth_clients (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
client_id TEXT UNIQUE NOT NULL,
client_secret_hash TEXT NOT NULL,
redirect_uris TEXT NOT NULL DEFAULT '[]',
allowed_scopes TEXT NOT NULL DEFAULT '[]',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_oauth_clients_user ON oauth_clients(user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_clients_client_id ON oauth_clients(client_id);
CREATE TABLE IF NOT EXISTS oauth_consents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
scopes TEXT NOT NULL DEFAULT '[]',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(client_id, user_id)
);
CREATE TABLE IF NOT EXISTS oauth_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
access_token_hash TEXT UNIQUE NOT NULL,
refresh_token_hash TEXT UNIQUE NOT NULL,
scopes TEXT NOT NULL DEFAULT '[]',
access_token_expires_at DATETIME NOT NULL,
refresh_token_expires_at DATETIME NOT NULL,
revoked_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_oauth_tokens_user ON oauth_tokens(user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_tokens_access ON oauth_tokens(access_token_hash);
CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_tokens_refresh ON oauth_tokens(refresh_token_hash);
`);
},
// Migration: Refresh-token rotation chain tracking for replay detection
() => {
db.exec(`
ALTER TABLE oauth_tokens ADD COLUMN parent_token_id INTEGER REFERENCES oauth_tokens(id);
CREATE INDEX IF NOT EXISTS idx_oauth_tokens_parent ON oauth_tokens(parent_token_id);
`);
},
// Migration: Public client support for browser-initiated dynamic registration (DCR)
() => {
db.exec(`
ALTER TABLE oauth_clients ADD COLUMN is_public INTEGER NOT NULL DEFAULT 0;
ALTER TABLE oauth_clients ADD COLUMN created_via TEXT NOT NULL DEFAULT 'settings_ui';
`);
},
// Migration: Make oauth_clients.user_id nullable to support anonymous RFC 7591 DCR clients
// (must run outside a transaction because PRAGMA foreign_keys cannot change mid-transaction)
{
raw: () => {
db.exec('PRAGMA foreign_keys = OFF');
try {
db.transaction(() => {
db.exec(`
CREATE TABLE IF NOT EXISTS oauth_clients_new (
id TEXT PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
client_id TEXT UNIQUE NOT NULL,
client_secret_hash TEXT NOT NULL,
redirect_uris TEXT NOT NULL DEFAULT '[]',
allowed_scopes TEXT NOT NULL DEFAULT '[]',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
is_public INTEGER NOT NULL DEFAULT 0,
created_via TEXT NOT NULL DEFAULT 'settings_ui'
)
`);
db.exec(`INSERT INTO oauth_clients_new SELECT id, user_id, name, client_id, client_secret_hash, redirect_uris, allowed_scopes, created_at, is_public, created_via FROM oauth_clients`);
db.exec(`DROP TABLE oauth_clients`);
db.exec(`ALTER TABLE oauth_clients_new RENAME TO oauth_clients`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_oauth_clients_user ON oauth_clients(user_id)`);
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_clients_client_id ON oauth_clients(client_id)`);
})();
} finally {
db.exec('PRAGMA foreign_keys = ON');
}
},
},
// Migration: Add OTP field, skip_ssl column, device_id (did) column, and hint column for Synology Photos
() => {
const cols = db.prepare('PRAGMA table_info(photo_provider_fields)').all() as Array<{ name: string }>;
if (!cols.some(c => c.name === 'hint')) {
db.exec(`ALTER TABLE photo_provider_fields ADD COLUMN hint TEXT`);
}
db.exec(`
INSERT OR IGNORE INTO photo_provider_fields
(provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order)
VALUES
('synologyphotos', 'synology_otp', 'providerOTP', 'text', '123456', 0, 0, NULL, 'synology_otp', 3)
`);
db.exec(`ALTER TABLE users ADD COLUMN synology_skip_ssl INTEGER NOT NULL DEFAULT 0`);
db.exec(`ALTER TABLE users ADD COLUMN synology_did TEXT`);
db.exec(`
INSERT OR IGNORE INTO photo_provider_fields
(provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order)
VALUES
('synologyphotos', 'synology_skip_ssl', 'skipSSLVerification', 'checkbox', NULL, 0, 0, 'synology_skip_ssl', 'synology_skip_ssl', 4)
`);
db.exec(`
UPDATE photo_provider_fields
SET hint = 'providerUrlHintSynology'
WHERE provider_id = 'synologyphotos' AND field_key = 'synology_url'
`);
},
// Migration 84: Journey addon — trip tracking & travel journal
() => {
// Register addon (disabled by default — opt-in)
db.prepare(`
INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, config, sort_order)
VALUES ('journey', 'Journey', 'Trip tracking & travel journal — check-ins, photos, daily stories', 'global', 'Compass', 0, '{}', 35)
`).run();
// Core journey table
db.exec(`
CREATE TABLE IF NOT EXISTS journeys (
id TEXT PRIMARY KEY,
trip_id INTEGER REFERENCES trips(id) ON DELETE SET NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT,
cover_image TEXT,
status TEXT NOT NULL DEFAULT 'draft',
started_at TEXT,
ended_at TEXT,
is_public INTEGER NOT NULL DEFAULT 0,
public_token TEXT UNIQUE,
settings TEXT DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
)
`);
// Check-ins — visited locations
db.exec(`
CREATE TABLE IF NOT EXISTS journey_checkins (
id TEXT PRIMARY KEY,
journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE,
place_id INTEGER REFERENCES places(id) ON DELETE SET NULL,
name TEXT NOT NULL,
lat REAL,
lng REAL,
address TEXT,
country_code TEXT,
notes TEXT,
checked_in_at TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'manual',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
)
`);
// Journal entries — daily stories
db.exec(`
CREATE TABLE IF NOT EXISTS journey_entries (
id TEXT PRIMARY KEY,
journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE,
checkin_id TEXT REFERENCES journey_checkins(id) ON DELETE SET NULL,
entry_date TEXT NOT NULL,
title TEXT,
body TEXT,
mood TEXT,
weather TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
)
`);
// Photos — local uploads + provider references (Immich/Synology)
db.exec(`
CREATE TABLE IF NOT EXISTS journey_photos (
id TEXT PRIMARY KEY,
journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE,
checkin_id TEXT REFERENCES journey_checkins(id) ON DELETE SET NULL,
entry_id TEXT REFERENCES journey_entries(id) ON DELETE SET NULL,
storage_type TEXT NOT NULL DEFAULT 'local',
asset_id TEXT,
file_path TEXT,
thumbnail_path TEXT,
original_name TEXT,
mime_type TEXT,
size_bytes INTEGER,
caption TEXT,
taken_at TEXT,
lat REAL,
lng REAL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
)
`);
// GPS trail points (Dawarich integration)
db.exec(`
CREATE TABLE IF NOT EXISTS journey_location_trail (
id INTEGER PRIMARY KEY AUTOINCREMENT,
journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE,
lat REAL NOT NULL,
lng REAL NOT NULL,
altitude REAL,
accuracy REAL,
recorded_at TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'dawarich'
)
`);
// Indexes
db.exec(`
CREATE INDEX IF NOT EXISTS idx_journeys_user ON journeys(user_id);
CREATE INDEX IF NOT EXISTS idx_journeys_trip ON journeys(trip_id);
CREATE INDEX IF NOT EXISTS idx_journeys_public_token ON journeys(public_token);
CREATE INDEX IF NOT EXISTS idx_journey_checkins_journey ON journey_checkins(journey_id, checked_in_at);
CREATE INDEX IF NOT EXISTS idx_journey_entries_journey_date ON journey_entries(journey_id, entry_date);
CREATE INDEX IF NOT EXISTS idx_journey_photos_journey ON journey_photos(journey_id);
CREATE INDEX IF NOT EXISTS idx_journey_photos_checkin ON journey_photos(checkin_id);
CREATE INDEX IF NOT EXISTS idx_journey_photos_entry ON journey_photos(entry_id);
CREATE INDEX IF NOT EXISTS idx_journey_trail_journey_time ON journey_location_trail(journey_id, recorded_at);
`);
},
// Migration 85: Journal — richer entry fields for magazine-style design
() => {
// Highlight tags (JSON array), visibility control, hero photo, color accent
try { db.exec('ALTER TABLE journey_entries ADD COLUMN highlight_tags TEXT'); } catch {}
try { db.exec("ALTER TABLE journey_entries ADD COLUMN visibility TEXT NOT NULL DEFAULT 'private'"); } catch {}
try { db.exec('ALTER TABLE journey_entries ADD COLUMN hero_photo_id TEXT'); } catch {}
try { db.exec('ALTER TABLE journey_entries ADD COLUMN color_accent TEXT'); } catch {}
try { db.exec('ALTER TABLE journey_entries ADD COLUMN place_name TEXT'); } catch {}
try { db.exec('ALTER TABLE journey_entries ADD COLUMN place_id INTEGER REFERENCES places(id) ON DELETE SET NULL'); } catch {}
try { db.exec('ALTER TABLE journey_entries ADD COLUMN lat REAL'); } catch {}
try { db.exec('ALTER TABLE journey_entries ADD COLUMN lng REAL'); } catch {}
// Check-in: allow a single cover photo reference
try { db.exec('ALTER TABLE journey_checkins ADD COLUMN photo_id TEXT'); } catch {}
// Photos: add caption edit timestamp for gallery ordering
try { db.exec('ALTER TABLE journey_photos ADD COLUMN width INTEGER'); } catch {}
try { db.exec('ALTER TABLE journey_photos ADD COLUMN height INTEGER'); } catch {}
},
// Migration 86: Journey multi-trip support + sharing/collaboration
() => {
// Junction table: journey can include multiple trips
db.exec(`
CREATE TABLE IF NOT EXISTS journey_trips (
journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
added_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
PRIMARY KEY (journey_id, trip_id)
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_journey_trips_journey ON journey_trips(journey_id)');
// Sharing: invite users to a journey
db.exec(`
CREATE TABLE IF NOT EXISTS journey_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'viewer',
invited_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
UNIQUE(journey_id, user_id)
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_journey_members_user ON journey_members(user_id)');
// author tracking on entries and checkins
try { db.exec('ALTER TABLE journey_entries ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL'); } catch {}
try { db.exec('ALTER TABLE journey_checkins ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL'); } catch {}
},
// Migration 87: Journey rebuild — new schema with trip sync
() => {
// Migrate existing data from old tables into backup, then rebuild
const hasOldJourneys = db.prepare(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='journeys'"
).get();
let oldJourneys: any[] = [];
let oldEntries: any[] = [];
let oldPhotos: any[] = [];
if (hasOldJourneys) {
// Save existing data before dropping
try { oldJourneys = db.prepare('SELECT * FROM journeys').all(); } catch {}
try { oldEntries = db.prepare('SELECT * FROM journey_entries').all(); } catch {}
try { oldPhotos = db.prepare('SELECT * FROM journey_photos').all(); } catch {}
// Drop all old journey tables
db.exec('DROP TABLE IF EXISTS journey_location_trail');
db.exec('DROP TABLE IF EXISTS journey_photos');
db.exec('DROP TABLE IF EXISTS journey_entries');
db.exec('DROP TABLE IF EXISTS journey_checkins');
db.exec('DROP TABLE IF EXISTS journey_members');
db.exec('DROP TABLE IF EXISTS journey_trips');
db.exec('DROP TABLE IF EXISTS journeys');
}
// New schema
db.exec(`
CREATE TABLE journeys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
title TEXT NOT NULL,
subtitle TEXT,
cover_gradient TEXT,
status TEXT DEFAULT 'draft',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
)
`);
db.exec(`
CREATE TABLE journey_trips (
journey_id INTEGER NOT NULL,
trip_id INTEGER NOT NULL,
added_at INTEGER NOT NULL,
PRIMARY KEY (journey_id, trip_id),
FOREIGN KEY (journey_id) REFERENCES journeys(id) ON DELETE CASCADE,
FOREIGN KEY (trip_id) REFERENCES trips(id) ON DELETE CASCADE
)
`);
db.exec(`
CREATE TABLE journey_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
journey_id INTEGER NOT NULL,
source_trip_id INTEGER,
source_place_id INTEGER,
author_id INTEGER NOT NULL,
type TEXT NOT NULL,
title TEXT,
story TEXT,
entry_date TEXT NOT NULL,
entry_time TEXT,
location_name TEXT,
location_lat REAL,
location_lng REAL,
mood TEXT,
weather TEXT,
tags TEXT,
visibility TEXT DEFAULT 'private',
sort_order INTEGER DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (journey_id) REFERENCES journeys(id) ON DELETE CASCADE,
FOREIGN KEY (source_trip_id) REFERENCES trips(id) ON DELETE SET NULL,
FOREIGN KEY (source_place_id) REFERENCES places(id) ON DELETE SET NULL,
FOREIGN KEY (author_id) REFERENCES users(id)
)
`);
db.exec(`
CREATE TABLE journey_photos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entry_id INTEGER NOT NULL,
file_path TEXT NOT NULL,
thumbnail_path TEXT,
caption TEXT,
sort_order INTEGER DEFAULT 0,
width INTEGER,
height INTEGER,
created_at INTEGER NOT NULL,
FOREIGN KEY (entry_id) REFERENCES journey_entries(id) ON DELETE CASCADE
)
`);
db.exec(`
CREATE TABLE journey_contributors (
journey_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
role TEXT NOT NULL,
added_at INTEGER NOT NULL,
PRIMARY KEY (journey_id, user_id),
FOREIGN KEY (journey_id) REFERENCES journeys(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id)
)
`);
// Indexes
db.exec(`
CREATE INDEX idx_journeys_user ON journeys(user_id);
CREATE INDEX idx_journey_entries_journey ON journey_entries(journey_id, entry_date);
CREATE INDEX idx_journey_entries_source ON journey_entries(source_place_id);
CREATE INDEX idx_journey_photos_entry ON journey_photos(entry_id);
CREATE INDEX idx_journey_trips_journey ON journey_trips(journey_id);
CREATE INDEX idx_journey_contributors_user ON journey_contributors(user_id);
`);
// Re-import old data if it existed
if (oldJourneys.length > 0) {
const ts = Date.now();
const journeyIdMap = new Map<string, number>(); // old TEXT id -> new INTEGER id
for (const j of oldJourneys) {
const res = db.prepare(`
INSERT INTO journeys (user_id, title, subtitle, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
j.user_id,
j.title || 'Untitled Journey',
j.description || null,
j.status || 'draft',
j.created_at ? new Date(j.created_at).getTime() : ts,
j.updated_at ? new Date(j.updated_at).getTime() : ts
);
journeyIdMap.set(j.id, Number(res.lastInsertRowid));
// Add owner as contributor
db.prepare(`
INSERT OR IGNORE INTO journey_contributors (journey_id, user_id, role, added_at)
VALUES (?, ?, 'owner', ?)
`).run(Number(res.lastInsertRowid), j.user_id, ts);
// Link trip if old journey had one
if (j.trip_id) {
try {
db.prepare(`
INSERT OR IGNORE INTO journey_trips (journey_id, trip_id, added_at)
VALUES (?, ?, ?)
`).run(Number(res.lastInsertRowid), j.trip_id, ts);
} catch {}
}
}
// Migrate entries
const entryIdMap = new Map<string, number>();
for (const e of oldEntries) {
const newJourneyId = journeyIdMap.get(e.journey_id);
if (!newJourneyId) continue;
const res = db.prepare(`
INSERT INTO journey_entries (journey_id, author_id, type, title, story, entry_date, entry_time, location_name, location_lat, location_lng, mood, weather, visibility, sort_order, created_at, updated_at)
VALUES (?, ?, 'entry', ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
newJourneyId,
e.user_id || oldJourneys.find((j: any) => j.id === e.journey_id)?.user_id || 1,
e.title || null,
e.body || null,
e.entry_date || new Date().toISOString().split('T')[0],
e.place_name || null,
e.lat || null,
e.lng || null,
e.mood || null,
e.weather || null,
e.visibility || 'private',
e.sort_order || 0,
e.created_at ? new Date(e.created_at).getTime() : ts,
e.updated_at ? new Date(e.updated_at).getTime() : ts
);
entryIdMap.set(e.id, Number(res.lastInsertRowid));
}
// Migrate photos
for (const p of oldPhotos) {
const newEntryId = p.entry_id ? entryIdMap.get(p.entry_id) : null;
if (!newEntryId || !p.file_path) continue;
db.prepare(`
INSERT INTO journey_photos (entry_id, file_path, thumbnail_path, caption, sort_order, width, height, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
newEntryId,
p.file_path,
p.thumbnail_path || null,
p.caption || null,
p.sort_order || 0,
p.width || null,
p.height || null,
p.created_at ? new Date(p.created_at).getTime() : ts
);
}
console.log(`[DB] Journey migration: imported ${journeyIdMap.size} journeys, ${entryIdMap.size} entries, photos migrated`);
}
},
// Migration 88: Journey photos — provider support (Immich/Synology)
() => {
try { db.exec("ALTER TABLE journey_photos ADD COLUMN provider TEXT NOT NULL DEFAULT 'local'"); } catch {}
try { db.exec('ALTER TABLE journey_photos ADD COLUMN asset_id TEXT'); } catch {}
try { db.exec('ALTER TABLE journey_photos ADD COLUMN owner_id INTEGER REFERENCES users(id)'); } catch {}
try { db.exec('ALTER TABLE journey_photos ADD COLUMN shared INTEGER NOT NULL DEFAULT 1'); } catch {}
// file_path was NOT NULL — recreate table to make it nullable
const hasProvider = db.prepare("SELECT 1 FROM pragma_table_info('journey_photos') WHERE name = 'provider'").get();
if (hasProvider) {
// Already has the column, just ensure file_path is nullable by recreating
try {
db.exec(`
CREATE TABLE journey_photos_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entry_id INTEGER NOT NULL,
provider TEXT NOT NULL DEFAULT 'local',
asset_id TEXT,
owner_id INTEGER REFERENCES users(id),
file_path TEXT,
thumbnail_path TEXT,
caption TEXT,
sort_order INTEGER DEFAULT 0,
width INTEGER,
height INTEGER,
shared INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
FOREIGN KEY (entry_id) REFERENCES journey_entries(id) ON DELETE CASCADE
);
INSERT INTO journey_photos_new SELECT id, entry_id, provider, asset_id, owner_id, file_path, thumbnail_path, caption, sort_order, width, height, shared, created_at FROM journey_photos;
DROP TABLE journey_photos;
ALTER TABLE journey_photos_new RENAME TO journey_photos;
CREATE INDEX idx_journey_photos_entry ON journey_photos(entry_id);
`);
} catch {}
}
},
// Migration 89: Journey cover image
() => {
try { db.exec('ALTER TABLE journeys ADD COLUMN cover_image TEXT'); } catch {}
},
// Migration 90: Pros/Cons for journey entries
() => {
try { db.exec('ALTER TABLE journey_entries ADD COLUMN pros_cons TEXT'); } catch {}
},
// Migration 91: Journey share tokens
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS journey_share_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
journey_id INTEGER NOT NULL,
token TEXT NOT NULL UNIQUE,
created_by INTEGER NOT NULL,
share_timeline INTEGER DEFAULT 1,
share_gallery INTEGER DEFAULT 1,
share_map INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (journey_id) REFERENCES journeys(id) ON DELETE CASCADE,
FOREIGN KEY (created_by) REFERENCES users(id)
)
`);
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_journey_share_journey ON journey_share_tokens(journey_id)');
},
// Migration: Vacay week_start setting (0=Sunday, 1=Monday default)
() => {
try { db.exec("ALTER TABLE vacay_plans ADD COLUMN week_start INTEGER NOT NULL DEFAULT 1"); } catch {}
},
// Migration: Unified Photo Provider Abstraction Layer (#584)
// Central trek_photos registry; trip_photos + journey_photos reference via photo_id
() => {
// 1. Create the central photo registry
db.exec(`
CREATE TABLE IF NOT EXISTS trek_photos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider TEXT NOT NULL,
asset_id TEXT,
owner_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
file_path TEXT,
thumbnail_path TEXT,
width INTEGER,
height INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_trek_photos_provider_asset ON trek_photos(provider, asset_id, owner_id) WHERE asset_id IS NOT NULL');
db.exec('CREATE INDEX IF NOT EXISTS idx_trek_photos_owner ON trek_photos(owner_id)');
// 2. Migrate trip_photos → trek_photos + photo_id FK
const tripPhotosExists = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'trip_photos'").get();
if (tripPhotosExists) {
// Detect schema variant: old (immich_asset_id) vs new (asset_id + provider)
const tpCols = db.prepare("PRAGMA table_info('trip_photos')").all() as Array<{ name: string }>;
const tpColNames = new Set(tpCols.map(c => c.name));
const hasProvider = tpColNames.has('provider');
const assetCol = tpColNames.has('asset_id') ? 'asset_id' : (tpColNames.has('immich_asset_id') ? 'immich_asset_id' : null);
const hasAlbumLink = tpColNames.has('album_link_id');
if (assetCol) {
const providerExpr = hasProvider ? 'provider' : "'immich'";
// Qualified alias needed in JOIN context where both trip_photos and trek_photos have provider
const providerJoinExpr = hasProvider ? 'tp.provider' : "'immich'";
const sharedExpr = tpColNames.has('shared') ? 'shared' : '1';
const addedAtExpr = tpColNames.has('added_at') ? 'COALESCE(added_at, CURRENT_TIMESTAMP)' : 'CURRENT_TIMESTAMP';
const albumLinkExpr = hasAlbumLink ? 'album_link_id' : 'NULL';
// Insert existing trip photo references into trek_photos
db.exec(`
INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id, created_at)
SELECT DISTINCT ${providerExpr}, ${assetCol}, user_id, ${addedAtExpr}
FROM trip_photos
WHERE ${assetCol} IS NOT NULL AND TRIM(${assetCol}) != ''
`);
// Recreate trip_photos with photo_id FK
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,
photo_id INTEGER NOT NULL REFERENCES trek_photos(id) ON DELETE CASCADE,
shared INTEGER NOT NULL DEFAULT 1,
album_link_id INTEGER REFERENCES trip_album_links(id) ON DELETE SET NULL,
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(trip_id, user_id, photo_id)
)
`);
db.exec(`
INSERT OR IGNORE INTO trip_photos_new (trip_id, user_id, photo_id, shared, album_link_id, added_at)
SELECT tp.trip_id, tp.user_id, tkp.id, ${sharedExpr}, ${albumLinkExpr}, ${addedAtExpr}
FROM trip_photos tp
JOIN trek_photos tkp ON tkp.provider = ${providerJoinExpr} AND tkp.asset_id = tp.${assetCol} AND tkp.owner_id = tp.user_id
`);
} else {
// No asset column at all — just recreate empty
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,
photo_id INTEGER NOT NULL REFERENCES trek_photos(id) ON DELETE CASCADE,
shared INTEGER NOT NULL DEFAULT 1,
album_link_id INTEGER REFERENCES trip_album_links(id) ON DELETE SET NULL,
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(trip_id, user_id, photo_id)
)
`);
}
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)');
db.exec('CREATE INDEX IF NOT EXISTS idx_trip_photos_photo ON trip_photos(photo_id)');
}
// 3. Migrate journey_photos → trek_photos + photo_id FK
const journeyPhotosExists = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'journey_photos'").get();
if (journeyPhotosExists) {
// Insert provider-based journey photos into trek_photos
db.exec(`
INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id, width, height, created_at)
SELECT DISTINCT provider, asset_id, owner_id, width, height, created_at
FROM journey_photos
WHERE provider != 'local' AND asset_id IS NOT NULL AND TRIM(asset_id) != ''
`);
// Insert local journey photos into trek_photos (each is unique)
db.exec(`
INSERT INTO trek_photos (provider, file_path, thumbnail_path, width, height, created_at)
SELECT 'local', file_path, thumbnail_path, width, height, created_at
FROM journey_photos
WHERE provider = 'local' AND file_path IS NOT NULL
`);
// Recreate journey_photos with photo_id FK
db.exec(`
CREATE TABLE journey_photos_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entry_id INTEGER NOT NULL,
photo_id INTEGER NOT NULL REFERENCES trek_photos(id) ON DELETE CASCADE,
caption TEXT,
sort_order INTEGER DEFAULT 0,
shared INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
FOREIGN KEY (entry_id) REFERENCES journey_entries(id) ON DELETE CASCADE
)
`);
// Migrate provider photos
db.exec(`
INSERT INTO journey_photos_new (entry_id, photo_id, caption, sort_order, shared, created_at)
SELECT jp.entry_id, tkp.id, jp.caption, jp.sort_order, jp.shared, jp.created_at
FROM journey_photos jp
JOIN trek_photos tkp ON tkp.provider = jp.provider AND tkp.asset_id = jp.asset_id AND tkp.owner_id = jp.owner_id
WHERE jp.provider != 'local' AND jp.asset_id IS NOT NULL
`);
// Migrate local photos (match by file_path)
db.exec(`
INSERT INTO journey_photos_new (entry_id, photo_id, caption, sort_order, shared, created_at)
SELECT jp.entry_id, tkp.id, jp.caption, jp.sort_order, jp.shared, jp.created_at
FROM journey_photos jp
JOIN trek_photos tkp ON tkp.provider = 'local' AND tkp.file_path = jp.file_path
WHERE jp.provider = 'local' AND jp.file_path IS NOT NULL
`);
db.exec('DROP TABLE journey_photos');
db.exec('ALTER TABLE journey_photos_new RENAME TO journey_photos');
db.exec('CREATE INDEX IF NOT EXISTS idx_journey_photos_entry ON journey_photos(entry_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_journey_photos_photo ON journey_photos(photo_id)');
}
},
// Migration 99: hide_skeletons per-user setting on journey_contributors
() => {
try { db.exec('ALTER TABLE journey_contributors ADD COLUMN hide_skeletons INTEGER NOT NULL DEFAULT 0'); } catch {}
},
// Migration 100: Idempotency keys for offline mutation replay
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS idempotency_keys (
key TEXT NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
method TEXT NOT NULL,
path TEXT NOT NULL,
status_code INTEGER NOT NULL,
response_body TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
PRIMARY KEY (key, user_id)
);
CREATE INDEX IF NOT EXISTS idx_idempotency_keys_created ON idempotency_keys(created_at);
`);
},
];
if (currentVersion < migrations.length) {
for (let i = currentVersion; i < migrations.length; i++) {
console.log(`[DB] Running migration ${i + 1}/${migrations.length}`);
try {
const migration = migrations[i];
if (typeof migration === 'function') {
db.transaction(migration)();
} else {
migration.raw();
}
} 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 };