refactoring: TypeScript migration, security fixes,
This commit is contained in:
@@ -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 };
|
||||
@@ -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 = [
|
||||
// 1–18: 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
131
server/src/db/database.ts
Normal 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
208
server/src/db/migrations.ts
Normal 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
375
server/src/db/schema.ts
Normal 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
51
server/src/db/seeds.ts
Normal 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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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
208
server/src/index.ts
Normal 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;
|
||||
@@ -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 };
|
||||
71
server/src/middleware/auth.ts
Normal file
71
server/src/middleware/auth.ts
Normal 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 };
|
||||
37
server/src/middleware/tripAccess.ts
Normal file
37
server/src/middleware/tripAccess.ts
Normal 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 };
|
||||
26
server/src/middleware/validate.ts
Normal file
26
server/src/middleware/validate.ts
Normal 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 };
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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}¤t=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;
|
||||
@@ -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 };
|
||||
93
server/src/services/queryHelpers.ts
Normal file
93
server/src/services/queryHelpers.ts
Normal 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
291
server/src/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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
176
server/src/websocket.ts
Normal 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 };
|
||||
Reference in New Issue
Block a user