2026-03-28 01:40:18 +08:00
|
|
|
import Database from 'better-sqlite3';
|
|
|
|
|
|
|
|
|
|
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 {}
|
|
|
|
|
try { db.exec('ALTER TABLE day_accommodations ADD COLUMN check_out TEXT'); } catch {}
|
|
|
|
|
try { db.exec('ALTER TABLE day_accommodations ADD COLUMN confirmation TEXT'); } catch {}
|
|
|
|
|
},
|
|
|
|
|
() => {
|
|
|
|
|
try { db.exec('ALTER TABLE places ADD COLUMN end_time TEXT'); } catch {}
|
|
|
|
|
},
|
|
|
|
|
() => {
|
|
|
|
|
try { db.exec('ALTER TABLE day_assignments ADD COLUMN reservation_status TEXT DEFAULT \'none\''); } catch {}
|
|
|
|
|
try { db.exec('ALTER TABLE day_assignments ADD COLUMN reservation_notes TEXT'); } catch {}
|
|
|
|
|
try { db.exec('ALTER TABLE day_assignments ADD COLUMN reservation_datetime TEXT'); } catch {}
|
|
|
|
|
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 {}
|
|
|
|
|
},
|
|
|
|
|
() => {
|
|
|
|
|
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 {}
|
|
|
|
|
},
|
|
|
|
|
() => {
|
|
|
|
|
try { db.exec('ALTER TABLE day_assignments ADD COLUMN assignment_time TEXT'); } catch {}
|
|
|
|
|
try { db.exec('ALTER TABLE day_assignments ADD COLUMN assignment_end_time TEXT'); } catch {}
|
|
|
|
|
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 {}
|
|
|
|
|
},
|
|
|
|
|
() => {
|
|
|
|
|
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 {}
|
|
|
|
|
},
|
|
|
|
|
() => {
|
|
|
|
|
try { db.exec('ALTER TABLE trip_files ADD COLUMN note_id INTEGER REFERENCES collab_notes(id) ON DELETE SET NULL'); } catch {}
|
|
|
|
|
try { db.exec('ALTER TABLE collab_notes ADD COLUMN website TEXT'); } catch {}
|
|
|
|
|
},
|
|
|
|
|
() => {
|
|
|
|
|
try { db.exec('ALTER TABLE reservations ADD COLUMN reservation_end_time TEXT'); } catch {}
|
|
|
|
|
},
|
v2.6.2 — TREK Rebrand, OSM Enrichment, File Management, Hotel Bookings & Bug Fixes
Rebrand:
- NOMAD → TREK branding across all UI, translations, server, PWA manifest
- New TREK logos (dark/light, with/without icon)
- Liquid glass toast notifications
Bugs Fixed:
- HTTPS redirect now opt-in only (FORCE_HTTPS=true), fixes #33 #43 #52 #54 #55
- PDF export "Tag" fallback uses i18n, fixes #15
- Vacay sharing color collision detection, fixes #25
- Backup settings import fix (PR #47)
- Atlas country detection uses smallest bounding box, fixes #31
- JPY and zero-decimal currencies formatted correctly, fixes #32
- HTML lang="en" instead of hardcoded "de", fixes #34
- Duplicate translation keys removed
- setSelectedAssignmentId crash fixed
New Features:
- OSM enrichment: Overpass API for opening hours, Wikimedia Commons for photos
- Reverse geocoding on map right-click to add places
- OIDC config via environment variables (OIDC_ISSUER, OIDC_CLIENT_ID, etc.), fixes #48
- Multi-arch Docker build (ARM64 + AMD64), fixes #11
- File management: star, trash/restore, upload owner, assign to places/bookings, notes
- Markdown rendering in Collab Notes with expand modal, fixes #17
- Type-specific booking fields (flight: airline/number/airports, hotel: check-in/out/days, train: number/platform/seat), fixes #35
- Hotel bookings auto-create accommodations, bidirectional sync
- Multiple hotels per day with check-in/check-out color coding
- Ko-fi and Buy Me a Coffee support cards
- GitHub releases proxy with server-side caching
2026-03-28 23:38:08 +08:00
|
|
|
() => {
|
|
|
|
|
try { db.exec('ALTER TABLE places ADD COLUMN osm_id TEXT'); } catch {}
|
|
|
|
|
},
|
|
|
|
|
() => {
|
|
|
|
|
try { db.exec('ALTER TABLE trip_files ADD COLUMN uploaded_by INTEGER REFERENCES users(id) ON DELETE SET NULL'); } catch {}
|
|
|
|
|
try { db.exec('ALTER TABLE trip_files ADD COLUMN starred INTEGER DEFAULT 0'); } catch {}
|
|
|
|
|
try { db.exec('ALTER TABLE trip_files ADD COLUMN deleted_at TEXT'); } catch {}
|
|
|
|
|
},
|
|
|
|
|
() => {
|
|
|
|
|
try { db.exec('ALTER TABLE reservations ADD COLUMN accommodation_id INTEGER REFERENCES day_accommodations(id) ON DELETE SET NULL'); } catch {}
|
|
|
|
|
try { db.exec('ALTER TABLE reservations ADD COLUMN metadata TEXT'); } catch {}
|
|
|
|
|
},
|
2026-03-28 01:40:18 +08:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
if (currentVersion < migrations.length) {
|
|
|
|
|
for (let i = currentVersion; i < migrations.length; i++) {
|
|
|
|
|
console.log(`[DB] Running migration ${i + 1}/${migrations.length}`);
|
|
|
|
|
migrations[i]();
|
|
|
|
|
}
|
|
|
|
|
db.prepare('UPDATE schema_version SET version = ?').run(migrations.length);
|
|
|
|
|
console.log(`[DB] Migrations complete — schema version ${migrations.length}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export { runMigrations };
|