refactoring: TypeScript migration, security fixes,

This commit is contained in:
Maurice
2026-03-27 18:40:18 +01:00
parent 510475a46f
commit 8396a75223
150 changed files with 8116 additions and 8467 deletions

View File

@@ -1,28 +1,26 @@
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
let JWT_SECRET = process.env.JWT_SECRET;
let JWT_SECRET: string = process.env.JWT_SECRET || '';
if (!JWT_SECRET) {
// Try to read a persisted secret from disk
const dataDir = path.resolve(__dirname, '../data');
const secretFile = path.join(dataDir, '.jwt_secret');
try {
JWT_SECRET = fs.readFileSync(secretFile, 'utf8').trim();
} catch {
// File doesn't exist yet — generate and persist a new secret
JWT_SECRET = crypto.randomBytes(32).toString('hex');
try {
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
fs.writeFileSync(secretFile, JWT_SECRET, { mode: 0o600 });
console.log('Generated and saved JWT secret to', secretFile);
} catch (writeErr) {
console.warn('WARNING: Could not persist JWT secret to disk:', writeErr.message);
} catch (writeErr: unknown) {
console.warn('WARNING: Could not persist JWT secret to disk:', writeErr instanceof Error ? writeErr.message : writeErr);
console.warn('Sessions will reset on server restart. Set JWT_SECRET env var for persistent sessions.');
}
}
}
module.exports = { JWT_SECRET };
export { JWT_SECRET };

View File

@@ -1,754 +0,0 @@
const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs');
const bcrypt = require('bcryptjs');
const dataDir = path.join(__dirname, '../../data');
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const dbPath = path.join(dataDir, 'travel.db');
let _db = null;
function initDb() {
if (_db) {
try { _db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {}
try { _db.close(); } catch (e) {}
_db = null;
}
_db = new Database(dbPath);
_db.exec('PRAGMA journal_mode = WAL');
_db.exec('PRAGMA busy_timeout = 5000');
_db.exec('PRAGMA foreign_keys = ON');
// Create all tables
_db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
maps_api_key TEXT,
unsplash_api_key TEXT,
openweather_api_key TEXT,
avatar TEXT,
oidc_sub TEXT,
oidc_issuer TEXT,
last_login DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
key TEXT NOT NULL,
value TEXT,
UNIQUE(user_id, key)
);
CREATE TABLE IF NOT EXISTS trips (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT,
start_date TEXT,
end_date TEXT,
currency TEXT DEFAULT 'EUR',
cover_image TEXT,
is_archived INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS days (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
day_number INTEGER NOT NULL,
date TEXT,
notes TEXT,
title TEXT,
UNIQUE(trip_id, day_number)
);
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
color TEXT DEFAULT '#6366f1',
icon TEXT DEFAULT '📍',
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
color TEXT DEFAULT '#10b981',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS places (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
lat REAL,
lng REAL,
address TEXT,
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
price REAL,
currency TEXT,
reservation_status TEXT DEFAULT 'none',
reservation_notes TEXT,
reservation_datetime TEXT,
place_time TEXT,
end_time TEXT,
duration_minutes INTEGER DEFAULT 60,
notes TEXT,
image_url TEXT,
google_place_id TEXT,
website TEXT,
phone TEXT,
transport_mode TEXT DEFAULT 'walking',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS place_tags (
place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (place_id, tag_id)
);
CREATE TABLE IF NOT EXISTS day_assignments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE,
order_index INTEGER DEFAULT 0,
notes TEXT,
reservation_status TEXT DEFAULT 'none',
reservation_notes TEXT,
reservation_datetime TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS packing_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,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS photos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
day_id INTEGER REFERENCES days(id) ON DELETE SET NULL,
place_id INTEGER REFERENCES places(id) ON DELETE SET NULL,
filename TEXT NOT NULL,
original_name TEXT NOT NULL,
file_size INTEGER,
mime_type TEXT,
caption TEXT,
taken_at TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS trip_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
place_id INTEGER REFERENCES places(id) ON DELETE SET NULL,
reservation_id INTEGER REFERENCES reservations(id) ON DELETE SET NULL,
filename TEXT NOT NULL,
original_name TEXT NOT NULL,
file_size INTEGER,
mime_type TEXT,
description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS reservations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
day_id INTEGER REFERENCES days(id) ON DELETE SET NULL,
place_id INTEGER REFERENCES places(id) ON DELETE SET NULL,
assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE SET NULL,
title TEXT NOT NULL,
reservation_time TEXT,
reservation_end_time TEXT,
location TEXT,
confirmation_number TEXT,
notes TEXT,
status TEXT DEFAULT 'pending',
type TEXT DEFAULT 'other',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS trip_members (
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,
invited_by INTEGER REFERENCES users(id),
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(trip_id, user_id)
);
CREATE TABLE IF NOT EXISTS day_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
text TEXT NOT NULL,
time TEXT,
icon TEXT DEFAULT '📝',
sort_order REAL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT
);
CREATE TABLE IF NOT EXISTS budget_items (
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
);
-- Addon system
CREATE TABLE IF NOT EXISTS addons (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
type TEXT NOT NULL DEFAULT 'global',
icon TEXT DEFAULT 'Puzzle',
enabled INTEGER DEFAULT 0,
config TEXT DEFAULT '{}',
sort_order INTEGER DEFAULT 0
);
-- Vacay addon tables
CREATE TABLE IF NOT EXISTS vacay_plans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
block_weekends INTEGER DEFAULT 1,
holidays_enabled INTEGER DEFAULT 0,
holidays_region TEXT DEFAULT '',
company_holidays_enabled INTEGER DEFAULT 1,
carry_over_enabled INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(owner_id)
);
CREATE TABLE IF NOT EXISTS vacay_plan_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status TEXT DEFAULT 'pending',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(plan_id, user_id)
);
CREATE TABLE IF NOT EXISTS vacay_user_colors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
color TEXT DEFAULT '#6366f1',
UNIQUE(user_id, plan_id)
);
CREATE TABLE IF NOT EXISTS vacay_years (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
year INTEGER NOT NULL,
UNIQUE(plan_id, year)
);
CREATE TABLE IF NOT EXISTS vacay_user_years (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
year INTEGER NOT NULL,
vacation_days INTEGER DEFAULT 30,
carried_over INTEGER DEFAULT 0,
UNIQUE(user_id, plan_id, year)
);
CREATE TABLE IF NOT EXISTS vacay_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
date TEXT NOT NULL,
note TEXT DEFAULT '',
UNIQUE(user_id, plan_id, date)
);
CREATE TABLE IF NOT EXISTS vacay_company_holidays (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
date TEXT NOT NULL,
note TEXT DEFAULT '',
UNIQUE(plan_id, date)
);
CREATE TABLE IF NOT EXISTS day_accommodations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE,
start_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
end_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
check_in TEXT,
check_out TEXT,
confirmation TEXT,
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Collab addon tables
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);
`);
// Create indexes for performance
_db.exec(`
CREATE INDEX IF NOT EXISTS idx_places_trip_id ON places(trip_id);
CREATE INDEX IF NOT EXISTS idx_places_category_id ON places(category_id);
CREATE INDEX IF NOT EXISTS idx_days_trip_id ON days(trip_id);
CREATE INDEX IF NOT EXISTS idx_day_assignments_day_id ON day_assignments(day_id);
CREATE INDEX IF NOT EXISTS idx_day_assignments_place_id ON day_assignments(place_id);
CREATE INDEX IF NOT EXISTS idx_place_tags_place_id ON place_tags(place_id);
CREATE INDEX IF NOT EXISTS idx_place_tags_tag_id ON place_tags(tag_id);
CREATE INDEX IF NOT EXISTS idx_trip_members_trip_id ON trip_members(trip_id);
CREATE INDEX IF NOT EXISTS idx_trip_members_user_id ON trip_members(user_id);
CREATE INDEX IF NOT EXISTS idx_packing_items_trip_id ON packing_items(trip_id);
CREATE INDEX IF NOT EXISTS idx_budget_items_trip_id ON budget_items(trip_id);
CREATE INDEX IF NOT EXISTS idx_reservations_trip_id ON reservations(trip_id);
CREATE INDEX IF NOT EXISTS idx_trip_files_trip_id ON trip_files(trip_id);
CREATE INDEX IF NOT EXISTS idx_day_notes_day_id ON day_notes(day_id);
CREATE INDEX IF NOT EXISTS idx_photos_trip_id ON photos(trip_id);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_day_accommodations_trip_id ON day_accommodations(trip_id);
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)
);
CREATE INDEX IF NOT EXISTS idx_assignment_participants_assignment ON assignment_participants(assignment_id);
`);
// Versioned migrations — each runs exactly once
_db.exec('CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)');
const versionRow = _db.prepare('SELECT version FROM schema_version').get();
let currentVersion = versionRow?.version ?? 0;
// Existing or fresh DBs may already have columns the migrations add.
// Detect by checking for a column from migration 1 (unsplash_api_key).
if (currentVersion === 0) {
const hasUnsplash = _db.prepare(
"SELECT 1 FROM pragma_table_info('users') WHERE name = 'unsplash_api_key'"
).get();
if (hasUnsplash) {
// All columns from CREATE TABLE already exist — skip ALTER migrations
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 = [
// 118: ALTER TABLE additions
() => _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'),
// 19: budget_items table rebuild (NOT NULL → nullable persons)
() => {
const schema = _db.prepare("SELECT sql FROM sqlite_master WHERE name = 'budget_items'").get();
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;
`);
}
},
// 20: accommodation check-in/check-out/confirmation fields
() => {
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 {}
},
// 21: places end_time field (place_time becomes start_time conceptually, end_time is new)
() => {
try { _db.exec('ALTER TABLE places ADD COLUMN end_time TEXT'); } catch {}
},
// 22: Move reservation fields from places to day_assignments
() => {
// Add new columns to day_assignments
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 {}
// Migrate existing data: copy reservation info from places to all their assignments
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) {
console.error('[DB] Migration 22 data copy error:', e.message);
}
},
// 23: Add assignment_id to reservations table
() => {
try { _db.exec('ALTER TABLE reservations ADD COLUMN assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE SET NULL'); } catch {}
},
// 24: Assignment participants (who's joining which activity)
() => {
_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)
)
`);
},
// 25: Collab addon tables
() => {
_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);
`);
// Ensure collab addon exists for existing installations
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 {}
},
// 26: Per-assignment times (instead of shared place times)
() => {
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 {}
// Copy existing place times to assignments as initial values
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 {}
},
// 27: Budget item members (per-person expense tracking)
() => {
_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);
`);
},
// 28: Message reactions
() => {
_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);
`);
},
// 29: Soft-delete for chat messages
() => {
try { _db.exec('ALTER TABLE collab_messages ADD COLUMN deleted INTEGER DEFAULT 0'); } catch {}
},
// 30: Note attachments + website field
() => {
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 {}
},
// 28: Add end_time to reservations
() => {
try { _db.exec('ALTER TABLE reservations ADD COLUMN reservation_end_time TEXT'); } catch {}
},
// Future migrations go here (append only, never reorder)
];
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}`);
}
// First registered user becomes admin — no default admin seed needed
// Seed: default categories
try {
const existingCats = _db.prepare('SELECT COUNT(*) as count FROM categories').get();
if (existingCats.count === 0) {
const defaultCategories = [
{ name: 'Hotel', color: '#3b82f6', icon: '🏨' },
{ name: 'Restaurant', color: '#ef4444', icon: '🍽️' },
{ name: 'Attraction', color: '#8b5cf6', icon: '🏛️' },
{ name: 'Shopping', color: '#f59e0b', icon: '🛍️' },
{ name: 'Transport', color: '#6b7280', icon: '🚌' },
{ name: 'Activity', color: '#10b981', icon: '🎯' },
{ name: 'Bar/Cafe', color: '#f97316', icon: '☕' },
{ name: 'Beach', color: '#06b6d4', icon: '🏖️' },
{ name: 'Nature', color: '#84cc16', icon: '🌿' },
{ name: 'Other', color: '#6366f1', icon: '📍' },
];
const insertCat = _db.prepare('INSERT INTO categories (name, color, icon) VALUES (?, ?, ?)');
for (const cat of defaultCategories) insertCat.run(cat.name, cat.color, cat.icon);
console.log('Default categories seeded');
}
} catch (err) {
console.error('Error seeding categories:', err.message);
}
// Seed: default addons (INSERT OR IGNORE so migration-inserted rows don't block seeding)
try {
const defaultAddons = [
{ id: 'packing', name: 'Packing List', description: 'Pack your bags with checklists per trip', type: 'trip', icon: 'ListChecks', enabled: 1, sort_order: 0 },
{ id: 'budget', name: 'Budget Planner', description: 'Track expenses and plan your travel budget', type: 'trip', icon: 'Wallet', enabled: 1, sort_order: 1 },
{ id: 'documents', name: 'Documents', description: 'Store and manage travel documents', type: 'trip', icon: 'FileText', enabled: 1, sort_order: 2 },
{ id: 'vacay', name: 'Vacay', description: 'Personal vacation day planner with calendar view', type: 'global', icon: 'CalendarDays', enabled: 1, sort_order: 10 },
{ id: 'atlas', name: 'Atlas', description: 'World map of your visited countries with travel stats', type: 'global', icon: 'Globe', enabled: 1, sort_order: 11 },
{ id: 'collab', name: 'Collab', description: 'Notes, polls, and live chat for trip collaboration', type: 'trip', icon: 'Users', enabled: 1, sort_order: 6 },
];
const insertAddon = _db.prepare('INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)');
for (const a of defaultAddons) insertAddon.run(a.id, a.name, a.description, a.type, a.icon, a.enabled, a.sort_order);
console.log('Default addons seeded');
} catch (err) {
console.error('Error seeding addons:', err.message);
}
}
// Initialize on module load
initDb();
// Demo mode: seed admin + demo user + example trips
if (process.env.DEMO_MODE === 'true') {
try {
const { seedDemoData } = require('../demo/demo-seed');
seedDemoData(_db);
} catch (err) {
console.error('[Demo] Seed error:', err.message);
}
}
// Proxy so all route modules always use the current _db instance
// without needing a server restart after reinitialize()
const db = new Proxy({}, {
get(_, prop) {
if (!_db) throw new Error('Database connection is not available (restore in progress?)');
const val = _db[prop];
return typeof val === 'function' ? val.bind(_db) : val;
},
set(_, prop, val) {
_db[prop] = val;
return true;
},
});
function closeDb() {
if (_db) {
try { _db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {}
try { _db.close(); } catch (e) {}
_db = null;
console.log('[DB] Database connection closed');
}
}
function reinitialize() {
console.log('[DB] Reinitializing database connection after restore...');
// initDb handles close + reopen, but if closeDb was already called, _db is null
if (_db) closeDb();
initDb();
console.log('[DB] Database reinitialized successfully');
}
function getPlaceWithTags(placeId) {
const place = _db.prepare(`
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
FROM places p
LEFT JOIN categories c ON p.category_id = c.id
WHERE p.id = ?
`).get(placeId);
if (!place) return null;
const tags = _db.prepare(`
SELECT t.* FROM tags t
JOIN place_tags pt ON t.id = pt.tag_id
WHERE pt.place_id = ?
`).all(placeId);
return {
...place,
category: place.category_id ? {
id: place.category_id,
name: place.category_name,
color: place.category_color,
icon: place.category_icon,
} : null,
tags,
};
}
function canAccessTrip(tripId, userId) {
return _db.prepare(`
SELECT t.id, t.user_id FROM trips t
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)
`).get(userId, tripId, userId);
}
function isOwner(tripId, userId) {
return !!_db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId);
}
module.exports = { db, closeDb, reinitialize, getPlaceWithTags, canAccessTrip, isOwner };

131
server/src/db/database.ts Normal file
View File

@@ -0,0 +1,131 @@
import Database from 'better-sqlite3';
import path from 'path';
import fs from 'fs';
import { createTables } from './schema';
import { runMigrations } from './migrations';
import { runSeeds } from './seeds';
import { Place, Tag } from '../types';
const dataDir = path.join(__dirname, '../../data');
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const dbPath = path.join(dataDir, 'travel.db');
let _db: Database.Database | null = null;
function initDb(): void {
if (_db) {
try { _db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {}
try { _db.close(); } catch (e) {}
_db = null;
}
_db = new Database(dbPath);
_db.exec('PRAGMA journal_mode = WAL');
_db.exec('PRAGMA busy_timeout = 5000');
_db.exec('PRAGMA foreign_keys = ON');
createTables(_db);
runMigrations(_db);
runSeeds(_db);
}
initDb();
if (process.env.DEMO_MODE === 'true') {
try {
const { seedDemoData } = require('../demo/demo-seed');
seedDemoData(_db);
} catch (err: unknown) {
console.error('[Demo] Seed error:', err instanceof Error ? err.message : err);
}
}
const db = new Proxy({} as Database.Database, {
get(_, prop: string | symbol) {
if (!_db) throw new Error('Database connection is not available (restore in progress?)');
const val = (_db as unknown as Record<string | symbol, unknown>)[prop];
return typeof val === 'function' ? val.bind(_db) : val;
},
set(_, prop: string | symbol, val: unknown) {
(_db as unknown as Record<string | symbol, unknown>)[prop] = val;
return true;
},
});
function closeDb(): void {
if (_db) {
try { _db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {}
try { _db.close(); } catch (e) {}
_db = null;
console.log('[DB] Database connection closed');
}
}
function reinitialize(): void {
console.log('[DB] Reinitializing database connection after restore...');
if (_db) closeDb();
initDb();
console.log('[DB] Database reinitialized successfully');
}
interface PlaceWithCategory extends Place {
category_name: string | null;
category_color: string | null;
category_icon: string | null;
}
interface PlaceWithTags extends Place {
category: { id: number; name: string; color: string; icon: string } | null;
tags: Tag[];
}
function getPlaceWithTags(placeId: number | string): PlaceWithTags | null {
const place = _db!.prepare(`
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
FROM places p
LEFT JOIN categories c ON p.category_id = c.id
WHERE p.id = ?
`).get(placeId) as PlaceWithCategory | undefined;
if (!place) return null;
const tags = _db!.prepare(`
SELECT t.* FROM tags t
JOIN place_tags pt ON t.id = pt.tag_id
WHERE pt.place_id = ?
`).all(placeId) as Tag[];
return {
...place,
category: place.category_id ? {
id: place.category_id,
name: place.category_name!,
color: place.category_color!,
icon: place.category_icon!,
} : null,
tags,
};
}
interface TripAccess {
id: number;
user_id: number;
}
function canAccessTrip(tripId: number | string, userId: number): TripAccess | undefined {
return _db!.prepare(`
SELECT t.id, t.user_id FROM trips t
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)
`).get(userId, tripId, userId) as TripAccess | undefined;
}
function isOwner(tripId: number | string, userId: number): boolean {
return !!_db!.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId);
}
export { db, closeDb, reinitialize, getPlaceWithTags, canAccessTrip, isOwner };

208
server/src/db/migrations.ts Normal file
View File

@@ -0,0 +1,208 @@
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 {}
},
];
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 };

375
server/src/db/schema.ts Normal file
View File

@@ -0,0 +1,375 @@
import Database from 'better-sqlite3';
function createTables(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
maps_api_key TEXT,
unsplash_api_key TEXT,
openweather_api_key TEXT,
avatar TEXT,
oidc_sub TEXT,
oidc_issuer TEXT,
last_login DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
key TEXT NOT NULL,
value TEXT,
UNIQUE(user_id, key)
);
CREATE TABLE IF NOT EXISTS trips (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT,
start_date TEXT,
end_date TEXT,
currency TEXT DEFAULT 'EUR',
cover_image TEXT,
is_archived INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS days (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
day_number INTEGER NOT NULL,
date TEXT,
notes TEXT,
title TEXT,
UNIQUE(trip_id, day_number)
);
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
color TEXT DEFAULT '#6366f1',
icon TEXT DEFAULT '📍',
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
color TEXT DEFAULT '#10b981',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS places (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
lat REAL,
lng REAL,
address TEXT,
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
price REAL,
currency TEXT,
reservation_status TEXT DEFAULT 'none',
reservation_notes TEXT,
reservation_datetime TEXT,
place_time TEXT,
end_time TEXT,
duration_minutes INTEGER DEFAULT 60,
notes TEXT,
image_url TEXT,
google_place_id TEXT,
website TEXT,
phone TEXT,
transport_mode TEXT DEFAULT 'walking',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS place_tags (
place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (place_id, tag_id)
);
CREATE TABLE IF NOT EXISTS day_assignments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE,
order_index INTEGER DEFAULT 0,
notes TEXT,
reservation_status TEXT DEFAULT 'none',
reservation_notes TEXT,
reservation_datetime TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS packing_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,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS photos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
day_id INTEGER REFERENCES days(id) ON DELETE SET NULL,
place_id INTEGER REFERENCES places(id) ON DELETE SET NULL,
filename TEXT NOT NULL,
original_name TEXT NOT NULL,
file_size INTEGER,
mime_type TEXT,
caption TEXT,
taken_at TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS trip_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
place_id INTEGER REFERENCES places(id) ON DELETE SET NULL,
reservation_id INTEGER REFERENCES reservations(id) ON DELETE SET NULL,
filename TEXT NOT NULL,
original_name TEXT NOT NULL,
file_size INTEGER,
mime_type TEXT,
description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS reservations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
day_id INTEGER REFERENCES days(id) ON DELETE SET NULL,
place_id INTEGER REFERENCES places(id) ON DELETE SET NULL,
assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE SET NULL,
title TEXT NOT NULL,
reservation_time TEXT,
reservation_end_time TEXT,
location TEXT,
confirmation_number TEXT,
notes TEXT,
status TEXT DEFAULT 'pending',
type TEXT DEFAULT 'other',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS trip_members (
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,
invited_by INTEGER REFERENCES users(id),
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(trip_id, user_id)
);
CREATE TABLE IF NOT EXISTS day_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
text TEXT NOT NULL,
time TEXT,
icon TEXT DEFAULT '📝',
sort_order REAL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT
);
CREATE TABLE IF NOT EXISTS budget_items (
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
);
-- Addon system
CREATE TABLE IF NOT EXISTS addons (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
type TEXT NOT NULL DEFAULT 'global',
icon TEXT DEFAULT 'Puzzle',
enabled INTEGER DEFAULT 0,
config TEXT DEFAULT '{}',
sort_order INTEGER DEFAULT 0
);
-- Vacay addon tables
CREATE TABLE IF NOT EXISTS vacay_plans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
block_weekends INTEGER DEFAULT 1,
holidays_enabled INTEGER DEFAULT 0,
holidays_region TEXT DEFAULT '',
company_holidays_enabled INTEGER DEFAULT 1,
carry_over_enabled INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(owner_id)
);
CREATE TABLE IF NOT EXISTS vacay_plan_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status TEXT DEFAULT 'pending',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(plan_id, user_id)
);
CREATE TABLE IF NOT EXISTS vacay_user_colors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
color TEXT DEFAULT '#6366f1',
UNIQUE(user_id, plan_id)
);
CREATE TABLE IF NOT EXISTS vacay_years (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
year INTEGER NOT NULL,
UNIQUE(plan_id, year)
);
CREATE TABLE IF NOT EXISTS vacay_user_years (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
year INTEGER NOT NULL,
vacation_days INTEGER DEFAULT 30,
carried_over INTEGER DEFAULT 0,
UNIQUE(user_id, plan_id, year)
);
CREATE TABLE IF NOT EXISTS vacay_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
date TEXT NOT NULL,
note TEXT DEFAULT '',
UNIQUE(user_id, plan_id, date)
);
CREATE TABLE IF NOT EXISTS vacay_company_holidays (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
date TEXT NOT NULL,
note TEXT DEFAULT '',
UNIQUE(plan_id, date)
);
CREATE TABLE IF NOT EXISTS day_accommodations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE,
start_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
end_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
check_in TEXT,
check_out TEXT,
confirmation TEXT,
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Collab addon tables
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);
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_places_trip_id ON places(trip_id);
CREATE INDEX IF NOT EXISTS idx_places_category_id ON places(category_id);
CREATE INDEX IF NOT EXISTS idx_days_trip_id ON days(trip_id);
CREATE INDEX IF NOT EXISTS idx_day_assignments_day_id ON day_assignments(day_id);
CREATE INDEX IF NOT EXISTS idx_day_assignments_place_id ON day_assignments(place_id);
CREATE INDEX IF NOT EXISTS idx_place_tags_place_id ON place_tags(place_id);
CREATE INDEX IF NOT EXISTS idx_place_tags_tag_id ON place_tags(tag_id);
CREATE INDEX IF NOT EXISTS idx_trip_members_trip_id ON trip_members(trip_id);
CREATE INDEX IF NOT EXISTS idx_trip_members_user_id ON trip_members(user_id);
CREATE INDEX IF NOT EXISTS idx_packing_items_trip_id ON packing_items(trip_id);
CREATE INDEX IF NOT EXISTS idx_budget_items_trip_id ON budget_items(trip_id);
CREATE INDEX IF NOT EXISTS idx_reservations_trip_id ON reservations(trip_id);
CREATE INDEX IF NOT EXISTS idx_trip_files_trip_id ON trip_files(trip_id);
CREATE INDEX IF NOT EXISTS idx_day_notes_day_id ON day_notes(day_id);
CREATE INDEX IF NOT EXISTS idx_photos_trip_id ON photos(trip_id);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_day_accommodations_trip_id ON day_accommodations(trip_id);
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)
);
CREATE INDEX IF NOT EXISTS idx_assignment_participants_assignment ON assignment_participants(assignment_id);
`);
}
export { createTables };

51
server/src/db/seeds.ts Normal file
View File

@@ -0,0 +1,51 @@
import Database from 'better-sqlite3';
function seedCategories(db: Database.Database): void {
try {
const existingCats = db.prepare('SELECT COUNT(*) as count FROM categories').get() as { count: number };
if (existingCats.count === 0) {
const defaultCategories = [
{ name: 'Hotel', color: '#3b82f6', icon: '🏨' },
{ name: 'Restaurant', color: '#ef4444', icon: '🍽️' },
{ name: 'Attraction', color: '#8b5cf6', icon: '🏛️' },
{ name: 'Shopping', color: '#f59e0b', icon: '🛍️' },
{ name: 'Transport', color: '#6b7280', icon: '🚌' },
{ name: 'Activity', color: '#10b981', icon: '🎯' },
{ name: 'Bar/Cafe', color: '#f97316', icon: '☕' },
{ name: 'Beach', color: '#06b6d4', icon: '🏖️' },
{ name: 'Nature', color: '#84cc16', icon: '🌿' },
{ name: 'Other', color: '#6366f1', icon: '📍' },
];
const insertCat = db.prepare('INSERT INTO categories (name, color, icon) VALUES (?, ?, ?)');
for (const cat of defaultCategories) insertCat.run(cat.name, cat.color, cat.icon);
console.log('Default categories seeded');
}
} catch (err: unknown) {
console.error('Error seeding categories:', err instanceof Error ? err.message : err);
}
}
function seedAddons(db: Database.Database): void {
try {
const defaultAddons = [
{ id: 'packing', name: 'Packing List', description: 'Pack your bags with checklists per trip', type: 'trip', icon: 'ListChecks', enabled: 1, sort_order: 0 },
{ id: 'budget', name: 'Budget Planner', description: 'Track expenses and plan your travel budget', type: 'trip', icon: 'Wallet', enabled: 1, sort_order: 1 },
{ id: 'documents', name: 'Documents', description: 'Store and manage travel documents', type: 'trip', icon: 'FileText', enabled: 1, sort_order: 2 },
{ id: 'vacay', name: 'Vacay', description: 'Personal vacation day planner with calendar view', type: 'global', icon: 'CalendarDays', enabled: 1, sort_order: 10 },
{ id: 'atlas', name: 'Atlas', description: 'World map of your visited countries with travel stats', type: 'global', icon: 'Globe', enabled: 1, sort_order: 11 },
{ id: 'collab', name: 'Collab', description: 'Notes, polls, and live chat for trip collaboration', type: 'trip', icon: 'Users', enabled: 1, sort_order: 6 },
];
const insertAddon = db.prepare('INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)');
for (const a of defaultAddons) insertAddon.run(a.id, a.name, a.description, a.type, a.icon, a.enabled, a.sort_order);
console.log('Default addons seeded');
} catch (err: unknown) {
console.error('Error seeding addons:', err instanceof Error ? err.message : err);
}
}
function runSeeds(db: Database.Database): void {
seedCategories(db);
seedAddons(db);
}
export { runSeeds };

View File

@@ -1,11 +1,11 @@
const fs = require('fs');
const path = require('path');
import fs from 'fs';
import path from 'path';
const dataDir = path.join(__dirname, '../../data');
const dbPath = path.join(dataDir, 'travel.db');
const baselinePath = path.join(dataDir, 'travel-baseline.db');
function resetDemoUser() {
function resetDemoUser(): void {
if (!fs.existsSync(baselinePath)) {
console.log('[Demo Reset] No baseline found, skipping. Admin must save baseline first.');
return;
@@ -15,13 +15,14 @@ function resetDemoUser() {
// Save admin's current credentials and API keys (these should survive the reset)
const adminEmail = process.env.DEMO_ADMIN_EMAIL || 'admin@nomad.app';
let adminData = null;
interface AdminData { password_hash: string; maps_api_key: string | null; openweather_api_key: string | null; unsplash_api_key: string | null; avatar: string | null; }
let adminData: AdminData | undefined = undefined;
try {
adminData = db.prepare(
'SELECT password_hash, maps_api_key, openweather_api_key, unsplash_api_key, avatar FROM users WHERE email = ?'
).get(adminEmail);
} catch (e) {
console.error('[Demo Reset] Failed to read admin data:', e.message);
).get(adminEmail) as AdminData | undefined;
} catch (e: unknown) {
console.error('[Demo Reset] Failed to read admin data:', e instanceof Error ? e.message : e);
}
// Flush WAL to main DB file
@@ -36,8 +37,8 @@ function resetDemoUser() {
// Remove WAL/SHM files if they exist (stale from old connection)
try { fs.unlinkSync(dbPath + '-wal'); } catch (e) {}
try { fs.unlinkSync(dbPath + '-shm'); } catch (e) {}
} catch (e) {
console.error('[Demo Reset] Failed to restore baseline:', e.message);
} catch (e: unknown) {
console.error('[Demo Reset] Failed to restore baseline:', e instanceof Error ? e.message : e);
reinitialize();
return;
}
@@ -59,15 +60,15 @@ function resetDemoUser() {
adminData.avatar,
adminEmail
);
} catch (e) {
console.error('[Demo Reset] Failed to restore admin credentials:', e.message);
} catch (e: unknown) {
console.error('[Demo Reset] Failed to restore admin credentials:', e instanceof Error ? e.message : e);
}
}
console.log('[Demo Reset] Database restored from baseline');
}
function saveBaseline() {
function saveBaseline(): void {
const { db } = require('../db/database');
// Flush WAL so baseline file is self-contained
@@ -77,8 +78,8 @@ function saveBaseline() {
console.log('[Demo] Baseline saved');
}
function hasBaseline() {
function hasBaseline(): boolean {
return fs.existsSync(baselinePath);
}
module.exports = { resetDemoUser, saveBaseline, hasBaseline };
export { resetDemoUser, saveBaseline, hasBaseline };

View File

@@ -1,6 +1,7 @@
const bcrypt = require('bcryptjs');
import bcrypt from 'bcryptjs';
import Database from 'better-sqlite3';
function seedDemoData(db) {
function seedDemoData(db: Database.Database): { adminId: number; demoId: number } {
const ADMIN_USER = process.env.DEMO_ADMIN_USER || 'admin';
const ADMIN_EMAIL = process.env.DEMO_ADMIN_EMAIL || 'admin@nomad.app';
const ADMIN_PASS = process.env.DEMO_ADMIN_PASS || 'admin12345';
@@ -8,7 +9,7 @@ function seedDemoData(db) {
const DEMO_PASS = 'demo12345';
// Create admin user if not exists
let admin = db.prepare('SELECT id FROM users WHERE email = ?').get(ADMIN_EMAIL);
let admin = db.prepare('SELECT id FROM users WHERE email = ?').get(ADMIN_EMAIL) as { id: number } | undefined;
if (!admin) {
const hash = bcrypt.hashSync(ADMIN_PASS, 10);
const r = db.prepare('INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)').run(ADMIN_USER, ADMIN_EMAIL, hash, 'admin');
@@ -19,7 +20,7 @@ function seedDemoData(db) {
}
// Create demo user if not exists
let demo = db.prepare('SELECT id FROM users WHERE email = ?').get(DEMO_EMAIL);
let demo = db.prepare('SELECT id FROM users WHERE email = ?').get(DEMO_EMAIL) as { id: number } | undefined;
if (!demo) {
const hash = bcrypt.hashSync(DEMO_PASS, 10);
const r = db.prepare('INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)').run('demo', DEMO_EMAIL, hash, 'user');
@@ -33,7 +34,7 @@ function seedDemoData(db) {
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run();
// Check if admin already has example trips
const adminTrips = db.prepare('SELECT COUNT(*) as count FROM trips WHERE user_id = ?').get(admin.id);
const adminTrips = db.prepare('SELECT COUNT(*) as count FROM trips WHERE user_id = ?').get(admin.id) as { count: number };
if (adminTrips.count > 0) {
console.log('[Demo] Example trips already exist, ensuring demo membership');
ensureDemoMembership(db, admin.id, demo.id);
@@ -52,15 +53,15 @@ function seedDemoData(db) {
return { adminId: admin.id, demoId: demo.id };
}
function ensureDemoMembership(db, adminId, demoId) {
const trips = db.prepare('SELECT id FROM trips WHERE user_id = ?').all(adminId);
function ensureDemoMembership(db: Database.Database, adminId: number, demoId: number): void {
const trips = db.prepare('SELECT id FROM trips WHERE user_id = ?').all(adminId) as { id: number }[];
const insertMember = db.prepare('INSERT OR IGNORE INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)');
for (const trip of trips) {
insertMember.run(trip.id, demoId, adminId);
}
}
function seedExampleTrips(db, adminId, demoId) {
function seedExampleTrips(db: Database.Database, adminId: number, demoId: number): void {
const insertTrip = db.prepare('INSERT INTO trips (user_id, title, description, start_date, end_date, currency) VALUES (?, ?, ?, ?, ?, ?)');
const insertDay = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)');
const insertPlace = db.prepare('INSERT INTO places (trip_id, name, lat, lng, address, category_id, place_time, duration_minutes, notes, image_url, google_place_id, website, phone) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
@@ -73,17 +74,17 @@ function seedExampleTrips(db, adminId, demoId) {
// Category IDs: 1=Hotel, 2=Restaurant, 3=Attraction, 5=Transport, 7=Bar/Cafe, 8=Beach, 9=Nature, 6=Entertainment
// ─── Trip 1: Tokyo & Kyoto ─────────────────────────────────────────────────
// --- Trip 1: Tokyo & Kyoto ---
const trip1 = insertTrip.run(adminId, 'Tokyo & Kyoto', 'Two weeks in Japan — from the neon-lit streets of Tokyo to the serene temples of Kyoto.', '2026-04-15', '2026-04-21', 'JPY');
const t1 = Number(trip1.lastInsertRowid);
const t1days = [];
const t1days: number[] = [];
for (let i = 0; i < 7; i++) {
const d = insertDay.run(t1, i + 1, `2026-04-${15 + i}`);
t1days.push(Number(d.lastInsertRowid));
}
const t1places = [
const t1places: [number, string, number, number, string, number, string, number, string, string | null, string | null, string | null, string | null][] = [
[t1, 'Hotel Shinjuku Granbell', 35.6938, 139.7035, '2-14-5 Kabukicho, Shinjuku City, Tokyo 160-0021, Japan', 1, '15:00', 60, 'Check-in from 3 PM. Steps from Shinjuku Station.', null, 'ChIJdaGEJBeMGGARYgt8sLBv6lM', 'https://www.grfranbellhotel.jp/shinjuku/', '+81 3-5155-2666'],
[t1, 'Senso-ji Temple', 35.7148, 139.7967, '2 Chome-3-1 Asakusa, Taito City, Tokyo 111-0032, Japan', 3, '09:00', 90, 'Oldest temple in Tokyo. Fewer tourists in the early morning.', null, 'ChIJ8T1GpMGOGGARDYGSgpoOdfg', 'https://www.senso-ji.jp/', '+81 3-3842-0181'],
[t1, 'Shibuya Crossing', 35.6595, 139.7004, '2 Chome-2-1 Dogenzaka, Shibuya City, Tokyo 150-0043, Japan', 3, '18:00', 45, 'World\'s busiest pedestrian crossing. Most impressive at night.', null, 'ChIJLyzOhmyLGGARMKWbl5z6wGg', null, null],
@@ -127,7 +128,7 @@ function seedExampleTrips(db, adminId, demoId) {
insertNote.run(t1days[6], t1, 'Last evening — farewell dinner at Pontocho Alley', '19:00', 'Star', 1);
// Packing
const t1packing = [
const t1packing: [string, number, string, number][] = [
['Passport', 1, 'Documents', 0], ['Japan Rail Pass', 1, 'Documents', 1],
['Power adapter Type A/B', 0, 'Electronics', 2], ['Camera + charger', 0, 'Electronics', 3],
['Comfortable walking shoes', 0, 'Clothing', 4], ['Rain jacket', 0, 'Clothing', 5],
@@ -150,17 +151,17 @@ function seedExampleTrips(db, adminId, demoId) {
insertMember.run(t1, demoId, adminId);
// ─── Trip 2: Barcelona Long Weekend ────────────────────────────────────────
// --- Trip 2: Barcelona Long Weekend ---
const trip2 = insertTrip.run(adminId, 'Barcelona Long Weekend', 'Gaudi, tapas, and Mediterranean vibes — a long weekend in the Catalan capital.', '2026-05-21', '2026-05-24', 'EUR');
const t2 = Number(trip2.lastInsertRowid);
const t2days = [];
const t2days: number[] = [];
for (let i = 0; i < 4; i++) {
const d = insertDay.run(t2, i + 1, `2026-05-${21 + i}`);
t2days.push(Number(d.lastInsertRowid));
}
const t2places = [
const t2places: [number, string, number, number, string, number, string, number, string, string | null, string | null, string | null, string | null][] = [
[t2, 'W Barcelona', 41.3686, 2.1920, 'Placa de la Rosa dels Vents 1, 08039 Barcelona, Spain', 1, '14:00', 60, 'Right on the beach. Rooftop bar with panoramic views!', null, 'ChIJKfj5C8yjpBIRCPC3RPI0JO4', 'https://www.marriott.com/hotels/travel/bcnwh-w-barcelona/', '+34 932 95 28 00'],
[t2, 'Sagrada Familia', 41.4036, 2.1744, 'C/ de Mallorca, 401, 08013 Barcelona, Spain', 3, '10:00', 120, 'Gaudi\'s masterpiece. Book tickets online in advance — sells out fast!', null, 'ChIJk_s92NyipBIRUMnDG8Kq2Js', 'https://sagradafamilia.org/', '+34 932 08 04 14'],
[t2, 'Park Guell', 41.4145, 2.1527, '08024 Barcelona, Spain', 3, '09:00', 90, 'Mosaic terrace with city views. Book early for the Monumental Zone.', null, 'ChIJ4eQMeOmipBIRb65JRUzGE8k', 'https://parkguell.barcelona/', '+34 934 09 18 31'],
@@ -204,17 +205,17 @@ function seedExampleTrips(db, adminId, demoId) {
insertMember.run(t2, demoId, adminId);
// ─── Trip 3: New York City ─────────────────────────────────────────────────
// --- Trip 3: New York City ---
const trip3 = insertTrip.run(adminId, 'New York City', 'The city that never sleeps — iconic landmarks, world-class food, and Broadway lights.', '2026-09-18', '2026-09-22', 'USD');
const t3 = Number(trip3.lastInsertRowid);
const t3days = [];
const t3days: number[] = [];
for (let i = 0; i < 5; i++) {
const d = insertDay.run(t3, i + 1, `2026-09-${18 + i}`);
t3days.push(Number(d.lastInsertRowid));
}
const t3places = [
const t3places: [number, string, number, number, string, number, string, number, string, string | null, string | null, string | null, string | null][] = [
[t3, 'The Plaza Hotel', 40.7645, -73.9744, '768 5th Ave, New York, NY 10019, USA', 1, '15:00', 60, 'Iconic luxury hotel on Central Park. The lobby alone is worth a visit.', null, 'ChIJYbISlAVYwokRn6ORbSPV0xk', 'https://www.theplazany.com/', '+1 212-759-3000'],
[t3, 'Statue of Liberty', 40.6892, -74.0445, 'Liberty Island, New York, NY 10004, USA', 3, '09:00', 180, 'Book crown access tickets months in advance. Ferry from Battery Park.', null, 'ChIJPTacEpBQwokRKwIlDXelxkA', 'https://www.nps.gov/stli/', '+1 212-363-3200'],
[t3, 'Central Park', 40.7829, -73.9654, 'Central Park, New York, NY 10024, USA', 9, '10:00', 120, 'Bethesda Fountain, Bow Bridge, and Strawberry Fields. Rent bikes!', null, 'ChIJ4zGFAZpYwokRGUGph3Mf37k', 'https://www.centralparknyc.org/', null],
@@ -251,7 +252,7 @@ function seedExampleTrips(db, adminId, demoId) {
insertNote.run(t3days[4], t3, 'Flight departs JFK at 17:00 — last bagel at Russ & Daughters!', '10:00', 'Plane', 0);
// Packing
const t3packing = [
const t3packing: [string, number, string, number][] = [
['Passport', 1, 'Documents', 0], ['ESTA confirmation', 1, 'Documents', 1],
['Travel insurance', 0, 'Documents', 2], ['Comfortable sneakers', 0, 'Clothing', 3],
['Light jacket', 0, 'Clothing', 4], ['Portable charger', 0, 'Electronics', 5],
@@ -275,4 +276,4 @@ function seedExampleTrips(db, adminId, demoId) {
console.log('[Demo] 3 example trips seeded and shared with demo user');
}
module.exports = { seedDemoData };
export { seedDemoData };

View File

@@ -1,164 +0,0 @@
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const path = require('path');
const fs = require('fs');
const app = express();
// Create upload directories on startup
const uploadsDir = path.join(__dirname, '../uploads');
const photosDir = path.join(uploadsDir, 'photos');
const filesDir = path.join(uploadsDir, 'files');
const coversDir = path.join(uploadsDir, 'covers');
const backupsDir = path.join(__dirname, '../data/backups');
const tmpDir = path.join(__dirname, '../data/tmp');
[uploadsDir, photosDir, filesDir, coversDir, backupsDir, tmpDir].forEach(dir => {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
});
// Middleware
const allowedOrigins = process.env.ALLOWED_ORIGINS
? process.env.ALLOWED_ORIGINS.split(',')
: null;
let corsOrigin;
if (allowedOrigins) {
// Explicit whitelist from env var
corsOrigin = (origin, callback) => {
if (!origin || allowedOrigins.includes(origin)) callback(null, true);
else callback(new Error('Not allowed by CORS'));
};
} else if (process.env.NODE_ENV === 'production') {
// Production: same-origin only (Express serves the static client)
corsOrigin = false;
} else {
// Development: allow all origins (needed for Vite dev server)
corsOrigin = true;
}
app.use(cors({
origin: corsOrigin,
credentials: true
}));
app.use(helmet({
contentSecurityPolicy: false, // managed by frontend meta tag or reverse proxy
crossOriginEmbedderPolicy: false, // allows loading external images (maps, etc.)
}));
app.use(express.json({ limit: '100kb' }));
app.use(express.urlencoded({ extended: true }));
// Serve uploaded files
app.use('/uploads', express.static(path.join(__dirname, '../uploads')));
// Routes
const authRoutes = require('./routes/auth');
const tripsRoutes = require('./routes/trips');
const daysRoutes = require('./routes/days');
const accommodationsRoutes = require('./routes/days').accommodationsRouter;
const placesRoutes = require('./routes/places');
const assignmentsRoutes = require('./routes/assignments');
const packingRoutes = require('./routes/packing');
const tagsRoutes = require('./routes/tags');
const categoriesRoutes = require('./routes/categories');
const adminRoutes = require('./routes/admin');
const mapsRoutes = require('./routes/maps');
const filesRoutes = require('./routes/files');
const reservationsRoutes = require('./routes/reservations');
const dayNotesRoutes = require('./routes/dayNotes');
const weatherRoutes = require('./routes/weather');
const settingsRoutes = require('./routes/settings');
const budgetRoutes = require('./routes/budget');
const collabRoutes = require('./routes/collab');
const backupRoutes = require('./routes/backup');
const oidcRoutes = require('./routes/oidc');
app.use('/api/auth', authRoutes);
app.use('/api/auth/oidc', oidcRoutes);
app.use('/api/trips', tripsRoutes);
app.use('/api/trips/:tripId/days', daysRoutes);
app.use('/api/trips/:tripId/accommodations', accommodationsRoutes);
app.use('/api/trips/:tripId/places', placesRoutes);
app.use('/api/trips/:tripId/packing', packingRoutes);
app.use('/api/trips/:tripId/files', filesRoutes);
app.use('/api/trips/:tripId/budget', budgetRoutes);
app.use('/api/trips/:tripId/collab', collabRoutes);
app.use('/api/trips/:tripId/reservations', reservationsRoutes);
app.use('/api/trips/:tripId/days/:dayId/notes', dayNotesRoutes);
app.get('/api/health', (req, res) => res.json({ status: 'ok' }));
app.use('/api', assignmentsRoutes);
app.use('/api/tags', tagsRoutes);
app.use('/api/categories', categoriesRoutes);
app.use('/api/admin', adminRoutes);
// Public addons endpoint (authenticated but not admin-only)
const { authenticate: addonAuth } = require('./middleware/auth');
const { db: addonDb } = require('./db/database');
app.get('/api/addons', addonAuth, (req, res) => {
const addons = addonDb.prepare('SELECT id, name, type, icon, enabled FROM addons WHERE enabled = 1 ORDER BY sort_order').all();
res.json({ addons: addons.map(a => ({ ...a, enabled: !!a.enabled })) });
});
// Addon routes
const vacayRoutes = require('./routes/vacay');
app.use('/api/addons/vacay', vacayRoutes);
const atlasRoutes = require('./routes/atlas');
app.use('/api/addons/atlas', atlasRoutes);
app.use('/api/maps', mapsRoutes);
app.use('/api/weather', weatherRoutes);
app.use('/api/settings', settingsRoutes);
app.use('/api/backup', backupRoutes);
// Serve static files in production
if (process.env.NODE_ENV === 'production') {
const publicPath = path.join(__dirname, '../public');
app.use(express.static(publicPath));
app.get('*', (req, res) => {
res.sendFile(path.join(publicPath, 'index.html'));
});
}
// Global error handler
app.use((err, req, res, next) => {
console.error('Unhandled error:', err);
res.status(500).json({ error: 'Internal server error' });
});
const scheduler = require('./scheduler');
const PORT = process.env.PORT || 3001;
const server = app.listen(PORT, () => {
console.log(`NOMAD API running on port ${PORT}`);
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
if (process.env.DEMO_MODE === 'true') console.log('Demo mode: ENABLED');
scheduler.start();
scheduler.startDemoReset();
const { setupWebSocket } = require('./websocket');
setupWebSocket(server);
});
// Graceful shutdown
function shutdown(signal) {
console.log(`\n${signal} received — shutting down gracefully...`);
scheduler.stop();
server.close(() => {
console.log('HTTP server closed');
const { closeDb } = require('./db/database');
closeDb();
console.log('Shutdown complete');
process.exit(0);
});
// Force exit after 10s if connections don't close
setTimeout(() => {
console.error('Forced shutdown after timeout');
process.exit(1);
}, 10000);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
module.exports = app;

208
server/src/index.ts Normal file
View File

@@ -0,0 +1,208 @@
import 'dotenv/config';
import express, { Request, Response, NextFunction } from 'express';
import cors from 'cors';
import helmet from 'helmet';
import path from 'path';
import fs from 'fs';
const app = express();
// Trust first proxy (nginx/Docker) for correct req.ip
if (process.env.NODE_ENV === 'production' || process.env.TRUST_PROXY) {
app.set('trust proxy', parseInt(process.env.TRUST_PROXY as string) || 1);
}
// Create upload directories on startup
const uploadsDir = path.join(__dirname, '../uploads');
const photosDir = path.join(uploadsDir, 'photos');
const filesDir = path.join(uploadsDir, 'files');
const coversDir = path.join(uploadsDir, 'covers');
const backupsDir = path.join(__dirname, '../data/backups');
const tmpDir = path.join(__dirname, '../data/tmp');
[uploadsDir, photosDir, filesDir, coversDir, backupsDir, tmpDir].forEach(dir => {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
});
// Middleware
const allowedOrigins = process.env.ALLOWED_ORIGINS
? process.env.ALLOWED_ORIGINS.split(',')
: null;
let corsOrigin: cors.CorsOptions['origin'];
if (allowedOrigins) {
// Explicit whitelist from env var
corsOrigin = (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => {
if (!origin || allowedOrigins.includes(origin)) callback(null, true);
else callback(new Error('Not allowed by CORS'));
};
} else if (process.env.NODE_ENV === 'production') {
// Production: same-origin only (Express serves the static client)
corsOrigin = false;
} else {
// Development: allow all origins (needed for Vite dev server)
corsOrigin = true;
}
app.use(cors({
origin: corsOrigin,
credentials: true
}));
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://unpkg.com"],
imgSrc: ["'self'", "data:", "blob:", "https:", "http:"],
connectSrc: ["'self'", "ws:", "wss:", "https:", "http:"],
fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
objectSrc: ["'self'"],
frameAncestors: ["'none'"],
}
},
crossOriginEmbedderPolicy: false,
}));
// Redirect HTTP to HTTPS in production
if (process.env.NODE_ENV === 'production' && process.env.FORCE_HTTPS !== 'false') {
app.use((req: Request, res: Response, next: NextFunction) => {
if (req.secure || req.headers['x-forwarded-proto'] === 'https') return next();
res.redirect(301, 'https://' + req.headers.host + req.url);
});
}
app.use(express.json({ limit: '100kb' }));
app.use(express.urlencoded({ extended: true }));
// Avatars are public (shown on login, sharing screens)
app.use('/uploads/avatars', express.static(path.join(__dirname, '../uploads/avatars')));
// All other uploads require authentication
app.get('/uploads/:type/:filename', (req: Request, res: Response) => {
const { type, filename } = req.params;
const allowedTypes = ['covers', 'files', 'photos'];
if (!allowedTypes.includes(type)) return res.status(404).send('Not found');
// Prevent path traversal
const safeName = path.basename(filename);
const filePath = path.join(__dirname, '../uploads', type, safeName);
const resolved = path.resolve(filePath);
if (!resolved.startsWith(path.resolve(__dirname, '../uploads', type))) {
return res.status(403).send('Forbidden');
}
if (!fs.existsSync(resolved)) return res.status(404).send('Not found');
res.sendFile(resolved);
});
// Routes
import authRoutes from './routes/auth';
import tripsRoutes from './routes/trips';
import daysRoutes, { accommodationsRouter as accommodationsRoutes } from './routes/days';
import placesRoutes from './routes/places';
import assignmentsRoutes from './routes/assignments';
import packingRoutes from './routes/packing';
import tagsRoutes from './routes/tags';
import categoriesRoutes from './routes/categories';
import adminRoutes from './routes/admin';
import mapsRoutes from './routes/maps';
import filesRoutes from './routes/files';
import reservationsRoutes from './routes/reservations';
import dayNotesRoutes from './routes/dayNotes';
import weatherRoutes from './routes/weather';
import settingsRoutes from './routes/settings';
import budgetRoutes from './routes/budget';
import collabRoutes from './routes/collab';
import backupRoutes from './routes/backup';
import oidcRoutes from './routes/oidc';
app.use('/api/auth', authRoutes);
app.use('/api/auth/oidc', oidcRoutes);
app.use('/api/trips', tripsRoutes);
app.use('/api/trips/:tripId/days', daysRoutes);
app.use('/api/trips/:tripId/accommodations', accommodationsRoutes);
app.use('/api/trips/:tripId/places', placesRoutes);
app.use('/api/trips/:tripId/packing', packingRoutes);
app.use('/api/trips/:tripId/files', filesRoutes);
app.use('/api/trips/:tripId/budget', budgetRoutes);
app.use('/api/trips/:tripId/collab', collabRoutes);
app.use('/api/trips/:tripId/reservations', reservationsRoutes);
app.use('/api/trips/:tripId/days/:dayId/notes', dayNotesRoutes);
app.get('/api/health', (req: Request, res: Response) => res.json({ status: 'ok' }));
app.use('/api', assignmentsRoutes);
app.use('/api/tags', tagsRoutes);
app.use('/api/categories', categoriesRoutes);
app.use('/api/admin', adminRoutes);
// Public addons endpoint (authenticated but not admin-only)
import { authenticate as addonAuth } from './middleware/auth';
import { db as addonDb } from './db/database';
import { Addon } from './types';
app.get('/api/addons', addonAuth, (req: Request, res: Response) => {
const addons = addonDb.prepare('SELECT id, name, type, icon, enabled FROM addons WHERE enabled = 1 ORDER BY sort_order').all() as Pick<Addon, 'id' | 'name' | 'type' | 'icon' | 'enabled'>[];
res.json({ addons: addons.map(a => ({ ...a, enabled: !!a.enabled })) });
});
// Addon routes
import vacayRoutes from './routes/vacay';
app.use('/api/addons/vacay', vacayRoutes);
import atlasRoutes from './routes/atlas';
app.use('/api/addons/atlas', atlasRoutes);
app.use('/api/maps', mapsRoutes);
app.use('/api/weather', weatherRoutes);
app.use('/api/settings', settingsRoutes);
app.use('/api/backup', backupRoutes);
// Serve static files in production
if (process.env.NODE_ENV === 'production') {
const publicPath = path.join(__dirname, '../public');
app.use(express.static(publicPath));
app.get('*', (req: Request, res: Response) => {
res.sendFile(path.join(publicPath, 'index.html'));
});
}
// Global error handler
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
console.error('Unhandled error:', err);
res.status(500).json({ error: 'Internal server error' });
});
import * as scheduler from './scheduler';
const PORT = process.env.PORT || 3001;
const server = app.listen(PORT, () => {
console.log(`NOMAD API running on port ${PORT}`);
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
if (process.env.DEMO_MODE === 'true') console.log('Demo mode: ENABLED');
if (process.env.DEMO_MODE === 'true' && process.env.NODE_ENV === 'production') {
console.warn('[SECURITY WARNING] DEMO_MODE is enabled in production! Demo credentials are publicly exposed.');
}
scheduler.start();
scheduler.startDemoReset();
import('./websocket').then(({ setupWebSocket }) => {
setupWebSocket(server);
});
});
// Graceful shutdown
function shutdown(signal: string): void {
console.log(`\n${signal} received — shutting down gracefully...`);
scheduler.stop();
server.close(() => {
console.log('HTTP server closed');
const { closeDb } = require('./db/database');
closeDb();
console.log('Shutdown complete');
process.exit(0);
});
// Force exit after 10s if connections don't close
setTimeout(() => {
console.error('Forced shutdown after timeout');
process.exit(1);
}, 10000);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
export default app;

View File

@@ -1,63 +0,0 @@
const jwt = require('jsonwebtoken');
const { db } = require('../db/database');
const { JWT_SECRET } = require('../config');
const authenticate = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'Access token required' });
}
try {
const decoded = jwt.verify(token, JWT_SECRET);
const user = db.prepare(
'SELECT id, username, email, role, maps_api_key, unsplash_api_key, openweather_api_key FROM users WHERE id = ?'
).get(decoded.id);
if (!user) {
return res.status(401).json({ error: 'User not found' });
}
req.user = user;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
};
const optionalAuth = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
req.user = null;
return next();
}
try {
const decoded = jwt.verify(token, JWT_SECRET);
const user = db.prepare(
'SELECT id, username, email, role, maps_api_key, unsplash_api_key, openweather_api_key FROM users WHERE id = ?'
).get(decoded.id);
req.user = user || null;
} catch (err) {
req.user = null;
}
next();
};
const adminOnly = (req, res, next) => {
if (!req.user || req.user.role !== 'admin') {
return res.status(403).json({ error: 'Admin access required' });
}
next();
};
const demoUploadBlock = (req, res, next) => {
if (process.env.DEMO_MODE === 'true' && req.user?.email === 'demo@nomad.app') {
return res.status(403).json({ error: 'Uploads are disabled in demo mode. Self-host NOMAD for full functionality.' });
}
next();
};
module.exports = { authenticate, optionalAuth, adminOnly, demoUploadBlock };

View File

@@ -0,0 +1,71 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { db } from '../db/database';
import { JWT_SECRET } from '../config';
import { AuthRequest, OptionalAuthRequest, User } from '../types';
const authenticate = (req: Request, res: Response, next: NextFunction): void => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
res.status(401).json({ error: 'Access token required' });
return;
}
try {
const decoded = jwt.verify(token, JWT_SECRET) as { id: number };
const user = db.prepare(
'SELECT id, username, email, role FROM users WHERE id = ?'
).get(decoded.id) as User | undefined;
if (!user) {
res.status(401).json({ error: 'User not found' });
return;
}
(req as AuthRequest).user = user;
next();
} catch (err: unknown) {
res.status(401).json({ error: 'Invalid or expired token' });
}
};
const optionalAuth = (req: Request, res: Response, next: NextFunction): void => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
(req as OptionalAuthRequest).user = null;
return next();
}
try {
const decoded = jwt.verify(token, JWT_SECRET) as { id: number };
const user = db.prepare(
'SELECT id, username, email, role FROM users WHERE id = ?'
).get(decoded.id) as User | undefined;
(req as OptionalAuthRequest).user = user || null;
} catch (err: unknown) {
(req as OptionalAuthRequest).user = null;
}
next();
};
const adminOnly = (req: Request, res: Response, next: NextFunction): void => {
const authReq = req as AuthRequest;
if (!authReq.user || authReq.user.role !== 'admin') {
res.status(403).json({ error: 'Admin access required' });
return;
}
next();
};
const demoUploadBlock = (req: Request, res: Response, next: NextFunction): void => {
const authReq = req as AuthRequest;
if (process.env.DEMO_MODE === 'true' && authReq.user?.email === 'demo@nomad.app') {
res.status(403).json({ error: 'Uploads are disabled in demo mode. Self-host NOMAD for full functionality.' });
return;
}
next();
};
export { authenticate, optionalAuth, adminOnly, demoUploadBlock };

View File

@@ -0,0 +1,37 @@
import { Request, Response, NextFunction } from 'express';
import { canAccessTrip, isOwner } from '../db/database';
import { AuthRequest } from '../types';
/** Middleware: verifies the authenticated user is an owner or member of the trip, then attaches trip to req. */
function requireTripAccess(req: Request, res: Response, next: NextFunction): void {
const authReq = req as AuthRequest;
const tripId = req.params.tripId || req.params.id;
if (!tripId) {
res.status(400).json({ error: 'Trip ID required' });
return;
}
const trip = canAccessTrip(Number(tripId), authReq.user.id);
if (!trip) {
res.status(404).json({ error: 'Trip not found' });
return;
}
authReq.trip = trip;
next();
}
/** Middleware: verifies the authenticated user is the trip owner (not just a member). */
function requireTripOwner(req: Request, res: Response, next: NextFunction): void {
const authReq = req as AuthRequest;
const tripId = req.params.tripId || req.params.id;
if (!tripId) {
res.status(400).json({ error: 'Trip ID required' });
return;
}
if (!isOwner(Number(tripId), authReq.user.id)) {
res.status(403).json({ error: 'Only the trip owner can do this' });
return;
}
next();
}
export { requireTripAccess, requireTripOwner };

View File

@@ -0,0 +1,26 @@
import { Request, Response, NextFunction } from 'express';
function maxLength(field: string, max: number) {
return (req: Request, res: Response, next: NextFunction): void => {
if (req.body[field] && typeof req.body[field] === 'string' && req.body[field].length > max) {
res.status(400).json({ error: `${field} must be ${max} characters or less` });
return;
}
next();
};
}
function validateStringLengths(maxLengths: Record<string, number>) {
return (req: Request, res: Response, next: NextFunction): void => {
for (const [field, max] of Object.entries(maxLengths)) {
const value = req.body[field];
if (value && typeof value === 'string' && value.length > max) {
res.status(400).json({ error: `${field} must be ${max} characters or less` });
return;
}
}
next();
};
}
export { maxLength, validateStringLengths };

View File

@@ -1,22 +1,21 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const { execSync } = require('child_process');
const path = require('path');
const { db } = require('../db/database');
const { authenticate, adminOnly } = require('../middleware/auth');
import express, { Request, Response } from 'express';
import bcrypt from 'bcryptjs';
import { execSync } from 'child_process';
import path from 'path';
import fs from 'fs';
import { db } from '../db/database';
import { authenticate, adminOnly } from '../middleware/auth';
import { AuthRequest, User, Addon } from '../types';
const router = express.Router();
// All admin routes require authentication and admin role
router.use(authenticate, adminOnly);
// GET /api/admin/users
router.get('/users', (req, res) => {
router.get('/users', (req: Request, res: Response) => {
const users = db.prepare(
'SELECT id, username, email, role, created_at, updated_at, last_login FROM users ORDER BY created_at DESC'
).all();
// Add online status from WebSocket connections
let onlineUserIds = new Set();
).all() as Pick<User, 'id' | 'username' | 'email' | 'role' | 'created_at' | 'updated_at' | 'last_login'>[];
let onlineUserIds = new Set<number>();
try {
const { getOnlineUserIds } = require('../websocket');
onlineUserIds = getOnlineUserIds();
@@ -25,8 +24,7 @@ router.get('/users', (req, res) => {
res.json({ users: usersWithStatus });
});
// POST /api/admin/users
router.post('/users', (req, res) => {
router.post('/users', (req: Request, res: Response) => {
const { username, email, password, role } = req.body;
if (!username?.trim() || !email?.trim() || !password?.trim()) {
@@ -43,7 +41,7 @@ router.post('/users', (req, res) => {
const existingEmail = db.prepare('SELECT id FROM users WHERE email = ?').get(email.trim());
if (existingEmail) return res.status(409).json({ error: 'Email already taken' });
const passwordHash = bcrypt.hashSync(password.trim(), 10);
const passwordHash = bcrypt.hashSync(password.trim(), 12);
const result = db.prepare(
'INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)'
@@ -56,10 +54,9 @@ router.post('/users', (req, res) => {
res.status(201).json({ user });
});
// PUT /api/admin/users/:id
router.put('/users/:id', (req, res) => {
router.put('/users/:id', (req: Request, res: Response) => {
const { username, email, role, password } = req.body;
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id) as User | undefined;
if (!user) return res.status(404).json({ error: 'User not found' });
@@ -76,7 +73,7 @@ router.put('/users/:id', (req, res) => {
if (conflict) return res.status(409).json({ error: 'Email already taken' });
}
const passwordHash = password ? bcrypt.hashSync(password, 10) : null;
const passwordHash = password ? bcrypt.hashSync(password, 12) : null;
db.prepare(`
UPDATE users SET
@@ -95,9 +92,9 @@ router.put('/users/:id', (req, res) => {
res.json({ user: updated });
});
// DELETE /api/admin/users/:id
router.delete('/users/:id', (req, res) => {
if (parseInt(req.params.id) === req.user.id) {
router.delete('/users/:id', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (parseInt(req.params.id) === authReq.user.id) {
return res.status(400).json({ error: 'Cannot delete own account' });
}
@@ -108,40 +105,37 @@ router.delete('/users/:id', (req, res) => {
res.json({ success: true });
});
// GET /api/admin/stats
router.get('/stats', (req, res) => {
const totalUsers = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
const totalTrips = db.prepare('SELECT COUNT(*) as count FROM trips').get().count;
const totalPlaces = db.prepare('SELECT COUNT(*) as count FROM places').get().count;
const totalFiles = db.prepare('SELECT COUNT(*) as count FROM trip_files').get().count;
router.get('/stats', (_req: Request, res: Response) => {
const totalUsers = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
const totalTrips = (db.prepare('SELECT COUNT(*) as count FROM trips').get() as { count: number }).count;
const totalPlaces = (db.prepare('SELECT COUNT(*) as count FROM places').get() as { count: number }).count;
const totalFiles = (db.prepare('SELECT COUNT(*) as count FROM trip_files').get() as { count: number }).count;
res.json({ totalUsers, totalTrips, totalPlaces, totalFiles });
});
// GET /api/admin/oidc — get OIDC config
router.get('/oidc', (req, res) => {
const get = (key) => db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key)?.value || '';
router.get('/oidc', (_req: Request, res: Response) => {
const get = (key: string) => (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || '';
const secret = get('oidc_client_secret');
res.json({
issuer: get('oidc_issuer'),
client_id: get('oidc_client_id'),
client_secret: get('oidc_client_secret'),
client_secret_set: !!secret,
display_name: get('oidc_display_name'),
});
});
// PUT /api/admin/oidc — update OIDC config
router.put('/oidc', (req, res) => {
router.put('/oidc', (req: Request, res: Response) => {
const { issuer, client_id, client_secret, display_name } = req.body;
const set = (key, val) => db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val || '');
const set = (key: string, val: string) => db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val || '');
set('oidc_issuer', issuer);
set('oidc_client_id', client_id);
set('oidc_client_secret', client_secret);
if (client_secret !== undefined) set('oidc_client_secret', client_secret);
set('oidc_display_name', display_name);
res.json({ success: true });
});
// POST /api/admin/save-demo-baseline (demo mode only)
router.post('/save-demo-baseline', (req, res) => {
router.post('/save-demo-baseline', (_req: Request, res: Response) => {
if (process.env.DEMO_MODE !== 'true') {
return res.status(404).json({ error: 'Not found' });
}
@@ -149,39 +143,19 @@ router.post('/save-demo-baseline', (req, res) => {
const { saveBaseline } = require('../demo/demo-reset');
saveBaseline();
res.json({ success: true, message: 'Demo baseline saved. Hourly resets will restore to this state.' });
} catch (err) {
res.status(500).json({ error: 'Failed to save baseline: ' + err.message });
} catch (err: unknown) {
console.error(err);
res.status(500).json({ error: 'Failed to save baseline' });
}
});
// ── Version check ──────────────────────────────────────────
// Detect if running inside Docker
const isDocker = (() => {
try {
const fs = require('fs');
return fs.existsSync('/.dockerenv') || (fs.existsSync('/proc/1/cgroup') && fs.readFileSync('/proc/1/cgroup', 'utf8').includes('docker'));
} catch { return false }
})();
router.get('/version-check', async (req, res) => {
const { version: currentVersion } = require('../../package.json');
try {
const resp = await fetch(
'https://api.github.com/repos/mauriceboe/NOMAD/releases/latest',
{ headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'NOMAD-Server' } }
);
if (!resp.ok) return res.json({ current: currentVersion, latest: currentVersion, update_available: false });
const data = await resp.json();
const latest = (data.tag_name || '').replace(/^v/, '');
const update_available = latest && latest !== currentVersion && compareVersions(latest, currentVersion) > 0;
res.json({ current: currentVersion, latest, update_available, release_url: data.html_url || '', is_docker: isDocker });
} catch {
res.json({ current: currentVersion, latest: currentVersion, update_available: false, is_docker: isDocker });
}
});
function compareVersions(a, b) {
function compareVersions(a: string, b: string): number {
const pa = a.split('.').map(Number);
const pb = b.split('.').map(Number);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
@@ -192,63 +166,72 @@ function compareVersions(a, b) {
return 0;
}
// POST /api/admin/update — pull latest code, install deps, restart
router.post('/update', async (req, res) => {
router.get('/version-check', async (_req: Request, res: Response) => {
const { version: currentVersion } = require('../../package.json');
try {
const resp = await fetch(
'https://api.github.com/repos/mauriceboe/NOMAD/releases/latest',
{ headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'NOMAD-Server' } }
);
if (!resp.ok) return res.json({ current: currentVersion, latest: currentVersion, update_available: false });
const data = await resp.json() as { tag_name?: string; html_url?: string };
const latest = (data.tag_name || '').replace(/^v/, '');
const update_available = latest && latest !== currentVersion && compareVersions(latest, currentVersion) > 0;
res.json({ current: currentVersion, latest, update_available, release_url: data.html_url || '', is_docker: isDocker });
} catch {
res.json({ current: currentVersion, latest: currentVersion, update_available: false, is_docker: isDocker });
}
});
router.post('/update', async (_req: Request, res: Response) => {
const rootDir = path.resolve(__dirname, '../../..');
const serverDir = path.resolve(__dirname, '../..');
const clientDir = path.join(rootDir, 'client');
const steps = [];
const steps: { step: string; success?: boolean; output?: string; version?: string }[] = [];
try {
// 1. git pull
const pullOutput = execSync('git pull origin main', { cwd: rootDir, timeout: 60000, encoding: 'utf8' });
steps.push({ step: 'git pull', success: true, output: pullOutput.trim() });
// 2. npm install server
execSync('npm install --production', { cwd: serverDir, timeout: 120000, encoding: 'utf8' });
execSync('npm install --production --ignore-scripts', { cwd: serverDir, timeout: 120000, encoding: 'utf8' });
steps.push({ step: 'npm install (server)', success: true });
// 3. npm install + build client (production only)
if (process.env.NODE_ENV === 'production') {
execSync('npm install', { cwd: clientDir, timeout: 120000, encoding: 'utf8' });
execSync('npm install --ignore-scripts', { cwd: clientDir, timeout: 120000, encoding: 'utf8' });
execSync('npm run build', { cwd: clientDir, timeout: 120000, encoding: 'utf8' });
steps.push({ step: 'npm install + build (client)', success: true });
}
// Read new version
delete require.cache[require.resolve('../../package.json')];
const { version: newVersion } = require('../../package.json');
steps.push({ step: 'version', version: newVersion });
// 4. Send response before restart
res.json({ success: true, steps, restarting: true });
// 5. Graceful restart — exit and let process manager (Docker/systemd/pm2) restart
setTimeout(() => {
console.log('[Update] Restarting after update...');
process.exit(0);
}, 1000);
} catch (err) {
steps.push({ step: 'error', success: false, output: err.message });
} catch (err: unknown) {
console.error(err);
steps.push({ step: 'error', success: false, output: 'Internal error' });
res.status(500).json({ success: false, steps });
}
});
// ── Addons ─────────────────────────────────────────────────
router.get('/addons', (req, res) => {
const addons = db.prepare('SELECT * FROM addons ORDER BY sort_order, id').all();
router.get('/addons', (_req: Request, res: Response) => {
const addons = db.prepare('SELECT * FROM addons ORDER BY sort_order, id').all() as Addon[];
res.json({ addons: addons.map(a => ({ ...a, enabled: !!a.enabled, config: JSON.parse(a.config || '{}') })) });
});
router.put('/addons/:id', (req, res) => {
router.put('/addons/:id', (req: Request, res: Response) => {
const addon = db.prepare('SELECT * FROM addons WHERE id = ?').get(req.params.id);
if (!addon) return res.status(404).json({ error: 'Addon not found' });
const { enabled, config } = req.body;
if (enabled !== undefined) db.prepare('UPDATE addons SET enabled = ? WHERE id = ?').run(enabled ? 1 : 0, req.params.id);
if (config !== undefined) db.prepare('UPDATE addons SET config = ? WHERE id = ?').run(JSON.stringify(config), req.params.id);
const updated = db.prepare('SELECT * FROM addons WHERE id = ?').get(req.params.id);
const updated = db.prepare('SELECT * FROM addons WHERE id = ?').get(req.params.id) as Addon;
res.json({ addon: { ...updated, enabled: !!updated.enabled, config: JSON.parse(updated.config || '{}') } });
});
module.exports = router;
export default router;

View File

@@ -1,15 +1,14 @@
const express = require('express');
const { db, canAccessTrip } = require('../db/database');
const { authenticate } = require('../middleware/auth');
const { broadcast } = require('../websocket');
import express, { Request, Response } from 'express';
import { db } from '../db/database';
import { authenticate } from '../middleware/auth';
import { requireTripAccess } from '../middleware/tripAccess';
import { broadcast } from '../websocket';
import { loadTagsByPlaceIds, loadParticipantsByAssignmentIds, formatAssignmentWithPlace } from '../services/queryHelpers';
import { AuthRequest, AssignmentRow, DayAssignment, Tag, Participant } from '../types';
const router = express.Router({ mergeParams: true });
function verifyTripOwnership(tripId, userId) {
return canAccessTrip(tripId, userId);
}
function getAssignmentWithPlace(assignmentId) {
function getAssignmentWithPlace(assignmentId: number | bigint) {
const a = db.prepare(`
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
@@ -22,7 +21,7 @@ function getAssignmentWithPlace(assignmentId) {
JOIN places p ON da.place_id = p.id
LEFT JOIN categories c ON p.category_id = c.id
WHERE da.id = ?
`).get(assignmentId);
`).get(assignmentId) as AssignmentRow | undefined;
if (!a) return null;
@@ -76,13 +75,9 @@ function getAssignmentWithPlace(assignmentId) {
};
}
// GET /api/trips/:tripId/days/:dayId/assignments
router.get('/trips/:tripId/days/:dayId/assignments', authenticate, (req, res) => {
router.get('/trips/:tripId/days/:dayId/assignments', authenticate, requireTripAccess, (req: Request, res: Response) => {
const { tripId, dayId } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
if (!day) return res.status(404).json({ error: 'Day not found' });
@@ -99,91 +94,32 @@ router.get('/trips/:tripId/days/:dayId/assignments', authenticate, (req, res) =>
LEFT JOIN categories c ON p.category_id = c.id
WHERE da.day_id = ?
ORDER BY da.order_index ASC, da.created_at ASC
`).all(dayId);
`).all(dayId) as AssignmentRow[];
// Batch-load all tags for all places in one query to avoid N+1
const placeIds = [...new Set(assignments.map(a => a.place_id))];
const tagsByPlaceId = {};
if (placeIds.length > 0) {
const placeholders = placeIds.map(() => '?').join(',');
const allTags = db.prepare(`
SELECT t.*, pt.place_id FROM tags t
JOIN place_tags pt ON t.id = pt.tag_id
WHERE pt.place_id IN (${placeholders})
`).all(...placeIds);
for (const tag of allTags) {
if (!tagsByPlaceId[tag.place_id]) tagsByPlaceId[tag.place_id] = [];
tagsByPlaceId[tag.place_id].push({ id: tag.id, name: tag.name, color: tag.color, created_at: tag.created_at });
}
}
const tagsByPlaceId = loadTagsByPlaceIds(placeIds, { compact: true });
// Load all participants for this day's assignments in one query
const assignmentIds = assignments.map(a => a.id)
const allParticipants = assignmentIds.length > 0
? db.prepare(`SELECT ap.assignment_id, ap.user_id, u.username, u.avatar FROM assignment_participants ap JOIN users u ON ap.user_id = u.id WHERE ap.assignment_id IN (${assignmentIds.map(() => '?').join(',')})`)
.all(...assignmentIds)
: []
const participantsByAssignment = {}
for (const p of allParticipants) {
if (!participantsByAssignment[p.assignment_id]) participantsByAssignment[p.assignment_id] = []
participantsByAssignment[p.assignment_id].push({ user_id: p.user_id, username: p.username, avatar: p.avatar })
}
const assignmentIds = assignments.map(a => a.id);
const participantsByAssignment = loadParticipantsByAssignmentIds(assignmentIds);
const result = assignments.map(a => {
return {
id: a.id,
day_id: a.day_id,
order_index: a.order_index,
notes: a.notes,
participants: participantsByAssignment[a.id] || [],
created_at: a.created_at,
place: {
id: a.place_id,
name: a.place_name,
description: a.place_description,
lat: a.lat,
lng: a.lng,
address: a.address,
category_id: a.category_id,
price: a.price,
currency: a.place_currency,
place_time: a.place_time,
duration_minutes: a.duration_minutes,
notes: a.place_notes,
image_url: a.image_url,
transport_mode: a.transport_mode,
google_place_id: a.google_place_id,
website: a.website,
phone: a.phone,
category: a.category_id ? {
id: a.category_id,
name: a.category_name,
color: a.category_color,
icon: a.category_icon,
} : null,
tags: tagsByPlaceId[a.place_id] || [],
}
};
return formatAssignmentWithPlace(a, tagsByPlaceId[a.place_id] || [], participantsByAssignment[a.id] || []);
});
res.json({ assignments: result });
});
// POST /api/trips/:tripId/days/:dayId/assignments
router.post('/trips/:tripId/days/:dayId/assignments', authenticate, (req, res) => {
router.post('/trips/:tripId/days/:dayId/assignments', authenticate, requireTripAccess, (req: Request, res: Response) => {
const { tripId, dayId } = req.params;
const { place_id, notes } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
if (!day) return res.status(404).json({ error: 'Day not found' });
const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(place_id, tripId);
if (!place) return res.status(404).json({ error: 'Place not found' });
const maxOrder = db.prepare('SELECT MAX(order_index) as max FROM day_assignments WHERE day_id = ?').get(dayId);
const maxOrder = db.prepare('SELECT MAX(order_index) as max FROM day_assignments WHERE day_id = ?').get(dayId) as { max: number | null };
const orderIndex = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
const result = db.prepare(
@@ -192,16 +128,12 @@ router.post('/trips/:tripId/days/:dayId/assignments', authenticate, (req, res) =
const assignment = getAssignmentWithPlace(result.lastInsertRowid);
res.status(201).json({ assignment });
broadcast(tripId, 'assignment:created', { assignment }, req.headers['x-socket-id']);
broadcast(tripId, 'assignment:created', { assignment }, req.headers['x-socket-id'] as string);
});
// DELETE /api/trips/:tripId/days/:dayId/assignments/:id
router.delete('/trips/:tripId/days/:dayId/assignments/:id', authenticate, (req, res) => {
router.delete('/trips/:tripId/days/:dayId/assignments/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
const { tripId, dayId, id } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const assignment = db.prepare(
'SELECT da.id FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE da.id = ? AND da.day_id = ? AND d.trip_id = ?'
).get(id, dayId, tripId);
@@ -210,24 +142,20 @@ router.delete('/trips/:tripId/days/:dayId/assignments/:id', authenticate, (req,
db.prepare('DELETE FROM day_assignments WHERE id = ?').run(id);
res.json({ success: true });
broadcast(tripId, 'assignment:deleted', { assignmentId: Number(id), dayId: Number(dayId) }, req.headers['x-socket-id']);
broadcast(tripId, 'assignment:deleted', { assignmentId: Number(id), dayId: Number(dayId) }, req.headers['x-socket-id'] as string);
});
// PUT /api/trips/:tripId/days/:dayId/assignments/reorder
router.put('/trips/:tripId/days/:dayId/assignments/reorder', authenticate, (req, res) => {
router.put('/trips/:tripId/days/:dayId/assignments/reorder', authenticate, requireTripAccess, (req: Request, res: Response) => {
const { tripId, dayId } = req.params;
const { orderedIds } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
if (!day) return res.status(404).json({ error: 'Day not found' });
const update = db.prepare('UPDATE day_assignments SET order_index = ? WHERE id = ? AND day_id = ?');
db.exec('BEGIN');
try {
orderedIds.forEach((id, index) => {
orderedIds.forEach((id: number, index: number) => {
update.run(index, id, dayId);
});
db.exec('COMMIT');
@@ -236,22 +164,18 @@ router.put('/trips/:tripId/days/:dayId/assignments/reorder', authenticate, (req,
throw e;
}
res.json({ success: true });
broadcast(tripId, 'assignment:reordered', { dayId: Number(dayId), orderedIds }, req.headers['x-socket-id']);
broadcast(tripId, 'assignment:reordered', { dayId: Number(dayId), orderedIds }, req.headers['x-socket-id'] as string);
});
// PUT /api/trips/:tripId/assignments/:id/move
router.put('/trips/:tripId/assignments/:id/move', authenticate, (req, res) => {
router.put('/trips/:tripId/assignments/:id/move', authenticate, requireTripAccess, (req: Request, res: Response) => {
const { tripId, id } = req.params;
const { new_day_id, order_index } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const assignment = db.prepare(`
SELECT da.* FROM day_assignments da
JOIN days d ON da.day_id = d.id
WHERE da.id = ? AND d.trip_id = ?
`).get(id, tripId);
`).get(id, tripId) as DayAssignment | undefined;
if (!assignment) return res.status(404).json({ error: 'Assignment not found' });
@@ -261,15 +185,13 @@ router.put('/trips/:tripId/assignments/:id/move', authenticate, (req, res) => {
const oldDayId = assignment.day_id;
db.prepare('UPDATE day_assignments SET day_id = ?, order_index = ? WHERE id = ?').run(new_day_id, order_index || 0, id);
const updated = getAssignmentWithPlace(id);
const updated = getAssignmentWithPlace(Number(id));
res.json({ assignment: updated });
broadcast(tripId, 'assignment:moved', { assignment: updated, oldDayId: Number(oldDayId), newDayId: Number(new_day_id) }, req.headers['x-socket-id']);
broadcast(tripId, 'assignment:moved', { assignment: updated, oldDayId: Number(oldDayId), newDayId: Number(new_day_id) }, req.headers['x-socket-id'] as string);
});
// GET /api/trips/:tripId/assignments/:id/participants
router.get('/trips/:tripId/assignments/:id/participants', authenticate, (req, res) => {
router.get('/trips/:tripId/assignments/:id/participants', authenticate, requireTripAccess, (req: Request, res: Response) => {
const { tripId, id } = req.params;
if (!canAccessTrip(Number(tripId), req.user.id)) return res.status(404).json({ error: 'Trip not found' });
const participants = db.prepare(`
SELECT ap.user_id, u.username, u.avatar
@@ -281,10 +203,8 @@ router.get('/trips/:tripId/assignments/:id/participants', authenticate, (req, re
res.json({ participants });
});
// PUT /api/trips/:tripId/assignments/:id/time — update per-assignment time
router.put('/trips/:tripId/assignments/:id/time', authenticate, (req, res) => {
router.put('/trips/:tripId/assignments/:id/time', authenticate, requireTripAccess, (req: Request, res: Response) => {
const { tripId, id } = req.params;
if (!canAccessTrip(Number(tripId), req.user.id)) return res.status(404).json({ error: 'Trip not found' });
const assignment = db.prepare(`
SELECT da.* FROM day_assignments da
@@ -297,20 +217,17 @@ router.put('/trips/:tripId/assignments/:id/time', authenticate, (req, res) => {
db.prepare('UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ? WHERE id = ?')
.run(place_time ?? null, end_time ?? null, id);
const updated = getAssignmentWithPlace(id);
const updated = getAssignmentWithPlace(Number(id));
res.json({ assignment: updated });
broadcast(Number(tripId), 'assignment:updated', { assignment: updated }, req.headers['x-socket-id']);
broadcast(Number(tripId), 'assignment:updated', { assignment: updated }, req.headers['x-socket-id'] as string);
});
// PUT /api/trips/:tripId/assignments/:id/participants — set participants (replace all)
router.put('/trips/:tripId/assignments/:id/participants', authenticate, (req, res) => {
router.put('/trips/:tripId/assignments/:id/participants', authenticate, requireTripAccess, (req: Request, res: Response) => {
const { tripId, id } = req.params;
if (!canAccessTrip(Number(tripId), req.user.id)) return res.status(404).json({ error: 'Trip not found' });
const { user_ids } = req.body; // array of user IDs, empty array = everyone
const { user_ids } = req.body;
if (!Array.isArray(user_ids)) return res.status(400).json({ error: 'user_ids must be an array' });
// Delete existing and insert new
db.prepare('DELETE FROM assignment_participants WHERE assignment_id = ?').run(id);
if (user_ids.length > 0) {
const insert = db.prepare('INSERT OR IGNORE INTO assignment_participants (assignment_id, user_id) VALUES (?, ?)');
@@ -325,7 +242,7 @@ router.put('/trips/:tripId/assignments/:id/participants', authenticate, (req, re
`).all(id);
res.json({ participants });
broadcast(Number(tripId), 'assignment:participants', { assignmentId: Number(id), participants }, req.headers['x-socket-id']);
broadcast(Number(tripId), 'assignment:participants', { assignmentId: Number(id), participants }, req.headers['x-socket-id'] as string);
});
module.exports = router;
export default router;

View File

@@ -1,13 +1,12 @@
const express = require('express');
const { db } = require('../db/database');
const { authenticate } = require('../middleware/auth');
import express, { Request, Response } from 'express';
import { db } from '../db/database';
import { authenticate } from '../middleware/auth';
import { AuthRequest, Trip, Place } from '../types';
const router = express.Router();
router.use(authenticate);
// Country code lookup from coordinates (bounding box approach)
// Covers most countries — not pixel-perfect but good enough for visited-country tracking
const COUNTRY_BOXES = {
const COUNTRY_BOXES: Record<string, [number, number, number, number]> = {
AF:[60.5,29.4,75,38.5],AL:[19,39.6,21.1,42.7],DZ:[-8.7,19,12,37.1],AD:[1.4,42.4,1.8,42.7],AO:[11.7,-18.1,24.1,-4.4],
AR:[-73.6,-55.1,-53.6,-21.8],AM:[43.4,38.8,46.6,41.3],AU:[112.9,-43.6,153.6,-10.7],AT:[9.5,46.4,17.2,49],AZ:[44.8,38.4,50.4,41.9],
BR:[-73.9,-33.8,-34.8,5.3],BE:[2.5,49.5,6.4,51.5],BG:[22.4,41.2,28.6,44.2],CA:[-141,41.7,-52.6,83.1],CL:[-75.6,-55.9,-66.9,-17.5],
@@ -24,7 +23,7 @@ const COUNTRY_BOXES = {
AE:[51.6,22.6,56.4,26.1],GB:[-8,49.9,2,60.9],US:[-125,24.5,-66.9,49.4],VN:[102.1,8.6,109.5,23.4],
};
function getCountryFromCoords(lat, lng) {
function getCountryFromCoords(lat: number, lng: number): string | null {
for (const [code, [minLng, minLat, maxLng, maxLat]] of Object.entries(COUNTRY_BOXES)) {
if (lat >= minLat && lat <= maxLat && lng >= minLng && lng <= maxLng) {
return code;
@@ -33,68 +32,72 @@ function getCountryFromCoords(lat, lng) {
return null;
}
function getCountryFromAddress(address) {
const NAME_TO_CODE: Record<string, string> = {
'germany':'DE','deutschland':'DE','france':'FR','frankreich':'FR','spain':'ES','spanien':'ES',
'italy':'IT','italien':'IT','united kingdom':'GB','uk':'GB','england':'GB','united states':'US',
'usa':'US','netherlands':'NL','niederlande':'NL','austria':'AT','osterreich':'AT','switzerland':'CH',
'schweiz':'CH','portugal':'PT','greece':'GR','griechenland':'GR','turkey':'TR','turkei':'TR',
'croatia':'HR','kroatien':'HR','czech republic':'CZ','tschechien':'CZ','czechia':'CZ',
'poland':'PL','polen':'PL','sweden':'SE','schweden':'SE','norway':'NO','norwegen':'NO',
'denmark':'DK','danemark':'DK','finland':'FI','finnland':'FI','belgium':'BE','belgien':'BE',
'ireland':'IE','irland':'IE','hungary':'HU','ungarn':'HU','romania':'RO','rumanien':'RO',
'bulgaria':'BG','bulgarien':'BG','japan':'JP','china':'CN','australia':'AU','australien':'AU',
'canada':'CA','kanada':'CA','mexico':'MX','mexiko':'MX','brazil':'BR','brasilien':'BR',
'argentina':'AR','argentinien':'AR','thailand':'TH','indonesia':'ID','indonesien':'ID',
'india':'IN','indien':'IN','egypt':'EG','agypten':'EG','morocco':'MA','marokko':'MA',
'south africa':'ZA','sudafrika':'ZA','new zealand':'NZ','neuseeland':'NZ','iceland':'IS','island':'IS',
'luxembourg':'LU','luxemburg':'LU','slovenia':'SI','slowenien':'SI','slovakia':'SK','slowakei':'SK',
'estonia':'EE','estland':'EE','latvia':'LV','lettland':'LV','lithuania':'LT','litauen':'LT',
'serbia':'RS','serbien':'RS','israel':'IL','russia':'RU','russland':'RU','ukraine':'UA',
'vietnam':'VN','south korea':'KR','sudkorea':'KR','philippines':'PH','philippinen':'PH',
'malaysia':'MY','colombia':'CO','kolumbien':'CO','peru':'PE','chile':'CL','iran':'IR',
'iraq':'IQ','irak':'IQ','pakistan':'PK','kenya':'KE','kenia':'KE','nigeria':'NG',
'saudi arabia':'SA','saudi-arabien':'SA','albania':'AL','albanien':'AL',
};
function getCountryFromAddress(address: string | null): string | null {
if (!address) return null;
// Take last segment after comma, trim
const parts = address.split(',').map(s => s.trim()).filter(Boolean);
if (parts.length === 0) return null;
const last = parts[parts.length - 1];
// Try to match known country names to codes
const NAME_TO_CODE = {
'germany':'DE','deutschland':'DE','france':'FR','frankreich':'FR','spain':'ES','spanien':'ES',
'italy':'IT','italien':'IT','united kingdom':'GB','uk':'GB','england':'GB','united states':'US',
'usa':'US','netherlands':'NL','niederlande':'NL','austria':'AT','österreich':'AT','switzerland':'CH',
'schweiz':'CH','portugal':'PT','greece':'GR','griechenland':'GR','turkey':'TR','türkei':'TR',
'croatia':'HR','kroatien':'HR','czech republic':'CZ','tschechien':'CZ','czechia':'CZ',
'poland':'PL','polen':'PL','sweden':'SE','schweden':'SE','norway':'NO','norwegen':'NO',
'denmark':'DK','dänemark':'DK','finland':'FI','finnland':'FI','belgium':'BE','belgien':'BE',
'ireland':'IE','irland':'IE','hungary':'HU','ungarn':'HU','romania':'RO','rumänien':'RO',
'bulgaria':'BG','bulgarien':'BG','japan':'JP','china':'CN','australia':'AU','australien':'AU',
'canada':'CA','kanada':'CA','mexico':'MX','mexiko':'MX','brazil':'BR','brasilien':'BR',
'argentina':'AR','argentinien':'AR','thailand':'TH','indonesia':'ID','indonesien':'ID',
'india':'IN','indien':'IN','egypt':'EG','ägypten':'EG','morocco':'MA','marokko':'MA',
'south africa':'ZA','südafrika':'ZA','new zealand':'NZ','neuseeland':'NZ','iceland':'IS','island':'IS',
'luxembourg':'LU','luxemburg':'LU','slovenia':'SI','slowenien':'SI','slovakia':'SK','slowakei':'SK',
'estonia':'EE','estland':'EE','latvia':'LV','lettland':'LV','lithuania':'LT','litauen':'LT',
'serbia':'RS','serbien':'RS','israel':'IL','russia':'RU','russland':'RU','ukraine':'UA',
'vietnam':'VN','south korea':'KR','südkorea':'KR','philippines':'PH','philippinen':'PH',
'malaysia':'MY','colombia':'CO','kolumbien':'CO','peru':'PE','chile':'CL','iran':'IR',
'iraq':'IQ','irak':'IQ','pakistan':'PK','kenya':'KE','kenia':'KE','nigeria':'NG',
'saudi arabia':'SA','saudi-arabien':'SA','albania':'AL','albanien':'AL',
'日本':'JP','中国':'CN','한국':'KR','대한민국':'KR','ไทย':'TH',
};
const normalized = last.toLowerCase();
if (NAME_TO_CODE[normalized]) return NAME_TO_CODE[normalized];
// Try original case (for non-Latin scripts like 日本)
if (NAME_TO_CODE[last]) return NAME_TO_CODE[last];
// Try 2-letter code directly
if (last.length === 2 && last === last.toUpperCase()) return last;
return null;
}
// GET /api/addons/atlas/stats
router.get('/stats', (req, res) => {
const userId = req.user.id;
const CONTINENT_MAP: Record<string, string> = {
AF:'Africa',AL:'Europe',DZ:'Africa',AD:'Europe',AO:'Africa',AR:'South America',AM:'Asia',AU:'Oceania',AT:'Europe',AZ:'Asia',
BR:'South America',BE:'Europe',BG:'Europe',CA:'North America',CL:'South America',CN:'Asia',CO:'South America',HR:'Europe',CZ:'Europe',DK:'Europe',
EG:'Africa',EE:'Europe',FI:'Europe',FR:'Europe',DE:'Europe',GR:'Europe',HU:'Europe',IS:'Europe',IN:'Asia',ID:'Asia',
IR:'Asia',IQ:'Asia',IE:'Europe',IL:'Asia',IT:'Europe',JP:'Asia',KE:'Africa',KR:'Asia',LV:'Europe',LT:'Europe',
LU:'Europe',MY:'Asia',MX:'North America',MA:'Africa',NL:'Europe',NZ:'Oceania',NO:'Europe',PK:'Asia',PE:'South America',PH:'Asia',
PL:'Europe',PT:'Europe',RO:'Europe',RU:'Europe',SA:'Asia',RS:'Europe',SK:'Europe',SI:'Europe',ZA:'Africa',ES:'Europe',
SE:'Europe',CH:'Europe',TH:'Asia',TR:'Europe',UA:'Europe',AE:'Asia',GB:'Europe',US:'North America',VN:'Asia',NG:'Africa',
};
router.get('/stats', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const userId = authReq.user.id;
// Get all trips (own + shared)
const trips = db.prepare(`
SELECT DISTINCT t.* FROM trips t
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
WHERE t.user_id = ? OR m.user_id = ?
ORDER BY t.start_date DESC
`).all(userId, userId, userId);
`).all(userId, userId, userId) as Trip[];
// Get all places from those trips
const tripIds = trips.map(t => t.id);
if (tripIds.length === 0) {
return res.json({ countries: [], trips: [], stats: { totalTrips: 0, totalPlaces: 0, totalCountries: 0, totalDays: 0 } });
}
const placeholders = tripIds.map(() => '?').join(',');
const places = db.prepare(`SELECT * FROM places WHERE trip_id IN (${placeholders})`).all(...tripIds);
const places = db.prepare(`SELECT * FROM places WHERE trip_id IN (${placeholders})`).all(...tripIds) as Place[];
// Extract countries
const countrySet = new Map(); // code -> { code, places: [], trips: Set }
interface CountryEntry { code: string; places: { id: number; name: string; lat: number | null; lng: number | null }[]; tripIds: Set<number> }
const countrySet = new Map<string, CountryEntry>();
for (const place of places) {
let code = getCountryFromAddress(place.address);
if (!code && place.lat && place.lng) {
@@ -104,18 +107,17 @@ router.get('/stats', (req, res) => {
if (!countrySet.has(code)) {
countrySet.set(code, { code, places: [], tripIds: new Set() });
}
countrySet.get(code).places.push({ id: place.id, name: place.name, lat: place.lat, lng: place.lng });
countrySet.get(code).tripIds.add(place.trip_id);
countrySet.get(code)!.places.push({ id: place.id, name: place.name, lat: place.lat, lng: place.lng });
countrySet.get(code)!.tripIds.add(place.trip_id);
}
}
// Calculate total days across all trips
let totalDays = 0;
for (const trip of trips) {
if (trip.start_date && trip.end_date) {
const start = new Date(trip.start_date);
const end = new Date(trip.end_date);
const diff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1;
const diff = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) + 1;
if (diff > 0) totalDays += diff;
}
}
@@ -132,45 +134,30 @@ router.get('/stats', (req, res) => {
};
});
// Unique cities (extract city from address — second to last comma segment)
// Strip postal codes and normalize to avoid duplicates like "Tokyo" vs "Tokyo 131-0045"
const citySet = new Set();
const citySet = new Set<string>();
for (const place of places) {
if (place.address) {
const parts = place.address.split(',').map(s => s.trim()).filter(Boolean);
const parts = place.address.split(',').map((s: string) => s.trim()).filter(Boolean);
let raw = parts.length >= 2 ? parts[parts.length - 2] : parts[0];
if (raw) {
const city = raw.replace(/[\d\-−〒]+/g, '').trim().toLowerCase();
const city = raw.replace(/[\d\-\u2212\u3012]+/g, '').trim().toLowerCase();
if (city) citySet.add(city);
}
}
}
const totalCities = citySet.size;
// Most visited country
const mostVisited = countries.length > 0 ? countries.reduce((a, b) => a.placeCount > b.placeCount ? a : b) : null;
// Continent breakdown
const CONTINENT_MAP = {
AF:'Africa',AL:'Europe',DZ:'Africa',AD:'Europe',AO:'Africa',AR:'South America',AM:'Asia',AU:'Oceania',AT:'Europe',AZ:'Asia',
BR:'South America',BE:'Europe',BG:'Europe',CA:'North America',CL:'South America',CN:'Asia',CO:'South America',HR:'Europe',CZ:'Europe',DK:'Europe',
EG:'Africa',EE:'Europe',FI:'Europe',FR:'Europe',DE:'Europe',GR:'Europe',HU:'Europe',IS:'Europe',IN:'Asia',ID:'Asia',
IR:'Asia',IQ:'Asia',IE:'Europe',IL:'Asia',IT:'Europe',JP:'Asia',KE:'Africa',KR:'Asia',LV:'Europe',LT:'Europe',
LU:'Europe',MY:'Asia',MX:'North America',MA:'Africa',NL:'Europe',NZ:'Oceania',NO:'Europe',PK:'Asia',PE:'South America',PH:'Asia',
PL:'Europe',PT:'Europe',RO:'Europe',RU:'Europe',SA:'Asia',RS:'Europe',SK:'Europe',SI:'Europe',ZA:'Africa',ES:'Europe',
SE:'Europe',CH:'Europe',TH:'Asia',TR:'Europe',UA:'Europe',AE:'Asia',GB:'Europe',US:'North America',VN:'Asia',NG:'Africa',
};
const continents = {};
const continents: Record<string, number> = {};
countries.forEach(c => {
const cont = CONTINENT_MAP[c.code] || 'Other';
continents[cont] = (continents[cont] || 0) + 1;
});
// Last trip (most recent past trip)
const now = new Date().toISOString().split('T')[0];
const pastTrips = trips.filter(t => t.end_date && t.end_date <= now).sort((a, b) => b.end_date.localeCompare(a.end_date));
const lastTrip = pastTrips[0] ? { id: pastTrips[0].id, title: pastTrips[0].title, start_date: pastTrips[0].start_date, end_date: pastTrips[0].end_date } : null;
// Find country for last trip
const lastTrip: { id: number; title: string; start_date?: string | null; end_date?: string | null; countryCode?: string } | null = pastTrips[0] ? { id: pastTrips[0].id, title: pastTrips[0].title, start_date: pastTrips[0].start_date, end_date: pastTrips[0].end_date } : null;
if (lastTrip) {
const lastTripPlaces = places.filter(p => p.trip_id === lastTrip.id);
for (const p of lastTripPlaces) {
@@ -180,15 +167,13 @@ router.get('/stats', (req, res) => {
}
}
// Next trip (earliest future trip)
const futureTrips = trips.filter(t => t.start_date && t.start_date > now).sort((a, b) => a.start_date.localeCompare(b.start_date));
const nextTrip = futureTrips[0] ? { id: futureTrips[0].id, title: futureTrips[0].title, start_date: futureTrips[0].start_date } : null;
const nextTrip: { id: number; title: string; start_date?: string | null; daysUntil?: number } | null = futureTrips[0] ? { id: futureTrips[0].id, title: futureTrips[0].title, start_date: futureTrips[0].start_date } : null;
if (nextTrip) {
const diff = Math.ceil((new Date(nextTrip.start_date) - new Date()) / (1000 * 60 * 60 * 24));
const diff = Math.ceil((new Date(nextTrip.start_date).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24));
nextTrip.daysUntil = Math.max(0, diff);
}
// Travel streak (consecutive years with at least one trip)
const tripYears = new Set(trips.filter(t => t.start_date).map(t => parseInt(t.start_date.split('-')[0])));
let streak = 0;
const currentYear = new Date().getFullYear();
@@ -217,25 +202,25 @@ router.get('/stats', (req, res) => {
});
});
// GET /api/addons/atlas/country/:code — details for a country
router.get('/country/:code', (req, res) => {
const userId = req.user.id;
router.get('/country/:code', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const userId = authReq.user.id;
const code = req.params.code.toUpperCase();
const trips = db.prepare(`
SELECT DISTINCT t.* FROM trips t
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
WHERE t.user_id = ? OR m.user_id = ?
`).all(userId, userId, userId);
`).all(userId, userId, userId) as Trip[];
const tripIds = trips.map(t => t.id);
if (tripIds.length === 0) return res.json({ places: [], trips: [] });
const placeholders = tripIds.map(() => '?').join(',');
const places = db.prepare(`SELECT * FROM places WHERE trip_id IN (${placeholders})`).all(...tripIds);
const places = db.prepare(`SELECT * FROM places WHERE trip_id IN (${placeholders})`).all(...tripIds) as Place[];
const matchingPlaces = [];
const matchingTripIds = new Set();
const matchingPlaces: { id: number; name: string; address: string | null; lat: number | null; lng: number | null; trip_id: number }[] = [];
const matchingTripIds = new Set<number>();
for (const place of places) {
let pCode = getCountryFromAddress(place.address);
@@ -251,4 +236,4 @@ router.get('/country/:code', (req, res) => {
res.json({ places: matchingPlaces, trips: matchingTrips });
});
module.exports = router;
export default router;

View File

@@ -1,26 +1,28 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { v4: uuid } = require('uuid');
const fetch = require('node-fetch');
const { db } = require('../db/database');
const { authenticate, demoUploadBlock } = require('../middleware/auth');
import express, { Request, Response, NextFunction } from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { v4 as uuid } from 'uuid';
import fetch from 'node-fetch';
import { db } from '../db/database';
import { authenticate, demoUploadBlock } from '../middleware/auth';
import { JWT_SECRET } from '../config';
import { AuthRequest, User } from '../types';
const router = express.Router();
const { JWT_SECRET } = require('../config');
const avatarDir = path.join(__dirname, '../../uploads/avatars');
if (!fs.existsSync(avatarDir)) fs.mkdirSync(avatarDir, { recursive: true });
const avatarStorage = multer.diskStorage({
destination: (req, file, cb) => cb(null, avatarDir),
filename: (req, file, cb) => cb(null, uuid() + path.extname(file.originalname))
destination: (_req, _file, cb) => cb(null, avatarDir),
filename: (_req, file, cb) => cb(null, uuid() + path.extname(file.originalname))
});
const ALLOWED_AVATAR_EXTS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
const avatarUpload = multer({ storage: avatarStorage, limits: { fileSize: 5 * 1024 * 1024 }, fileFilter: (req, file, cb) => {
const MAX_AVATAR_SIZE = 5 * 1024 * 1024; // 5 MB
const avatarUpload = multer({ storage: avatarStorage, limits: { fileSize: MAX_AVATAR_SIZE }, fileFilter: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
if (!file.mimetype.startsWith('image/') || !ALLOWED_AVATAR_EXTS.includes(ext)) {
return cb(new Error('Only .jpg, .jpeg, .png, .gif, .webp images are allowed'));
@@ -28,11 +30,20 @@ const avatarUpload = multer({ storage: avatarStorage, limits: { fileSize: 5 * 10
cb(null, true);
}});
// Simple rate limiter
const loginAttempts = new Map();
function rateLimiter(maxAttempts, windowMs) {
return (req, res, next) => {
const key = req.ip;
const RATE_LIMIT_WINDOW = 15 * 60 * 1000; // 15 minutes
const RATE_LIMIT_CLEANUP = 5 * 60 * 1000; // 5 minutes
const loginAttempts = new Map<string, { count: number; first: number }>();
setInterval(() => {
const now = Date.now();
for (const [key, record] of loginAttempts) {
if (now - record.first >= RATE_LIMIT_WINDOW) loginAttempts.delete(key);
}
}, RATE_LIMIT_CLEANUP);
function rateLimiter(maxAttempts: number, windowMs: number) {
return (req: Request, res: Response, next: NextFunction) => {
const key = req.ip || 'unknown';
const now = Date.now();
const record = loginAttempts.get(key);
if (record && record.count >= maxAttempts && now - record.first < windowMs) {
@@ -46,32 +57,37 @@ function rateLimiter(maxAttempts, windowMs) {
next();
};
}
const authLimiter = rateLimiter(10, 15 * 60 * 1000); // 10 attempts per 15 minutes
const authLimiter = rateLimiter(10, RATE_LIMIT_WINDOW);
function avatarUrl(user) {
function maskKey(key: string | null | undefined): string | null {
if (!key) return null;
if (key.length <= 8) return '--------';
return '----' + key.slice(-4);
}
function avatarUrl(user: { avatar?: string | null }): string | null {
return user.avatar ? `/uploads/avatars/${user.avatar}` : null;
}
function generateToken(user) {
function generateToken(user: { id: number | bigint }) {
return jwt.sign(
{ id: user.id, username: user.username, email: user.email, role: user.role },
{ id: user.id },
JWT_SECRET,
{ expiresIn: '24h' }
);
}
// GET /api/auth/app-config (public — no auth needed)
router.get('/app-config', (req, res) => {
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get();
router.get('/app-config', (_req: Request, res: Response) => {
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get() as { value: string } | undefined;
const allowRegistration = userCount === 0 || (setting?.value ?? 'true') === 'true';
const isDemo = process.env.DEMO_MODE === 'true';
const { version } = require('../../package.json');
const hasGoogleKey = !!db.prepare("SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1").get();
const oidcDisplayName = db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_display_name'").get()?.value || null;
const oidcDisplayName = (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_display_name'").get() as { value: string } | undefined)?.value || null;
const oidcConfigured = !!(
db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_issuer'").get()?.value &&
db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_client_id'").get()?.value
(db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_issuer'").get() as { value: string } | undefined)?.value &&
(db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_client_id'").get() as { value: string } | undefined)?.value
);
res.json({
allow_registration: isDemo ? false : allowRegistration,
@@ -80,33 +96,30 @@ router.get('/app-config', (req, res) => {
has_maps_key: hasGoogleKey,
oidc_configured: oidcConfigured,
oidc_display_name: oidcConfigured ? (oidcDisplayName || 'SSO') : undefined,
allowed_file_types: db.prepare("SELECT value FROM app_settings WHERE key = 'allowed_file_types'").get()?.value || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv',
allowed_file_types: (db.prepare("SELECT value FROM app_settings WHERE key = 'allowed_file_types'").get() as { value: string } | undefined)?.value || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv',
demo_mode: isDemo,
demo_email: isDemo ? 'demo@nomad.app' : undefined,
demo_password: isDemo ? 'demo12345' : undefined,
});
});
// POST /api/auth/demo-login (demo mode only)
router.post('/demo-login', (req, res) => {
router.post('/demo-login', (_req: Request, res: Response) => {
if (process.env.DEMO_MODE !== 'true') {
return res.status(404).json({ error: 'Not found' });
}
const user = db.prepare('SELECT * FROM users WHERE email = ?').get('demo@nomad.app');
const user = db.prepare('SELECT * FROM users WHERE email = ?').get('demo@nomad.app') as User | undefined;
if (!user) return res.status(500).json({ error: 'Demo user not found' });
const token = generateToken(user);
const { password_hash, maps_api_key, openweather_api_key, unsplash_api_key, ...safe } = user;
res.json({ token, user: { ...safe, avatar_url: avatarUrl(user) } });
});
// POST /api/auth/register
router.post('/register', authLimiter, (req, res) => {
router.post('/register', authLimiter, (req: Request, res: Response) => {
const { username, email, password } = req.body;
// Check if registration is allowed
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
if (userCount > 0) {
const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get();
const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get() as { value: string } | undefined;
if (setting?.value === 'false') {
return res.status(403).json({ error: 'Registration is disabled. Contact your administrator.' });
}
@@ -120,6 +133,10 @@ router.post('/register', authLimiter, (req, res) => {
return res.status(400).json({ error: 'Password must be at least 8 characters' });
}
if (!/[A-Z]/.test(password) || !/[a-z]/.test(password) || !/[0-9]/.test(password)) {
return res.status(400).json({ error: 'Password must contain at least one uppercase letter, one lowercase letter, and one number' });
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return res.status(400).json({ error: 'Invalid email format' });
@@ -127,12 +144,11 @@ router.post('/register', authLimiter, (req, res) => {
const existingUser = db.prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?) OR LOWER(username) = LOWER(?)').get(email, username);
if (existingUser) {
return res.status(409).json({ error: 'A user with this email or username already exists' });
return res.status(409).json({ error: 'Registration failed. Please try different credentials.' });
}
const password_hash = bcrypt.hashSync(password, 10);
const password_hash = bcrypt.hashSync(password, 12);
// First user becomes admin
const isFirstUser = userCount === 0;
const role = isFirstUser ? 'admin' : 'user';
@@ -145,25 +161,24 @@ router.post('/register', authLimiter, (req, res) => {
const token = generateToken(user);
res.status(201).json({ token, user: { ...user, avatar_url: null } });
} catch (err) {
} catch (err: unknown) {
res.status(500).json({ error: 'Error creating user' });
}
});
// POST /api/auth/login
router.post('/login', authLimiter, (req, res) => {
router.post('/login', authLimiter, (req: Request, res: Response) => {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'Email and password are required' });
}
const user = db.prepare('SELECT * FROM users WHERE LOWER(email) = LOWER(?)').get(email);
const user = db.prepare('SELECT * FROM users WHERE LOWER(email) = LOWER(?)').get(email) as User | undefined;
if (!user) {
return res.status(401).json({ error: 'Invalid email or password' });
}
const validPassword = bcrypt.compareSync(password, user.password_hash);
const validPassword = bcrypt.compareSync(password, user.password_hash!);
if (!validPassword) {
return res.status(401).json({ error: 'Invalid email or password' });
}
@@ -175,11 +190,11 @@ router.post('/login', authLimiter, (req, res) => {
res.json({ token, user: { ...userWithoutSensitive, avatar_url: avatarUrl(user) } });
});
// GET /api/auth/me
router.get('/me', authenticate, (req, res) => {
router.get('/me', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const user = db.prepare(
'SELECT id, username, email, role, avatar, oidc_issuer, created_at FROM users WHERE id = ?'
).get(req.user.id);
).get(authReq.user.id) as User | undefined;
if (!user) {
return res.status(404).json({ error: 'User not found' });
@@ -188,147 +203,177 @@ router.get('/me', authenticate, (req, res) => {
res.json({ user: { ...user, avatar_url: avatarUrl(user) } });
});
// PUT /api/auth/me/password
router.put('/me/password', authenticate, (req, res) => {
if (process.env.DEMO_MODE === 'true' && req.user.email === 'demo@nomad.app') {
router.put('/me/password', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (process.env.DEMO_MODE === 'true' && authReq.user.email === 'demo@nomad.app') {
return res.status(403).json({ error: 'Password change is disabled in demo mode.' });
}
const { new_password } = req.body;
const { current_password, new_password } = req.body;
if (!current_password) return res.status(400).json({ error: 'Current password is required' });
if (!new_password) return res.status(400).json({ error: 'New password is required' });
if (new_password.length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters' });
const hash = bcrypt.hashSync(new_password, 10);
db.prepare('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(hash, req.user.id);
if (!/[A-Z]/.test(new_password) || !/[a-z]/.test(new_password) || !/[0-9]/.test(new_password)) {
return res.status(400).json({ error: 'Password must contain at least one uppercase letter, one lowercase letter, and one number' });
}
const user = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(authReq.user.id) as { password_hash: string } | undefined;
if (!user || !bcrypt.compareSync(current_password, user.password_hash)) {
return res.status(401).json({ error: 'Current password is incorrect' });
}
const hash = bcrypt.hashSync(new_password, 12);
db.prepare('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(hash, authReq.user.id);
res.json({ success: true });
});
// DELETE /api/auth/me — delete own account
router.delete('/me', authenticate, (req, res) => {
// Block demo user
if (process.env.DEMO_MODE === 'true' && req.user.email === 'demo@nomad.app') {
router.delete('/me', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (process.env.DEMO_MODE === 'true' && authReq.user.email === 'demo@nomad.app') {
return res.status(403).json({ error: 'Account deletion is disabled in demo mode.' });
}
// Prevent deleting last admin
if (req.user.role === 'admin') {
const adminCount = db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin'").get().count;
if (authReq.user.role === 'admin') {
const adminCount = (db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin'").get() as { count: number }).count;
if (adminCount <= 1) {
return res.status(400).json({ error: 'Cannot delete the last admin account' });
}
}
db.prepare('DELETE FROM users WHERE id = ?').run(req.user.id);
db.prepare('DELETE FROM users WHERE id = ?').run(authReq.user.id);
res.json({ success: true });
});
// PUT /api/auth/me/maps-key
router.put('/me/maps-key', authenticate, (req, res) => {
router.put('/me/maps-key', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { maps_api_key } = req.body;
db.prepare(
'UPDATE users SET maps_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
).run(maps_api_key || null, req.user.id);
).run(maps_api_key || null, authReq.user.id);
res.json({ success: true, maps_api_key: maps_api_key || null });
});
// PUT /api/auth/me/api-keys
router.put('/me/api-keys', authenticate, (req, res) => {
router.put('/me/api-keys', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { maps_api_key, openweather_api_key } = req.body;
const current = db.prepare('SELECT maps_api_key, openweather_api_key FROM users WHERE id = ?').get(authReq.user.id) as Pick<User, 'maps_api_key' | 'openweather_api_key'> | undefined;
db.prepare(
'UPDATE users SET maps_api_key = ?, openweather_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
).run(
maps_api_key !== undefined ? (maps_api_key || null) : req.user.maps_api_key,
openweather_api_key !== undefined ? (openweather_api_key || null) : req.user.openweather_api_key,
req.user.id
maps_api_key !== undefined ? (maps_api_key || null) : current.maps_api_key,
openweather_api_key !== undefined ? (openweather_api_key || null) : current.openweather_api_key,
authReq.user.id
);
const updated = db.prepare(
'SELECT id, username, email, role, maps_api_key, openweather_api_key, avatar FROM users WHERE id = ?'
).get(req.user.id);
).get(authReq.user.id) as Pick<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'avatar'> | undefined;
res.json({ success: true, user: { ...updated, avatar_url: avatarUrl(updated) } });
res.json({ success: true, user: { ...updated, maps_api_key: maskKey(updated?.maps_api_key), openweather_api_key: maskKey(updated?.openweather_api_key), avatar_url: avatarUrl(updated || {}) } });
});
// PUT /api/auth/me/settings
router.put('/me/settings', authenticate, (req, res) => {
router.put('/me/settings', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { maps_api_key, openweather_api_key, username, email } = req.body;
const updates = [];
const params = [];
if (username !== undefined) {
const trimmed = username.trim();
if (!trimmed || trimmed.length < 2 || trimmed.length > 50) {
return res.status(400).json({ error: 'Username must be between 2 and 50 characters' });
}
if (!/^[a-zA-Z0-9_.-]+$/.test(trimmed)) {
return res.status(400).json({ error: 'Username can only contain letters, numbers, underscores, dots and hyphens' });
}
const conflict = db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?) AND id != ?').get(trimmed, authReq.user.id);
if (conflict) return res.status(409).json({ error: 'Username already taken' });
}
if (email !== undefined) {
const trimmed = email.trim();
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!trimmed || !emailRegex.test(trimmed)) {
return res.status(400).json({ error: 'Invalid email format' });
}
const conflict = db.prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?) AND id != ?').get(trimmed, authReq.user.id);
if (conflict) return res.status(409).json({ error: 'Email already taken' });
}
const updates: string[] = [];
const params: (string | number | null)[] = [];
if (maps_api_key !== undefined) { updates.push('maps_api_key = ?'); params.push(maps_api_key || null); }
if (openweather_api_key !== undefined) { updates.push('openweather_api_key = ?'); params.push(openweather_api_key || null); }
if (username !== undefined) { updates.push('username = ?'); params.push(username); }
if (email !== undefined) { updates.push('email = ?'); params.push(email); }
if (username !== undefined) { updates.push('username = ?'); params.push(username.trim()); }
if (email !== undefined) { updates.push('email = ?'); params.push(email.trim()); }
if (updates.length > 0) {
updates.push('updated_at = CURRENT_TIMESTAMP');
params.push(req.user.id);
params.push(authReq.user.id);
db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...params);
}
const updated = db.prepare(
'SELECT id, username, email, role, maps_api_key, openweather_api_key, avatar FROM users WHERE id = ?'
).get(req.user.id);
).get(authReq.user.id) as Pick<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'avatar'> | undefined;
res.json({ success: true, user: { ...updated, avatar_url: avatarUrl(updated) } });
res.json({ success: true, user: { ...updated, maps_api_key: maskKey(updated?.maps_api_key), openweather_api_key: maskKey(updated?.openweather_api_key), avatar_url: avatarUrl(updated || {}) } });
});
// GET /api/auth/me/settings (admin only — returns API keys)
router.get('/me/settings', authenticate, (req, res) => {
router.get('/me/settings', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const user = db.prepare(
'SELECT role, maps_api_key, openweather_api_key FROM users WHERE id = ?'
).get(req.user.id);
).get(authReq.user.id) as Pick<User, 'role' | 'maps_api_key' | 'openweather_api_key'> | undefined;
if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' });
res.json({ settings: { maps_api_key: user.maps_api_key, openweather_api_key: user.openweather_api_key } });
});
// POST /api/auth/avatar — upload avatar
router.post('/avatar', authenticate, demoUploadBlock, avatarUpload.single('avatar'), (req, res) => {
router.post('/avatar', authenticate, demoUploadBlock, avatarUpload.single('avatar'), (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!req.file) return res.status(400).json({ error: 'No image uploaded' });
const current = db.prepare('SELECT avatar FROM users WHERE id = ?').get(req.user.id);
const current = db.prepare('SELECT avatar FROM users WHERE id = ?').get(authReq.user.id) as { avatar: string | null } | undefined;
if (current && current.avatar) {
const oldPath = path.join(avatarDir, current.avatar);
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
}
const filename = req.file.filename;
db.prepare('UPDATE users SET avatar = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(filename, req.user.id);
db.prepare('UPDATE users SET avatar = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(filename, authReq.user.id);
const updated = db.prepare('SELECT id, username, email, role, avatar FROM users WHERE id = ?').get(req.user.id);
res.json({ success: true, avatar_url: avatarUrl(updated) });
const updated = db.prepare('SELECT id, username, email, role, avatar FROM users WHERE id = ?').get(authReq.user.id) as Pick<User, 'id' | 'username' | 'email' | 'role' | 'avatar'> | undefined;
res.json({ success: true, avatar_url: avatarUrl(updated || {}) });
});
// DELETE /api/auth/avatar — remove avatar
router.delete('/avatar', authenticate, (req, res) => {
const current = db.prepare('SELECT avatar FROM users WHERE id = ?').get(req.user.id);
router.delete('/avatar', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const current = db.prepare('SELECT avatar FROM users WHERE id = ?').get(authReq.user.id) as { avatar: string | null } | undefined;
if (current && current.avatar) {
const filePath = path.join(avatarDir, current.avatar);
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
}
db.prepare('UPDATE users SET avatar = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(req.user.id);
db.prepare('UPDATE users SET avatar = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(authReq.user.id);
res.json({ success: true });
});
// GET /api/auth/users — list all users (for sharing/inviting)
router.get('/users', authenticate, (req, res) => {
router.get('/users', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const users = db.prepare(
'SELECT id, username, avatar FROM users WHERE id != ? ORDER BY username ASC'
).all(req.user.id);
).all(authReq.user.id) as Pick<User, 'id' | 'username' | 'avatar'>[];
res.json({ users: users.map(u => ({ ...u, avatar_url: avatarUrl(u) })) });
});
// GET /api/auth/validate-keys (admin only)
router.get('/validate-keys', authenticate, async (req, res) => {
const user = db.prepare('SELECT role, maps_api_key, openweather_api_key FROM users WHERE id = ?').get(req.user.id);
router.get('/validate-keys', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const user = db.prepare('SELECT role, maps_api_key, openweather_api_key FROM users WHERE id = ?').get(authReq.user.id) as Pick<User, 'role' | 'maps_api_key' | 'openweather_api_key'> | undefined;
if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' });
const result = { maps: false, weather: false };
// Test Google Maps Places API
if (user.maps_api_key) {
try {
const mapsRes = await fetch(
@@ -344,19 +389,18 @@ router.get('/validate-keys', authenticate, async (req, res) => {
}
);
result.maps = mapsRes.status === 200;
} catch (err) {
} catch (err: unknown) {
result.maps = false;
}
}
// Test OpenWeatherMap API
if (user.openweather_api_key) {
try {
const weatherRes = await fetch(
`https://api.openweathermap.org/data/2.5/weather?q=London&appid=${user.openweather_api_key}`
);
result.weather = weatherRes.status === 200;
} catch (err) {
} catch (err: unknown) {
result.weather = false;
}
}
@@ -364,9 +408,9 @@ router.get('/validate-keys', authenticate, async (req, res) => {
res.json(result);
});
// PUT /api/auth/app-settings (admin only)
router.put('/app-settings', authenticate, (req, res) => {
const user = db.prepare('SELECT role FROM users WHERE id = ?').get(req.user.id);
router.put('/app-settings', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const user = db.prepare('SELECT role FROM users WHERE id = ?').get(authReq.user.id) as { role: string } | undefined;
if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' });
const { allow_registration, allowed_file_types } = req.body;
@@ -379,20 +423,18 @@ router.put('/app-settings', authenticate, (req, res) => {
res.json({ success: true });
});
// GET /api/auth/travel-stats — aggregated travel statistics for current user
router.get('/travel-stats', authenticate, (req, res) => {
const userId = req.user.id;
router.get('/travel-stats', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const userId = authReq.user.id;
// Get all places from user's trips (owned + shared)
const places = db.prepare(`
SELECT DISTINCT p.address, p.lat, p.lng
FROM places p
JOIN trips t ON p.trip_id = t.id
LEFT JOIN trip_members tm ON t.id = tm.trip_id
WHERE t.user_id = ? OR tm.user_id = ?
`).all(userId, userId);
`).all(userId, userId) as { address: string | null; lat: number | null; lng: number | null }[];
// Get trip count + total days
const tripStats = db.prepare(`
SELECT COUNT(DISTINCT t.id) as trips,
COUNT(DISTINCT d.id) as days
@@ -400,21 +442,20 @@ router.get('/travel-stats', authenticate, (req, res) => {
LEFT JOIN days d ON d.trip_id = t.id
LEFT JOIN trip_members tm ON t.id = tm.trip_id
WHERE (t.user_id = ? OR tm.user_id = ?) AND t.is_archived = 0
`).get(userId, userId);
`).get(userId, userId) as { trips: number; days: number } | undefined;
// Known country names (EN + DE)
const KNOWN_COUNTRIES = new Set([
'Japan', 'Germany', 'Deutschland', 'France', 'Frankreich', 'Italy', 'Italien', 'Spain', 'Spanien',
'United States', 'USA', 'United Kingdom', 'UK', 'Thailand', 'Australia', 'Australien',
'Canada', 'Kanada', 'Mexico', 'Mexiko', 'Brazil', 'Brasilien', 'China', 'India', 'Indien',
'South Korea', 'Südkorea', 'Indonesia', 'Indonesien', 'Turkey', 'Türkei', 'Türkiye',
'South Korea', 'Sudkorea', 'Indonesia', 'Indonesien', 'Turkey', 'Turkei', 'Turkiye',
'Greece', 'Griechenland', 'Portugal', 'Netherlands', 'Niederlande', 'Belgium', 'Belgien',
'Switzerland', 'Schweiz', 'Austria', 'Österreich', 'Sweden', 'Schweden', 'Norway', 'Norwegen',
'Denmark', 'Dänemark', 'Finland', 'Finnland', 'Poland', 'Polen', 'Czech Republic', 'Tschechien',
'Czechia', 'Hungary', 'Ungarn', 'Croatia', 'Kroatien', 'Romania', 'Rumänien',
'Switzerland', 'Schweiz', 'Austria', 'Osterreich', 'Sweden', 'Schweden', 'Norway', 'Norwegen',
'Denmark', 'Danemark', 'Finland', 'Finnland', 'Poland', 'Polen', 'Czech Republic', 'Tschechien',
'Czechia', 'Hungary', 'Ungarn', 'Croatia', 'Kroatien', 'Romania', 'Rumanien',
'Ireland', 'Irland', 'Iceland', 'Island', 'New Zealand', 'Neuseeland',
'Singapore', 'Singapur', 'Malaysia', 'Vietnam', 'Philippines', 'Philippinen',
'Egypt', 'Ägypten', 'Morocco', 'Marokko', 'South Africa', 'Südafrika', 'Kenya', 'Kenia',
'Egypt', 'Agypten', 'Morocco', 'Marokko', 'South Africa', 'Sudafrika', 'Kenya', 'Kenia',
'Argentina', 'Argentinien', 'Chile', 'Colombia', 'Kolumbien', 'Peru',
'Russia', 'Russland', 'United Arab Emirates', 'UAE', 'Vereinigte Arabische Emirate',
'Israel', 'Jordan', 'Jordanien', 'Taiwan', 'Hong Kong', 'Hongkong',
@@ -424,16 +465,15 @@ router.get('/travel-stats', authenticate, (req, res) => {
'Bulgaria', 'Bulgarien', 'Serbia', 'Serbien', 'Montenegro', 'Albania', 'Albanien',
'Sri Lanka', 'Nepal', 'Cambodia', 'Kambodscha', 'Laos', 'Myanmar', 'Mongolia', 'Mongolei',
'Saudi Arabia', 'Saudi-Arabien', 'Qatar', 'Katar', 'Oman', 'Bahrain', 'Kuwait',
'Tanzania', 'Tansania', 'Ethiopia', 'Äthiopien', 'Nigeria', 'Ghana', 'Tunisia', 'Tunesien',
'Tanzania', 'Tansania', 'Ethiopia', 'Athiopien', 'Nigeria', 'Ghana', 'Tunisia', 'Tunesien',
'Dominican Republic', 'Dominikanische Republik', 'Jamaica', 'Jamaika',
'Ukraine', 'Georgia', 'Georgien', 'Armenia', 'Armenien', 'Pakistan', 'Bangladesh', 'Bangladesch',
'Senegal', 'Mozambique', 'Mosambik', 'Moldova', 'Moldawien', 'Belarus', 'Weißrussland',
'Senegal', 'Mozambique', 'Mosambik', 'Moldova', 'Moldawien', 'Belarus', 'Weissrussland',
]);
// Extract countries from addresses — only accept known country names
const countries = new Set();
const cities = new Set();
const coords = [];
const countries = new Set<string>();
const cities = new Set<string>();
const coords: { lat: number; lng: number }[] = [];
places.forEach(p => {
if (p.lat && p.lng) coords.push({ lat: p.lat, lng: p.lng });
@@ -442,8 +482,7 @@ router.get('/travel-stats', authenticate, (req, res) => {
for (const part of parts) {
if (KNOWN_COUNTRIES.has(part)) { countries.add(part); break; }
}
// City: first part that's not the country and looks like a name (Latin chars, > 2 chars)
const cityPart = parts.find(s => !KNOWN_COUNTRIES.has(s) && /^[A-Za-zÀ-ÿ\s-]{2,}$/.test(s));
const cityPart = parts.find(s => !KNOWN_COUNTRIES.has(s) && /^[A-Za-z\u00C0-\u00FF\s-]{2,}$/.test(s));
if (cityPart) cities.add(cityPart);
}
});
@@ -458,4 +497,4 @@ router.get('/travel-stats', authenticate, (req, res) => {
});
});
module.exports = router;
export default router;

View File

@@ -1,18 +1,38 @@
const express = require('express');
const archiver = require('archiver');
const unzipper = require('unzipper');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { authenticate, adminOnly } = require('../middleware/auth');
const scheduler = require('../scheduler');
const { db, closeDb, reinitialize } = require('../db/database');
import express, { Request, Response, NextFunction } from 'express';
import archiver from 'archiver';
import unzipper from 'unzipper';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { authenticate, adminOnly } from '../middleware/auth';
import scheduler from '../scheduler';
import { db, closeDb, reinitialize } from '../db/database';
const router = express.Router();
// All backup routes require admin
router.use(authenticate, adminOnly);
const BACKUP_RATE_WINDOW = 60 * 60 * 1000; // 1 hour
const MAX_BACKUP_UPLOAD_SIZE = 500 * 1024 * 1024; // 500 MB
const backupAttempts = new Map<string, { count: number; first: number }>();
function backupRateLimiter(maxAttempts: number, windowMs: number) {
return (req: Request, res: Response, next: NextFunction) => {
const key = req.ip || 'unknown';
const now = Date.now();
const record = backupAttempts.get(key);
if (record && record.count >= maxAttempts && now - record.first < windowMs) {
return res.status(429).json({ error: 'Too many backup requests. Please try again later.' });
}
if (!record || now - record.first >= windowMs) {
backupAttempts.set(key, { count: 1, first: now });
} else {
record.count++;
}
next();
};
}
const dataDir = path.join(__dirname, '../../data');
const backupsDir = path.join(dataDir, 'backups');
const uploadsDir = path.join(__dirname, '../../uploads');
@@ -21,14 +41,13 @@ function ensureBackupsDir() {
if (!fs.existsSync(backupsDir)) fs.mkdirSync(backupsDir, { recursive: true });
}
function formatSize(bytes) {
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
// GET /api/backup/list
router.get('/list', (req, res) => {
router.get('/list', (_req: Request, res: Response) => {
ensureBackupsDir();
try {
@@ -44,16 +63,15 @@ router.get('/list', (req, res) => {
created_at: stat.birthtime.toISOString(),
};
})
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
res.json({ backups: files });
} catch (err) {
} catch (err: unknown) {
res.status(500).json({ error: 'Error loading backups' });
}
});
// POST /api/backup/create
router.post('/create', async (req, res) => {
router.post('/create', backupRateLimiter(3, BACKUP_RATE_WINDOW), async (_req: Request, res: Response) => {
ensureBackupsDir();
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
@@ -61,10 +79,9 @@ router.post('/create', async (req, res) => {
const outputPath = path.join(backupsDir, filename);
try {
// Flush WAL to main DB file before archiving so all data is captured
try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {}
await new Promise((resolve, reject) => {
await new Promise<void>((resolve, reject) => {
const output = fs.createWriteStream(outputPath);
const archive = archiver('zip', { zlib: { level: 9 } });
@@ -73,13 +90,11 @@ router.post('/create', async (req, res) => {
archive.pipe(output);
// Add database
const dbPath = path.join(dataDir, 'travel.db');
if (fs.existsSync(dbPath)) {
archive.file(dbPath, { name: 'travel.db' });
}
// Add uploads directory
if (fs.existsSync(uploadsDir)) {
archive.directory(uploadsDir, 'uploads');
}
@@ -97,18 +112,16 @@ router.post('/create', async (req, res) => {
created_at: stat.birthtime.toISOString(),
}
});
} catch (err) {
} catch (err: unknown) {
console.error('Backup error:', err);
if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath);
res.status(500).json({ error: 'Error creating backup' });
}
});
// GET /api/backup/download/:filename
router.get('/download/:filename', (req, res) => {
router.get('/download/:filename', (req: Request, res: Response) => {
const { filename } = req.params;
// Security: prevent path traversal
if (!/^backup-[\w\-]+\.zip$/.test(filename)) {
return res.status(400).json({ error: 'Invalid filename' });
}
@@ -121,8 +134,7 @@ router.get('/download/:filename', (req, res) => {
res.download(filePath, filename);
});
// Helper: restore from a zip file path
async function restoreFromZip(zipPath, res) {
async function restoreFromZip(zipPath: string, res: Response) {
const extractDir = path.join(dataDir, `restore-${Date.now()}`);
try {
await fs.createReadStream(zipPath)
@@ -135,22 +147,17 @@ async function restoreFromZip(zipPath, res) {
return res.status(400).json({ error: 'Invalid backup: travel.db not found' });
}
// Step 1: close DB connection BEFORE touching the file (required on Windows)
closeDb();
try {
// Step 2: remove WAL/SHM and overwrite DB file
const dbDest = path.join(dataDir, 'travel.db');
for (const ext of ['', '-wal', '-shm']) {
try { fs.unlinkSync(dbDest + ext); } catch (e) {}
}
fs.copyFileSync(extractedDb, dbDest);
// Step 3: restore uploads — overwrite in-place instead of rmSync
// (rmSync fails with EBUSY because express.static holds the directory)
const extractedUploads = path.join(extractDir, 'uploads');
if (fs.existsSync(extractedUploads)) {
// Clear contents of each subdirectory without removing the root uploads dir
for (const sub of fs.readdirSync(uploadsDir)) {
const subPath = path.join(uploadsDir, sub);
if (fs.statSync(subPath).isDirectory()) {
@@ -159,26 +166,23 @@ async function restoreFromZip(zipPath, res) {
}
}
}
// Copy restored files over
fs.cpSync(extractedUploads, uploadsDir, { recursive: true, force: true });
}
} finally {
// Step 4: ALWAYS reopen DB — even if file copy failed, so the server stays functional
reinitialize();
}
fs.rmSync(extractDir, { recursive: true, force: true });
res.json({ success: true });
} catch (err) {
} catch (err: unknown) {
console.error('Restore error:', err);
if (fs.existsSync(extractDir)) fs.rmSync(extractDir, { recursive: true, force: true });
if (!res.headersSent) res.status(500).json({ error: err.message || 'Error restoring backup' });
if (!res.headersSent) res.status(500).json({ error: 'Error restoring backup' });
}
}
// POST /api/backup/restore/:filename - restore from stored backup
router.post('/restore/:filename', async (req, res) => {
router.post('/restore/:filename', async (req: Request, res: Response) => {
const { filename } = req.params;
if (!/^backup-[\w\-]+\.zip$/.test(filename)) {
return res.status(400).json({ error: 'Invalid filename' });
@@ -190,30 +194,27 @@ router.post('/restore/:filename', async (req, res) => {
await restoreFromZip(zipPath, res);
});
// POST /api/backup/upload-restore - upload a zip and restore
const uploadTmp = multer({
dest: path.join(dataDir, 'tmp/'),
fileFilter: (req, file, cb) => {
fileFilter: (_req, file, cb) => {
if (file.originalname.endsWith('.zip')) cb(null, true);
else cb(new Error('Only ZIP files allowed'));
},
limits: { fileSize: 500 * 1024 * 1024 },
limits: { fileSize: MAX_BACKUP_UPLOAD_SIZE },
});
router.post('/upload-restore', uploadTmp.single('backup'), async (req, res) => {
router.post('/upload-restore', uploadTmp.single('backup'), async (req: Request, res: Response) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const zipPath = req.file.path;
await restoreFromZip(zipPath, res);
if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath);
});
// GET /api/backup/auto-settings
router.get('/auto-settings', (req, res) => {
router.get('/auto-settings', (_req: Request, res: Response) => {
res.json({ settings: scheduler.loadSettings() });
});
// PUT /api/backup/auto-settings
router.put('/auto-settings', (req, res) => {
router.put('/auto-settings', (req: Request, res: Response) => {
const { enabled, interval, keep_days } = req.body;
const settings = {
enabled: !!enabled,
@@ -225,8 +226,7 @@ router.put('/auto-settings', (req, res) => {
res.json({ settings });
});
// DELETE /api/backup/:filename
router.delete('/:filename', (req, res) => {
router.delete('/:filename', (req: Request, res: Response) => {
const { filename } = req.params;
if (!/^backup-[\w\-]+\.zip$/.test(filename)) {
@@ -242,4 +242,4 @@ router.delete('/:filename', (req, res) => {
res.json({ success: true });
});
module.exports = router;
export default router;

View File

@@ -1,48 +1,48 @@
const express = require('express');
const { db, canAccessTrip } = require('../db/database');
const { authenticate } = require('../middleware/auth');
const { broadcast } = require('../websocket');
import express, { Request, Response } from 'express';
import { db, canAccessTrip } from '../db/database';
import { authenticate } from '../middleware/auth';
import { broadcast } from '../websocket';
import { AuthRequest, BudgetItem, BudgetItemMember } from '../types';
const router = express.Router({ mergeParams: true });
function verifyTripOwnership(tripId, userId) {
function verifyTripOwnership(tripId: string | number, userId: number) {
return canAccessTrip(tripId, userId);
}
function loadItemMembers(itemId) {
function loadItemMembers(itemId: number | string) {
return db.prepare(`
SELECT bm.user_id, bm.paid, u.username, u.avatar
FROM budget_item_members bm
JOIN users u ON bm.user_id = u.id
WHERE bm.budget_item_id = ?
`).all(itemId);
`).all(itemId) as BudgetItemMember[];
}
function avatarUrl(user) {
function avatarUrl(user: { avatar?: string | null }): string | null {
return user.avatar ? `/uploads/avatars/${user.avatar}` : null;
}
// GET /api/trips/:tripId/budget
router.get('/', authenticate, (req, res) => {
router.get('/', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const items = db.prepare(
'SELECT * FROM budget_items WHERE trip_id = ? ORDER BY category ASC, created_at ASC'
).all(tripId);
).all(tripId) as BudgetItem[];
// Batch-load all members
const itemIds = items.map(i => i.id);
const membersByItem = {};
const membersByItem: Record<number, (BudgetItemMember & { avatar_url: string | null })[]> = {};
if (itemIds.length > 0) {
const allMembers = db.prepare(`
SELECT bm.budget_item_id, bm.user_id, bm.paid, u.username, u.avatar
FROM budget_item_members bm
JOIN users u ON bm.user_id = u.id
WHERE bm.budget_item_id IN (${itemIds.map(() => '?').join(',')})
`).all(...itemIds);
`).all(...itemIds) as (BudgetItemMember & { budget_item_id: number })[];
for (const m of allMembers) {
if (!membersByItem[m.budget_item_id]) membersByItem[m.budget_item_id] = [];
membersByItem[m.budget_item_id].push({
@@ -55,10 +55,10 @@ router.get('/', authenticate, (req, res) => {
res.json({ items });
});
// GET /api/trips/:tripId/budget/summary/per-person (must be before /:id routes)
router.get('/summary/per-person', authenticate, (req, res) => {
router.get('/summary/per-person', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
if (!canAccessTrip(Number(tripId), req.user.id)) return res.status(404).json({ error: 'Trip not found' });
if (!canAccessTrip(Number(tripId), authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
const summary = db.prepare(`
SELECT bm.user_id, u.username, u.avatar,
@@ -70,22 +70,22 @@ router.get('/summary/per-person', authenticate, (req, res) => {
JOIN users u ON bm.user_id = u.id
WHERE bi.trip_id = ?
GROUP BY bm.user_id
`).all(tripId);
`).all(tripId) as { user_id: number; username: string; avatar: string | null; total_assigned: number; total_paid: number; items_count: number }[];
res.json({ summary: summary.map(s => ({ ...s, avatar_url: avatarUrl(s) })) });
});
// POST /api/trips/:tripId/budget
router.post('/', authenticate, (req, res) => {
router.post('/', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const { category, name, total_price, persons, days, note } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id);
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!name) return res.status(400).json({ error: 'Name is required' });
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM budget_items WHERE trip_id = ?').get(tripId);
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM budget_items WHERE trip_id = ?').get(tripId) as { max: number | null };
const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
const result = db.prepare(
@@ -101,18 +101,18 @@ router.post('/', authenticate, (req, res) => {
sortOrder
);
const item = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(result.lastInsertRowid);
const item = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(result.lastInsertRowid) as BudgetItem & { members?: BudgetItemMember[] };
item.members = [];
res.status(201).json({ item });
broadcast(tripId, 'budget:created', { item }, req.headers['x-socket-id']);
broadcast(tripId, 'budget:created', { item }, req.headers['x-socket-id'] as string);
});
// PUT /api/trips/:tripId/budget/:id
router.put('/:id', authenticate, (req, res) => {
router.put('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
const { category, name, total_price, persons, days, note, sort_order } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id);
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const item = db.prepare('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
@@ -139,16 +139,16 @@ router.put('/:id', authenticate, (req, res) => {
id
);
const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id);
const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id) as BudgetItem & { members?: BudgetItemMember[] };
updated.members = loadItemMembers(id);
res.json({ item: updated });
broadcast(tripId, 'budget:updated', { item: updated }, req.headers['x-socket-id']);
broadcast(tripId, 'budget:updated', { item: updated }, req.headers['x-socket-id'] as string);
});
// PUT /api/trips/:tripId/budget/:id/members
router.put('/:id/members', authenticate, (req, res) => {
router.put('/:id/members', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
if (!canAccessTrip(Number(tripId), req.user.id)) return res.status(404).json({ error: 'Trip not found' });
if (!canAccessTrip(Number(tripId), authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
const item = db.prepare('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!item) return res.status(404).json({ error: 'Budget item not found' });
@@ -156,16 +156,14 @@ router.put('/:id/members', authenticate, (req, res) => {
const { user_ids } = req.body;
if (!Array.isArray(user_ids)) return res.status(400).json({ error: 'user_ids must be an array' });
// Preserve paid status for existing members
const existingPaid = {};
const existing = db.prepare('SELECT user_id, paid FROM budget_item_members WHERE budget_item_id = ?').all(id);
const existingPaid: Record<number, number> = {};
const existing = db.prepare('SELECT user_id, paid FROM budget_item_members WHERE budget_item_id = ?').all(id) as { user_id: number; paid: number }[];
for (const e of existing) existingPaid[e.user_id] = e.paid;
db.prepare('DELETE FROM budget_item_members WHERE budget_item_id = ?').run(id);
if (user_ids.length > 0) {
const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, ?)');
for (const userId of user_ids) insert.run(id, userId, existingPaid[userId] || 0);
// Auto-update persons count
db.prepare('UPDATE budget_items SET persons = ? WHERE id = ?').run(user_ids.length, id);
} else {
db.prepare('UPDATE budget_items SET persons = NULL WHERE id = ?').run(id);
@@ -174,13 +172,13 @@ router.put('/:id/members', authenticate, (req, res) => {
const members = loadItemMembers(id).map(m => ({ ...m, avatar_url: avatarUrl(m) }));
const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id);
res.json({ members, item: updated });
broadcast(Number(tripId), 'budget:members-updated', { itemId: Number(id), members, persons: updated.persons }, req.headers['x-socket-id']);
broadcast(Number(tripId), 'budget:members-updated', { itemId: Number(id), members, persons: (updated as BudgetItem).persons }, req.headers['x-socket-id'] as string);
});
// PUT /api/trips/:tripId/budget/:id/members/:userId/paid
router.put('/:id/members/:userId/paid', authenticate, (req, res) => {
router.put('/:id/members/:userId/paid', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id, userId } = req.params;
if (!canAccessTrip(Number(tripId), req.user.id)) return res.status(404).json({ error: 'Trip not found' });
if (!canAccessTrip(Number(tripId), authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
const { paid } = req.body;
db.prepare('UPDATE budget_item_members SET paid = ? WHERE budget_item_id = ? AND user_id = ?')
@@ -190,18 +188,18 @@ router.put('/:id/members/:userId/paid', authenticate, (req, res) => {
SELECT bm.user_id, bm.paid, u.username, u.avatar
FROM budget_item_members bm JOIN users u ON bm.user_id = u.id
WHERE bm.budget_item_id = ? AND bm.user_id = ?
`).get(id, userId);
`).get(id, userId) as BudgetItemMember | undefined;
const result = member ? { ...member, avatar_url: avatarUrl(member) } : null;
res.json({ member: result });
broadcast(Number(tripId), 'budget:member-paid-updated', { itemId: Number(id), userId: Number(userId), paid: paid ? 1 : 0 }, req.headers['x-socket-id']);
broadcast(Number(tripId), 'budget:member-paid-updated', { itemId: Number(id), userId: Number(userId), paid: paid ? 1 : 0 }, req.headers['x-socket-id'] as string);
});
// DELETE /api/trips/:tripId/budget/:id
router.delete('/:id', authenticate, (req, res) => {
router.delete('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const item = db.prepare('SELECT id FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
@@ -209,7 +207,7 @@ router.delete('/:id', authenticate, (req, res) => {
db.prepare('DELETE FROM budget_items WHERE id = ?').run(id);
res.json({ success: true });
broadcast(tripId, 'budget:deleted', { itemId: Number(id) }, req.headers['x-socket-id']);
broadcast(tripId, 'budget:deleted', { itemId: Number(id) }, req.headers['x-socket-id'] as string);
});
module.exports = router;
export default router;

View File

@@ -1,33 +1,32 @@
const express = require('express');
const { db } = require('../db/database');
const { authenticate, adminOnly } = require('../middleware/auth');
import express, { Request, Response } from 'express';
import { db } from '../db/database';
import { authenticate, adminOnly } from '../middleware/auth';
import { AuthRequest } from '../types';
const router = express.Router();
// GET /api/categories - public to all authenticated users
router.get('/', authenticate, (req, res) => {
router.get('/', authenticate, (_req: Request, res: Response) => {
const categories = db.prepare(
'SELECT * FROM categories ORDER BY name ASC'
).all();
res.json({ categories });
});
// POST /api/categories - admin only
router.post('/', authenticate, adminOnly, (req, res) => {
router.post('/', authenticate, adminOnly, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { name, color, icon } = req.body;
if (!name) return res.status(400).json({ error: 'Category name is required' });
const result = db.prepare(
'INSERT INTO categories (name, color, icon, user_id) VALUES (?, ?, ?, ?)'
).run(name, color || '#6366f1', icon || '📍', req.user.id);
).run(name, color || '#6366f1', icon || '\uD83D\uDCCD', authReq.user.id);
const category = db.prepare('SELECT * FROM categories WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json({ category });
});
// PUT /api/categories/:id - admin only
router.put('/:id', authenticate, adminOnly, (req, res) => {
router.put('/:id', authenticate, adminOnly, (req: Request, res: Response) => {
const { name, color, icon } = req.body;
const category = db.prepare('SELECT * FROM categories WHERE id = ?').get(req.params.id);
@@ -45,8 +44,7 @@ router.put('/:id', authenticate, adminOnly, (req, res) => {
res.json({ category: updated });
});
// DELETE /api/categories/:id - admin only
router.delete('/:id', authenticate, adminOnly, (req, res) => {
router.delete('/:id', authenticate, adminOnly, (req: Request, res: Response) => {
const category = db.prepare('SELECT * FROM categories WHERE id = ?').get(req.params.id);
if (!category) return res.status(404).json({ error: 'Category not found' });
@@ -55,4 +53,4 @@ router.delete('/:id', authenticate, adminOnly, (req, res) => {
res.json({ success: true });
});
module.exports = router;
export default router;

View File

@@ -1,33 +1,66 @@
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { v4: uuidv4 } = require('uuid');
const { db, canAccessTrip } = require('../db/database');
const { authenticate } = require('../middleware/auth');
const { broadcast } = require('../websocket');
import express, { Request, Response } from 'express';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { v4 as uuidv4 } from 'uuid';
import { db, canAccessTrip } from '../db/database';
import { authenticate } from '../middleware/auth';
import { broadcast } from '../websocket';
import { validateStringLengths } from '../middleware/validate';
import { AuthRequest, CollabNote, CollabPoll, CollabMessage, TripFile } from '../types';
interface ReactionRow {
emoji: string;
user_id: number;
username: string;
message_id?: number;
}
interface PollVoteRow {
option_index: number;
user_id: number;
username: string;
avatar: string | null;
}
interface NoteFileRow {
id: number;
filename: string;
original_name?: string;
file_size?: number;
mime_type?: string;
}
const MAX_NOTE_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
const filesDir = path.join(__dirname, '../../uploads/files');
const noteUpload = multer({
storage: multer.diskStorage({
destination: (req, file, cb) => { if (!fs.existsSync(filesDir)) fs.mkdirSync(filesDir, { recursive: true }); cb(null, filesDir) },
filename: (req, file, cb) => { cb(null, `${uuidv4()}${path.extname(file.originalname)}`) },
destination: (_req, _file, cb) => { if (!fs.existsSync(filesDir)) fs.mkdirSync(filesDir, { recursive: true }); cb(null, filesDir) },
filename: (_req, file, cb) => { cb(null, `${uuidv4()}${path.extname(file.originalname)}`) },
}),
limits: { fileSize: 50 * 1024 * 1024 },
limits: { fileSize: MAX_NOTE_FILE_SIZE },
fileFilter: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
const BLOCKED = ['.svg', '.html', '.htm', '.xml', '.xhtml', '.js', '.jsx', '.ts', '.exe', '.bat', '.sh', '.cmd', '.msi', '.dll', '.com', '.vbs', '.ps1', '.php'];
if (BLOCKED.includes(ext) || file.mimetype.includes('svg') || file.mimetype.includes('html') || file.mimetype.includes('javascript')) {
return cb(new Error('File type not allowed'));
}
cb(null, true);
},
});
const router = express.Router({ mergeParams: true });
function verifyTripAccess(tripId, userId) {
function verifyTripAccess(tripId: string | number, userId: number) {
return canAccessTrip(tripId, userId);
}
function avatarUrl(user) {
function avatarUrl(user: { avatar?: string | null }): string | null {
return user.avatar ? `/uploads/avatars/${user.avatar}` : null;
}
function formatNote(note) {
const attachments = db.prepare('SELECT id, filename, original_name, file_size, mime_type FROM trip_files WHERE note_id = ?').all(note.id);
function formatNote(note: CollabNote) {
const attachments = db.prepare('SELECT id, filename, original_name, file_size, mime_type FROM trip_files WHERE note_id = ?').all(note.id) as NoteFileRow[];
return {
...note,
avatar_url: avatarUrl(note),
@@ -35,17 +68,17 @@ function formatNote(note) {
};
}
function loadReactions(messageId) {
function loadReactions(messageId: number | string) {
return db.prepare(`
SELECT r.emoji, r.user_id, u.username
FROM collab_message_reactions r
JOIN users u ON r.user_id = u.id
WHERE r.message_id = ?
`).all(messageId);
`).all(messageId) as ReactionRow[];
}
function groupReactions(reactions) {
const map = {};
function groupReactions(reactions: ReactionRow[]) {
const map: Record<string, { user_id: number; username: string }[]> = {};
for (const r of reactions) {
if (!map[r.emoji]) map[r.emoji] = [];
map[r.emoji].push({ user_id: r.user_id, username: r.username });
@@ -53,16 +86,14 @@ function groupReactions(reactions) {
return Object.entries(map).map(([emoji, users]) => ({ emoji, users, count: users.length }));
}
function formatMessage(msg, reactions) {
function formatMessage(msg: CollabMessage, reactions?: { emoji: string; users: { user_id: number; username: string }[]; count: number }[]) {
return { ...msg, user_avatar: avatarUrl(msg), avatar_url: avatarUrl(msg), reactions: reactions || [] };
}
// ─── NOTES ───────────────────────────────────────────────────────────────────
// GET /notes
router.get('/notes', authenticate, (req, res) => {
router.get('/notes', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
if (!verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
const notes = db.prepare(`
SELECT n.*, u.username, u.avatar
@@ -70,37 +101,37 @@ router.get('/notes', authenticate, (req, res) => {
JOIN users u ON n.user_id = u.id
WHERE n.trip_id = ?
ORDER BY n.pinned DESC, n.updated_at DESC
`).all(tripId);
`).all(tripId) as CollabNote[];
res.json({ notes: notes.map(formatNote) });
});
// POST /notes
router.post('/notes', authenticate, (req, res) => {
router.post('/notes', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const { title, content, category, color, website } = req.body;
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
if (!verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
if (!title) return res.status(400).json({ error: 'Title is required' });
const result = db.prepare(`
INSERT INTO collab_notes (trip_id, user_id, title, content, category, color, website)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(tripId, req.user.id, title, content || null, category || 'General', color || '#6366f1', website || null);
`).run(tripId, authReq.user.id, title, content || null, category || 'General', color || '#6366f1', website || null);
const note = db.prepare(`
SELECT n.*, u.username, u.avatar FROM collab_notes n JOIN users u ON n.user_id = u.id WHERE n.id = ?
`).get(result.lastInsertRowid);
`).get(result.lastInsertRowid) as CollabNote;
const formatted = formatNote(note);
res.status(201).json({ note: formatted });
broadcast(tripId, 'collab:note:created', { note: formatted }, req.headers['x-socket-id']);
broadcast(tripId, 'collab:note:created', { note: formatted }, req.headers['x-socket-id'] as string);
});
// PUT /notes/:id
router.put('/notes/:id', authenticate, (req, res) => {
router.put('/notes/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
const { title, content, category, color, pinned, website } = req.body;
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
if (!verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
const existing = db.prepare('SELECT * FROM collab_notes WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!existing) return res.status(404).json({ error: 'Note not found' });
@@ -127,23 +158,22 @@ router.put('/notes/:id', authenticate, (req, res) => {
const note = db.prepare(`
SELECT n.*, u.username, u.avatar FROM collab_notes n JOIN users u ON n.user_id = u.id WHERE n.id = ?
`).get(id);
`).get(id) as CollabNote;
const formatted = formatNote(note);
res.json({ note: formatted });
broadcast(tripId, 'collab:note:updated', { note: formatted }, req.headers['x-socket-id']);
broadcast(tripId, 'collab:note:updated', { note: formatted }, req.headers['x-socket-id'] as string);
});
// DELETE /notes/:id
router.delete('/notes/:id', authenticate, (req, res) => {
router.delete('/notes/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
if (!verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
const existing = db.prepare('SELECT id FROM collab_notes WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!existing) return res.status(404).json({ error: 'Note not found' });
// Delete attached files (physical + DB)
const noteFiles = db.prepare('SELECT id, filename FROM trip_files WHERE note_id = ?').all(id);
const noteFiles = db.prepare('SELECT id, filename FROM trip_files WHERE note_id = ?').all(id) as NoteFileRow[];
for (const f of noteFiles) {
const filePath = path.join(__dirname, '../../uploads', f.filename);
try { fs.unlinkSync(filePath) } catch {}
@@ -152,13 +182,13 @@ router.delete('/notes/:id', authenticate, (req, res) => {
db.prepare('DELETE FROM collab_notes WHERE id = ?').run(id);
res.json({ success: true });
broadcast(tripId, 'collab:note:deleted', { noteId: Number(id) }, req.headers['x-socket-id']);
broadcast(tripId, 'collab:note:deleted', { noteId: Number(id) }, req.headers['x-socket-id'] as string);
});
// POST /notes/:id/files — upload attachment to note
router.post('/notes/:id/files', authenticate, noteUpload.single('file'), (req, res) => {
router.post('/notes/:id/files', authenticate, noteUpload.single('file'), (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
if (!verifyTripAccess(Number(tripId), req.user.id)) return res.status(404).json({ error: 'Trip not found' });
if (!verifyTripAccess(Number(tripId), authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const note = db.prepare('SELECT id FROM collab_notes WHERE id = ? AND trip_id = ?').get(id, tripId);
@@ -168,51 +198,47 @@ router.post('/notes/:id/files', authenticate, noteUpload.single('file'), (req, r
'INSERT INTO trip_files (trip_id, note_id, filename, original_name, file_size, mime_type) VALUES (?, ?, ?, ?, ?, ?)'
).run(tripId, id, `files/${req.file.filename}`, req.file.originalname, req.file.size, req.file.mimetype);
const file = db.prepare('SELECT * FROM trip_files WHERE id = ?').get(result.lastInsertRowid);
const file = db.prepare('SELECT * FROM trip_files WHERE id = ?').get(result.lastInsertRowid) as TripFile;
res.status(201).json({ file: { ...file, url: `/uploads/${file.filename}` } });
broadcast(Number(tripId), 'collab:note:updated', { note: formatNote(db.prepare('SELECT n.*, u.username, u.avatar FROM collab_notes n JOIN users u ON n.user_id = u.id WHERE n.id = ?').get(id)) }, req.headers['x-socket-id']);
broadcast(Number(tripId), 'collab:note:updated', { note: formatNote(db.prepare('SELECT n.*, u.username, u.avatar FROM collab_notes n JOIN users u ON n.user_id = u.id WHERE n.id = ?').get(id) as CollabNote) }, req.headers['x-socket-id'] as string);
});
// DELETE /notes/:id/files/:fileId — remove attachment
router.delete('/notes/:id/files/:fileId', authenticate, (req, res) => {
router.delete('/notes/:id/files/:fileId', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id, fileId } = req.params;
if (!verifyTripAccess(Number(tripId), req.user.id)) return res.status(404).json({ error: 'Trip not found' });
if (!verifyTripAccess(Number(tripId), authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND note_id = ?').get(fileId, id);
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND note_id = ?').get(fileId, id) as TripFile | undefined;
if (!file) return res.status(404).json({ error: 'File not found' });
// Delete physical file
const filePath = path.join(__dirname, '../../uploads', file.filename);
try { fs.unlinkSync(filePath) } catch {}
db.prepare('DELETE FROM trip_files WHERE id = ?').run(fileId);
res.json({ success: true });
broadcast(Number(tripId), 'collab:note:updated', { note: formatNote(db.prepare('SELECT n.*, u.username, u.avatar FROM collab_notes n JOIN users u ON n.user_id = u.id WHERE n.id = ?').get(id)) }, req.headers['x-socket-id']);
broadcast(Number(tripId), 'collab:note:updated', { note: formatNote(db.prepare('SELECT n.*, u.username, u.avatar FROM collab_notes n JOIN users u ON n.user_id = u.id WHERE n.id = ?').get(id) as CollabNote) }, req.headers['x-socket-id'] as string);
});
// ─── POLLS ───────────────────────────────────────────────────────────────────
function getPollWithVotes(pollId) {
function getPollWithVotes(pollId: number | bigint | string) {
const poll = db.prepare(`
SELECT p.*, u.username, u.avatar
FROM collab_polls p
JOIN users u ON p.user_id = u.id
WHERE p.id = ?
`).get(pollId);
`).get(pollId) as CollabPoll | undefined;
if (!poll) return null;
const options = JSON.parse(poll.options);
const options: (string | { label: string })[] = JSON.parse(poll.options);
const votes = db.prepare(`
SELECT v.option_index, v.user_id, u.username, u.avatar
FROM collab_poll_votes v
JOIN users u ON v.user_id = u.id
WHERE v.poll_id = ?
`).all(pollId);
`).all(pollId) as PollVoteRow[];
// Transform: nest voters into each option (frontend expects options[i].voters)
const formattedOptions = options.map((label, idx) => ({
const formattedOptions = options.map((label: string | { label: string }, idx: number) => ({
label: typeof label === 'string' ? label : label.label || label,
voters: votes
.filter(v => v.option_index === idx)
@@ -228,49 +254,48 @@ function getPollWithVotes(pollId) {
};
}
// GET /polls
router.get('/polls', authenticate, (req, res) => {
router.get('/polls', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
if (!verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
const rows = db.prepare(`
SELECT id FROM collab_polls WHERE trip_id = ? ORDER BY created_at DESC
`).all(tripId);
`).all(tripId) as { id: number }[];
const polls = rows.map(row => getPollWithVotes(row.id)).filter(Boolean);
res.json({ polls });
});
// POST /polls
router.post('/polls', authenticate, (req, res) => {
router.post('/polls', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const { question, options, multiple, multiple_choice, deadline } = req.body;
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
if (!verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
if (!question) return res.status(400).json({ error: 'Question is required' });
if (!Array.isArray(options) || options.length < 2) {
return res.status(400).json({ error: 'At least 2 options are required' });
}
// Accept both 'multiple' and 'multiple_choice' from frontend
const isMultiple = multiple || multiple_choice;
const result = db.prepare(`
INSERT INTO collab_polls (trip_id, user_id, question, options, multiple, deadline)
VALUES (?, ?, ?, ?, ?, ?)
`).run(tripId, req.user.id, question, JSON.stringify(options), isMultiple ? 1 : 0, deadline || null);
`).run(tripId, authReq.user.id, question, JSON.stringify(options), isMultiple ? 1 : 0, deadline || null);
const poll = getPollWithVotes(result.lastInsertRowid);
res.status(201).json({ poll });
broadcast(tripId, 'collab:poll:created', { poll }, req.headers['x-socket-id']);
broadcast(tripId, 'collab:poll:created', { poll }, req.headers['x-socket-id'] as string);
});
// POST /polls/:id/vote
router.post('/polls/:id/vote', authenticate, (req, res) => {
router.post('/polls/:id/vote', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
const { option_index } = req.body;
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
if (!verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
const poll = db.prepare('SELECT * FROM collab_polls WHERE id = ? AND trip_id = ?').get(id, tripId);
const poll = db.prepare('SELECT * FROM collab_polls WHERE id = ? AND trip_id = ?').get(id, tripId) as CollabPoll | undefined;
if (!poll) return res.status(404).json({ error: 'Poll not found' });
if (poll.closed) return res.status(400).json({ error: 'Poll is closed' });
@@ -279,29 +304,28 @@ router.post('/polls/:id/vote', authenticate, (req, res) => {
return res.status(400).json({ error: 'Invalid option index' });
}
// Toggle: if vote exists, remove it; otherwise add it
const existingVote = db.prepare(
'SELECT id FROM collab_poll_votes WHERE poll_id = ? AND user_id = ? AND option_index = ?'
).get(id, req.user.id, option_index);
).get(id, authReq.user.id, option_index) as { id: number } | undefined;
if (existingVote) {
db.prepare('DELETE FROM collab_poll_votes WHERE id = ?').run(existingVote.id);
} else {
if (!poll.multiple) {
db.prepare('DELETE FROM collab_poll_votes WHERE poll_id = ? AND user_id = ?').run(id, req.user.id);
db.prepare('DELETE FROM collab_poll_votes WHERE poll_id = ? AND user_id = ?').run(id, authReq.user.id);
}
db.prepare('INSERT INTO collab_poll_votes (poll_id, user_id, option_index) VALUES (?, ?, ?)').run(id, req.user.id, option_index);
db.prepare('INSERT INTO collab_poll_votes (poll_id, user_id, option_index) VALUES (?, ?, ?)').run(id, authReq.user.id, option_index);
}
const updatedPoll = getPollWithVotes(id);
res.json({ poll: updatedPoll });
broadcast(tripId, 'collab:poll:voted', { poll: updatedPoll }, req.headers['x-socket-id']);
broadcast(tripId, 'collab:poll:voted', { poll: updatedPoll }, req.headers['x-socket-id'] as string);
});
// PUT /polls/:id/close
router.put('/polls/:id/close', authenticate, (req, res) => {
router.put('/polls/:id/close', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
if (!verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
const poll = db.prepare('SELECT * FROM collab_polls WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!poll) return res.status(404).json({ error: 'Poll not found' });
@@ -310,29 +334,27 @@ router.put('/polls/:id/close', authenticate, (req, res) => {
const updatedPoll = getPollWithVotes(id);
res.json({ poll: updatedPoll });
broadcast(tripId, 'collab:poll:closed', { poll: updatedPoll }, req.headers['x-socket-id']);
broadcast(tripId, 'collab:poll:closed', { poll: updatedPoll }, req.headers['x-socket-id'] as string);
});
// DELETE /polls/:id
router.delete('/polls/:id', authenticate, (req, res) => {
router.delete('/polls/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
if (!verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
const poll = db.prepare('SELECT id FROM collab_polls WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!poll) return res.status(404).json({ error: 'Poll not found' });
db.prepare('DELETE FROM collab_polls WHERE id = ?').run(id);
res.json({ success: true });
broadcast(tripId, 'collab:poll:deleted', { pollId: Number(id) }, req.headers['x-socket-id']);
broadcast(tripId, 'collab:poll:deleted', { pollId: Number(id) }, req.headers['x-socket-id'] as string);
});
// ─── MESSAGES (CHAT) ────────────────────────────────────────────────────────
// GET /messages
router.get('/messages', authenticate, (req, res) => {
router.get('/messages', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const { before } = req.query;
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
if (!verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
const query = `
SELECT m.*, u.username, u.avatar,
@@ -347,20 +369,19 @@ router.get('/messages', authenticate, (req, res) => {
`;
const messages = before
? db.prepare(query).all(tripId, before)
: db.prepare(query).all(tripId);
? db.prepare(query).all(tripId, before) as CollabMessage[]
: db.prepare(query).all(tripId) as CollabMessage[];
messages.reverse();
// Batch-load reactions
const msgIds = messages.map(m => m.id);
const reactionsByMsg = {};
const reactionsByMsg: Record<number, ReactionRow[]> = {};
if (msgIds.length > 0) {
const allReactions = db.prepare(`
SELECT r.message_id, r.emoji, r.user_id, u.username
FROM collab_message_reactions r
JOIN users u ON r.user_id = u.id
WHERE r.message_id IN (${msgIds.map(() => '?').join(',')})
`).all(...msgIds);
`).all(...msgIds) as (ReactionRow & { message_id: number })[];
for (const r of allReactions) {
if (!reactionsByMsg[r.message_id]) reactionsByMsg[r.message_id] = [];
reactionsByMsg[r.message_id].push(r);
@@ -369,11 +390,11 @@ router.get('/messages', authenticate, (req, res) => {
res.json({ messages: messages.map(m => formatMessage(m, groupReactions(reactionsByMsg[m.id] || []))) });
});
// POST /messages
router.post('/messages', authenticate, (req, res) => {
router.post('/messages', authenticate, validateStringLengths({ text: 5000 }), (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const { text, reply_to } = req.body;
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
if (!verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
if (!text || !text.trim()) return res.status(400).json({ error: 'Message text is required' });
if (reply_to) {
@@ -383,7 +404,7 @@ router.post('/messages', authenticate, (req, res) => {
const result = db.prepare(`
INSERT INTO collab_messages (trip_id, user_id, text, reply_to) VALUES (?, ?, ?, ?)
`).run(tripId, req.user.id, text.trim(), reply_to || null);
`).run(tripId, authReq.user.id, text.trim(), reply_to || null);
const message = db.prepare(`
SELECT m.*, u.username, u.avatar,
@@ -393,71 +414,93 @@ router.post('/messages', authenticate, (req, res) => {
LEFT JOIN collab_messages rm ON m.reply_to = rm.id
LEFT JOIN users ru ON rm.user_id = ru.id
WHERE m.id = ?
`).get(result.lastInsertRowid);
`).get(result.lastInsertRowid) as CollabMessage;
const formatted = formatMessage(message);
res.status(201).json({ message: formatted });
broadcast(tripId, 'collab:message:created', { message: formatted }, req.headers['x-socket-id']);
broadcast(tripId, 'collab:message:created', { message: formatted }, req.headers['x-socket-id'] as string);
});
// POST /messages/:id/react — toggle emoji reaction
router.post('/messages/:id/react', authenticate, (req, res) => {
router.post('/messages/:id/react', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
const { emoji } = req.body;
if (!verifyTripAccess(Number(tripId), req.user.id)) return res.status(404).json({ error: 'Trip not found' });
if (!verifyTripAccess(Number(tripId), authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
if (!emoji) return res.status(400).json({ error: 'Emoji is required' });
const msg = db.prepare('SELECT id FROM collab_messages WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!msg) return res.status(404).json({ error: 'Message not found' });
const existing = db.prepare('SELECT id FROM collab_message_reactions WHERE message_id = ? AND user_id = ? AND emoji = ?').get(id, req.user.id, emoji);
const existing = db.prepare('SELECT id FROM collab_message_reactions WHERE message_id = ? AND user_id = ? AND emoji = ?').get(id, authReq.user.id, emoji) as { id: number } | undefined;
if (existing) {
db.prepare('DELETE FROM collab_message_reactions WHERE id = ?').run(existing.id);
} else {
db.prepare('INSERT INTO collab_message_reactions (message_id, user_id, emoji) VALUES (?, ?, ?)').run(id, req.user.id, emoji);
db.prepare('INSERT INTO collab_message_reactions (message_id, user_id, emoji) VALUES (?, ?, ?)').run(id, authReq.user.id, emoji);
}
const reactions = groupReactions(loadReactions(id));
res.json({ reactions });
broadcast(Number(tripId), 'collab:message:reacted', { messageId: Number(id), reactions }, req.headers['x-socket-id']);
broadcast(Number(tripId), 'collab:message:reacted', { messageId: Number(id), reactions }, req.headers['x-socket-id'] as string);
});
// DELETE /messages/:id (soft-delete)
router.delete('/messages/:id', authenticate, (req, res) => {
router.delete('/messages/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
if (!verifyTripAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
if (!verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
const message = db.prepare('SELECT * FROM collab_messages WHERE id = ? AND trip_id = ?').get(id, tripId);
const message = db.prepare('SELECT * FROM collab_messages WHERE id = ? AND trip_id = ?').get(id, tripId) as CollabMessage | undefined;
if (!message) return res.status(404).json({ error: 'Message not found' });
if (Number(message.user_id) !== Number(req.user.id)) return res.status(403).json({ error: 'You can only delete your own messages' });
if (Number(message.user_id) !== Number(authReq.user.id)) return res.status(403).json({ error: 'You can only delete your own messages' });
db.prepare('UPDATE collab_messages SET deleted = 1 WHERE id = ?').run(id);
res.json({ success: true });
broadcast(tripId, 'collab:message:deleted', { messageId: Number(id), username: message.username || req.user.username }, req.headers['x-socket-id']);
broadcast(tripId, 'collab:message:deleted', { messageId: Number(id), username: message.username || authReq.user.username }, req.headers['x-socket-id'] as string);
});
// ─── LINK PREVIEW ────────────────────────────────────────────────────────────
router.get('/link-preview', authenticate, (req, res) => {
const { url } = req.query;
router.get('/link-preview', authenticate, async (req: Request, res: Response) => {
const { url } = req.query as { url?: string };
if (!url) return res.status(400).json({ error: 'URL is required' });
try {
const fetch = require('node-fetch');
const parsed = new URL(url);
if (!['http:', 'https:'].includes(parsed.protocol)) {
return res.status(400).json({ error: 'Only HTTP(S) URLs are allowed' });
}
const hostname = parsed.hostname.toLowerCase();
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' ||
hostname === '0.0.0.0' || hostname.endsWith('.local') || hostname.endsWith('.internal') ||
/^10\./.test(hostname) || /^172\.(1[6-9]|2\d|3[01])\./.test(hostname) || /^192\.168\./.test(hostname) ||
/^169\.254\./.test(hostname) || hostname === '[::1]' || hostname.startsWith('fc') || hostname.startsWith('fd') || hostname.startsWith('fe80')) {
return res.status(400).json({ error: 'Private/internal URLs are not allowed' });
}
const dns = require('dns').promises;
let resolved: { address: string };
try {
resolved = await dns.lookup(parsed.hostname);
} catch {
return res.status(400).json({ error: 'Could not resolve hostname' });
}
const ip = resolved.address;
if (/^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|0\.|169\.254\.|::1|::ffff:(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.))/.test(ip)) {
return res.status(400).json({ error: 'Private/internal URLs are not allowed' });
}
const nodeFetch = require('node-fetch');
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
fetch(url, {
nodeFetch(url, { redirect: 'error',
signal: controller.signal,
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; NOMAD/1.0; +https://github.com/mauriceboe/NOMAD)' },
})
.then(r => {
.then((r: { ok: boolean; text: () => Promise<string> }) => {
clearTimeout(timeout);
if (!r.ok) throw new Error('Fetch failed');
return r.text();
})
.then(html => {
const get = (prop) => {
.then((html: string) => {
const get = (prop: string) => {
const m = html.match(new RegExp(`<meta[^>]*property=["']og:${prop}["'][^>]*content=["']([^"']*)["']`, 'i'))
|| html.match(new RegExp(`<meta[^>]*content=["']([^"']*)["'][^>]*property=["']og:${prop}["']`, 'i'));
return m ? m[1] : null;
@@ -483,4 +526,4 @@ router.get('/link-preview', authenticate, (req, res) => {
}
});
module.exports = router;
export default router;

View File

@@ -1,18 +1,20 @@
const express = require('express');
const { db, canAccessTrip } = require('../db/database');
const { authenticate } = require('../middleware/auth');
const { broadcast } = require('../websocket');
import express, { Request, Response } from 'express';
import { db, canAccessTrip } from '../db/database';
import { authenticate } from '../middleware/auth';
import { broadcast } from '../websocket';
import { validateStringLengths } from '../middleware/validate';
import { AuthRequest, DayNote } from '../types';
const router = express.Router({ mergeParams: true });
function verifyAccess(tripId, userId) {
function verifyAccess(tripId: string | number, userId: number) {
return canAccessTrip(tripId, userId);
}
// GET /api/trips/:tripId/days/:dayId/notes
router.get('/', authenticate, (req, res) => {
router.get('/', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, dayId } = req.params;
if (!verifyAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
if (!verifyAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
const notes = db.prepare(
'SELECT * FROM day_notes WHERE day_id = ? AND trip_id = ? ORDER BY sort_order ASC, created_at ASC'
@@ -21,10 +23,10 @@ router.get('/', authenticate, (req, res) => {
res.json({ notes });
});
// POST /api/trips/:tripId/days/:dayId/notes
router.post('/', authenticate, (req, res) => {
router.post('/', authenticate, validateStringLengths({ text: 500, time: 150 }), (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, dayId } = req.params;
if (!verifyAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
if (!verifyAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
if (!day) return res.status(404).json({ error: 'Day not found' });
@@ -34,19 +36,19 @@ router.post('/', authenticate, (req, res) => {
const result = db.prepare(
'INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order) VALUES (?, ?, ?, ?, ?, ?)'
).run(dayId, tripId, text.trim(), time || null, icon || '📝', sort_order ?? 9999);
).run(dayId, tripId, text.trim(), time || null, icon || '\uD83D\uDCDD', sort_order ?? 9999);
const note = db.prepare('SELECT * FROM day_notes WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json({ note });
broadcast(tripId, 'dayNote:created', { dayId: Number(dayId), note }, req.headers['x-socket-id']);
broadcast(tripId, 'dayNote:created', { dayId: Number(dayId), note }, req.headers['x-socket-id'] as string);
});
// PUT /api/trips/:tripId/days/:dayId/notes/:id
router.put('/:id', authenticate, (req, res) => {
router.put('/:id', authenticate, validateStringLengths({ text: 500, time: 150 }), (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, dayId, id } = req.params;
if (!verifyAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
if (!verifyAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
const note = db.prepare('SELECT * FROM day_notes WHERE id = ? AND day_id = ? AND trip_id = ?').get(id, dayId, tripId);
const note = db.prepare('SELECT * FROM day_notes WHERE id = ? AND day_id = ? AND trip_id = ?').get(id, dayId, tripId) as DayNote | undefined;
if (!note) return res.status(404).json({ error: 'Note not found' });
const { text, time, icon, sort_order } = req.body;
@@ -62,20 +64,20 @@ router.put('/:id', authenticate, (req, res) => {
const updated = db.prepare('SELECT * FROM day_notes WHERE id = ?').get(id);
res.json({ note: updated });
broadcast(tripId, 'dayNote:updated', { dayId: Number(dayId), note: updated }, req.headers['x-socket-id']);
broadcast(tripId, 'dayNote:updated', { dayId: Number(dayId), note: updated }, req.headers['x-socket-id'] as string);
});
// DELETE /api/trips/:tripId/days/:dayId/notes/:id
router.delete('/:id', authenticate, (req, res) => {
router.delete('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, dayId, id } = req.params;
if (!verifyAccess(tripId, req.user.id)) return res.status(404).json({ error: 'Trip not found' });
if (!verifyAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
const note = db.prepare('SELECT id FROM day_notes WHERE id = ? AND day_id = ? AND trip_id = ?').get(id, dayId, tripId);
if (!note) return res.status(404).json({ error: 'Note not found' });
db.prepare('DELETE FROM day_notes WHERE id = ?').run(id);
res.json({ success: true });
broadcast(tripId, 'dayNote:deleted', { noteId: Number(id), dayId: Number(dayId) }, req.headers['x-socket-id']);
broadcast(tripId, 'dayNote:deleted', { noteId: Number(id), dayId: Number(dayId) }, req.headers['x-socket-id'] as string);
});
module.exports = router;
export default router;

View File

@@ -1,15 +1,14 @@
const express = require('express');
const { db, canAccessTrip } = require('../db/database');
const { authenticate } = require('../middleware/auth');
const { broadcast } = require('../websocket');
import express, { Request, Response } from 'express';
import { db } from '../db/database';
import { authenticate } from '../middleware/auth';
import { requireTripAccess } from '../middleware/tripAccess';
import { broadcast } from '../websocket';
import { loadTagsByPlaceIds, loadParticipantsByAssignmentIds, formatAssignmentWithPlace } from '../services/queryHelpers';
import { AuthRequest, AssignmentRow, Day, DayNote } from '../types';
const router = express.Router({ mergeParams: true });
function verifyTripOwnership(tripId, userId) {
return canAccessTrip(tripId, userId);
}
function getAssignmentsForDay(dayId) {
function getAssignmentsForDay(dayId: number | string) {
const assignments = db.prepare(`
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
@@ -23,7 +22,7 @@ function getAssignmentsForDay(dayId) {
LEFT JOIN categories c ON p.category_id = c.id
WHERE da.day_id = ?
ORDER BY da.order_index ASC, da.created_at ASC
`).all(dayId);
`).all(dayId) as AssignmentRow[];
return assignments.map(a => {
const tags = db.prepare(`
@@ -69,16 +68,10 @@ function getAssignmentsForDay(dayId) {
});
}
// GET /api/trips/:tripId/days
router.get('/', authenticate, (req, res) => {
router.get('/', authenticate, requireTripAccess, (req: Request, res: Response) => {
const { tripId } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) {
return res.status(404).json({ error: 'Trip not found' });
}
const days = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(tripId);
const days = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(tripId) as Day[];
if (days.length === 0) {
return res.json({ days: [] });
@@ -87,7 +80,6 @@ router.get('/', authenticate, (req, res) => {
const dayIds = days.map(d => d.id);
const dayPlaceholders = dayIds.map(() => '?').join(',');
// Load ALL assignments for all days in one query
const allAssignments = db.prepare(`
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
@@ -101,82 +93,24 @@ router.get('/', authenticate, (req, res) => {
LEFT JOIN categories c ON p.category_id = c.id
WHERE da.day_id IN (${dayPlaceholders})
ORDER BY da.order_index ASC, da.created_at ASC
`).all(...dayIds);
`).all(...dayIds) as AssignmentRow[];
// Batch-load ALL tags for all places across all assignments
const placeIds = [...new Set(allAssignments.map(a => a.place_id))];
const tagsByPlaceId = {};
if (placeIds.length > 0) {
const placePlaceholders = placeIds.map(() => '?').join(',');
const allTags = db.prepare(`
SELECT t.*, pt.place_id FROM tags t
JOIN place_tags pt ON t.id = pt.tag_id
WHERE pt.place_id IN (${placePlaceholders})
`).all(...placeIds);
for (const tag of allTags) {
if (!tagsByPlaceId[tag.place_id]) tagsByPlaceId[tag.place_id] = [];
tagsByPlaceId[tag.place_id].push({ id: tag.id, name: tag.name, color: tag.color, created_at: tag.created_at });
}
}
const tagsByPlaceId = loadTagsByPlaceIds(placeIds, { compact: true });
// Group assignments by day_id
const assignmentsByDayId = {};
// Load all participants for all assignments
const allAssignmentIds = allAssignments.map(a => a.id)
const allParticipants = allAssignmentIds.length > 0
? db.prepare(`SELECT ap.assignment_id, ap.user_id, u.username, u.avatar FROM assignment_participants ap JOIN users u ON ap.user_id = u.id WHERE ap.assignment_id IN (${allAssignmentIds.map(() => '?').join(',')})`)
.all(...allAssignmentIds)
: []
const participantsByAssignment = {}
for (const p of allParticipants) {
if (!participantsByAssignment[p.assignment_id]) participantsByAssignment[p.assignment_id] = []
participantsByAssignment[p.assignment_id].push({ user_id: p.user_id, username: p.username, avatar: p.avatar })
}
const allAssignmentIds = allAssignments.map(a => a.id);
const participantsByAssignment = loadParticipantsByAssignmentIds(allAssignmentIds);
const assignmentsByDayId: Record<number, ReturnType<typeof formatAssignmentWithPlace>[]> = {};
for (const a of allAssignments) {
if (!assignmentsByDayId[a.day_id]) assignmentsByDayId[a.day_id] = [];
assignmentsByDayId[a.day_id].push({
id: a.id,
day_id: a.day_id,
order_index: a.order_index,
notes: a.notes,
participants: participantsByAssignment[a.id] || [],
created_at: a.created_at,
place: {
id: a.place_id,
name: a.place_name,
description: a.place_description,
lat: a.lat,
lng: a.lng,
address: a.address,
category_id: a.category_id,
price: a.price,
currency: a.place_currency,
place_time: a.place_time,
end_time: a.end_time,
duration_minutes: a.duration_minutes,
notes: a.place_notes,
image_url: a.image_url,
transport_mode: a.transport_mode,
google_place_id: a.google_place_id,
website: a.website,
phone: a.phone,
category: a.category_id ? {
id: a.category_id,
name: a.category_name,
color: a.category_color,
icon: a.category_icon,
} : null,
tags: tagsByPlaceId[a.place_id] || [],
}
});
assignmentsByDayId[a.day_id].push(formatAssignmentWithPlace(a, tagsByPlaceId[a.place_id] || [], participantsByAssignment[a.id] || []));
}
// Load ALL day_notes for all days in one query
const allNotes = db.prepare(
`SELECT * FROM day_notes WHERE day_id IN (${dayPlaceholders}) ORDER BY sort_order ASC, created_at ASC`
).all(...dayIds);
const notesByDayId = {};
).all(...dayIds) as DayNote[];
const notesByDayId: Record<number, DayNote[]> = {};
for (const note of allNotes) {
if (!notesByDayId[note.day_id]) notesByDayId[note.day_id] = [];
notesByDayId[note.day_id].push(note);
@@ -191,41 +125,28 @@ router.get('/', authenticate, (req, res) => {
res.json({ days: daysWithAssignments });
});
// POST /api/trips/:tripId/days
router.post('/', authenticate, (req, res) => {
router.post('/', authenticate, requireTripAccess, (req: Request, res: Response) => {
const { tripId } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) {
return res.status(404).json({ error: 'Trip not found' });
}
const { date, notes } = req.body;
const maxDay = db.prepare('SELECT MAX(day_number) as max FROM days WHERE trip_id = ?').get(tripId);
const maxDay = db.prepare('SELECT MAX(day_number) as max FROM days WHERE trip_id = ?').get(tripId) as { max: number | null };
const dayNumber = (maxDay.max || 0) + 1;
const result = db.prepare(
'INSERT INTO days (trip_id, day_number, date, notes) VALUES (?, ?, ?, ?)'
).run(tripId, dayNumber, date || null, notes || null);
const day = db.prepare('SELECT * FROM days WHERE id = ?').get(result.lastInsertRowid);
const day = db.prepare('SELECT * FROM days WHERE id = ?').get(result.lastInsertRowid) as Day;
const dayResult = { ...day, assignments: [] };
res.status(201).json({ day: dayResult });
broadcast(tripId, 'day:created', { day: dayResult }, req.headers['x-socket-id']);
broadcast(tripId, 'day:created', { day: dayResult }, req.headers['x-socket-id'] as string);
});
// PUT /api/trips/:tripId/days/:id
router.put('/:id', authenticate, (req, res) => {
router.put('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
const { tripId, id } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) {
return res.status(404).json({ error: 'Trip not found' });
}
const day = db.prepare('SELECT * FROM days WHERE id = ? AND trip_id = ?').get(id, tripId);
const day = db.prepare('SELECT * FROM days WHERE id = ? AND trip_id = ?').get(id, tripId) as Day | undefined;
if (!day) {
return res.status(404).json({ error: 'Day not found' });
}
@@ -233,21 +154,15 @@ router.put('/:id', authenticate, (req, res) => {
const { notes, title } = req.body;
db.prepare('UPDATE days SET notes = ?, title = ? WHERE id = ?').run(notes || null, title !== undefined ? title : day.title, id);
const updatedDay = db.prepare('SELECT * FROM days WHERE id = ?').get(id);
const updatedDay = db.prepare('SELECT * FROM days WHERE id = ?').get(id) as Day;
const dayWithAssignments = { ...updatedDay, assignments: getAssignmentsForDay(id) };
res.json({ day: dayWithAssignments });
broadcast(tripId, 'day:updated', { day: dayWithAssignments }, req.headers['x-socket-id']);
broadcast(tripId, 'day:updated', { day: dayWithAssignments }, req.headers['x-socket-id'] as string);
});
// DELETE /api/trips/:tripId/days/:id
router.delete('/:id', authenticate, (req, res) => {
router.delete('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
const { tripId, id } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) {
return res.status(404).json({ error: 'Trip not found' });
}
const day = db.prepare('SELECT * FROM days WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!day) {
return res.status(404).json({ error: 'Day not found' });
@@ -255,13 +170,12 @@ router.delete('/:id', authenticate, (req, res) => {
db.prepare('DELETE FROM days WHERE id = ?').run(id);
res.json({ success: true });
broadcast(tripId, 'day:deleted', { dayId: Number(id) }, req.headers['x-socket-id']);
broadcast(tripId, 'day:deleted', { dayId: Number(id) }, req.headers['x-socket-id'] as string);
});
// === Accommodation routes ===
const accommodationsRouter = express.Router({ mergeParams: true });
function getAccommodationWithPlace(id) {
function getAccommodationWithPlace(id: number | bigint) {
return db.prepare(`
SELECT a.*, p.name as place_name, p.address as place_address, p.image_url as place_image, p.lat as place_lat, p.lng as place_lng
FROM day_accommodations a
@@ -270,15 +184,9 @@ function getAccommodationWithPlace(id) {
`).get(id);
}
// GET /api/trips/:tripId/accommodations
accommodationsRouter.get('/', authenticate, (req, res) => {
accommodationsRouter.get('/', authenticate, requireTripAccess, (req: Request, res: Response) => {
const { tripId } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) {
return res.status(404).json({ error: 'Trip not found' });
}
const accommodations = db.prepare(`
SELECT a.*, p.name as place_name, p.address as place_address, p.image_url as place_image, p.lat as place_lat, p.lng as place_lng
FROM day_accommodations a
@@ -290,15 +198,8 @@ accommodationsRouter.get('/', authenticate, (req, res) => {
res.json({ accommodations });
});
// POST /api/trips/:tripId/accommodations
accommodationsRouter.post('/', authenticate, (req, res) => {
accommodationsRouter.post('/', authenticate, requireTripAccess, (req: Request, res: Response) => {
const { tripId } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) {
return res.status(404).json({ error: 'Trip not found' });
}
const { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes } = req.body;
if (!place_id || !start_day_id || !end_day_id) {
@@ -306,19 +207,13 @@ accommodationsRouter.post('/', authenticate, (req, res) => {
}
const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(place_id, tripId);
if (!place) {
return res.status(404).json({ error: 'Place not found' });
}
if (!place) return res.status(404).json({ error: 'Place not found' });
const startDay = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(start_day_id, tripId);
if (!startDay) {
return res.status(404).json({ error: 'Start day not found' });
}
if (!startDay) return res.status(404).json({ error: 'Start day not found' });
const endDay = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(end_day_id, tripId);
if (!endDay) {
return res.status(404).json({ error: 'End day not found' });
}
if (!endDay) return res.status(404).json({ error: 'End day not found' });
const result = db.prepare(
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
@@ -326,22 +221,15 @@ accommodationsRouter.post('/', authenticate, (req, res) => {
const accommodation = getAccommodationWithPlace(result.lastInsertRowid);
res.status(201).json({ accommodation });
broadcast(tripId, 'accommodation:created', { accommodation }, req.headers['x-socket-id']);
broadcast(tripId, 'accommodation:created', { accommodation }, req.headers['x-socket-id'] as string);
});
// PUT /api/trips/:tripId/accommodations/:id
accommodationsRouter.put('/:id', authenticate, (req, res) => {
accommodationsRouter.put('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
const { tripId, id } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) {
return res.status(404).json({ error: 'Trip not found' });
}
const existing = db.prepare('SELECT * FROM day_accommodations WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!existing) {
return res.status(404).json({ error: 'Accommodation not found' });
}
interface DayAccommodation { id: number; trip_id: number; place_id: number; start_day_id: number; end_day_id: number; check_in: string | null; check_out: string | null; confirmation: string | null; notes: string | null; }
const existing = db.prepare('SELECT * FROM day_accommodations WHERE id = ? AND trip_id = ?').get(id, tripId) as DayAccommodation | undefined;
if (!existing) return res.status(404).json({ error: 'Accommodation not found' });
const { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes } = req.body;
@@ -355,52 +243,38 @@ accommodationsRouter.put('/:id', authenticate, (req, res) => {
if (place_id !== undefined) {
const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(place_id, tripId);
if (!place) {
return res.status(404).json({ error: 'Place not found' });
}
if (!place) return res.status(404).json({ error: 'Place not found' });
}
if (start_day_id !== undefined) {
const startDay = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(start_day_id, tripId);
if (!startDay) {
return res.status(404).json({ error: 'Start day not found' });
}
if (!startDay) return res.status(404).json({ error: 'Start day not found' });
}
if (end_day_id !== undefined) {
const endDay = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(end_day_id, tripId);
if (!endDay) {
return res.status(404).json({ error: 'End day not found' });
}
if (!endDay) return res.status(404).json({ error: 'End day not found' });
}
db.prepare(
'UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ?, notes = ? WHERE id = ?'
).run(newPlaceId, newStartDayId, newEndDayId, newCheckIn, newCheckOut, newConfirmation, newNotes, id);
const accommodation = getAccommodationWithPlace(id);
const accommodation = getAccommodationWithPlace(Number(id));
res.json({ accommodation });
broadcast(tripId, 'accommodation:updated', { accommodation }, req.headers['x-socket-id']);
broadcast(tripId, 'accommodation:updated', { accommodation }, req.headers['x-socket-id'] as string);
});
// DELETE /api/trips/:tripId/accommodations/:id
accommodationsRouter.delete('/:id', authenticate, (req, res) => {
accommodationsRouter.delete('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
const { tripId, id } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) {
return res.status(404).json({ error: 'Trip not found' });
}
const existing = db.prepare('SELECT * FROM day_accommodations WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!existing) {
return res.status(404).json({ error: 'Accommodation not found' });
}
if (!existing) return res.status(404).json({ error: 'Accommodation not found' });
db.prepare('DELETE FROM day_accommodations WHERE id = ?').run(id);
res.json({ success: true });
broadcast(tripId, 'accommodation:deleted', { accommodationId: Number(id) }, req.headers['x-socket-id']);
broadcast(tripId, 'accommodation:deleted', { accommodationId: Number(id) }, req.headers['x-socket-id'] as string);
});
module.exports = router;
module.exports.accommodationsRouter = accommodationsRouter;
export default router;
export { accommodationsRouter };

View File

@@ -1,22 +1,25 @@
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { v4: uuidv4 } = require('uuid');
const { db, canAccessTrip } = require('../db/database');
const { authenticate, demoUploadBlock } = require('../middleware/auth');
const { broadcast } = require('../websocket');
import express, { Request, Response } from 'express';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { v4 as uuidv4 } from 'uuid';
import { db, canAccessTrip } from '../db/database';
import { authenticate, demoUploadBlock } from '../middleware/auth';
import { requireTripAccess } from '../middleware/tripAccess';
import { broadcast } from '../websocket';
import { AuthRequest, TripFile } from '../types';
const router = express.Router({ mergeParams: true });
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
const filesDir = path.join(__dirname, '../../uploads/files');
const storage = multer.diskStorage({
destination: (req, file, cb) => {
destination: (_req, _file, cb) => {
if (!fs.existsSync(filesDir)) fs.mkdirSync(filesDir, { recursive: true });
cb(null, filesDir);
},
filename: (req, file, cb) => {
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `${uuidv4()}${ext}`);
},
@@ -25,17 +28,17 @@ const storage = multer.diskStorage({
const DEFAULT_ALLOWED_EXTENSIONS = 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv';
const BLOCKED_EXTENSIONS = ['.svg', '.html', '.htm', '.xml'];
function getAllowedExtensions() {
function getAllowedExtensions(): string {
try {
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'allowed_file_types'").get();
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'allowed_file_types'").get() as { value: string } | undefined;
return row?.value || DEFAULT_ALLOWED_EXTENSIONS;
} catch { return DEFAULT_ALLOWED_EXTENSIONS; }
}
const upload = multer({
storage,
limits: { fileSize: 50 * 1024 * 1024 }, // 50MB
fileFilter: (req, file, cb) => {
limits: { fileSize: MAX_FILE_SIZE },
fileFilter: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
if (BLOCKED_EXTENSIONS.includes(ext) || file.mimetype.includes('svg')) {
return cb(new Error('File type not allowed'));
@@ -50,22 +53,22 @@ const upload = multer({
},
});
function verifyTripOwnership(tripId, userId) {
function verifyTripOwnership(tripId: string | number, userId: number) {
return canAccessTrip(tripId, userId);
}
function formatFile(file) {
function formatFile(file: TripFile) {
return {
...file,
url: file.filename?.startsWith('files/') ? `/uploads/${file.filename}` : `/uploads/files/${file.filename}`,
};
}
// GET /api/trips/:tripId/files
router.get('/', authenticate, (req, res) => {
router.get('/', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const files = db.prepare(`
@@ -74,21 +77,14 @@ router.get('/', authenticate, (req, res) => {
LEFT JOIN reservations r ON f.reservation_id = r.id
WHERE f.trip_id = ?
ORDER BY f.created_at DESC
`).all(tripId);
`).all(tripId) as TripFile[];
res.json({ files: files.map(formatFile) });
});
// POST /api/trips/:tripId/files
router.post('/', authenticate, demoUploadBlock, upload.single('file'), (req, res) => {
router.post('/', authenticate, requireTripAccess, demoUploadBlock, upload.single('file'), (req: Request, res: Response) => {
const { tripId } = req.params;
const { place_id, description, reservation_id } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) {
if (req.file) fs.unlinkSync(req.file.path);
return res.status(404).json({ error: 'Trip not found' });
}
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
@@ -112,20 +108,20 @@ router.post('/', authenticate, demoUploadBlock, upload.single('file'), (req, res
FROM trip_files f
LEFT JOIN reservations r ON f.reservation_id = r.id
WHERE f.id = ?
`).get(result.lastInsertRowid);
`).get(result.lastInsertRowid) as TripFile;
res.status(201).json({ file: formatFile(file) });
broadcast(tripId, 'file:created', { file: formatFile(file) }, req.headers['x-socket-id']);
broadcast(tripId, 'file:created', { file: formatFile(file) }, req.headers['x-socket-id'] as string);
});
// PUT /api/trips/:tripId/files/:id
router.put('/:id', authenticate, (req, res) => {
router.put('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
const { description, place_id, reservation_id } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id);
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, tripId);
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, tripId) as TripFile | undefined;
if (!file) return res.status(404).json({ error: 'File not found' });
db.prepare(`
@@ -146,19 +142,19 @@ router.put('/:id', authenticate, (req, res) => {
FROM trip_files f
LEFT JOIN reservations r ON f.reservation_id = r.id
WHERE f.id = ?
`).get(id);
`).get(id) as TripFile;
res.json({ file: formatFile(updated) });
broadcast(tripId, 'file:updated', { file: formatFile(updated) }, req.headers['x-socket-id']);
broadcast(tripId, 'file:updated', { file: formatFile(updated) }, req.headers['x-socket-id'] as string);
});
// DELETE /api/trips/:tripId/files/:id
router.delete('/:id', authenticate, (req, res) => {
router.delete('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, tripId);
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, tripId) as TripFile | undefined;
if (!file) return res.status(404).json({ error: 'File not found' });
const filePath = path.join(filesDir, file.filename);
@@ -168,7 +164,7 @@ router.delete('/:id', authenticate, (req, res) => {
db.prepare('DELETE FROM trip_files WHERE id = ?').run(id);
res.json({ success: true });
broadcast(tripId, 'file:deleted', { fileId: Number(id) }, req.headers['x-socket-id']);
broadcast(tripId, 'file:deleted', { fileId: Number(id) }, req.headers['x-socket-id'] as string);
});
module.exports = router;
export default router;

View File

@@ -1,24 +1,66 @@
const express = require('express');
const fetch = require('node-fetch');
const { db } = require('../db/database');
const { authenticate } = require('../middleware/auth');
import express, { Request, Response } from 'express';
import fetch from 'node-fetch';
import { db } from '../db/database';
import { authenticate } from '../middleware/auth';
import { AuthRequest } from '../types';
interface NominatimResult {
osm_type: string;
osm_id: string;
name?: string;
display_name?: string;
lat: string;
lon: string;
}
interface GooglePlaceResult {
id: string;
displayName?: { text: string };
formattedAddress?: string;
location?: { latitude: number; longitude: number };
rating?: number;
websiteUri?: string;
nationalPhoneNumber?: string;
types?: string[];
}
interface GooglePlaceDetails extends GooglePlaceResult {
userRatingCount?: number;
regularOpeningHours?: { weekdayDescriptions?: string[]; openNow?: boolean };
googleMapsUri?: string;
editorialSummary?: { text: string };
reviews?: { authorAttribution?: { displayName?: string; photoUri?: string }; rating?: number; text?: { text?: string }; relativePublishTimeDescription?: string }[];
photos?: { name: string; authorAttributions?: { displayName?: string }[] }[];
}
const router = express.Router();
// Get API key: user's own key, or fall back to any admin's key
function getMapsKey(userId) {
const user = db.prepare('SELECT maps_api_key FROM users WHERE id = ?').get(userId);
function getMapsKey(userId: number): string | null {
const user = db.prepare('SELECT maps_api_key FROM users WHERE id = ?').get(userId) as { maps_api_key: string | null } | undefined;
if (user?.maps_api_key) return user.maps_api_key;
const admin = db.prepare("SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1").get();
const admin = db.prepare("SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1").get() as { maps_api_key: string } | undefined;
return admin?.maps_api_key || null;
}
// In-memory photo cache: placeId → { photoUrl, attribution, fetchedAt }
const photoCache = new Map();
const photoCache = new Map<string, { photoUrl: string; attribution: string | null; fetchedAt: number }>();
const PHOTO_TTL = 12 * 60 * 60 * 1000; // 12 hours
const CACHE_MAX_ENTRIES = 1000;
const CACHE_PRUNE_TARGET = 500;
const CACHE_CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 minutes
// Nominatim search (OpenStreetMap) — free fallback when no Google API key
async function searchNominatim(query, lang) {
setInterval(() => {
const now = Date.now();
for (const [key, entry] of photoCache) {
if (now - entry.fetchedAt > PHOTO_TTL) photoCache.delete(key);
}
if (photoCache.size > CACHE_MAX_ENTRIES) {
const entries = [...photoCache.entries()].sort((a, b) => a[1].fetchedAt - b[1].fetchedAt);
const toDelete = entries.slice(0, entries.length - CACHE_PRUNE_TARGET);
toDelete.forEach(([key]) => photoCache.delete(key));
}
}, CACHE_CLEANUP_INTERVAL);
async function searchNominatim(query: string, lang?: string) {
const params = new URLSearchParams({
q: query,
format: 'json',
@@ -30,7 +72,7 @@ async function searchNominatim(query, lang) {
headers: { 'User-Agent': 'NOMAD Travel Planner (https://github.com/mauriceboe/NOMAD)' },
});
if (!response.ok) throw new Error('Nominatim API error');
const data = await response.json();
const data = await response.json() as NominatimResult[];
return data.map(item => ({
google_place_id: null,
osm_id: `${item.osm_type}/${item.osm_id}`,
@@ -45,20 +87,19 @@ async function searchNominatim(query, lang) {
}));
}
// POST /api/maps/search
router.post('/search', authenticate, async (req, res) => {
router.post('/search', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { query } = req.body;
if (!query) return res.status(400).json({ error: 'Search query is required' });
const apiKey = getMapsKey(req.user.id);
const apiKey = getMapsKey(authReq.user.id);
// No Google API key → use Nominatim (OpenStreetMap)
if (!apiKey) {
try {
const places = await searchNominatim(query, req.query.lang);
const places = await searchNominatim(query, req.query.lang as string);
return res.json({ places, source: 'openstreetmap' });
} catch (err) {
} catch (err: unknown) {
console.error('Nominatim search error:', err);
return res.status(500).json({ error: 'OpenStreetMap search error' });
}
@@ -72,16 +113,16 @@ router.post('/search', authenticate, async (req, res) => {
'X-Goog-Api-Key': apiKey,
'X-Goog-FieldMask': 'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types',
},
body: JSON.stringify({ textQuery: query, languageCode: req.query.lang || 'en' }),
body: JSON.stringify({ textQuery: query, languageCode: (req.query.lang as string) || 'en' }),
});
const data = await response.json();
const data = await response.json() as { places?: GooglePlaceResult[]; error?: { message?: string } };
if (!response.ok) {
return res.status(response.status).json({ error: data.error?.message || 'Google Places API error' });
}
const places = (data.places || []).map(p => ({
const places = (data.places || []).map((p: GooglePlaceResult) => ({
google_place_id: p.id,
name: p.displayName?.text || '',
address: p.formattedAddress || '',
@@ -94,23 +135,23 @@ router.post('/search', authenticate, async (req, res) => {
}));
res.json({ places, source: 'google' });
} catch (err) {
} catch (err: unknown) {
console.error('Maps search error:', err);
res.status(500).json({ error: 'Google Places search error' });
}
});
// GET /api/maps/details/:placeId
router.get('/details/:placeId', authenticate, async (req, res) => {
router.get('/details/:placeId', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { placeId } = req.params;
const apiKey = getMapsKey(req.user.id);
const apiKey = getMapsKey(authReq.user.id);
if (!apiKey) {
return res.status(400).json({ error: 'Google Maps API key not configured' });
}
try {
const lang = req.query.lang || 'de'
const lang = (req.query.lang as string) || 'de';
const response = await fetch(`https://places.googleapis.com/v1/places/${placeId}?languageCode=${lang}`, {
method: 'GET',
headers: {
@@ -119,7 +160,7 @@ router.get('/details/:placeId', authenticate, async (req, res) => {
},
});
const data = await response.json();
const data = await response.json() as GooglePlaceDetails & { error?: { message?: string } };
if (!response.ok) {
return res.status(response.status).json({ error: data.error?.message || 'Google Places API error' });
@@ -139,7 +180,7 @@ router.get('/details/:placeId', authenticate, async (req, res) => {
open_now: data.regularOpeningHours?.openNow ?? null,
google_maps_url: data.googleMapsUri || null,
summary: data.editorialSummary?.text || null,
reviews: (data.reviews || []).slice(0, 5).map(r => ({
reviews: (data.reviews || []).slice(0, 5).map((r: NonNullable<GooglePlaceDetails['reviews']>[number]) => ({
author: r.authorAttribution?.displayName || null,
rating: r.rating || null,
text: r.text?.text || null,
@@ -149,37 +190,34 @@ router.get('/details/:placeId', authenticate, async (req, res) => {
};
res.json({ place });
} catch (err) {
} catch (err: unknown) {
console.error('Maps details error:', err);
res.status(500).json({ error: 'Error fetching place details' });
}
});
// GET /api/maps/place-photo/:placeId
// Proxies a Google Places photo (hides API key from client). Returns { photoUrl, attribution }.
router.get('/place-photo/:placeId', authenticate, async (req, res) => {
router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { placeId } = req.params;
// Check TTL cache
const cached = photoCache.get(placeId);
if (cached && Date.now() - cached.fetchedAt < PHOTO_TTL) {
return res.json({ photoUrl: cached.photoUrl, attribution: cached.attribution });
}
const apiKey = getMapsKey(req.user.id);
const apiKey = getMapsKey(authReq.user.id);
if (!apiKey) {
return res.status(400).json({ error: 'Google Maps API key not configured' });
}
try {
// Fetch place details to get photo reference
const detailsRes = await fetch(`https://places.googleapis.com/v1/places/${placeId}`, {
headers: {
'X-Goog-Api-Key': apiKey,
'X-Goog-FieldMask': 'photos',
},
});
const details = await detailsRes.json();
const details = await detailsRes.json() as GooglePlaceDetails & { error?: { message?: string } };
if (!detailsRes.ok) {
console.error('Google Places photo details error:', details.error?.message || detailsRes.status);
@@ -194,11 +232,10 @@ router.get('/place-photo/:placeId', authenticate, async (req, res) => {
const photoName = photo.name;
const attribution = photo.authorAttributions?.[0]?.displayName || null;
// Fetch the media URL (skipHttpRedirect returns JSON with photoUri)
const mediaRes = await fetch(
`https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=600&key=${apiKey}&skipHttpRedirect=true`
);
const mediaData = await mediaRes.json();
const mediaData = await mediaRes.json() as { photoUri?: string };
const photoUrl = mediaData.photoUri;
if (!photoUrl) {
@@ -207,8 +244,6 @@ router.get('/place-photo/:placeId', authenticate, async (req, res) => {
photoCache.set(placeId, { photoUrl, attribution, fetchedAt: Date.now() });
// Persist the photo URL to all places with this google_place_id so future
// loads serve image_url directly without hitting the Google API again.
try {
db.prepare(
'UPDATE places SET image_url = ?, updated_at = CURRENT_TIMESTAMP WHERE google_place_id = ? AND (image_url IS NULL OR image_url = ?)'
@@ -218,10 +253,10 @@ router.get('/place-photo/:placeId', authenticate, async (req, res) => {
}
res.json({ photoUrl, attribution });
} catch (err) {
} catch (err: unknown) {
console.error('Place photo error:', err);
res.status(500).json({ error: 'Error fetching photo' });
}
});
module.exports = router;
export default router;

View File

@@ -1,27 +1,57 @@
const express = require('express');
const crypto = require('crypto');
const fetch = require('node-fetch');
const jwt = require('jsonwebtoken');
const { db } = require('../db/database');
const { JWT_SECRET } = require('../config');
import express, { Request, Response } from 'express';
import crypto from 'crypto';
import fetch from 'node-fetch';
import jwt from 'jsonwebtoken';
import { db } from '../db/database';
import { JWT_SECRET } from '../config';
import { User } from '../types';
interface OidcDiscoveryDoc {
authorization_endpoint: string;
token_endpoint: string;
userinfo_endpoint: string;
_issuer?: string;
}
interface OidcTokenResponse {
access_token?: string;
id_token?: string;
token_type?: string;
}
interface OidcUserInfo {
sub: string;
email?: string;
name?: string;
preferred_username?: string;
}
const router = express.Router();
// In-memory state store for CSRF protection (state → { createdAt, redirectUri })
const pendingStates = new Map();
const STATE_TTL = 5 * 60 * 1000; // 5 minutes
const AUTH_CODE_TTL = 60000; // 1 minute
const AUTH_CODE_CLEANUP = 30000; // 30 seconds
const STATE_TTL = 5 * 60 * 1000; // 5 minutes
const STATE_CLEANUP = 60 * 1000; // 1 minute
const authCodes = new Map<string, { token: string; created: number }>();
setInterval(() => {
const now = Date.now();
for (const [code, entry] of authCodes) {
if (now - entry.created > AUTH_CODE_TTL) authCodes.delete(code);
}
}, AUTH_CODE_CLEANUP);
const pendingStates = new Map<string, { createdAt: number; redirectUri: string }>();
// Cleanup expired states periodically
setInterval(() => {
const now = Date.now();
for (const [state, data] of pendingStates) {
if (now - data.createdAt > STATE_TTL) pendingStates.delete(state);
}
}, 60 * 1000);
}, STATE_CLEANUP);
// Read OIDC config from app_settings
function getOidcConfig() {
const get = (key) => db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key)?.value || null;
const get = (key: string) => (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || null;
const issuer = get('oidc_issuer');
const clientId = get('oidc_client_id');
const clientSecret = get('oidc_client_secret');
@@ -30,25 +60,24 @@ function getOidcConfig() {
return { issuer: issuer.replace(/\/+$/, ''), clientId, clientSecret, displayName };
}
// Cache discovery document
let discoveryCache = null;
let discoveryCache: OidcDiscoveryDoc | null = null;
let discoveryCacheTime = 0;
const DISCOVERY_TTL = 60 * 60 * 1000; // 1 hour
async function discover(issuer) {
async function discover(issuer: string) {
if (discoveryCache && Date.now() - discoveryCacheTime < DISCOVERY_TTL && discoveryCache._issuer === issuer) {
return discoveryCache;
}
const res = await fetch(`${issuer}/.well-known/openid-configuration`);
if (!res.ok) throw new Error('Failed to fetch OIDC discovery document');
const doc = await res.json();
const doc = await res.json() as OidcDiscoveryDoc;
doc._issuer = issuer;
discoveryCache = doc;
discoveryCacheTime = Date.now();
return doc;
}
function generateToken(user) {
function generateToken(user: { id: number; username: string; email: string; role: string }) {
return jwt.sign(
{ id: user.id, username: user.username, email: user.email, role: user.role },
JWT_SECRET,
@@ -56,21 +85,24 @@ function generateToken(user) {
);
}
function frontendUrl(path) {
function frontendUrl(path: string): string {
const base = process.env.NODE_ENV === 'production' ? '' : 'http://localhost:5173';
return base + path;
}
// GET /api/auth/oidc/login — redirect to OIDC provider
router.get('/login', async (req, res) => {
router.get('/login', async (req: Request, res: Response) => {
const config = getOidcConfig();
if (!config) return res.status(400).json({ error: 'OIDC not configured' });
if (config.issuer && !config.issuer.startsWith('https://') && process.env.NODE_ENV === 'production') {
return res.status(400).json({ error: 'OIDC issuer must use HTTPS in production' });
}
try {
const doc = await discover(config.issuer);
const state = crypto.randomBytes(32).toString('hex');
const proto = req.headers['x-forwarded-proto'] || req.protocol;
const host = req.headers['x-forwarded-host'] || req.headers.host;
const proto = (req.headers['x-forwarded-proto'] as string) || req.protocol;
const host = (req.headers['x-forwarded-host'] as string) || req.headers.host;
const redirectUri = `${proto}://${host}/api/auth/oidc/callback`;
pendingStates.set(state, { createdAt: Date.now(), redirectUri });
@@ -84,15 +116,14 @@ router.get('/login', async (req, res) => {
});
res.redirect(`${doc.authorization_endpoint}?${params}`);
} catch (err) {
console.error('[OIDC] Login error:', err.message);
} catch (err: unknown) {
console.error('[OIDC] Login error:', err instanceof Error ? err.message : err);
res.status(500).json({ error: 'OIDC login failed' });
}
});
// GET /api/auth/oidc/callback — handle provider callback
router.get('/callback', async (req, res) => {
const { code, state, error: oidcError } = req.query;
router.get('/callback', async (req: Request, res: Response) => {
const { code, state, error: oidcError } = req.query as { code?: string; state?: string; error?: string };
if (oidcError) {
console.error('[OIDC] Provider error:', oidcError);
@@ -112,10 +143,13 @@ router.get('/callback', async (req, res) => {
const config = getOidcConfig();
if (!config) return res.redirect(frontendUrl('/login?oidc_error=not_configured'));
if (config.issuer && !config.issuer.startsWith('https://') && process.env.NODE_ENV === 'production') {
return res.redirect(frontendUrl('/login?oidc_error=issuer_not_https'));
}
try {
const doc = await discover(config.issuer);
// Exchange code for tokens
const tokenRes = await fetch(doc.token_endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
@@ -128,17 +162,16 @@ router.get('/callback', async (req, res) => {
}),
});
const tokenData = await tokenRes.json();
const tokenData = await tokenRes.json() as OidcTokenResponse;
if (!tokenRes.ok || !tokenData.access_token) {
console.error('[OIDC] Token exchange failed:', tokenData);
return res.redirect(frontendUrl('/login?oidc_error=token_failed'));
}
// Get user info
const userInfoRes = await fetch(doc.userinfo_endpoint, {
headers: { Authorization: `Bearer ${tokenData.access_token}` },
});
const userInfo = await userInfoRes.json();
const userInfo = await userInfoRes.json() as OidcUserInfo;
if (!userInfo.email) {
return res.redirect(frontendUrl('/login?oidc_error=no_email'));
@@ -148,37 +181,31 @@ router.get('/callback', async (req, res) => {
const name = userInfo.name || userInfo.preferred_username || email.split('@')[0];
const sub = userInfo.sub;
// Find existing user by OIDC sub or email
let user = db.prepare('SELECT * FROM users WHERE oidc_sub = ? AND oidc_issuer = ?').get(sub, config.issuer);
let user = db.prepare('SELECT * FROM users WHERE oidc_sub = ? AND oidc_issuer = ?').get(sub, config.issuer) as User | undefined;
if (!user) {
user = db.prepare('SELECT * FROM users WHERE LOWER(email) = ?').get(email);
user = db.prepare('SELECT * FROM users WHERE LOWER(email) = ?').get(email) as User | undefined;
}
if (user) {
// Existing user — link OIDC if not already linked
if (!user.oidc_sub) {
db.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?').run(sub, config.issuer, user.id);
}
} else {
// New user — check if registration is allowed
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
const isFirstUser = userCount === 0;
if (!isFirstUser) {
const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get();
const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get() as { value: string } | undefined;
if (setting?.value === 'false') {
return res.redirect(frontendUrl('/login?oidc_error=registration_disabled'));
}
}
// Create user (first user = admin)
const role = isFirstUser ? 'admin' : 'user';
// Generate a random password hash (user won't use password login)
const randomPass = crypto.randomBytes(32).toString('hex');
const bcrypt = require('bcryptjs');
const hash = bcrypt.hashSync(randomPass, 10);
// Ensure unique username
let username = name.replace(/[^a-zA-Z0-9_-]/g, '').substring(0, 30) || 'user';
const existing = db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?)').get(username);
if (existing) username = `${username}_${Date.now() % 10000}`;
@@ -187,20 +214,30 @@ router.get('/callback', async (req, res) => {
'INSERT INTO users (username, email, password_hash, role, oidc_sub, oidc_issuer) VALUES (?, ?, ?, ?, ?, ?)'
).run(username, email, hash, role, sub, config.issuer);
user = { id: Number(result.lastInsertRowid), username, email, role };
user = { id: Number(result.lastInsertRowid), username, email, role } as User;
}
// Update last login
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(user.id);
// Generate JWT and redirect to frontend
const token = generateToken(user);
// In dev mode, frontend runs on a different port
res.redirect(frontendUrl(`/login#token=${token}`));
} catch (err) {
const { v4: uuidv4 } = require('uuid');
const authCode = uuidv4();
authCodes.set(authCode, { token, created: Date.now() });
res.redirect(frontendUrl('/login?oidc_code=' + authCode));
} catch (err: unknown) {
console.error('[OIDC] Callback error:', err);
res.redirect(frontendUrl('/login?oidc_error=server_error'));
}
});
module.exports = router;
router.get('/exchange', (req: Request, res: Response) => {
const { code } = req.query as { code?: string };
if (!code) return res.status(400).json({ error: 'Code required' });
const entry = authCodes.get(code);
if (!entry) return res.status(400).json({ error: 'Invalid or expired code' });
authCodes.delete(code);
if (Date.now() - entry.created > AUTH_CODE_TTL) return res.status(400).json({ error: 'Code expired' });
res.json({ token: entry.token });
});
export default router;

View File

@@ -1,19 +1,20 @@
const express = require('express');
const { db, canAccessTrip } = require('../db/database');
const { authenticate } = require('../middleware/auth');
const { broadcast } = require('../websocket');
import express, { Request, Response } from 'express';
import { db, canAccessTrip } from '../db/database';
import { authenticate } from '../middleware/auth';
import { broadcast } from '../websocket';
import { AuthRequest } from '../types';
const router = express.Router({ mergeParams: true });
function verifyTripOwnership(tripId, userId) {
function verifyTripOwnership(tripId: string | number, userId: number) {
return canAccessTrip(tripId, userId);
}
// GET /api/trips/:tripId/packing
router.get('/', authenticate, (req, res) => {
router.get('/', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const items = db.prepare(
@@ -23,17 +24,17 @@ router.get('/', authenticate, (req, res) => {
res.json({ items });
});
// POST /api/trips/:tripId/packing
router.post('/', authenticate, (req, res) => {
router.post('/', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const { name, category, checked } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id);
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!name) return res.status(400).json({ error: 'Item name is required' });
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_items WHERE trip_id = ?').get(tripId);
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_items WHERE trip_id = ?').get(tripId) as { max: number | null };
const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
const result = db.prepare(
@@ -42,15 +43,15 @@ router.post('/', authenticate, (req, res) => {
const item = db.prepare('SELECT * FROM packing_items WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json({ item });
broadcast(tripId, 'packing:created', { item }, req.headers['x-socket-id']);
broadcast(tripId, 'packing:created', { item }, req.headers['x-socket-id'] as string);
});
// PUT /api/trips/:tripId/packing/:id
router.put('/:id', authenticate, (req, res) => {
router.put('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
const { name, checked, category } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id);
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const item = db.prepare('SELECT * FROM packing_items WHERE id = ? AND trip_id = ?').get(id, tripId);
@@ -72,14 +73,14 @@ router.put('/:id', authenticate, (req, res) => {
const updated = db.prepare('SELECT * FROM packing_items WHERE id = ?').get(id);
res.json({ item: updated });
broadcast(tripId, 'packing:updated', { item: updated }, req.headers['x-socket-id']);
broadcast(tripId, 'packing:updated', { item: updated }, req.headers['x-socket-id'] as string);
});
// DELETE /api/trips/:tripId/packing/:id
router.delete('/:id', authenticate, (req, res) => {
router.delete('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const item = db.prepare('SELECT id FROM packing_items WHERE id = ? AND trip_id = ?').get(id, tripId);
@@ -87,19 +88,19 @@ router.delete('/:id', authenticate, (req, res) => {
db.prepare('DELETE FROM packing_items WHERE id = ?').run(id);
res.json({ success: true });
broadcast(tripId, 'packing:deleted', { itemId: Number(id) }, req.headers['x-socket-id']);
broadcast(tripId, 'packing:deleted', { itemId: Number(id) }, req.headers['x-socket-id'] as string);
});
// PUT /api/trips/:tripId/packing/reorder
router.put('/reorder', authenticate, (req, res) => {
router.put('/reorder', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const { orderedIds } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id);
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const update = db.prepare('UPDATE packing_items SET sort_order = ? WHERE id = ? AND trip_id = ?');
const updateMany = db.transaction((ids) => {
const updateMany = db.transaction((ids: number[]) => {
ids.forEach((id, index) => {
update.run(index, id, tripId);
});
@@ -109,4 +110,4 @@ router.put('/reorder', authenticate, (req, res) => {
res.json({ success: true });
});
module.exports = router;
export default router;

View File

@@ -1,167 +0,0 @@
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { v4: uuidv4 } = require('uuid');
const { db, canAccessTrip } = require('../db/database');
const { authenticate, demoUploadBlock } = require('../middleware/auth');
const router = express.Router({ mergeParams: true });
const photosDir = path.join(__dirname, '../../uploads/photos');
const storage = multer.diskStorage({
destination: (req, file, cb) => {
if (!fs.existsSync(photosDir)) fs.mkdirSync(photosDir, { recursive: true });
cb(null, photosDir);
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `${uuidv4()}${ext}`);
},
});
const upload = multer({
storage,
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
fileFilter: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
const allowedExts = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
if (file.mimetype.startsWith('image/') && !file.mimetype.includes('svg') && allowedExts.includes(ext)) {
cb(null, true);
} else {
cb(new Error('Only jpg, png, gif, webp images allowed'));
}
},
});
function formatPhoto(photo) {
return {
...photo,
url: `/uploads/photos/${photo.filename}`,
};
}
// GET /api/trips/:tripId/photos
router.get('/', authenticate, (req, res) => {
const { tripId } = req.params;
const { day_id, place_id } = req.query;
const trip = canAccessTrip(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
let query = 'SELECT * FROM photos WHERE trip_id = ?';
const params = [tripId];
if (day_id) {
query += ' AND day_id = ?';
params.push(day_id);
}
if (place_id) {
query += ' AND place_id = ?';
params.push(place_id);
}
query += ' ORDER BY created_at DESC';
const photos = db.prepare(query).all(...params);
res.json({ photos: photos.map(formatPhoto) });
});
// POST /api/trips/:tripId/photos
router.post('/', authenticate, demoUploadBlock, upload.array('photos', 20), (req, res) => {
const { tripId } = req.params;
const { day_id, place_id, caption } = req.body;
const trip = canAccessTrip(tripId, req.user.id);
if (!trip) {
// Delete uploaded files on auth failure
if (req.files) req.files.forEach(f => fs.unlinkSync(f.path));
return res.status(404).json({ error: 'Trip not found' });
}
if (!req.files || req.files.length === 0) {
return res.status(400).json({ error: 'No files uploaded' });
}
const insertPhoto = db.prepare(`
INSERT INTO photos (trip_id, day_id, place_id, filename, original_name, file_size, mime_type, caption)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
const photos = [];
db.exec('BEGIN');
try {
for (const file of req.files) {
const result = insertPhoto.run(
tripId,
day_id || null,
place_id || null,
file.filename,
file.originalname,
file.size,
file.mimetype,
caption || null
);
const photo = db.prepare('SELECT * FROM photos WHERE id = ?').get(result.lastInsertRowid);
photos.push(formatPhoto(photo));
}
db.exec('COMMIT');
} catch (e) {
db.exec('ROLLBACK');
throw e;
}
res.status(201).json({ photos });
});
// PUT /api/trips/:tripId/photos/:id
router.put('/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
const { caption, day_id, place_id } = req.body;
const trip = canAccessTrip(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const photo = db.prepare('SELECT * FROM photos WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!photo) return res.status(404).json({ error: 'Photo not found' });
db.prepare(`
UPDATE photos SET
caption = COALESCE(?, caption),
day_id = ?,
place_id = ?
WHERE id = ?
`).run(
caption !== undefined ? caption : photo.caption,
day_id !== undefined ? (day_id || null) : photo.day_id,
place_id !== undefined ? (place_id || null) : photo.place_id,
id
);
const updated = db.prepare('SELECT * FROM photos WHERE id = ?').get(id);
res.json({ photo: formatPhoto(updated) });
});
// DELETE /api/trips/:tripId/photos/:id
router.delete('/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
const trip = canAccessTrip(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const photo = db.prepare('SELECT * FROM photos WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!photo) return res.status(404).json({ error: 'Photo not found' });
// Delete file
const filePath = path.join(photosDir, photo.filename);
if (fs.existsSync(filePath)) {
try { fs.unlinkSync(filePath); } catch (e) { console.error('Error deleting photo file:', e); }
}
db.prepare('DELETE FROM photos WHERE id = ?').run(id);
res.json({ success: true });
});
module.exports = router;

View File

@@ -1,32 +1,37 @@
const express = require('express');
const fetch = require('node-fetch');
const { db, getPlaceWithTags, canAccessTrip } = require('../db/database');
const { authenticate } = require('../middleware/auth');
const { broadcast } = require('../websocket');
import express, { Request, Response } from 'express';
import fetch from 'node-fetch';
import { db, getPlaceWithTags } from '../db/database';
import { authenticate } from '../middleware/auth';
import { requireTripAccess } from '../middleware/tripAccess';
import { broadcast } from '../websocket';
import { loadTagsByPlaceIds } from '../services/queryHelpers';
import { validateStringLengths } from '../middleware/validate';
import { AuthRequest, Place } from '../types';
interface PlaceWithCategory extends Place {
category_name: string | null;
category_color: string | null;
category_icon: string | null;
}
interface UnsplashSearchResponse {
results?: { id: string; urls?: { regular?: string; thumb?: string }; description?: string; alt_description?: string; user?: { name?: string }; links?: { html?: string } }[];
errors?: string[];
}
const router = express.Router({ mergeParams: true });
function verifyTripOwnership(tripId, userId) {
return canAccessTrip(tripId, userId);
}
// GET /api/trips/:tripId/places
router.get('/', authenticate, (req, res) => {
router.get('/', authenticate, requireTripAccess, (req: Request, res: Response) => {
const { tripId } = req.params;
const { search, category, tag } = req.query;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) {
return res.status(404).json({ error: 'Trip not found' });
}
let query = `
SELECT DISTINCT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
FROM places p
LEFT JOIN categories c ON p.category_id = c.id
WHERE p.trip_id = ?
`;
const params = [tripId];
const params: (string | number)[] = [tripId];
if (search) {
query += ' AND (p.name LIKE ? OR p.address LIKE ? OR p.description LIKE ?)';
@@ -46,26 +51,10 @@ router.get('/', authenticate, (req, res) => {
query += ' ORDER BY p.created_at DESC';
const places = db.prepare(query).all(...params);
const places = db.prepare(query).all(...params) as PlaceWithCategory[];
// Load all tags for these places in a single query to avoid N+1
const placeIds = places.map(p => p.id);
const tagsByPlaceId = {};
if (placeIds.length > 0) {
const placeholders = placeIds.map(() => '?').join(',');
const allTags = db.prepare(`
SELECT t.*, pt.place_id FROM tags t
JOIN place_tags pt ON t.id = pt.tag_id
WHERE pt.place_id IN (${placeholders})
`).all(...placeIds);
for (const tag of allTags) {
const pid = tag.place_id;
delete tag.place_id;
if (!tagsByPlaceId[pid]) tagsByPlaceId[pid] = [];
tagsByPlaceId[pid].push(tag);
}
}
const tagsByPlaceId = loadTagsByPlaceIds(placeIds);
const placesWithTags = places.map(p => {
return {
@@ -83,15 +72,9 @@ router.get('/', authenticate, (req, res) => {
res.json({ places: placesWithTags });
});
// POST /api/trips/:tripId/places
router.post('/', authenticate, (req, res) => {
router.post('/', authenticate, requireTripAccess, validateStringLengths({ name: 200, description: 2000, address: 500, notes: 2000 }), (req: Request, res: Response) => {
const { tripId } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) {
return res.status(404).json({ error: 'Trip not found' });
}
const {
name, description, lat, lng, address, category_id, price, currency,
place_time, end_time,
@@ -126,18 +109,12 @@ router.post('/', authenticate, (req, res) => {
const place = getPlaceWithTags(placeId);
res.status(201).json({ place });
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id']);
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
});
// GET /api/trips/:tripId/places/:id
router.get('/:id', authenticate, (req, res) => {
router.get('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
const { tripId, id } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) {
return res.status(404).json({ error: 'Trip not found' });
}
const placeCheck = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!placeCheck) {
return res.status(404).json({ error: 'Place not found' });
@@ -147,21 +124,16 @@ router.get('/:id', authenticate, (req, res) => {
res.json({ place });
});
// GET /api/trips/:tripId/places/:id/image - fetch image from Unsplash
router.get('/:id/image', authenticate, async (req, res) => {
router.get('/:id/image', authenticate, requireTripAccess, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) {
return res.status(404).json({ error: 'Trip not found' });
}
const place = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(id, tripId);
const place = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(id, tripId) as Place | undefined;
if (!place) {
return res.status(404).json({ error: 'Place not found' });
}
const user = db.prepare('SELECT unsplash_api_key FROM users WHERE id = ?').get(req.user.id);
const user = db.prepare('SELECT unsplash_api_key FROM users WHERE id = ?').get(authReq.user.id) as { unsplash_api_key: string | null } | undefined;
if (!user || !user.unsplash_api_key) {
return res.status(400).json({ error: 'No Unsplash API key configured' });
}
@@ -171,13 +143,13 @@ router.get('/:id/image', authenticate, async (req, res) => {
const response = await fetch(
`https://api.unsplash.com/search/photos?query=${query}&per_page=5&client_id=${user.unsplash_api_key}`
);
const data = await response.json();
const data = await response.json() as UnsplashSearchResponse;
if (!response.ok) {
return res.status(response.status).json({ error: data.errors?.[0] || 'Unsplash API error' });
}
const photos = (data.results || []).map(p => ({
const photos = (data.results || []).map((p: NonNullable<UnsplashSearchResponse['results']>[number]) => ({
id: p.id,
url: p.urls?.regular,
thumb: p.urls?.thumb,
@@ -187,22 +159,16 @@ router.get('/:id/image', authenticate, async (req, res) => {
}));
res.json({ photos });
} catch (err) {
} catch (err: unknown) {
console.error('Unsplash error:', err);
res.status(500).json({ error: 'Error searching for image' });
}
});
// PUT /api/trips/:tripId/places/:id
router.put('/:id', authenticate, (req, res) => {
router.put('/:id', authenticate, requireTripAccess, validateStringLengths({ name: 200, description: 2000, address: 500, notes: 2000 }), (req: Request, res: Response) => {
const { tripId, id } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) {
return res.status(404).json({ error: 'Trip not found' });
}
const existingPlace = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(id, tripId);
const existingPlace = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(id, tripId) as Place | undefined;
if (!existingPlace) {
return res.status(404).json({ error: 'Place not found' });
}
@@ -268,18 +234,12 @@ router.put('/:id', authenticate, (req, res) => {
const place = getPlaceWithTags(id);
res.json({ place });
broadcast(tripId, 'place:updated', { place }, req.headers['x-socket-id']);
broadcast(tripId, 'place:updated', { place }, req.headers['x-socket-id'] as string);
});
// DELETE /api/trips/:tripId/places/:id
router.delete('/:id', authenticate, (req, res) => {
router.delete('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
const { tripId, id } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
if (!trip) {
return res.status(404).json({ error: 'Trip not found' });
}
const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!place) {
return res.status(404).json({ error: 'Place not found' });
@@ -287,7 +247,7 @@ router.delete('/:id', authenticate, (req, res) => {
db.prepare('DELETE FROM places WHERE id = ?').run(id);
res.json({ success: true });
broadcast(tripId, 'place:deleted', { placeId: Number(id) }, req.headers['x-socket-id']);
broadcast(tripId, 'place:deleted', { placeId: Number(id) }, req.headers['x-socket-id'] as string);
});
module.exports = router;
export default router;

View File

@@ -1,19 +1,20 @@
const express = require('express');
const { db, canAccessTrip } = require('../db/database');
const { authenticate } = require('../middleware/auth');
const { broadcast } = require('../websocket');
import express, { Request, Response } from 'express';
import { db, canAccessTrip } from '../db/database';
import { authenticate } from '../middleware/auth';
import { broadcast } from '../websocket';
import { AuthRequest, Reservation } from '../types';
const router = express.Router({ mergeParams: true });
function verifyTripOwnership(tripId, userId) {
function verifyTripOwnership(tripId: string | number, userId: number) {
return canAccessTrip(tripId, userId);
}
// GET /api/trips/:tripId/reservations
router.get('/', authenticate, (req, res) => {
router.get('/', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const reservations = db.prepare(`
@@ -28,12 +29,12 @@ router.get('/', authenticate, (req, res) => {
res.json({ reservations });
});
// POST /api/trips/:tripId/reservations
router.post('/', authenticate, (req, res) => {
router.post('/', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id);
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!title) return res.status(400).json({ error: 'Title is required' });
@@ -65,18 +66,18 @@ router.post('/', authenticate, (req, res) => {
`).get(result.lastInsertRowid);
res.status(201).json({ reservation });
broadcast(tripId, 'reservation:created', { reservation }, req.headers['x-socket-id']);
broadcast(tripId, 'reservation:created', { reservation }, req.headers['x-socket-id'] as string);
});
// PUT /api/trips/:tripId/reservations/:id
router.put('/:id', authenticate, (req, res) => {
router.put('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type } = req.body;
const trip = verifyTripOwnership(tripId, req.user.id);
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const reservation = db.prepare('SELECT * FROM reservations WHERE id = ? AND trip_id = ?').get(id, tripId);
const reservation = db.prepare('SELECT * FROM reservations WHERE id = ? AND trip_id = ?').get(id, tripId) as Reservation | undefined;
if (!reservation) return res.status(404).json({ error: 'Reservation not found' });
db.prepare(`
@@ -117,14 +118,14 @@ router.put('/:id', authenticate, (req, res) => {
`).get(id);
res.json({ reservation: updated });
broadcast(tripId, 'reservation:updated', { reservation: updated }, req.headers['x-socket-id']);
broadcast(tripId, 'reservation:updated', { reservation: updated }, req.headers['x-socket-id'] as string);
});
// DELETE /api/trips/:tripId/reservations/:id
router.delete('/:id', authenticate, (req, res) => {
router.delete('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
const trip = verifyTripOwnership(tripId, req.user.id);
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const reservation = db.prepare('SELECT id FROM reservations WHERE id = ? AND trip_id = ?').get(id, tripId);
@@ -132,7 +133,7 @@ router.delete('/:id', authenticate, (req, res) => {
db.prepare('DELETE FROM reservations WHERE id = ?').run(id);
res.json({ success: true });
broadcast(tripId, 'reservation:deleted', { reservationId: Number(id) }, req.headers['x-socket-id']);
broadcast(tripId, 'reservation:deleted', { reservationId: Number(id) }, req.headers['x-socket-id'] as string);
});
module.exports = router;
export default router;

View File

@@ -1,13 +1,14 @@
const express = require('express');
const { db } = require('../db/database');
const { authenticate } = require('../middleware/auth');
import express, { Request, Response } from 'express';
import { db } from '../db/database';
import { authenticate } from '../middleware/auth';
import { AuthRequest } from '../types';
const router = express.Router();
// GET /api/settings - return all settings for user
router.get('/', authenticate, (req, res) => {
const rows = db.prepare('SELECT key, value FROM settings WHERE user_id = ?').all(req.user.id);
const settings = {};
router.get('/', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const rows = db.prepare('SELECT key, value FROM settings WHERE user_id = ?').all(authReq.user.id) as { key: string; value: string }[];
const settings: Record<string, unknown> = {};
for (const row of rows) {
try {
settings[row.key] = JSON.parse(row.value);
@@ -18,8 +19,8 @@ router.get('/', authenticate, (req, res) => {
res.json({ settings });
});
// PUT /api/settings - upsert single setting
router.put('/', authenticate, (req, res) => {
router.put('/', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { key, value } = req.body;
if (!key) return res.status(400).json({ error: 'Key is required' });
@@ -29,13 +30,13 @@ router.put('/', authenticate, (req, res) => {
db.prepare(`
INSERT INTO settings (user_id, key, value) VALUES (?, ?, ?)
ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value
`).run(req.user.id, key, serialized);
`).run(authReq.user.id, key, serialized);
res.json({ success: true, key, value });
});
// POST /api/settings/bulk - upsert multiple settings
router.post('/bulk', authenticate, (req, res) => {
router.post('/bulk', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { settings } = req.body;
if (!settings || typeof settings !== 'object') {
@@ -51,15 +52,16 @@ router.post('/bulk', authenticate, (req, res) => {
db.exec('BEGIN');
for (const [key, value] of Object.entries(settings)) {
const serialized = typeof value === 'object' ? JSON.stringify(value) : String(value !== undefined ? value : '');
upsert.run(req.user.id, key, serialized);
upsert.run(authReq.user.id, key, serialized);
}
db.exec('COMMIT');
} catch (err) {
} catch (err: unknown) {
db.exec('ROLLBACK');
return res.status(500).json({ error: 'Error saving settings', detail: err.message });
console.error('Error saving settings:', err);
return res.status(500).json({ error: 'Error saving settings' });
}
res.json({ success: true, updated: Object.keys(settings).length });
});
module.exports = router;
export default router;

View File

@@ -1,35 +1,36 @@
const express = require('express');
const { db } = require('../db/database');
const { authenticate } = require('../middleware/auth');
import express, { Request, Response } from 'express';
import { db } from '../db/database';
import { authenticate } from '../middleware/auth';
import { AuthRequest } from '../types';
const router = express.Router();
// GET /api/tags
router.get('/', authenticate, (req, res) => {
router.get('/', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const tags = db.prepare(
'SELECT * FROM tags WHERE user_id = ? ORDER BY name ASC'
).all(req.user.id);
).all(authReq.user.id);
res.json({ tags });
});
// POST /api/tags
router.post('/', authenticate, (req, res) => {
router.post('/', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'Tag name is required' });
const result = db.prepare(
'INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)'
).run(req.user.id, name, color || '#10b981');
).run(authReq.user.id, name, color || '#10b981');
const tag = db.prepare('SELECT * FROM tags WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json({ tag });
});
// PUT /api/tags/:id
router.put('/:id', authenticate, (req, res) => {
router.put('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { name, color } = req.body;
const tag = db.prepare('SELECT * FROM tags WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
const tag = db.prepare('SELECT * FROM tags WHERE id = ? AND user_id = ?').get(req.params.id, authReq.user.id);
if (!tag) return res.status(404).json({ error: 'Tag not found' });
@@ -40,13 +41,13 @@ router.put('/:id', authenticate, (req, res) => {
res.json({ tag: updated });
});
// DELETE /api/tags/:id
router.delete('/:id', authenticate, (req, res) => {
const tag = db.prepare('SELECT * FROM tags WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
router.delete('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const tag = db.prepare('SELECT * FROM tags WHERE id = ? AND user_id = ?').get(req.params.id, authReq.user.id);
if (!tag) return res.status(404).json({ error: 'Tag not found' });
db.prepare('DELETE FROM tags WHERE id = ?').run(req.params.id);
res.json({ success: true });
});
module.exports = router;
export default router;

View File

@@ -1,29 +1,34 @@
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { v4: uuidv4 } = require('uuid');
const { db, canAccessTrip, isOwner } = require('../db/database');
const { authenticate, demoUploadBlock } = require('../middleware/auth');
const { broadcast } = require('../websocket');
import express, { Request, Response } from 'express';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { v4 as uuidv4 } from 'uuid';
import { db, canAccessTrip, isOwner } from '../db/database';
import { authenticate, demoUploadBlock } from '../middleware/auth';
import { broadcast } from '../websocket';
import { AuthRequest, Trip, User } from '../types';
const router = express.Router();
const MS_PER_DAY = 86400000;
const MAX_TRIP_DAYS = 90;
const MAX_COVER_SIZE = 20 * 1024 * 1024; // 20 MB
const coversDir = path.join(__dirname, '../../uploads/covers');
const coverStorage = multer.diskStorage({
destination: (req, file, cb) => {
destination: (_req, _file, cb) => {
if (!fs.existsSync(coversDir)) fs.mkdirSync(coversDir, { recursive: true });
cb(null, coversDir);
},
filename: (req, file, cb) => {
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `${uuidv4()}${ext}`);
},
});
const uploadCover = multer({
storage: coverStorage,
limits: { fileSize: 20 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
limits: { fileSize: MAX_COVER_SIZE },
fileFilter: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
const allowedExts = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
if (file.mimetype.startsWith('image/') && !file.mimetype.includes('svg') && allowedExts.includes(ext)) {
@@ -45,74 +50,62 @@ const TRIP_SELECT = `
JOIN users u ON u.id = t.user_id
`;
function generateDays(tripId, startDate, endDate) {
const existing = db.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ?').all(tripId);
function generateDays(tripId: number | bigint | string, startDate: string | null, endDate: string | null) {
const existing = db.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ?').all(tripId) as { id: number; day_number: number; date: string | null }[];
if (!startDate || !endDate) {
// No dates — keep up to 7 dateless days, reuse existing ones
const datelessExisting = existing.filter(d => !d.date).sort((a, b) => a.day_number - b.day_number);
// Remove days with dates (they no longer apply)
const withDates = existing.filter(d => d.date);
if (withDates.length > 0) {
db.prepare(`DELETE FROM days WHERE trip_id = ? AND date IS NOT NULL`).run(tripId);
}
// Ensure exactly 7 dateless days
const needed = 7 - datelessExisting.length;
if (needed > 0) {
const insert = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, NULL)');
for (let i = 0; i < needed; i++) insert.run(tripId, datelessExisting.length + i + 1);
} else if (needed < 0) {
// Too many dateless days — remove extras (highest day_number first to preserve earlier assignments)
const toRemove = datelessExisting.slice(7);
const del = db.prepare('DELETE FROM days WHERE id = ?');
for (const d of toRemove) del.run(d.id);
}
// Renumber — use negative temp values first to avoid UNIQUE conflicts
const remaining = db.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId);
const remaining = db.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as { id: number }[];
const tmpUpd = db.prepare('UPDATE days SET day_number = ? WHERE id = ?');
remaining.forEach((d, i) => tmpUpd.run(-(i + 1), d.id));
remaining.forEach((d, i) => tmpUpd.run(i + 1, d.id));
return;
}
// Use pure string-based date math to avoid timezone/DST issues
const [sy, sm, sd] = startDate.split('-').map(Number);
const [ey, em, ed] = endDate.split('-').map(Number);
const startMs = Date.UTC(sy, sm - 1, sd);
const endMs = Date.UTC(ey, em - 1, ed);
const numDays = Math.min(Math.floor((endMs - startMs) / 86400000) + 1, 90);
const numDays = Math.min(Math.floor((endMs - startMs) / MS_PER_DAY) + 1, MAX_TRIP_DAYS);
// Build target dates
const targetDates = [];
const targetDates: string[] = [];
for (let i = 0; i < numDays; i++) {
const d = new Date(startMs + i * 86400000);
const d = new Date(startMs + i * MS_PER_DAY);
const yyyy = d.getUTCFullYear();
const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
const dd = String(d.getUTCDate()).padStart(2, '0');
targetDates.push(`${yyyy}-${mm}-${dd}`);
}
// Index existing days by date
const existingByDate = new Map();
const existingByDate = new Map<string, { id: number; day_number: number; date: string | null }>();
for (const d of existing) {
if (d.date) existingByDate.set(d.date, d);
}
const targetDateSet = new Set(targetDates);
// Delete days whose date is no longer in the new range
const toDelete = existing.filter(d => d.date && !targetDateSet.has(d.date));
// Also delete dateless days (they are replaced by dated ones)
const datelessToDelete = existing.filter(d => !d.date);
const del = db.prepare('DELETE FROM days WHERE id = ?');
for (const d of [...toDelete, ...datelessToDelete]) del.run(d.id);
// Move all kept days to negative day_numbers to avoid UNIQUE conflicts
const setTemp = db.prepare('UPDATE days SET day_number = ? WHERE id = ?');
const kept = existing.filter(d => d.date && targetDateSet.has(d.date));
for (let i = 0; i < kept.length; i++) setTemp.run(-(i + 1), kept[i].id);
// Now assign correct day_numbers and insert missing days
const insert = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)');
const update = db.prepare('UPDATE days SET day_number = ? WHERE id = ?');
@@ -127,10 +120,10 @@ function generateDays(tripId, startDate, endDate) {
}
}
// GET /api/trips — active or archived, includes shared trips
router.get('/', authenticate, (req, res) => {
router.get('/', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const archived = req.query.archived === '1' ? 1 : 0;
const userId = req.user.id;
const userId = authReq.user.id;
const trips = db.prepare(`
${TRIP_SELECT}
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
@@ -140,8 +133,8 @@ router.get('/', authenticate, (req, res) => {
res.json({ trips });
});
// POST /api/trips
router.post('/', authenticate, (req, res) => {
router.post('/', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { title, description, start_date, end_date, currency } = req.body;
if (!title) return res.status(400).json({ error: 'Title is required' });
if (start_date && end_date && new Date(end_date) < new Date(start_date))
@@ -150,17 +143,17 @@ router.post('/', authenticate, (req, res) => {
const result = db.prepare(`
INSERT INTO trips (user_id, title, description, start_date, end_date, currency)
VALUES (?, ?, ?, ?, ?, ?)
`).run(req.user.id, title, description || null, start_date || null, end_date || null, currency || 'EUR');
`).run(authReq.user.id, title, description || null, start_date || null, end_date || null, currency || 'EUR');
const tripId = result.lastInsertRowid;
generateDays(tripId, start_date, end_date);
const trip = db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId: req.user.id, tripId });
const trip = db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId: authReq.user.id, tripId });
res.status(201).json({ trip });
});
// GET /api/trips/:id
router.get('/:id', authenticate, (req, res) => {
const userId = req.user.id;
router.get('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const userId = authReq.user.id;
const trip = db.prepare(`
${TRIP_SELECT}
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
@@ -170,16 +163,17 @@ router.get('/:id', authenticate, (req, res) => {
res.json({ trip });
});
// PUT /api/trips/:id — all members can edit; archive/cover owner-only
router.put('/:id', authenticate, (req, res) => {
const access = canAccessTrip(req.params.id, req.user.id);
router.put('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const access = canAccessTrip(req.params.id, authReq.user.id);
if (!access) return res.status(404).json({ error: 'Trip not found' });
const ownerOnly = req.body.is_archived !== undefined || req.body.cover_image !== undefined;
if (ownerOnly && !isOwner(req.params.id, req.user.id))
if (ownerOnly && !isOwner(req.params.id, authReq.user.id))
return res.status(403).json({ error: 'Only the owner can change this setting' });
const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(req.params.id);
const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(req.params.id) as Trip | undefined;
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const { title, description, start_date, end_date, currency, is_archived, cover_image } = req.body;
if (start_date && end_date && new Date(end_date) < new Date(start_date))
@@ -202,17 +196,17 @@ router.put('/:id', authenticate, (req, res) => {
if (newStart !== trip.start_date || newEnd !== trip.end_date)
generateDays(req.params.id, newStart, newEnd);
const updatedTrip = db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId: req.user.id, tripId: req.params.id });
const updatedTrip = db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId: authReq.user.id, tripId: req.params.id });
res.json({ trip: updatedTrip });
broadcast(req.params.id, 'trip:updated', { trip: updatedTrip }, req.headers['x-socket-id']);
broadcast(req.params.id, 'trip:updated', { trip: updatedTrip }, req.headers['x-socket-id'] as string);
});
// POST /api/trips/:id/cover
router.post('/:id/cover', authenticate, demoUploadBlock, uploadCover.single('cover'), (req, res) => {
if (!isOwner(req.params.id, req.user.id))
router.post('/:id/cover', authenticate, demoUploadBlock, uploadCover.single('cover'), (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!isOwner(req.params.id, authReq.user.id))
return res.status(403).json({ error: 'Only the owner can change the cover image' });
const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(req.params.id);
const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(req.params.id) as Trip | undefined;
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!req.file) return res.status(400).json({ error: 'No image uploaded' });
@@ -230,24 +224,22 @@ router.post('/:id/cover', authenticate, demoUploadBlock, uploadCover.single('cov
res.json({ cover_image: coverUrl });
});
// DELETE /api/trips/:id — owner only
router.delete('/:id', authenticate, (req, res) => {
if (!isOwner(req.params.id, req.user.id))
router.delete('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!isOwner(req.params.id, authReq.user.id))
return res.status(403).json({ error: 'Only the owner can delete the trip' });
const deletedTripId = Number(req.params.id);
db.prepare('DELETE FROM trips WHERE id = ?').run(req.params.id);
res.json({ success: true });
broadcast(deletedTripId, 'trip:deleted', { id: deletedTripId }, req.headers['x-socket-id']);
broadcast(deletedTripId, 'trip:deleted', { id: deletedTripId }, req.headers['x-socket-id'] as string);
});
// ── Member Management ────────────────────────────────────────────────────────
// GET /api/trips/:id/members
router.get('/:id/members', authenticate, (req, res) => {
if (!canAccessTrip(req.params.id, req.user.id))
router.get('/:id/members', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!canAccessTrip(req.params.id, authReq.user.id))
return res.status(404).json({ error: 'Trip not found' });
const trip = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(req.params.id);
const trip = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(req.params.id) as { user_id: number };
const members = db.prepare(`
SELECT u.id, u.username, u.email, u.avatar,
CASE WHEN u.id = ? THEN 'owner' ELSE 'member' END as role,
@@ -258,55 +250,55 @@ router.get('/:id/members', authenticate, (req, res) => {
LEFT JOIN users ib ON ib.id = m.invited_by
WHERE m.trip_id = ?
ORDER BY m.added_at ASC
`).all(trip.user_id, req.params.id);
`).all(trip.user_id, req.params.id) as { id: number; username: string; email: string; avatar: string | null; role: string; added_at: string; invited_by_username: string | null }[];
const owner = db.prepare('SELECT id, username, email, avatar FROM users WHERE id = ?').get(trip.user_id);
const owner = db.prepare('SELECT id, username, email, avatar FROM users WHERE id = ?').get(trip.user_id) as Pick<User, 'id' | 'username' | 'email' | 'avatar'>;
res.json({
owner: { ...owner, role: 'owner', avatar_url: owner.avatar ? `/uploads/avatars/${owner.avatar}` : null },
members: members.map(m => ({ ...m, avatar_url: m.avatar ? `/uploads/avatars/${m.avatar}` : null })),
current_user_id: req.user.id,
current_user_id: authReq.user.id,
});
});
// POST /api/trips/:id/members — add by email or username
router.post('/:id/members', authenticate, (req, res) => {
if (!canAccessTrip(req.params.id, req.user.id))
router.post('/:id/members', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!canAccessTrip(req.params.id, authReq.user.id))
return res.status(404).json({ error: 'Trip not found' });
const { identifier } = req.body; // email or username
const { identifier } = req.body;
if (!identifier) return res.status(400).json({ error: 'Email or username required' });
const target = db.prepare(
'SELECT id, username, email, avatar FROM users WHERE email = ? OR username = ?'
).get(identifier.trim(), identifier.trim());
).get(identifier.trim(), identifier.trim()) as Pick<User, 'id' | 'username' | 'email' | 'avatar'> | undefined;
if (!target) return res.status(404).json({ error: 'User not found' });
const trip = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(req.params.id);
const trip = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(req.params.id) as { user_id: number };
if (target.id === trip.user_id)
return res.status(400).json({ error: 'Trip owner is already a member' });
const existing = db.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(req.params.id, target.id);
if (existing) return res.status(400).json({ error: 'User already has access' });
db.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(req.params.id, target.id, req.user.id);
db.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(req.params.id, target.id, authReq.user.id);
res.status(201).json({ member: { ...target, role: 'member', avatar_url: target.avatar ? `/uploads/avatars/${target.avatar}` : null } });
});
// DELETE /api/trips/:id/members/:userId — owner removes anyone; member removes self
router.delete('/:id/members/:userId', authenticate, (req, res) => {
if (!canAccessTrip(req.params.id, req.user.id))
router.delete('/:id/members/:userId', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!canAccessTrip(req.params.id, authReq.user.id))
return res.status(404).json({ error: 'Trip not found' });
const targetId = parseInt(req.params.userId);
const isSelf = targetId === req.user.id;
if (!isSelf && !isOwner(req.params.id, req.user.id))
const isSelf = targetId === authReq.user.id;
if (!isSelf && !isOwner(req.params.id, authReq.user.id))
return res.status(403).json({ error: 'No permission' });
db.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(req.params.id, targetId);
res.json({ success: true });
});
module.exports = router;
export default router;

View File

@@ -1,99 +1,127 @@
const express = require('express');
const { db } = require('../db/database');
const { authenticate } = require('../middleware/auth');
import express, { Request, Response } from 'express';
import { db } from '../db/database';
import { authenticate } from '../middleware/auth';
import { AuthRequest } from '../types';
// In-memory cache for holiday API results (key: "year-country", ttl: 24h)
const holidayCache = new Map();
interface VacayPlan {
id: number;
owner_id: number;
block_weekends: number;
holidays_enabled: number;
holidays_region: string | null;
company_holidays_enabled: number;
carry_over_enabled: number;
}
interface VacayUserYear {
user_id: number;
plan_id: number;
year: number;
vacation_days: number;
carried_over: number;
}
interface VacayUser {
id: number;
username: string;
email: string;
}
interface VacayPlanMember {
id: number;
plan_id: number;
user_id: number;
status: string;
created_at?: string;
}
interface Holiday {
date: string;
localName?: string;
name?: string;
global?: boolean;
counties?: string[] | null;
}
const holidayCache = new Map<string, { data: unknown; time: number }>();
const CACHE_TTL = 24 * 60 * 60 * 1000;
const router = express.Router();
router.use(authenticate);
// Broadcast vacay updates to all users in the same plan (exclude only the triggering socket, not the whole user)
function notifyPlanUsers(planId, excludeSid, event = 'vacay:update') {
function notifyPlanUsers(planId: number, excludeSid: string | undefined, event = 'vacay:update') {
try {
const { broadcastToUser } = require('../websocket');
const plan = db.prepare('SELECT owner_id FROM vacay_plans WHERE id = ?').get(planId);
const plan = db.prepare('SELECT owner_id FROM vacay_plans WHERE id = ?').get(planId) as { owner_id: number } | undefined;
if (!plan) return;
const userIds = [plan.owner_id];
const members = db.prepare("SELECT user_id FROM vacay_plan_members WHERE plan_id = ? AND status = 'accepted'").all(planId);
const members = db.prepare("SELECT user_id FROM vacay_plan_members WHERE plan_id = ? AND status = 'accepted'").all(planId) as { user_id: number }[];
members.forEach(m => userIds.push(m.user_id));
userIds.forEach(id => broadcastToUser(id, { type: event }, excludeSid));
} catch { /* */ }
}
// ── Helpers ────────────────────────────────────────────────
// Get or create the user's own plan
function getOwnPlan(userId) {
let plan = db.prepare('SELECT * FROM vacay_plans WHERE owner_id = ?').get(userId);
function getOwnPlan(userId: number) {
let plan = db.prepare('SELECT * FROM vacay_plans WHERE owner_id = ?').get(userId) as VacayPlan | undefined;
if (!plan) {
db.prepare('INSERT INTO vacay_plans (owner_id) VALUES (?)').run(userId);
plan = db.prepare('SELECT * FROM vacay_plans WHERE owner_id = ?').get(userId);
plan = db.prepare('SELECT * FROM vacay_plans WHERE owner_id = ?').get(userId) as VacayPlan;
const yr = new Date().getFullYear();
db.prepare('INSERT OR IGNORE INTO vacay_years (plan_id, year) VALUES (?, ?)').run(plan.id, yr);
// Create user config for current year
db.prepare('INSERT OR IGNORE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, 0)').run(userId, plan.id, yr);
}
return plan;
}
// Get the plan the user is currently part of (own or fused)
function getActivePlan(userId) {
// Check if user has accepted a fusion
function getActivePlan(userId: number) {
const membership = db.prepare(`
SELECT plan_id FROM vacay_plan_members WHERE user_id = ? AND status = 'accepted'
`).get(userId);
`).get(userId) as { plan_id: number } | undefined;
if (membership) {
return db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(membership.plan_id);
return db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(membership.plan_id) as VacayPlan;
}
return getOwnPlan(userId);
}
function getActivePlanId(userId) {
function getActivePlanId(userId: number): number {
return getActivePlan(userId).id;
}
// Get all users in a plan (owner + accepted members)
function getPlanUsers(planId) {
const plan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId);
function getPlanUsers(planId: number) {
const plan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan | undefined;
if (!plan) return [];
const owner = db.prepare('SELECT id, username, email FROM users WHERE id = ?').get(plan.owner_id);
const owner = db.prepare('SELECT id, username, email FROM users WHERE id = ?').get(plan.owner_id) as VacayUser;
const members = db.prepare(`
SELECT u.id, u.username, u.email FROM vacay_plan_members m
JOIN users u ON m.user_id = u.id
WHERE m.plan_id = ? AND m.status = 'accepted'
`).all(planId);
`).all(planId) as VacayUser[];
return [owner, ...members];
}
// ── Plan ───────────────────────────────────────────────────
router.get('/plan', (req, res) => {
const plan = getActivePlan(req.user.id);
router.get('/plan', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const plan = getActivePlan(authReq.user.id);
const activePlanId = plan.id;
// Get user colors
const users = getPlanUsers(activePlanId).map(u => {
const colorRow = db.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?').get(u.id, activePlanId);
const colorRow = db.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?').get(u.id, activePlanId) as { color: string } | undefined;
return { ...u, color: colorRow?.color || '#6366f1' };
});
// Pending invites (sent from this plan)
const pendingInvites = db.prepare(`
SELECT m.id, m.user_id, u.username, u.email, m.created_at
FROM vacay_plan_members m JOIN users u ON m.user_id = u.id
WHERE m.plan_id = ? AND m.status = 'pending'
`).all(activePlanId);
// Pending invites FOR this user (from others)
const incomingInvites = db.prepare(`
SELECT m.id, m.plan_id, u.username, u.email, m.created_at
FROM vacay_plan_members m
JOIN vacay_plans p ON m.plan_id = p.id
JOIN users u ON p.owner_id = u.id
WHERE m.user_id = ? AND m.status = 'pending'
`).all(req.user.id);
`).all(authReq.user.id);
res.json({
plan: {
@@ -106,22 +134,22 @@ router.get('/plan', (req, res) => {
users,
pendingInvites,
incomingInvites,
isOwner: plan.owner_id === req.user.id,
isOwner: plan.owner_id === authReq.user.id,
isFused: users.length > 1,
});
});
router.put('/plan', async (req, res) => {
const planId = getActivePlanId(req.user.id);
router.put('/plan', async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const planId = getActivePlanId(authReq.user.id);
const { block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled } = req.body;
const updates = [];
const params = [];
const updates: string[] = [];
const params: (string | number)[] = [];
if (block_weekends !== undefined) { updates.push('block_weekends = ?'); params.push(block_weekends ? 1 : 0); }
if (holidays_enabled !== undefined) { updates.push('holidays_enabled = ?'); params.push(holidays_enabled ? 1 : 0); }
if (holidays_region !== undefined) { updates.push('holidays_region = ?'); params.push(holidays_region); }
if (company_holidays_enabled !== undefined) { updates.push('company_holidays_enabled = ?'); params.push(company_holidays_enabled ? 1 : 0); }
if (carry_over_enabled !== undefined) { updates.push('carry_over_enabled = ?'); params.push(carry_over_enabled ? 1 : 0); }
if (updates.length > 0) {
@@ -129,32 +157,28 @@ router.put('/plan', async (req, res) => {
db.prepare(`UPDATE vacay_plans SET ${updates.join(', ')} WHERE id = ?`).run(...params);
}
// If company holidays re-enabled, remove vacation entries that overlap with company holidays
if (company_holidays_enabled === true) {
const companyDates = db.prepare('SELECT date FROM vacay_company_holidays WHERE plan_id = ?').all(planId);
const companyDates = db.prepare('SELECT date FROM vacay_company_holidays WHERE plan_id = ?').all(planId) as { date: string }[];
for (const { date } of companyDates) {
db.prepare('DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?').run(planId, date);
}
}
// If public holidays enabled (or region changed), remove vacation entries that land on holidays
// Only if a full region is selected (for countries that require it)
const updatedPlan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId);
const updatedPlan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan;
if (updatedPlan.holidays_enabled && updatedPlan.holidays_region) {
const country = updatedPlan.holidays_region.split('-')[0];
const region = updatedPlan.holidays_region.includes('-') ? updatedPlan.holidays_region : null;
const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ?').all(planId);
const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ?').all(planId) as { year: number }[];
for (const { year } of years) {
try {
const cacheKey = `${year}-${country}`;
let holidays = holidayCache.get(cacheKey)?.data;
let holidays = holidayCache.get(cacheKey)?.data as Holiday[] | undefined;
if (!holidays) {
const resp = await fetch(`https://date.nager.at/api/v3/PublicHolidays/${year}/${country}`);
holidays = await resp.json();
holidays = await resp.json() as Holiday[];
holidayCache.set(cacheKey, { data: holidays, time: Date.now() });
}
const hasRegions = holidays.some(h => h.counties && h.counties.length > 0);
// If country has regions but no region selected, skip cleanup
const hasRegions = (holidays as Holiday[]).some((h: Holiday) => h.counties && h.counties.length > 0);
if (hasRegions && !region) continue;
for (const h of holidays) {
if (h.global || !h.counties || (region && h.counties.includes(region))) {
@@ -166,21 +190,19 @@ router.put('/plan', async (req, res) => {
}
}
// If carry-over was just disabled, reset all carried_over values to 0
if (carry_over_enabled === false) {
db.prepare('UPDATE vacay_user_years SET carried_over = 0 WHERE plan_id = ?').run(planId);
}
// If carry-over was just enabled, recalculate all years
if (carry_over_enabled === true) {
const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ? ORDER BY year').all(planId);
const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ? ORDER BY year').all(planId) as { year: number }[];
const users = getPlanUsers(planId);
for (let i = 0; i < years.length - 1; i++) {
const yr = years[i].year;
const nextYr = years[i + 1].year;
for (const u of users) {
const used = db.prepare("SELECT COUNT(*) as count FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date LIKE ?").get(u.id, planId, `${yr}-%`).count;
const config = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?').get(u.id, planId, yr);
const used = (db.prepare("SELECT COUNT(*) as count FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date LIKE ?").get(u.id, planId, `${yr}-%`) as { count: number }).count;
const config = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?').get(u.id, planId, yr) as VacayUserYear | undefined;
const total = (config ? config.vacation_days : 30) + (config ? config.carried_over : 0);
const carry = Math.max(0, total - used);
db.prepare(`
@@ -191,20 +213,19 @@ router.put('/plan', async (req, res) => {
}
}
notifyPlanUsers(planId, req.headers['x-socket-id'], 'vacay:settings');
notifyPlanUsers(planId, req.headers['x-socket-id'] as string, 'vacay:settings');
const updated = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId);
const updated = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan;
res.json({
plan: { ...updated, block_weekends: !!updated.block_weekends, holidays_enabled: !!updated.holidays_enabled, company_holidays_enabled: !!updated.company_holidays_enabled, carry_over_enabled: !!updated.carry_over_enabled }
});
});
// ── User color ─────────────────────────────────────────────
router.put('/color', (req, res) => {
router.put('/color', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { color, target_user_id } = req.body;
const planId = getActivePlanId(req.user.id);
const userId = target_user_id ? parseInt(target_user_id) : req.user.id;
const planId = getActivePlanId(authReq.user.id);
const userId = target_user_id ? parseInt(target_user_id) : authReq.user.id;
const planUsers = getPlanUsers(planId);
if (!planUsers.find(u => u.id === userId)) {
return res.status(403).json({ error: 'User not in plan' });
@@ -213,42 +234,37 @@ router.put('/color', (req, res) => {
INSERT INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)
ON CONFLICT(user_id, plan_id) DO UPDATE SET color = excluded.color
`).run(userId, planId, color || '#6366f1');
notifyPlanUsers(planId, req.headers['x-socket-id'], 'vacay:update');
notifyPlanUsers(planId, req.headers['x-socket-id'] as string, 'vacay:update');
res.json({ success: true });
});
// ── Invite / Accept / Decline / Dissolve ───────────────────
// Invite a user
router.post('/invite', (req, res) => {
router.post('/invite', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { user_id } = req.body;
if (!user_id) return res.status(400).json({ error: 'user_id required' });
if (user_id === req.user.id) return res.status(400).json({ error: 'Cannot invite yourself' });
if (user_id === authReq.user.id) return res.status(400).json({ error: 'Cannot invite yourself' });
const targetUser = db.prepare('SELECT id, username FROM users WHERE id = ?').get(user_id);
if (!targetUser) return res.status(404).json({ error: 'User not found' });
const plan = getActivePlan(req.user.id);
const plan = getActivePlan(authReq.user.id);
// Check if already invited or member
const existing = db.prepare('SELECT id, status FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?').get(plan.id, user_id);
const existing = db.prepare('SELECT id, status FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?').get(plan.id, user_id) as { id: number; status: string } | undefined;
if (existing) {
if (existing.status === 'accepted') return res.status(400).json({ error: 'Already fused' });
if (existing.status === 'pending') return res.status(400).json({ error: 'Invite already pending' });
}
// Check if target user is already fused with someone else
const targetFusion = db.prepare("SELECT id FROM vacay_plan_members WHERE user_id = ? AND status = 'accepted'").get(user_id);
if (targetFusion) return res.status(400).json({ error: 'User is already fused with another plan' });
db.prepare('INSERT INTO vacay_plan_members (plan_id, user_id, status) VALUES (?, ?, ?)').run(plan.id, user_id, 'pending');
// Broadcast via WebSocket if available
try {
const { broadcastToUser } = require('../websocket');
broadcastToUser(user_id, {
type: 'vacay:invite',
from: { id: req.user.id, username: req.user.username },
from: { id: authReq.user.id, username: authReq.user.username },
planId: plan.id,
});
} catch { /* websocket not available */ }
@@ -256,69 +272,59 @@ router.post('/invite', (req, res) => {
res.json({ success: true });
});
// Accept invite
router.post('/invite/accept', (req, res) => {
router.post('/invite/accept', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { plan_id } = req.body;
const invite = db.prepare("SELECT * FROM vacay_plan_members WHERE plan_id = ? AND user_id = ? AND status = 'pending'").get(plan_id, req.user.id);
const invite = db.prepare("SELECT * FROM vacay_plan_members WHERE plan_id = ? AND user_id = ? AND status = 'pending'").get(plan_id, authReq.user.id) as VacayPlanMember | undefined;
if (!invite) return res.status(404).json({ error: 'No pending invite' });
// Accept
db.prepare("UPDATE vacay_plan_members SET status = 'accepted' WHERE id = ?").run(invite.id);
// Migrate user's own entries into the fused plan
const ownPlan = db.prepare('SELECT id FROM vacay_plans WHERE owner_id = ?').get(req.user.id);
const ownPlan = db.prepare('SELECT id FROM vacay_plans WHERE owner_id = ?').get(authReq.user.id) as { id: number } | undefined;
if (ownPlan && ownPlan.id !== plan_id) {
// Move entries
db.prepare('UPDATE vacay_entries SET plan_id = ? WHERE plan_id = ? AND user_id = ?').run(plan_id, ownPlan.id, req.user.id);
// Copy year configs
const ownYears = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ?').all(req.user.id, ownPlan.id);
db.prepare('UPDATE vacay_entries SET plan_id = ? WHERE plan_id = ? AND user_id = ?').run(plan_id, ownPlan.id, authReq.user.id);
const ownYears = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ?').all(authReq.user.id, ownPlan.id) as VacayUserYear[];
for (const y of ownYears) {
db.prepare('INSERT OR IGNORE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, ?, ?)').run(req.user.id, plan_id, y.year, y.vacation_days, y.carried_over);
db.prepare('INSERT OR IGNORE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, ?, ?)').run(authReq.user.id, plan_id, y.year, y.vacation_days, y.carried_over);
}
// Copy color
const colorRow = db.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?').get(req.user.id, ownPlan.id);
const colorRow = db.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?').get(authReq.user.id, ownPlan.id) as { color: string } | undefined;
if (colorRow) {
db.prepare('INSERT OR IGNORE INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)').run(req.user.id, plan_id, colorRow.color);
db.prepare('INSERT OR IGNORE INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)').run(authReq.user.id, plan_id, colorRow.color);
}
}
// Auto-change color if it collides with existing plan users
const COLORS = ['#6366f1','#ec4899','#14b8a6','#8b5cf6','#ef4444','#3b82f6','#22c55e','#06b6d4','#f43f5e','#a855f7','#10b981','#0ea5e9','#64748b','#be185d','#0d9488'];
const existingColors = db.prepare('SELECT color FROM vacay_user_colors WHERE plan_id = ? AND user_id != ?').all(plan_id, req.user.id).map(r => r.color);
const myColor = db.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?').get(req.user.id, plan_id);
const existingColors = (db.prepare('SELECT color FROM vacay_user_colors WHERE plan_id = ? AND user_id != ?').all(plan_id, authReq.user.id) as { color: string }[]).map(r => r.color);
const myColor = db.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?').get(authReq.user.id, plan_id) as { color: string } | undefined;
if (myColor && existingColors.includes(myColor.color)) {
const available = COLORS.find(c => !existingColors.includes(c));
if (available) {
db.prepare('UPDATE vacay_user_colors SET color = ? WHERE user_id = ? AND plan_id = ?').run(available, req.user.id, plan_id);
db.prepare('UPDATE vacay_user_colors SET color = ? WHERE user_id = ? AND plan_id = ?').run(available, authReq.user.id, plan_id);
}
}
// Ensure years exist in target plan
const targetYears = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ?').all(plan_id);
const targetYears = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ?').all(plan_id) as { year: number }[];
for (const y of targetYears) {
db.prepare('INSERT OR IGNORE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, 0)').run(req.user.id, plan_id, y.year);
db.prepare('INSERT OR IGNORE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, 0)').run(authReq.user.id, plan_id, y.year);
}
// Notify all plan users (not just owner)
notifyPlanUsers(plan_id, req.headers['x-socket-id'], 'vacay:accepted');
notifyPlanUsers(plan_id, req.headers['x-socket-id'] as string, 'vacay:accepted');
res.json({ success: true });
});
// Decline invite
router.post('/invite/decline', (req, res) => {
router.post('/invite/decline', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { plan_id } = req.body;
db.prepare("DELETE FROM vacay_plan_members WHERE plan_id = ? AND user_id = ? AND status = 'pending'").run(plan_id, req.user.id);
notifyPlanUsers(plan_id, req.headers['x-socket-id'], 'vacay:declined');
db.prepare("DELETE FROM vacay_plan_members WHERE plan_id = ? AND user_id = ? AND status = 'pending'").run(plan_id, authReq.user.id);
notifyPlanUsers(plan_id, req.headers['x-socket-id'] as string, 'vacay:declined');
res.json({ success: true });
});
// Cancel pending invite (by inviter)
router.post('/invite/cancel', (req, res) => {
router.post('/invite/cancel', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { user_id } = req.body;
const plan = getActivePlan(req.user.id);
const plan = getActivePlan(authReq.user.id);
db.prepare("DELETE FROM vacay_plan_members WHERE plan_id = ? AND user_id = ? AND status = 'pending'").run(plan.id, user_id);
try {
@@ -329,50 +335,44 @@ router.post('/invite/cancel', (req, res) => {
res.json({ success: true });
});
// Dissolve fusion
router.post('/dissolve', (req, res) => {
const plan = getActivePlan(req.user.id);
const isOwner = plan.owner_id === req.user.id;
router.post('/dissolve', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const plan = getActivePlan(authReq.user.id);
const isOwnerFlag = plan.owner_id === authReq.user.id;
// Collect all user IDs and company holidays before dissolving
const allUserIds = getPlanUsers(plan.id).map(u => u.id);
const companyHolidays = db.prepare('SELECT date, note FROM vacay_company_holidays WHERE plan_id = ?').all(plan.id);
const companyHolidays = db.prepare('SELECT date, note FROM vacay_company_holidays WHERE plan_id = ?').all(plan.id) as { date: string; note: string }[];
if (isOwner) {
const members = db.prepare("SELECT user_id FROM vacay_plan_members WHERE plan_id = ? AND status = 'accepted'").all(plan.id);
if (isOwnerFlag) {
const members = db.prepare("SELECT user_id FROM vacay_plan_members WHERE plan_id = ? AND status = 'accepted'").all(plan.id) as { user_id: number }[];
for (const m of members) {
const memberPlan = getOwnPlan(m.user_id);
db.prepare('UPDATE vacay_entries SET plan_id = ? WHERE plan_id = ? AND user_id = ?').run(memberPlan.id, plan.id, m.user_id);
// Copy company holidays to member's own plan
for (const ch of companyHolidays) {
db.prepare('INSERT OR IGNORE INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)').run(memberPlan.id, ch.date, ch.note);
}
}
db.prepare('DELETE FROM vacay_plan_members WHERE plan_id = ?').run(plan.id);
} else {
const ownPlan = getOwnPlan(req.user.id);
db.prepare('UPDATE vacay_entries SET plan_id = ? WHERE plan_id = ? AND user_id = ?').run(ownPlan.id, plan.id, req.user.id);
// Copy company holidays to own plan
const ownPlan = getOwnPlan(authReq.user.id);
db.prepare('UPDATE vacay_entries SET plan_id = ? WHERE plan_id = ? AND user_id = ?').run(ownPlan.id, plan.id, authReq.user.id);
for (const ch of companyHolidays) {
db.prepare('INSERT OR IGNORE INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)').run(ownPlan.id, ch.date, ch.note);
}
db.prepare("DELETE FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?").run(plan.id, req.user.id);
db.prepare("DELETE FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?").run(plan.id, authReq.user.id);
}
// Notify all former plan members
try {
const { broadcastToUser } = require('../websocket');
allUserIds.filter(id => id !== req.user.id).forEach(id => broadcastToUser(id, { type: 'vacay:dissolved' }));
allUserIds.filter(id => id !== authReq.user.id).forEach(id => broadcastToUser(id, { type: 'vacay:dissolved' }));
} catch { /* */ }
res.json({ success: true });
});
// ── Available users to invite ──────────────────────────────
router.get('/available-users', (req, res) => {
const planId = getActivePlanId(req.user.id);
// All users except: self, already in this plan, already fused elsewhere
router.get('/available-users', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const planId = getActivePlanId(authReq.user.id);
const users = db.prepare(`
SELECT u.id, u.username, u.email FROM users u
WHERE u.id != ?
@@ -382,34 +382,33 @@ router.get('/available-users', (req, res) => {
SELECT plan_id FROM vacay_plan_members WHERE status = 'accepted'
))
ORDER BY u.username
`).all(req.user.id, planId);
`).all(authReq.user.id, planId);
res.json({ users });
});
// ── Years ──────────────────────────────────────────────────
router.get('/years', (req, res) => {
const planId = getActivePlanId(req.user.id);
const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ? ORDER BY year').all(planId);
router.get('/years', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const planId = getActivePlanId(authReq.user.id);
const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ? ORDER BY year').all(planId) as { year: number }[];
res.json({ years: years.map(y => y.year) });
});
router.post('/years', (req, res) => {
router.post('/years', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { year } = req.body;
if (!year) return res.status(400).json({ error: 'Year required' });
const planId = getActivePlanId(req.user.id);
const planId = getActivePlanId(authReq.user.id);
try {
db.prepare('INSERT INTO vacay_years (plan_id, year) VALUES (?, ?)').run(planId, year);
const plan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId);
const plan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan | undefined;
const carryOverEnabled = plan ? !!plan.carry_over_enabled : true;
const users = getPlanUsers(planId);
for (const u of users) {
// Calculate carry-over from previous year if enabled
let carriedOver = 0;
if (carryOverEnabled) {
const prevConfig = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?').get(u.id, planId, year - 1);
const prevConfig = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?').get(u.id, planId, year - 1) as VacayUserYear | undefined;
if (prevConfig) {
const used = db.prepare("SELECT COUNT(*) as count FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date LIKE ?").get(u.id, planId, `${year - 1}-%`).count;
const used = (db.prepare("SELECT COUNT(*) as count FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date LIKE ?").get(u.id, planId, `${year - 1}-%`) as { count: number }).count;
const total = prevConfig.vacation_days + prevConfig.carried_over;
carriedOver = Math.max(0, total - used);
}
@@ -417,27 +416,27 @@ router.post('/years', (req, res) => {
db.prepare('INSERT OR IGNORE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, ?)').run(u.id, planId, year, carriedOver);
}
} catch { /* exists */ }
notifyPlanUsers(planId, req.headers['x-socket-id'], 'vacay:settings');
const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ? ORDER BY year').all(planId);
notifyPlanUsers(planId, req.headers['x-socket-id'] as string, 'vacay:settings');
const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ? ORDER BY year').all(planId) as { year: number }[];
res.json({ years: years.map(y => y.year) });
});
router.delete('/years/:year', (req, res) => {
router.delete('/years/:year', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const year = parseInt(req.params.year);
const planId = getActivePlanId(req.user.id);
const planId = getActivePlanId(authReq.user.id);
db.prepare('DELETE FROM vacay_years WHERE plan_id = ? AND year = ?').run(planId, year);
db.prepare("DELETE FROM vacay_entries WHERE plan_id = ? AND date LIKE ?").run(planId, `${year}-%`);
db.prepare("DELETE FROM vacay_company_holidays WHERE plan_id = ? AND date LIKE ?").run(planId, `${year}-%`);
notifyPlanUsers(planId, req.headers['x-socket-id'], 'vacay:settings');
const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ? ORDER BY year').all(planId);
notifyPlanUsers(planId, req.headers['x-socket-id'] as string, 'vacay:settings');
const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ? ORDER BY year').all(planId) as { year: number }[];
res.json({ years: years.map(y => y.year) });
});
// ── Entries ────────────────────────────────────────────────
router.get('/entries/:year', (req, res) => {
router.get('/entries/:year', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const year = req.params.year;
const planId = getActivePlanId(req.user.id);
const planId = getActivePlanId(authReq.user.id);
const entries = db.prepare(`
SELECT e.*, u.username as person_name, COALESCE(c.color, '#6366f1') as person_color
FROM vacay_entries e
@@ -449,13 +448,13 @@ router.get('/entries/:year', (req, res) => {
res.json({ entries, companyHolidays });
});
router.post('/entries/toggle', (req, res) => {
router.post('/entries/toggle', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { date, target_user_id } = req.body;
if (!date) return res.status(400).json({ error: 'date required' });
const planId = getActivePlanId(req.user.id);
// Allow toggling for another user if they are in the same plan
let userId = req.user.id;
if (target_user_id && parseInt(target_user_id) !== req.user.id) {
const planId = getActivePlanId(authReq.user.id);
let userId = authReq.user.id;
if (target_user_id && parseInt(target_user_id) !== authReq.user.id) {
const planUsers = getPlanUsers(planId);
const tid = parseInt(target_user_id);
if (!planUsers.find(u => u.id === tid)) {
@@ -463,54 +462,52 @@ router.post('/entries/toggle', (req, res) => {
}
userId = tid;
}
const existing = db.prepare('SELECT id FROM vacay_entries WHERE user_id = ? AND date = ? AND plan_id = ?').get(userId, date, planId);
const existing = db.prepare('SELECT id FROM vacay_entries WHERE user_id = ? AND date = ? AND plan_id = ?').get(userId, date, planId) as { id: number } | undefined;
if (existing) {
db.prepare('DELETE FROM vacay_entries WHERE id = ?').run(existing.id);
notifyPlanUsers(planId, req.headers['x-socket-id']);
notifyPlanUsers(planId, req.headers['x-socket-id'] as string);
res.json({ action: 'removed' });
} else {
db.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)').run(planId, userId, date, '');
notifyPlanUsers(planId, req.headers['x-socket-id']);
notifyPlanUsers(planId, req.headers['x-socket-id'] as string);
res.json({ action: 'added' });
}
});
router.post('/entries/company-holiday', (req, res) => {
router.post('/entries/company-holiday', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { date, note } = req.body;
const planId = getActivePlanId(req.user.id);
const existing = db.prepare('SELECT id FROM vacay_company_holidays WHERE plan_id = ? AND date = ?').get(planId, date);
const planId = getActivePlanId(authReq.user.id);
const existing = db.prepare('SELECT id FROM vacay_company_holidays WHERE plan_id = ? AND date = ?').get(planId, date) as { id: number } | undefined;
if (existing) {
db.prepare('DELETE FROM vacay_company_holidays WHERE id = ?').run(existing.id);
notifyPlanUsers(planId, req.headers['x-socket-id']);
notifyPlanUsers(planId, req.headers['x-socket-id'] as string);
res.json({ action: 'removed' });
} else {
db.prepare('INSERT INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)').run(planId, date, note || '');
// Remove any vacation entries on this date
db.prepare('DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?').run(planId, date);
notifyPlanUsers(planId, req.headers['x-socket-id']);
notifyPlanUsers(planId, req.headers['x-socket-id'] as string);
res.json({ action: 'added' });
}
});
// ── Stats ──────────────────────────────────────────────────
router.get('/stats/:year', (req, res) => {
router.get('/stats/:year', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const year = parseInt(req.params.year);
const planId = getActivePlanId(req.user.id);
const plan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId);
const planId = getActivePlanId(authReq.user.id);
const plan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan | undefined;
const carryOverEnabled = plan ? !!plan.carry_over_enabled : true;
const users = getPlanUsers(planId);
const stats = users.map(u => {
const used = db.prepare("SELECT COUNT(*) as count FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date LIKE ?").get(u.id, planId, `${year}-%`).count;
const config = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?').get(u.id, planId, year);
const used = (db.prepare("SELECT COUNT(*) as count FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date LIKE ?").get(u.id, planId, `${year}-%`) as { count: number }).count;
const config = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?').get(u.id, planId, year) as VacayUserYear | undefined;
const vacationDays = config ? config.vacation_days : 30;
const carriedOver = carryOverEnabled ? (config ? config.carried_over : 0) : 0;
const total = vacationDays + carriedOver;
const remaining = total - used;
const colorRow = db.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?').get(u.id, planId);
const colorRow = db.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?').get(u.id, planId) as { color: string } | undefined;
// Auto-update carry-over into next year (only if enabled)
const nextYearExists = db.prepare('SELECT id FROM vacay_years WHERE plan_id = ? AND year = ?').get(planId, year + 1);
if (nextYearExists && carryOverEnabled) {
const carry = Math.max(0, remaining);
@@ -530,12 +527,12 @@ router.get('/stats/:year', (req, res) => {
res.json({ stats });
});
// Update vacation days for a year (own or fused partner)
router.put('/stats/:year', (req, res) => {
router.put('/stats/:year', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const year = parseInt(req.params.year);
const { vacation_days, target_user_id } = req.body;
const planId = getActivePlanId(req.user.id);
const userId = target_user_id ? parseInt(target_user_id) : req.user.id;
const planId = getActivePlanId(authReq.user.id);
const userId = target_user_id ? parseInt(target_user_id) : authReq.user.id;
const planUsers = getPlanUsers(planId);
if (!planUsers.find(u => u.id === userId)) {
return res.status(403).json({ error: 'User not in plan' });
@@ -544,13 +541,11 @@ router.put('/stats/:year', (req, res) => {
INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, ?, 0)
ON CONFLICT(user_id, plan_id, year) DO UPDATE SET vacation_days = excluded.vacation_days
`).run(userId, planId, year, vacation_days);
notifyPlanUsers(planId, req.headers['x-socket-id']);
notifyPlanUsers(planId, req.headers['x-socket-id'] as string);
res.json({ success: true });
});
// ── Public Holidays API (proxy to Nager.Date) ─────────────
router.get('/holidays/countries', async (req, res) => {
router.get('/holidays/countries', async (_req: Request, res: Response) => {
const cacheKey = 'countries';
const cached = holidayCache.get(cacheKey);
if (cached && Date.now() - cached.time < CACHE_TTL) return res.json(cached.data);
@@ -564,7 +559,7 @@ router.get('/holidays/countries', async (req, res) => {
}
});
router.get('/holidays/:year/:country', async (req, res) => {
router.get('/holidays/:year/:country', async (req: Request, res: Response) => {
const { year, country } = req.params;
const cacheKey = `${year}-${country}`;
const cached = holidayCache.get(cacheKey);
@@ -579,4 +574,4 @@ router.get('/holidays/:year/:country', async (req, res) => {
}
});
module.exports = router;
export default router;

View File

@@ -1,23 +1,89 @@
const express = require('express');
const fetch = require('node-fetch');
const { authenticate } = require('../middleware/auth');
import express, { Request, Response } from 'express';
import fetch from 'node-fetch';
import { authenticate } from '../middleware/auth';
const router = express.Router();
// --------------- In-memory weather cache ---------------
const weatherCache = new Map();
interface WeatherResult {
temp: number;
temp_max?: number;
temp_min?: number;
main: string;
description: string;
type: string;
sunrise?: string | null;
sunset?: string | null;
precipitation_sum?: number;
precipitation_probability_max?: number;
wind_max?: number;
hourly?: HourlyEntry[];
error?: string;
}
const TTL_FORECAST_MS = 60 * 60 * 1000; // 1 hour
const TTL_CURRENT_MS = 15 * 60 * 1000; // 15 minutes
const TTL_CLIMATE_MS = 24 * 60 * 60 * 1000; // 24 hours (historical data doesn't change)
interface HourlyEntry {
hour: number;
temp: number;
precipitation: number;
precipitation_probability: number;
main: string;
wind: number;
humidity: number;
}
function cacheKey(lat, lng, date) {
interface OpenMeteoForecast {
error?: boolean;
reason?: string;
current?: { temperature_2m: number; weathercode: number };
daily?: {
time: string[];
temperature_2m_max: number[];
temperature_2m_min: number[];
weathercode: number[];
precipitation_sum?: number[];
precipitation_probability_max?: number[];
windspeed_10m_max?: number[];
sunrise?: string[];
sunset?: string[];
};
hourly?: {
time: string[];
temperature_2m: number[];
precipitation_probability?: number[];
precipitation?: number[];
weathercode?: number[];
windspeed_10m?: number[];
relativehumidity_2m?: number[];
};
}
const weatherCache = new Map<string, { data: WeatherResult; expiresAt: number }>();
const CACHE_MAX_ENTRIES = 1000;
const CACHE_PRUNE_TARGET = 500;
const CACHE_CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 minutes
setInterval(() => {
const now = Date.now();
for (const [key, entry] of weatherCache) {
if (now > entry.expiresAt) weatherCache.delete(key);
}
if (weatherCache.size > CACHE_MAX_ENTRIES) {
const entries = [...weatherCache.entries()].sort((a, b) => a[1].expiresAt - b[1].expiresAt);
const toDelete = entries.slice(0, entries.length - CACHE_PRUNE_TARGET);
toDelete.forEach(([key]) => weatherCache.delete(key));
}
}, CACHE_CLEANUP_INTERVAL);
const TTL_FORECAST_MS = 60 * 60 * 1000; // 1 hour
const TTL_CURRENT_MS = 15 * 60 * 1000; // 15 minutes
const TTL_CLIMATE_MS = 24 * 60 * 60 * 1000; // 24 hours
function cacheKey(lat: string, lng: string, date?: string): string {
const rlat = parseFloat(lat).toFixed(2);
const rlng = parseFloat(lng).toFixed(2);
return `${rlat}_${rlng}_${date || 'current'}`;
}
function getCached(key) {
function getCached(key: string) {
const entry = weatherCache.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
@@ -27,116 +93,56 @@ function getCached(key) {
return entry.data;
}
function setCache(key, data, ttlMs) {
function setCache(key: string, data: WeatherResult, ttlMs: number) {
weatherCache.set(key, { data, expiresAt: Date.now() + ttlMs });
}
// WMO weather code mapping → condition string used by client icon map
const WMO_MAP = {
0: 'Clear',
1: 'Clear', // mainly clear
2: 'Clouds', // partly cloudy
3: 'Clouds', // overcast
45: 'Fog',
48: 'Fog',
51: 'Drizzle',
53: 'Drizzle',
55: 'Drizzle',
56: 'Drizzle', // freezing drizzle
57: 'Drizzle',
61: 'Rain',
63: 'Rain',
65: 'Rain', // heavy rain
66: 'Rain', // freezing rain
67: 'Rain',
71: 'Snow',
73: 'Snow',
75: 'Snow',
77: 'Snow', // snow grains
80: 'Rain', // rain showers
81: 'Rain',
82: 'Rain',
85: 'Snow', // snow showers
86: 'Snow',
95: 'Thunderstorm',
96: 'Thunderstorm',
99: 'Thunderstorm',
const WMO_MAP: Record<number, string> = {
0: 'Clear', 1: 'Clear', 2: 'Clouds', 3: 'Clouds',
45: 'Fog', 48: 'Fog',
51: 'Drizzle', 53: 'Drizzle', 55: 'Drizzle', 56: 'Drizzle', 57: 'Drizzle',
61: 'Rain', 63: 'Rain', 65: 'Rain', 66: 'Rain', 67: 'Rain',
71: 'Snow', 73: 'Snow', 75: 'Snow', 77: 'Snow',
80: 'Rain', 81: 'Rain', 82: 'Rain',
85: 'Snow', 86: 'Snow',
95: 'Thunderstorm', 96: 'Thunderstorm', 99: 'Thunderstorm',
};
const WMO_DESCRIPTION_DE = {
0: 'Klar',
1: 'Überwiegend klar',
2: 'Teilweise bewölkt',
3: 'Bewölkt',
45: 'Nebel',
48: 'Nebel mit Reif',
51: 'Leichter Nieselregen',
53: 'Nieselregen',
55: 'Starker Nieselregen',
56: 'Gefrierender Nieselregen',
57: 'Starker gefr. Nieselregen',
61: 'Leichter Regen',
63: 'Regen',
65: 'Starker Regen',
66: 'Gefrierender Regen',
67: 'Starker gefr. Regen',
71: 'Leichter Schneefall',
73: 'Schneefall',
75: 'Starker Schneefall',
77: 'Schneekörner',
80: 'Leichte Regenschauer',
81: 'Regenschauer',
82: 'Starke Regenschauer',
85: 'Leichte Schneeschauer',
86: 'Starke Schneeschauer',
95: 'Gewitter',
96: 'Gewitter mit Hagel',
99: 'Starkes Gewitter mit Hagel',
const WMO_DESCRIPTION_DE: Record<number, string> = {
0: 'Klar', 1: 'Uberwiegend klar', 2: 'Teilweise bewolkt', 3: 'Bewolkt',
45: 'Nebel', 48: 'Nebel mit Reif',
51: 'Leichter Nieselregen', 53: 'Nieselregen', 55: 'Starker Nieselregen',
56: 'Gefrierender Nieselregen', 57: 'Starker gefr. Nieselregen',
61: 'Leichter Regen', 63: 'Regen', 65: 'Starker Regen',
66: 'Gefrierender Regen', 67: 'Starker gefr. Regen',
71: 'Leichter Schneefall', 73: 'Schneefall', 75: 'Starker Schneefall', 77: 'Schneekorner',
80: 'Leichte Regenschauer', 81: 'Regenschauer', 82: 'Starke Regenschauer',
85: 'Leichte Schneeschauer', 86: 'Starke Schneeschauer',
95: 'Gewitter', 96: 'Gewitter mit Hagel', 99: 'Starkes Gewitter mit Hagel',
};
const WMO_DESCRIPTION_EN = {
0: 'Clear sky',
1: 'Mainly clear',
2: 'Partly cloudy',
3: 'Overcast',
45: 'Fog',
48: 'Rime fog',
51: 'Light drizzle',
53: 'Drizzle',
55: 'Heavy drizzle',
56: 'Freezing drizzle',
57: 'Heavy freezing drizzle',
61: 'Light rain',
63: 'Rain',
65: 'Heavy rain',
66: 'Freezing rain',
67: 'Heavy freezing rain',
71: 'Light snowfall',
73: 'Snowfall',
75: 'Heavy snowfall',
77: 'Snow grains',
80: 'Light rain showers',
81: 'Rain showers',
82: 'Heavy rain showers',
85: 'Light snow showers',
86: 'Heavy snow showers',
95: 'Thunderstorm',
96: 'Thunderstorm with hail',
99: 'Severe thunderstorm with hail',
const WMO_DESCRIPTION_EN: Record<number, string> = {
0: 'Clear sky', 1: 'Mainly clear', 2: 'Partly cloudy', 3: 'Overcast',
45: 'Fog', 48: 'Rime fog',
51: 'Light drizzle', 53: 'Drizzle', 55: 'Heavy drizzle',
56: 'Freezing drizzle', 57: 'Heavy freezing drizzle',
61: 'Light rain', 63: 'Rain', 65: 'Heavy rain',
66: 'Freezing rain', 67: 'Heavy freezing rain',
71: 'Light snowfall', 73: 'Snowfall', 75: 'Heavy snowfall', 77: 'Snow grains',
80: 'Light rain showers', 81: 'Rain showers', 82: 'Heavy rain showers',
85: 'Light snow showers', 86: 'Heavy snow showers',
95: 'Thunderstorm', 96: 'Thunderstorm with hail', 99: 'Severe thunderstorm with hail',
};
// Estimate weather condition from average temperature + precipitation
function estimateCondition(tempAvg, precipMm) {
function estimateCondition(tempAvg: number, precipMm: number): string {
if (precipMm > 5) return tempAvg <= 0 ? 'Snow' : 'Rain';
if (precipMm > 1) return tempAvg <= 0 ? 'Snow' : 'Drizzle';
if (precipMm > 0.3) return 'Clouds';
return tempAvg > 15 ? 'Clear' : 'Clouds';
}
// -------------------------------------------------------
// GET /api/weather?lat=&lng=&date=&lang=de
router.get('/', authenticate, async (req, res) => {
const { lat, lng, date, lang = 'de' } = req.query;
router.get('/', authenticate, async (req: Request, res: Response) => {
const { lat, lng, date, lang = 'de' } = req.query as { lat: string; lng: string; date?: string; lang?: string };
if (!lat || !lng) {
return res.status(400).json({ error: 'Latitude and longitude are required' });
@@ -145,20 +151,18 @@ router.get('/', authenticate, async (req, res) => {
const ck = cacheKey(lat, lng, date);
try {
// ── Forecast for a specific date ──
if (date) {
const cached = getCached(ck);
if (cached) return res.json(cached);
const targetDate = new Date(date);
const now = new Date();
const diffDays = (targetDate - now) / (1000 * 60 * 60 * 24);
const diffDays = (targetDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24);
// Within 16-day forecast window → real forecast
if (diffDays >= -1 && diffDays <= 16) {
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}&daily=temperature_2m_max,temperature_2m_min,weathercode&timezone=auto&forecast_days=16`;
const response = await fetch(url);
const data = await response.json();
const data = await response.json() as OpenMeteoForecast;
if (!response.ok || data.error) {
return res.status(response.status || 500).json({ error: data.reason || 'Open-Meteo API error' });
@@ -183,14 +187,11 @@ router.get('/', authenticate, async (req, res) => {
setCache(ck, result, TTL_FORECAST_MS);
return res.json(result);
}
// Forecast didn't include this date — fall through to climate
}
// Beyond forecast range or forecast gap → historical climate average
if (diffDays > -1) {
const month = targetDate.getMonth() + 1;
const day = targetDate.getDate();
// Query a 5-day window around the target date for smoother averages (using last year as reference)
const refYear = targetDate.getFullYear() - 1;
const startDate = new Date(refYear, month - 1, day - 2);
const endDate = new Date(refYear, month - 1, day + 2);
@@ -199,7 +200,7 @@ router.get('/', authenticate, async (req, res) => {
const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${lat}&longitude=${lng}&start_date=${startStr}&end_date=${endStr}&daily=temperature_2m_max,temperature_2m_min,precipitation_sum&timezone=auto`;
const response = await fetch(url);
const data = await response.json();
const data = await response.json() as OpenMeteoForecast;
if (!response.ok || data.error) {
return res.status(response.status || 500).json({ error: data.reason || 'Open-Meteo Climate API error' });
@@ -210,7 +211,6 @@ router.get('/', authenticate, async (req, res) => {
return res.json({ error: 'no_forecast' });
}
// Average across the window
let sumMax = 0, sumMin = 0, sumPrecip = 0, count = 0;
for (let i = 0; i < daily.time.length; i++) {
if (daily.temperature_2m_max[i] != null && daily.temperature_2m_min[i] != null) {
@@ -244,17 +244,15 @@ router.get('/', authenticate, async (req, res) => {
return res.json(result);
}
// Past dates beyond yesterday
return res.json({ error: 'no_forecast' });
}
// ── Current weather (no date) ──
const cached = getCached(ck);
if (cached) return res.json(cached);
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}&current=temperature_2m,weathercode&timezone=auto`;
const response = await fetch(url);
const data = await response.json();
const data = await response.json() as OpenMeteoForecast;
if (!response.ok || data.error) {
return res.status(response.status || 500).json({ error: data.reason || 'Open-Meteo API error' });
@@ -272,15 +270,14 @@ router.get('/', authenticate, async (req, res) => {
setCache(ck, result, TTL_CURRENT_MS);
res.json(result);
} catch (err) {
} catch (err: unknown) {
console.error('Weather error:', err);
res.status(500).json({ error: 'Error fetching weather data' });
}
});
// GET /api/weather/detailed?lat=&lng=&date=&lang=de
router.get('/detailed', authenticate, async (req, res) => {
const { lat, lng, date, lang = 'de' } = req.query;
router.get('/detailed', authenticate, async (req: Request, res: Response) => {
const { lat, lng, date, lang = 'de' } = req.query as { lat: string; lng: string; date: string; lang?: string };
if (!lat || !lng || !date) {
return res.status(400).json({ error: 'Latitude, longitude, and date are required' });
@@ -294,11 +291,10 @@ router.get('/detailed', authenticate, async (req, res) => {
const targetDate = new Date(date);
const now = new Date();
const diffDays = (targetDate - now) / (1000 * 60 * 60 * 24);
const diffDays = (targetDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24);
const dateStr = targetDate.toISOString().slice(0, 10);
const descriptions = lang === 'de' ? WMO_DESCRIPTION_DE : WMO_DESCRIPTION_EN;
// Beyond 16-day forecast window → archive API with hourly data from same date last year
if (diffDays > 16) {
const refYear = targetDate.getFullYear() - 1;
const refDateStr = `${refYear}-${String(targetDate.getMonth() + 1).padStart(2, '0')}-${String(targetDate.getDate()).padStart(2, '0')}`;
@@ -309,7 +305,7 @@ router.get('/detailed', authenticate, async (req, res) => {
+ `&daily=temperature_2m_max,temperature_2m_min,weathercode,precipitation_sum,windspeed_10m_max,sunrise,sunset`
+ `&timezone=auto`;
const response = await fetch(url);
const data = await response.json();
const data = await response.json() as OpenMeteoForecast;
if (!response.ok || data.error) {
return res.status(response.status || 500).json({ error: data.reason || 'Open-Meteo Climate API error' });
@@ -326,8 +322,7 @@ router.get('/detailed', authenticate, async (req, res) => {
const avgMax = daily.temperature_2m_max[idx];
const avgMin = daily.temperature_2m_min[idx];
// Build hourly array
const hourlyData = [];
const hourlyData: HourlyEntry[] = [];
if (hourly?.time) {
for (let i = 0; i < hourly.time.length; i++) {
const hour = new Date(hourly.time[i]).getHours();
@@ -336,7 +331,7 @@ router.get('/detailed', authenticate, async (req, res) => {
hour,
temp: Math.round(hourly.temperature_2m[i]),
precipitation: hourly.precipitation?.[i] || 0,
precipitation_probability: 0, // archive has no probability
precipitation_probability: 0,
main: WMO_MAP[hCode] || 'Clouds',
wind: Math.round(hourly.windspeed_10m?.[i] || 0),
humidity: hourly.relativehumidity_2m?.[i] || 0,
@@ -344,8 +339,7 @@ router.get('/detailed', authenticate, async (req, res) => {
}
}
// Format sunrise/sunset
let sunrise = null, sunset = null;
let sunrise: string | null = null, sunset: string | null = null;
if (daily.sunrise?.[idx]) sunrise = daily.sunrise[idx].split('T')[1]?.slice(0, 5);
if (daily.sunset?.[idx]) sunset = daily.sunset[idx].split('T')[1]?.slice(0, 5);
@@ -367,14 +361,13 @@ router.get('/detailed', authenticate, async (req, res) => {
return res.json(result);
}
// Within 16-day forecast window → full forecast with hourly data
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}`
+ `&hourly=temperature_2m,precipitation_probability,precipitation,weathercode,windspeed_10m,relativehumidity_2m`
+ `&daily=temperature_2m_max,temperature_2m_min,weathercode,sunrise,sunset,precipitation_probability_max,precipitation_sum,windspeed_10m_max`
+ `&timezone=auto&start_date=${dateStr}&end_date=${dateStr}`;
const response = await fetch(url);
const data = await response.json();
const data = await response.json() as OpenMeteoForecast;
if (!response.ok || data.error) {
return res.status(response.status || 500).json({ error: data.reason || 'Open-Meteo API error' });
@@ -387,18 +380,16 @@ router.get('/detailed', authenticate, async (req, res) => {
return res.json({ error: 'no_forecast' });
}
const dayIdx = 0; // We requested a single day
const dayIdx = 0;
const code = daily.weathercode[dayIdx];
// Parse sunrise/sunset to HH:MM
const formatTime = (isoStr) => {
const formatTime = (isoStr: string) => {
if (!isoStr) return '';
const d = new Date(isoStr);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
};
// Build hourly array
const hourlyData = [];
const hourlyData: HourlyEntry[] = [];
if (hourly && hourly.time) {
for (let i = 0; i < hourly.time.length; i++) {
const h = new Date(hourly.time[i]).getHours();
@@ -431,10 +422,10 @@ router.get('/detailed', authenticate, async (req, res) => {
setCache(ck, result, TTL_FORECAST_MS);
return res.json(result);
} catch (err) {
} catch (err: unknown) {
console.error('Detailed weather error:', err);
res.status(500).json({ error: 'Error fetching detailed weather data' });
}
});
module.exports = router;
export default router;

View File

@@ -1,14 +1,14 @@
const cron = require('node-cron');
const archiver = require('archiver');
const path = require('path');
const fs = require('fs');
import cron from 'node-cron';
import archiver from 'archiver';
import path from 'path';
import fs from 'fs';
const dataDir = path.join(__dirname, '../data');
const backupsDir = path.join(dataDir, 'backups');
const uploadsDir = path.join(__dirname, '../uploads');
const settingsFile = path.join(dataDir, 'backup-settings.json');
const CRON_EXPRESSIONS = {
const CRON_EXPRESSIONS: Record<string, string> = {
hourly: '0 * * * *',
daily: '0 2 * * *',
weekly: '0 2 * * 0',
@@ -17,9 +17,15 @@ const CRON_EXPRESSIONS = {
const VALID_INTERVALS = Object.keys(CRON_EXPRESSIONS);
let currentTask = null;
interface BackupSettings {
enabled: boolean;
interval: string;
keep_days: number;
}
function loadSettings() {
let currentTask: cron.ScheduledTask | null = null;
function loadSettings(): BackupSettings {
try {
if (fs.existsSync(settingsFile)) {
return JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
@@ -28,12 +34,12 @@ function loadSettings() {
return { enabled: false, interval: 'daily', keep_days: 7 };
}
function saveSettings(settings) {
function saveSettings(settings: BackupSettings): void {
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2));
}
async function runBackup() {
async function runBackup(): Promise<void> {
if (!fs.existsSync(backupsDir)) fs.mkdirSync(backupsDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
@@ -44,7 +50,7 @@ async function runBackup() {
// Flush WAL to main DB file before archiving
try { const { db } = require('./db/database'); db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {}
await new Promise((resolve, reject) => {
await new Promise<void>((resolve, reject) => {
const output = fs.createWriteStream(outputPath);
const archive = archiver('zip', { zlib: { level: 9 } });
output.on('close', resolve);
@@ -56,8 +62,8 @@ async function runBackup() {
archive.finalize();
});
console.log(`[Auto-Backup] Created: ${filename}`);
} catch (err) {
console.error('[Auto-Backup] Error:', err.message);
} catch (err: unknown) {
console.error('[Auto-Backup] Error:', err instanceof Error ? err.message : err);
if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath);
return;
}
@@ -68,9 +74,10 @@ async function runBackup() {
}
}
function cleanupOldBackups(keepDays) {
function cleanupOldBackups(keepDays: number): void {
try {
const cutoff = Date.now() - keepDays * 24 * 60 * 60 * 1000;
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const cutoff = Date.now() - keepDays * MS_PER_DAY;
const files = fs.readdirSync(backupsDir).filter(f => f.endsWith('.zip'));
for (const file of files) {
const filePath = path.join(backupsDir, file);
@@ -80,12 +87,12 @@ function cleanupOldBackups(keepDays) {
console.log(`[Auto-Backup] Old backup deleted: ${file}`);
}
}
} catch (err) {
console.error('[Auto-Backup] Cleanup error:', err.message);
} catch (err: unknown) {
console.error('[Auto-Backup] Cleanup error:', err instanceof Error ? err.message : err);
}
}
function start() {
function start(): void {
if (currentTask) {
currentTask.stop();
currentTask = null;
@@ -103,9 +110,9 @@ function start() {
}
// Demo mode: hourly reset of demo user data
let demoTask = null;
let demoTask: cron.ScheduledTask | null = null;
function startDemoReset() {
function startDemoReset(): void {
if (demoTask) { demoTask.stop(); demoTask = null; }
if (process.env.DEMO_MODE !== 'true') return;
@@ -113,16 +120,16 @@ function startDemoReset() {
try {
const { resetDemoUser } = require('./demo/demo-reset');
resetDemoUser();
} catch (err) {
console.error('[Demo Reset] Error:', err.message);
} catch (err: unknown) {
console.error('[Demo Reset] Error:', err instanceof Error ? err.message : err);
}
});
console.log('[Demo] Hourly reset scheduled (at :00 every hour)');
}
function stop() {
function stop(): void {
if (currentTask) { currentTask.stop(); currentTask = null; }
if (demoTask) { demoTask.stop(); demoTask = null; }
}
module.exports = { start, stop, startDemoReset, loadSettings, saveSettings, VALID_INTERVALS };
export { start, stop, startDemoReset, loadSettings, saveSettings, VALID_INTERVALS };

View File

@@ -0,0 +1,93 @@
import { db } from '../db/database';
import { AssignmentRow, Tag, Participant } from '../types';
interface TagRow extends Tag {
place_id: number;
}
interface ParticipantRow {
assignment_id: number;
user_id: number;
username: string;
avatar: string | null;
}
/** Batch-load tags for multiple places in a single query, indexed by place ID. */
function loadTagsByPlaceIds(placeIds: number[], { compact }: { compact?: boolean } = {}): Record<number, Partial<Tag>[]> {
const tagsByPlaceId: Record<number, Partial<Tag>[]> = {};
if (placeIds.length > 0) {
const placeholders = placeIds.map(() => '?').join(',');
const allTags = db.prepare(`
SELECT t.*, pt.place_id FROM tags t
JOIN place_tags pt ON t.id = pt.tag_id
WHERE pt.place_id IN (${placeholders})
`).all(...placeIds) as TagRow[];
for (const tag of allTags) {
const pid = tag.place_id;
if (!tagsByPlaceId[pid]) tagsByPlaceId[pid] = [];
if (compact) {
tagsByPlaceId[pid].push({ id: tag.id, name: tag.name, color: tag.color, created_at: tag.created_at });
} else {
const { place_id, ...rest } = tag;
tagsByPlaceId[pid].push(rest);
}
}
}
return tagsByPlaceId;
}
/** Batch-load participants for multiple day-assignments in a single query, indexed by assignment ID. */
function loadParticipantsByAssignmentIds(assignmentIds: number[]): Record<number, Participant[]> {
const participantsByAssignment: Record<number, Participant[]> = {};
if (assignmentIds.length > 0) {
const allParticipants = db.prepare(`SELECT ap.assignment_id, ap.user_id, u.username, u.avatar FROM assignment_participants ap JOIN users u ON ap.user_id = u.id WHERE ap.assignment_id IN (${assignmentIds.map(() => '?').join(',')})`)
.all(...assignmentIds) as ParticipantRow[];
for (const p of allParticipants) {
if (!participantsByAssignment[p.assignment_id]) participantsByAssignment[p.assignment_id] = [];
participantsByAssignment[p.assignment_id].push({ user_id: p.user_id, username: p.username, avatar: p.avatar });
}
}
return participantsByAssignment;
}
/** Reshape a flat assignment+place DB row into the nested API response shape with embedded place, tags, and participants. */
function formatAssignmentWithPlace(a: AssignmentRow, tags: Partial<Tag>[], participants: Participant[]) {
return {
id: a.id,
day_id: a.day_id,
order_index: a.order_index,
notes: a.notes,
participants: participants || [],
created_at: a.created_at,
place: {
id: a.place_id,
name: a.place_name,
description: a.place_description,
lat: a.lat,
lng: a.lng,
address: a.address,
category_id: a.category_id,
price: a.price,
currency: a.place_currency,
place_time: a.place_time,
end_time: a.end_time,
duration_minutes: a.duration_minutes,
notes: a.place_notes,
image_url: a.image_url,
transport_mode: a.transport_mode,
google_place_id: a.google_place_id,
website: a.website,
phone: a.phone,
category: a.category_id ? {
id: a.category_id,
name: a.category_name,
color: a.category_color,
icon: a.category_icon,
} : null,
tags: tags || [],
}
};
}
export { loadTagsByPlaceIds, loadParticipantsByAssignmentIds, formatAssignmentWithPlace };

291
server/src/types.ts Normal file
View File

@@ -0,0 +1,291 @@
import { Request } from 'express';
export interface User {
id: number;
username: string;
email: string;
role: 'admin' | 'user';
password_hash?: string;
maps_api_key?: string | null;
unsplash_api_key?: string | null;
openweather_api_key?: string | null;
avatar?: string | null;
oidc_sub?: string | null;
oidc_issuer?: string | null;
last_login?: string | null;
created_at?: string;
updated_at?: string;
}
export interface Trip {
id: number;
user_id: number;
title: string;
description?: string | null;
start_date?: string | null;
end_date?: string | null;
currency: string;
cover_image?: string | null;
is_archived: number;
created_at?: string;
updated_at?: string;
}
export interface Day {
id: number;
trip_id: number;
day_number: number;
date?: string | null;
notes?: string | null;
title?: string | null;
}
export interface Place {
id: number;
trip_id: number;
name: string;
description?: string | null;
lat?: number | null;
lng?: number | null;
address?: string | null;
category_id?: number | null;
price?: number | null;
currency?: string | null;
reservation_status?: string;
reservation_notes?: string | null;
reservation_datetime?: string | null;
place_time?: string | null;
end_time?: string | null;
duration_minutes?: number;
notes?: string | null;
image_url?: string | null;
google_place_id?: string | null;
website?: string | null;
phone?: string | null;
transport_mode?: string;
created_at?: string;
updated_at?: string;
}
export interface Category {
id: number;
name: string;
color: string;
icon: string;
user_id?: number | null;
created_at?: string;
}
export interface Tag {
id: number;
user_id: number;
name: string;
color: string;
created_at?: string;
}
export interface DayAssignment {
id: number;
day_id: number;
place_id: number;
order_index: number;
notes?: string | null;
reservation_status?: string;
reservation_notes?: string | null;
reservation_datetime?: string | null;
assignment_time?: string | null;
assignment_end_time?: string | null;
created_at?: string;
}
export interface PackingItem {
id: number;
trip_id: number;
name: string;
checked: number;
category?: string | null;
sort_order: number;
created_at?: string;
}
export interface BudgetItem {
id: number;
trip_id: number;
category: string;
name: string;
total_price: number;
persons?: number | null;
days?: number | null;
note?: string | null;
sort_order: number;
created_at?: string;
members?: BudgetItemMember[];
}
export interface BudgetItemMember {
user_id: number;
paid: number;
username: string;
avatar_url?: string | null;
avatar?: string | null;
budget_item_id?: number;
}
export interface Reservation {
id: number;
trip_id: number;
day_id?: number | null;
place_id?: number | null;
assignment_id?: number | null;
title: string;
reservation_time?: string | null;
reservation_end_time?: string | null;
location?: string | null;
confirmation_number?: string | null;
notes?: string | null;
status: string;
type: string;
created_at?: string;
day_number?: number;
place_name?: string;
}
export interface TripFile {
id: number;
trip_id: number;
place_id?: number | null;
reservation_id?: number | null;
note_id?: number | null;
filename: string;
original_name: string;
file_size?: number | null;
mime_type?: string | null;
description?: string | null;
created_at?: string;
reservation_title?: string;
url?: string;
}
export interface TripMember {
id: number;
trip_id: number;
user_id: number;
invited_by?: number | null;
added_at?: string;
}
export interface DayNote {
id: number;
day_id: number;
trip_id: number;
text: string;
time?: string | null;
icon: string;
sort_order: number;
created_at?: string;
}
export interface CollabNote {
id: number;
trip_id: number;
user_id: number;
category: string;
title: string;
content?: string | null;
color: string;
pinned: number;
website?: string | null;
username?: string;
avatar?: string | null;
created_at?: string;
updated_at?: string;
}
export interface CollabPoll {
id: number;
trip_id: number;
user_id: number;
question: string;
options: string;
multiple: number;
closed: number;
deadline?: string | null;
username?: string;
avatar?: string | null;
created_at?: string;
}
export interface CollabMessage {
id: number;
trip_id: number;
user_id: number;
text: string;
reply_to?: number | null;
deleted?: number;
username?: string;
avatar?: string | null;
reply_text?: string | null;
reply_username?: string | null;
created_at?: string;
}
export interface Addon {
id: string;
name: string;
description?: string | null;
type: string;
icon: string;
enabled: number;
config: string;
sort_order: number;
}
export interface AppSetting {
key: string;
value?: string | null;
}
export interface Setting {
id: number;
user_id: number;
key: string;
value?: string | null;
}
export interface AuthRequest extends Request {
user: { id: number; username: string; email: string; role: string };
trip?: { id: number; user_id: number };
}
export interface OptionalAuthRequest extends Request {
user: { id: number; username: string; email: string; role: string } | null;
}
export interface AssignmentRow extends DayAssignment {
place_name: string;
place_description: string | null;
lat: number | null;
lng: number | null;
address: string | null;
category_id: number | null;
price: number | null;
place_currency: string | null;
place_time: string | null;
end_time: string | null;
duration_minutes: number;
place_notes: string | null;
image_url: string | null;
transport_mode: string;
google_place_id: string | null;
website: string | null;
phone: string | null;
category_name: string | null;
category_color: string | null;
category_icon: string | null;
}
export interface Participant {
user_id: number;
username: string;
avatar?: string | null;
}

View File

@@ -1,168 +0,0 @@
const { WebSocketServer } = require('ws');
const jwt = require('jsonwebtoken');
const { JWT_SECRET } = require('./config');
const { db, canAccessTrip } = require('./db/database');
// Room management: tripId → Set<WebSocket>
const rooms = new Map();
// Track which rooms each socket is in
const socketRooms = new WeakMap();
// Track user info per socket
const socketUser = new WeakMap();
// Track unique socket ID
const socketId = new WeakMap();
let nextSocketId = 1;
let wss;
function setupWebSocket(server) {
wss = new WebSocketServer({ server, path: '/ws' });
// Heartbeat: ping every 30s, terminate if no pong
const heartbeat = setInterval(() => {
wss.clients.forEach((ws) => {
if (ws.isAlive === false) return ws.terminate();
ws.isAlive = false;
ws.ping();
});
}, 30000);
wss.on('close', () => clearInterval(heartbeat));
wss.on('connection', (ws, req) => {
// Extract token from query param
const url = new URL(req.url, 'http://localhost');
const token = url.searchParams.get('token');
if (!token) {
ws.close(4001, 'Authentication required');
return;
}
let user;
try {
const decoded = jwt.verify(token, JWT_SECRET);
user = db.prepare(
'SELECT id, username, email, role FROM users WHERE id = ?'
).get(decoded.id);
if (!user) {
ws.close(4001, 'User not found');
return;
}
} catch (err) {
ws.close(4001, 'Invalid or expired token');
return;
}
ws.isAlive = true;
const sid = nextSocketId++;
socketId.set(ws, sid);
socketUser.set(ws, user);
socketRooms.set(ws, new Set());
ws.send(JSON.stringify({ type: 'welcome', socketId: sid }));
ws.on('pong', () => { ws.isAlive = true; });
ws.on('message', (data) => {
let msg;
try {
msg = JSON.parse(data.toString());
} catch {
return;
}
if (msg.type === 'join' && msg.tripId) {
const tripId = Number(msg.tripId);
// Verify the user has access to this trip
if (!canAccessTrip(tripId, user.id)) {
ws.send(JSON.stringify({ type: 'error', message: 'Access denied' }));
return;
}
// Add to room
if (!rooms.has(tripId)) rooms.set(tripId, new Set());
rooms.get(tripId).add(ws);
socketRooms.get(ws).add(tripId);
ws.send(JSON.stringify({ type: 'joined', tripId }));
}
if (msg.type === 'leave' && msg.tripId) {
const tripId = Number(msg.tripId);
leaveRoom(ws, tripId);
ws.send(JSON.stringify({ type: 'left', tripId }));
}
});
ws.on('close', () => {
// Clean up all rooms this socket was in
const myRooms = socketRooms.get(ws);
if (myRooms) {
for (const tripId of myRooms) {
leaveRoom(ws, tripId);
}
}
});
});
console.log('WebSocket server attached at /ws');
}
function leaveRoom(ws, tripId) {
const room = rooms.get(tripId);
if (room) {
room.delete(ws);
if (room.size === 0) rooms.delete(tripId);
}
const myRooms = socketRooms.get(ws);
if (myRooms) myRooms.delete(tripId);
}
/**
* Broadcast an event to all sockets in a trip room, optionally excluding a user.
* @param {number} tripId
* @param {string} eventType e.g. 'place:created'
* @param {object} payload the data to send
* @param {number} [excludeUserId] don't send to this user (the one who triggered the change)
*/
function broadcast(tripId, eventType, payload, excludeSid) {
tripId = Number(tripId);
const room = rooms.get(tripId);
if (!room || room.size === 0) return;
const excludeNum = excludeSid ? Number(excludeSid) : null;
for (const ws of room) {
if (ws.readyState !== 1) continue; // WebSocket.OPEN === 1
// Exclude the specific socket that triggered the change
if (excludeNum && socketId.get(ws) === excludeNum) continue;
ws.send(JSON.stringify({ type: eventType, tripId, ...payload }));
}
}
function broadcastToUser(userId, payload, excludeSid) {
if (!wss) return;
const excludeNum = excludeSid ? Number(excludeSid) : null;
for (const ws of wss.clients) {
if (ws.readyState !== 1) continue;
if (excludeNum && socketId.get(ws) === excludeNum) continue;
const user = socketUser.get(ws);
if (user && user.id === userId) {
ws.send(JSON.stringify(payload));
}
}
}
function getOnlineUserIds() {
const ids = new Set();
if (!wss) return ids;
for (const ws of wss.clients) {
if (ws.readyState !== 1) continue;
const user = socketUser.get(ws);
if (user) ids.add(user.id);
}
return ids;
}
module.exports = { setupWebSocket, broadcast, broadcastToUser, getOnlineUserIds };

176
server/src/websocket.ts Normal file
View File

@@ -0,0 +1,176 @@
import { WebSocketServer, WebSocket } from 'ws';
import jwt from 'jsonwebtoken';
import { JWT_SECRET } from './config';
import { db, canAccessTrip } from './db/database';
import { User } from './types';
import http from 'http';
interface NomadWebSocket extends WebSocket {
isAlive: boolean;
}
// Room management: tripId -> Set<WebSocket>
const rooms = new Map<number, Set<NomadWebSocket>>();
// Track which rooms each socket is in
const socketRooms = new WeakMap<NomadWebSocket, Set<number>>();
// Track user info per socket
const socketUser = new WeakMap<NomadWebSocket, User>();
// Track unique socket ID
const socketId = new WeakMap<NomadWebSocket, number>();
let nextSocketId = 1;
let wss: WebSocketServer | null = null;
/** Attaches a WebSocket server with JWT auth, room-based trip channels, and heartbeat keep-alive. */
function setupWebSocket(server: http.Server): void {
wss = new WebSocketServer({ server, path: '/ws' });
const HEARTBEAT_INTERVAL = 30000; // 30 seconds
const heartbeat = setInterval(() => {
wss!.clients.forEach((ws) => {
const nws = ws as NomadWebSocket;
if (nws.isAlive === false) return nws.terminate();
nws.isAlive = false;
nws.ping();
});
}, HEARTBEAT_INTERVAL);
wss.on('close', () => clearInterval(heartbeat));
wss.on('connection', (ws: WebSocket, req: http.IncomingMessage) => {
const nws = ws as NomadWebSocket;
// Extract token from query param
const url = new URL(req.url!, 'http://localhost');
const token = url.searchParams.get('token');
if (!token) {
nws.close(4001, 'Authentication required');
return;
}
let user: User | undefined;
try {
const decoded = jwt.verify(token, JWT_SECRET) as { id: number };
user = db.prepare(
'SELECT id, username, email, role FROM users WHERE id = ?'
).get(decoded.id) as User | undefined;
if (!user) {
nws.close(4001, 'User not found');
return;
}
} catch (err: unknown) {
nws.close(4001, 'Invalid or expired token');
return;
}
nws.isAlive = true;
const sid = nextSocketId++;
socketId.set(nws, sid);
socketUser.set(nws, user);
socketRooms.set(nws, new Set());
nws.send(JSON.stringify({ type: 'welcome', socketId: sid }));
nws.on('pong', () => { nws.isAlive = true; });
nws.on('message', (data) => {
let msg: { type: string; tripId?: number | string };
try {
msg = JSON.parse(data.toString());
} catch {
return;
}
if (msg.type === 'join' && msg.tripId) {
const tripId = Number(msg.tripId);
// Verify the user has access to this trip
if (!canAccessTrip(tripId, user!.id)) {
nws.send(JSON.stringify({ type: 'error', message: 'Access denied' }));
return;
}
// Add to room
if (!rooms.has(tripId)) rooms.set(tripId, new Set());
rooms.get(tripId)!.add(nws);
socketRooms.get(nws)!.add(tripId);
nws.send(JSON.stringify({ type: 'joined', tripId }));
}
if (msg.type === 'leave' && msg.tripId) {
const tripId = Number(msg.tripId);
leaveRoom(nws, tripId);
nws.send(JSON.stringify({ type: 'left', tripId }));
}
});
nws.on('close', () => {
// Clean up all rooms this socket was in
const myRooms = socketRooms.get(nws);
if (myRooms) {
for (const tripId of myRooms) {
leaveRoom(nws, tripId);
}
}
});
});
console.log('WebSocket server attached at /ws');
}
function leaveRoom(ws: NomadWebSocket, tripId: number): void {
const room = rooms.get(tripId);
if (room) {
room.delete(ws);
if (room.size === 0) rooms.delete(tripId);
}
const myRooms = socketRooms.get(ws);
if (myRooms) myRooms.delete(tripId);
}
/**
* Broadcast an event to all sockets in a trip room, optionally excluding a socket.
*/
function broadcast(tripId: number | string, eventType: string, payload: Record<string, unknown>, excludeSid?: number | string): void {
tripId = Number(tripId);
const room = rooms.get(tripId);
if (!room || room.size === 0) return;
const excludeNum = excludeSid ? Number(excludeSid) : null;
for (const ws of room) {
if (ws.readyState !== 1) continue; // WebSocket.OPEN === 1
// Exclude the specific socket that triggered the change
if (excludeNum && socketId.get(ws) === excludeNum) continue;
ws.send(JSON.stringify({ type: eventType, tripId, ...payload }));
}
}
/** Send a message to all sockets belonging to a specific user (e.g., for trip invitations). */
function broadcastToUser(userId: number, payload: Record<string, unknown>, excludeSid?: number | string): void {
if (!wss) return;
const excludeNum = excludeSid ? Number(excludeSid) : null;
for (const ws of wss.clients) {
const nws = ws as NomadWebSocket;
if (nws.readyState !== 1) continue;
if (excludeNum && socketId.get(nws) === excludeNum) continue;
const user = socketUser.get(nws);
if (user && user.id === userId) {
nws.send(JSON.stringify(payload));
}
}
}
function getOnlineUserIds(): Set<number> {
const ids = new Set<number>();
if (!wss) return ids;
for (const ws of wss.clients) {
const nws = ws as NomadWebSocket;
if (nws.readyState !== 1) continue;
const user = socketUser.get(nws);
if (user) ids.add(user.id);
}
return ids;
}
export { setupWebSocket, broadcast, broadcastToUser, getOnlineUserIds };