diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index cfe6544..12481c8 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -1,192 +1,82 @@ import express, { Request, Response } from 'express'; -import bcrypt from 'bcryptjs'; -import crypto from 'crypto'; -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'; +import { AuthRequest } from '../types'; import { writeAudit, getClientIp, logInfo } from '../services/auditLog'; -import { getAllPermissions, savePermissions, PERMISSION_ACTIONS } from '../services/permissions'; -import { revokeUserSessions } from '../mcp'; -import { maybe_encrypt_api_key, decrypt_api_key } from '../services/apiKeyCrypto'; -import { validatePassword } from '../services/passwordPolicy'; -import { updateJwtSecret } from '../config'; +import * as svc from '../services/adminService'; const router = express.Router(); router.use(authenticate, adminOnly); -function utcSuffix(ts: string | null | undefined): string | null { - if (!ts) return null; - return ts.endsWith('Z') ? ts : ts.replace(' ', 'T') + 'Z'; -} +// ── User CRUD ────────────────────────────────────────────────────────────── -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() as Pick[]; - let onlineUserIds = new Set(); - try { - const { getOnlineUserIds } = require('../websocket'); - onlineUserIds = getOnlineUserIds(); - } catch { /* */ } - const usersWithStatus = users.map(u => ({ - ...u, - created_at: utcSuffix(u.created_at), - updated_at: utcSuffix(u.updated_at as string), - last_login: utcSuffix(u.last_login), - online: onlineUserIds.has(u.id), - })); - res.json({ users: usersWithStatus }); +router.get('/users', (_req: Request, res: Response) => { + res.json({ users: svc.listUsers() }); }); router.post('/users', (req: Request, res: Response) => { - const { username, email, password, role } = req.body; - - if (!username?.trim() || !email?.trim() || !password?.trim()) { - return res.status(400).json({ error: 'Username, email and password are required' }); - } - - const pwCheck = validatePassword(password.trim()); - if (!pwCheck.ok) return res.status(400).json({ error: pwCheck.reason }); - - if (role && !['user', 'admin'].includes(role)) { - return res.status(400).json({ error: 'Invalid role' }); - } - - const existingUsername = db.prepare('SELECT id FROM users WHERE username = ?').get(username.trim()); - if (existingUsername) return res.status(409).json({ error: 'Username already taken' }); - - 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(), 12); - - const result = db.prepare( - 'INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)' - ).run(username.trim(), email.trim(), passwordHash, role || 'user'); - - const user = db.prepare( - 'SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?' - ).get(result.lastInsertRowid); - + const result = svc.createUser(req.body); + if ('error' in result) return res.status(result.status!).json({ error: result.error }); const authReq = req as AuthRequest; writeAudit({ userId: authReq.user.id, action: 'admin.user_create', - resource: String(result.lastInsertRowid), + resource: String(result.insertedId), ip: getClientIp(req), - details: { username: username.trim(), email: email.trim(), role: role || 'user' }, + details: result.auditDetails, }); - res.status(201).json({ user }); + res.status(201).json({ user: result.user }); }); 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) as User | undefined; - - if (!user) return res.status(404).json({ error: 'User not found' }); - - if (role && !['user', 'admin'].includes(role)) { - return res.status(400).json({ error: 'Invalid role' }); - } - - if (username && username !== user.username) { - const conflict = db.prepare('SELECT id FROM users WHERE username = ? AND id != ?').get(username, req.params.id); - if (conflict) return res.status(409).json({ error: 'Username already taken' }); - } - if (email && email !== user.email) { - const conflict = db.prepare('SELECT id FROM users WHERE email = ? AND id != ?').get(email, req.params.id); - if (conflict) return res.status(409).json({ error: 'Email already taken' }); - } - - if (password) { - const pwCheck = validatePassword(password); - if (!pwCheck.ok) return res.status(400).json({ error: pwCheck.reason }); - } - const passwordHash = password ? bcrypt.hashSync(password, 12) : null; - - db.prepare(` - UPDATE users SET - username = COALESCE(?, username), - email = COALESCE(?, email), - role = COALESCE(?, role), - password_hash = COALESCE(?, password_hash), - updated_at = CURRENT_TIMESTAMP - WHERE id = ? - `).run(username || null, email || null, role || null, passwordHash, req.params.id); - - const updated = db.prepare( - 'SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?' - ).get(req.params.id); - + const result = svc.updateUser(req.params.id, req.body); + if ('error' in result) return res.status(result.status!).json({ error: result.error }); const authReq = req as AuthRequest; - const changed: string[] = []; - if (username) changed.push('username'); - if (email) changed.push('email'); - if (role) changed.push('role'); - if (password) changed.push('password'); writeAudit({ userId: authReq.user.id, action: 'admin.user_update', resource: String(req.params.id), ip: getClientIp(req), - details: { targetUser: user.email, fields: changed }, + details: { targetUser: result.previousEmail, fields: result.changed }, }); - logInfo(`Admin ${authReq.user.email} edited user ${user.email} (fields: ${changed.join(', ')})`); - res.json({ user: updated }); + logInfo(`Admin ${authReq.user.email} edited user ${result.previousEmail} (fields: ${result.changed.join(', ')})`); + res.json({ user: result.user }); }); router.delete('/users/:id', (req: Request, res: Response) => { const authReq = req as AuthRequest; - if (parseInt(req.params.id as string) === authReq.user.id) { - return res.status(400).json({ error: 'Cannot delete own account' }); - } - - const userToDel = db.prepare('SELECT id, email FROM users WHERE id = ?').get(req.params.id) as { id: number; email: string } | undefined; - if (!userToDel) return res.status(404).json({ error: 'User not found' }); - - db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id); + const result = svc.deleteUser(req.params.id, authReq.user.id); + if ('error' in result) return res.status(result.status!).json({ error: result.error }); writeAudit({ userId: authReq.user.id, action: 'admin.user_delete', resource: String(req.params.id), ip: getClientIp(req), - details: { targetUser: userToDel.email }, + details: { targetUser: result.email }, }); - logInfo(`Admin ${authReq.user.email} deleted user ${userToDel.email}`); + logInfo(`Admin ${authReq.user.email} deleted user ${result.email}`); res.json({ success: true }); }); -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; +// ── Stats ────────────────────────────────────────────────────────────────── - res.json({ totalUsers, totalTrips, totalPlaces, totalFiles }); +router.get('/stats', (_req: Request, res: Response) => { + res.json(svc.getStats()); }); -// Permissions management +// ── Permissions ──────────────────────────────────────────────────────────── + router.get('/permissions', (_req: Request, res: Response) => { - const current = getAllPermissions(); - const actions = PERMISSION_ACTIONS.map(a => ({ - key: a.key, - level: current[a.key], - defaultLevel: a.defaultLevel, - allowedLevels: a.allowedLevels, - })); - res.json({ permissions: actions }); + res.json(svc.getPermissions()); }); router.put('/permissions', (req: Request, res: Response) => { - const authReq = req as AuthRequest; const { permissions } = req.body; if (!permissions || typeof permissions !== 'object') { return res.status(400).json({ error: 'permissions object required' }); } - const { skipped } = savePermissions(permissions); + const authReq = req as AuthRequest; + const result = svc.savePermissions(permissions); writeAudit({ userId: authReq.user.id, action: 'admin.permissions_update', @@ -194,198 +84,76 @@ router.put('/permissions', (req: Request, res: Response) => { ip: getClientIp(req), details: permissions, }); - res.json({ success: true, permissions: getAllPermissions(), ...(skipped.length ? { skipped } : {}) }); + res.json({ success: true, permissions: result.permissions, ...(result.skipped.length ? { skipped: result.skipped } : {}) }); }); +// ── Audit Log ────────────────────────────────────────────────────────────── + router.get('/audit-log', (req: Request, res: Response) => { - const limitRaw = parseInt(String(req.query.limit || '100'), 10); - const offsetRaw = parseInt(String(req.query.offset || '0'), 10); - const limit = Math.min(Math.max(Number.isFinite(limitRaw) ? limitRaw : 100, 1), 500); - const offset = Math.max(Number.isFinite(offsetRaw) ? offsetRaw : 0, 0); - type Row = { - id: number; - created_at: string; - user_id: number | null; - username: string | null; - user_email: string | null; - action: string; - resource: string | null; - details: string | null; - ip: string | null; - }; - const rows = db.prepare(` - SELECT a.id, a.created_at, a.user_id, u.username, u.email as user_email, a.action, a.resource, a.details, a.ip - FROM audit_log a - LEFT JOIN users u ON u.id = a.user_id - ORDER BY a.id DESC - LIMIT ? OFFSET ? - `).all(limit, offset) as Row[]; - const total = (db.prepare('SELECT COUNT(*) as c FROM audit_log').get() as { c: number }).c; - res.json({ - entries: rows.map((r) => { - let details: Record | null = null; - if (r.details) { - try { - details = JSON.parse(r.details) as Record; - } catch { - details = { _parse_error: true }; - } - } - const created_at = r.created_at && !r.created_at.endsWith('Z') ? r.created_at.replace(' ', 'T') + 'Z' : r.created_at; - return { ...r, created_at, details }; - }), - total, - limit, - offset, - }); + res.json(svc.getAuditLog(req.query as { limit?: string; offset?: string })); }); +// ── OIDC Settings ────────────────────────────────────────────────────────── + 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 = decrypt_api_key(get('oidc_client_secret')); - res.json({ - issuer: get('oidc_issuer'), - client_id: get('oidc_client_id'), - client_secret_set: !!secret, - display_name: get('oidc_display_name'), - oidc_only: get('oidc_only') === 'true', - discovery_url: get('oidc_discovery_url'), - }); + res.json(svc.getOidcSettings()); }); router.put('/oidc', (req: Request, res: Response) => { - const { issuer, client_id, client_secret, display_name, oidc_only, discovery_url } = req.body; - 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); - if (client_secret !== undefined) set('oidc_client_secret', maybe_encrypt_api_key(client_secret) ?? ''); - set('oidc_display_name', display_name); - set('oidc_only', oidc_only ? 'true' : 'false'); - set('oidc_discovery_url', discovery_url); + svc.updateOidcSettings(req.body); const authReq = req as AuthRequest; writeAudit({ userId: authReq.user.id, action: 'admin.oidc_update', ip: getClientIp(req), - details: { oidc_only: !!oidc_only, issuer_set: !!issuer }, + details: { oidc_only: !!req.body.oidc_only, issuer_set: !!req.body.issuer }, }); res.json({ success: true }); }); +// ── Demo Baseline ────────────────────────────────────────────────────────── + router.post('/save-demo-baseline', (req: Request, res: Response) => { - if (process.env.DEMO_MODE !== 'true') { - return res.status(404).json({ error: 'Not found' }); - } - try { - const { saveBaseline } = require('../demo/demo-reset'); - saveBaseline(); - const authReq = req as AuthRequest; - writeAudit({ userId: authReq.user.id, action: 'admin.demo_baseline_save', ip: getClientIp(req) }); - res.json({ success: true, message: 'Demo baseline saved. Hourly resets will restore to this state.' }); - } catch (err: unknown) { - console.error(err); - res.status(500).json({ error: 'Failed to save baseline' }); - } + const result = svc.saveDemoBaseline(); + if (result.error) return res.status(result.status!).json({ error: result.error }); + const authReq = req as AuthRequest; + writeAudit({ userId: authReq.user.id, action: 'admin.demo_baseline_save', ip: getClientIp(req) }); + res.json({ success: true, message: result.message }); }); -const isDocker = (() => { - try { - return fs.existsSync('/.dockerenv') || (fs.existsSync('/proc/1/cgroup') && fs.readFileSync('/proc/1/cgroup', 'utf8').includes('docker')); - } catch { return false } -})(); - -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++) { - const na = pa[i] || 0, nb = pb[i] || 0; - if (na > nb) return 1; - if (na < nb) return -1; - } - return 0; -} +// ── GitHub / Version ─────────────────────────────────────────────────────── router.get('/github-releases', async (req: Request, res: Response) => { const { per_page = '10', page = '1' } = req.query; - try { - const resp = await fetch( - `https://api.github.com/repos/mauriceboe/TREK/releases?per_page=${per_page}&page=${page}`, - { headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } } - ); - if (!resp.ok) return res.json([]); - const data = await resp.json(); - res.json(Array.isArray(data) ? data : []); - } catch { - res.json([]); - } + res.json(await svc.getGithubReleases(String(per_page), String(page))); }); 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/TREK/releases/latest', - { headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-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 }); - } + res.json(await svc.checkVersion()); }); -// ── Invite Tokens ─────────────────────────────────────────────────────────── +// ── Invite Tokens ────────────────────────────────────────────────────────── router.get('/invites', (_req: Request, res: Response) => { - const invites = db.prepare(` - SELECT i.*, u.username as created_by_name - FROM invite_tokens i - JOIN users u ON i.created_by = u.id - ORDER BY i.created_at DESC - `).all(); - res.json({ invites }); + res.json({ invites: svc.listInvites() }); }); router.post('/invites', (req: Request, res: Response) => { const authReq = req as AuthRequest; - const { max_uses, expires_in_days } = req.body; - - const rawUses = parseInt(max_uses); - const uses = rawUses === 0 ? 0 : Math.min(Math.max(rawUses || 1, 1), 5); - const token = crypto.randomBytes(16).toString('hex'); - const expiresAt = expires_in_days - ? new Date(Date.now() + parseInt(expires_in_days) * 86400000).toISOString() - : null; - - const ins = db.prepare( - 'INSERT INTO invite_tokens (token, max_uses, expires_at, created_by) VALUES (?, ?, ?, ?)' - ).run(token, uses, expiresAt, authReq.user.id); - - const inviteId = Number(ins.lastInsertRowid); - const invite = db.prepare(` - SELECT i.*, u.username as created_by_name - FROM invite_tokens i - JOIN users u ON i.created_by = u.id - WHERE i.id = ? - `).get(inviteId); - + const result = svc.createInvite(authReq.user.id, req.body); writeAudit({ userId: authReq.user.id, action: 'admin.invite_create', - resource: String(inviteId), + resource: String(result.inviteId), ip: getClientIp(req), - details: { max_uses: uses, expires_in_days: expires_in_days ?? null }, + details: { max_uses: result.uses, expires_in_days: result.expiresInDays }, }); - res.status(201).json({ invite }); + res.status(201).json({ invite: result.invite }); }); router.delete('/invites/:id', (req: Request, res: Response) => { - const invite = db.prepare('SELECT id FROM invite_tokens WHERE id = ?').get(req.params.id); - if (!invite) return res.status(404).json({ error: 'Invite not found' }); - db.prepare('DELETE FROM invite_tokens WHERE id = ?').run(req.params.id); + const result = svc.deleteInvite(req.params.id); + if ('error' in result) return res.status(result.status!).json({ error: result.error }); const authReq = req as AuthRequest; writeAudit({ userId: authReq.user.id, @@ -396,190 +164,141 @@ router.delete('/invites/:id', (req: Request, res: Response) => { res.json({ success: true }); }); -// ── Bag Tracking Setting ──────────────────────────────────────────────────── +// ── Bag Tracking ─────────────────────────────────────────────────────────── router.get('/bag-tracking', (_req: Request, res: Response) => { - const row = db.prepare("SELECT value FROM app_settings WHERE key = 'bag_tracking_enabled'").get() as { value: string } | undefined; - res.json({ enabled: row?.value === 'true' }); + res.json(svc.getBagTracking()); }); router.put('/bag-tracking', (req: Request, res: Response) => { - const { enabled } = req.body; - db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('bag_tracking_enabled', ?)").run(enabled ? 'true' : 'false'); + const result = svc.updateBagTracking(req.body.enabled); const authReq = req as AuthRequest; writeAudit({ userId: authReq.user.id, action: 'admin.bag_tracking', ip: getClientIp(req), - details: { enabled: !!enabled }, + details: { enabled: result.enabled }, }); - res.json({ enabled: !!enabled }); + res.json(result); }); -// ── Packing Templates ─────────────────────────────────────────────────────── +// ── Packing Templates ────────────────────────────────────────────────────── router.get('/packing-templates', (_req: Request, res: Response) => { - const templates = db.prepare(` - SELECT pt.*, u.username as created_by_name, - (SELECT COUNT(*) FROM packing_template_items ti JOIN packing_template_categories tc ON ti.category_id = tc.id WHERE tc.template_id = pt.id) as item_count, - (SELECT COUNT(*) FROM packing_template_categories WHERE template_id = pt.id) as category_count - FROM packing_templates pt - JOIN users u ON pt.created_by = u.id - ORDER BY pt.created_at DESC - `).all(); - res.json({ templates }); + res.json({ templates: svc.listPackingTemplates() }); }); -router.get('/packing-templates/:id', (_req: Request, res: Response) => { - const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(_req.params.id); - if (!template) return res.status(404).json({ error: 'Template not found' }); - const categories = db.prepare('SELECT * FROM packing_template_categories WHERE template_id = ? ORDER BY sort_order, id').all(_req.params.id) as any[]; - const items = db.prepare(` - SELECT ti.* FROM packing_template_items ti - JOIN packing_template_categories tc ON ti.category_id = tc.id - WHERE tc.template_id = ? ORDER BY ti.sort_order, ti.id - `).all(_req.params.id); - res.json({ template, categories, items }); +router.get('/packing-templates/:id', (req: Request, res: Response) => { + const result = svc.getPackingTemplate(req.params.id); + if ('error' in result) return res.status(result.status!).json({ error: result.error }); + res.json(result); }); router.post('/packing-templates', (req: Request, res: Response) => { const authReq = req as AuthRequest; - const { name } = req.body; - if (!name?.trim()) return res.status(400).json({ error: 'Name is required' }); - const result = db.prepare('INSERT INTO packing_templates (name, created_by) VALUES (?, ?)').run(name.trim(), authReq.user.id); - const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(result.lastInsertRowid); - res.status(201).json({ template }); + const result = svc.createPackingTemplate(req.body.name, authReq.user.id); + if ('error' in result) return res.status(result.status!).json({ error: result.error }); + res.status(201).json(result); }); router.put('/packing-templates/:id', (req: Request, res: Response) => { - const { name } = req.body; - const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(req.params.id); - if (!template) return res.status(404).json({ error: 'Template not found' }); - if (name?.trim()) db.prepare('UPDATE packing_templates SET name = ? WHERE id = ?').run(name.trim(), req.params.id); - res.json({ template: db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(req.params.id) }); + const result = svc.updatePackingTemplate(req.params.id, req.body); + if ('error' in result) return res.status(result.status!).json({ error: result.error }); + res.json(result); }); router.delete('/packing-templates/:id', (req: Request, res: Response) => { - const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(req.params.id); - if (!template) return res.status(404).json({ error: 'Template not found' }); - db.prepare('DELETE FROM packing_templates WHERE id = ?').run(req.params.id); + const result = svc.deletePackingTemplate(req.params.id); + if ('error' in result) return res.status(result.status!).json({ error: result.error }); const authReq = req as AuthRequest; - const t = template as { name?: string }; writeAudit({ userId: authReq.user.id, action: 'admin.packing_template_delete', resource: String(req.params.id), ip: getClientIp(req), - details: { name: t.name }, + details: { name: result.name }, }); res.json({ success: true }); }); // Template categories + router.post('/packing-templates/:id/categories', (req: Request, res: Response) => { - const { name } = req.body; - if (!name?.trim()) return res.status(400).json({ error: 'Category name is required' }); - const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(req.params.id); - if (!template) return res.status(404).json({ error: 'Template not found' }); - const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_template_categories WHERE template_id = ?').get(req.params.id) as { max: number | null }; - const result = db.prepare('INSERT INTO packing_template_categories (template_id, name, sort_order) VALUES (?, ?, ?)').run(req.params.id, name.trim(), (maxOrder.max ?? -1) + 1); - res.status(201).json({ category: db.prepare('SELECT * FROM packing_template_categories WHERE id = ?').get(result.lastInsertRowid) }); + const result = svc.createTemplateCategory(req.params.id, req.body.name); + if ('error' in result) return res.status(result.status!).json({ error: result.error }); + res.status(201).json(result); }); router.put('/packing-templates/:templateId/categories/:catId', (req: Request, res: Response) => { - const { name } = req.body; - const cat = db.prepare('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?').get(req.params.catId, req.params.templateId); - if (!cat) return res.status(404).json({ error: 'Category not found' }); - if (name?.trim()) db.prepare('UPDATE packing_template_categories SET name = ? WHERE id = ?').run(name.trim(), req.params.catId); - res.json({ category: db.prepare('SELECT * FROM packing_template_categories WHERE id = ?').get(req.params.catId) }); + const result = svc.updateTemplateCategory(req.params.templateId, req.params.catId, req.body); + if ('error' in result) return res.status(result.status!).json({ error: result.error }); + res.json(result); }); -router.delete('/packing-templates/:templateId/categories/:catId', (_req: Request, res: Response) => { - const cat = db.prepare('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?').get(_req.params.catId, _req.params.templateId); - if (!cat) return res.status(404).json({ error: 'Category not found' }); - db.prepare('DELETE FROM packing_template_categories WHERE id = ?').run(_req.params.catId); +router.delete('/packing-templates/:templateId/categories/:catId', (req: Request, res: Response) => { + const result = svc.deleteTemplateCategory(req.params.templateId, req.params.catId); + if ('error' in result) return res.status(result.status!).json({ error: result.error }); res.json({ success: true }); }); // Template items + router.post('/packing-templates/:templateId/categories/:catId/items', (req: Request, res: Response) => { - const { name } = req.body; - if (!name?.trim()) return res.status(400).json({ error: 'Item name is required' }); - const cat = db.prepare('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?').get(req.params.catId, req.params.templateId); - if (!cat) return res.status(404).json({ error: 'Category not found' }); - const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_template_items WHERE category_id = ?').get(req.params.catId) as { max: number | null }; - const result = db.prepare('INSERT INTO packing_template_items (category_id, name, sort_order) VALUES (?, ?, ?)').run(req.params.catId, name.trim(), (maxOrder.max ?? -1) + 1); - res.status(201).json({ item: db.prepare('SELECT * FROM packing_template_items WHERE id = ?').get(result.lastInsertRowid) }); + const result = svc.createTemplateItem(req.params.templateId, req.params.catId, req.body.name); + if ('error' in result) return res.status(result.status!).json({ error: result.error }); + res.status(201).json(result); }); router.put('/packing-templates/:templateId/items/:itemId', (req: Request, res: Response) => { - const { name } = req.body; - const item = db.prepare('SELECT * FROM packing_template_items WHERE id = ?').get(req.params.itemId); - if (!item) return res.status(404).json({ error: 'Item not found' }); - if (name?.trim()) db.prepare('UPDATE packing_template_items SET name = ? WHERE id = ?').run(name.trim(), req.params.itemId); - res.json({ item: db.prepare('SELECT * FROM packing_template_items WHERE id = ?').get(req.params.itemId) }); + const result = svc.updateTemplateItem(req.params.itemId, req.body); + if ('error' in result) return res.status(result.status!).json({ error: result.error }); + res.json(result); }); -router.delete('/packing-templates/:templateId/items/:itemId', (_req: Request, res: Response) => { - const item = db.prepare('SELECT * FROM packing_template_items WHERE id = ?').get(_req.params.itemId); - if (!item) return res.status(404).json({ error: 'Item not found' }); - db.prepare('DELETE FROM packing_template_items WHERE id = ?').run(_req.params.itemId); +router.delete('/packing-templates/:templateId/items/:itemId', (req: Request, res: Response) => { + const result = svc.deleteTemplateItem(req.params.itemId); + if ('error' in result) return res.status(result.status!).json({ error: result.error }); res.json({ success: true }); }); +// ── Addons ───────────────────────────────────────────────────────────────── + 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 || '{}') })) }); + res.json({ addons: svc.listAddons() }); }); 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) as Addon; + const result = svc.updateAddon(req.params.id, req.body); + if ('error' in result) return res.status(result.status!).json({ error: result.error }); const authReq = req as AuthRequest; writeAudit({ userId: authReq.user.id, action: 'admin.addon_update', resource: String(req.params.id), ip: getClientIp(req), - details: { enabled: enabled !== undefined ? !!enabled : undefined, config_changed: config !== undefined }, + details: result.auditDetails, }); - res.json({ addon: { ...updated, enabled: !!updated.enabled, config: JSON.parse(updated.config || '{}') } }); + res.json({ addon: result.addon }); }); -router.get('/mcp-tokens', (req: Request, res: Response) => { - const tokens = db.prepare(` - SELECT t.id, t.name, t.token_prefix, t.created_at, t.last_used_at, t.user_id, u.username - FROM mcp_tokens t - JOIN users u ON u.id = t.user_id - ORDER BY t.created_at DESC - `).all(); - res.json({ tokens }); +// ── MCP Tokens ───────────────────────────────────────────────────────────── + +router.get('/mcp-tokens', (_req: Request, res: Response) => { + res.json({ tokens: svc.listMcpTokens() }); }); router.delete('/mcp-tokens/:id', (req: Request, res: Response) => { - const token = db.prepare('SELECT id, user_id FROM mcp_tokens WHERE id = ?').get(req.params.id) as { id: number; user_id: number } | undefined; - if (!token) return res.status(404).json({ error: 'Token not found' }); - db.prepare('DELETE FROM mcp_tokens WHERE id = ?').run(req.params.id); - revokeUserSessions(token.user_id); + const result = svc.deleteMcpToken(req.params.id); + if ('error' in result) return res.status(result.status!).json({ error: result.error }); res.json({ success: true }); }); +// ── JWT Rotation ─────────────────────────────────────────────────────────── + router.post('/rotate-jwt-secret', (req: Request, res: Response) => { + const result = svc.rotateJwtSecret(); + if (result.error) return res.status(result.status!).json({ error: result.error }); const authReq = req as AuthRequest; - const newSecret = crypto.randomBytes(32).toString('hex'); - const dataDir = path.resolve(__dirname, '../../data'); - const secretFile = path.join(dataDir, '.jwt_secret'); - try { - if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); - fs.writeFileSync(secretFile, newSecret, { mode: 0o600 }); - } catch (err: unknown) { - return res.status(500).json({ error: 'Failed to persist new JWT secret to disk' }); - } - updateJwtSecret(newSecret); writeAudit({ user_id: authReq.user?.id ?? null, username: authReq.user?.username ?? 'unknown', diff --git a/server/src/routes/assignments.ts b/server/src/routes/assignments.ts index b2cf887..6559fa6 100644 --- a/server/src/routes/assignments.ts +++ b/server/src/routes/assignments.ts @@ -1,112 +1,33 @@ 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 { checkPermission } from '../services/permissions'; -import { AuthRequest, AssignmentRow, DayAssignment, Tag, Participant } from '../types'; +import { + getAssignmentWithPlace, + listDayAssignments, + dayExists, + placeExists, + createAssignment, + assignmentExistsInDay, + deleteAssignment, + reorderAssignments, + getAssignmentForTrip, + moveAssignment, + getParticipants, + updateTime, + setParticipants, +} from '../services/assignmentService'; +import { AuthRequest } from '../types'; const router = express.Router({ mergeParams: true }); -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, - COALESCE(da.assignment_time, p.place_time) as place_time, - COALESCE(da.assignment_end_time, p.end_time) as end_time, - p.duration_minutes, p.notes as place_notes, - p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone, - c.name as category_name, c.color as category_color, c.icon as category_icon - FROM day_assignments da - JOIN places p ON da.place_id = p.id - LEFT JOIN categories c ON p.category_id = c.id - WHERE da.id = ? - `).get(assignmentId) as AssignmentRow | undefined; - - if (!a) 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(a.place_id); - - const participants = db.prepare(` - SELECT ap.user_id, u.username, u.avatar - FROM assignment_participants ap - JOIN users u ON ap.user_id = u.id - WHERE ap.assignment_id = ? - `).all(a.id); - - return { - id: a.id, - day_id: a.day_id, - order_index: a.order_index, - notes: a.notes, - 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, - } - }; -} - router.get('/trips/:tripId/days/:dayId/assignments', authenticate, requireTripAccess, (req: Request, res: Response) => { const { tripId, dayId } = req.params; - 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 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, - COALESCE(da.assignment_time, p.place_time) as place_time, - COALESCE(da.assignment_end_time, p.end_time) as end_time, - p.duration_minutes, p.notes as place_notes, - p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone, - c.name as category_name, c.color as category_color, c.icon as category_icon - FROM day_assignments da - JOIN places p ON da.place_id = p.id - 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) as AssignmentRow[]; - - const placeIds = [...new Set(assignments.map(a => a.place_id))]; - const tagsByPlaceId = loadTagsByPlaceIds(placeIds, { compact: true }); - - const assignmentIds = assignments.map(a => a.id); - const participantsByAssignment = loadParticipantsByAssignmentIds(assignmentIds); - - const result = assignments.map(a => { - return formatAssignmentWithPlace(a, tagsByPlaceId[a.place_id] || [], participantsByAssignment[a.id] || []); - }); + if (!dayExists(dayId, tripId)) return res.status(404).json({ error: 'Day not found' }); + const result = listDayAssignments(dayId); res.json({ assignments: result }); }); @@ -118,20 +39,10 @@ router.post('/trips/:tripId/days/:dayId/assignments', authenticate, requireTripA const { tripId, dayId } = req.params; const { place_id, notes } = req.body; - 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' }); + if (!dayExists(dayId, tripId)) return res.status(404).json({ error: 'Day not found' }); + if (!placeExists(place_id, tripId)) return res.status(404).json({ error: 'Place 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) as { max: number | null }; - const orderIndex = (maxOrder.max !== null ? maxOrder.max : -1) + 1; - - const result = db.prepare( - 'INSERT INTO day_assignments (day_id, place_id, order_index, notes) VALUES (?, ?, ?, ?)' - ).run(dayId, place_id, orderIndex, notes || null); - - const assignment = getAssignmentWithPlace(result.lastInsertRowid); + const assignment = createAssignment(dayId, place_id, notes); res.status(201).json({ assignment }); broadcast(tripId, 'assignment:created', { assignment }, req.headers['x-socket-id'] as string); }); @@ -143,13 +54,9 @@ router.delete('/trips/:tripId/days/:dayId/assignments/:id', authenticate, requir const { tripId, dayId, id } = req.params; - 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); + if (!assignmentExistsInDay(id, dayId, tripId)) return res.status(404).json({ error: 'Assignment not found' }); - if (!assignment) return res.status(404).json({ error: 'Assignment not found' }); - - db.prepare('DELETE FROM day_assignments WHERE id = ?').run(id); + deleteAssignment(id); res.json({ success: true }); broadcast(tripId, 'assignment:deleted', { assignmentId: Number(id), dayId: Number(dayId) }, req.headers['x-socket-id'] as string); }); @@ -162,20 +69,9 @@ router.put('/trips/:tripId/days/:dayId/assignments/reorder', authenticate, requi const { tripId, dayId } = req.params; const { orderedIds } = req.body; - 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' }); + if (!dayExists(dayId, tripId)) 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: number, index: number) => { - update.run(index, id, dayId); - }); - db.exec('COMMIT'); - } catch (e) { - db.exec('ROLLBACK'); - throw e; - } + reorderAssignments(dayId, orderedIds); res.json({ success: true }); broadcast(tripId, 'assignment:reordered', { dayId: Number(dayId), orderedIds }, req.headers['x-socket-id'] as string); }); @@ -188,35 +84,21 @@ router.put('/trips/:tripId/assignments/:id/move', authenticate, requireTripAcces const { tripId, id } = req.params; const { new_day_id, order_index } = req.body; - 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) as DayAssignment | undefined; + const existing = getAssignmentForTrip(id, tripId); + if (!existing) return res.status(404).json({ error: 'Assignment not found' }); - if (!assignment) return res.status(404).json({ error: 'Assignment not found' }); + if (!dayExists(new_day_id, tripId)) return res.status(404).json({ error: 'Target day not found' }); - const newDay = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(new_day_id, tripId); - if (!newDay) return res.status(404).json({ error: 'Target day not found' }); - - 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(Number(id)); + const oldDayId = existing.day_id; + const { assignment: updated } = moveAssignment(id, new_day_id, order_index, oldDayId); res.json({ assignment: updated }); broadcast(tripId, 'assignment:moved', { assignment: updated, oldDayId: Number(oldDayId), newDayId: Number(new_day_id) }, req.headers['x-socket-id'] as string); }); router.get('/trips/:tripId/assignments/:id/participants', authenticate, requireTripAccess, (req: Request, res: Response) => { - const { tripId, id } = req.params; - - const participants = db.prepare(` - SELECT ap.user_id, u.username, u.avatar - FROM assignment_participants ap - JOIN users u ON ap.user_id = u.id - WHERE ap.assignment_id = ? - `).all(id); + const { id } = req.params; + const participants = getParticipants(id); res.json({ participants }); }); @@ -227,18 +109,11 @@ router.put('/trips/:tripId/assignments/:id/time', authenticate, requireTripAcces const { tripId, id } = req.params; - 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); - if (!assignment) return res.status(404).json({ error: 'Assignment not found' }); + const existing = getAssignmentForTrip(id, tripId); + if (!existing) return res.status(404).json({ error: 'Assignment not found' }); const { place_time, end_time } = req.body; - db.prepare('UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ? WHERE id = ?') - .run(place_time ?? null, end_time ?? null, id); - - const updated = getAssignmentWithPlace(Number(id)); + const updated = updateTime(id, place_time, end_time); res.json({ assignment: updated }); broadcast(Number(tripId), 'assignment:updated', { assignment: updated }, req.headers['x-socket-id'] as string); }); @@ -249,23 +124,10 @@ router.put('/trips/:tripId/assignments/:id/participants', authenticate, requireT return res.status(403).json({ error: 'No permission' }); const { tripId, id } = req.params; - const { user_ids } = req.body; if (!Array.isArray(user_ids)) return res.status(400).json({ error: 'user_ids must be an array' }); - 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 (?, ?)'); - for (const userId of user_ids) insert.run(id, userId); - } - - const participants = db.prepare(` - SELECT ap.user_id, u.username, u.avatar - FROM assignment_participants ap - JOIN users u ON ap.user_id = u.id - WHERE ap.assignment_id = ? - `).all(id); - + const participants = setParticipants(id, user_ids); res.json({ participants }); broadcast(Number(tripId), 'assignment:participants', { assignmentId: Number(id), participants }, req.headers['x-socket-id'] as string); }); diff --git a/server/src/routes/atlas.ts b/server/src/routes/atlas.ts index 76c9ff4..9929c3d 100644 --- a/server/src/routes/atlas.ts +++ b/server/src/routes/atlas.ts @@ -1,348 +1,71 @@ import express, { Request, Response } from 'express'; -import fetch from 'node-fetch'; -import { db } from '../db/database'; import { authenticate } from '../middleware/auth'; -import { AuthRequest, Trip, Place } from '../types'; +import { AuthRequest } from '../types'; +import { + getStats, + getCountryPlaces, + markCountryVisited, + unmarkCountryVisited, + listBucketList, + createBucketItem, + updateBucketItem, + deleteBucketItem, +} from '../services/atlasService'; const router = express.Router(); router.use(authenticate); -// Geocode cache: rounded coords -> country code -const geocodeCache = new Map(); - -function roundKey(lat: number, lng: number): string { - return `${lat.toFixed(3)},${lng.toFixed(3)}`; -} - -async function reverseGeocodeCountry(lat: number, lng: number): Promise { - const key = roundKey(lat, lng); - if (geocodeCache.has(key)) return geocodeCache.get(key)!; - try { - const res = await fetch(`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&zoom=3&accept-language=en`, { - headers: { 'User-Agent': 'TREK Travel Planner' }, - }); - if (!res.ok) return null; - const data = await res.json() as { address?: { country_code?: string } }; - const code = data.address?.country_code?.toUpperCase() || null; - geocodeCache.set(key, code); - return code; - } catch { - return null; - } -} - -const COUNTRY_BOXES: Record = { - 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], - BD:[88.0,20.7,92.7,26.6],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], - CN:[73.6,18.2,134.8,53.6],CO:[-79.1,-4.3,-66.9,12.5],HR:[13.5,42.4,19.5,46.6],CZ:[12.1,48.6,18.9,51.1],DK:[8,54.6,15.2,57.8], - EG:[24.7,22,37,31.7],EE:[21.8,57.5,28.2,59.7],FI:[20.6,59.8,31.6,70.1],FR:[-5.1,41.3,9.6,51.1],DE:[5.9,47.3,15.1,55.1], - GR:[19.4,34.8,29.7,41.8],HU:[16,45.7,22.9,48.6],IS:[-24.5,63.4,-13.5,66.6],IN:[68.2,6.7,97.4,35.5],ID:[95.3,-11,141,5.9], - IR:[44.1,25.1,63.3,39.8],IQ:[38.8,29.1,48.6,37.4],IE:[-10.5,51.4,-6,55.4],IL:[34.3,29.5,35.9,33.3],IT:[6.6,36.6,18.5,47.1], - JP:[129.4,31.1,145.5,45.5],KE:[33.9,-4.7,41.9,5.5],KR:[126,33.2,129.6,38.6],LV:[21,55.7,28.2,58.1],LT:[21,53.9,26.8,56.5], - LU:[5.7,49.4,6.5,50.2],MY:[99.6,0.9,119.3,7.4],MX:[-118.4,14.5,-86.7,32.7],MA:[-13.2,27.7,-1,35.9],NL:[3.4,50.8,7.2,53.5], - NZ:[166.4,-47.3,178.5,-34.4],NO:[4.6,58,31.1,71.2],PK:[60.9,23.7,77.1,37.1],PE:[-81.3,-18.4,-68.7,-0.1],PH:[117,5,126.6,18.5], - PL:[14.1,49,24.1,54.9],PT:[-9.5,36.8,-6.2,42.2],RO:[20.2,43.6,29.7,48.3],RU:[19.6,41.2,180,81.9],SA:[34.6,16.4,55.7,32.2], - RS:[18.8,42.2,23,46.2],SK:[16.8,47.7,22.6,49.6],SI:[13.4,45.4,16.6,46.9],ZA:[16.5,-34.8,32.9,-22.1],ES:[-9.4,36,-0.2,43.8], - SE:[11.1,55.3,24.2,69.1],CH:[6,45.8,10.5,47.8],TH:[97.3,5.6,105.6,20.5],TR:[26,36,44.8,42.1],UA:[22.1,44.4,40.2,52.4], - 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: number, lng: number): string | null { - let bestCode: string | null = null; - let bestArea = Infinity; - for (const [code, [minLng, minLat, maxLng, maxLat]] of Object.entries(COUNTRY_BOXES)) { - if (lat >= minLat && lat <= maxLat && lng >= minLng && lng <= maxLng) { - const area = (maxLng - minLng) * (maxLat - minLat); - if (area < bestArea) { - bestArea = area; - bestCode = code; - } - } - } - return bestCode; -} - -const NAME_TO_CODE: Record = { - '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; - const parts = address.split(',').map(s => s.trim()).filter(Boolean); - if (parts.length === 0) return null; - const last = parts[parts.length - 1]; - const normalized = last.toLowerCase(); - if (NAME_TO_CODE[normalized]) return NAME_TO_CODE[normalized]; - if (NAME_TO_CODE[last]) return NAME_TO_CODE[last]; - if (last.length === 2 && last === last.toUpperCase()) return last; - return null; -} - -const CONTINENT_MAP: Record = { - 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', async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const userId = authReq.user.id; - - 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) as Trip[]; - - const tripIds = trips.map(t => t.id); - if (tripIds.length === 0) { - // Still include manually marked countries even without trips - const manualCountries = db.prepare('SELECT country_code FROM visited_countries WHERE user_id = ?').all(userId) as { country_code: string }[]; - const countries = manualCountries.map(mc => ({ code: mc.country_code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null })); - return res.json({ countries, trips: [], stats: { totalTrips: 0, totalPlaces: 0, totalCountries: countries.length, totalDays: 0 } }); - } - - const placeholders = tripIds.map(() => '?').join(','); - const places = db.prepare(`SELECT * FROM places WHERE trip_id IN (${placeholders})`).all(...tripIds) as Place[]; - - interface CountryEntry { code: string; places: { id: number; name: string; lat: number | null; lng: number | null }[]; tripIds: Set } - const countrySet = new Map(); - for (const place of places) { - let code = getCountryFromAddress(place.address); - if (!code && place.lat && place.lng) { - code = await reverseGeocodeCountry(place.lat, place.lng); - } - if (!code && place.lat && place.lng) { - code = getCountryFromCoords(place.lat, place.lng); - } - if (code) { - 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); - } - } - - 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.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) + 1; - if (diff > 0) totalDays += diff; - } - } - - const countries = [...countrySet.values()].map(c => { - const countryTrips = trips.filter(t => c.tripIds.has(t.id)); - const dates = countryTrips.map(t => t.start_date).filter(Boolean).sort(); - return { - code: c.code, - placeCount: c.places.length, - tripCount: c.tripIds.size, - firstVisit: dates[0] || null, - lastVisit: dates[dates.length - 1] || null, - }; - }); - - const citySet = new Set(); - for (const place of places) { - if (place.address) { - 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\-\u2212\u3012]+/g, '').trim().toLowerCase(); - if (city) citySet.add(city); - } - } - } - const totalCities = citySet.size; - - // Merge manually marked countries - const manualCountries = db.prepare('SELECT country_code FROM visited_countries WHERE user_id = ?').all(userId) as { country_code: string }[]; - for (const mc of manualCountries) { - if (!countries.find(c => c.code === mc.country_code)) { - countries.push({ code: mc.country_code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }); - } - } - - const mostVisited = countries.length > 0 ? countries.reduce((a, b) => a.placeCount > b.placeCount ? a : b) : null; - - const continents: Record = {}; - countries.forEach(c => { - const cont = CONTINENT_MAP[c.code] || 'Other'; - continents[cont] = (continents[cont] || 0) + 1; - }); - - 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: { 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) { - let code = getCountryFromAddress(p.address); - if (!code && p.lat && p.lng) code = getCountryFromCoords(p.lat, p.lng); - if (code) { lastTrip.countryCode = code; break; } - } - } - - const futureTrips = trips.filter(t => t.start_date && t.start_date > now).sort((a, b) => a.start_date.localeCompare(b.start_date)); - 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).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24)); - nextTrip.daysUntil = Math.max(0, diff); - } - - 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(); - for (let y = currentYear; y >= 2000; y--) { - if (tripYears.has(y)) streak++; - else break; - } - const firstYear = tripYears.size > 0 ? Math.min(...tripYears) : null; - - res.json({ - countries, - stats: { - totalTrips: trips.length, - totalPlaces: places.length, - totalCountries: countries.length, - totalDays, - totalCities, - }, - mostVisited, - continents, - lastTrip, - nextTrip, - streak, - firstYear, - tripsThisYear: trips.filter(t => t.start_date && t.start_date.startsWith(String(currentYear))).length, - }); + const userId = (req as AuthRequest).user.id; + const data = await getStats(userId); + res.json(data); }); router.get('/country/:code', (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const userId = authReq.user.id; + const userId = (req as AuthRequest).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) 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) as Place[]; - - const matchingPlaces: { id: number; name: string; address: string | null; lat: number | null; lng: number | null; trip_id: number }[] = []; - const matchingTripIds = new Set(); - - for (const place of places) { - let pCode = getCountryFromAddress(place.address); - if (!pCode && place.lat && place.lng) pCode = getCountryFromCoords(place.lat, place.lng); - if (pCode === code) { - matchingPlaces.push({ id: place.id, name: place.name, address: place.address, lat: place.lat, lng: place.lng, trip_id: place.trip_id }); - matchingTripIds.add(place.trip_id); - } - } - - const matchingTrips = trips.filter(t => matchingTripIds.has(t.id)).map(t => ({ id: t.id, title: t.title, start_date: t.start_date, end_date: t.end_date })); - - const isManuallyMarked = !!(db.prepare('SELECT 1 FROM visited_countries WHERE user_id = ? AND country_code = ?').get(userId, code)); - res.json({ places: matchingPlaces, trips: matchingTrips, manually_marked: isManuallyMarked }); + res.json(getCountryPlaces(userId, code)); }); -// Mark/unmark country as visited router.post('/country/:code/mark', (req: Request, res: Response) => { - const authReq = req as AuthRequest; - db.prepare('INSERT OR IGNORE INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(authReq.user.id, req.params.code.toUpperCase()); + const userId = (req as AuthRequest).user.id; + markCountryVisited(userId, req.params.code.toUpperCase()); res.json({ success: true }); }); router.delete('/country/:code/mark', (req: Request, res: Response) => { - const authReq = req as AuthRequest; - db.prepare('DELETE FROM visited_countries WHERE user_id = ? AND country_code = ?').run(authReq.user.id, req.params.code.toUpperCase()); + const userId = (req as AuthRequest).user.id; + unmarkCountryVisited(userId, req.params.code.toUpperCase()); res.json({ success: true }); }); // ── Bucket List ───────────────────────────────────────────────────────────── router.get('/bucket-list', (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const items = db.prepare('SELECT * FROM bucket_list WHERE user_id = ? ORDER BY created_at DESC').all(authReq.user.id); - res.json({ items }); + const userId = (req as AuthRequest).user.id; + res.json({ items: listBucketList(userId) }); }); router.post('/bucket-list', (req: Request, res: Response) => { - const authReq = req as AuthRequest; + const userId = (req as AuthRequest).user.id; const { name, lat, lng, country_code, notes, target_date } = req.body; if (!name?.trim()) return res.status(400).json({ error: 'Name is required' }); - const result = db.prepare('INSERT INTO bucket_list (user_id, name, lat, lng, country_code, notes, target_date) VALUES (?, ?, ?, ?, ?, ?, ?)').run( - authReq.user.id, name.trim(), lat ?? null, lng ?? null, country_code ?? null, notes ?? null, target_date ?? null - ); - const item = db.prepare('SELECT * FROM bucket_list WHERE id = ?').get(result.lastInsertRowid); + const item = createBucketItem(userId, { name, lat, lng, country_code, notes, target_date }); res.status(201).json({ item }); }); router.put('/bucket-list/:id', (req: Request, res: Response) => { - const authReq = req as AuthRequest; + const userId = (req as AuthRequest).user.id; const { name, notes, lat, lng, country_code, target_date } = req.body; - const item = db.prepare('SELECT * FROM bucket_list WHERE id = ? AND user_id = ?').get(req.params.id, authReq.user.id); + const item = updateBucketItem(userId, req.params.id, { name, notes, lat, lng, country_code, target_date }); if (!item) return res.status(404).json({ error: 'Item not found' }); - db.prepare(`UPDATE bucket_list SET - name = COALESCE(?, name), - notes = CASE WHEN ? THEN ? ELSE notes END, - lat = CASE WHEN ? THEN ? ELSE lat END, - lng = CASE WHEN ? THEN ? ELSE lng END, - country_code = CASE WHEN ? THEN ? ELSE country_code END, - target_date = CASE WHEN ? THEN ? ELSE target_date END - WHERE id = ?`).run( - name?.trim() || null, - notes !== undefined ? 1 : 0, notes !== undefined ? (notes || null) : null, - lat !== undefined ? 1 : 0, lat !== undefined ? (lat || null) : null, - lng !== undefined ? 1 : 0, lng !== undefined ? (lng || null) : null, - country_code !== undefined ? 1 : 0, country_code !== undefined ? (country_code || null) : null, - target_date !== undefined ? 1 : 0, target_date !== undefined ? (target_date || null) : null, - req.params.id - ); - res.json({ item: db.prepare('SELECT * FROM bucket_list WHERE id = ?').get(req.params.id) }); + res.json({ item }); }); router.delete('/bucket-list/:id', (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const item = db.prepare('SELECT * FROM bucket_list WHERE id = ? AND user_id = ?').get(req.params.id, authReq.user.id); - if (!item) return res.status(404).json({ error: 'Item not found' }); - db.prepare('DELETE FROM bucket_list WHERE id = ?').run(req.params.id); + const userId = (req as AuthRequest).user.id; + const deleted = deleteBucketItem(userId, req.params.id); + if (!deleted) return res.status(404).json({ error: 'Item not found' }); res.json({ success: true }); }); diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index eb96998..eb7463d 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -1,118 +1,76 @@ 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 crypto from 'crypto'; import { v4 as uuid } from 'uuid'; -import fetch from 'node-fetch'; -import { authenticator } from 'otplib'; -import QRCode from 'qrcode'; -import { db } from '../db/database'; -import { validatePassword } from '../services/passwordPolicy'; import { authenticate, optionalAuth, demoUploadBlock } from '../middleware/auth'; -import { JWT_SECRET } from '../config'; -import { encryptMfaSecret, decryptMfaSecret } from '../services/mfaCrypto'; -import { getAllPermissions } from '../services/permissions'; -import { randomBytes, createHash } from 'crypto'; -import { revokeUserSessions } from '../mcp'; -import { AuthRequest, OptionalAuthRequest, User } from '../types'; +import { AuthRequest, OptionalAuthRequest } from '../types'; import { writeAudit, getClientIp } from '../services/auditLog'; -import { decrypt_api_key, maybe_encrypt_api_key, encrypt_api_key } from '../services/apiKeyCrypto'; -import { startTripReminders } from '../scheduler'; -import { createEphemeralToken } from '../services/ephemeralTokens'; import { setAuthCookie, clearAuthCookie } from '../services/cookie'; - -authenticator.options = { window: 1 }; - -const MFA_SETUP_TTL_MS = 15 * 60 * 1000; -const mfaSetupPending = new Map(); -const MFA_BACKUP_CODE_COUNT = 10; - -function normalizeBackupCode(input: string): string { - return String(input || '').toUpperCase().replace(/[^A-Z0-9]/g, ''); -} - -function hashBackupCode(input: string): string { - return crypto.createHash('sha256').update(normalizeBackupCode(input)).digest('hex'); -} - -function generateBackupCodes(count = MFA_BACKUP_CODE_COUNT): string[] { - const codes: string[] = []; - while (codes.length < count) { - const raw = crypto.randomBytes(4).toString('hex').toUpperCase(); - const code = `${raw.slice(0, 4)}-${raw.slice(4)}`; - if (!codes.includes(code)) codes.push(code); - } - return codes; -} - -function parseBackupCodeHashes(raw: string | null | undefined): string[] { - if (!raw) return []; - try { - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed.filter(v => typeof v === 'string') : []; - } catch { - return []; - } -} - -function getPendingMfaSecret(userId: number): string | null { - const row = mfaSetupPending.get(userId); - if (!row || Date.now() > row.exp) { - mfaSetupPending.delete(userId); - return null; - } - return row.secret; -} - -function utcSuffix(ts: string | null | undefined): string | null { - if (!ts) return null; - return ts.endsWith('Z') ? ts : ts.replace(' ', 'T') + 'Z'; -} - -function stripUserForClient(user: User): Record { - const { - password_hash: _p, - maps_api_key: _m, - openweather_api_key: _o, - unsplash_api_key: _u, - mfa_secret: _mf, - mfa_backup_codes: _mbc, - ...rest - } = user; - return { - ...rest, - created_at: utcSuffix(rest.created_at), - updated_at: utcSuffix(rest.updated_at), - last_login: utcSuffix(rest.last_login), - mfa_enabled: !!(user.mfa_enabled === 1 || user.mfa_enabled === true), - must_change_password: !!(user.must_change_password === 1 || user.must_change_password === true), - }; -} +import { + getAppConfig, + demoLogin, + validateInviteToken, + registerUser, + loginUser, + getCurrentUser, + changePassword, + deleteAccount, + updateMapsKey, + updateApiKeys, + updateSettings, + getSettings, + saveAvatar, + deleteAvatar, + listUsers, + validateKeys, + getAppSettings, + updateAppSettings, + getTravelStats, + setupMfa, + enableMfa, + disableMfa, + verifyMfaLogin, + listMcpTokens, + createMcpToken, + deleteMcpToken, + createWsToken, + createResourceToken, +} from '../services/authService'; const router = express.Router(); +// --------------------------------------------------------------------------- +// Avatar upload (multer config stays in route — middleware concern) +// --------------------------------------------------------------------------- + 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)) + filename: (_req, file, cb) => cb(null, uuid() + path.extname(file.originalname)), }); const ALLOWED_AVATAR_EXTS = ['.jpg', '.jpeg', '.png', '.gif', '.webp']; -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')); - } - cb(null, true); -}}); +const MAX_AVATAR_SIZE = 5 * 1024 * 1024; +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')); + } + cb(null, true); + }, +}); -const RATE_LIMIT_WINDOW = 15 * 60 * 1000; // 15 minutes -const RATE_LIMIT_CLEANUP = 5 * 60 * 1000; // 5 minutes +// --------------------------------------------------------------------------- +// Rate limiter (middleware concern — stays in route) +// --------------------------------------------------------------------------- + +const RATE_LIMIT_WINDOW = 15 * 60 * 1000; +const RATE_LIMIT_CLEANUP = 5 * 60 * 1000; const loginAttempts = new Map(); const mfaAttempts = new Map(); @@ -145,708 +103,163 @@ function rateLimiter(maxAttempts: number, windowMs: number, store = loginAttempt const authLimiter = rateLimiter(10, RATE_LIMIT_WINDOW); const mfaLimiter = rateLimiter(5, RATE_LIMIT_WINDOW, mfaAttempts); -function isOidcOnlyMode(): boolean { - const get = (key: string) => (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || null; - const enabled = process.env.OIDC_ONLY === 'true' || get('oidc_only') === 'true'; - if (!enabled) return false; - const oidcConfigured = !!( - (process.env.OIDC_ISSUER || get('oidc_issuer')) && - (process.env.OIDC_CLIENT_ID || get('oidc_client_id')) - ); - return oidcConfigured; -} - -function maskKey(key: string | null | undefined): string | null { - if (!key) return null; - if (key.length <= 8) return '--------'; - return '----' + key.slice(-4); -} - -function mask_stored_api_key(key: string | null | undefined): string | null { - const plain = decrypt_api_key(key); - return maskKey(plain); -} - -function avatarUrl(user: { avatar?: string | null }): string | null { - return user.avatar ? `/uploads/avatars/${user.avatar}` : null; -} - -function generateToken(user: { id: number | bigint }) { - return jwt.sign( - { id: user.id }, - JWT_SECRET, - { expiresIn: '24h', algorithm: 'HS256' } - ); -} +// --------------------------------------------------------------------------- +// Routes +// --------------------------------------------------------------------------- router.get('/app-config', optionalAuth, (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 = process.env.OIDC_DISPLAY_NAME || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_display_name'").get() as { value: string } | undefined)?.value || null; - const oidcConfigured = !!( - (process.env.OIDC_ISSUER || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_issuer'").get() as { value: string } | undefined)?.value) && - (process.env.OIDC_CLIENT_ID || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_client_id'").get() as { value: string } | undefined)?.value) - ); - const oidcOnlySetting = process.env.OIDC_ONLY || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_only'").get() as { value: string } | undefined)?.value; - const oidcOnlyMode = oidcConfigured && oidcOnlySetting === 'true'; - const requireMfaRow = db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined; - const notifChannel = (db.prepare("SELECT value FROM app_settings WHERE key = 'notification_channel'").get() as { value: string } | undefined)?.value || 'none'; - const tripReminderSetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'notify_trip_reminder'").get() as { value: string } | undefined)?.value; - const hasSmtpHost = !!(process.env.SMTP_HOST || (db.prepare("SELECT value FROM app_settings WHERE key = 'smtp_host'").get() as { value: string } | undefined)?.value); - const hasWebhookUrl = !!(process.env.NOTIFICATION_WEBHOOK_URL || (db.prepare("SELECT value FROM app_settings WHERE key = 'notification_webhook_url'").get() as { value: string } | undefined)?.value); - const channelConfigured = (notifChannel === 'email' && hasSmtpHost) || (notifChannel === 'webhook' && hasWebhookUrl); - const tripRemindersEnabled = channelConfigured && tripReminderSetting !== 'false'; - const setupComplete = userCount > 0 && !(db.prepare("SELECT id FROM users WHERE role = 'admin' AND must_change_password = 1 LIMIT 1").get()); - res.json({ - allow_registration: isDemo ? false : allowRegistration, - has_users: userCount > 0, - setup_complete: setupComplete, - version, - has_maps_key: hasGoogleKey, - oidc_configured: oidcConfigured, - oidc_display_name: oidcConfigured ? (oidcDisplayName || 'SSO') : undefined, - oidc_only_mode: oidcOnlyMode, - require_mfa: requireMfaRow?.value === 'true', - 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@trek.app' : undefined, - demo_password: isDemo ? 'demo12345' : undefined, - timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC', - notification_channel: notifChannel, - trip_reminders_enabled: tripRemindersEnabled, - permissions: (req as OptionalAuthRequest).user ? getAllPermissions() : undefined, - }); + const user = (req as OptionalAuthRequest).user; + res.json(getAppConfig(user)); }); 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@trek.app') as User | undefined; - if (!user) return res.status(500).json({ error: 'Demo user not found' }); - const token = generateToken(user); - const safe = stripUserForClient(user) as Record; - setAuthCookie(res, token); - res.json({ token, user: { ...safe, avatar_url: avatarUrl(user) } }); + const result = demoLogin(); + if (result.error) return res.status(result.status!).json({ error: result.error }); + setAuthCookie(res, result.token!); + res.json({ token: result.token, user: result.user }); }); -// Validate invite token (public, no auth needed, rate limited) router.get('/invite/:token', authLimiter, (req: Request, res: Response) => { - const invite = db.prepare('SELECT * FROM invite_tokens WHERE token = ?').get(req.params.token) as any; - if (!invite) return res.status(404).json({ error: 'Invalid invite link' }); - if (invite.max_uses > 0 && invite.used_count >= invite.max_uses) return res.status(410).json({ error: 'Invite link has been fully used' }); - if (invite.expires_at && new Date(invite.expires_at) < new Date()) return res.status(410).json({ error: 'Invite link has expired' }); - res.json({ valid: true, max_uses: invite.max_uses, used_count: invite.used_count, expires_at: invite.expires_at }); + const result = validateInviteToken(req.params.token); + if (result.error) return res.status(result.status!).json({ error: result.error }); + res.json({ valid: result.valid, max_uses: result.max_uses, used_count: result.used_count, expires_at: result.expires_at }); }); router.post('/register', authLimiter, (req: Request, res: Response) => { - const { username, email, password, invite_token } = req.body; - - const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count; - - // Check invite token first — valid token bypasses registration restrictions - let validInvite: any = null; - if (invite_token) { - validInvite = db.prepare('SELECT * FROM invite_tokens WHERE token = ?').get(invite_token); - if (!validInvite) return res.status(400).json({ error: 'Invalid invite link' }); - if (validInvite.max_uses > 0 && validInvite.used_count >= validInvite.max_uses) return res.status(410).json({ error: 'Invite link has been fully used' }); - if (validInvite.expires_at && new Date(validInvite.expires_at) < new Date()) return res.status(410).json({ error: 'Invite link has expired' }); - } - - if (userCount > 0 && !validInvite) { - if (isOidcOnlyMode()) { - return res.status(403).json({ error: 'Password authentication is disabled. Please sign in with SSO.' }); - } - 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.' }); - } - } - - if (!username || !email || !password) { - return res.status(400).json({ error: 'Username, email and password are required' }); - } - - const pwCheck = validatePassword(password); - if (!pwCheck.ok) return res.status(400).json({ error: pwCheck.reason }); - - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(email)) { - return res.status(400).json({ error: 'Invalid email format' }); - } - - 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: 'Registration failed. Please try different credentials.' }); - } - - const password_hash = bcrypt.hashSync(password, 12); - - const isFirstUser = userCount === 0; - const role = isFirstUser ? 'admin' : 'user'; - - try { - const result = db.prepare( - 'INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)' - ).run(username, email, password_hash, role); - - const user = { id: result.lastInsertRowid, username, email, role, avatar: null, mfa_enabled: false }; - const token = generateToken(user); - - // Atomically increment invite token usage (prevents race condition) - if (validInvite) { - const updated = db.prepare( - 'UPDATE invite_tokens SET used_count = used_count + 1 WHERE id = ? AND (max_uses = 0 OR used_count < max_uses) RETURNING used_count' - ).get(validInvite.id); - if (!updated) { - // Race condition: token was used up between check and now — user was already created, so just log it - console.warn(`[Auth] Invite token ${validInvite.token.slice(0, 8)}... exceeded max_uses due to race condition`); - } - } - - writeAudit({ userId: Number(result.lastInsertRowid), action: 'user.register', ip: getClientIp(req), details: { username, email, role } }); - setAuthCookie(res, token); - res.status(201).json({ token, user: { ...user, avatar_url: null } }); - } catch (err: unknown) { - res.status(500).json({ error: 'Error creating user' }); - } + const result = registerUser(req.body); + if (result.error) return res.status(result.status!).json({ error: result.error }); + writeAudit({ userId: result.auditUserId!, action: 'user.register', ip: getClientIp(req), details: result.auditDetails }); + setAuthCookie(res, result.token!); + res.status(201).json({ token: result.token, user: result.user }); }); router.post('/login', authLimiter, (req: Request, res: Response) => { - if (isOidcOnlyMode()) { - return res.status(403).json({ error: 'Password authentication is disabled. Please sign in with SSO.' }); + const result = loginUser(req.body); + if (result.auditAction) { + writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req), details: result.auditDetails }); } - - 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) as User | undefined; - if (!user) { - writeAudit({ userId: null, action: 'user.login_failed', ip: getClientIp(req), details: { email, reason: 'unknown_email' } }); - return res.status(401).json({ error: 'Invalid email or password' }); - } - - const validPassword = bcrypt.compareSync(password, user.password_hash!); - if (!validPassword) { - writeAudit({ userId: Number(user.id), action: 'user.login_failed', ip: getClientIp(req), details: { email, reason: 'wrong_password' } }); - return res.status(401).json({ error: 'Invalid email or password' }); - } - - if (user.mfa_enabled === 1 || user.mfa_enabled === true) { - const mfa_token = jwt.sign( - { id: Number(user.id), purpose: 'mfa_login' }, - JWT_SECRET, - { expiresIn: '5m', algorithm: 'HS256' } - ); - return res.json({ mfa_required: true, mfa_token }); - } - - db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(user.id); - const token = generateToken(user); - const userSafe = stripUserForClient(user) as Record; - - writeAudit({ userId: Number(user.id), action: 'user.login', ip: getClientIp(req), details: { email } }); - setAuthCookie(res, token); - res.json({ token, user: { ...userSafe, avatar_url: avatarUrl(user) } }); + if (result.error) return res.status(result.status!).json({ error: result.error }); + if (result.mfa_required) return res.json({ mfa_required: true, mfa_token: result.mfa_token }); + setAuthCookie(res, result.token!); + res.json({ token: result.token, user: result.user }); }); 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, mfa_enabled, must_change_password FROM users WHERE id = ?' - ).get(authReq.user.id) as User | undefined; - - if (!user) { - return res.status(404).json({ error: 'User not found' }); - } - - const base = stripUserForClient(user as User) as Record; - res.json({ user: { ...base, avatar_url: avatarUrl(user) } }); + const user = getCurrentUser(authReq.user.id); + if (!user) return res.status(404).json({ error: 'User not found' }); + res.json({ user }); }); -router.post('/logout', (req: Request, res: Response) => { +router.post('/logout', (_req: Request, res: Response) => { clearAuthCookie(res); res.json({ success: true }); }); router.put('/me/password', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => { const authReq = req as AuthRequest; - if (isOidcOnlyMode()) { - return res.status(403).json({ error: 'Password authentication is disabled.' }); - } - if (process.env.DEMO_MODE === 'true' && authReq.user.email === 'demo@trek.app') { - return res.status(403).json({ error: 'Password change is disabled in demo mode.' }); - } - 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' }); - const pwCheck = validatePassword(new_password); - if (!pwCheck.ok) return res.status(400).json({ error: pwCheck.reason }); - - 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 = ?, must_change_password = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(hash, authReq.user.id); + const result = changePassword(authReq.user.id, authReq.user.email, req.body); + if (result.error) return res.status(result.status!).json({ error: result.error }); writeAudit({ userId: authReq.user.id, action: 'user.password_change', ip: getClientIp(req) }); res.json({ success: true }); }); router.delete('/me', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; - if (process.env.DEMO_MODE === 'true' && authReq.user.email === 'demo@trek.app') { - return res.status(403).json({ error: 'Account deletion is disabled in demo mode.' }); - } - 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' }); - } - } + const result = deleteAccount(authReq.user.id, authReq.user.email, authReq.user.role); + if (result.error) return res.status(result.status!).json({ error: result.error }); writeAudit({ userId: authReq.user.id, action: 'user.account_delete', ip: getClientIp(req) }); - db.prepare('DELETE FROM users WHERE id = ?').run(authReq.user.id); res.json({ success: true }); }); 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(maybe_encrypt_api_key(maps_api_key), authReq.user.id); - - res.json({ success: true, maps_api_key: mask_stored_api_key(maps_api_key) }); + res.json(updateMapsKey(authReq.user.id, req.body.maps_api_key)); }); 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 | undefined; - - db.prepare( - 'UPDATE users SET maps_api_key = ?, openweather_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?' - ).run( - maps_api_key !== undefined ? maybe_encrypt_api_key(maps_api_key) : current.maps_api_key, - openweather_api_key !== undefined ? maybe_encrypt_api_key(openweather_api_key) : current.openweather_api_key, - authReq.user.id - ); - - const updated = db.prepare( - 'SELECT id, username, email, role, maps_api_key, openweather_api_key, avatar, mfa_enabled FROM users WHERE id = ?' - ).get(authReq.user.id) as Pick | undefined; - - const u = updated ? { ...updated, mfa_enabled: !!(updated.mfa_enabled === 1 || updated.mfa_enabled === true) } : undefined; - res.json({ success: true, user: { ...u, maps_api_key: mask_stored_api_key(u?.maps_api_key), openweather_api_key: mask_stored_api_key(u?.openweather_api_key), avatar_url: avatarUrl(updated || {}) } }); + res.json(updateApiKeys(authReq.user.id, req.body)); }); 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; - - 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(maybe_encrypt_api_key(maps_api_key)); } - if (openweather_api_key !== undefined) { updates.push('openweather_api_key = ?'); params.push(maybe_encrypt_api_key(openweather_api_key)); } - 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(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, mfa_enabled FROM users WHERE id = ?' - ).get(authReq.user.id) as Pick | undefined; - - const u = updated ? { ...updated, mfa_enabled: !!(updated.mfa_enabled === 1 || updated.mfa_enabled === true) } : undefined; - res.json({ success: true, user: { ...u, maps_api_key: mask_stored_api_key(u?.maps_api_key), openweather_api_key: mask_stored_api_key(u?.openweather_api_key), avatar_url: avatarUrl(updated || {}) } }); + const result = updateSettings(authReq.user.id, req.body); + if (result.error) return res.status(result.status!).json({ error: result.error }); + res.json({ success: result.success, user: result.user }); }); 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(authReq.user.id) as Pick | undefined; - if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' }); - - res.json({ - settings: { - maps_api_key: decrypt_api_key(user.maps_api_key), - openweather_api_key: decrypt_api_key(user.openweather_api_key), - } - }); + const result = getSettings(authReq.user.id); + if (result.error) return res.status(result.status!).json({ error: result.error }); + res.json({ settings: result.settings }); }); 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(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, authReq.user.id); - - const updated = db.prepare('SELECT id, username, email, role, avatar FROM users WHERE id = ?').get(authReq.user.id) as Pick | undefined; - res.json({ success: true, avatar_url: avatarUrl(updated || {}) }); + res.json(saveAvatar(authReq.user.id, req.file.filename)); }); 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(authReq.user.id); - res.json({ success: true }); + res.json(deleteAvatar(authReq.user.id)); }); 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(authReq.user.id) as Pick[]; - res.json({ users: users.map(u => ({ ...u, avatar_url: avatarUrl(u) })) }); + res.json({ users: listUsers(authReq.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 | undefined; - if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' }); - - const result: { - maps: boolean; - weather: boolean; - maps_details: null | { - ok: boolean; - status: number | null; - status_text: string | null; - error_message: string | null; - error_status: string | null; - error_raw: string | null; - }; - } = { maps: false, weather: false, maps_details: null }; - - const maps_api_key = decrypt_api_key(user.maps_api_key); - if (maps_api_key) { - try { - const mapsRes = await fetch( - `https://places.googleapis.com/v1/places:searchText`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Goog-Api-Key': maps_api_key, - 'X-Goog-FieldMask': 'places.displayName', - }, - body: JSON.stringify({ textQuery: 'test' }), - } - ); - result.maps = mapsRes.status === 200; - let error_text: string | null = null; - let error_json: any = null; - if (!result.maps) { - try { - error_text = await mapsRes.text(); - try { - error_json = JSON.parse(error_text); - } catch { - error_json = null; - } - } catch { - error_text = null; - error_json = null; - } - } - result.maps_details = { - ok: result.maps, - status: mapsRes.status, - status_text: mapsRes.statusText || null, - error_message: error_json?.error?.message || null, - error_status: error_json?.error?.status || null, - error_raw: error_text, - }; - } catch (err: unknown) { - result.maps = false; - result.maps_details = { - ok: false, - status: null, - status_text: null, - error_message: err instanceof Error ? err.message : 'Request failed', - error_status: 'FETCH_ERROR', - error_raw: null, - }; - } - } - - const openweather_api_key = decrypt_api_key(user.openweather_api_key); - if (openweather_api_key) { - try { - const weatherRes = await fetch( - `https://api.openweathermap.org/data/2.5/weather?q=London&appid=${openweather_api_key}` - ); - result.weather = weatherRes.status === 200; - } catch (err: unknown) { - result.weather = false; - } - } - - res.json(result); + const result = await validateKeys(authReq.user.id); + if (result.error) return res.status(result.status!).json({ error: result.error }); + res.json({ maps: result.maps, weather: result.weather, maps_details: result.maps_details }); }); -const ADMIN_SETTINGS_KEYS = ['allow_registration', 'allowed_file_types', 'require_mfa', 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify', 'notification_webhook_url', 'notification_channel', 'notify_trip_invite', 'notify_booking_change', 'notify_trip_reminder', 'notify_vacay_invite', 'notify_photos_shared', 'notify_collab_message', 'notify_packing_tagged']; - router.get('/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 result: Record = {}; - for (const key of ADMIN_SETTINGS_KEYS) { - const row = db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined; - if (row) result[key] = key === 'smtp_pass' ? '••••••••' : row.value; - } - res.json(result); + const result = getAppSettings(authReq.user.id); + if (result.error) return res.status(result.status!).json({ error: result.error }); + res.json(result.data); }); 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 { require_mfa } = req.body as Record; - - if (require_mfa === true || require_mfa === 'true') { - const adminMfa = db.prepare('SELECT mfa_enabled FROM users WHERE id = ?').get(authReq.user.id) as { mfa_enabled: number } | undefined; - if (!(adminMfa?.mfa_enabled === 1)) { - return res.status(400).json({ - error: 'Enable two-factor authentication on your own account before requiring it for all users.', - }); - } - } - - for (const key of ADMIN_SETTINGS_KEYS) { - if (req.body[key] !== undefined) { - let val = String(req.body[key]); - if (key === 'require_mfa') { - val = req.body[key] === true || val === 'true' ? 'true' : 'false'; - } - // Don't save masked password - if (key === 'smtp_pass' && val === '••••••••') continue; - if (key === 'smtp_pass') val = encrypt_api_key(val); - db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val); - } - } - const changedKeys = ADMIN_SETTINGS_KEYS.filter(k => req.body[k] !== undefined && !(k === 'smtp_pass' && String(req.body[k]) === '••••••••')); - - const summary: Record = {}; - const smtpChanged = changedKeys.some(k => k.startsWith('smtp_')); - const eventsChanged = changedKeys.some(k => k.startsWith('notify_')); - if (changedKeys.includes('notification_channel')) summary.notification_channel = req.body.notification_channel; - if (changedKeys.includes('notification_webhook_url')) summary.webhook_url_updated = true; - if (smtpChanged) summary.smtp_settings_updated = true; - if (eventsChanged) summary.notification_events_updated = true; - if (changedKeys.includes('allow_registration')) summary.allow_registration = req.body.allow_registration; - if (changedKeys.includes('allowed_file_types')) summary.allowed_file_types_updated = true; - if (changedKeys.includes('require_mfa')) summary.require_mfa = req.body.require_mfa; - - const debugDetails: Record = {}; - for (const k of changedKeys) { - debugDetails[k] = k === 'smtp_pass' ? '***' : req.body[k]; - } - + const result = updateAppSettings(authReq.user.id, req.body); + if (result.error) return res.status(result.status!).json({ error: result.error }); writeAudit({ userId: authReq.user.id, action: 'settings.app_update', ip: getClientIp(req), - details: summary, - debugDetails, + details: result.auditSummary, + debugDetails: result.auditDebugDetails, }); - - const notifRelated = ['notification_channel', 'notification_webhook_url', 'smtp_host', 'notify_trip_reminder']; - if (changedKeys.some(k => notifRelated.includes(k))) { - startTripReminders(); - } - res.json({ success: true }); }); router.get('/travel-stats', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; - const userId = authReq.user.id; - - 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) as { address: string | null; lat: number | null; lng: number | null }[]; - - const tripStats = db.prepare(` - SELECT COUNT(DISTINCT t.id) as trips, - COUNT(DISTINCT d.id) as days - FROM trips t - 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) as { trips: number; days: number } | undefined; - - 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', 'Sudkorea', 'Indonesia', 'Indonesien', 'Turkey', 'Turkei', 'Turkiye', - 'Greece', 'Griechenland', 'Portugal', 'Netherlands', 'Niederlande', 'Belgium', 'Belgien', - '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', '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', - 'Cuba', 'Kuba', 'Costa Rica', 'Panama', 'Ecuador', 'Bolivia', 'Bolivien', 'Uruguay', 'Paraguay', - 'Luxembourg', 'Luxemburg', 'Malta', 'Cyprus', 'Zypern', 'Estonia', 'Estland', - 'Latvia', 'Lettland', 'Lithuania', 'Litauen', 'Slovakia', 'Slowakei', 'Slovenia', 'Slowenien', - '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', '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', 'Weissrussland', - ]); - - const countries = new Set(); - const cities = new Set(); - const coords: { lat: number; lng: number }[] = []; - - places.forEach(p => { - if (p.lat && p.lng) coords.push({ lat: p.lat, lng: p.lng }); - if (p.address) { - const parts = p.address.split(',').map(s => s.trim().replace(/\d{3,}/g, '').trim()); - for (const part of parts) { - if (KNOWN_COUNTRIES.has(part)) { countries.add(part); break; } - } - const cityPart = parts.find(s => !KNOWN_COUNTRIES.has(s) && /^[A-Za-z\u00C0-\u00FF\s-]{2,}$/.test(s)); - if (cityPart) cities.add(cityPart); - } - }); - - res.json({ - countries: [...countries], - cities: [...cities], - coords, - totalTrips: tripStats?.trips || 0, - totalDays: tripStats?.days || 0, - totalPlaces: places.length, - }); + res.json(getTravelStats(authReq.user.id)); }); router.post('/mfa/verify-login', mfaLimiter, (req: Request, res: Response) => { - const { mfa_token, code } = req.body as { mfa_token?: string; code?: string }; - if (!mfa_token || !code) { - return res.status(400).json({ error: 'Verification token and code are required' }); - } - try { - const decoded = jwt.verify(mfa_token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number; purpose?: string }; - if (decoded.purpose !== 'mfa_login') { - return res.status(401).json({ error: 'Invalid verification token' }); - } - const user = db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.id) as User | undefined; - if (!user || !(user.mfa_enabled === 1 || user.mfa_enabled === true) || !user.mfa_secret) { - return res.status(401).json({ error: 'Invalid session' }); - } - const secret = decryptMfaSecret(user.mfa_secret); - const tokenStr = String(code).trim(); - const okTotp = authenticator.verify({ token: tokenStr.replace(/\s/g, ''), secret }); - if (!okTotp) { - const hashes = parseBackupCodeHashes(user.mfa_backup_codes); - const candidateHash = hashBackupCode(tokenStr); - const idx = hashes.findIndex(h => h === candidateHash); - if (idx === -1) { - return res.status(401).json({ error: 'Invalid verification code' }); - } - hashes.splice(idx, 1); - db.prepare('UPDATE users SET mfa_backup_codes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run( - JSON.stringify(hashes), - user.id - ); - } - db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(user.id); - const sessionToken = generateToken(user); - const userSafe = stripUserForClient(user) as Record; - writeAudit({ userId: Number(user.id), action: 'user.login', ip: getClientIp(req), details: { mfa: true } }); - setAuthCookie(res, sessionToken); - res.json({ token: sessionToken, user: { ...userSafe, avatar_url: avatarUrl(user) } }); - } catch { - return res.status(401).json({ error: 'Invalid or expired verification token' }); - } + const result = verifyMfaLogin(req.body); + if (result.error) return res.status(result.status!).json({ error: result.error }); + writeAudit({ userId: result.auditUserId!, action: 'user.login', ip: getClientIp(req), details: { mfa: true } }); + setAuthCookie(res, result.token!); + res.json({ token: result.token, user: result.user }); }); router.post('/mfa/setup', 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: 'MFA is not available in demo mode.' }); - } - const row = db.prepare('SELECT mfa_enabled FROM users WHERE id = ?').get(authReq.user.id) as { mfa_enabled: number } | undefined; - if (row?.mfa_enabled) { - return res.status(400).json({ error: 'MFA is already enabled' }); - } - let secret: string, otpauth_url: string; - try { - secret = authenticator.generateSecret(); - mfaSetupPending.set(authReq.user.id, { secret, exp: Date.now() + MFA_SETUP_TTL_MS }); - otpauth_url = authenticator.keyuri(authReq.user.email, 'TREK', secret); - } catch (err) { - console.error('[MFA] Setup error:', err); - return res.status(500).json({ error: 'MFA setup failed' }); - } - QRCode.toDataURL(otpauth_url) + const result = setupMfa(authReq.user.id, authReq.user.email); + if (result.error) return res.status(result.status!).json({ error: result.error }); + result.qrPromise! .then((qr_data_url: string) => { - res.json({ secret, otpauth_url, qr_data_url }); + res.json({ secret: result.secret, otpauth_url: result.otpauth_url, qr_data_url }); }) .catch((err: unknown) => { console.error('[MFA] QR code generation error:', err); @@ -856,128 +269,55 @@ router.post('/mfa/setup', authenticate, (req: Request, res: Response) => { router.post('/mfa/enable', authenticate, mfaLimiter, (req: Request, res: Response) => { const authReq = req as AuthRequest; - const { code } = req.body as { code?: string }; - if (!code) { - return res.status(400).json({ error: 'Verification code is required' }); - } - const pending = getPendingMfaSecret(authReq.user.id); - if (!pending) { - return res.status(400).json({ error: 'No MFA setup in progress. Start the setup again.' }); - } - const tokenStr = String(code).replace(/\s/g, ''); - const ok = authenticator.verify({ token: tokenStr, secret: pending }); - if (!ok) { - return res.status(401).json({ error: 'Invalid verification code' }); - } - const backupCodes = generateBackupCodes(); - const backupHashes = backupCodes.map(hashBackupCode); - const enc = encryptMfaSecret(pending); - db.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ?, mfa_backup_codes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run( - enc, - JSON.stringify(backupHashes), - authReq.user.id - ); - mfaSetupPending.delete(authReq.user.id); + const result = enableMfa(authReq.user.id, req.body.code); + if (result.error) return res.status(result.status!).json({ error: result.error }); writeAudit({ userId: authReq.user.id, action: 'user.mfa_enable', ip: getClientIp(req) }); - res.json({ success: true, mfa_enabled: true, backup_codes: backupCodes }); + res.json({ success: true, mfa_enabled: result.mfa_enabled, backup_codes: result.backup_codes }); }); router.post('/mfa/disable', 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: 'MFA cannot be changed in demo mode.' }); - } - const policy = db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined; - if (policy?.value === 'true') { - return res.status(403).json({ error: 'Two-factor authentication cannot be disabled while it is required for all users.' }); - } - const { password, code } = req.body as { password?: string; code?: string }; - if (!password || !code) { - return res.status(400).json({ error: 'Password and authenticator code are required' }); - } - const user = db.prepare('SELECT * FROM users WHERE id = ?').get(authReq.user.id) as User | undefined; - if (!user?.mfa_enabled || !user.mfa_secret) { - return res.status(400).json({ error: 'MFA is not enabled' }); - } - if (!user.password_hash || !bcrypt.compareSync(password, user.password_hash)) { - return res.status(401).json({ error: 'Incorrect password' }); - } - const secret = decryptMfaSecret(user.mfa_secret); - const tokenStr = String(code).replace(/\s/g, ''); - const ok = authenticator.verify({ token: tokenStr, secret }); - if (!ok) { - return res.status(401).json({ error: 'Invalid verification code' }); - } - db.prepare('UPDATE users SET mfa_enabled = 0, mfa_secret = NULL, mfa_backup_codes = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run( - authReq.user.id - ); - mfaSetupPending.delete(authReq.user.id); + const result = disableMfa(authReq.user.id, authReq.user.email, req.body); + if (result.error) return res.status(result.status!).json({ error: result.error }); writeAudit({ userId: authReq.user.id, action: 'user.mfa_disable', ip: getClientIp(req) }); - res.json({ success: true, mfa_enabled: false }); + res.json({ success: true, mfa_enabled: result.mfa_enabled }); }); // --- MCP Token Management --- router.get('/mcp-tokens', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; - const tokens = db.prepare( - 'SELECT id, name, token_prefix, created_at, last_used_at FROM mcp_tokens WHERE user_id = ? ORDER BY created_at DESC' - ).all(authReq.user.id); - res.json({ tokens }); + res.json({ tokens: listMcpTokens(authReq.user.id) }); }); router.post('/mcp-tokens', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => { const authReq = req as AuthRequest; - const { name } = req.body; - if (!name?.trim()) return res.status(400).json({ error: 'Token name is required' }); - if (name.trim().length > 100) return res.status(400).json({ error: 'Token name must be 100 characters or less' }); - - const tokenCount = (db.prepare('SELECT COUNT(*) as count FROM mcp_tokens WHERE user_id = ?').get(authReq.user.id) as { count: number }).count; - if (tokenCount >= 10) return res.status(400).json({ error: 'Maximum of 10 tokens per user reached' }); - - const rawToken = 'trek_' + randomBytes(24).toString('hex'); - const tokenHash = createHash('sha256').update(rawToken).digest('hex'); - const tokenPrefix = rawToken.slice(0, 13); // "trek_" + 8 hex chars - - const result = db.prepare( - 'INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)' - ).run(authReq.user.id, name.trim(), tokenHash, tokenPrefix); - - const token = db.prepare( - 'SELECT id, name, token_prefix, created_at, last_used_at FROM mcp_tokens WHERE id = ?' - ).get(result.lastInsertRowid); - - res.status(201).json({ token: { ...(token as object), raw_token: rawToken } }); + const result = createMcpToken(authReq.user.id, req.body.name); + if (result.error) return res.status(result.status!).json({ error: result.error }); + res.status(201).json({ token: result.token }); }); router.delete('/mcp-tokens/:id', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; - const { id } = req.params; - const token = db.prepare('SELECT id FROM mcp_tokens WHERE id = ? AND user_id = ?').get(id, authReq.user.id); - if (!token) return res.status(404).json({ error: 'Token not found' }); - db.prepare('DELETE FROM mcp_tokens WHERE id = ?').run(id); - revokeUserSessions(authReq.user.id); + const result = deleteMcpToken(authReq.user.id, req.params.id); + if (result.error) return res.status(result.status!).json({ error: result.error }); res.json({ success: true }); }); -// Short-lived single-use token for WebSocket connections (avoids JWT in WS URL) +// Short-lived single-use token for WebSocket connections router.post('/ws-token', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; - const token = createEphemeralToken(authReq.user.id, 'ws'); - if (!token) return res.status(503).json({ error: 'Service unavailable' }); - res.json({ token }); + const result = createWsToken(authReq.user.id); + if (result.error) return res.status(result.status!).json({ error: result.error }); + res.json({ token: result.token }); }); -// Short-lived single-use token for direct resource URLs (file downloads, Immich assets) +// Short-lived single-use token for direct resource URLs router.post('/resource-token', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; - const { purpose } = req.body as { purpose?: string }; - if (purpose !== 'download' && purpose !== 'immich') { - return res.status(400).json({ error: 'Invalid purpose' }); - } - const token = createEphemeralToken(authReq.user.id, purpose); - if (!token) return res.status(503).json({ error: 'Service unavailable' }); - res.json({ token }); + const result = createResourceToken(authReq.user.id, req.body.purpose); + if (result.error) return res.status(result.status!).json({ error: result.error }); + res.json({ token: result.token }); }); export default router; diff --git a/server/src/routes/backup.ts b/server/src/routes/backup.ts index 53a772c..2d9ce08 100644 --- a/server/src/routes/backup.ts +++ b/server/src/routes/backup.ts @@ -1,133 +1,68 @@ 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 Database from 'better-sqlite3'; import { authenticate, adminOnly } from '../middleware/auth'; -import * as scheduler from '../scheduler'; -import { db, closeDb, reinitialize } from '../db/database'; import { AuthRequest } from '../types'; import { writeAudit, getClientIp } from '../services/auditLog'; - -type RestoreAuditInfo = { userId: number; ip: string | null; source: 'backup.restore' | 'backup.upload_restore'; label: string }; +import { + listBackups, + createBackup, + restoreFromZip, + getAutoSettings, + updateAutoSettings, + deleteBackup, + isValidBackupFilename, + backupFilePath, + backupFileExists, + checkRateLimit, + getUploadTmpDir, + BACKUP_RATE_WINDOW, + MAX_BACKUP_UPLOAD_SIZE, +} from '../services/backupService'; const router = express.Router(); router.use(authenticate, adminOnly); -const BACKUP_RATE_WINDOW = 60 * 60 * 1000; // 1 hour -const MAX_BACKUP_UPLOAD_SIZE = 500 * 1024 * 1024; // 500 MB +// --------------------------------------------------------------------------- +// Rate-limiter middleware (HTTP concern wrapping service-level check) +// --------------------------------------------------------------------------- -const backupAttempts = new Map(); 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) { + if (!checkRateLimit(key, maxAttempts, 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'); - -function ensureBackupsDir() { - if (!fs.existsSync(backupsDir)) fs.mkdirSync(backupsDir, { recursive: true }); -} - -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`; -} +// --------------------------------------------------------------------------- +// Routes +// --------------------------------------------------------------------------- router.get('/list', (_req: Request, res: Response) => { - ensureBackupsDir(); - try { - const files = fs.readdirSync(backupsDir) - .filter(f => f.endsWith('.zip')) - .map(filename => { - const filePath = path.join(backupsDir, filename); - const stat = fs.statSync(filePath); - return { - filename, - size: stat.size, - sizeText: formatSize(stat.size), - created_at: stat.birthtime.toISOString(), - }; - }) - .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); - - res.json({ backups: files }); + res.json({ backups: listBackups() }); } catch (err: unknown) { res.status(500).json({ error: 'Error loading backups' }); } }); -router.post('/create', backupRateLimiter(3, BACKUP_RATE_WINDOW), async (_req: Request, res: Response) => { - ensureBackupsDir(); - - const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); - const filename = `backup-${timestamp}.zip`; - const outputPath = path.join(backupsDir, filename); - +router.post('/create', backupRateLimiter(3, BACKUP_RATE_WINDOW), async (req: Request, res: Response) => { try { - try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {} - - await new Promise((resolve, reject) => { - const output = fs.createWriteStream(outputPath); - const archive = archiver('zip', { zlib: { level: 9 } }); - - output.on('close', resolve); - archive.on('error', reject); - - archive.pipe(output); - - const dbPath = path.join(dataDir, 'travel.db'); - if (fs.existsSync(dbPath)) { - archive.file(dbPath, { name: 'travel.db' }); - } - - if (fs.existsSync(uploadsDir)) { - archive.directory(uploadsDir, 'uploads'); - } - - archive.finalize(); - }); - - const stat = fs.statSync(outputPath); - const authReq = _req as AuthRequest; + const backup = await createBackup(); + const authReq = req as AuthRequest; writeAudit({ userId: authReq.user.id, action: 'backup.create', - resource: filename, - ip: getClientIp(_req), - details: { size: stat.size }, - }); - res.json({ - success: true, - backup: { - filename, - size: stat.size, - sizeText: formatSize(stat.size), - created_at: stat.birthtime.toISOString(), - } + resource: backup.filename, + ip: getClientIp(req), + details: { size: backup.size }, }); + res.json({ success: true, backup }); } catch (err: unknown) { - console.error('Backup error:', err); - if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath); res.status(500).json({ error: 'Error creating backup' }); } }); @@ -135,122 +70,46 @@ router.post('/create', backupRateLimiter(3, BACKUP_RATE_WINDOW), async (_req: Re router.get('/download/:filename', (req: Request, res: Response) => { const { filename } = req.params; - if (!/^backup-[\w\-]+\.zip$/.test(filename)) { + if (!isValidBackupFilename(filename)) { return res.status(400).json({ error: 'Invalid filename' }); } - - const filePath = path.join(backupsDir, filename); - if (!fs.existsSync(filePath)) { + if (!backupFileExists(filename)) { return res.status(404).json({ error: 'Backup not found' }); } - res.download(filePath, filename); + res.download(backupFilePath(filename), filename); }); -async function restoreFromZip(zipPath: string, res: Response, audit?: RestoreAuditInfo) { - const extractDir = path.join(dataDir, `restore-${Date.now()}`); - try { - await fs.createReadStream(zipPath) - .pipe(unzipper.Extract({ path: extractDir })) - .promise(); - - const extractedDb = path.join(extractDir, 'travel.db'); - if (!fs.existsSync(extractedDb)) { - fs.rmSync(extractDir, { recursive: true, force: true }); - return res.status(400).json({ error: 'Invalid backup: travel.db not found' }); - } - - let uploadedDb: InstanceType | null = null; - try { - uploadedDb = new Database(extractedDb, { readonly: true }); - - const integrityResult = uploadedDb.prepare('PRAGMA integrity_check').get() as { integrity_check: string }; - if (integrityResult.integrity_check !== 'ok') { - fs.rmSync(extractDir, { recursive: true, force: true }); - return res.status(400).json({ error: `Uploaded database failed integrity check: ${integrityResult.integrity_check}` }); - } - - const requiredTables = ['users', 'trips', 'trip_members', 'places', 'days']; - const existingTables = uploadedDb - .prepare("SELECT name FROM sqlite_master WHERE type='table'") - .all() as { name: string }[]; - const tableNames = new Set(existingTables.map(t => t.name)); - for (const table of requiredTables) { - if (!tableNames.has(table)) { - fs.rmSync(extractDir, { recursive: true, force: true }); - return res.status(400).json({ error: `Uploaded database is missing required table: ${table}. This does not appear to be a TREK backup.` }); - } - } - } catch (err) { - fs.rmSync(extractDir, { recursive: true, force: true }); - return res.status(400).json({ error: 'Uploaded file is not a valid SQLite database' }); - } finally { - uploadedDb?.close(); - } - - closeDb(); - - try { - const dbDest = path.join(dataDir, 'travel.db'); - for (const ext of ['', '-wal', '-shm']) { - try { fs.unlinkSync(dbDest + ext); } catch (e) {} - } - fs.copyFileSync(extractedDb, dbDest); - - const extractedUploads = path.join(extractDir, 'uploads'); - if (fs.existsSync(extractedUploads)) { - for (const sub of fs.readdirSync(uploadsDir)) { - const subPath = path.join(uploadsDir, sub); - if (fs.statSync(subPath).isDirectory()) { - for (const file of fs.readdirSync(subPath)) { - try { fs.unlinkSync(path.join(subPath, file)); } catch (e) {} - } - } - } - fs.cpSync(extractedUploads, uploadsDir, { recursive: true, force: true }); - } - } finally { - reinitialize(); - } - - fs.rmSync(extractDir, { recursive: true, force: true }); - - if (audit) { - writeAudit({ - userId: audit.userId, - action: audit.source, - resource: audit.label, - ip: audit.ip, - }); - } - res.json({ success: true }); - } 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: 'Error restoring backup' }); - } -} - router.post('/restore/:filename', async (req: Request, res: Response) => { const { filename } = req.params; - if (!/^backup-[\w\-]+\.zip$/.test(filename)) { + if (!isValidBackupFilename(filename)) { return res.status(400).json({ error: 'Invalid filename' }); } - const zipPath = path.join(backupsDir, filename); - if (!fs.existsSync(zipPath)) { + const zipPath = backupFilePath(filename); + if (!backupFileExists(filename)) { return res.status(404).json({ error: 'Backup not found' }); } - const authReq = req as AuthRequest; - await restoreFromZip(zipPath, res, { - userId: authReq.user.id, - ip: getClientIp(req), - source: 'backup.restore', - label: filename, - }); + + try { + const result = await restoreFromZip(zipPath); + if (!result.success) { + return res.status(result.status || 400).json({ error: result.error }); + } + const authReq = req as AuthRequest; + writeAudit({ + userId: authReq.user.id, + action: 'backup.restore', + resource: filename, + ip: getClientIp(req), + }); + res.json({ success: true }); + } catch (err: unknown) { + if (!res.headersSent) res.status(500).json({ error: 'Error restoring backup' }); + } }); const uploadTmp = multer({ - dest: path.join(dataDir, 'tmp/'), + dest: getUploadTmpDir(), fileFilter: (_req, file, cb) => { if (file.originalname.endsWith('.zip')) cb(null, true); else cb(new Error('Only ZIP files allowed')); @@ -261,62 +120,41 @@ const uploadTmp = multer({ 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; - const authReq = req as AuthRequest; const origName = req.file.originalname || 'upload.zip'; - await restoreFromZip(zipPath, res, { - userId: authReq.user.id, - ip: getClientIp(req), - source: 'backup.upload_restore', - label: origName, - }); - if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath); + + try { + const result = await restoreFromZip(zipPath); + if (!result.success) { + return res.status(result.status || 400).json({ error: result.error }); + } + const authReq = req as AuthRequest; + writeAudit({ + userId: authReq.user.id, + action: 'backup.upload_restore', + resource: origName, + ip: getClientIp(req), + }); + res.json({ success: true }); + } catch (err: unknown) { + if (!res.headersSent) res.status(500).json({ error: 'Error restoring backup' }); + } finally { + if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath); + } }); router.get('/auto-settings', (_req: Request, res: Response) => { try { - const tz = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; - res.json({ settings: scheduler.loadSettings(), timezone: tz }); + const data = getAutoSettings(); + res.json(data); } catch (err: unknown) { console.error('[backup] GET auto-settings:', err); res.status(500).json({ error: 'Could not load backup settings' }); } }); -function parseIntField(raw: unknown, fallback: number): number { - if (typeof raw === 'number' && Number.isFinite(raw)) return Math.floor(raw); - if (typeof raw === 'string' && raw.trim() !== '') { - const n = parseInt(raw, 10); - if (Number.isFinite(n)) return n; - } - return fallback; -} - -function parseAutoBackupBody(body: Record): { - enabled: boolean; - interval: string; - keep_days: number; - hour: number; - day_of_week: number; - day_of_month: number; -} { - const enabled = body.enabled === true || body.enabled === 'true' || body.enabled === 1; - const rawInterval = body.interval; - const interval = - typeof rawInterval === 'string' && scheduler.VALID_INTERVALS.includes(rawInterval) - ? rawInterval - : 'daily'; - const keep_days = Math.max(0, parseIntField(body.keep_days, 7)); - const hour = Math.min(23, Math.max(0, parseIntField(body.hour, 2))); - const day_of_week = Math.min(6, Math.max(0, parseIntField(body.day_of_week, 0))); - const day_of_month = Math.min(28, Math.max(1, parseIntField(body.day_of_month, 1))); - return { enabled, interval, keep_days, hour, day_of_week, day_of_month }; -} - router.put('/auto-settings', (req: Request, res: Response) => { try { - const settings = parseAutoBackupBody((req.body || {}) as Record); - scheduler.saveSettings(settings); - scheduler.start(); + const settings = updateAutoSettings((req.body || {}) as Record); const authReq = req as AuthRequest; writeAudit({ userId: authReq.user.id, @@ -338,16 +176,14 @@ router.put('/auto-settings', (req: Request, res: Response) => { router.delete('/:filename', (req: Request, res: Response) => { const { filename } = req.params; - if (!/^backup-[\w\-]+\.zip$/.test(filename)) { + if (!isValidBackupFilename(filename)) { return res.status(400).json({ error: 'Invalid filename' }); } - - const filePath = path.join(backupsDir, filename); - if (!fs.existsSync(filePath)) { + if (!backupFileExists(filename)) { return res.status(404).json({ error: 'Backup not found' }); } - fs.unlinkSync(filePath); + deleteBackup(filename); const authReq = req as AuthRequest; writeAudit({ userId: authReq.user.id, diff --git a/server/src/routes/budget.ts b/server/src/routes/budget.ts index 201790e..57da643 100644 --- a/server/src/routes/budget.ts +++ b/server/src/routes/budget.ts @@ -1,113 +1,56 @@ import express, { Request, Response } from 'express'; -import { db, canAccessTrip } from '../db/database'; import { authenticate } from '../middleware/auth'; import { broadcast } from '../websocket'; import { checkPermission } from '../services/permissions'; -import { AuthRequest, BudgetItem, BudgetItemMember } from '../types'; +import { AuthRequest } from '../types'; +import { + verifyTripAccess, + listBudgetItems, + createBudgetItem, + updateBudgetItem, + deleteBudgetItem, + updateMembers, + toggleMemberPaid, + getPerPersonSummary, + calculateSettlement, +} from '../services/budgetService'; const router = express.Router({ mergeParams: true }); -function verifyTripOwnership(tripId: string | number, userId: number) { - return canAccessTrip(tripId, userId); -} - -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) as BudgetItemMember[]; -} - -function avatarUrl(user: { avatar?: string | null }): string | null { - return user.avatar ? `/uploads/avatars/${user.avatar}` : null; -} - router.get('/', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; - const trip = verifyTripOwnership(tripId, authReq.user.id); + const trip = verifyTripAccess(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) as BudgetItem[]; - - const itemIds = items.map(i => i.id); - const membersByItem: Record = {}; - 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) 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({ - user_id: m.user_id, paid: m.paid, username: m.username, avatar_url: avatarUrl(m) - }); - } - } - items.forEach(item => { item.members = membersByItem[item.id] || []; }); - - res.json({ items }); + res.json({ items: listBudgetItems(tripId) }); }); router.get('/summary/per-person', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; - 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, - SUM(bi.total_price * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id)) as total_assigned, - SUM(CASE WHEN bm.paid = 1 THEN bi.total_price * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id) ELSE 0 END) as total_paid, - COUNT(bi.id) as items_count - FROM budget_item_members bm - JOIN budget_items bi ON bm.budget_item_id = bi.id - JOIN users u ON bm.user_id = u.id - WHERE bi.trip_id = ? - GROUP BY bm.user_id - `).all(tripId) as { user_id: number; username: string; avatar: string | null; total_assigned: number; total_paid: number; items_count: number }[]; + if (!verifyTripAccess(Number(tripId), authReq.user.id)) + return res.status(404).json({ error: 'Trip not found' }); - res.json({ summary: summary.map(s => ({ ...s, avatar_url: avatarUrl(s) })) }); + res.json({ summary: getPerPersonSummary(tripId) }); }); router.post('/', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; - const { category, name, total_price, persons, days, note, expense_date } = req.body; - const trip = verifyTripOwnership(tripId, authReq.user.id); + const trip = verifyTripAccess(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); if (!checkPermission('budget_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); + const { name } = req.body; 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) as { max: number | null }; - const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1; - - const result = db.prepare( - 'INSERT INTO budget_items (trip_id, category, name, total_price, persons, days, note, sort_order, expense_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' - ).run( - tripId, - category || 'Other', - name, - total_price || 0, - persons != null ? persons : null, - days !== undefined && days !== null ? days : null, - note || null, - sortOrder, - expense_date || null - ); - - const item = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(result.lastInsertRowid) as BudgetItem & { members?: BudgetItemMember[] }; - item.members = []; + const item = createBudgetItem(tripId, req.body); res.status(201).json({ item }); broadcast(tripId, 'budget:created', { item }, req.headers['x-socket-id'] as string); }); @@ -115,42 +58,16 @@ router.post('/', authenticate, (req: Request, res: Response) => { 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, expense_date } = req.body; - const trip = verifyTripOwnership(tripId, authReq.user.id); + const trip = verifyTripAccess(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); if (!checkPermission('budget_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); - 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' }); + const updated = updateBudgetItem(id, tripId, req.body); + if (!updated) return res.status(404).json({ error: 'Budget item not found' }); - db.prepare(` - UPDATE budget_items SET - category = COALESCE(?, category), - name = COALESCE(?, name), - total_price = CASE WHEN ? IS NOT NULL THEN ? ELSE total_price END, - persons = CASE WHEN ? IS NOT NULL THEN ? ELSE persons END, - days = CASE WHEN ? THEN ? ELSE days END, - note = CASE WHEN ? THEN ? ELSE note END, - sort_order = CASE WHEN ? IS NOT NULL THEN ? ELSE sort_order END, - expense_date = CASE WHEN ? THEN ? ELSE expense_date END - WHERE id = ? - `).run( - category || null, - name || null, - total_price !== undefined ? 1 : null, total_price !== undefined ? total_price : 0, - persons !== undefined ? 1 : null, persons !== undefined ? persons : null, - days !== undefined ? 1 : 0, days !== undefined ? days : null, - note !== undefined ? 1 : 0, note !== undefined ? note : null, - sort_order !== undefined ? 1 : null, sort_order !== undefined ? sort_order : 0, - expense_date !== undefined ? 1 : 0, expense_date !== undefined ? (expense_date || null) : null, - 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'] as string); }); @@ -158,146 +75,62 @@ router.put('/:id', authenticate, (req: Request, res: Response) => { router.put('/:id/members', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, id } = req.params; - const access = canAccessTrip(Number(tripId), authReq.user.id); + + const access = verifyTripAccess(Number(tripId), authReq.user.id); if (!access) return res.status(404).json({ error: 'Trip not found' }); if (!checkPermission('budget_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); - 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' }); - const { user_ids } = req.body; if (!Array.isArray(user_ids)) return res.status(400).json({ error: 'user_ids must be an array' }); - const existingPaid: Record = {}; - 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; + const result = updateMembers(id, tripId, user_ids); + if (!result) return res.status(404).json({ error: 'Budget item not found' }); - 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); - 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); - } - - 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 as BudgetItem).persons }, req.headers['x-socket-id'] as string); + res.json({ members: result.members, item: result.item }); + broadcast(Number(tripId), 'budget:members-updated', { itemId: Number(id), members: result.members, persons: result.item.persons }, req.headers['x-socket-id'] as string); }); router.put('/:id/members/:userId/paid', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, id, userId } = req.params; - const access = canAccessTrip(Number(tripId), authReq.user.id); + + const access = verifyTripAccess(Number(tripId), authReq.user.id); if (!access) return res.status(404).json({ error: 'Trip not found' }); if (!checkPermission('budget_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); const { paid } = req.body; - db.prepare('UPDATE budget_item_members SET paid = ? WHERE budget_item_id = ? AND user_id = ?') - .run(paid ? 1 : 0, id, userId); - - const member = 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 = ? AND bm.user_id = ? - `).get(id, userId) as BudgetItemMember | undefined; - - const result = member ? { ...member, avatar_url: avatarUrl(member) } : null; - res.json({ member: result }); + const member = toggleMemberPaid(id, userId, paid); + res.json({ member }); broadcast(Number(tripId), 'budget:member-paid-updated', { itemId: Number(id), userId: Number(userId), paid: paid ? 1 : 0 }, req.headers['x-socket-id'] as string); }); -// Settlement calculation: who owes whom router.get('/settlement', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; - if (!canAccessTrip(Number(tripId), authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); - const items = db.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(tripId) as BudgetItem[]; - 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 (SELECT id FROM budget_items WHERE trip_id = ?) - `).all(tripId) as (BudgetItemMember & { budget_item_id: number })[]; + if (!verifyTripAccess(Number(tripId), authReq.user.id)) + return res.status(404).json({ error: 'Trip not found' }); - // Calculate net balance per user: positive = is owed money, negative = owes money - const balances: Record = {}; - - for (const item of items) { - const members = allMembers.filter(m => m.budget_item_id === item.id); - if (members.length === 0) continue; - - const payers = members.filter(m => m.paid); - if (payers.length === 0) continue; // no one marked as paid - - const sharePerMember = item.total_price / members.length; - const paidPerPayer = item.total_price / payers.length; - - for (const m of members) { - if (!balances[m.user_id]) { - balances[m.user_id] = { user_id: m.user_id, username: m.username, avatar_url: avatarUrl(m), balance: 0 }; - } - // Everyone owes their share - balances[m.user_id].balance -= sharePerMember; - // Payers get credited what they paid - if (m.paid) balances[m.user_id].balance += paidPerPayer; - } - } - - // Calculate optimized payment flows (greedy algorithm) - const people = Object.values(balances).filter(b => Math.abs(b.balance) > 0.01); - const debtors = people.filter(p => p.balance < -0.01).map(p => ({ ...p, amount: -p.balance })); - const creditors = people.filter(p => p.balance > 0.01).map(p => ({ ...p, amount: p.balance })); - - // Sort by amount descending for efficient matching - debtors.sort((a, b) => b.amount - a.amount); - creditors.sort((a, b) => b.amount - a.amount); - - const flows: { from: { user_id: number; username: string; avatar_url: string | null }; to: { user_id: number; username: string; avatar_url: string | null }; amount: number }[] = []; - - let di = 0, ci = 0; - while (di < debtors.length && ci < creditors.length) { - const transfer = Math.min(debtors[di].amount, creditors[ci].amount); - if (transfer > 0.01) { - flows.push({ - from: { user_id: debtors[di].user_id, username: debtors[di].username, avatar_url: debtors[di].avatar_url }, - to: { user_id: creditors[ci].user_id, username: creditors[ci].username, avatar_url: creditors[ci].avatar_url }, - amount: Math.round(transfer * 100) / 100, - }); - } - debtors[di].amount -= transfer; - creditors[ci].amount -= transfer; - if (debtors[di].amount < 0.01) di++; - if (creditors[ci].amount < 0.01) ci++; - } - - res.json({ - balances: Object.values(balances).map(b => ({ ...b, balance: Math.round(b.balance * 100) / 100 })), - flows, - }); + res.json(calculateSettlement(tripId)); }); router.delete('/:id', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, id } = req.params; - const trip = verifyTripOwnership(tripId, authReq.user.id); + const trip = verifyTripAccess(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); if (!checkPermission('budget_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); - const item = db.prepare('SELECT id FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId); - if (!item) return res.status(404).json({ error: 'Budget item not found' }); + if (!deleteBudgetItem(id, tripId)) + return res.status(404).json({ error: 'Budget item not found' }); - 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'] as string); }); diff --git a/server/src/routes/categories.ts b/server/src/routes/categories.ts index f7dc993..b33712e 100644 --- a/server/src/routes/categories.ts +++ b/server/src/routes/categories.ts @@ -1,55 +1,34 @@ import express, { Request, Response } from 'express'; -import { db } from '../db/database'; import { authenticate, adminOnly } from '../middleware/auth'; import { AuthRequest } from '../types'; +import * as categoryService from '../services/categoryService'; const router = express.Router(); router.get('/', authenticate, (_req: Request, res: Response) => { - const categories = db.prepare( - 'SELECT * FROM categories ORDER BY name ASC' - ).all(); - res.json({ categories }); + res.json({ categories: categoryService.listCategories() }); }); 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 || '\uD83D\uDCCD', authReq.user.id); - - const category = db.prepare('SELECT * FROM categories WHERE id = ?').get(result.lastInsertRowid); + const category = categoryService.createCategory(authReq.user.id, name, color, icon); res.status(201).json({ category }); }); 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); - - if (!category) return res.status(404).json({ error: 'Category not found' }); - - db.prepare(` - UPDATE categories SET - name = COALESCE(?, name), - color = COALESCE(?, color), - icon = COALESCE(?, icon) - WHERE id = ? - `).run(name || null, color || null, icon || null, req.params.id); - - const updated = db.prepare('SELECT * FROM categories WHERE id = ?').get(req.params.id); - res.json({ category: updated }); + if (!categoryService.getCategoryById(req.params.id)) + return res.status(404).json({ error: 'Category not found' }); + const category = categoryService.updateCategory(req.params.id, name, color, icon); + res.json({ category }); }); 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' }); - - db.prepare('DELETE FROM categories WHERE id = ?').run(req.params.id); + if (!categoryService.getCategoryById(req.params.id)) + return res.status(404).json({ error: 'Category not found' }); + categoryService.deleteCategory(req.params.id); res.json({ success: true }); }); diff --git a/server/src/routes/collab.ts b/server/src/routes/collab.ts index 15f1779..6043247 100644 --- a/server/src/routes/collab.ts +++ b/server/src/routes/collab.ts @@ -3,35 +3,32 @@ 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 { checkPermission } from '../services/permissions'; -import { AuthRequest, CollabNote, CollabPoll, CollabMessage, TripFile } from '../types'; -import { checkSsrf, createPinnedAgent } from '../utils/ssrfGuard'; - -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; -} +import { AuthRequest } from '../types'; +import { db } from '../db/database'; +import { + verifyTripAccess, + listNotes, + createNote, + updateNote, + deleteNote, + addNoteFile, + getFormattedNoteById, + deleteNoteFile, + listPolls, + createPoll, + votePoll, + closePoll, + deletePoll, + listMessages, + createMessage, + deleteMessage, + addOrRemoveReaction, + fetchLinkPreview, +} from '../services/collabService'; const MAX_NOTE_FILE_SIZE = 50 * 1024 * 1024; // 50 MB const filesDir = path.join(__dirname, '../../uploads/files'); @@ -54,59 +51,16 @@ const noteUpload = multer({ const router = express.Router({ mergeParams: true }); -function verifyTripAccess(tripId: string | number, userId: number) { - return canAccessTrip(tripId, userId); -} - -function avatarUrl(user: { avatar?: string | null }): string | null { - return user.avatar ? `/uploads/avatars/${user.avatar}` : null; -} - -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), - attachments: attachments.map(a => ({ ...a, url: `/uploads/${a.filename}` })), - }; -} - -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) as ReactionRow[]; -} - -function groupReactions(reactions: ReactionRow[]) { - const map: Record = {}; - for (const r of reactions) { - if (!map[r.emoji]) map[r.emoji] = []; - map[r.emoji].push({ user_id: r.user_id, username: r.username }); - } - return Object.entries(map).map(([emoji, users]) => ({ emoji, users, count: users.length })); -} - -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 */ +/* ------------------------------------------------------------------ */ router.get('/notes', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; 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 - FROM collab_notes n - JOIN users u ON n.user_id = u.id - WHERE n.trip_id = ? - ORDER BY n.pinned DESC, n.updated_at DESC - `).all(tripId) as CollabNote[]; - - res.json({ notes: notes.map(formatNote) }); + res.json({ notes: listNotes(tripId) }); }); router.post('/notes', authenticate, (req: Request, res: Response) => { @@ -119,16 +73,7 @@ router.post('/notes', authenticate, (req: Request, res: Response) => { return res.status(403).json({ error: 'No permission' }); 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, 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) as CollabNote; - - const formatted = formatNote(note); + const formatted = createNote(tripId, authReq.user.id, { title, content, category, color, website }); res.status(201).json({ note: formatted }); broadcast(tripId, 'collab:note:created', { note: formatted }, req.headers['x-socket-id'] as string); @@ -147,34 +92,9 @@ router.put('/notes/:id', authenticate, (req: Request, res: Response) => { if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); - 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' }); + const formatted = updateNote(tripId, id, { title, content, category, color, pinned, website }); + if (!formatted) return res.status(404).json({ error: 'Note not found' }); - db.prepare(` - UPDATE collab_notes SET - title = COALESCE(?, title), - content = CASE WHEN ? THEN ? ELSE content END, - category = COALESCE(?, category), - color = COALESCE(?, color), - pinned = CASE WHEN ? IS NOT NULL THEN ? ELSE pinned END, - website = CASE WHEN ? THEN ? ELSE website END, - updated_at = CURRENT_TIMESTAMP - WHERE id = ? - `).run( - title || null, - content !== undefined ? 1 : 0, content !== undefined ? content : null, - category || null, - color || null, - pinned !== undefined ? 1 : null, pinned ? 1 : 0, - website !== undefined ? 1 : 0, website !== undefined ? website : null, - id - ); - - 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) as CollabNote; - - const formatted = formatNote(note); res.json({ note: formatted }); broadcast(tripId, 'collab:note:updated', { note: formatted }, req.headers['x-socket-id'] as string); }); @@ -187,21 +107,16 @@ router.delete('/notes/:id', authenticate, (req: Request, res: Response) => { if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); - 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' }); + if (!deleteNote(tripId, id)) return res.status(404).json({ error: 'Note not found' }); - 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 {} - } - db.prepare('DELETE FROM trip_files WHERE note_id = ?').run(id); - - 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'] as string); }); +/* ------------------------------------------------------------------ */ +/* Note files */ +/* ------------------------------------------------------------------ */ + router.post('/notes/:id/files', authenticate, noteUpload.single('file'), (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, id } = req.params; @@ -211,16 +126,11 @@ router.post('/notes/:id/files', authenticate, noteUpload.single('file'), (req: R return res.status(403).json({ error: 'No permission to upload files' }); 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); - if (!note) return res.status(404).json({ error: 'Note not found' }); + const result = addNoteFile(tripId, id, req.file); + if (!result) return res.status(404).json({ error: 'Note not found' }); - const result = db.prepare( - '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) 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) as CollabNote) }, req.headers['x-socket-id'] as string); + res.status(201).json(result); + broadcast(Number(tripId), 'collab:note:updated', { note: getFormattedNoteById(id) }, req.headers['x-socket-id'] as string); }); router.delete('/notes/:id/files/:fileId', authenticate, (req: Request, res: Response) => { @@ -231,63 +141,22 @@ router.delete('/notes/:id/files/:fileId', authenticate, (req: Request, res: Resp if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); - 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' }); + if (!deleteNoteFile(id, fileId)) return res.status(404).json({ error: 'File not found' }); - 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) as CollabNote) }, req.headers['x-socket-id'] as string); + broadcast(Number(tripId), 'collab:note:updated', { note: getFormattedNoteById(id) }, req.headers['x-socket-id'] as string); }); -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) as CollabPoll | undefined; - - if (!poll) return null; - - 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) as PollVoteRow[]; - - 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) - .map(v => ({ id: v.user_id, user_id: v.user_id, username: v.username, avatar: v.avatar, avatar_url: avatarUrl(v) })), - })); - - return { - ...poll, - avatar_url: avatarUrl(poll), - options: formattedOptions, - is_closed: !!poll.closed, - multiple_choice: !!poll.multiple, - }; -} +/* ------------------------------------------------------------------ */ +/* Polls */ +/* ------------------------------------------------------------------ */ router.get('/polls', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; 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) as { id: number }[]; - - const polls = rows.map(row => getPollWithVotes(row.id)).filter(Boolean); - res.json({ polls }); + res.json({ polls: listPolls(tripId) }); }); router.post('/polls', authenticate, (req: Request, res: Response) => { @@ -303,14 +172,7 @@ router.post('/polls', authenticate, (req: Request, res: Response) => { return res.status(400).json({ error: 'At least 2 options are required' }); } - const isMultiple = multiple || multiple_choice; - - const result = db.prepare(` - INSERT INTO collab_polls (trip_id, user_id, question, options, multiple, deadline) - VALUES (?, ?, ?, ?, ?, ?) - `).run(tripId, authReq.user.id, question, JSON.stringify(options), isMultiple ? 1 : 0, deadline || null); - - const poll = getPollWithVotes(result.lastInsertRowid); + const poll = createPoll(tripId, authReq.user.id, { question, options, multiple, multiple_choice, deadline }); res.status(201).json({ poll }); broadcast(tripId, 'collab:poll:created', { poll }, req.headers['x-socket-id'] as string); }); @@ -324,31 +186,13 @@ router.post('/polls/:id/vote', authenticate, (req: Request, res: Response) => { if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); - 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' }); + const result = votePoll(tripId, id, authReq.user.id, option_index); + if (result.error === 'not_found') return res.status(404).json({ error: 'Poll not found' }); + if (result.error === 'closed') return res.status(400).json({ error: 'Poll is closed' }); + if (result.error === 'invalid_index') return res.status(400).json({ error: 'Invalid option index' }); - const options = JSON.parse(poll.options); - if (option_index < 0 || option_index >= options.length) { - return res.status(400).json({ error: 'Invalid option index' }); - } - - const existingVote = db.prepare( - 'SELECT id FROM collab_poll_votes WHERE poll_id = ? AND user_id = ? AND 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, authReq.user.id); - } - 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'] as string); + res.json({ poll: result.poll }); + broadcast(tripId, 'collab:poll:voted', { poll: result.poll }, req.headers['x-socket-id'] as string); }); router.put('/polls/:id/close', authenticate, (req: Request, res: Response) => { @@ -359,12 +203,9 @@ router.put('/polls/:id/close', authenticate, (req: Request, res: Response) => { if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); - 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' }); + const updatedPoll = closePoll(tripId, id); + if (!updatedPoll) return res.status(404).json({ error: 'Poll not found' }); - db.prepare('UPDATE collab_polls SET closed = 1 WHERE id = ?').run(id); - - const updatedPoll = getPollWithVotes(id); res.json({ poll: updatedPoll }); broadcast(tripId, 'collab:poll:closed', { poll: updatedPoll }, req.headers['x-socket-id'] as string); }); @@ -377,52 +218,23 @@ router.delete('/polls/:id', authenticate, (req: Request, res: Response) => { if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); - 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' }); + if (!deletePoll(tripId, id)) 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'] as string); }); +/* ------------------------------------------------------------------ */ +/* Messages */ +/* ------------------------------------------------------------------ */ + router.get('/messages', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; const { before } = req.query; if (!verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); - const query = ` - SELECT m.*, u.username, u.avatar, - rm.text AS reply_text, ru.username AS reply_username - FROM collab_messages m - JOIN users u ON m.user_id = u.id - LEFT JOIN collab_messages rm ON m.reply_to = rm.id - LEFT JOIN users ru ON rm.user_id = ru.id - WHERE m.trip_id = ?${before ? ' AND m.id < ?' : ''} - ORDER BY m.id DESC - LIMIT 100 - `; - - const messages = before - ? db.prepare(query).all(tripId, before) as CollabMessage[] - : db.prepare(query).all(tripId) as CollabMessage[]; - - messages.reverse(); - const msgIds = messages.map(m => m.id); - const reactionsByMsg: Record = {}; - 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) 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); - } - } - res.json({ messages: messages.map(m => formatMessage(m, groupReactions(reactionsByMsg[m.id] || []))) }); + res.json({ messages: listMessages(tripId, before as string | undefined) }); }); router.post('/messages', authenticate, validateStringLengths({ text: 5000 }), (req: Request, res: Response) => { @@ -435,28 +247,11 @@ router.post('/messages', authenticate, validateStringLengths({ text: 5000 }), (r return res.status(403).json({ error: 'No permission' }); if (!text || !text.trim()) return res.status(400).json({ error: 'Message text is required' }); - if (reply_to) { - const replyMsg = db.prepare('SELECT id FROM collab_messages WHERE id = ? AND trip_id = ?').get(reply_to, tripId); - if (!replyMsg) return res.status(400).json({ error: 'Reply target message not found' }); - } + const result = createMessage(tripId, authReq.user.id, text, reply_to); + if (result.error === 'reply_not_found') return res.status(400).json({ error: 'Reply target message not found' }); - const result = db.prepare(` - INSERT INTO collab_messages (trip_id, user_id, text, reply_to) VALUES (?, ?, ?, ?) - `).run(tripId, authReq.user.id, text.trim(), reply_to || null); - - const message = db.prepare(` - SELECT m.*, u.username, u.avatar, - rm.text AS reply_text, ru.username AS reply_username - FROM collab_messages m - JOIN users u ON m.user_id = u.id - 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) as CollabMessage; - - const formatted = formatMessage(message); - res.status(201).json({ message: formatted }); - broadcast(tripId, 'collab:message:created', { message: formatted }, req.headers['x-socket-id'] as string); + res.status(201).json({ message: result.message }); + broadcast(tripId, 'collab:message:created', { message: result.message }, req.headers['x-socket-id'] as string); // Notify trip members about new chat message import('../services/notifications').then(({ notifyTripMembers }) => { @@ -466,6 +261,10 @@ router.post('/messages', authenticate, validateStringLengths({ text: 5000 }), (r }); }); +/* ------------------------------------------------------------------ */ +/* Reactions */ +/* ------------------------------------------------------------------ */ + router.post('/messages/:id/react', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, id } = req.params; @@ -476,21 +275,17 @@ router.post('/messages/:id/react', authenticate, (req: Request, res: Response) = return res.status(403).json({ error: 'No permission' }); 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 result = addOrRemoveReaction(id, tripId, authReq.user.id, emoji); + if (!result.found) 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, 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, 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'] as string); + res.json({ reactions: result.reactions }); + broadcast(Number(tripId), 'collab:message:reacted', { messageId: Number(id), reactions: result.reactions }, req.headers['x-socket-id'] as string); }); +/* ------------------------------------------------------------------ */ +/* Delete message */ +/* ------------------------------------------------------------------ */ + router.delete('/messages/:id', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, id } = req.params; @@ -499,63 +294,27 @@ router.delete('/messages/:id', authenticate, (req: Request, res: Response) => { if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); - 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(authReq.user.id)) return res.status(403).json({ error: 'You can only delete your own messages' }); + const result = deleteMessage(tripId, id, authReq.user.id); + if (result.error === 'not_found') return res.status(404).json({ error: 'Message not found' }); + if (result.error === 'not_owner') 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 || authReq.user.username }, req.headers['x-socket-id'] as string); + broadcast(tripId, 'collab:message:deleted', { messageId: Number(id), username: result.username || authReq.user.username }, req.headers['x-socket-id'] as string); }); +/* ------------------------------------------------------------------ */ +/* Link preview */ +/* ------------------------------------------------------------------ */ + 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 parsed = new URL(url); - const ssrf = await checkSsrf(url); - if (!ssrf.allowed) { - return res.status(400).json({ error: ssrf.error }); - } - - const nodeFetch = require('node-fetch'); - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 5000); - - nodeFetch(url, { - redirect: 'error', - signal: controller.signal, - agent: createPinnedAgent(ssrf.resolvedIp!, parsed.protocol), - headers: { 'User-Agent': 'Mozilla/5.0 (compatible; NOMAD/1.0; +https://github.com/mauriceboe/NOMAD)' }, - }) - .then((r: { ok: boolean; text: () => Promise }) => { - clearTimeout(timeout); - if (!r.ok) throw new Error('Fetch failed'); - return r.text(); - }) - .then((html: string) => { - const get = (prop: string) => { - const m = html.match(new RegExp(`]*property=["']og:${prop}["'][^>]*content=["']([^"']*)["']`, 'i')) - || html.match(new RegExp(`]*content=["']([^"']*)["'][^>]*property=["']og:${prop}["']`, 'i')); - return m ? m[1] : null; - }; - const titleTag = html.match(/]*>([^<]*)<\/title>/i); - const descMeta = html.match(/]*name=["']description["'][^>]*content=["']([^"']*)["']/i) - || html.match(/]*content=["']([^"']*)["'][^>]*name=["']description["']/i); - - res.json({ - title: get('title') || (titleTag ? titleTag[1].trim() : null), - description: get('description') || (descMeta ? descMeta[1].trim() : null), - image: get('image') || null, - site_name: get('site_name') || null, - url, - }); - }) - .catch(() => { - clearTimeout(timeout); - res.json({ title: null, description: null, image: null, url }); - }); + const preview = await fetchLinkPreview(url); + const asAny = preview as any; + if (asAny.error) return res.status(400).json({ error: asAny.error }); + res.json(preview); } catch { res.json({ title: null, description: null, image: null, url }); } diff --git a/server/src/routes/dayNotes.ts b/server/src/routes/dayNotes.ts index 7c60f3f..48c46ff 100644 --- a/server/src/routes/dayNotes.ts +++ b/server/src/routes/dayNotes.ts @@ -1,48 +1,33 @@ 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 { checkPermission } from '../services/permissions'; -import { AuthRequest, DayNote } from '../types'; +import { AuthRequest } from '../types'; +import * as dayNoteService from '../services/dayNoteService'; const router = express.Router({ mergeParams: true }); -function verifyAccess(tripId: string | number, userId: number) { - return canAccessTrip(tripId, userId); -} - router.get('/', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, dayId } = req.params; - 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' - ).all(dayId, tripId); - - res.json({ notes }); + if (!dayNoteService.verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); + res.json({ notes: dayNoteService.listNotes(dayId, tripId) }); }); router.post('/', authenticate, validateStringLengths({ text: 500, time: 150 }), (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, dayId } = req.params; - const access = verifyAccess(tripId, authReq.user.id); + const access = dayNoteService.verifyTripAccess(tripId, authReq.user.id); if (!access) return res.status(404).json({ error: 'Trip not found' }); if (!checkPermission('day_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); - - 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' }); + if (!dayNoteService.dayExists(dayId, tripId)) return res.status(404).json({ error: 'Day not found' }); const { text, time, icon, sort_order } = req.body; if (!text?.trim()) return res.status(400).json({ error: 'Text required' }); - 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 || '\uD83D\uDCDD', sort_order ?? 9999); - - const note = db.prepare('SELECT * FROM day_notes WHERE id = ?').get(result.lastInsertRowid); + const note = dayNoteService.createNote(dayId, tripId, text, time, icon, sort_order); res.status(201).json({ note }); broadcast(tripId, 'dayNote:created', { dayId: Number(dayId), note }, req.headers['x-socket-id'] as string); }); @@ -50,26 +35,16 @@ router.post('/', authenticate, validateStringLengths({ text: 500, time: 150 }), router.put('/:id', authenticate, validateStringLengths({ text: 500, time: 150 }), (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, dayId, id } = req.params; - const access = verifyAccess(tripId, authReq.user.id); + const access = dayNoteService.verifyTripAccess(tripId, authReq.user.id); if (!access) return res.status(404).json({ error: 'Trip not found' }); if (!checkPermission('day_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); - 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 current = dayNoteService.getNote(id, dayId, tripId); + if (!current) return res.status(404).json({ error: 'Note not found' }); const { text, time, icon, sort_order } = req.body; - db.prepare( - 'UPDATE day_notes SET text = ?, time = ?, icon = ?, sort_order = ? WHERE id = ?' - ).run( - text !== undefined ? text.trim() : note.text, - time !== undefined ? time : note.time, - icon !== undefined ? icon : note.icon, - sort_order !== undefined ? sort_order : note.sort_order, - id - ); - - const updated = db.prepare('SELECT * FROM day_notes WHERE id = ?').get(id); + const updated = dayNoteService.updateNote(id, current, { text, time, icon, sort_order }); res.json({ note: updated }); broadcast(tripId, 'dayNote:updated', { dayId: Number(dayId), note: updated }, req.headers['x-socket-id'] as string); }); @@ -77,15 +52,13 @@ router.put('/:id', authenticate, validateStringLengths({ text: 500, time: 150 }) router.delete('/:id', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, dayId, id } = req.params; - const access = verifyAccess(tripId, authReq.user.id); + const access = dayNoteService.verifyTripAccess(tripId, authReq.user.id); if (!access) return res.status(404).json({ error: 'Trip not found' }); if (!checkPermission('day_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); - 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); + if (!dayNoteService.getNote(id, dayId, tripId)) return res.status(404).json({ error: 'Note not found' }); + dayNoteService.deleteNote(id); res.json({ success: true }); broadcast(tripId, 'dayNote:deleted', { noteId: Number(id), dayId: Number(dayId) }, req.headers['x-socket-id'] as string); }); diff --git a/server/src/routes/days.ts b/server/src/routes/days.ts index 469b47e..60ff8b9 100644 --- a/server/src/routes/days.ts +++ b/server/src/routes/days.ts @@ -1,129 +1,16 @@ 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 { checkPermission } from '../services/permissions'; -import { AuthRequest, AssignmentRow, Day, DayNote } from '../types'; +import { AuthRequest } from '../types'; +import * as dayService from '../services/dayService'; const router = express.Router({ mergeParams: true }); -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, - COALESCE(da.assignment_time, p.place_time) as place_time, - COALESCE(da.assignment_end_time, p.end_time) as end_time, - p.duration_minutes, p.notes as place_notes, - p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone, - c.name as category_name, c.color as category_color, c.icon as category_icon - FROM day_assignments da - JOIN places p ON da.place_id = p.id - 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) as AssignmentRow[]; - - return assignments.map(a => { - const tags = db.prepare(` - SELECT t.* FROM tags t - JOIN place_tags pt ON t.id = pt.tag_id - WHERE pt.place_id = ? - `).all(a.place_id); - - return { - id: a.id, - day_id: a.day_id, - order_index: a.order_index, - notes: a.notes, - 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, - } - }; - }); -} - router.get('/', authenticate, requireTripAccess, (req: Request, res: Response) => { const { tripId } = req.params; - - 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: [] }); - } - - const dayIds = days.map(d => d.id); - const dayPlaceholders = dayIds.map(() => '?').join(','); - - 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, - COALESCE(da.assignment_time, p.place_time) as place_time, - COALESCE(da.assignment_end_time, p.end_time) as end_time, - p.duration_minutes, p.notes as place_notes, - p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone, - c.name as category_name, c.color as category_color, c.icon as category_icon - FROM day_assignments da - JOIN places p ON da.place_id = p.id - 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) as AssignmentRow[]; - - const placeIds = [...new Set(allAssignments.map(a => a.place_id))]; - const tagsByPlaceId = loadTagsByPlaceIds(placeIds, { compact: true }); - - const allAssignmentIds = allAssignments.map(a => a.id); - const participantsByAssignment = loadParticipantsByAssignmentIds(allAssignmentIds); - - const assignmentsByDayId: Record[]> = {}; - for (const a of allAssignments) { - if (!assignmentsByDayId[a.day_id]) assignmentsByDayId[a.day_id] = []; - assignmentsByDayId[a.day_id].push(formatAssignmentWithPlace(a, tagsByPlaceId[a.place_id] || [], participantsByAssignment[a.id] || [])); - } - - const allNotes = db.prepare( - `SELECT * FROM day_notes WHERE day_id IN (${dayPlaceholders}) ORDER BY sort_order ASC, created_at ASC` - ).all(...dayIds) as DayNote[]; - const notesByDayId: Record = {}; - for (const note of allNotes) { - if (!notesByDayId[note.day_id]) notesByDayId[note.day_id] = []; - notesByDayId[note.day_id].push(note); - } - - const daysWithAssignments = days.map(day => ({ - ...day, - assignments: assignmentsByDayId[day.id] || [], - notes_items: notesByDayId[day.id] || [], - })); - - res.json({ days: daysWithAssignments }); + res.json(dayService.listDays(tripId)); }); router.post('/', authenticate, requireTripAccess, (req: Request, res: Response) => { @@ -134,18 +21,9 @@ router.post('/', authenticate, requireTripAccess, (req: Request, res: Response) const { tripId } = req.params; const { date, notes } = req.body; - 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) as Day; - - const dayResult = { ...day, assignments: [] }; - res.status(201).json({ day: dayResult }); - broadcast(tripId, 'day:created', { day: dayResult }, req.headers['x-socket-id'] as string); + const day = dayService.createDay(tripId, date, notes); + res.status(201).json({ day }); + broadcast(tripId, 'day:created', { day }, req.headers['x-socket-id'] as string); }); router.put('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => { @@ -155,18 +33,13 @@ router.put('/:id', authenticate, requireTripAccess, (req: Request, res: Response const { tripId, id } = req.params; - 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' }); - } + const current = dayService.getDay(id, tripId); + if (!current) return res.status(404).json({ error: 'Day not found' }); 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) as Day; - const dayWithAssignments = { ...updatedDay, assignments: getAssignmentsForDay(id) }; - res.json({ day: dayWithAssignments }); - broadcast(tripId, 'day:updated', { day: dayWithAssignments }, req.headers['x-socket-id'] as string); + const day = dayService.updateDay(id, current, { notes, title }); + res.json({ day }); + broadcast(tripId, 'day:updated', { day }, req.headers['x-socket-id'] as string); }); router.delete('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => { @@ -176,39 +49,22 @@ router.delete('/:id', authenticate, requireTripAccess, (req: Request, res: Respo const { tripId, id } = req.params; - 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' }); - } + if (!dayService.getDay(id, tripId)) return res.status(404).json({ error: 'Day not found' }); - db.prepare('DELETE FROM days WHERE id = ?').run(id); + dayService.deleteDay(id); res.json({ success: true }); broadcast(tripId, 'day:deleted', { dayId: Number(id) }, req.headers['x-socket-id'] as string); }); -const accommodationsRouter = express.Router({ mergeParams: true }); +// --------------------------------------------------------------------------- +// Accommodations sub-router +// --------------------------------------------------------------------------- -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 - JOIN places p ON a.place_id = p.id - WHERE a.id = ? - `).get(id); -} +const accommodationsRouter = express.Router({ mergeParams: true }); accommodationsRouter.get('/', authenticate, requireTripAccess, (req: Request, res: Response) => { const { tripId } = req.params; - - 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 - JOIN places p ON a.place_id = p.id - WHERE a.trip_id = ? - ORDER BY a.created_at ASC - `).all(tripId); - - res.json({ accommodations }); + res.json({ accommodations: dayService.listAccommodations(tripId) }); }); accommodationsRouter.post('/', authenticate, requireTripAccess, (req: Request, res: Response) => { @@ -223,37 +79,10 @@ accommodationsRouter.post('/', authenticate, requireTripAccess, (req: Request, r return res.status(400).json({ error: 'place_id, start_day_id, and end_day_id are required' }); } - 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 errors = dayService.validateAccommodationRefs(tripId, place_id, start_day_id, end_day_id); + if (errors.length > 0) return res.status(404).json({ error: errors[0].message }); - 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' }); - - 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' }); - - 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 (?, ?, ?, ?, ?, ?, ?, ?)' - ).run(tripId, place_id, start_day_id, end_day_id, check_in || null, check_out || null, confirmation || null, notes || null); - - const accommodationId = result.lastInsertRowid; - - // Auto-create linked reservation for this accommodation - const placeName = (db.prepare('SELECT name FROM places WHERE id = ?').get(place_id) as { name: string } | undefined)?.name || 'Hotel'; - const startDayDate = (db.prepare('SELECT date FROM days WHERE id = ?').get(start_day_id) as { date: string } | undefined)?.date || null; - const meta: Record = {}; - if (check_in) meta.check_in_time = check_in; - if (check_out) meta.check_out_time = check_out; - db.prepare(` - INSERT INTO reservations (trip_id, day_id, title, reservation_time, location, confirmation_number, notes, status, type, accommodation_id, metadata) - VALUES (?, ?, ?, ?, ?, ?, ?, 'confirmed', 'hotel', ?, ?) - `).run( - tripId, start_day_id, placeName, startDayDate || null, null, - confirmation || null, notes || null, accommodationId, - Object.keys(meta).length > 0 ? JSON.stringify(meta) : null - ); - - const accommodation = getAccommodationWithPlace(accommodationId); + const accommodation = dayService.createAccommodation(tripId, { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes }); res.status(201).json({ accommodation }); broadcast(tripId, 'accommodation:created', { accommodation }, req.headers['x-socket-id'] as string); broadcast(tripId, 'reservation:created', {}, req.headers['x-socket-id'] as string); @@ -266,50 +95,15 @@ accommodationsRouter.put('/:id', authenticate, requireTripAccess, (req: Request, const { tripId, id } = req.params; - 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; + const existing = dayService.getAccommodation(id, tripId); 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; - const newPlaceId = place_id !== undefined ? place_id : existing.place_id; - const newStartDayId = start_day_id !== undefined ? start_day_id : existing.start_day_id; - const newEndDayId = end_day_id !== undefined ? end_day_id : existing.end_day_id; - const newCheckIn = check_in !== undefined ? check_in : existing.check_in; - const newCheckOut = check_out !== undefined ? check_out : existing.check_out; - const newConfirmation = confirmation !== undefined ? confirmation : existing.confirmation; - const newNotes = notes !== undefined ? notes : existing.notes; + const errors = dayService.validateAccommodationRefs(tripId, place_id, start_day_id, end_day_id); + if (errors.length > 0) return res.status(404).json({ error: errors[0].message }); - 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 (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 (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' }); - } - - 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); - - // Sync check-in/out/confirmation to linked reservation - const linkedRes = db.prepare('SELECT id, metadata FROM reservations WHERE accommodation_id = ?').get(Number(id)) as { id: number; metadata: string | null } | undefined; - if (linkedRes) { - const meta = linkedRes.metadata ? JSON.parse(linkedRes.metadata) : {}; - if (newCheckIn) meta.check_in_time = newCheckIn; - if (newCheckOut) meta.check_out_time = newCheckOut; - db.prepare('UPDATE reservations SET metadata = ?, confirmation_number = COALESCE(?, confirmation_number) WHERE id = ?') - .run(JSON.stringify(meta), newConfirmation || null, linkedRes.id); - } - - const accommodation = getAccommodationWithPlace(Number(id)); + const accommodation = dayService.updateAccommodation(id, existing, { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes }); res.json({ accommodation }); broadcast(tripId, 'accommodation:updated', { accommodation }, req.headers['x-socket-id'] as string); }); @@ -321,17 +115,13 @@ accommodationsRouter.delete('/:id', authenticate, requireTripAccess, (req: Reque const { tripId, id } = req.params; - 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 (!dayService.getAccommodation(id, tripId)) return res.status(404).json({ error: 'Accommodation not found' }); - // Delete linked reservation - const linkedRes = db.prepare('SELECT id FROM reservations WHERE accommodation_id = ?').get(Number(id)) as { id: number } | undefined; - if (linkedRes) { - db.prepare('DELETE FROM reservations WHERE id = ?').run(linkedRes.id); - broadcast(tripId, 'reservation:deleted', { reservationId: linkedRes.id }, req.headers['x-socket-id'] as string); + const { linkedReservationId } = dayService.deleteAccommodation(id); + if (linkedReservationId) { + broadcast(tripId, 'reservation:deleted', { reservationId: linkedReservationId }, req.headers['x-socket-id'] as string); } - 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'] as string); }); diff --git a/server/src/routes/files.ts b/server/src/routes/files.ts index 13df3cd..f0c9fdc 100644 --- a/server/src/routes/files.ts +++ b/server/src/routes/files.ts @@ -3,20 +3,41 @@ import multer from 'multer'; import path from 'path'; import fs from 'fs'; import { v4 as uuidv4 } from 'uuid'; -import jwt from 'jsonwebtoken'; -import { JWT_SECRET } from '../config'; -import { db, canAccessTrip } from '../db/database'; -import { consumeEphemeralToken } from '../services/ephemeralTokens'; import { authenticate, demoUploadBlock } from '../middleware/auth'; import { requireTripAccess } from '../middleware/tripAccess'; import { broadcast } from '../websocket'; -import { AuthRequest, TripFile } from '../types'; +import { AuthRequest } from '../types'; import { checkPermission } from '../services/permissions'; +import { + MAX_FILE_SIZE, + BLOCKED_EXTENSIONS, + filesDir, + getAllowedExtensions, + verifyTripAccess, + formatFile, + resolveFilePath, + authenticateDownload, + listFiles, + getFileById, + getFileByIdFull, + getDeletedFile, + createFile, + updateFile, + toggleStarred, + softDeleteFile, + restoreFile, + permanentDeleteFile, + emptyTrash, + createFileLink, + deleteFileLink, + getFileLinks, +} from '../services/fileService'; const router = express.Router({ mergeParams: true }); -const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB -const filesDir = path.join(__dirname, '../../uploads/files'); +// --------------------------------------------------------------------------- +// Multer setup (HTTP middleware — stays in route) +// --------------------------------------------------------------------------- const storage = multer.diskStorage({ destination: (_req, _file, cb) => { @@ -29,16 +50,6 @@ 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(): string { - try { - 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: MAX_FILE_SIZE }, @@ -58,109 +69,44 @@ const upload = multer({ }, }); -function verifyTripOwnership(tripId: string | number, userId: number) { - return canAccessTrip(tripId, userId); -} +// --------------------------------------------------------------------------- +// Routes +// --------------------------------------------------------------------------- -const FILE_SELECT = ` - SELECT f.*, r.title as reservation_title, u.username as uploaded_by_name, u.avatar as uploaded_by_avatar - FROM trip_files f - LEFT JOIN reservations r ON f.reservation_id = r.id - LEFT JOIN users u ON f.uploaded_by = u.id -`; - -function formatFile(file: TripFile & { trip_id?: number }) { - const tripId = file.trip_id; - return { - ...file, - url: `/api/trips/${tripId}/files/${file.id}/download`, - }; -} - -function getPlaceFiles(tripId: string | number, placeId: number) { - return (db.prepare('SELECT * FROM trip_files WHERE trip_id = ? AND place_id = ? AND deleted_at IS NULL ORDER BY created_at DESC').all(tripId, placeId) as (TripFile & { trip_id: number })[]).map(formatFile); -} - -// Authenticated file download (supports Bearer header or ?token= query param for direct links) +// Authenticated file download (supports Bearer header or ?token= query param) router.get('/:id/download', (req: Request, res: Response) => { const { tripId, id } = req.params; - // Accept token from Authorization header (JWT) or query parameter (ephemeral token) const authHeader = req.headers['authorization']; const bearerToken = authHeader && authHeader.split(' ')[1]; const queryToken = req.query.token as string | undefined; - if (!bearerToken && !queryToken) return res.status(401).json({ error: 'Authentication required' }); + const auth = authenticateDownload(bearerToken, queryToken); + if ('error' in auth) return res.status(auth.status).json({ error: auth.error }); - let userId: number; - if (bearerToken) { - try { - const decoded = jwt.verify(bearerToken, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number }; - userId = decoded.id; - } catch { - return res.status(401).json({ error: 'Invalid or expired token' }); - } - } else { - const uid = consumeEphemeralToken(queryToken!, 'download'); - if (!uid) return res.status(401).json({ error: 'Invalid or expired token' }); - userId = uid; - } - - const trip = verifyTripOwnership(tripId, userId); + const trip = verifyTripAccess(tripId, auth.userId); 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) as TripFile | undefined; + const file = getFileById(id, tripId); if (!file) return res.status(404).json({ error: 'File not found' }); - const safeName = path.basename(file.filename); - const filePath = path.join(filesDir, safeName); - const resolved = path.resolve(filePath); - if (!resolved.startsWith(path.resolve(filesDir))) { - return res.status(403).json({ error: 'Forbidden' }); - } - + const { resolved, safe } = resolveFilePath(file.filename); + if (!safe) return res.status(403).json({ error: 'Forbidden' }); if (!fs.existsSync(resolved)) return res.status(404).json({ error: 'File not found' }); + res.sendFile(resolved); }); // List files (excludes soft-deleted by default) -interface FileLink { - file_id: number; - reservation_id: number | null; - place_id: number | null; -} - router.get('/', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; const showTrash = req.query.trash === 'true'; - const trip = verifyTripOwnership(tripId, authReq.user.id); + const trip = verifyTripAccess(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); - const where = showTrash ? 'f.trip_id = ? AND f.deleted_at IS NOT NULL' : 'f.trip_id = ? AND f.deleted_at IS NULL'; - const files = db.prepare(`${FILE_SELECT} WHERE ${where} ORDER BY f.starred DESC, f.created_at DESC`).all(tripId) as TripFile[]; - - // Get all file_links for this trip's files - const fileIds = files.map(f => f.id); - let linksMap: Record = {}; - if (fileIds.length > 0) { - const placeholders = fileIds.map(() => '?').join(','); - const links = db.prepare(`SELECT file_id, reservation_id, place_id FROM file_links WHERE file_id IN (${placeholders})`).all(...fileIds) as FileLink[]; - for (const link of links) { - if (!linksMap[link.file_id]) linksMap[link.file_id] = []; - linksMap[link.file_id].push(link); - } - } - - res.json({ files: files.map(f => { - const fileLinks = linksMap[f.id] || []; - return { - ...formatFile(f), - linked_reservation_ids: fileLinks.filter(l => l.reservation_id).map(l => l.reservation_id), - linked_place_ids: fileLinks.filter(l => l.place_id).map(l => l.place_id), - }; - })}); + res.json({ files: listFiles(tripId, showTrash) }); }); // Upload file @@ -170,30 +116,13 @@ router.post('/', authenticate, requireTripAccess, demoUploadBlock, upload.single const { user_id: tripOwnerId } = authReq.trip!; if (!checkPermission('file_upload', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id)) return res.status(403).json({ error: 'No permission to upload files' }); + + if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); + const { place_id, description, reservation_id } = req.body; - - if (!req.file) { - return res.status(400).json({ error: 'No file uploaded' }); - } - - const result = db.prepare(` - INSERT INTO trip_files (trip_id, place_id, reservation_id, filename, original_name, file_size, mime_type, description, uploaded_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run( - tripId, - place_id || null, - reservation_id || null, - req.file.filename, - req.file.originalname, - req.file.size, - req.file.mimetype, - description || null, - authReq.user.id - ); - - const file = db.prepare(`${FILE_SELECT} WHERE f.id = ?`).get(result.lastInsertRowid) as TripFile; - res.status(201).json({ file: formatFile(file) }); - broadcast(tripId, 'file:created', { file: formatFile(file) }, req.headers['x-socket-id'] as string); + const created = createFile(tripId, req.file, authReq.user.id, { place_id, description, reservation_id }); + res.status(201).json({ file: created }); + broadcast(tripId, 'file:created', { file: created }, req.headers['x-socket-id'] as string); }); // Update file metadata @@ -202,30 +131,17 @@ router.put('/:id', authenticate, (req: Request, res: Response) => { const { tripId, id } = req.params; const { description, place_id, reservation_id } = req.body; - const access = canAccessTrip(tripId, authReq.user.id); + const access = verifyTripAccess(tripId, authReq.user.id); if (!access) return res.status(404).json({ error: 'Trip not found' }); if (!checkPermission('file_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission to edit files' }); - const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, tripId) as TripFile | undefined; + const file = getFileById(id, tripId); if (!file) return res.status(404).json({ error: 'File not found' }); - db.prepare(` - UPDATE trip_files SET - description = ?, - place_id = ?, - reservation_id = ? - WHERE id = ? - `).run( - description !== undefined ? description : file.description, - place_id !== undefined ? (place_id || null) : file.place_id, - reservation_id !== undefined ? (reservation_id || null) : file.reservation_id, - id - ); - - const updated = db.prepare(`${FILE_SELECT} WHERE f.id = ?`).get(id) as TripFile; - res.json({ file: formatFile(updated) }); - broadcast(tripId, 'file:updated', { file: formatFile(updated) }, req.headers['x-socket-id'] as string); + const updated = updateFile(id, file, { description, place_id, reservation_id }); + res.json({ file: updated }); + broadcast(tripId, 'file:updated', { file: updated }, req.headers['x-socket-id'] as string); }); // Toggle starred @@ -233,20 +149,17 @@ router.patch('/:id/star', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, id } = req.params; - const trip = verifyTripOwnership(tripId, authReq.user.id); + const trip = verifyTripAccess(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); if (!checkPermission('file_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); - const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, tripId) as TripFile | undefined; + const file = getFileById(id, tripId); if (!file) return res.status(404).json({ error: 'File not found' }); - const newStarred = file.starred ? 0 : 1; - db.prepare('UPDATE trip_files SET starred = ? WHERE id = ?').run(newStarred, id); - - const updated = db.prepare(`${FILE_SELECT} WHERE f.id = ?`).get(id) as TripFile; - res.json({ file: formatFile(updated) }); - broadcast(tripId, 'file:updated', { file: formatFile(updated) }, req.headers['x-socket-id'] as string); + const updated = toggleStarred(id, file.starred); + res.json({ file: updated }); + broadcast(tripId, 'file:updated', { file: updated }, req.headers['x-socket-id'] as string); }); // Soft-delete (move to trash) @@ -254,15 +167,15 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, id } = req.params; - const access = canAccessTrip(tripId, authReq.user.id); + const access = verifyTripAccess(tripId, authReq.user.id); if (!access) return res.status(404).json({ error: 'Trip not found' }); if (!checkPermission('file_delete', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission to delete files' }); - const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, tripId) as TripFile | undefined; + const file = getFileById(id, tripId); if (!file) return res.status(404).json({ error: 'File not found' }); - db.prepare('UPDATE trip_files SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?').run(id); + softDeleteFile(id); res.json({ success: true }); broadcast(tripId, 'file:deleted', { fileId: Number(id) }, req.headers['x-socket-id'] as string); }); @@ -272,19 +185,17 @@ router.post('/:id/restore', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, id } = req.params; - const trip = verifyTripOwnership(tripId, authReq.user.id); + const trip = verifyTripAccess(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); if (!checkPermission('file_delete', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); - const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ? AND deleted_at IS NOT NULL').get(id, tripId) as TripFile | undefined; + const file = getDeletedFile(id, tripId); if (!file) return res.status(404).json({ error: 'File not found in trash' }); - db.prepare('UPDATE trip_files SET deleted_at = NULL WHERE id = ?').run(id); - - const restored = db.prepare(`${FILE_SELECT} WHERE f.id = ?`).get(id) as TripFile; - res.json({ file: formatFile(restored) }); - broadcast(tripId, 'file:created', { file: formatFile(restored) }, req.headers['x-socket-id'] as string); + const restored = restoreFile(id); + res.json({ file: restored }); + broadcast(tripId, 'file:created', { file: restored }, req.headers['x-socket-id'] as string); }); // Permanently delete from trash @@ -292,20 +203,15 @@ router.delete('/:id/permanent', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, id } = req.params; - const trip = verifyTripOwnership(tripId, authReq.user.id); + const trip = verifyTripAccess(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); if (!checkPermission('file_delete', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); - const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ? AND deleted_at IS NOT NULL').get(id, tripId) as TripFile | undefined; + const file = getDeletedFile(id, tripId); if (!file) return res.status(404).json({ error: 'File not found in trash' }); - const filePath = path.join(filesDir, file.filename); - if (fs.existsSync(filePath)) { - try { fs.unlinkSync(filePath); } catch (e) { console.error('Error deleting file:', e); } - } - - db.prepare('DELETE FROM trip_files WHERE id = ?').run(id); + permanentDeleteFile(file); res.json({ success: true }); broadcast(tripId, 'file:deleted', { fileId: Number(id) }, req.headers['x-socket-id'] as string); }); @@ -315,21 +221,13 @@ router.delete('/trash/empty', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; - const trip = verifyTripOwnership(tripId, authReq.user.id); + const trip = verifyTripAccess(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); if (!checkPermission('file_delete', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); - const trashed = db.prepare('SELECT * FROM trip_files WHERE trip_id = ? AND deleted_at IS NOT NULL').all(tripId) as TripFile[]; - for (const file of trashed) { - const filePath = path.join(filesDir, file.filename); - if (fs.existsSync(filePath)) { - try { fs.unlinkSync(filePath); } catch (e) { console.error('Error deleting file:', e); } - } - } - - db.prepare('DELETE FROM trip_files WHERE trip_id = ? AND deleted_at IS NOT NULL').run(tripId); - res.json({ success: true, deleted: trashed.length }); + const deleted = emptyTrash(tripId); + res.json({ success: true, deleted }); }); // Link a file to a reservation (many-to-many) @@ -338,23 +236,15 @@ router.post('/:id/link', authenticate, (req: Request, res: Response) => { const { tripId, id } = req.params; const { reservation_id, assignment_id, place_id } = req.body; - const trip = verifyTripOwnership(tripId, authReq.user.id); + const trip = verifyTripAccess(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); if (!checkPermission('file_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); - const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, tripId); + const file = getFileById(id, tripId); if (!file) return res.status(404).json({ error: 'File not found' }); - try { - db.prepare('INSERT OR IGNORE INTO file_links (file_id, reservation_id, assignment_id, place_id) VALUES (?, ?, ?, ?)').run( - id, reservation_id || null, assignment_id || null, place_id || null - ); - } catch (err) { - console.error('[Files] Error creating file link:', err instanceof Error ? err.message : err); - } - - const links = db.prepare('SELECT * FROM file_links WHERE file_id = ?').all(id); + const links = createFileLink(id, { reservation_id, assignment_id, place_id }); res.json({ success: true, links }); }); @@ -363,12 +253,12 @@ router.delete('/:id/link/:linkId', authenticate, (req: Request, res: Response) = const authReq = req as AuthRequest; const { tripId, id, linkId } = req.params; - const trip = verifyTripOwnership(tripId, authReq.user.id); + const trip = verifyTripAccess(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); if (!checkPermission('file_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); - db.prepare('DELETE FROM file_links WHERE id = ? AND file_id = ?').run(linkId, id); + deleteFileLink(linkId, id); res.json({ success: true }); }); @@ -377,15 +267,10 @@ router.get('/:id/links', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, id } = req.params; - const trip = verifyTripOwnership(tripId, authReq.user.id); + const trip = verifyTripAccess(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); - const links = db.prepare(` - SELECT fl.*, r.title as reservation_title - FROM file_links fl - LEFT JOIN reservations r ON fl.reservation_id = r.id - WHERE fl.file_id = ? - `).all(id); + const links = getFileLinks(id); res.json({ links }); }); diff --git a/server/src/routes/immich.ts b/server/src/routes/immich.ts index 9b63da4..198b6e8 100644 --- a/server/src/routes/immich.ts +++ b/server/src/routes/immich.ts @@ -4,295 +4,33 @@ import { authenticate } from '../middleware/auth'; import { broadcast } from '../websocket'; import { AuthRequest } from '../types'; import { consumeEphemeralToken } from '../services/ephemeralTokens'; -import { maybe_encrypt_api_key, decrypt_api_key } from '../services/apiKeyCrypto'; -import { checkSsrf } from '../utils/ssrfGuard'; -import { writeAudit, getClientIp } from '../services/auditLog'; +import { getClientIp } from '../services/auditLog'; +import { + getConnectionSettings, + saveImmichSettings, + testConnection, + getConnectionStatus, + browseTimeline, + searchPhotos, + listTripPhotos, + addTripPhotos, + removeTripPhoto, + togglePhotoSharing, + getAssetInfo, + proxyThumbnail, + proxyOriginal, + isValidAssetId, + listAlbums, + listAlbumLinks, + createAlbumLink, + deleteAlbumLink, + syncAlbumAssets, +} from '../services/immichService'; const router = express.Router(); -function getImmichCredentials(userId: number) { - const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(userId) as any; - if (!user?.immich_url || !user?.immich_api_key) return null; - return { immich_url: user.immich_url as string, immich_api_key: decrypt_api_key(user.immich_api_key) as string }; -} +// ── Dual auth middleware (JWT or ephemeral token for src) ───────────── -/** Validate that an asset ID is a safe UUID-like string (no path traversal). */ -function isValidAssetId(id: string): boolean { - return /^[a-zA-Z0-9_-]+$/.test(id) && id.length <= 100; -} - -// ── Immich Connection Settings ────────────────────────────────────────────── - -router.get('/settings', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const creds = getImmichCredentials(authReq.user.id); - res.json({ - immich_url: creds?.immich_url || '', - connected: !!(creds?.immich_url && creds?.immich_api_key), - }); -}); - -router.put('/settings', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { immich_url, immich_api_key } = req.body; - - if (immich_url) { - const ssrf = await checkSsrf(immich_url.trim()); - if (!ssrf.allowed) { - return res.status(400).json({ error: `Invalid Immich URL: ${ssrf.error}` }); - } - db.prepare('UPDATE users SET immich_url = ?, immich_api_key = ? WHERE id = ?').run( - immich_url.trim(), - maybe_encrypt_api_key(immich_api_key), - authReq.user.id - ); - if (ssrf.isPrivate) { - writeAudit({ - userId: authReq.user.id, - action: 'immich.private_ip_configured', - ip: getClientIp(req), - details: { immich_url: immich_url.trim(), resolved_ip: ssrf.resolvedIp }, - }); - return res.json({ - success: true, - warning: `Immich URL resolves to a private IP address (${ssrf.resolvedIp}). Make sure this is intentional.`, - }); - } - } else { - db.prepare('UPDATE users SET immich_url = ?, immich_api_key = ? WHERE id = ?').run( - null, - maybe_encrypt_api_key(immich_api_key), - authReq.user.id - ); - } - - res.json({ success: true }); -}); - -router.get('/status', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const creds = getImmichCredentials(authReq.user.id); - if (!creds) { - return res.json({ connected: false, error: 'Not configured' }); - } - try { - const resp = await fetch(`${creds.immich_url}/api/users/me`, { - headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, - signal: AbortSignal.timeout(10000), - }); - if (!resp.ok) return res.json({ connected: false, error: `HTTP ${resp.status}` }); - const data = await resp.json() as { name?: string; email?: string }; - res.json({ connected: true, user: { name: data.name, email: data.email } }); - } catch (err: unknown) { - res.json({ connected: false, error: err instanceof Error ? err.message : 'Connection failed' }); - } -}); - -// Test connection with provided credentials (without saving) -router.post('/test', authenticate, async (req: Request, res: Response) => { - const { immich_url, immich_api_key } = req.body; - if (!immich_url || !immich_api_key) return res.json({ connected: false, error: 'URL and API key required' }); - const ssrf = await checkSsrf(immich_url); - if (!ssrf.allowed) return res.json({ connected: false, error: ssrf.error ?? 'Invalid Immich URL' }); - try { - const resp = await fetch(`${immich_url}/api/users/me`, { - headers: { 'x-api-key': immich_api_key, 'Accept': 'application/json' }, - signal: AbortSignal.timeout(10000), - }); - if (!resp.ok) return res.json({ connected: false, error: `HTTP ${resp.status}` }); - const data = await resp.json() as { name?: string; email?: string }; - res.json({ connected: true, user: { name: data.name, email: data.email } }); - } catch (err: unknown) { - res.json({ connected: false, error: err instanceof Error ? err.message : 'Connection failed' }); - } -}); - -// ── Browse Immich Library (for photo picker) ──────────────────────────────── - -router.get('/browse', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { page = '1', size = '50' } = req.query; - const creds = getImmichCredentials(authReq.user.id); - if (!creds) return res.status(400).json({ error: 'Immich not configured' }); - - try { - const resp = await fetch(`${creds.immich_url}/api/timeline/buckets`, { - method: 'GET', - headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, - signal: AbortSignal.timeout(15000), - }); - if (!resp.ok) return res.status(resp.status).json({ error: 'Failed to fetch from Immich' }); - const buckets = await resp.json(); - res.json({ buckets }); - } catch (err: unknown) { - res.status(502).json({ error: 'Could not reach Immich' }); - } -}); - -// Search photos by date range (for the date-filter in picker) -router.post('/search', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { from, to } = req.body; - const creds = getImmichCredentials(authReq.user.id); - if (!creds) return res.status(400).json({ error: 'Immich not configured' }); - - try { - // Paginate through all results (Immich limits per-page to 1000) - const allAssets: any[] = []; - let page = 1; - const pageSize = 1000; - while (true) { - const resp = await fetch(`${creds.immich_url}/api/search/metadata`, { - method: 'POST', - headers: { 'x-api-key': creds.immich_api_key, 'Content-Type': 'application/json' }, - body: JSON.stringify({ - takenAfter: from ? `${from}T00:00:00.000Z` : undefined, - takenBefore: to ? `${to}T23:59:59.999Z` : undefined, - type: 'IMAGE', - size: pageSize, - page, - }), - signal: AbortSignal.timeout(15000), - }); - if (!resp.ok) return res.status(resp.status).json({ error: 'Search failed' }); - const data = await resp.json() as { assets?: { items?: any[] } }; - const items = data.assets?.items || []; - allAssets.push(...items); - if (items.length < pageSize) break; // Last page - page++; - if (page > 20) break; // Safety limit (20k photos max) - } - const assets = allAssets.map((a: any) => ({ - id: a.id, - takenAt: a.fileCreatedAt || a.createdAt, - city: a.exifInfo?.city || null, - country: a.exifInfo?.country || null, - })); - res.json({ assets }); - } catch { - res.status(502).json({ error: 'Could not reach Immich' }); - } -}); - -// ── Trip Photos (selected by user) ────────────────────────────────────────── - -// Get all photos for a trip (own + shared by others) -router.get('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); - - const photos = db.prepare(` - SELECT tp.immich_asset_id, tp.user_id, tp.shared, tp.added_at, - u.username, u.avatar, u.immich_url - FROM trip_photos tp - JOIN users u ON tp.user_id = u.id - WHERE tp.trip_id = ? - AND (tp.user_id = ? OR tp.shared = 1) - ORDER BY tp.added_at ASC - `).all(tripId, authReq.user.id); - - res.json({ photos }); -}); - -// Add photos to a trip -router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); - const { asset_ids, shared = true } = req.body; - - if (!Array.isArray(asset_ids) || asset_ids.length === 0) { - return res.status(400).json({ error: 'asset_ids required' }); - } - - const insert = db.prepare( - 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, immich_asset_id, shared) VALUES (?, ?, ?, ?)' - ); - let added = 0; - for (const assetId of asset_ids) { - const result = insert.run(tripId, authReq.user.id, assetId, shared ? 1 : 0); - if (result.changes > 0) added++; - } - - res.json({ success: true, added }); - broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); - - // Notify trip members about shared photos - if (shared && added > 0) { - import('../services/notifications').then(({ notifyTripMembers }) => { - const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined; - notifyTripMembers(Number(tripId), authReq.user.id, 'photos_shared', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, count: String(added) }).catch(() => {}); - }); - } -}); - -// Remove a photo from a trip (own photos only) -router.delete('/trips/:tripId/photos/:assetId', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - if (!canAccessTrip(req.params.tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); - db.prepare('DELETE FROM trip_photos WHERE trip_id = ? AND user_id = ? AND immich_asset_id = ?') - .run(req.params.tripId, authReq.user.id, req.params.assetId); - res.json({ success: true }); - broadcast(req.params.tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); -}); - -// Toggle sharing for a specific photo -router.put('/trips/:tripId/photos/:assetId/sharing', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - if (!canAccessTrip(req.params.tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); - const { shared } = req.body; - db.prepare('UPDATE trip_photos SET shared = ? WHERE trip_id = ? AND user_id = ? AND immich_asset_id = ?') - .run(shared ? 1 : 0, req.params.tripId, authReq.user.id, req.params.assetId); - res.json({ success: true }); - broadcast(req.params.tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); -}); - -// ── Asset Details ─────────────────────────────────────────────────────────── - -router.get('/assets/:assetId/info', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { assetId } = req.params; - if (!isValidAssetId(assetId)) return res.status(400).json({ error: 'Invalid asset ID' }); - - // Only allow accessing own Immich credentials — prevent leaking other users' API keys - const creds = getImmichCredentials(authReq.user.id); - if (!creds) return res.status(404).json({ error: 'Not found' }); - - try { - const resp = await fetch(`${creds.immich_url}/api/assets/${assetId}`, { - headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, - signal: AbortSignal.timeout(10000), - }); - if (!resp.ok) return res.status(resp.status).json({ error: 'Failed' }); - const asset = await resp.json() as any; - res.json({ - id: asset.id, - takenAt: asset.fileCreatedAt || asset.createdAt, - width: asset.exifInfo?.exifImageWidth || null, - height: asset.exifInfo?.exifImageHeight || null, - camera: asset.exifInfo?.make && asset.exifInfo?.model ? `${asset.exifInfo.make} ${asset.exifInfo.model}` : null, - lens: asset.exifInfo?.lensModel || null, - focalLength: asset.exifInfo?.focalLength ? `${asset.exifInfo.focalLength}mm` : null, - aperture: asset.exifInfo?.fNumber ? `f/${asset.exifInfo.fNumber}` : null, - shutter: asset.exifInfo?.exposureTime || null, - iso: asset.exifInfo?.iso || null, - city: asset.exifInfo?.city || null, - state: asset.exifInfo?.state || null, - country: asset.exifInfo?.country || null, - lat: asset.exifInfo?.latitude || null, - lng: asset.exifInfo?.longitude || null, - fileSize: asset.exifInfo?.fileSizeInByte || null, - fileName: asset.originalFileName || null, - }); - } catch { - res.status(502).json({ error: 'Proxy error' }); - } -}); - -// ── Proxy Immich Assets ───────────────────────────────────────────────────── - -// Asset proxy routes accept ephemeral token via query param (for src usage) function authFromQuery(req: Request, res: Response, next: NextFunction) { const queryToken = req.query.token as string | undefined; if (queryToken) { @@ -306,160 +44,174 @@ function authFromQuery(req: Request, res: Response, next: NextFunction) { return (authenticate as any)(req, res, next); } +// ── Immich Connection Settings ───────────────────────────────────────────── + +router.get('/settings', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + res.json(getConnectionSettings(authReq.user.id)); +}); + +router.put('/settings', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { immich_url, immich_api_key } = req.body; + const result = await saveImmichSettings(authReq.user.id, immich_url, immich_api_key, getClientIp(req)); + if (!result.success) return res.status(400).json({ error: result.error }); + if (result.warning) return res.json({ success: true, warning: result.warning }); + res.json({ success: true }); +}); + +router.get('/status', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + res.json(await getConnectionStatus(authReq.user.id)); +}); + +router.post('/test', authenticate, async (req: Request, res: Response) => { + const { immich_url, immich_api_key } = req.body; + if (!immich_url || !immich_api_key) return res.json({ connected: false, error: 'URL and API key required' }); + res.json(await testConnection(immich_url, immich_api_key)); +}); + +// ── Browse Immich Library (for photo picker) ─────────────────────────────── + +router.get('/browse', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const result = await browseTimeline(authReq.user.id); + if (result.error) return res.status(result.status!).json({ error: result.error }); + res.json({ buckets: result.buckets }); +}); + +router.post('/search', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { from, to } = req.body; + const result = await searchPhotos(authReq.user.id, from, to); + if (result.error) return res.status(result.status!).json({ error: result.error }); + res.json({ assets: result.assets }); +}); + +// ── Trip Photos (selected by user) ──────────────────────────────────────── + +router.get('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId } = req.params; + if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); + res.json({ photos: listTripPhotos(tripId, authReq.user.id) }); +}); + +router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId } = req.params; + if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); + const { asset_ids, shared = true } = req.body; + + if (!Array.isArray(asset_ids) || asset_ids.length === 0) { + return res.status(400).json({ error: 'asset_ids required' }); + } + + const added = addTripPhotos(tripId, authReq.user.id, asset_ids, shared); + res.json({ success: true, added }); + broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); + + // Notify trip members about shared photos + if (shared && added > 0) { + import('../services/notifications').then(({ notifyTripMembers }) => { + const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined; + notifyTripMembers(Number(tripId), authReq.user.id, 'photos_shared', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, count: String(added) }).catch(() => {}); + }); + } +}); + +router.delete('/trips/:tripId/photos/:assetId', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + if (!canAccessTrip(req.params.tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); + removeTripPhoto(req.params.tripId, authReq.user.id, req.params.assetId); + res.json({ success: true }); + broadcast(req.params.tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); +}); + +router.put('/trips/:tripId/photos/:assetId/sharing', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + if (!canAccessTrip(req.params.tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); + const { shared } = req.body; + togglePhotoSharing(req.params.tripId, authReq.user.id, req.params.assetId, shared); + res.json({ success: true }); + broadcast(req.params.tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); +}); + +// ── Asset Details ────────────────────────────────────────────────────────── + +router.get('/assets/:assetId/info', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { assetId } = req.params; + if (!isValidAssetId(assetId)) return res.status(400).json({ error: 'Invalid asset ID' }); + const result = await getAssetInfo(authReq.user.id, assetId); + if (result.error) return res.status(result.status!).json({ error: result.error }); + res.json(result.data); +}); + +// ── Proxy Immich Assets ──────────────────────────────────────────────────── + router.get('/assets/:assetId/thumbnail', authFromQuery, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { assetId } = req.params; if (!isValidAssetId(assetId)) return res.status(400).send('Invalid asset ID'); - - // Only allow accessing own Immich credentials — prevent leaking other users' API keys - const creds = getImmichCredentials(authReq.user.id); - if (!creds) return res.status(404).send('Not found'); - - try { - const resp = await fetch(`${creds.immich_url}/api/assets/${assetId}/thumbnail`, { - headers: { 'x-api-key': creds.immich_api_key }, - signal: AbortSignal.timeout(10000), - }); - if (!resp.ok) return res.status(resp.status).send('Failed'); - res.set('Content-Type', resp.headers.get('content-type') || 'image/webp'); - res.set('Cache-Control', 'public, max-age=86400'); - const buffer = Buffer.from(await resp.arrayBuffer()); - res.send(buffer); - } catch { - res.status(502).send('Proxy error'); - } + const result = await proxyThumbnail(authReq.user.id, assetId); + if (result.error) return res.status(result.status!).send(result.error); + res.set('Content-Type', result.contentType!); + res.set('Cache-Control', 'public, max-age=86400'); + res.send(result.buffer); }); router.get('/assets/:assetId/original', authFromQuery, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { assetId } = req.params; if (!isValidAssetId(assetId)) return res.status(400).send('Invalid asset ID'); - - // Only allow accessing own Immich credentials — prevent leaking other users' API keys - const creds = getImmichCredentials(authReq.user.id); - if (!creds) return res.status(404).send('Not found'); - - try { - const resp = await fetch(`${creds.immich_url}/api/assets/${assetId}/original`, { - headers: { 'x-api-key': creds.immich_api_key }, - signal: AbortSignal.timeout(30000), - }); - if (!resp.ok) return res.status(resp.status).send('Failed'); - res.set('Content-Type', resp.headers.get('content-type') || 'image/jpeg'); - res.set('Cache-Control', 'public, max-age=86400'); - const buffer = Buffer.from(await resp.arrayBuffer()); - res.send(buffer); - } catch { - res.status(502).send('Proxy error'); - } + const result = await proxyOriginal(authReq.user.id, assetId); + if (result.error) return res.status(result.status!).send(result.error); + res.set('Content-Type', result.contentType!); + res.set('Cache-Control', 'public, max-age=86400'); + res.send(result.buffer); }); // ── Album Linking ────────────────────────────────────────────────────────── -// List user's Immich albums router.get('/albums', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; - const creds = getImmichCredentials(authReq.user.id); - if (!creds) return res.status(400).json({ error: 'Immich not configured' }); - - try { - const resp = await fetch(`${creds.immich_url}/api/albums`, { - headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, - signal: AbortSignal.timeout(10000), - }); - if (!resp.ok) return res.status(resp.status).json({ error: 'Failed to fetch albums' }); - const albums = (await resp.json() as any[]).map((a: any) => ({ - id: a.id, - albumName: a.albumName, - assetCount: a.assetCount || 0, - startDate: a.startDate, - endDate: a.endDate, - albumThumbnailAssetId: a.albumThumbnailAssetId, - })); - res.json({ albums }); - } catch { - res.status(502).json({ error: 'Could not reach Immich' }); - } + const result = await listAlbums(authReq.user.id); + if (result.error) return res.status(result.status!).json({ error: result.error }); + res.json({ albums: result.albums }); }); -// Get album links for a trip router.get('/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; - if (!canAccessTrip(req.params.tripId, (authReq as AuthRequest).user.id)) return res.status(404).json({ error: 'Trip not found' }); - const links = db.prepare(` - SELECT tal.*, u.username - FROM trip_album_links tal - JOIN users u ON tal.user_id = u.id - WHERE tal.trip_id = ? - ORDER BY tal.created_at ASC - `).all(req.params.tripId); - res.json({ links }); + if (!canAccessTrip(req.params.tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); + res.json({ links: listAlbumLinks(req.params.tripId) }); }); -// Link an album to a trip router.post('/trips/:tripId/album-links', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); const { album_id, album_name } = req.body; if (!album_id) return res.status(400).json({ error: 'album_id required' }); - - try { - db.prepare( - 'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, immich_album_id, album_name) VALUES (?, ?, ?, ?)' - ).run(tripId, authReq.user.id, album_id, album_name || ''); - res.json({ success: true }); - } catch (err: any) { - res.status(400).json({ error: 'Album already linked' }); - } -}); - -// Remove album link -router.delete('/trips/:tripId/album-links/:linkId', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?') - .run(req.params.linkId, req.params.tripId, authReq.user.id); + const result = createAlbumLink(tripId, authReq.user.id, album_id, album_name); + if (!result.success) return res.status(400).json({ error: result.error }); + res.json({ success: true }); +}); + +router.delete('/trips/:tripId/album-links/:linkId', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + deleteAlbumLink(req.params.linkId, req.params.tripId, authReq.user.id); res.json({ success: true }); }); -// Sync album — fetch all assets from Immich album and add missing ones to trip router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, linkId } = req.params; - - const link = db.prepare('SELECT * FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?') - .get(linkId, tripId, authReq.user.id) as any; - if (!link) return res.status(404).json({ error: 'Album link not found' }); - - const creds = getImmichCredentials(authReq.user.id); - if (!creds) return res.status(400).json({ error: 'Immich not configured' }); - - try { - const resp = await fetch(`${creds.immich_url}/api/albums/${link.immich_album_id}`, { - headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, - signal: AbortSignal.timeout(15000), - }); - if (!resp.ok) return res.status(resp.status).json({ error: 'Failed to fetch album' }); - const albumData = await resp.json() as { assets?: any[] }; - const assets = (albumData.assets || []).filter((a: any) => a.type === 'IMAGE'); - - const insert = db.prepare( - 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, immich_asset_id, shared) VALUES (?, ?, ?, 1)' - ); - let added = 0; - for (const asset of assets) { - const r = insert.run(tripId, authReq.user.id, asset.id); - if (r.changes > 0) added++; - } - - db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId); - - res.json({ success: true, added, total: assets.length }); - if (added > 0) { - broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); - } - } catch { - res.status(502).json({ error: 'Could not reach Immich' }); + const result = await syncAlbumAssets(tripId, linkId, authReq.user.id); + if (result.error) return res.status(result.status!).json({ error: result.error }); + res.json({ success: true, added: result.added, total: result.total }); + if (result.added! > 0) { + broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); } }); diff --git a/server/src/routes/maps.ts b/server/src/routes/maps.ts index 46ced7d..3d135ef 100644 --- a/server/src/routes/maps.ts +++ b/server/src/routes/maps.ts @@ -1,556 +1,94 @@ 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'; -import { decrypt_api_key } from '../services/apiKeyCrypto'; - -interface NominatimResult { - osm_type: string; - osm_id: string; - name?: string; - display_name?: string; - lat: string; - lon: string; -} - -interface OverpassElement { - tags?: Record; -} - -interface WikiCommonsPage { - imageinfo?: { url?: string; extmetadata?: { Artist?: { value?: string } } }[]; -} - -const UA = 'TREK Travel Planner (https://github.com/mauriceboe/NOMAD)'; - -// ── OSM Enrichment: Overpass API for details ────────────────────────────────── - -async function fetchOverpassDetails(osmType: string, osmId: string): Promise { - const typeMap: Record = { node: 'node', way: 'way', relation: 'rel' }; - const oType = typeMap[osmType]; - if (!oType) return null; - const query = `[out:json][timeout:5];${oType}(${osmId});out tags;`; - try { - const res = await fetch('https://overpass-api.de/api/interpreter', { - method: 'POST', - headers: { 'User-Agent': UA, 'Content-Type': 'application/x-www-form-urlencoded' }, - body: `data=${encodeURIComponent(query)}`, - }); - if (!res.ok) return null; - const data = await res.json() as { elements?: OverpassElement[] }; - return data.elements?.[0] || null; - } catch { return null; } -} - -function parseOpeningHours(ohString: string): { weekdayDescriptions: string[]; openNow: boolean | null } { - const DAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']; - const LONG = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; - const result: string[] = LONG.map(d => `${d}: ?`); - - // Parse segments like "Mo-Fr 09:00-18:00; Sa 10:00-14:00" - for (const segment of ohString.split(';')) { - const trimmed = segment.trim(); - if (!trimmed) continue; - const match = trimmed.match(/^((?:Mo|Tu|We|Th|Fr|Sa|Su)(?:\s*-\s*(?:Mo|Tu|We|Th|Fr|Sa|Su))?(?:\s*,\s*(?:Mo|Tu|We|Th|Fr|Sa|Su)(?:\s*-\s*(?:Mo|Tu|We|Th|Fr|Sa|Su))?)*)\s+(.+)$/i); - if (!match) continue; - const [, daysPart, timePart] = match; - const dayIndices = new Set(); - for (const range of daysPart.split(',')) { - const parts = range.trim().split('-').map(d => DAYS.indexOf(d.trim())); - if (parts.length === 2 && parts[0] >= 0 && parts[1] >= 0) { - for (let i = parts[0]; i !== (parts[1] + 1) % 7; i = (i + 1) % 7) dayIndices.add(i); - dayIndices.add(parts[1]); - } else if (parts[0] >= 0) { - dayIndices.add(parts[0]); - } - } - for (const idx of dayIndices) { - result[idx] = `${LONG[idx]}: ${timePart.trim()}`; - } - } - - // Compute openNow - let openNow: boolean | null = null; - try { - const now = new Date(); - const jsDay = now.getDay(); - const dayIdx = jsDay === 0 ? 6 : jsDay - 1; - const todayLine = result[dayIdx]; - const timeRanges = [...todayLine.matchAll(/(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})/g)]; - if (timeRanges.length > 0) { - const nowMins = now.getHours() * 60 + now.getMinutes(); - openNow = timeRanges.some(m => { - const start = parseInt(m[1]) * 60 + parseInt(m[2]); - const end = parseInt(m[3]) * 60 + parseInt(m[4]); - return end > start ? nowMins >= start && nowMins < end : nowMins >= start || nowMins < end; - }); - } - } catch { /* best effort */ } - - return { weekdayDescriptions: result, openNow }; -} - -function buildOsmDetails(tags: Record, osmType: string, osmId: string) { - let opening_hours: string[] | null = null; - let open_now: boolean | null = null; - if (tags.opening_hours) { - const parsed = parseOpeningHours(tags.opening_hours); - const hasData = parsed.weekdayDescriptions.some(line => !line.endsWith('?')); - if (hasData) { - opening_hours = parsed.weekdayDescriptions; - open_now = parsed.openNow; - } - } - return { - website: tags['contact:website'] || tags.website || null, - phone: tags['contact:phone'] || tags.phone || null, - opening_hours, - open_now, - osm_url: `https://www.openstreetmap.org/${osmType}/${osmId}`, - summary: tags.description || null, - source: 'openstreetmap' as const, - }; -} - -// ── Wikimedia Commons: Free place photos ────────────────────────────────────── - -async function fetchWikimediaPhoto(lat: number, lng: number, name?: string): Promise<{ photoUrl: string; attribution: string | null } | null> { - // Strategy 1: Search Wikipedia for the place name → get the article image - if (name) { - try { - const searchParams = new URLSearchParams({ - action: 'query', format: 'json', - titles: name, - prop: 'pageimages', - piprop: 'thumbnail', - pithumbsize: '400', - pilimit: '1', - redirects: '1', - }); - const res = await fetch(`https://en.wikipedia.org/w/api.php?${searchParams}`, { headers: { 'User-Agent': UA } }); - if (res.ok) { - const data = await res.json() as { query?: { pages?: Record } }; - const pages = data.query?.pages; - if (pages) { - for (const page of Object.values(pages)) { - if (page.thumbnail?.source) { - return { photoUrl: page.thumbnail.source, attribution: 'Wikipedia' }; - } - } - } - } - } catch { /* fall through to geosearch */ } - } - - // Strategy 2: Wikimedia Commons geosearch by coordinates - const params = new URLSearchParams({ - action: 'query', format: 'json', - generator: 'geosearch', - ggsprimary: 'all', - ggsnamespace: '6', - ggsradius: '300', - ggscoord: `${lat}|${lng}`, - ggslimit: '5', - prop: 'imageinfo', - iiprop: 'url|extmetadata|mime', - iiurlwidth: '400', - }); - try { - const res = await fetch(`https://commons.wikimedia.org/w/api.php?${params}`, { headers: { 'User-Agent': UA } }); - if (!res.ok) return null; - const data = await res.json() as { query?: { pages?: Record } }; - const pages = data.query?.pages; - if (!pages) return null; - for (const page of Object.values(pages)) { - const info = page.imageinfo?.[0]; - // Only use actual photos (JPEG/PNG), skip SVGs and PDFs - const mime = (info as { mime?: string })?.mime || ''; - if (info?.url && (mime.startsWith('image/jpeg') || mime.startsWith('image/png'))) { - const attribution = info.extmetadata?.Artist?.value?.replace(/<[^>]+>/g, '').trim() || null; - return { photoUrl: info.url, attribution }; - } - } - return null; - } catch { return null; } -} - -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 }[] }[]; -} +import { + searchPlaces, + getPlaceDetails, + getPlacePhoto, + reverseGeocode, + resolveGoogleMapsUrl, +} from '../services/mapsService'; const router = express.Router(); -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; - const user_key = decrypt_api_key(user?.maps_api_key); - if (user_key) return user_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() as { maps_api_key: string } | undefined; - return decrypt_api_key(admin?.maps_api_key) || null; -} - -const photoCache = new Map(); -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 - -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', - addressdetails: '1', - limit: '10', - 'accept-language': lang || 'en', - }); - const response = await fetch(`https://nominatim.openstreetmap.org/search?${params}`, { - headers: { 'User-Agent': 'TREK Travel Planner (https://github.com/mauriceboe/NOMAD)' }, - }); - if (!response.ok) throw new Error('Nominatim API error'); - const data = await response.json() as NominatimResult[]; - return data.map(item => ({ - google_place_id: null, - osm_id: `${item.osm_type}:${item.osm_id}`, - name: item.name || item.display_name?.split(',')[0] || '', - address: item.display_name || '', - lat: parseFloat(item.lat) || null, - lng: parseFloat(item.lon) || null, - rating: null, - website: null, - phone: null, - source: 'openstreetmap', - })); -} - +// POST /search 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(authReq.user.id); - - if (!apiKey) { - try { - const places = await searchNominatim(query, req.query.lang as string); - return res.json({ places, source: 'openstreetmap' }); - } catch (err: unknown) { - console.error('Nominatim search error:', err); - return res.status(500).json({ error: 'OpenStreetMap search error' }); - } - } - try { - const response = await fetch('https://places.googleapis.com/v1/places:searchText', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - '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 as string) || 'en' }), - }); - - 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: GooglePlaceResult) => ({ - google_place_id: p.id, - name: p.displayName?.text || '', - address: p.formattedAddress || '', - lat: p.location?.latitude || null, - lng: p.location?.longitude || null, - rating: p.rating || null, - website: p.websiteUri || null, - phone: p.nationalPhoneNumber || null, - source: 'google', - })); - - res.json({ places, source: 'google' }); + const result = await searchPlaces(authReq.user.id, query, req.query.lang as string); + res.json(result); } catch (err: unknown) { + const status = (err as { status?: number }).status || 500; + const message = err instanceof Error ? err.message : 'Search error'; console.error('Maps search error:', err); - res.status(500).json({ error: 'Google Places search error' }); + res.status(status).json({ error: message }); } }); +// GET /details/:placeId router.get('/details/:placeId', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { placeId } = req.params; - // OSM details: placeId is "node:123456" or "way:123456" etc. - if (placeId.includes(':')) { - const [osmType, osmId] = placeId.split(':'); - try { - const element = await fetchOverpassDetails(osmType, osmId); - if (!element?.tags) return res.json({ place: buildOsmDetails({}, osmType, osmId) }); - res.json({ place: buildOsmDetails(element.tags, osmType, osmId) }); - } catch (err: unknown) { - console.error('OSM details error:', err); - res.status(500).json({ error: 'Error fetching OSM details' }); - } - return; - } - - // Google details - 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 as string) || 'de'; - const response = await fetch(`https://places.googleapis.com/v1/places/${placeId}?languageCode=${lang}`, { - method: 'GET', - headers: { - 'X-Goog-Api-Key': apiKey, - 'X-Goog-FieldMask': 'id,displayName,formattedAddress,location,rating,userRatingCount,websiteUri,nationalPhoneNumber,regularOpeningHours,googleMapsUri,reviews,editorialSummary', - }, - }); - - 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' }); - } - - const place = { - google_place_id: data.id, - name: data.displayName?.text || '', - address: data.formattedAddress || '', - lat: data.location?.latitude || null, - lng: data.location?.longitude || null, - rating: data.rating || null, - rating_count: data.userRatingCount || null, - website: data.websiteUri || null, - phone: data.nationalPhoneNumber || null, - opening_hours: data.regularOpeningHours?.weekdayDescriptions || null, - 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: NonNullable[number]) => ({ - author: r.authorAttribution?.displayName || null, - rating: r.rating || null, - text: r.text?.text || null, - time: r.relativePublishTimeDescription || null, - photo: r.authorAttribution?.photoUri || null, - })), - source: 'google' as const, - }; - - res.json({ place }); + const result = await getPlaceDetails(authReq.user.id, placeId, req.query.lang as string); + res.json(result); } catch (err: unknown) { + const status = (err as { status?: number }).status || 500; + const message = err instanceof Error ? err.message : 'Error fetching place details'; console.error('Maps details error:', err); - res.status(500).json({ error: 'Error fetching place details' }); + res.status(status).json({ error: message }); } }); +// GET /place-photo/:placeId router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; const { placeId } = req.params; - - const cached = photoCache.get(placeId); - const ERROR_TTL = 5 * 60 * 1000; // 5 min for errors - if (cached) { - const ttl = cached.error ? ERROR_TTL : PHOTO_TTL; - if (Date.now() - cached.fetchedAt < ttl) { - if (cached.error) return res.status(404).json({ error: `(Cache) No photo available` }); - return res.json({ photoUrl: cached.photoUrl, attribution: cached.attribution }); - } - photoCache.delete(placeId); - } - - // Wikimedia Commons fallback for OSM places (using lat/lng query params) const lat = parseFloat(req.query.lat as string); const lng = parseFloat(req.query.lng as string); - const apiKey = getMapsKey(authReq.user.id); - const isCoordLookup = placeId.startsWith('coords:'); - - // No Google key or coordinate-only lookup → try Wikimedia - if (!apiKey || isCoordLookup) { - if (!isNaN(lat) && !isNaN(lng)) { - try { - const wiki = await fetchWikimediaPhoto(lat, lng, req.query.name as string); - if (wiki) { - photoCache.set(placeId, { ...wiki, fetchedAt: Date.now() }); - return res.json(wiki); - } else { - photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true }); - } - } catch { /* fall through */ } - } - return res.status(404).json({ error: '(Wikimedia) No photo available' }); - } - - // Google Photos try { - 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() as GooglePlaceDetails & { error?: { message?: string } }; - - if (!detailsRes.ok) { - console.error('Google Places photo details error:', details.error?.message || detailsRes.status); - photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true }); - return res.status(404).json({ error: '(Google Places) Photo could not be retrieved' }); - } - - if (!details.photos?.length) { - photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true }); - return res.status(404).json({ error: '(Google Places) No photo available' }); - } - - const photo = details.photos[0]; - const photoName = photo.name; - const attribution = photo.authorAttributions?.[0]?.displayName || null; - - const mediaRes = await fetch( - `https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=400&skipHttpRedirect=true`, - { headers: { 'X-Goog-Api-Key': apiKey } } - ); - const mediaData = await mediaRes.json() as { photoUri?: string }; - const photoUrl = mediaData.photoUri; - - if (!photoUrl) { - photoCache.set(placeId, { photoUrl: '', attribution, fetchedAt: Date.now(), error: true }); - return res.status(404).json({ error: '(Google Places) Photo URL not available' }); - } - - photoCache.set(placeId, { photoUrl, attribution, fetchedAt: Date.now() }); - - try { - db.prepare( - 'UPDATE places SET image_url = ?, updated_at = CURRENT_TIMESTAMP WHERE google_place_id = ? AND (image_url IS NULL OR image_url = ?)' - ).run(photoUrl, placeId, ''); - } catch (dbErr) { - console.error('Failed to persist photo URL to database:', dbErr); - } - - res.json({ photoUrl, attribution }); + const result = await getPlacePhoto(authReq.user.id, placeId, lat, lng, req.query.name as string); + res.json(result); } catch (err: unknown) { - console.error('Place photo error:', err); - photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true }); - res.status(500).json({ error: 'Error fetching photo' }); + const status = (err as { status?: number }).status || 500; + const message = err instanceof Error ? err.message : 'Error fetching photo'; + if (status >= 500) console.error('Place photo error:', err); + res.status(status).json({ error: message }); } }); -// Reverse geocoding via Nominatim +// GET /reverse router.get('/reverse', authenticate, async (req: Request, res: Response) => { const { lat, lng, lang } = req.query as { lat: string; lng: string; lang?: string }; if (!lat || !lng) return res.status(400).json({ error: 'lat and lng required' }); + try { - const params = new URLSearchParams({ - lat, lon: lng, format: 'json', addressdetails: '1', zoom: '18', - 'accept-language': lang || 'en', - }); - const response = await fetch(`https://nominatim.openstreetmap.org/reverse?${params}`, { - headers: { 'User-Agent': UA }, - }); - if (!response.ok) return res.json({ name: null, address: null }); - const data = await response.json() as { name?: string; display_name?: string; address?: Record }; - const addr = data.address || {}; - const name = data.name || addr.tourism || addr.amenity || addr.shop || addr.building || addr.road || null; - res.json({ name, address: data.display_name || null }); + const result = await reverseGeocode(lat, lng, lang); + res.json(result); } catch { res.json({ name: null, address: null }); } }); -// Resolve a Google Maps URL to place data (coordinates, name, address) +// POST /resolve-url router.post('/resolve-url', authenticate, async (req: Request, res: Response) => { const { url } = req.body; if (!url || typeof url !== 'string') return res.status(400).json({ error: 'URL is required' }); try { - let resolvedUrl = url; - - // Follow redirects for short URLs (goo.gl, maps.app.goo.gl) - if (url.includes('goo.gl') || url.includes('maps.app')) { - const redirectRes = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(10000) }); - resolvedUrl = redirectRes.url; - } - - // Extract coordinates from Google Maps URL patterns: - // /@48.8566,2.3522,15z or /place/.../@48.8566,2.3522 - // ?q=48.8566,2.3522 or ?ll=48.8566,2.3522 - let lat: number | null = null; - let lng: number | null = null; - let placeName: string | null = null; - - // Pattern: /@lat,lng - const atMatch = resolvedUrl.match(/@(-?\d+\.?\d*),(-?\d+\.?\d*)/); - if (atMatch) { lat = parseFloat(atMatch[1]); lng = parseFloat(atMatch[2]); } - - // Pattern: !3dlat!4dlng (Google Maps data params) - if (!lat) { - const dataMatch = resolvedUrl.match(/!3d(-?\d+\.?\d*)!4d(-?\d+\.?\d*)/); - if (dataMatch) { lat = parseFloat(dataMatch[1]); lng = parseFloat(dataMatch[2]); } - } - - // Pattern: ?q=lat,lng or &q=lat,lng - if (!lat) { - const qMatch = resolvedUrl.match(/[?&]q=(-?\d+\.?\d*),(-?\d+\.?\d*)/); - if (qMatch) { lat = parseFloat(qMatch[1]); lng = parseFloat(qMatch[2]); } - } - - // Extract place name from URL path: /place/Place+Name/@... - const placeMatch = resolvedUrl.match(/\/place\/([^/@]+)/); - if (placeMatch) { - placeName = decodeURIComponent(placeMatch[1].replace(/\+/g, ' ')); - } - - if (!lat || !lng || isNaN(lat) || isNaN(lng)) { - return res.status(400).json({ error: 'Could not extract coordinates from URL' }); - } - - // Reverse geocode to get address - const nominatimRes = await fetch( - `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&addressdetails=1`, - { headers: { 'User-Agent': 'TREK-Travel-Planner/1.0' }, signal: AbortSignal.timeout(8000) } - ); - const nominatim = await nominatimRes.json() as { display_name?: string; name?: string; address?: Record }; - - const name = placeName || nominatim.name || nominatim.address?.tourism || nominatim.address?.building || null; - const address = nominatim.display_name || null; - - res.json({ lat, lng, name, address }); + const result = await resolveGoogleMapsUrl(url); + res.json(result); } catch (err: unknown) { - console.error('[Maps] URL resolve error:', err instanceof Error ? err.message : err); - res.status(400).json({ error: 'Failed to resolve URL' }); + const status = (err as { status?: number }).status || 400; + const message = err instanceof Error ? err.message : 'Failed to resolve URL'; + console.error('[Maps] URL resolve error:', message); + res.status(status).json({ error: message }); } }); diff --git a/server/src/routes/notifications.ts b/server/src/routes/notifications.ts index d0c9123..d8e3d00 100644 --- a/server/src/routes/notifications.ts +++ b/server/src/routes/notifications.ts @@ -1,67 +1,36 @@ import express, { Request, Response } from 'express'; -import { db } from '../db/database'; import { authenticate } from '../middleware/auth'; import { AuthRequest } from '../types'; import { testSmtp, testWebhook } from '../services/notifications'; +import * as prefsService from '../services/notificationPreferencesService'; const router = express.Router(); -// Get user's notification preferences router.get('/preferences', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; - let prefs = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(authReq.user.id); - if (!prefs) { - db.prepare('INSERT INTO notification_preferences (user_id) VALUES (?)').run(authReq.user.id); - prefs = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(authReq.user.id); - } - res.json({ preferences: prefs }); + res.json({ preferences: prefsService.getPreferences(authReq.user.id) }); }); -// Update user's notification preferences router.put('/preferences', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { notify_trip_invite, notify_booking_change, notify_trip_reminder, notify_webhook } = req.body; - - // Ensure row exists - const existing = db.prepare('SELECT id FROM notification_preferences WHERE user_id = ?').get(authReq.user.id); - if (!existing) { - db.prepare('INSERT INTO notification_preferences (user_id) VALUES (?)').run(authReq.user.id); - } - - db.prepare(`UPDATE notification_preferences SET - notify_trip_invite = COALESCE(?, notify_trip_invite), - notify_booking_change = COALESCE(?, notify_booking_change), - notify_trip_reminder = COALESCE(?, notify_trip_reminder), - notify_webhook = COALESCE(?, notify_webhook) - WHERE user_id = ?`).run( - notify_trip_invite !== undefined ? (notify_trip_invite ? 1 : 0) : null, - notify_booking_change !== undefined ? (notify_booking_change ? 1 : 0) : null, - notify_trip_reminder !== undefined ? (notify_trip_reminder ? 1 : 0) : null, - notify_webhook !== undefined ? (notify_webhook ? 1 : 0) : null, - authReq.user.id - ); - - const prefs = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(authReq.user.id); - res.json({ preferences: prefs }); + const preferences = prefsService.updatePreferences(authReq.user.id, { + notify_trip_invite, notify_booking_change, notify_trip_reminder, notify_webhook + }); + res.json({ preferences }); }); -// Admin: test SMTP configuration router.post('/test-smtp', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; if (authReq.user.role !== 'admin') return res.status(403).json({ error: 'Admin only' }); - const { email } = req.body; - const result = await testSmtp(email || authReq.user.email); - res.json(result); + res.json(await testSmtp(email || authReq.user.email)); }); -// Admin: test webhook configuration router.post('/test-webhook', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; if (authReq.user.role !== 'admin') return res.status(403).json({ error: 'Admin only' }); - - const result = await testWebhook(); - res.json(result); + res.json(await testWebhook()); }); export default router; diff --git a/server/src/routes/oidc.ts b/server/src/routes/oidc.ts index 77b9b94..338c4bd 100644 --- a/server/src/routes/oidc.ts +++ b/server/src/routes/oidc.ts @@ -1,118 +1,24 @@ 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'; -import { decrypt_api_key } from '../services/apiKeyCrypto'; import { setAuthCookie } from '../services/cookie'; - -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; - groups?: string[]; - roles?: string[]; - [key: string]: unknown; -} +import { + getOidcConfig, + discover, + createState, + consumeState, + createAuthCode, + consumeAuthCode, + exchangeCodeForToken, + getUserInfo, + findOrCreateUser, + touchLastLogin, + generateToken, + frontendUrl, + getAppUrl, +} from '../services/oidcService'; const router = express.Router(); -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(); -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(); - -setInterval(() => { - const now = Date.now(); - for (const [state, data] of pendingStates) { - if (now - data.createdAt > STATE_TTL) pendingStates.delete(state); - } -}, STATE_CLEANUP); - -function getOidcConfig() { - const get = (key: string) => (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || null; - const issuer = process.env.OIDC_ISSUER || get('oidc_issuer'); - const clientId = process.env.OIDC_CLIENT_ID || get('oidc_client_id'); - const clientSecret = process.env.OIDC_CLIENT_SECRET || decrypt_api_key(get('oidc_client_secret')); - const displayName = process.env.OIDC_DISPLAY_NAME || get('oidc_display_name') || 'SSO'; - const discoveryUrl = process.env.OIDC_DISCOVERY_URL || get('oidc_discovery_url') || null; - if (!issuer || !clientId || !clientSecret) return null; - return { issuer: issuer.replace(/\/+$/, ''), clientId, clientSecret, displayName, discoveryUrl }; -} - -let discoveryCache: OidcDiscoveryDoc | null = null; -let discoveryCacheTime = 0; -const DISCOVERY_TTL = 60 * 60 * 1000; // 1 hour - -async function discover(issuer: string, discoveryUrl?: string | null) { - const url = discoveryUrl || `${issuer}/.well-known/openid-configuration`; - if (discoveryCache && Date.now() - discoveryCacheTime < DISCOVERY_TTL && discoveryCache._issuer === url) { - return discoveryCache; - } - const res = await fetch(url); - if (!res.ok) throw new Error('Failed to fetch OIDC discovery document'); - const doc = await res.json() as OidcDiscoveryDoc; - doc._issuer = url; - discoveryCache = doc; - discoveryCacheTime = Date.now(); - return doc; -} - -function generateToken(user: { id: number }) { - return jwt.sign( - { id: user.id }, - JWT_SECRET, - { expiresIn: '24h', algorithm: 'HS256' } - ); -} - -// Check if user should be admin based on OIDC claims -// Env: OIDC_ADMIN_CLAIM (default: "groups"), OIDC_ADMIN_VALUE (required, e.g. "app-trek-admins") -function resolveOidcRole(userInfo: OidcUserInfo, isFirstUser: boolean): 'admin' | 'user' { - if (isFirstUser) return 'admin'; - const adminValue = process.env.OIDC_ADMIN_VALUE; - if (!adminValue) return 'user'; // No claim mapping configured - const claimKey = process.env.OIDC_ADMIN_CLAIM || 'groups'; - const claimData = userInfo[claimKey]; - if (Array.isArray(claimData)) { - return claimData.some(v => String(v) === adminValue) ? 'admin' : 'user'; - } - if (typeof claimData === 'string') { - return claimData === adminValue ? 'admin' : 'user'; - } - return 'user'; -} - -function frontendUrl(path: string): string { - const base = process.env.NODE_ENV === 'production' ? '' : 'http://localhost:5173'; - return base + path; -} +// ---- GET /login ---------------------------------------------------------- router.get('/login', async (req: Request, res: Response) => { const config = getOidcConfig(); @@ -124,15 +30,14 @@ router.get('/login', async (req: Request, res: Response) => { try { const doc = await discover(config.issuer, config.discoveryUrl); - const state = crypto.randomBytes(32).toString('hex'); - const appUrl = process.env.APP_URL || (db.prepare("SELECT value FROM app_settings WHERE key = 'app_url'").get() as { value: string } | undefined)?.value; + const appUrl = getAppUrl(); if (!appUrl) { return res.status(500).json({ error: 'APP_URL is not configured. OIDC cannot be used.' }); } + const redirectUri = `${appUrl.replace(/\/+$/, '')}/api/auth/oidc/callback`; const inviteToken = req.query.invite as string | undefined; - - pendingStates.set(state, { createdAt: Date.now(), redirectUri, inviteToken }); + const state = createState(redirectUri, inviteToken); const params = new URLSearchParams({ response_type: 'code', @@ -149,6 +54,8 @@ router.get('/login', async (req: Request, res: Response) => { } }); +// ---- GET /callback ------------------------------------------------------- + router.get('/callback', async (req: Request, res: Response) => { const { code, state, error: oidcError } = req.query as { code?: string; state?: string; error?: string }; @@ -156,16 +63,14 @@ router.get('/callback', async (req: Request, res: Response) => { console.error('[OIDC] Provider error:', oidcError); return res.redirect(frontendUrl('/login?oidc_error=' + encodeURIComponent(oidcError))); } - if (!code || !state) { return res.redirect(frontendUrl('/login?oidc_error=missing_params')); } - const pending = pendingStates.get(state); + const pending = consumeState(state); if (!pending) { return res.redirect(frontendUrl('/login?oidc_error=invalid_state')); } - pendingStates.delete(state); const config = getOidcConfig(); if (!config) return res.redirect(frontendUrl('/login?oidc_error=not_configured')); @@ -177,105 +82,25 @@ router.get('/callback', async (req: Request, res: Response) => { try { const doc = await discover(config.issuer, config.discoveryUrl); - const tokenRes = await fetch(doc.token_endpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - grant_type: 'authorization_code', - code, - redirect_uri: pending.redirectUri, - client_id: config.clientId, - client_secret: config.clientSecret, - }), - }); - - const tokenData = await tokenRes.json() as OidcTokenResponse; - if (!tokenRes.ok || !tokenData.access_token) { - console.error('[OIDC] Token exchange failed: status', tokenRes.status); + const tokenData = await exchangeCodeForToken(doc, code, pending.redirectUri, config.clientId, config.clientSecret); + if (!tokenData._ok || !tokenData.access_token) { + console.error('[OIDC] Token exchange failed: status', tokenData._status); return res.redirect(frontendUrl('/login?oidc_error=token_failed')); } - const userInfoRes = await fetch(doc.userinfo_endpoint, { - headers: { Authorization: `Bearer ${tokenData.access_token}` }, - }); - const userInfo = await userInfoRes.json() as OidcUserInfo; - + const userInfo = await getUserInfo(doc.userinfo_endpoint, tokenData.access_token); if (!userInfo.email) { return res.redirect(frontendUrl('/login?oidc_error=no_email')); } - const email = userInfo.email.toLowerCase(); - const name = userInfo.name || userInfo.preferred_username || email.split('@')[0]; - const sub = userInfo.sub; - - 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) as User | undefined; + const result = findOrCreateUser(userInfo, config, pending.inviteToken); + if ('error' in result) { + return res.redirect(frontendUrl('/login?oidc_error=' + result.error)); } - if (user) { - if (!user.oidc_sub) { - db.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?').run(sub, config.issuer, user.id); - } - // Update role based on OIDC claims on every login (if claim mapping is configured) - if (process.env.OIDC_ADMIN_VALUE) { - const newRole = resolveOidcRole(userInfo, false); - if (user.role !== newRole) { - db.prepare('UPDATE users SET role = ? WHERE id = ?').run(newRole, user.id); - user = { ...user, role: newRole } as User; - } - } - } else { - const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count; - const isFirstUser = userCount === 0; - - let validInvite: any = null; - if (pending.inviteToken) { - validInvite = db.prepare('SELECT * FROM invite_tokens WHERE token = ?').get(pending.inviteToken); - if (validInvite) { - if (validInvite.max_uses > 0 && validInvite.used_count >= validInvite.max_uses) validInvite = null; - if (validInvite?.expires_at && new Date(validInvite.expires_at) < new Date()) validInvite = null; - } - } - - if (!isFirstUser && !validInvite) { - 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')); - } - } - - const role = resolveOidcRole(userInfo, isFirstUser); - const randomPass = crypto.randomBytes(32).toString('hex'); - const bcrypt = require('bcryptjs'); - const hash = bcrypt.hashSync(randomPass, 10); - - 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}`; - - const result = db.prepare( - 'INSERT INTO users (username, email, password_hash, role, oidc_sub, oidc_issuer) VALUES (?, ?, ?, ?, ?, ?)' - ).run(username, email, hash, role, sub, config.issuer); - - if (validInvite) { - const updated = db.prepare( - 'UPDATE invite_tokens SET used_count = used_count + 1 WHERE id = ? AND (max_uses = 0 OR used_count < max_uses)' - ).run(validInvite.id); - if (updated.changes === 0) { - console.warn(`[OIDC] Invite token ${pending.inviteToken?.slice(0, 8)}... exceeded max_uses (race condition)`); - } - } - - user = { id: Number(result.lastInsertRowid), username, email, role } as User; - } - - db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(user.id); - - const token = generateToken(user); - const { v4: uuidv4 } = require('uuid'); - const authCode = uuidv4(); - authCodes.set(authCode, { token, created: Date.now() }); + touchLastLogin(result.user.id); + const jwtToken = generateToken(result.user); + const authCode = createAuthCode(jwtToken); res.redirect(frontendUrl('/login?oidc_code=' + authCode)); } catch (err: unknown) { console.error('[OIDC] Callback error:', err); @@ -283,15 +108,17 @@ router.get('/callback', async (req: Request, res: Response) => { } }); +// ---- GET /exchange ------------------------------------------------------- + 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' }); - setAuthCookie(res, entry.token); - res.json({ token: entry.token }); + + const result = consumeAuthCode(code); + if ('error' in result) return res.status(400).json({ error: result.error }); + + setAuthCookie(res, result.token); + res.json({ token: result.token }); }); export default router; diff --git a/server/src/routes/packing.ts b/server/src/routes/packing.ts index bf325ab..c434ea3 100644 --- a/server/src/routes/packing.ts +++ b/server/src/routes/packing.ts @@ -1,27 +1,36 @@ import express, { Request, Response } from 'express'; -import { db, canAccessTrip } from '../db/database'; +import { db } from '../db/database'; import { authenticate } from '../middleware/auth'; import { broadcast } from '../websocket'; import { checkPermission } from '../services/permissions'; import { AuthRequest } from '../types'; +import { + verifyTripAccess, + listItems, + createItem, + updateItem, + deleteItem, + bulkImport, + listBags, + createBag, + updateBag, + deleteBag, + applyTemplate, + getCategoryAssignees, + updateCategoryAssignees, + reorderItems, +} from '../services/packingService'; const router = express.Router({ mergeParams: true }); -function verifyTripOwnership(tripId: string | number, userId: number) { - return canAccessTrip(tripId, userId); -} - router.get('/', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; - const trip = verifyTripOwnership(tripId, authReq.user.id); + const trip = verifyTripAccess(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); - const items = db.prepare( - 'SELECT * FROM packing_items WHERE trip_id = ? ORDER BY sort_order ASC, created_at ASC' - ).all(tripId); - + const items = listItems(tripId); res.json({ items }); }); @@ -29,9 +38,9 @@ router.get('/', authenticate, (req: Request, res: Response) => { router.post('/import', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; - const { items } = req.body; // [{ name, category?, quantity? }] + const { items } = req.body; - const trip = verifyTripOwnership(tripId, authReq.user.id); + const trip = verifyTripAccess(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) @@ -39,35 +48,7 @@ router.post('/import', authenticate, (req: Request, res: Response) => { if (!Array.isArray(items) || items.length === 0) return res.status(400).json({ error: 'items must be a non-empty array' }); - const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_items WHERE trip_id = ?').get(tripId) as { max: number | null }; - let sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1; - - const stmt = db.prepare('INSERT INTO packing_items (trip_id, name, checked, category, weight_grams, bag_id, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)'); - const created: any[] = []; - const insertAll = db.transaction(() => { - for (const item of items) { - if (!item.name?.trim()) continue; - const checked = item.checked ? 1 : 0; - const weight = item.weight_grams ? parseInt(item.weight_grams) || null : null; - // Resolve bag by name if provided - let bagId = null; - if (item.bag?.trim()) { - const bagName = item.bag.trim(); - const existing = db.prepare('SELECT id FROM packing_bags WHERE trip_id = ? AND name = ?').get(tripId, bagName) as { id: number } | undefined; - if (existing) { - bagId = existing.id; - } else { - const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b']; - const bagCount = (db.prepare('SELECT COUNT(*) as c FROM packing_bags WHERE trip_id = ?').get(tripId) as { c: number }).c; - const newBag = db.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(tripId, bagName, BAG_COLORS[bagCount % BAG_COLORS.length]); - bagId = newBag.lastInsertRowid; - } - } - const result = stmt.run(tripId, item.name.trim(), checked, item.category?.trim() || 'Other', weight, bagId, sortOrder++); - created.push(db.prepare('SELECT * FROM packing_items WHERE id = ?').get(result.lastInsertRowid)); - } - }); - insertAll(); + const created = bulkImport(tripId, items); res.status(201).json({ items: created, count: created.length }); for (const item of created) { @@ -80,7 +61,7 @@ router.post('/', authenticate, (req: Request, res: Response) => { const { tripId } = req.params; const { name, category, checked } = req.body; - const trip = verifyTripOwnership(tripId, authReq.user.id); + const trip = verifyTripAccess(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) @@ -88,14 +69,7 @@ router.post('/', authenticate, (req: Request, res: Response) => { 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) as { max: number | null }; - const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1; - - const result = db.prepare( - 'INSERT INTO packing_items (trip_id, name, checked, category, sort_order) VALUES (?, ?, ?, ?, ?)' - ).run(tripId, name, checked ? 1 : 0, category || 'Allgemein', sortOrder); - - const item = db.prepare('SELECT * FROM packing_items WHERE id = ?').get(result.lastInsertRowid); + const item = createItem(tripId, { name, category, checked }); res.status(201).json({ item }); broadcast(tripId, 'packing:created', { item }, req.headers['x-socket-id'] as string); }); @@ -105,36 +79,15 @@ router.put('/:id', authenticate, (req: Request, res: Response) => { const { tripId, id } = req.params; const { name, checked, category, weight_grams, bag_id } = req.body; - const trip = verifyTripOwnership(tripId, authReq.user.id); + const trip = verifyTripAccess(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); - const item = db.prepare('SELECT * FROM packing_items WHERE id = ? AND trip_id = ?').get(id, tripId); - if (!item) return res.status(404).json({ error: 'Item not found' }); + const updated = updateItem(tripId, id, { name, checked, category, weight_grams, bag_id }, Object.keys(req.body)); + if (!updated) return res.status(404).json({ error: 'Item not found' }); - db.prepare(` - UPDATE packing_items SET - name = COALESCE(?, name), - checked = CASE WHEN ? IS NOT NULL THEN ? ELSE checked END, - category = COALESCE(?, category), - weight_grams = CASE WHEN ? THEN ? ELSE weight_grams END, - bag_id = CASE WHEN ? THEN ? ELSE bag_id END - WHERE id = ? - `).run( - name || null, - checked !== undefined ? 1 : null, - checked ? 1 : 0, - category || null, - 'weight_grams' in req.body ? 1 : 0, - weight_grams ?? null, - 'bag_id' in req.body ? 1 : 0, - bag_id ?? null, - id - ); - - 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'] as string); }); @@ -143,16 +96,14 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, id } = req.params; - const trip = verifyTripOwnership(tripId, authReq.user.id); + const trip = verifyTripAccess(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); - const item = db.prepare('SELECT id FROM packing_items WHERE id = ? AND trip_id = ?').get(id, tripId); - if (!item) return res.status(404).json({ error: 'Item not found' }); + if (!deleteItem(tripId, id)) return res.status(404).json({ error: 'Item not found' }); - 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'] as string); }); @@ -162,9 +113,9 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => { router.get('/bags', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; - const trip = verifyTripOwnership(tripId, authReq.user.id); + const trip = verifyTripAccess(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); - const bags = db.prepare('SELECT * FROM packing_bags WHERE trip_id = ? ORDER BY sort_order, id').all(tripId); + const bags = listBags(tripId); res.json({ bags }); }); @@ -172,14 +123,12 @@ router.post('/bags', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; const { name, color } = req.body; - const trip = verifyTripOwnership(tripId, authReq.user.id); + const trip = verifyTripAccess(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); if (!name?.trim()) return res.status(400).json({ error: 'Name is required' }); - const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_bags WHERE trip_id = ?').get(tripId) as { max: number | null }; - const result = db.prepare('INSERT INTO packing_bags (trip_id, name, color, sort_order) VALUES (?, ?, ?, ?)').run(tripId, name.trim(), color || '#6366f1', (maxOrder.max ?? -1) + 1); - const bag = db.prepare('SELECT * FROM packing_bags WHERE id = ?').get(result.lastInsertRowid); + const bag = createBag(tripId, { name, color }); res.status(201).json({ bag }); broadcast(tripId, 'packing:bag-created', { bag }, req.headers['x-socket-id'] as string); }); @@ -188,14 +137,12 @@ router.put('/bags/:bagId', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, bagId } = req.params; const { name, color, weight_limit_grams } = req.body; - const trip = verifyTripOwnership(tripId, authReq.user.id); + const trip = verifyTripAccess(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); - const bag = db.prepare('SELECT * FROM packing_bags WHERE id = ? AND trip_id = ?').get(bagId, tripId); - if (!bag) return res.status(404).json({ error: 'Bag not found' }); - db.prepare('UPDATE packing_bags SET name = COALESCE(?, name), color = COALESCE(?, color), weight_limit_grams = ? WHERE id = ?').run(name?.trim() || null, color || null, weight_limit_grams ?? null, bagId); - const updated = db.prepare('SELECT * FROM packing_bags WHERE id = ?').get(bagId); + const updated = updateBag(tripId, bagId, { name, color, weight_limit_grams }); + if (!updated) return res.status(404).json({ error: 'Bag not found' }); res.json({ bag: updated }); broadcast(tripId, 'packing:bag-updated', { bag: updated }, req.headers['x-socket-id'] as string); }); @@ -203,13 +150,11 @@ router.put('/bags/:bagId', authenticate, (req: Request, res: Response) => { router.delete('/bags/:bagId', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, bagId } = req.params; - const trip = verifyTripOwnership(tripId, authReq.user.id); + const trip = verifyTripAccess(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); - const bag = db.prepare('SELECT * FROM packing_bags WHERE id = ? AND trip_id = ?').get(bagId, tripId); - if (!bag) return res.status(404).json({ error: 'Bag not found' }); - db.prepare('DELETE FROM packing_bags WHERE id = ?').run(bagId); + if (!deleteBag(tripId, bagId)) return res.status(404).json({ error: 'Bag not found' }); res.json({ success: true }); broadcast(tripId, 'packing:bag-deleted', { bagId: Number(bagId) }, req.headers['x-socket-id'] as string); }); @@ -220,31 +165,14 @@ router.post('/apply-template/:templateId', authenticate, (req: Request, res: Res const authReq = req as AuthRequest; const { tripId, templateId } = req.params; - const trip = verifyTripOwnership(tripId, authReq.user.id); + const trip = verifyTripAccess(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); - const templateItems = db.prepare(` - SELECT ti.name, tc.name as category - FROM packing_template_items ti - JOIN packing_template_categories tc ON ti.category_id = tc.id - WHERE tc.template_id = ? - ORDER BY tc.sort_order, ti.sort_order - `).all(templateId) as { name: string; category: string }[]; - if (templateItems.length === 0) return res.status(404).json({ error: 'Template not found or empty' }); - - const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_items WHERE trip_id = ?').get(tripId) as { max: number | null }; - let sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1; - - const insert = db.prepare('INSERT INTO packing_items (trip_id, name, checked, category, sort_order) VALUES (?, ?, 0, ?, ?)'); - const added: any[] = []; - for (const ti of templateItems) { - const result = insert.run(tripId, ti.name, ti.category, sortOrder++); - const item = db.prepare('SELECT * FROM packing_items WHERE id = ?').get(result.lastInsertRowid); - added.push(item); - } + const added = applyTemplate(tripId, templateId); + if (!added) return res.status(404).json({ error: 'Template not found or empty' }); res.json({ items: added, count: added.length }); broadcast(tripId, 'packing:template-applied', { items: added }, req.headers['x-socket-id'] as string); @@ -255,23 +183,10 @@ router.post('/apply-template/:templateId', authenticate, (req: Request, res: Res router.get('/category-assignees', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; - const trip = verifyTripOwnership(tripId, authReq.user.id); + const trip = verifyTripAccess(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); - const rows = db.prepare(` - SELECT pca.category_name, pca.user_id, u.username, u.avatar - FROM packing_category_assignees pca - JOIN users u ON pca.user_id = u.id - WHERE pca.trip_id = ? - `).all(tripId); - - // Group by category - const assignees: Record = {}; - for (const row of rows as any[]) { - if (!assignees[row.category_name]) assignees[row.category_name] = []; - assignees[row.category_name].push({ user_id: row.user_id, username: row.username, avatar: row.avatar }); - } - + const assignees = getCategoryAssignees(tripId); res.json({ assignees }); }); @@ -280,26 +195,14 @@ router.put('/category-assignees/:categoryName', authenticate, (req: Request, res const { tripId, categoryName } = req.params; const { user_ids } = req.body; - const trip = verifyTripOwnership(tripId, authReq.user.id); + const trip = verifyTripAccess(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); const cat = decodeURIComponent(categoryName); - db.prepare('DELETE FROM packing_category_assignees WHERE trip_id = ? AND category_name = ?').run(tripId, cat); - - if (Array.isArray(user_ids) && user_ids.length > 0) { - const insert = db.prepare('INSERT OR IGNORE INTO packing_category_assignees (trip_id, category_name, user_id) VALUES (?, ?, ?)'); - for (const uid of user_ids) insert.run(tripId, cat, uid); - } - - const rows = db.prepare(` - SELECT pca.user_id, u.username, u.avatar - FROM packing_category_assignees pca - JOIN users u ON pca.user_id = u.id - WHERE pca.trip_id = ? AND pca.category_name = ? - `).all(tripId, cat); + const rows = updateCategoryAssignees(tripId, cat, user_ids); res.json({ assignees: rows }); broadcast(tripId, 'packing:assignees', { category: cat, assignees: rows }, req.headers['x-socket-id'] as string); @@ -322,20 +225,13 @@ router.put('/reorder', authenticate, (req: Request, res: Response) => { const { tripId } = req.params; const { orderedIds } = req.body; - const trip = verifyTripOwnership(tripId, authReq.user.id); + const trip = verifyTripAccess(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); - const update = db.prepare('UPDATE packing_items SET sort_order = ? WHERE id = ? AND trip_id = ?'); - const updateMany = db.transaction((ids: number[]) => { - ids.forEach((id, index) => { - update.run(index, id, tripId); - }); - }); - - updateMany(orderedIds); + reorderItems(tripId, orderedIds); res.json({ success: true }); }); diff --git a/server/src/routes/places.ts b/server/src/routes/places.ts index 5dd4b10..642c95a 100644 --- a/server/src/routes/places.ts +++ b/server/src/routes/places.ts @@ -1,79 +1,37 @@ import express, { Request, Response } from 'express'; -import fetch from 'node-fetch'; import multer from 'multer'; -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 { checkPermission } from '../services/permissions'; -import { AuthRequest, Place } from '../types'; +import { AuthRequest } from '../types'; +import { + listPlaces, + createPlace, + getPlace, + updatePlace, + deletePlace, + importGpx, + importGoogleList, + searchPlaceImage, +} from '../services/placeService'; const gpxUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } }); -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 }); router.get('/', authenticate, requireTripAccess, (req: Request, res: Response) => { - const { tripId } = req.params + const { tripId } = req.params; const { search, category, tag } = req.query; - 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: (string | number)[] = [tripId]; - - if (search) { - query += ' AND (p.name LIKE ? OR p.address LIKE ? OR p.description LIKE ?)'; - const searchParam = `%${search}%`; - params.push(searchParam, searchParam, searchParam); - } - - if (category) { - query += ' AND p.category_id = ?'; - params.push(category as string); - } - - if (tag) { - query += ' AND p.id IN (SELECT place_id FROM place_tags WHERE tag_id = ?)'; - params.push(tag as string); - } - - query += ' ORDER BY p.created_at DESC'; - - const places = db.prepare(query).all(...params) as PlaceWithCategory[]; - - const placeIds = places.map(p => p.id); - const tagsByPlaceId = loadTagsByPlaceIds(placeIds); - - const placesWithTags = places.map(p => { - return { - ...p, - category: p.category_id ? { - id: p.category_id, - name: p.category_name, - color: p.category_color, - icon: p.category_icon, - } : null, - tags: tagsByPlaceId[p.id] || [], - }; + const places = listPlaces(tripId, { + search: search as string | undefined, + category: category as string | undefined, + tag: tag as string | undefined, }); - res.json({ places: placesWithTags }); + res.json({ places }); }); router.post('/', authenticate, requireTripAccess, validateStringLengths({ name: 200, description: 2000, address: 500, notes: 2000 }), (req: Request, res: Response) => { @@ -81,41 +39,14 @@ router.post('/', authenticate, requireTripAccess, validateStringLengths({ name: if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); - const { tripId } = req.params - - const { - name, description, lat, lng, address, category_id, price, currency, - place_time, end_time, - duration_minutes, notes, image_url, google_place_id, osm_id, website, phone, - transport_mode, tags = [] - } = req.body; + const { tripId } = req.params; + const { name } = req.body; if (!name) { return res.status(400).json({ error: 'Place name is required' }); } - const result = db.prepare(` - INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency, - place_time, end_time, - duration_minutes, notes, image_url, google_place_id, osm_id, website, phone, transport_mode) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run( - tripId, name, description || null, lat || null, lng || null, address || null, - category_id || null, price || null, currency || null, - place_time || null, end_time || null, duration_minutes || 60, notes || null, image_url || null, - google_place_id || null, osm_id || null, website || null, phone || null, transport_mode || 'walking' - ); - - const placeId = result.lastInsertRowid; - - if (tags && tags.length > 0) { - const insertTag = db.prepare('INSERT OR IGNORE INTO place_tags (place_id, tag_id) VALUES (?, ?)'); - for (const tagId of tags) { - insertTag.run(placeId, tagId); - } - } - - const place = getPlaceWithTags(Number(placeId)); + const place = createPlace(tripId, req.body); res.status(201).json({ place }); broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string); }); @@ -130,84 +61,11 @@ router.post('/import/gpx', authenticate, requireTripAccess, gpxUpload.single('fi const file = (req as any).file; if (!file) return res.status(400).json({ error: 'No file uploaded' }); - const xml = file.buffer.toString('utf-8'); - - const parseCoords = (attrs: string): { lat: number; lng: number } | null => { - const latMatch = attrs.match(/lat=["']([^"']+)["']/i); - const lonMatch = attrs.match(/lon=["']([^"']+)["']/i); - if (!latMatch || !lonMatch) return null; - const lat = parseFloat(latMatch[1]); - const lng = parseFloat(lonMatch[1]); - return (!isNaN(lat) && !isNaN(lng)) ? { lat, lng } : null; - }; - - const stripCdata = (s: string) => s.replace(//g, '$1').trim(); - const extractName = (body: string) => { const m = body.match(/]*>([\s\S]*?)<\/name>/i); return m ? stripCdata(m[1]) : null }; - const extractDesc = (body: string) => { const m = body.match(/]*>([\s\S]*?)<\/desc>/i); return m ? stripCdata(m[1]) : null }; - - const waypoints: { name: string; lat: number; lng: number; description: string | null; routeGeometry?: string }[] = []; - - // 1) Parse elements (named waypoints / POIs) - const wptRegex = /]+)>([\s\S]*?)<\/wpt>/gi; - let match; - while ((match = wptRegex.exec(xml)) !== null) { - const coords = parseCoords(match[1]); - if (!coords) continue; - const name = extractName(match[2]) || `Waypoint ${waypoints.length + 1}`; - waypoints.push({ ...coords, name, description: extractDesc(match[2]) }); - } - - // 2) If no , try (route points) - if (waypoints.length === 0) { - const rteptRegex = /]+)>([\s\S]*?)<\/rtept>/gi; - while ((match = rteptRegex.exec(xml)) !== null) { - const coords = parseCoords(match[1]); - if (!coords) continue; - const name = extractName(match[2]) || `Route Point ${waypoints.length + 1}`; - waypoints.push({ ...coords, name, description: extractDesc(match[2]) }); - } - } - - // 3) If still nothing, extract full track geometry from - if (waypoints.length === 0) { - const trackNameMatch = xml.match(/]*>[\s\S]*?]*>([\s\S]*?)<\/name>/i); - const trackName = trackNameMatch?.[1]?.trim() || 'GPX Track'; - const trackDesc = (() => { const m = xml.match(/]*>[\s\S]*?]*>([\s\S]*?)<\/desc>/i); return m ? stripCdata(m[1]) : null })(); - const trkptRegex = /]*?)(?:\/>|>([\s\S]*?)<\/trkpt>)/gi; - const trackPoints: { lat: number; lng: number; ele: number | null }[] = []; - while ((match = trkptRegex.exec(xml)) !== null) { - const coords = parseCoords(match[1]); - if (!coords) continue; - const eleMatch = match[2]?.match(/]*>([\s\S]*?)<\/ele>/i); - const ele = eleMatch ? parseFloat(eleMatch[1]) : null; - trackPoints.push({ ...coords, ele: (ele !== null && !isNaN(ele)) ? ele : null }); - } - if (trackPoints.length > 0) { - const start = trackPoints[0]; - const hasAllEle = trackPoints.every(p => p.ele !== null); - const routeGeometry = trackPoints.map(p => hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng]); - waypoints.push({ ...start, name: trackName, description: trackDesc, routeGeometry: JSON.stringify(routeGeometry) }); - } - } - - if (waypoints.length === 0) { + const created = importGpx(tripId, file.buffer); + if (!created) { return res.status(400).json({ error: 'No waypoints found in GPX file' }); } - const insertStmt = db.prepare(` - INSERT INTO places (trip_id, name, description, lat, lng, transport_mode, route_geometry) - VALUES (?, ?, ?, ?, ?, 'walking', ?) - `); - const created: any[] = []; - const insertAll = db.transaction(() => { - for (const wp of waypoints) { - const result = insertStmt.run(tripId, wp.name, wp.description, wp.lat, wp.lng, wp.routeGeometry || null); - const place = getPlaceWithTags(Number(result.lastInsertRowid)); - created.push(place); - } - }); - insertAll(); - res.status(201).json({ places: created, count: created.length }); for (const place of created) { broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string); @@ -225,92 +83,14 @@ router.post('/import/google-list', authenticate, requireTripAccess, async (req: if (!url || typeof url !== 'string') return res.status(400).json({ error: 'URL is required' }); try { - // Extract list ID from various Google Maps list URL formats - let listId: string | null = null; - let resolvedUrl = url; + const result = await importGoogleList(tripId, url); - // Follow redirects for short URLs (maps.app.goo.gl, goo.gl) - if (url.includes('goo.gl') || url.includes('maps.app')) { - const redirectRes = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(10000) }); - resolvedUrl = redirectRes.url; + if ('error' in result) { + return res.status(result.status).json({ error: result.error }); } - // Pattern: /placelists/list/{ID} - const plMatch = resolvedUrl.match(/placelists\/list\/([A-Za-z0-9_-]+)/); - if (plMatch) listId = plMatch[1]; - - // Pattern: !2s{ID} in data URL params - if (!listId) { - const dataMatch = resolvedUrl.match(/!2s([A-Za-z0-9_-]{15,})/); - if (dataMatch) listId = dataMatch[1]; - } - - if (!listId) { - return res.status(400).json({ error: 'Could not extract list ID from URL. Please use a shared Google Maps list link.' }); - } - - // Fetch list data from Google Maps internal API - const apiUrl = `https://www.google.com/maps/preview/entitylist/getlist?authuser=0&hl=en&gl=us&pb=!1m1!1s${encodeURIComponent(listId)}!2e2!3e2!4i500!16b1`; - const apiRes = await fetch(apiUrl, { - headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' }, - signal: AbortSignal.timeout(15000), - }); - - if (!apiRes.ok) { - return res.status(502).json({ error: 'Failed to fetch list from Google Maps' }); - } - - const rawText = await apiRes.text(); - const jsonStr = rawText.substring(rawText.indexOf('\n') + 1); - const listData = JSON.parse(jsonStr); - - const meta = listData[0]; - if (!meta) { - return res.status(400).json({ error: 'Invalid list data received from Google Maps' }); - } - - const listName = meta[4] || 'Google Maps List'; - const items = meta[8]; - - if (!Array.isArray(items) || items.length === 0) { - return res.status(400).json({ error: 'List is empty or could not be read' }); - } - - // Parse place data from items - const places: { name: string; lat: number; lng: number; notes: string | null }[] = []; - for (const item of items) { - const coords = item?.[1]?.[5]; - const lat = coords?.[2]; - const lng = coords?.[3]; - const name = item?.[2]; - const note = item?.[3] || null; - - if (name && typeof lat === 'number' && typeof lng === 'number' && !isNaN(lat) && !isNaN(lng)) { - places.push({ name, lat, lng, notes: note || null }); - } - } - - if (places.length === 0) { - return res.status(400).json({ error: 'No places with coordinates found in list' }); - } - - // Insert places into trip - const insertStmt = db.prepare(` - INSERT INTO places (trip_id, name, lat, lng, notes, transport_mode) - VALUES (?, ?, ?, ?, ?, 'walking') - `); - const created: any[] = []; - const insertAll = db.transaction(() => { - for (const p of places) { - const result = insertStmt.run(tripId, p.name, p.lat, p.lng, p.notes); - const place = getPlaceWithTags(Number(result.lastInsertRowid)); - created.push(place); - } - }); - insertAll(); - - res.status(201).json({ places: created, count: created.length, listName }); - for (const place of created) { + res.status(201).json({ places: result.places, count: result.places.length, listName: result.listName }); + for (const place of result.places) { broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string); } } catch (err: unknown) { @@ -320,52 +100,28 @@ router.post('/import/google-list', authenticate, requireTripAccess, async (req: }); router.get('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => { - const { tripId, id } = req.params + const { tripId, id } = req.params; - const placeCheck = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(id, tripId); - if (!placeCheck) { + const place = getPlace(tripId, id); + if (!place) { return res.status(404).json({ error: 'Place not found' }); } - const place = getPlaceWithTags(id); res.json({ place }); }); router.get('/:id/image', authenticate, requireTripAccess, async (req: Request, res: Response) => { const authReq = req as AuthRequest; - const { tripId, id } = req.params - - 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(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' }); - } + const { tripId, id } = req.params; try { - const query = encodeURIComponent(place.name + (place.address ? ' ' + place.address : '')); - 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() as UnsplashSearchResponse; + const result = await searchPlaceImage(tripId, id, authReq.user.id); - if (!response.ok) { - return res.status(response.status).json({ error: data.errors?.[0] || 'Unsplash API error' }); + if ('error' in result) { + return res.status(result.status).json({ error: result.error }); } - const photos = (data.results || []).map((p: NonNullable[number]) => ({ - id: p.id, - url: p.urls?.regular, - thumb: p.urls?.thumb, - description: p.description || p.alt_description, - photographer: p.user?.name, - link: p.links?.html, - })); - - res.json({ photos }); + res.json({ photos: result.photos }); } catch (err: unknown) { console.error('Unsplash error:', err); res.status(500).json({ error: 'Error searching for image' }); @@ -377,73 +133,13 @@ router.put('/:id', authenticate, requireTripAccess, validateStringLengths({ name if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); - const { tripId, id } = req.params + const { tripId, id } = req.params; - const existingPlace = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(id, tripId) as Place | undefined; - if (!existingPlace) { + const place = updatePlace(tripId, id, req.body); + if (!place) { return res.status(404).json({ error: 'Place not found' }); } - const { - name, description, lat, lng, address, category_id, price, currency, - place_time, end_time, - duration_minutes, notes, image_url, google_place_id, website, phone, - transport_mode, tags - } = req.body; - - db.prepare(` - UPDATE places SET - name = COALESCE(?, name), - description = ?, - lat = ?, - lng = ?, - address = ?, - category_id = ?, - price = ?, - currency = COALESCE(?, currency), - place_time = ?, - end_time = ?, - duration_minutes = COALESCE(?, duration_minutes), - notes = ?, - image_url = ?, - google_place_id = ?, - website = ?, - phone = ?, - transport_mode = COALESCE(?, transport_mode), - updated_at = CURRENT_TIMESTAMP - WHERE id = ? - `).run( - name || null, - description !== undefined ? description : existingPlace.description, - lat !== undefined ? lat : existingPlace.lat, - lng !== undefined ? lng : existingPlace.lng, - address !== undefined ? address : existingPlace.address, - category_id !== undefined ? category_id : existingPlace.category_id, - price !== undefined ? price : existingPlace.price, - currency || null, - place_time !== undefined ? place_time : existingPlace.place_time, - end_time !== undefined ? end_time : existingPlace.end_time, - duration_minutes || null, - notes !== undefined ? notes : existingPlace.notes, - image_url !== undefined ? image_url : existingPlace.image_url, - google_place_id !== undefined ? google_place_id : existingPlace.google_place_id, - website !== undefined ? website : existingPlace.website, - phone !== undefined ? phone : existingPlace.phone, - transport_mode || null, - id - ); - - if (tags !== undefined) { - db.prepare('DELETE FROM place_tags WHERE place_id = ?').run(id); - if (tags.length > 0) { - const insertTag = db.prepare('INSERT OR IGNORE INTO place_tags (place_id, tag_id) VALUES (?, ?)'); - for (const tagId of tags) { - insertTag.run(id, tagId); - } - } - } - - const place = getPlaceWithTags(id); res.json({ place }); broadcast(tripId, 'place:updated', { place }, req.headers['x-socket-id'] as string); }); @@ -453,14 +149,13 @@ router.delete('/:id', authenticate, requireTripAccess, (req: Request, res: Respo if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); - const { tripId, id } = req.params + const { tripId, id } = req.params; - const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(id, tripId); - if (!place) { + const deleted = deletePlace(tripId, id); + if (!deleted) { return res.status(404).json({ error: 'Place not found' }); } - 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'] as string); }); diff --git a/server/src/routes/reservations.ts b/server/src/routes/reservations.ts index f2df031..b4a5add 100644 --- a/server/src/routes/reservations.ts +++ b/server/src/routes/reservations.ts @@ -1,35 +1,29 @@ import express, { Request, Response } from 'express'; -import { db, canAccessTrip } from '../db/database'; +import { db } from '../db/database'; import { authenticate } from '../middleware/auth'; import { broadcast } from '../websocket'; import { checkPermission } from '../services/permissions'; -import { AuthRequest, Reservation } from '../types'; +import { AuthRequest } from '../types'; +import { + verifyTripAccess, + listReservations, + createReservation, + updatePositions, + getReservation, + updateReservation, + deleteReservation, +} from '../services/reservationService'; const router = express.Router({ mergeParams: true }); -function verifyTripOwnership(tripId: string | number, userId: number) { - return canAccessTrip(tripId, userId); -} - router.get('/', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; - const trip = verifyTripOwnership(tripId, authReq.user.id); + const trip = verifyTripAccess(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); - const reservations = db.prepare(` - SELECT r.*, d.day_number, p.name as place_name, r.assignment_id, - ap.place_id as accommodation_place_id, acc_p.name as accommodation_name - FROM reservations r - LEFT JOIN days d ON r.day_id = d.id - LEFT JOIN places p ON r.place_id = p.id - LEFT JOIN day_accommodations ap ON r.accommodation_id = ap.id - LEFT JOIN places acc_p ON ap.place_id = acc_p.id - WHERE r.trip_id = ? - ORDER BY r.reservation_time ASC, r.created_at ASC - `).all(tripId); - + const reservations = listReservations(tripId); res.json({ reservations }); }); @@ -38,7 +32,7 @@ router.post('/', authenticate, (req: Request, res: Response) => { const { tripId } = req.params; const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type, accommodation_id, metadata, create_accommodation } = req.body; - const trip = verifyTripOwnership(tripId, authReq.user.id); + const trip = verifyTripAccess(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); if (!checkPermission('reservation_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) @@ -46,63 +40,16 @@ router.post('/', authenticate, (req: Request, res: Response) => { if (!title) return res.status(400).json({ error: 'Title is required' }); - // Auto-create accommodation for hotel reservations - let resolvedAccommodationId = accommodation_id || null; - if (type === 'hotel' && !resolvedAccommodationId && create_accommodation) { - const { place_id: accPlaceId, start_day_id, end_day_id, check_in, check_out, confirmation: accConf } = create_accommodation; - if (accPlaceId && start_day_id && end_day_id) { - const accResult = db.prepare( - 'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)' - ).run(tripId, accPlaceId, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null); - resolvedAccommodationId = accResult.lastInsertRowid; - broadcast(tripId, 'accommodation:created', {}, req.headers['x-socket-id'] as string); - } + const { reservation, accommodationCreated } = createReservation(tripId, { + title, reservation_time, reservation_end_time, location, + confirmation_number, notes, day_id, place_id, assignment_id, + status, type, accommodation_id, metadata, create_accommodation + }); + + if (accommodationCreated) { + broadcast(tripId, 'accommodation:created', {}, req.headers['x-socket-id'] as string); } - const result = db.prepare(` - INSERT INTO reservations (trip_id, day_id, place_id, assignment_id, title, reservation_time, reservation_end_time, location, confirmation_number, notes, status, type, accommodation_id, metadata) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run( - tripId, - day_id || null, - place_id || null, - assignment_id || null, - title, - reservation_time || null, - reservation_end_time || null, - location || null, - confirmation_number || null, - notes || null, - status || 'pending', - type || 'other', - resolvedAccommodationId, - metadata ? JSON.stringify(metadata) : null - ); - - // Sync check-in/out to accommodation if linked - if (accommodation_id && metadata) { - const meta = typeof metadata === 'string' ? JSON.parse(metadata) : metadata; - if (meta.check_in_time || meta.check_out_time) { - db.prepare('UPDATE day_accommodations SET check_in = COALESCE(?, check_in), check_out = COALESCE(?, check_out) WHERE id = ?') - .run(meta.check_in_time || null, meta.check_out_time || null, accommodation_id); - } - if (confirmation_number) { - db.prepare('UPDATE day_accommodations SET confirmation = COALESCE(?, confirmation) WHERE id = ?') - .run(confirmation_number, accommodation_id); - } - } - - const reservation = db.prepare(` - SELECT r.*, d.day_number, p.name as place_name, r.assignment_id, - ap.place_id as accommodation_place_id, acc_p.name as accommodation_name - FROM reservations r - LEFT JOIN days d ON r.day_id = d.id - LEFT JOIN places p ON r.place_id = p.id - LEFT JOIN day_accommodations ap ON r.accommodation_id = ap.id - LEFT JOIN places acc_p ON ap.place_id = acc_p.id - WHERE r.id = ? - `).get(result.lastInsertRowid); - res.status(201).json({ reservation }); broadcast(tripId, 'reservation:created', { reservation }, req.headers['x-socket-id'] as string); @@ -119,7 +66,7 @@ router.put('/positions', authenticate, (req: Request, res: Response) => { const { tripId } = req.params; const { positions } = req.body; - const trip = verifyTripOwnership(tripId, authReq.user.id); + const trip = verifyTripAccess(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); if (!checkPermission('reservation_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) @@ -127,13 +74,7 @@ router.put('/positions', authenticate, (req: Request, res: Response) => { if (!Array.isArray(positions)) return res.status(400).json({ error: 'positions must be an array' }); - const stmt = db.prepare('UPDATE reservations SET day_plan_position = ? WHERE id = ? AND trip_id = ?'); - const updateMany = db.transaction((items: { id: number; day_plan_position: number }[]) => { - for (const item of items) { - stmt.run(item.day_plan_position, item.id, tripId); - } - }); - updateMany(positions); + updatePositions(tripId, positions); res.json({ success: true }); broadcast(tripId, 'reservation:positions', { positions }, req.headers['x-socket-id'] as string); @@ -144,98 +85,31 @@ router.put('/:id', authenticate, (req: Request, res: Response) => { const { tripId, id } = req.params; const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type, accommodation_id, metadata, create_accommodation } = req.body; - const trip = verifyTripOwnership(tripId, authReq.user.id); + const trip = verifyTripAccess(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); if (!checkPermission('reservation_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); - 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' }); + const current = getReservation(id, tripId); + if (!current) return res.status(404).json({ error: 'Reservation not found' }); - // Update or create accommodation for hotel reservations - let resolvedAccId = accommodation_id !== undefined ? (accommodation_id || null) : reservation.accommodation_id; - if (type === 'hotel' && create_accommodation) { - const { place_id: accPlaceId, start_day_id, end_day_id, check_in, check_out, confirmation: accConf } = create_accommodation; - if (accPlaceId && start_day_id && end_day_id) { - if (resolvedAccId) { - db.prepare('UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ? WHERE id = ?') - .run(accPlaceId, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null, resolvedAccId); - } else { - const accResult = db.prepare( - 'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)' - ).run(tripId, accPlaceId, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null); - resolvedAccId = accResult.lastInsertRowid; - } - broadcast(tripId, 'accommodation:updated', {}, req.headers['x-socket-id'] as string); - } + const { reservation, accommodationChanged } = updateReservation(id, tripId, { + title, reservation_time, reservation_end_time, location, + confirmation_number, notes, day_id, place_id, assignment_id, + status, type, accommodation_id, metadata, create_accommodation + }, current); + + if (accommodationChanged) { + broadcast(tripId, 'accommodation:updated', {}, req.headers['x-socket-id'] as string); } - db.prepare(` - UPDATE reservations SET - title = COALESCE(?, title), - reservation_time = ?, - reservation_end_time = ?, - location = ?, - confirmation_number = ?, - notes = ?, - day_id = ?, - place_id = ?, - assignment_id = ?, - status = COALESCE(?, status), - type = COALESCE(?, type), - accommodation_id = ?, - metadata = ? - WHERE id = ? - `).run( - title || null, - reservation_time !== undefined ? (reservation_time || null) : reservation.reservation_time, - reservation_end_time !== undefined ? (reservation_end_time || null) : reservation.reservation_end_time, - location !== undefined ? (location || null) : reservation.location, - confirmation_number !== undefined ? (confirmation_number || null) : reservation.confirmation_number, - notes !== undefined ? (notes || null) : reservation.notes, - day_id !== undefined ? (day_id || null) : reservation.day_id, - place_id !== undefined ? (place_id || null) : reservation.place_id, - assignment_id !== undefined ? (assignment_id || null) : reservation.assignment_id, - status || null, - type || null, - resolvedAccId, - metadata !== undefined ? (metadata ? JSON.stringify(metadata) : null) : reservation.metadata, - id - ); - - // Sync check-in/out to accommodation if linked - const resolvedMeta = metadata !== undefined ? metadata : (reservation.metadata ? JSON.parse(reservation.metadata as string) : null); - if (resolvedAccId && resolvedMeta) { - const meta = typeof resolvedMeta === 'string' ? JSON.parse(resolvedMeta) : resolvedMeta; - if (meta.check_in_time || meta.check_out_time) { - db.prepare('UPDATE day_accommodations SET check_in = COALESCE(?, check_in), check_out = COALESCE(?, check_out) WHERE id = ?') - .run(meta.check_in_time || null, meta.check_out_time || null, resolvedAccId); - } - const resolvedConf = confirmation_number !== undefined ? confirmation_number : reservation.confirmation_number; - if (resolvedConf) { - db.prepare('UPDATE day_accommodations SET confirmation = COALESCE(?, confirmation) WHERE id = ?') - .run(resolvedConf, resolvedAccId); - } - } - - const updated = db.prepare(` - SELECT r.*, d.day_number, p.name as place_name, r.assignment_id, - ap.place_id as accommodation_place_id, acc_p.name as accommodation_name - FROM reservations r - LEFT JOIN days d ON r.day_id = d.id - LEFT JOIN places p ON r.place_id = p.id - LEFT JOIN day_accommodations ap ON r.accommodation_id = ap.id - LEFT JOIN places acc_p ON ap.place_id = acc_p.id - WHERE r.id = ? - `).get(id); - - res.json({ reservation: updated }); - broadcast(tripId, 'reservation:updated', { reservation: updated }, req.headers['x-socket-id'] as string); + res.json({ reservation }); + broadcast(tripId, 'reservation:updated', { reservation }, req.headers['x-socket-id'] as string); import('../services/notifications').then(({ notifyTripMembers }) => { const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined; - notifyTripMembers(Number(tripId), authReq.user.id, 'booking_change', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, booking: title || reservation.title, type: type || reservation.type || 'booking' }).catch(() => {}); + notifyTripMembers(Number(tripId), authReq.user.id, 'booking_change', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, booking: title || current.title, type: type || current.type || 'booking' }).catch(() => {}); }); }); @@ -243,21 +117,19 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, id } = req.params; - const trip = verifyTripOwnership(tripId, authReq.user.id); + const trip = verifyTripAccess(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); if (!checkPermission('reservation_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); - const reservation = db.prepare('SELECT id, title, type, accommodation_id FROM reservations WHERE id = ? AND trip_id = ?').get(id, tripId) as { id: number; title: string; type: string; accommodation_id: number | null } | undefined; + const { deleted: reservation, accommodationDeleted } = deleteReservation(id, tripId); if (!reservation) return res.status(404).json({ error: 'Reservation not found' }); - if (reservation.accommodation_id) { - db.prepare('DELETE FROM day_accommodations WHERE id = ?').run(reservation.accommodation_id); + if (accommodationDeleted) { broadcast(tripId, 'accommodation:deleted', { accommodationId: reservation.accommodation_id }, req.headers['x-socket-id'] as string); } - 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'] as string); diff --git a/server/src/routes/settings.ts b/server/src/routes/settings.ts index 06b6d6a..0d51e69 100644 --- a/server/src/routes/settings.ts +++ b/server/src/routes/settings.ts @@ -1,67 +1,35 @@ import express, { Request, Response } from 'express'; -import { db } from '../db/database'; import { authenticate } from '../middleware/auth'; import { AuthRequest } from '../types'; +import * as settingsService from '../services/settingsService'; const router = express.Router(); 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 = {}; - for (const row of rows) { - try { - settings[row.key] = JSON.parse(row.value); - } catch { - settings[row.key] = row.value; - } - } - res.json({ settings }); + res.json({ settings: settingsService.getUserSettings(authReq.user.id) }); }); 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' }); - - const serialized = typeof value === 'object' ? JSON.stringify(value) : String(value !== undefined ? value : ''); - - db.prepare(` - INSERT INTO settings (user_id, key, value) VALUES (?, ?, ?) - ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value - `).run(authReq.user.id, key, serialized); - + settingsService.upsertSetting(authReq.user.id, key, value); res.json({ success: true, key, value }); }); router.post('/bulk', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { settings } = req.body; - - if (!settings || typeof settings !== 'object') { + if (!settings || typeof settings !== 'object') return res.status(400).json({ error: 'Settings object is required' }); - } - - const upsert = db.prepare(` - INSERT INTO settings (user_id, key, value) VALUES (?, ?, ?) - ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value - `); - try { - 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(authReq.user.id, key, serialized); - } - db.exec('COMMIT'); - } catch (err: unknown) { - db.exec('ROLLBACK'); + const updated = settingsService.bulkUpsertSettings(authReq.user.id, settings); + res.json({ success: true, updated }); + } catch (err) { console.error('Error saving settings:', err); - return res.status(500).json({ error: 'Error saving settings' }); + res.status(500).json({ error: 'Error saving settings' }); } - - res.json({ success: true, updated: Object.keys(settings).length }); }); export default router; diff --git a/server/src/routes/share.ts b/server/src/routes/share.ts index 87f962a..9e8145f 100644 --- a/server/src/routes/share.ts +++ b/server/src/routes/share.ts @@ -1,10 +1,9 @@ import express, { Request, Response } from 'express'; -import crypto from 'crypto'; -import { db, canAccessTrip } from '../db/database'; +import { canAccessTrip } from '../db/database'; import { authenticate } from '../middleware/auth'; import { checkPermission } from '../services/permissions'; import { AuthRequest } from '../types'; -import { loadTagsByPlaceIds } from '../services/queryHelpers'; +import * as shareService from '../services/shareService'; const router = express.Router(); @@ -17,21 +16,15 @@ router.post('/trips/:tripId/share-link', authenticate, (req: Request, res: Respo if (!checkPermission('share_manage', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); - const { share_map = true, share_bookings = true, share_packing = false, share_budget = false, share_collab = false } = req.body || {}; + const { share_map, share_bookings, share_packing, share_budget, share_collab } = req.body || {}; + const result = shareService.createOrUpdateShareLink(tripId, authReq.user.id, { + share_map, share_bookings, share_packing, share_budget, share_collab, + }); - // Check if token already exists - const existing = db.prepare('SELECT token FROM share_tokens WHERE trip_id = ?').get(tripId) as { token: string } | undefined; - if (existing) { - // Update permissions - db.prepare('UPDATE share_tokens SET share_map = ?, share_bookings = ?, share_packing = ?, share_budget = ?, share_collab = ? WHERE trip_id = ?') - .run(share_map ? 1 : 0, share_bookings ? 1 : 0, share_packing ? 1 : 0, share_budget ? 1 : 0, share_collab ? 1 : 0, tripId); - return res.json({ token: existing.token }); + if (result.created) { + return res.status(201).json({ token: result.token }); } - - const token = crypto.randomBytes(24).toString('base64url'); - db.prepare('INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab) VALUES (?, ?, ?, ?, ?, ?, ?, ?)') - .run(tripId, token, authReq.user.id, share_map ? 1 : 0, share_bookings ? 1 : 0, share_packing ? 1 : 0, share_budget ? 1 : 0, share_collab ? 1 : 0); - res.status(201).json({ token }); + return res.json({ token: result.token }); }); // Get share link status @@ -40,8 +33,8 @@ router.get('/trips/:tripId/share-link', authenticate, (req: Request, res: Respon const { tripId } = req.params; if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); - const row = db.prepare('SELECT * FROM share_tokens WHERE trip_id = ?').get(tripId) as any; - res.json(row ? { token: row.token, created_at: row.created_at, share_map: !!row.share_map, share_bookings: !!row.share_bookings, share_packing: !!row.share_packing, share_budget: !!row.share_budget, share_collab: !!row.share_collab } : { token: null }); + const info = shareService.getShareLink(tripId); + res.json(info ? info : { token: null }); }); // Delete share link @@ -53,120 +46,16 @@ router.delete('/trips/:tripId/share-link', authenticate, (req: Request, res: Res if (!checkPermission('share_manage', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); - db.prepare('DELETE FROM share_tokens WHERE trip_id = ?').run(tripId); + shareService.deleteShareLink(tripId); res.json({ success: true }); }); // Public read-only trip data (no auth required) router.get('/shared/:token', (req: Request, res: Response) => { const { token } = req.params; - const shareRow = db.prepare('SELECT * FROM share_tokens WHERE token = ?').get(token) as any; - if (!shareRow) return res.status(404).json({ error: 'Invalid or expired link' }); - - const tripId = shareRow.trip_id; - - // Trip - const trip = db.prepare('SELECT id, title, description, start_date, end_date, cover_image, currency FROM trips WHERE id = ?').get(tripId); - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - - // Days with assignments - const days = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(tripId) as any[]; - const dayIds = days.map(d => d.id); - - let assignments = {}; - let dayNotes = {}; - if (dayIds.length > 0) { - const ph = dayIds.map(() => '?').join(','); - 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, - COALESCE(da.assignment_time, p.place_time) as place_time, - COALESCE(da.assignment_end_time, p.end_time) as end_time, - p.duration_minutes, p.notes as place_notes, p.image_url, p.transport_mode, - c.name as category_name, c.color as category_color, c.icon as category_icon - FROM day_assignments da - JOIN places p ON da.place_id = p.id - LEFT JOIN categories c ON p.category_id = c.id - WHERE da.day_id IN (${ph}) - ORDER BY da.order_index ASC - `).all(...dayIds); - - const placeIds = [...new Set(allAssignments.map((a: any) => a.place_id))]; - const tagsByPlace = loadTagsByPlaceIds(placeIds, { compact: true }); - - const byDay: Record = {}; - for (const a of allAssignments as any[]) { - if (!byDay[a.day_id]) byDay[a.day_id] = []; - byDay[a.day_id].push({ - id: a.id, day_id: a.day_id, order_index: a.order_index, notes: a.notes, - 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, place_time: a.place_time, end_time: a.end_time, - image_url: a.image_url, transport_mode: a.transport_mode, - category: a.category_id ? { id: a.category_id, name: a.category_name, color: a.category_color, icon: a.category_icon } : null, - tags: tagsByPlace[a.place_id] || [], - } - }); - } - assignments = byDay; - - const allNotes = db.prepare(`SELECT * FROM day_notes WHERE day_id IN (${ph}) ORDER BY sort_order ASC`).all(...dayIds); - const notesByDay: Record = {}; - for (const n of allNotes as any[]) { - if (!notesByDay[n.day_id]) notesByDay[n.day_id] = []; - notesByDay[n.day_id].push(n); - } - dayNotes = notesByDay; - } - - // Places - const places = 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.trip_id = ? ORDER BY p.created_at DESC - `).all(tripId); - - // Reservations - const reservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ? ORDER BY reservation_time ASC').all(tripId); - - // Accommodations - const accommodations = db.prepare(` - SELECT a.*, p.name as place_name, p.address as place_address, p.lat as place_lat, p.lng as place_lng - FROM day_accommodations a JOIN places p ON a.place_id = p.id - WHERE a.trip_id = ? - `).all(tripId); - - // Packing - const packing = db.prepare('SELECT * FROM packing_items WHERE trip_id = ? ORDER BY sort_order ASC').all(tripId); - - // Budget - const budget = db.prepare('SELECT * FROM budget_items WHERE trip_id = ? ORDER BY category ASC').all(tripId); - - // Categories - const categories = db.prepare('SELECT * FROM categories').all(); - - const permissions = { - share_map: !!shareRow.share_map, - share_bookings: !!shareRow.share_bookings, - share_packing: !!shareRow.share_packing, - share_budget: !!shareRow.share_budget, - share_collab: !!shareRow.share_collab, - }; - - // Only include data the owner chose to share - const collabMessages = permissions.share_collab - ? db.prepare('SELECT m.*, u.username, u.avatar FROM collab_messages m JOIN users u ON m.user_id = u.id WHERE m.trip_id = ? ORDER BY m.created_at ASC').all(tripId) - : []; - - res.json({ - trip, days, assignments, dayNotes, places, categories, permissions, - reservations: permissions.share_bookings ? reservations : [], - accommodations: permissions.share_bookings ? accommodations : [], - packing: permissions.share_packing ? packing : [], - budget: permissions.share_budget ? budget : [], - collab: collabMessages, - }); + const data = shareService.getSharedTripData(token); + if (!data) return res.status(404).json({ error: 'Invalid or expired link' }); + res.json(data); }); export default router; diff --git a/server/src/routes/tags.ts b/server/src/routes/tags.ts index 0398631..6087f7a 100644 --- a/server/src/routes/tags.ts +++ b/server/src/routes/tags.ts @@ -1,52 +1,37 @@ import express, { Request, Response } from 'express'; -import { db } from '../db/database'; import { authenticate } from '../middleware/auth'; import { AuthRequest } from '../types'; +import * as tagService from '../services/tagService'; const router = express.Router(); 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(authReq.user.id); - res.json({ tags }); + res.json({ tags: tagService.listTags(authReq.user.id) }); }); 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(authReq.user.id, name, color || '#10b981'); - - const tag = db.prepare('SELECT * FROM tags WHERE id = ?').get(result.lastInsertRowid); + const tag = tagService.createTag(authReq.user.id, name, color); res.status(201).json({ tag }); }); 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, authReq.user.id); - - if (!tag) return res.status(404).json({ error: 'Tag not found' }); - - db.prepare('UPDATE tags SET name = COALESCE(?, name), color = COALESCE(?, color) WHERE id = ?') - .run(name || null, color || null, req.params.id); - - const updated = db.prepare('SELECT * FROM tags WHERE id = ?').get(req.params.id); - res.json({ tag: updated }); + if (!tagService.getTagByIdAndUser(req.params.id, authReq.user.id)) + return res.status(404).json({ error: 'Tag not found' }); + const tag = tagService.updateTag(req.params.id, name, color); + res.json({ tag }); }); 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); + if (!tagService.getTagByIdAndUser(req.params.id, authReq.user.id)) + return res.status(404).json({ error: 'Tag not found' }); + tagService.deleteTag(req.params.id); res.json({ success: true }); }); diff --git a/server/src/routes/trips.ts b/server/src/routes/trips.ts index 651a479..785cf72 100644 --- a/server/src/routes/trips.ts +++ b/server/src/routes/trips.ts @@ -3,17 +3,33 @@ 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 { canAccessTrip } from '../db/database'; import { authenticate, demoUploadBlock } from '../middleware/auth'; import { broadcast } from '../websocket'; -import { AuthRequest, Trip, User } from '../types'; +import { AuthRequest } from '../types'; import { writeAudit, getClientIp, logInfo } from '../services/auditLog'; import { checkPermission } from '../services/permissions'; +import { + listTrips, + createTrip, + getTrip, + updateTrip, + deleteTrip, + getTripRaw, + getTripOwner, + deleteOldCover, + updateCoverImage, + listMembers, + addMember, + removeMember, + exportICS, + verifyTripAccess, + NotFoundError, + ValidationError, +} from '../services/tripService'; const router = express.Router(); -const MS_PER_DAY = 86400000; -const MAX_TRIP_DAYS = 365; const MAX_COVER_SIZE = 20 * 1024 * 1024; // 20 MB const coversDir = path.join(__dirname, '../../uploads/covers'); @@ -41,138 +57,48 @@ const uploadCover = multer({ }, }); -const TRIP_SELECT = ` - SELECT t.*, - (SELECT COUNT(*) FROM days d WHERE d.trip_id = t.id) as day_count, - (SELECT COUNT(*) FROM places p WHERE p.trip_id = t.id) as place_count, - CASE WHEN t.user_id = :userId THEN 1 ELSE 0 END as is_owner, - u.username as owner_username, - (SELECT COUNT(*) FROM trip_members tm WHERE tm.trip_id = t.id) as shared_count - FROM trips t - JOIN users u ON u.id = t.user_id -`; - -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) { - const datelessExisting = existing.filter(d => !d.date).sort((a, b) => a.day_number - b.day_number); - 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); - } - 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) { - const toRemove = datelessExisting.slice(7); - const del = db.prepare('DELETE FROM days WHERE id = ?'); - for (const d of toRemove) del.run(d.id); - } - 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; - } - - 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) / MS_PER_DAY) + 1, MAX_TRIP_DAYS); - - const targetDates: string[] = []; - for (let i = 0; i < numDays; i++) { - 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}`); - } - - const existingByDate = new Map(); - for (const d of existing) { - if (d.date) existingByDate.set(d.date, d); - } - - const targetDateSet = new Set(targetDates); - - const toDelete = existing.filter(d => d.date && !targetDateSet.has(d.date)); - 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); - - 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); - - const insert = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)'); - const update = db.prepare('UPDATE days SET day_number = ? WHERE id = ?'); - - for (let i = 0; i < targetDates.length; i++) { - const date = targetDates[i]; - const ex = existingByDate.get(date); - if (ex) { - update.run(i + 1, ex.id); - } else { - insert.run(tripId, i + 1, date); - } - } -} +// ── List trips ──────────────────────────────────────────────────────────── router.get('/', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const archived = req.query.archived === '1' ? 1 : 0; - 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 - WHERE (t.user_id = :userId OR m.user_id IS NOT NULL) AND t.is_archived = :archived - ORDER BY t.created_at DESC - `).all({ userId, archived }); + const trips = listTrips(authReq.user.id, archived); res.json({ trips }); }); +// ── Create trip ─────────────────────────────────────────────────────────── + router.post('/', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; if (!checkPermission('trip_create', authReq.user.role, null, authReq.user.id, false)) return res.status(403).json({ error: 'No permission to create trips' }); + const { title, description, start_date, end_date, currency, reminder_days } = 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)) return res.status(400).json({ error: 'End date must be after start date' }); - const rd = reminder_days !== undefined ? (Number(reminder_days) >= 0 && Number(reminder_days) <= 30 ? Number(reminder_days) : 3) : 3; + const { trip, tripId, reminderDays } = createTrip(authReq.user.id, { title, description, start_date, end_date, currency, reminder_days }); - const result = db.prepare(` - INSERT INTO trips (user_id, title, description, start_date, end_date, currency, reminder_days) - VALUES (?, ?, ?, ?, ?, ?, ?) - `).run(authReq.user.id, title, description || null, start_date || null, end_date || null, currency || 'EUR', rd); - - const tripId = result.lastInsertRowid; - generateDays(tripId, start_date, end_date); - writeAudit({ userId: authReq.user.id, action: 'trip.create', ip: getClientIp(req), details: { tripId: Number(tripId), title, reminder_days: rd === 0 ? 'none' : `${rd} days` } }); - if (rd > 0) { - logInfo(`${authReq.user.email} set ${rd}-day reminder for trip "${title}"`); + writeAudit({ userId: authReq.user.id, action: 'trip.create', ip: getClientIp(req), details: { tripId, title, reminder_days: reminderDays === 0 ? 'none' : `${reminderDays} days` } }); + if (reminderDays > 0) { + logInfo(`${authReq.user.email} set ${reminderDays}-day reminder for trip "${title}"`); } - const trip = db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId: authReq.user.id, tripId }); + res.status(201).json({ trip }); }); +// ── Get trip ────────────────────────────────────────────────────────────── + 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 - WHERE t.id = :tripId AND (t.user_id = :userId OR m.user_id IS NOT NULL) - `).get({ userId, tripId: req.params.id }); + const trip = getTrip(req.params.id, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); res.json({ trip }); }); +// ── Update trip ─────────────────────────────────────────────────────────── + router.put('/:id', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const access = canAccessTrip(req.params.id, authReq.user.id); @@ -198,60 +124,35 @@ router.put('/:id', authenticate, (req: Request, res: Response) => { return res.status(403).json({ error: 'No permission to edit this trip' }); } - 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, reminder_days } = req.body; + try { + const result = updateTrip(req.params.id, authReq.user.id, req.body, authReq.user.role); - if (start_date && end_date && new Date(end_date) < new Date(start_date)) - return res.status(400).json({ error: 'End date must be after start date' }); - - const newTitle = title || trip.title; - const newDesc = description !== undefined ? description : trip.description; - const newStart = start_date !== undefined ? start_date : trip.start_date; - const newEnd = end_date !== undefined ? end_date : trip.end_date; - const newCurrency = currency || trip.currency; - const newArchived = is_archived !== undefined ? (is_archived ? 1 : 0) : trip.is_archived; - const newCover = cover_image !== undefined ? cover_image : trip.cover_image; - const newReminder = reminder_days !== undefined ? (Number(reminder_days) >= 0 && Number(reminder_days) <= 30 ? Number(reminder_days) : (trip as any).reminder_days) : (trip as any).reminder_days; - - db.prepare(` - UPDATE trips SET title=?, description=?, start_date=?, end_date=?, - currency=?, is_archived=?, cover_image=?, reminder_days=?, updated_at=CURRENT_TIMESTAMP - WHERE id=? - `).run(newTitle, newDesc, newStart || null, newEnd || null, newCurrency, newArchived, newCover, newReminder, req.params.id); - - if (newStart !== trip.start_date || newEnd !== trip.end_date) - generateDays(req.params.id, newStart, newEnd); - - const changes: Record = {}; - if (title && title !== trip.title) changes.title = title; - if (newStart !== trip.start_date) changes.start_date = newStart; - if (newEnd !== trip.end_date) changes.end_date = newEnd; - if (newReminder !== (trip as any).reminder_days) changes.reminder_days = newReminder === 0 ? 'none' : `${newReminder} days`; - if (is_archived !== undefined && newArchived !== trip.is_archived) changes.archived = !!newArchived; - - const isAdminEdit = authReq.user.role === 'admin' && trip.user_id !== authReq.user.id; - if (Object.keys(changes).length > 0) { - const ownerEmail = isAdminEdit ? (db.prepare('SELECT email FROM users WHERE id = ?').get(trip.user_id) as { email: string } | undefined)?.email : undefined; - writeAudit({ userId: authReq.user.id, action: 'trip.update', ip: getClientIp(req), details: { tripId: Number(req.params.id), trip: newTitle, ...(ownerEmail ? { owner: ownerEmail } : {}), ...changes } }); - if (isAdminEdit && ownerEmail) { - logInfo(`Admin ${authReq.user.email} edited trip "${newTitle}" owned by ${ownerEmail}`); + if (Object.keys(result.changes).length > 0) { + writeAudit({ userId: authReq.user.id, action: 'trip.update', ip: getClientIp(req), details: { tripId: Number(req.params.id), trip: result.newTitle, ...(result.ownerEmail ? { owner: result.ownerEmail } : {}), ...result.changes } }); + if (result.isAdminEdit && result.ownerEmail) { + logInfo(`Admin ${authReq.user.email} edited trip "${result.newTitle}" owned by ${result.ownerEmail}`); + } } - } - if (newReminder !== (trip as any).reminder_days) { - if (newReminder > 0) { - logInfo(`${authReq.user.email} set ${newReminder}-day reminder for trip "${newTitle}"`); - } else { - logInfo(`${authReq.user.email} removed reminder for trip "${newTitle}"`); + if (result.newReminder !== result.oldReminder) { + if (result.newReminder > 0) { + logInfo(`${authReq.user.email} set ${result.newReminder}-day reminder for trip "${result.newTitle}"`); + } else { + logInfo(`${authReq.user.email} removed reminder for trip "${result.newTitle}"`); + } } - } - 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'] as string); + res.json({ trip: result.updatedTrip }); + broadcast(req.params.id, 'trip:updated', { trip: result.updatedTrip }, req.headers['x-socket-id'] as string); + } catch (e: any) { + if (e instanceof NotFoundError) return res.status(404).json({ error: e.message }); + if (e instanceof ValidationError) return res.status(400).json({ error: e.message }); + throw e; + } }); +// ── Cover upload ────────────────────────────────────────────────────────── + router.post('/:id/cover', authenticate, demoUploadBlock, uploadCover.single('cover'), (req: Request, res: Response) => { const authReq = req as AuthRequest; const access = canAccessTrip(req.params.id, authReq.user.id); @@ -261,73 +162,53 @@ router.post('/:id/cover', authenticate, demoUploadBlock, uploadCover.single('cov if (!checkPermission('trip_cover_upload', authReq.user.role, tripOwnerId, authReq.user.id, isMember)) return res.status(403).json({ error: 'No permission to change the cover image' }); - const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(req.params.id) as Trip | undefined; + const trip = getTripRaw(req.params.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); if (!req.file) return res.status(400).json({ error: 'No image uploaded' }); - if (trip.cover_image) { - const oldPath = path.join(__dirname, '../../', trip.cover_image.replace(/^\//, '')); - const resolvedPath = path.resolve(oldPath); - const uploadsDir = path.resolve(__dirname, '../../uploads'); - if (resolvedPath.startsWith(uploadsDir) && fs.existsSync(resolvedPath)) { - fs.unlinkSync(resolvedPath); - } - } + deleteOldCover(trip.cover_image); const coverUrl = `/uploads/covers/${req.file.filename}`; - db.prepare('UPDATE trips SET cover_image=?, updated_at=CURRENT_TIMESTAMP WHERE id=?').run(coverUrl, req.params.id); + updateCoverImage(req.params.id, coverUrl); res.json({ cover_image: coverUrl }); }); +// ── Delete trip ─────────────────────────────────────────────────────────── + router.delete('/:id', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; - const trip = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(req.params.id) as { user_id: number } | undefined; - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - const tripOwnerId = trip.user_id; + const tripOwner = getTripOwner(req.params.id); + if (!tripOwner) return res.status(404).json({ error: 'Trip not found' }); + const tripOwnerId = tripOwner.user_id; const isMemberDel = tripOwnerId !== authReq.user.id; if (!checkPermission('trip_delete', authReq.user.role, tripOwnerId, authReq.user.id, isMemberDel)) return res.status(403).json({ error: 'No permission to delete this trip' }); - const deletedTripId = Number(req.params.id); - const delTrip = db.prepare('SELECT title, user_id FROM trips WHERE id = ?').get(req.params.id) as { title: string; user_id: number } | undefined; - const isAdminDel = authReq.user.role === 'admin' && delTrip && delTrip.user_id !== authReq.user.id; - const ownerEmail = isAdminDel ? (db.prepare('SELECT email FROM users WHERE id = ?').get(delTrip!.user_id) as { email: string } | undefined)?.email : undefined; - writeAudit({ userId: authReq.user.id, action: 'trip.delete', ip: getClientIp(req), details: { tripId: deletedTripId, trip: delTrip?.title, ...(ownerEmail ? { owner: ownerEmail } : {}) } }); - if (isAdminDel && ownerEmail) { - logInfo(`Admin ${authReq.user.email} deleted trip "${delTrip!.title}" owned by ${ownerEmail}`); + + const info = deleteTrip(req.params.id, authReq.user.id, authReq.user.role); + + writeAudit({ userId: authReq.user.id, action: 'trip.delete', ip: getClientIp(req), details: { tripId: info.tripId, trip: info.title, ...(info.ownerEmail ? { owner: info.ownerEmail } : {}) } }); + if (info.isAdminDelete && info.ownerEmail) { + logInfo(`Admin ${authReq.user.email} deleted trip "${info.title}" owned by ${info.ownerEmail}`); } - 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'] as string); + broadcast(info.tripId, 'trip:deleted', { id: info.tripId }, req.headers['x-socket-id'] as string); }); +// ── List members ────────────────────────────────────────────────────────── + router.get('/:id/members', 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 tripOwnerId = access.user_id; - const members = db.prepare(` - SELECT u.id, u.username, u.email, u.avatar, - CASE WHEN u.id = ? THEN 'owner' ELSE 'member' END as role, - m.added_at, - ib.username as invited_by_username - FROM trip_members m - JOIN users u ON u.id = m.user_id - LEFT JOIN users ib ON ib.id = m.invited_by - WHERE m.trip_id = ? - ORDER BY m.added_at ASC - `).all(tripOwnerId, 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(tripOwnerId) as Pick; - - 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: authReq.user.id, - }); + const { owner, members } = listMembers(req.params.id, access.user_id); + res.json({ owner, members, current_user_id: authReq.user.id }); }); +// ── Add member ──────────────────────────────────────────────────────────── + router.post('/:id/members', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const access = canAccessTrip(req.params.id, authReq.user.id); @@ -340,31 +221,25 @@ router.post('/:id/members', authenticate, (req: Request, res: Response) => { return res.status(403).json({ error: 'No permission to manage members' }); 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()) as Pick | undefined; + try { + const result = addMember(req.params.id, identifier, tripOwnerId, authReq.user.id); - if (!target) return res.status(404).json({ error: 'User not found' }); + // Notify invited user + import('../services/notifications').then(({ notify }) => { + notify({ userId: result.targetUserId, event: 'trip_invite', params: { trip: result.tripTitle, actor: authReq.user.email, invitee: result.member.email } }).catch(() => {}); + }); - if (target.id === tripOwnerId) - 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, authReq.user.id); - - // Notify invited user - const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(req.params.id) as { title: string } | undefined; - import('../services/notifications').then(({ notify }) => { - notify({ userId: target.id, event: 'trip_invite', params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, invitee: target.email } }).catch(() => {}); - }); - - res.status(201).json({ member: { ...target, role: 'member', avatar_url: target.avatar ? `/uploads/avatars/${target.avatar}` : null } }); + res.status(201).json({ member: result.member }); + } catch (e: any) { + if (e instanceof NotFoundError) return res.status(404).json({ error: e.message }); + if (e instanceof ValidationError) return res.status(400).json({ error: e.message }); + throw e; + } }); +// ── Remove member ───────────────────────────────────────────────────────── + router.delete('/:id/members/:userId', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; if (!canAccessTrip(req.params.id, authReq.user.id)) @@ -380,93 +255,27 @@ router.delete('/:id/members/:userId', authenticate, (req: Request, res: Response return res.status(403).json({ error: 'No permission to remove members' }); } - db.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(req.params.id, targetId); + removeMember(req.params.id, targetId); res.json({ success: true }); }); -// ICS calendar export +// ── ICS calendar export ─────────────────────────────────────────────────── + router.get('/:id/export.ics', 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 * FROM trips WHERE id = ?').get(req.params.id) as any; - if (!trip) return res.status(404).json({ error: 'Trip not found' }); + try { + const { ics, filename } = exportICS(req.params.id); - const days = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(req.params.id) as any[]; - const reservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ?').all(req.params.id) as any[]; - - const esc = (s: string) => s - .replace(/\\/g, '\\\\') - .replace(/;/g, '\\;') - .replace(/,/g, '\\,') - .replace(/\r?\n/g, '\\n') - .replace(/\r/g, ''); - const fmtDate = (d: string) => d.replace(/-/g, ''); - const now = new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'; - const uid = (id: number, type: string) => `trek-${type}-${id}@trek`; - - // Format datetime: handles full ISO "2026-03-30T09:00" and time-only "10:00" - const fmtDateTime = (d: string, refDate?: string) => { - if (d.includes('T')) return d.replace(/[-:]/g, '').split('.')[0]; - // Time-only: combine with reference date - if (refDate && d.match(/^\d{2}:\d{2}/)) { - const datePart = refDate.split('T')[0]; - return `${datePart}T${d.replace(/:/g, '')}00`.replace(/-/g, ''); - } - return d.replace(/[-:]/g, ''); - }; - - let ics = 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//TREK//Travel Planner//EN\r\nCALSCALE:GREGORIAN\r\nMETHOD:PUBLISH\r\n'; - ics += `X-WR-CALNAME:${esc(trip.title || 'TREK Trip')}\r\n`; - - // Trip as all-day event - if (trip.start_date && trip.end_date) { - const endNext = new Date(trip.end_date + 'T00:00:00'); - endNext.setDate(endNext.getDate() + 1); - const endStr = endNext.toISOString().split('T')[0].replace(/-/g, ''); - ics += `BEGIN:VEVENT\r\nUID:${uid(trip.id, 'trip')}\r\nDTSTAMP:${now}\r\nDTSTART;VALUE=DATE:${fmtDate(trip.start_date)}\r\nDTEND;VALUE=DATE:${endStr}\r\nSUMMARY:${esc(trip.title || 'Trip')}\r\n`; - if (trip.description) ics += `DESCRIPTION:${esc(trip.description)}\r\n`; - ics += `END:VEVENT\r\n`; + res.setHeader('Content-Type', 'text/calendar; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.send(ics); + } catch (e: any) { + if (e instanceof NotFoundError) return res.status(404).json({ error: e.message }); + throw e; } - - // Reservations as events - for (const r of reservations) { - if (!r.reservation_time) continue; - const hasTime = r.reservation_time.includes('T'); - const meta = r.metadata ? (typeof r.metadata === 'string' ? JSON.parse(r.metadata) : r.metadata) : {}; - - ics += `BEGIN:VEVENT\r\nUID:${uid(r.id, 'res')}\r\nDTSTAMP:${now}\r\n`; - if (hasTime) { - ics += `DTSTART:${fmtDateTime(r.reservation_time)}\r\n`; - if (r.reservation_end_time) { - const endDt = fmtDateTime(r.reservation_end_time, r.reservation_time); - if (endDt.length >= 15) ics += `DTEND:${endDt}\r\n`; - } - } else { - ics += `DTSTART;VALUE=DATE:${fmtDate(r.reservation_time)}\r\n`; - } - ics += `SUMMARY:${esc(r.title)}\r\n`; - - let desc = r.type ? `Type: ${r.type}` : ''; - if (r.confirmation_number) desc += `\nConfirmation: ${r.confirmation_number}`; - if (meta.airline) desc += `\nAirline: ${meta.airline}`; - if (meta.flight_number) desc += `\nFlight: ${meta.flight_number}`; - if (meta.departure_airport) desc += `\nFrom: ${meta.departure_airport}`; - if (meta.arrival_airport) desc += `\nTo: ${meta.arrival_airport}`; - if (meta.train_number) desc += `\nTrain: ${meta.train_number}`; - if (r.notes) desc += `\n${r.notes}`; - if (desc) ics += `DESCRIPTION:${esc(desc)}\r\n`; - if (r.location) ics += `LOCATION:${esc(r.location)}\r\n`; - ics += `END:VEVENT\r\n`; - } - - ics += 'END:VCALENDAR\r\n'; - - res.setHeader('Content-Type', 'text/calendar; charset=utf-8'); - const safeFilename = (trip.title || 'trek-trip').replace(/["\r\n]/g, '').replace(/[^\w\s.-]/g, '_'); - res.setHeader('Content-Disposition', `attachment; filename="${safeFilename}.ics"`); - res.send(ics); }); export default router; diff --git a/server/src/routes/vacay.ts b/server/src/routes/vacay.ts index 90374a3..e3ad528 100644 --- a/server/src/routes/vacay.ts +++ b/server/src/routes/vacay.ts @@ -1,320 +1,60 @@ import express, { Request, Response } from 'express'; -import { db } from '../db/database'; import { authenticate } from '../middleware/auth'; import { AuthRequest } from '../types'; - -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; -} - -interface VacayHolidayCalendar { - id: number; - plan_id: number; - region: string; - label: string | null; - color: string; - sort_order: number; -} - -const holidayCache = new Map(); -const CACHE_TTL = 24 * 60 * 60 * 1000; - -async function applyHolidayCalendars(planId: number): Promise { - const plan = db.prepare('SELECT holidays_enabled FROM vacay_plans WHERE id = ?').get(planId) as { holidays_enabled: number } | undefined; - if (!plan?.holidays_enabled) return; - const calendars = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? ORDER BY sort_order, id').all(planId) as VacayHolidayCalendar[]; - if (calendars.length === 0) return; - const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ?').all(planId) as { year: number }[]; - for (const cal of calendars) { - const country = cal.region.split('-')[0]; - const region = cal.region.includes('-') ? cal.region : null; - for (const { year } of years) { - try { - const cacheKey = `${year}-${country}`; - 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() as Holiday[]; - holidayCache.set(cacheKey, { data: holidays, time: Date.now() }); - } - const hasRegions = holidays.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))) { - db.prepare('DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?').run(planId, h.date); - db.prepare('DELETE FROM vacay_company_holidays WHERE plan_id = ? AND date = ?').run(planId, h.date); - } - } - } catch { /* API error, skip */ } - } - } -} - -async function migrateHolidayCalendars(planId: number, plan: VacayPlan): Promise { - const existing = db.prepare('SELECT id FROM vacay_holiday_calendars WHERE plan_id = ?').get(planId); - if (existing) return; - if (plan.holidays_enabled && plan.holidays_region) { - db.prepare( - 'INSERT INTO vacay_holiday_calendars (plan_id, region, label, color, sort_order) VALUES (?, ?, NULL, ?, 0)' - ).run(planId, plan.holidays_region, '#fecaca'); - } -} +import * as svc from '../services/vacayService'; const router = express.Router(); router.use(authenticate); -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) 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) as { user_id: number }[]; - members.forEach(m => userIds.push(m.user_id)); - userIds.forEach(id => broadcastToUser(id, { type: event }, excludeSid)); - } catch { /* */ } -} - -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) as VacayPlan; - const yr = new Date().getFullYear(); - db.prepare('INSERT OR IGNORE INTO vacay_years (plan_id, year) VALUES (?, ?)').run(plan.id, yr); - 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); - db.prepare('INSERT OR IGNORE INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)').run(userId, plan.id, '#6366f1'); - } - return plan; -} - -function getActivePlan(userId: number) { - const membership = db.prepare(` - SELECT plan_id FROM vacay_plan_members WHERE user_id = ? AND status = 'accepted' - `).get(userId) as { plan_id: number } | undefined; - if (membership) { - return db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(membership.plan_id) as VacayPlan; - } - return getOwnPlan(userId); -} - -function getActivePlanId(userId: number): number { - return getActivePlan(userId).id; -} - -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) 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) as VacayUser[]; - return [owner, ...members]; -} - router.get('/plan', (req: Request, res: Response) => { const authReq = req as AuthRequest; - const plan = getActivePlan(authReq.user.id); - const activePlanId = plan.id; - - 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) as { color: string } | undefined; - return { ...u, color: colorRow?.color || '#6366f1' }; - }); - - 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); - - 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(authReq.user.id); - - const holidayCalendars = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? ORDER BY sort_order, id').all(activePlanId) as VacayHolidayCalendar[]; - - res.json({ - plan: { - ...plan, - block_weekends: !!plan.block_weekends, - holidays_enabled: !!plan.holidays_enabled, - company_holidays_enabled: !!plan.company_holidays_enabled, - carry_over_enabled: !!plan.carry_over_enabled, - holiday_calendars: holidayCalendars, - }, - users, - pendingInvites, - incomingInvites, - isOwner: plan.owner_id === authReq.user.id, - isFused: users.length > 1, - }); + res.json(svc.getPlanData(authReq.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, weekend_days } = req.body; - - 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 (weekend_days !== undefined) { updates.push('weekend_days = ?'); params.push(String(weekend_days)); } - - if (updates.length > 0) { - params.push(planId); - db.prepare(`UPDATE vacay_plans SET ${updates.join(', ')} WHERE id = ?`).run(...params); - } - - if (company_holidays_enabled === true) { - 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); - } - } - - const updatedPlan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan; - await migrateHolidayCalendars(planId, updatedPlan); - await applyHolidayCalendars(planId); - - if (carry_over_enabled === false) { - db.prepare('UPDATE vacay_user_years SET carried_over = 0 WHERE plan_id = ?').run(planId); - } - - if (carry_over_enabled === true) { - 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}-%`) 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(` - INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, ?) - ON CONFLICT(user_id, plan_id, year) DO UPDATE SET carried_over = ? - `).run(u.id, planId, nextYr, carry, carry); - } - } - } - - notifyPlanUsers(planId, req.headers['x-socket-id'] as string, 'vacay:settings'); - - const updated = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan; - const updatedCalendars = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? ORDER BY sort_order, id').all(planId) as VacayHolidayCalendar[]; - 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, holiday_calendars: updatedCalendars } - }); + const planId = svc.getActivePlanId(authReq.user.id); + const result = await svc.updatePlan(planId, req.body, req.headers['x-socket-id'] as string); + res.json(result); }); router.post('/plan/holiday-calendars', (req: Request, res: Response) => { const authReq = req as AuthRequest; const { region, label, color, sort_order } = req.body; if (!region) return res.status(400).json({ error: 'region required' }); - const planId = getActivePlanId(authReq.user.id); - const result = db.prepare( - 'INSERT INTO vacay_holiday_calendars (plan_id, region, label, color, sort_order) VALUES (?, ?, ?, ?, ?)' - ).run(planId, region, label || null, color || '#fecaca', sort_order ?? 0); - const cal = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ?').get(result.lastInsertRowid) as VacayHolidayCalendar; - notifyPlanUsers(planId, req.headers['x-socket-id'] as string, 'vacay:settings'); - res.json({ calendar: cal }); + const planId = svc.getActivePlanId(authReq.user.id); + const calendar = svc.addHolidayCalendar(planId, region, label, color, sort_order, req.headers['x-socket-id'] as string); + res.json({ calendar }); }); router.put('/plan/holiday-calendars/:id', (req: Request, res: Response) => { const authReq = req as AuthRequest; const id = parseInt(req.params.id); - const planId = getActivePlanId(authReq.user.id); - const cal = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ? AND plan_id = ?').get(id, planId) as VacayHolidayCalendar | undefined; - if (!cal) return res.status(404).json({ error: 'Calendar not found' }); - const { region, label, color, sort_order } = req.body; - const updates: string[] = []; - const params: (string | number | null)[] = []; - if (region !== undefined) { updates.push('region = ?'); params.push(region); } - if (label !== undefined) { updates.push('label = ?'); params.push(label); } - if (color !== undefined) { updates.push('color = ?'); params.push(color); } - if (sort_order !== undefined) { updates.push('sort_order = ?'); params.push(sort_order); } - if (updates.length > 0) { - params.push(id); - db.prepare(`UPDATE vacay_holiday_calendars SET ${updates.join(', ')} WHERE id = ?`).run(...params); - } - const updated = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ?').get(id) as VacayHolidayCalendar; - notifyPlanUsers(planId, req.headers['x-socket-id'] as string, 'vacay:settings'); - res.json({ calendar: updated }); + const planId = svc.getActivePlanId(authReq.user.id); + const calendar = svc.updateHolidayCalendar(id, planId, req.body, req.headers['x-socket-id'] as string); + if (!calendar) return res.status(404).json({ error: 'Calendar not found' }); + res.json({ calendar }); }); router.delete('/plan/holiday-calendars/:id', (req: Request, res: Response) => { const authReq = req as AuthRequest; const id = parseInt(req.params.id); - const planId = getActivePlanId(authReq.user.id); - const cal = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ? AND plan_id = ?').get(id, planId); - if (!cal) return res.status(404).json({ error: 'Calendar not found' }); - db.prepare('DELETE FROM vacay_holiday_calendars WHERE id = ?').run(id); - notifyPlanUsers(planId, req.headers['x-socket-id'] as string, 'vacay:settings'); + const planId = svc.getActivePlanId(authReq.user.id); + const deleted = svc.deleteHolidayCalendar(id, planId, req.headers['x-socket-id'] as string); + if (!deleted) return res.status(404).json({ error: 'Calendar not found' }); res.json({ success: true }); }); router.put('/color', (req: Request, res: Response) => { const authReq = req as AuthRequest; const { color, target_user_id } = req.body; - const planId = getActivePlanId(authReq.user.id); + const planId = svc.getActivePlanId(authReq.user.id); const userId = target_user_id ? parseInt(target_user_id) : authReq.user.id; - const planUsers = getPlanUsers(planId); + const planUsers = svc.getPlanUsers(planId); if (!planUsers.find(u => u.id === userId)) { return res.status(403).json({ error: 'User not in plan' }); } - db.prepare(` - 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'] as string, 'vacay:update'); + svc.setUserColor(userId, planId, color, req.headers['x-socket-id'] as string); res.json({ success: true }); }); @@ -322,345 +62,133 @@ 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 === 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(authReq.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' }); - } - - 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'); - - try { - const { broadcastToUser } = require('../websocket'); - broadcastToUser(user_id, { - type: 'vacay:invite', - from: { id: authReq.user.id, username: authReq.user.username }, - planId: plan.id, - }); - } catch { /* websocket not available */ } - - // Notify invited user - import('../services/notifications').then(({ notify }) => { - notify({ userId: user_id, event: 'vacay_invite', params: { actor: authReq.user.email } }).catch(() => {}); - }); - + const plan = svc.getActivePlan(authReq.user.id); + const result = svc.sendInvite(plan.id, authReq.user.id, authReq.user.username, authReq.user.email, user_id); + if (result.error) return res.status(result.status!).json({ error: result.error }); res.json({ success: true }); }); 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, authReq.user.id) as VacayPlanMember | undefined; - if (!invite) return res.status(404).json({ error: 'No pending invite' }); - - db.prepare("UPDATE vacay_plan_members SET status = 'accepted' WHERE id = ?").run(invite.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) { - 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(authReq.user.id, plan_id, y.year, y.vacation_days, y.carried_over); - } - 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(authReq.user.id, plan_id, colorRow.color); - } - } - - 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, 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; - const effectiveColor = myColor?.color || '#6366f1'; - if (existingColors.includes(effectiveColor)) { - const available = COLORS.find(c => !existingColors.includes(c)); - if (available) { - db.prepare(`INSERT INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?) - ON CONFLICT(user_id, plan_id) DO UPDATE SET color = excluded.color`).run(authReq.user.id, plan_id, available); - } - } else if (!myColor) { - db.prepare('INSERT OR IGNORE INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)').run(authReq.user.id, plan_id, effectiveColor); - } - - 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(authReq.user.id, plan_id, y.year); - } - - notifyPlanUsers(plan_id, req.headers['x-socket-id'] as string, 'vacay:accepted'); - + const result = svc.acceptInvite(authReq.user.id, plan_id, req.headers['x-socket-id'] as string); + if (result.error) return res.status(result.status!).json({ error: result.error }); res.json({ success: true }); }); 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, authReq.user.id); - notifyPlanUsers(plan_id, req.headers['x-socket-id'] as string, 'vacay:declined'); + svc.declineInvite(authReq.user.id, plan_id, req.headers['x-socket-id'] as string); res.json({ success: true }); }); router.post('/invite/cancel', (req: Request, res: Response) => { const authReq = req as AuthRequest; const { user_id } = req.body; - 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 { - const { broadcastToUser } = require('../websocket'); - broadcastToUser(user_id, { type: 'vacay:cancelled' }); - } catch { /* */ } - + const plan = svc.getActivePlan(authReq.user.id); + svc.cancelInvite(plan.id, user_id); res.json({ success: true }); }); 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; - - 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) as { date: string; note: string }[]; - - 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); - 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(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, authReq.user.id); - } - - try { - const { broadcastToUser } = require('../websocket'); - allUserIds.filter(id => id !== authReq.user.id).forEach(id => broadcastToUser(id, { type: 'vacay:dissolved' })); - } catch { /* */ } - + svc.dissolvePlan(authReq.user.id, req.headers['x-socket-id'] as string); res.json({ success: true }); }); 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 != ? - AND u.id NOT IN (SELECT user_id FROM vacay_plan_members WHERE plan_id = ?) - AND u.id NOT IN (SELECT user_id FROM vacay_plan_members WHERE status = 'accepted') - AND u.id NOT IN (SELECT owner_id FROM vacay_plans WHERE id IN ( - SELECT plan_id FROM vacay_plan_members WHERE status = 'accepted' - )) - ORDER BY u.username - `).all(authReq.user.id, planId); + const planId = svc.getActivePlanId(authReq.user.id); + const users = svc.getAvailableUsers(authReq.user.id, planId); res.json({ users }); }); 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) }); + const planId = svc.getActivePlanId(authReq.user.id); + res.json({ years: svc.listYears(planId) }); }); 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(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) as VacayPlan | undefined; - const carryOverEnabled = plan ? !!plan.carry_over_enabled : true; - const users = getPlanUsers(planId); - for (const u of users) { - 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) 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}-%`) as { count: number }).count; - const total = prevConfig.vacation_days + prevConfig.carried_over; - carriedOver = Math.max(0, total - used); - } - } - 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'] 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) }); + const planId = svc.getActivePlanId(authReq.user.id); + const years = svc.addYear(planId, year, req.headers['x-socket-id'] as string); + res.json({ years }); }); router.delete('/years/:year', (req: Request, res: Response) => { const authReq = req as AuthRequest; const year = parseInt(req.params.year); - 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'] 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) }); + const planId = svc.getActivePlanId(authReq.user.id); + const years = svc.deleteYear(planId, year, req.headers['x-socket-id'] as string); + res.json({ years }); }); router.get('/entries/:year', (req: Request, res: Response) => { const authReq = req as AuthRequest; - const year = req.params.year; - 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 - JOIN users u ON e.user_id = u.id - LEFT JOIN vacay_user_colors c ON c.user_id = e.user_id AND c.plan_id = e.plan_id - WHERE e.plan_id = ? AND e.date LIKE ? - `).all(planId, `${year}-%`); - const companyHolidays = db.prepare("SELECT * FROM vacay_company_holidays WHERE plan_id = ? AND date LIKE ?").all(planId, `${year}-%`); - res.json({ entries, companyHolidays }); + const planId = svc.getActivePlanId(authReq.user.id); + res.json(svc.getEntries(planId, req.params.year)); }); 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(authReq.user.id); + const planId = svc.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 planUsers = svc.getPlanUsers(planId); const tid = parseInt(target_user_id); if (!planUsers.find(u => u.id === tid)) { return res.status(403).json({ error: 'User not in plan' }); } userId = tid; } - 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'] 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'] as string); - res.json({ action: 'added' }); - } + res.json(svc.toggleEntry(userId, planId, date, req.headers['x-socket-id'] as string)); }); router.post('/entries/company-holiday', (req: Request, res: Response) => { const authReq = req as AuthRequest; const { date, note } = req.body; - 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'] as string); - res.json({ action: 'removed' }); - } else { - db.prepare('INSERT INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)').run(planId, date, note || ''); - db.prepare('DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?').run(planId, date); - notifyPlanUsers(planId, req.headers['x-socket-id'] as string); - res.json({ action: 'added' }); - } + const planId = svc.getActivePlanId(authReq.user.id); + res.json(svc.toggleCompanyHoliday(planId, date, note, req.headers['x-socket-id'] as string)); }); router.get('/stats/:year', (req: Request, res: Response) => { const authReq = req as AuthRequest; const year = parseInt(req.params.year); - 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}-%`) 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) as { color: string } | undefined; - - 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); - db.prepare(` - INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, ?) - ON CONFLICT(user_id, plan_id, year) DO UPDATE SET carried_over = ? - `).run(u.id, planId, year + 1, carry, carry); - } - - return { - user_id: u.id, person_name: u.username, person_color: colorRow?.color || '#6366f1', - year, vacation_days: vacationDays, carried_over: carriedOver, - total_available: total, used, remaining, - }; - }); - - res.json({ stats }); + const planId = svc.getActivePlanId(authReq.user.id); + res.json({ stats: svc.getStats(planId, year) }); }); 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(authReq.user.id); + const planId = svc.getActivePlanId(authReq.user.id); const userId = target_user_id ? parseInt(target_user_id) : authReq.user.id; - const planUsers = getPlanUsers(planId); + const planUsers = svc.getPlanUsers(planId); if (!planUsers.find(u => u.id === userId)) { return res.status(403).json({ error: 'User not in plan' }); } - db.prepare(` - 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'] as string); + svc.updateStats(userId, planId, year, vacation_days, req.headers['x-socket-id'] as string); res.json({ success: true }); }); 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); - try { - const resp = await fetch('https://date.nager.at/api/v3/AvailableCountries'); - const data = await resp.json(); - holidayCache.set(cacheKey, { data, time: Date.now() }); - res.json(data); - } catch { - res.status(502).json({ error: 'Failed to fetch countries' }); - } + const result = await svc.getCountries(); + if (result.error) return res.status(502).json({ error: result.error }); + res.json(result.data); }); router.get('/holidays/:year/:country', async (req: Request, res: Response) => { const { year, country } = req.params; - const cacheKey = `${year}-${country}`; - const cached = holidayCache.get(cacheKey); - if (cached && Date.now() - cached.time < CACHE_TTL) return res.json(cached.data); - try { - const resp = await fetch(`https://date.nager.at/api/v3/PublicHolidays/${year}/${country}`); - const data = await resp.json(); - holidayCache.set(cacheKey, { data, time: Date.now() }); - res.json(data); - } catch { - res.status(502).json({ error: 'Failed to fetch holidays' }); - } + const result = await svc.getHolidays(year, country); + if (result.error) return res.status(502).json({ error: result.error }); + res.json(result.data); }); export default router; diff --git a/server/src/routes/weather.ts b/server/src/routes/weather.ts index 0577161..08194dd 100644 --- a/server/src/routes/weather.ts +++ b/server/src/routes/weather.ts @@ -1,146 +1,9 @@ import express, { Request, Response } from 'express'; -import fetch from 'node-fetch'; import { authenticate } from '../middleware/auth'; +import { getWeather, getDetailedWeather, ApiError } from '../services/weatherService'; const router = express.Router(); -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; -} - -interface HourlyEntry { - hour: number; - temp: number; - precipitation: number; - precipitation_probability: number; - main: string; - wind: number; - humidity: number; -} - -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(); -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: string) { - const entry = weatherCache.get(key); - if (!entry) return null; - if (Date.now() > entry.expiresAt) { - weatherCache.delete(key); - return null; - } - return entry.data; -} - -function setCache(key: string, data: WeatherResult, ttlMs: number) { - weatherCache.set(key, { data, expiresAt: Date.now() + ttlMs }); -} - -const WMO_MAP: Record = { - 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: Record = { - 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: Record = { - 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', -}; - -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'; -} - 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 }; @@ -148,129 +11,13 @@ router.get('/', authenticate, async (req: Request, res: Response) => { return res.status(400).json({ error: 'Latitude and longitude are required' }); } - const ck = cacheKey(lat, lng, date); - try { - if (date) { - const cached = getCached(ck); - if (cached) return res.json(cached); - - const targetDate = new Date(date); - const now = new Date(); - const diffDays = (targetDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24); - - 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() as OpenMeteoForecast; - - if (!response.ok || data.error) { - return res.status(response.status || 500).json({ error: data.reason || 'Open-Meteo API error' }); - } - - const dateStr = targetDate.toISOString().slice(0, 10); - const idx = (data.daily?.time || []).indexOf(dateStr); - - if (idx !== -1) { - const code = data.daily.weathercode[idx]; - const descriptions = lang === 'de' ? WMO_DESCRIPTION_DE : WMO_DESCRIPTION_EN; - - const result = { - temp: Math.round((data.daily.temperature_2m_max[idx] + data.daily.temperature_2m_min[idx]) / 2), - temp_max: Math.round(data.daily.temperature_2m_max[idx]), - temp_min: Math.round(data.daily.temperature_2m_min[idx]), - main: WMO_MAP[code] || 'Clouds', - description: descriptions[code] || '', - type: 'forecast', - }; - - setCache(ck, result, TTL_FORECAST_MS); - return res.json(result); - } - } - - if (diffDays > -1) { - const month = targetDate.getMonth() + 1; - const day = targetDate.getDate(); - const refYear = targetDate.getFullYear() - 1; - const startDate = new Date(refYear, month - 1, day - 2); - const endDate = new Date(refYear, month - 1, day + 2); - const startStr = startDate.toISOString().slice(0, 10); - const endStr = endDate.toISOString().slice(0, 10); - - 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() as OpenMeteoForecast; - - if (!response.ok || data.error) { - return res.status(response.status || 500).json({ error: data.reason || 'Open-Meteo Climate API error' }); - } - - const daily = data.daily; - if (!daily || !daily.time || daily.time.length === 0) { - return res.json({ error: 'no_forecast' }); - } - - 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) { - sumMax += daily.temperature_2m_max[i]; - sumMin += daily.temperature_2m_min[i]; - sumPrecip += daily.precipitation_sum[i] || 0; - count++; - } - } - - if (count === 0) { - return res.json({ error: 'no_forecast' }); - } - - const avgMax = sumMax / count; - const avgMin = sumMin / count; - const avgTemp = (avgMax + avgMin) / 2; - const avgPrecip = sumPrecip / count; - const main = estimateCondition(avgTemp, avgPrecip); - - const result = { - temp: Math.round(avgTemp), - temp_max: Math.round(avgMax), - temp_min: Math.round(avgMin), - main, - description: '', - type: 'climate', - }; - - setCache(ck, result, TTL_CLIMATE_MS); - return res.json(result); - } - - return res.json({ error: 'no_forecast' }); - } - - 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() as OpenMeteoForecast; - - if (!response.ok || data.error) { - return res.status(response.status || 500).json({ error: data.reason || 'Open-Meteo API error' }); - } - - const code = data.current.weathercode; - const descriptions = lang === 'de' ? WMO_DESCRIPTION_DE : WMO_DESCRIPTION_EN; - - const result = { - temp: Math.round(data.current.temperature_2m), - main: WMO_MAP[code] || 'Clouds', - description: descriptions[code] || '', - type: 'current', - }; - - setCache(ck, result, TTL_CURRENT_MS); + const result = await getWeather(lat, lng, date, lang); res.json(result); } catch (err: unknown) { + if (err instanceof ApiError) { + return res.status(err.status).json({ error: err.message }); + } console.error('Weather error:', err); res.status(500).json({ error: 'Error fetching weather data' }); } @@ -283,146 +30,13 @@ router.get('/detailed', authenticate, async (req: Request, res: Response) => { return res.status(400).json({ error: 'Latitude, longitude, and date are required' }); } - const ck = `detailed_${cacheKey(lat, lng, date)}`; - try { - const cached = getCached(ck); - if (cached) return res.json(cached); - - const targetDate = new Date(date); - const now = new Date(); - 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; - - if (diffDays > 16) { - const refYear = targetDate.getFullYear() - 1; - const refDateStr = `${refYear}-${String(targetDate.getMonth() + 1).padStart(2, '0')}-${String(targetDate.getDate()).padStart(2, '0')}`; - - const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${lat}&longitude=${lng}` - + `&start_date=${refDateStr}&end_date=${refDateStr}` - + `&hourly=temperature_2m,precipitation,weathercode,windspeed_10m,relativehumidity_2m` - + `&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() as OpenMeteoForecast; - - if (!response.ok || data.error) { - return res.status(response.status || 500).json({ error: data.reason || 'Open-Meteo Climate API error' }); - } - - const daily = data.daily; - const hourly = data.hourly; - if (!daily || !daily.time || daily.time.length === 0) { - return res.json({ error: 'no_forecast' }); - } - - const idx = 0; - const code = daily.weathercode?.[idx]; - const avgMax = daily.temperature_2m_max[idx]; - const avgMin = daily.temperature_2m_min[idx]; - - const hourlyData: HourlyEntry[] = []; - if (hourly?.time) { - for (let i = 0; i < hourly.time.length; i++) { - const hour = new Date(hourly.time[i]).getHours(); - const hCode = hourly.weathercode?.[i]; - hourlyData.push({ - hour, - temp: Math.round(hourly.temperature_2m[i]), - precipitation: hourly.precipitation?.[i] || 0, - precipitation_probability: 0, - main: WMO_MAP[hCode] || 'Clouds', - wind: Math.round(hourly.windspeed_10m?.[i] || 0), - humidity: hourly.relativehumidity_2m?.[i] || 0, - }); - } - } - - 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); - - const result = { - type: 'climate', - temp: Math.round((avgMax + avgMin) / 2), - temp_max: Math.round(avgMax), - temp_min: Math.round(avgMin), - main: WMO_MAP[code] || estimateCondition((avgMax + avgMin) / 2, daily.precipitation_sum?.[idx] || 0), - description: descriptions[code] || '', - precipitation_sum: Math.round((daily.precipitation_sum?.[idx] || 0) * 10) / 10, - wind_max: Math.round(daily.windspeed_10m_max?.[idx] || 0), - sunrise, - sunset, - hourly: hourlyData, - }; - - setCache(ck, result, TTL_CLIMATE_MS); - return res.json(result); - } - - 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() as OpenMeteoForecast; - - if (!response.ok || data.error) { - return res.status(response.status || 500).json({ error: data.reason || 'Open-Meteo API error' }); - } - - const daily = data.daily; - const hourly = data.hourly; - - if (!daily || !daily.time || daily.time.length === 0) { - return res.json({ error: 'no_forecast' }); - } - - const dayIdx = 0; - const code = daily.weathercode[dayIdx]; - - 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')}`; - }; - - const hourlyData: HourlyEntry[] = []; - if (hourly && hourly.time) { - for (let i = 0; i < hourly.time.length; i++) { - const h = new Date(hourly.time[i]).getHours(); - hourlyData.push({ - hour: h, - temp: Math.round(hourly.temperature_2m[i]), - precipitation_probability: hourly.precipitation_probability[i] || 0, - precipitation: hourly.precipitation[i] || 0, - main: WMO_MAP[hourly.weathercode[i]] || 'Clouds', - wind: Math.round(hourly.windspeed_10m[i] || 0), - humidity: Math.round(hourly.relativehumidity_2m[i] || 0), - }); - } - } - - const result = { - type: 'forecast', - temp: Math.round((daily.temperature_2m_max[dayIdx] + daily.temperature_2m_min[dayIdx]) / 2), - temp_max: Math.round(daily.temperature_2m_max[dayIdx]), - temp_min: Math.round(daily.temperature_2m_min[dayIdx]), - main: WMO_MAP[code] || 'Clouds', - description: descriptions[code] || '', - sunrise: formatTime(daily.sunrise[dayIdx]), - sunset: formatTime(daily.sunset[dayIdx]), - precipitation_sum: daily.precipitation_sum[dayIdx] || 0, - precipitation_probability_max: daily.precipitation_probability_max[dayIdx] || 0, - wind_max: Math.round(daily.windspeed_10m_max[dayIdx] || 0), - hourly: hourlyData, - }; - - setCache(ck, result, TTL_FORECAST_MS); - return res.json(result); + const result = await getDetailedWeather(lat, lng, date, lang); + res.json(result); } catch (err: unknown) { + if (err instanceof ApiError) { + return res.status(err.status).json({ error: err.message }); + } console.error('Detailed weather error:', err); res.status(500).json({ error: 'Error fetching detailed weather data' }); } diff --git a/server/src/services/adminService.ts b/server/src/services/adminService.ts new file mode 100644 index 0000000..ae42c92 --- /dev/null +++ b/server/src/services/adminService.ts @@ -0,0 +1,516 @@ +import bcrypt from 'bcryptjs'; +import crypto from 'crypto'; +import path from 'path'; +import fs from 'fs'; +import { db } from '../db/database'; +import { User, Addon } from '../types'; +import { updateJwtSecret } from '../config'; +import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto'; +import { getAllPermissions, savePermissions as savePerms, PERMISSION_ACTIONS } from './permissions'; +import { revokeUserSessions } from '../mcp'; +import { validatePassword } from './passwordPolicy'; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +export function utcSuffix(ts: string | null | undefined): string | null { + if (!ts) return null; + return ts.endsWith('Z') ? ts : ts.replace(' ', 'T') + 'Z'; +} + +export 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++) { + const na = pa[i] || 0, nb = pb[i] || 0; + if (na > nb) return 1; + if (na < nb) return -1; + } + return 0; +} + +export const isDocker = (() => { + try { + return fs.existsSync('/.dockerenv') || (fs.existsSync('/proc/1/cgroup') && fs.readFileSync('/proc/1/cgroup', 'utf8').includes('docker')); + } catch { return false; } +})(); + +// ── User CRUD ────────────────────────────────────────────────────────────── + +export function listUsers() { + const users = db.prepare( + 'SELECT id, username, email, role, created_at, updated_at, last_login FROM users ORDER BY created_at DESC' + ).all() as Pick[]; + let onlineUserIds = new Set(); + try { + const { getOnlineUserIds } = require('../websocket'); + onlineUserIds = getOnlineUserIds(); + } catch { /* */ } + return users.map(u => ({ + ...u, + created_at: utcSuffix(u.created_at), + updated_at: utcSuffix(u.updated_at as string), + last_login: utcSuffix(u.last_login), + online: onlineUserIds.has(u.id), + })); +} + +export function createUser(data: { username: string; email: string; password: string; role?: string }) { + const username = data.username?.trim(); + const email = data.email?.trim(); + const password = data.password?.trim(); + + if (!username || !email || !password) { + return { error: 'Username, email and password are required', status: 400 }; + } + + const pwCheck = validatePassword(password); + if (!pwCheck.ok) return { error: pwCheck.reason, status: 400 }; + + if (data.role && !['user', 'admin'].includes(data.role)) { + return { error: 'Invalid role', status: 400 }; + } + + const existingUsername = db.prepare('SELECT id FROM users WHERE username = ?').get(username); + if (existingUsername) return { error: 'Username already taken', status: 409 }; + + const existingEmail = db.prepare('SELECT id FROM users WHERE email = ?').get(email); + if (existingEmail) return { error: 'Email already taken', status: 409 }; + + const passwordHash = bcrypt.hashSync(password, 12); + + const result = db.prepare( + 'INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)' + ).run(username, email, passwordHash, data.role || 'user'); + + const user = db.prepare( + 'SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?' + ).get(result.lastInsertRowid); + + return { + user, + insertedId: Number(result.lastInsertRowid), + auditDetails: { username, email, role: data.role || 'user' }, + }; +} + +export function updateUser(id: string, data: { username?: string; email?: string; role?: string; password?: string }) { + const { username, email, role, password } = data; + const user = db.prepare('SELECT * FROM users WHERE id = ?').get(id) as User | undefined; + + if (!user) return { error: 'User not found', status: 404 }; + + if (role && !['user', 'admin'].includes(role)) { + return { error: 'Invalid role', status: 400 }; + } + + if (username && username !== user.username) { + const conflict = db.prepare('SELECT id FROM users WHERE username = ? AND id != ?').get(username, id); + if (conflict) return { error: 'Username already taken', status: 409 }; + } + if (email && email !== user.email) { + const conflict = db.prepare('SELECT id FROM users WHERE email = ? AND id != ?').get(email, id); + if (conflict) return { error: 'Email already taken', status: 409 }; + } + + if (password) { + const pwCheck = validatePassword(password); + if (!pwCheck.ok) return { error: pwCheck.reason, status: 400 }; + } + const passwordHash = password ? bcrypt.hashSync(password, 12) : null; + + db.prepare(` + UPDATE users SET + username = COALESCE(?, username), + email = COALESCE(?, email), + role = COALESCE(?, role), + password_hash = COALESCE(?, password_hash), + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `).run(username || null, email || null, role || null, passwordHash, id); + + const updated = db.prepare( + 'SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?' + ).get(id); + + const changed: string[] = []; + if (username) changed.push('username'); + if (email) changed.push('email'); + if (role) changed.push('role'); + if (password) changed.push('password'); + + return { + user: updated, + previousEmail: user.email, + changed, + }; +} + +export function deleteUser(id: string, currentUserId: number) { + if (parseInt(id) === currentUserId) { + return { error: 'Cannot delete own account', status: 400 }; + } + + const userToDel = db.prepare('SELECT id, email FROM users WHERE id = ?').get(id) as { id: number; email: string } | undefined; + if (!userToDel) return { error: 'User not found', status: 404 }; + + db.prepare('DELETE FROM users WHERE id = ?').run(id); + return { email: userToDel.email }; +} + +// ── Stats ────────────────────────────────────────────────────────────────── + +export function getStats() { + 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; + return { totalUsers, totalTrips, totalPlaces, totalFiles }; +} + +// ── Permissions ──────────────────────────────────────────────────────────── + +export function getPermissions() { + const current = getAllPermissions(); + const actions = PERMISSION_ACTIONS.map(a => ({ + key: a.key, + level: current[a.key], + defaultLevel: a.defaultLevel, + allowedLevels: a.allowedLevels, + })); + return { permissions: actions }; +} + +export function savePermissions(permissions: Record) { + const { skipped } = savePerms(permissions); + return { permissions: getAllPermissions(), skipped }; +} + +// ── Audit Log ────────────────────────────────────────────────────────────── + +export function getAuditLog(query: { limit?: string; offset?: string }) { + const limitRaw = parseInt(String(query.limit || '100'), 10); + const offsetRaw = parseInt(String(query.offset || '0'), 10); + const limit = Math.min(Math.max(Number.isFinite(limitRaw) ? limitRaw : 100, 1), 500); + const offset = Math.max(Number.isFinite(offsetRaw) ? offsetRaw : 0, 0); + + type Row = { + id: number; + created_at: string; + user_id: number | null; + username: string | null; + user_email: string | null; + action: string; + resource: string | null; + details: string | null; + ip: string | null; + }; + + const rows = db.prepare(` + SELECT a.id, a.created_at, a.user_id, u.username, u.email as user_email, a.action, a.resource, a.details, a.ip + FROM audit_log a + LEFT JOIN users u ON u.id = a.user_id + ORDER BY a.id DESC + LIMIT ? OFFSET ? + `).all(limit, offset) as Row[]; + + const total = (db.prepare('SELECT COUNT(*) as c FROM audit_log').get() as { c: number }).c; + + const entries = rows.map((r) => { + let details: Record | null = null; + if (r.details) { + try { + details = JSON.parse(r.details) as Record; + } catch { + details = { _parse_error: true }; + } + } + const created_at = r.created_at && !r.created_at.endsWith('Z') ? r.created_at.replace(' ', 'T') + 'Z' : r.created_at; + return { ...r, created_at, details }; + }); + + return { entries, total, limit, offset }; +} + +// ── OIDC Settings ────────────────────────────────────────────────────────── + +export function getOidcSettings() { + const get = (key: string) => (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || ''; + const secret = decrypt_api_key(get('oidc_client_secret')); + return { + issuer: get('oidc_issuer'), + client_id: get('oidc_client_id'), + client_secret_set: !!secret, + display_name: get('oidc_display_name'), + oidc_only: get('oidc_only') === 'true', + discovery_url: get('oidc_discovery_url'), + }; +} + +export function updateOidcSettings(data: { + issuer?: string; + client_id?: string; + client_secret?: string; + display_name?: string; + oidc_only?: boolean; + discovery_url?: string; +}) { + const set = (key: string, val: string) => db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val || ''); + set('oidc_issuer', data.issuer ?? ''); + set('oidc_client_id', data.client_id ?? ''); + if (data.client_secret !== undefined) set('oidc_client_secret', maybe_encrypt_api_key(data.client_secret) ?? ''); + set('oidc_display_name', data.display_name ?? ''); + set('oidc_only', data.oidc_only ? 'true' : 'false'); + set('oidc_discovery_url', data.discovery_url ?? ''); +} + +// ── Demo Baseline ────────────────────────────────────────────────────────── + +export function saveDemoBaseline(): { error?: string; status?: number; message?: string } { + if (process.env.DEMO_MODE !== 'true') { + return { error: 'Not found', status: 404 }; + } + try { + const { saveBaseline } = require('../demo/demo-reset'); + saveBaseline(); + return { message: 'Demo baseline saved. Hourly resets will restore to this state.' }; + } catch (err: unknown) { + console.error(err); + return { error: 'Failed to save baseline', status: 500 }; + } +} + +// ── GitHub Integration ───────────────────────────────────────────────────── + +export async function getGithubReleases(perPage: string = '10', page: string = '1') { + try { + const resp = await fetch( + `https://api.github.com/repos/mauriceboe/TREK/releases?per_page=${perPage}&page=${page}`, + { headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } } + ); + if (!resp.ok) return []; + const data = await resp.json(); + return Array.isArray(data) ? data : []; + } catch { + return []; + } +} + +export async function checkVersion() { + const { version: currentVersion } = require('../../package.json'); + try { + const resp = await fetch( + 'https://api.github.com/repos/mauriceboe/TREK/releases/latest', + { headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } } + ); + if (!resp.ok) return { 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; + return { current: currentVersion, latest, update_available, release_url: data.html_url || '', is_docker: isDocker }; + } catch { + return { current: currentVersion, latest: currentVersion, update_available: false, is_docker: isDocker }; + } +} + +// ── Invite Tokens ────────────────────────────────────────────────────────── + +export function listInvites() { + return db.prepare(` + SELECT i.*, u.username as created_by_name + FROM invite_tokens i + JOIN users u ON i.created_by = u.id + ORDER BY i.created_at DESC + `).all(); +} + +export function createInvite(createdBy: number, data: { max_uses?: string | number; expires_in_days?: string | number }) { + const rawUses = parseInt(String(data.max_uses)); + const uses = rawUses === 0 ? 0 : Math.min(Math.max(rawUses || 1, 1), 5); + const token = crypto.randomBytes(16).toString('hex'); + const expiresAt = data.expires_in_days + ? new Date(Date.now() + parseInt(String(data.expires_in_days)) * 86400000).toISOString() + : null; + + const ins = db.prepare( + 'INSERT INTO invite_tokens (token, max_uses, expires_at, created_by) VALUES (?, ?, ?, ?)' + ).run(token, uses, expiresAt, createdBy); + + const inviteId = Number(ins.lastInsertRowid); + const invite = db.prepare(` + SELECT i.*, u.username as created_by_name + FROM invite_tokens i + JOIN users u ON i.created_by = u.id + WHERE i.id = ? + `).get(inviteId); + + return { invite, inviteId, uses, expiresInDays: data.expires_in_days ?? null }; +} + +export function deleteInvite(id: string) { + const invite = db.prepare('SELECT id FROM invite_tokens WHERE id = ?').get(id); + if (!invite) return { error: 'Invite not found', status: 404 }; + db.prepare('DELETE FROM invite_tokens WHERE id = ?').run(id); + return {}; +} + +// ── Bag Tracking ─────────────────────────────────────────────────────────── + +export function getBagTracking() { + const row = db.prepare("SELECT value FROM app_settings WHERE key = 'bag_tracking_enabled'").get() as { value: string } | undefined; + return { enabled: row?.value === 'true' }; +} + +export function updateBagTracking(enabled: boolean) { + db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('bag_tracking_enabled', ?)").run(enabled ? 'true' : 'false'); + return { enabled: !!enabled }; +} + +// ── Packing Templates ────────────────────────────────────────────────────── + +export function listPackingTemplates() { + return db.prepare(` + SELECT pt.*, u.username as created_by_name, + (SELECT COUNT(*) FROM packing_template_items ti JOIN packing_template_categories tc ON ti.category_id = tc.id WHERE tc.template_id = pt.id) as item_count, + (SELECT COUNT(*) FROM packing_template_categories WHERE template_id = pt.id) as category_count + FROM packing_templates pt + JOIN users u ON pt.created_by = u.id + ORDER BY pt.created_at DESC + `).all(); +} + +export function getPackingTemplate(id: string) { + const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(id); + if (!template) return { error: 'Template not found', status: 404 }; + const categories = db.prepare('SELECT * FROM packing_template_categories WHERE template_id = ? ORDER BY sort_order, id').all(id) as any[]; + const items = db.prepare(` + SELECT ti.* FROM packing_template_items ti + JOIN packing_template_categories tc ON ti.category_id = tc.id + WHERE tc.template_id = ? ORDER BY ti.sort_order, ti.id + `).all(id); + return { template, categories, items }; +} + +export function createPackingTemplate(name: string, createdBy: number) { + if (!name?.trim()) return { error: 'Name is required', status: 400 }; + const result = db.prepare('INSERT INTO packing_templates (name, created_by) VALUES (?, ?)').run(name.trim(), createdBy); + const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(result.lastInsertRowid); + return { template }; +} + +export function updatePackingTemplate(id: string, data: { name?: string }) { + const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(id); + if (!template) return { error: 'Template not found', status: 404 }; + if (data.name?.trim()) db.prepare('UPDATE packing_templates SET name = ? WHERE id = ?').run(data.name.trim(), id); + return { template: db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(id) }; +} + +export function deletePackingTemplate(id: string) { + const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(id) as { name?: string } | undefined; + if (!template) return { error: 'Template not found', status: 404 }; + db.prepare('DELETE FROM packing_templates WHERE id = ?').run(id); + return { name: template.name }; +} + +// Template categories + +export function createTemplateCategory(templateId: string, name: string) { + if (!name?.trim()) return { error: 'Category name is required', status: 400 }; + const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(templateId); + if (!template) return { error: 'Template not found', status: 404 }; + const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_template_categories WHERE template_id = ?').get(templateId) as { max: number | null }; + const result = db.prepare('INSERT INTO packing_template_categories (template_id, name, sort_order) VALUES (?, ?, ?)').run(templateId, name.trim(), (maxOrder.max ?? -1) + 1); + return { category: db.prepare('SELECT * FROM packing_template_categories WHERE id = ?').get(result.lastInsertRowid) }; +} + +export function updateTemplateCategory(templateId: string, catId: string, data: { name?: string }) { + const cat = db.prepare('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?').get(catId, templateId); + if (!cat) return { error: 'Category not found', status: 404 }; + if (data.name?.trim()) db.prepare('UPDATE packing_template_categories SET name = ? WHERE id = ?').run(data.name.trim(), catId); + return { category: db.prepare('SELECT * FROM packing_template_categories WHERE id = ?').get(catId) }; +} + +export function deleteTemplateCategory(templateId: string, catId: string) { + const cat = db.prepare('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?').get(catId, templateId); + if (!cat) return { error: 'Category not found', status: 404 }; + db.prepare('DELETE FROM packing_template_categories WHERE id = ?').run(catId); + return {}; +} + +// Template items + +export function createTemplateItem(templateId: string, catId: string, name: string) { + if (!name?.trim()) return { error: 'Item name is required', status: 400 }; + const cat = db.prepare('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?').get(catId, templateId); + if (!cat) return { error: 'Category not found', status: 404 }; + const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_template_items WHERE category_id = ?').get(catId) as { max: number | null }; + const result = db.prepare('INSERT INTO packing_template_items (category_id, name, sort_order) VALUES (?, ?, ?)').run(catId, name.trim(), (maxOrder.max ?? -1) + 1); + return { item: db.prepare('SELECT * FROM packing_template_items WHERE id = ?').get(result.lastInsertRowid) }; +} + +export function updateTemplateItem(itemId: string, data: { name?: string }) { + const item = db.prepare('SELECT * FROM packing_template_items WHERE id = ?').get(itemId); + if (!item) return { error: 'Item not found', status: 404 }; + if (data.name?.trim()) db.prepare('UPDATE packing_template_items SET name = ? WHERE id = ?').run(data.name.trim(), itemId); + return { item: db.prepare('SELECT * FROM packing_template_items WHERE id = ?').get(itemId) }; +} + +export function deleteTemplateItem(itemId: string) { + const item = db.prepare('SELECT * FROM packing_template_items WHERE id = ?').get(itemId); + if (!item) return { error: 'Item not found', status: 404 }; + db.prepare('DELETE FROM packing_template_items WHERE id = ?').run(itemId); + return {}; +} + +// ── Addons ───────────────────────────────────────────────────────────────── + +export function listAddons() { + const addons = db.prepare('SELECT * FROM addons ORDER BY sort_order, id').all() as Addon[]; + return addons.map(a => ({ ...a, enabled: !!a.enabled, config: JSON.parse(a.config || '{}') })); +} + +export function updateAddon(id: string, data: { enabled?: boolean; config?: Record }) { + const addon = db.prepare('SELECT * FROM addons WHERE id = ?').get(id); + if (!addon) return { error: 'Addon not found', status: 404 }; + if (data.enabled !== undefined) db.prepare('UPDATE addons SET enabled = ? WHERE id = ?').run(data.enabled ? 1 : 0, id); + if (data.config !== undefined) db.prepare('UPDATE addons SET config = ? WHERE id = ?').run(JSON.stringify(data.config), id); + const updated = db.prepare('SELECT * FROM addons WHERE id = ?').get(id) as Addon; + return { + addon: { ...updated, enabled: !!updated.enabled, config: JSON.parse(updated.config || '{}') }, + auditDetails: { enabled: data.enabled !== undefined ? !!data.enabled : undefined, config_changed: data.config !== undefined }, + }; +} + +// ── MCP Tokens ───────────────────────────────────────────────────────────── + +export function listMcpTokens() { + return db.prepare(` + SELECT t.id, t.name, t.token_prefix, t.created_at, t.last_used_at, t.user_id, u.username + FROM mcp_tokens t + JOIN users u ON u.id = t.user_id + ORDER BY t.created_at DESC + `).all(); +} + +export function deleteMcpToken(id: string) { + const token = db.prepare('SELECT id, user_id FROM mcp_tokens WHERE id = ?').get(id) as { id: number; user_id: number } | undefined; + if (!token) return { error: 'Token not found', status: 404 }; + db.prepare('DELETE FROM mcp_tokens WHERE id = ?').run(id); + revokeUserSessions(token.user_id); + return {}; +} + +// ── JWT Rotation ─────────────────────────────────────────────────────────── + +export function rotateJwtSecret(): { error?: string; status?: number } { + const newSecret = crypto.randomBytes(32).toString('hex'); + const dataDir = path.resolve(__dirname, '../../data'); + const secretFile = path.join(dataDir, '.jwt_secret'); + try { + if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); + fs.writeFileSync(secretFile, newSecret, { mode: 0o600 }); + } catch (err: unknown) { + return { error: 'Failed to persist new JWT secret to disk', status: 500 }; + } + updateJwtSecret(newSecret); + return {}; +} diff --git a/server/src/services/assignmentService.ts b/server/src/services/assignmentService.ts new file mode 100644 index 0000000..008553a --- /dev/null +++ b/server/src/services/assignmentService.ts @@ -0,0 +1,184 @@ +import { db } from '../db/database'; +import { loadTagsByPlaceIds, loadParticipantsByAssignmentIds, formatAssignmentWithPlace } from './queryHelpers'; +import { AssignmentRow, DayAssignment } from '../types'; + +export 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, + COALESCE(da.assignment_time, p.place_time) as place_time, + COALESCE(da.assignment_end_time, p.end_time) as end_time, + p.duration_minutes, p.notes as place_notes, + p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone, + c.name as category_name, c.color as category_color, c.icon as category_icon + FROM day_assignments da + JOIN places p ON da.place_id = p.id + LEFT JOIN categories c ON p.category_id = c.id + WHERE da.id = ? + `).get(assignmentId) as AssignmentRow | undefined; + + if (!a) 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(a.place_id); + + const participants = db.prepare(` + SELECT ap.user_id, u.username, u.avatar + FROM assignment_participants ap + JOIN users u ON ap.user_id = u.id + WHERE ap.assignment_id = ? + `).all(a.id); + + return { + id: a.id, + day_id: a.day_id, + order_index: a.order_index, + notes: a.notes, + 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, + } + }; +} + +export function listDayAssignments(dayId: string | number) { + 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, + COALESCE(da.assignment_time, p.place_time) as place_time, + COALESCE(da.assignment_end_time, p.end_time) as end_time, + p.duration_minutes, p.notes as place_notes, + p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone, + c.name as category_name, c.color as category_color, c.icon as category_icon + FROM day_assignments da + JOIN places p ON da.place_id = p.id + 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) as AssignmentRow[]; + + const placeIds = [...new Set(assignments.map(a => a.place_id))]; + const tagsByPlaceId = loadTagsByPlaceIds(placeIds, { compact: true }); + + const assignmentIds = assignments.map(a => a.id); + const participantsByAssignment = loadParticipantsByAssignmentIds(assignmentIds); + + return assignments.map(a => { + return formatAssignmentWithPlace(a, tagsByPlaceId[a.place_id] || [], participantsByAssignment[a.id] || []); + }); +} + +export function dayExists(dayId: string | number, tripId: string | number) { + return !!db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId); +} + +export function placeExists(placeId: string | number, tripId: string | number) { + return !!db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId); +} + +export function createAssignment(dayId: string | number, placeId: string | number, notes: string | null) { + 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( + 'INSERT INTO day_assignments (day_id, place_id, order_index, notes) VALUES (?, ?, ?, ?)' + ).run(dayId, placeId, orderIndex, notes || null); + + return getAssignmentWithPlace(result.lastInsertRowid); +} + +export function assignmentExistsInDay(id: string | number, dayId: string | number, tripId: string | number) { + return !!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); +} + +export function deleteAssignment(id: string | number) { + db.prepare('DELETE FROM day_assignments WHERE id = ?').run(id); +} + +export function reorderAssignments(dayId: string | number, orderedIds: number[]) { + const update = db.prepare('UPDATE day_assignments SET order_index = ? WHERE id = ? AND day_id = ?'); + db.exec('BEGIN'); + try { + orderedIds.forEach((id: number, index: number) => { + update.run(index, id, dayId); + }); + db.exec('COMMIT'); + } catch (e) { + db.exec('ROLLBACK'); + throw e; + } +} + +export function getAssignmentForTrip(id: string | number, tripId: string | number) { + return 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) as DayAssignment | undefined; +} + +export function moveAssignment(id: string | number, newDayId: string | number, orderIndex: number, oldDayId: number) { + db.prepare('UPDATE day_assignments SET day_id = ?, order_index = ? WHERE id = ?').run(newDayId, orderIndex || 0, id); + const updated = getAssignmentWithPlace(Number(id)); + return { assignment: updated, oldDayId }; +} + +export function getParticipants(assignmentId: string | number) { + return db.prepare(` + SELECT ap.user_id, u.username, u.avatar + FROM assignment_participants ap + JOIN users u ON ap.user_id = u.id + WHERE ap.assignment_id = ? + `).all(assignmentId); +} + +export function updateTime(id: string | number, placeTime: string | null, endTime: string | null) { + db.prepare('UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ? WHERE id = ?') + .run(placeTime ?? null, endTime ?? null, id); + return getAssignmentWithPlace(Number(id)); +} + +export function setParticipants(assignmentId: string | number, userIds: number[]) { + db.prepare('DELETE FROM assignment_participants WHERE assignment_id = ?').run(assignmentId); + if (userIds.length > 0) { + const insert = db.prepare('INSERT OR IGNORE INTO assignment_participants (assignment_id, user_id) VALUES (?, ?)'); + for (const userId of userIds) insert.run(assignmentId, userId); + } + + return db.prepare(` + SELECT ap.user_id, u.username, u.avatar + FROM assignment_participants ap + JOIN users u ON ap.user_id = u.id + WHERE ap.assignment_id = ? + `).all(assignmentId); +} diff --git a/server/src/services/atlasService.ts b/server/src/services/atlasService.ts new file mode 100644 index 0000000..1ef896b --- /dev/null +++ b/server/src/services/atlasService.ts @@ -0,0 +1,378 @@ +import fetch from 'node-fetch'; +import { db } from '../db/database'; +import { Trip, Place } from '../types'; + +// ── Geocode cache ─────────────────────────────────────────────────────────── + +const geocodeCache = new Map(); + +function roundKey(lat: number, lng: number): string { + return `${lat.toFixed(3)},${lng.toFixed(3)}`; +} + +function cacheKey(lat: number, lng: number): string { + return roundKey(lat, lng); +} + +export function getCached(lat: number, lng: number): string | null | undefined { + const key = cacheKey(lat, lng); + if (geocodeCache.has(key)) return geocodeCache.get(key)!; + return undefined; +} + +export function setCache(lat: number, lng: number, code: string | null): void { + geocodeCache.set(cacheKey(lat, lng), code); +} + +// Periodically trim the cache so it doesn't grow unbounded +const CACHE_MAX = 50_000; +const CACHE_CLEANUP_MS = 10 * 60 * 1000; +setInterval(() => { + if (geocodeCache.size > CACHE_MAX) { + const keys = [...geocodeCache.keys()]; + const toDelete = keys.slice(0, keys.length - CACHE_MAX); + for (const k of toDelete) geocodeCache.delete(k); + } +}, CACHE_CLEANUP_MS).unref(); + +// ── Bounding-box lookup tables ────────────────────────────────────────────── + +export const COUNTRY_BOXES: Record = { + 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], + BD:[88.0,20.7,92.7,26.6],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], + CN:[73.6,18.2,134.8,53.6],CO:[-79.1,-4.3,-66.9,12.5],HR:[13.5,42.4,19.5,46.6],CZ:[12.1,48.6,18.9,51.1],DK:[8,54.6,15.2,57.8], + EG:[24.7,22,37,31.7],EE:[21.8,57.5,28.2,59.7],FI:[20.6,59.8,31.6,70.1],FR:[-5.1,41.3,9.6,51.1],DE:[5.9,47.3,15.1,55.1], + GR:[19.4,34.8,29.7,41.8],HU:[16,45.7,22.9,48.6],IS:[-24.5,63.4,-13.5,66.6],IN:[68.2,6.7,97.4,35.5],ID:[95.3,-11,141,5.9], + IR:[44.1,25.1,63.3,39.8],IQ:[38.8,29.1,48.6,37.4],IE:[-10.5,51.4,-6,55.4],IL:[34.3,29.5,35.9,33.3],IT:[6.6,36.6,18.5,47.1], + JP:[129.4,31.1,145.5,45.5],KE:[33.9,-4.7,41.9,5.5],KR:[126,33.2,129.6,38.6],LV:[21,55.7,28.2,58.1],LT:[21,53.9,26.8,56.5], + LU:[5.7,49.4,6.5,50.2],MY:[99.6,0.9,119.3,7.4],MX:[-118.4,14.5,-86.7,32.7],MA:[-13.2,27.7,-1,35.9],NL:[3.4,50.8,7.2,53.5], + NZ:[166.4,-47.3,178.5,-34.4],NO:[4.6,58,31.1,71.2],PK:[60.9,23.7,77.1,37.1],PE:[-81.3,-18.4,-68.7,-0.1],PH:[117,5,126.6,18.5], + PL:[14.1,49,24.1,54.9],PT:[-9.5,36.8,-6.2,42.2],RO:[20.2,43.6,29.7,48.3],RU:[19.6,41.2,180,81.9],SA:[34.6,16.4,55.7,32.2], + RS:[18.8,42.2,23,46.2],SK:[16.8,47.7,22.6,49.6],SI:[13.4,45.4,16.6,46.9],ZA:[16.5,-34.8,32.9,-22.1],ES:[-9.4,36,-0.2,43.8], + SE:[11.1,55.3,24.2,69.1],CH:[6,45.8,10.5,47.8],TH:[97.3,5.6,105.6,20.5],TR:[26,36,44.8,42.1],UA:[22.1,44.4,40.2,52.4], + 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], +}; + +export const NAME_TO_CODE: Record = { + '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', +}; + +export const CONTINENT_MAP: Record = { + 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', +}; + +// ── Geocoding helpers ─────────────────────────────────────────────────────── + +export async function reverseGeocodeCountry(lat: number, lng: number): Promise { + const key = roundKey(lat, lng); + if (geocodeCache.has(key)) return geocodeCache.get(key)!; + try { + const res = await fetch(`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&zoom=3&accept-language=en`, { + headers: { 'User-Agent': 'TREK Travel Planner' }, + }); + if (!res.ok) return null; + const data = await res.json() as { address?: { country_code?: string } }; + const code = data.address?.country_code?.toUpperCase() || null; + geocodeCache.set(key, code); + return code; + } catch { + return null; + } +} + +export function getCountryFromCoords(lat: number, lng: number): string | null { + let bestCode: string | null = null; + let bestArea = Infinity; + for (const [code, [minLng, minLat, maxLng, maxLat]] of Object.entries(COUNTRY_BOXES)) { + if (lat >= minLat && lat <= maxLat && lng >= minLng && lng <= maxLng) { + const area = (maxLng - minLng) * (maxLat - minLat); + if (area < bestArea) { + bestArea = area; + bestCode = code; + } + } + } + return bestCode; +} + +export function getCountryFromAddress(address: string | null): string | null { + if (!address) return null; + const parts = address.split(',').map(s => s.trim()).filter(Boolean); + if (parts.length === 0) return null; + const last = parts[parts.length - 1]; + const normalized = last.toLowerCase(); + if (NAME_TO_CODE[normalized]) return NAME_TO_CODE[normalized]; + if (NAME_TO_CODE[last]) return NAME_TO_CODE[last]; + if (last.length === 2 && last === last.toUpperCase()) return last; + return null; +} + +// ── Resolve a place to a country code (address -> geocode -> bbox) ────────── + +async function resolveCountryCode(place: Place): Promise { + let code = getCountryFromAddress(place.address); + if (!code && place.lat && place.lng) { + code = await reverseGeocodeCountry(place.lat, place.lng); + } + if (!code && place.lat && place.lng) { + code = getCountryFromCoords(place.lat, place.lng); + } + return code; +} + +function resolveCountryCodeSync(place: Place): string | null { + let code = getCountryFromAddress(place.address); + if (!code && place.lat && place.lng) { + code = getCountryFromCoords(place.lat, place.lng); + } + return code; +} + +// ── Shared query: all trips the user owns or is a member of ───────────────── + +function getUserTrips(userId: number): Trip[] { + return 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) as Trip[]; +} + +function getPlacesForTrips(tripIds: number[]): Place[] { + if (tripIds.length === 0) return []; + const placeholders = tripIds.map(() => '?').join(','); + return db.prepare(`SELECT * FROM places WHERE trip_id IN (${placeholders})`).all(...tripIds) as Place[]; +} + +// ── getStats ──────────────────────────────────────────────────────────────── + +export async function getStats(userId: number) { + const trips = getUserTrips(userId); + const tripIds = trips.map(t => t.id); + + if (tripIds.length === 0) { + const manualCountries = db.prepare('SELECT country_code FROM visited_countries WHERE user_id = ?').all(userId) as { country_code: string }[]; + const countries = manualCountries.map(mc => ({ code: mc.country_code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null })); + return { countries, trips: [], stats: { totalTrips: 0, totalPlaces: 0, totalCountries: countries.length, totalDays: 0 } }; + } + + const places = getPlacesForTrips(tripIds); + + interface CountryEntry { code: string; places: { id: number; name: string; lat: number | null; lng: number | null }[]; tripIds: Set } + const countrySet = new Map(); + for (const place of places) { + const code = await resolveCountryCode(place); + if (code) { + 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 ?? null, lng: place.lng ?? null }); + countrySet.get(code)!.tripIds.add(place.trip_id); + } + } + + 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.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) + 1; + if (diff > 0) totalDays += diff; + } + } + + const countries = [...countrySet.values()].map(c => { + const countryTrips = trips.filter(t => c.tripIds.has(t.id)); + const dates = countryTrips.map(t => t.start_date).filter(Boolean).sort(); + return { + code: c.code, + placeCount: c.places.length, + tripCount: c.tripIds.size, + firstVisit: dates[0] || null, + lastVisit: dates[dates.length - 1] || null, + }; + }); + + const citySet = new Set(); + for (const place of places) { + if (place.address) { + 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\-\u2212\u3012]+/g, '').trim().toLowerCase(); + if (city) citySet.add(city); + } + } + } + const totalCities = citySet.size; + + // Merge manually marked countries + const manualCountries = db.prepare('SELECT country_code FROM visited_countries WHERE user_id = ?').all(userId) as { country_code: string }[]; + for (const mc of manualCountries) { + if (!countries.find(c => c.code === mc.country_code)) { + countries.push({ code: mc.country_code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }); + } + } + + const mostVisited = countries.length > 0 ? countries.reduce((a, b) => a.placeCount > b.placeCount ? a : b) : null; + + const continents: Record = {}; + countries.forEach(c => { + const cont = CONTINENT_MAP[c.code] || 'Other'; + continents[cont] = (continents[cont] || 0) + 1; + }); + + 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: { 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) { + const code = resolveCountryCodeSync(p); + if (code) { lastTrip.countryCode = code; break; } + } + } + + const futureTrips = trips.filter(t => t.start_date && t.start_date > now).sort((a, b) => a.start_date!.localeCompare(b.start_date!)); + 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!).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24)); + nextTrip.daysUntil = Math.max(0, diff); + } + + 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(); + for (let y = currentYear; y >= 2000; y--) { + if (tripYears.has(y)) streak++; + else break; + } + const firstYear = tripYears.size > 0 ? Math.min(...tripYears) : null; + + return { + countries, + stats: { + totalTrips: trips.length, + totalPlaces: places.length, + totalCountries: countries.length, + totalDays, + totalCities, + }, + mostVisited, + continents, + lastTrip, + nextTrip, + streak, + firstYear, + tripsThisYear: trips.filter(t => t.start_date && t.start_date.startsWith(String(currentYear))).length, + }; +} + +// ── getCountryPlaces ──────────────────────────────────────────────────────── + +export function getCountryPlaces(userId: number, code: string) { + const trips = getUserTrips(userId); + const tripIds = trips.map(t => t.id); + if (tripIds.length === 0) return { places: [], trips: [], manually_marked: false }; + + const places = getPlacesForTrips(tripIds); + + const matchingPlaces: { id: number; name: string; address: string | null; lat: number | null; lng: number | null; trip_id: number }[] = []; + const matchingTripIds = new Set(); + + for (const place of places) { + const pCode = resolveCountryCodeSync(place); + if (pCode === code) { + matchingPlaces.push({ id: place.id, name: place.name, address: place.address ?? null, lat: place.lat ?? null, lng: place.lng ?? null, trip_id: place.trip_id }); + matchingTripIds.add(place.trip_id); + } + } + + const matchingTrips = trips.filter(t => matchingTripIds.has(t.id)).map(t => ({ id: t.id, title: t.title, start_date: t.start_date, end_date: t.end_date })); + + const isManuallyMarked = !!(db.prepare('SELECT 1 FROM visited_countries WHERE user_id = ? AND country_code = ?').get(userId, code)); + return { places: matchingPlaces, trips: matchingTrips, manually_marked: isManuallyMarked }; +} + +// ── Mark / unmark country ─────────────────────────────────────────────────── + +export function markCountryVisited(userId: number, code: string): void { + db.prepare('INSERT OR IGNORE INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(userId, code); +} + +export function unmarkCountryVisited(userId: number, code: string): void { + db.prepare('DELETE FROM visited_countries WHERE user_id = ? AND country_code = ?').run(userId, code); +} + +// ── Bucket list CRUD ──────────────────────────────────────────────────────── + +export function listBucketList(userId: number) { + return db.prepare('SELECT * FROM bucket_list WHERE user_id = ? ORDER BY created_at DESC').all(userId); +} + +export function createBucketItem(userId: number, data: { name: string; lat?: number | null; lng?: number | null; country_code?: string | null; notes?: string | null; target_date?: string | null }) { + const result = db.prepare('INSERT INTO bucket_list (user_id, name, lat, lng, country_code, notes, target_date) VALUES (?, ?, ?, ?, ?, ?, ?)').run( + userId, data.name.trim(), data.lat ?? null, data.lng ?? null, data.country_code ?? null, data.notes ?? null, data.target_date ?? null + ); + return db.prepare('SELECT * FROM bucket_list WHERE id = ?').get(result.lastInsertRowid); +} + +export function updateBucketItem(userId: number, itemId: string | number, data: { name?: string; notes?: string; lat?: number | null; lng?: number | null; country_code?: string | null; target_date?: string | null }) { + const item = db.prepare('SELECT * FROM bucket_list WHERE id = ? AND user_id = ?').get(itemId, userId); + if (!item) return null; + db.prepare(`UPDATE bucket_list SET + name = COALESCE(?, name), + notes = CASE WHEN ? THEN ? ELSE notes END, + lat = CASE WHEN ? THEN ? ELSE lat END, + lng = CASE WHEN ? THEN ? ELSE lng END, + country_code = CASE WHEN ? THEN ? ELSE country_code END, + target_date = CASE WHEN ? THEN ? ELSE target_date END + WHERE id = ?`).run( + data.name?.trim() || null, + data.notes !== undefined ? 1 : 0, data.notes !== undefined ? (data.notes || null) : null, + data.lat !== undefined ? 1 : 0, data.lat !== undefined ? (data.lat || null) : null, + data.lng !== undefined ? 1 : 0, data.lng !== undefined ? (data.lng || null) : null, + data.country_code !== undefined ? 1 : 0, data.country_code !== undefined ? (data.country_code || null) : null, + data.target_date !== undefined ? 1 : 0, data.target_date !== undefined ? (data.target_date || null) : null, + itemId + ); + return db.prepare('SELECT * FROM bucket_list WHERE id = ?').get(itemId); +} + +export function deleteBucketItem(userId: number, itemId: string | number): boolean { + const item = db.prepare('SELECT * FROM bucket_list WHERE id = ? AND user_id = ?').get(itemId, userId); + if (!item) return false; + db.prepare('DELETE FROM bucket_list WHERE id = ?').run(itemId); + return true; +} diff --git a/server/src/services/authService.ts b/server/src/services/authService.ts new file mode 100644 index 0000000..361cda2 --- /dev/null +++ b/server/src/services/authService.ts @@ -0,0 +1,989 @@ +import bcrypt from 'bcryptjs'; +import jwt from 'jsonwebtoken'; +import crypto from 'crypto'; +import path from 'path'; +import fs from 'fs'; +import fetch from 'node-fetch'; +import { authenticator } from 'otplib'; +import QRCode from 'qrcode'; +import { randomBytes, createHash } from 'crypto'; +import { db } from '../db/database'; +import { JWT_SECRET } from '../config'; +import { validatePassword } from './passwordPolicy'; +import { encryptMfaSecret, decryptMfaSecret } from './mfaCrypto'; +import { getAllPermissions } from './permissions'; +import { decrypt_api_key, maybe_encrypt_api_key, encrypt_api_key } from './apiKeyCrypto'; +import { createEphemeralToken } from './ephemeralTokens'; +import { revokeUserSessions } from '../mcp'; +import { startTripReminders } from '../scheduler'; +import { User } from '../types'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +authenticator.options = { window: 1 }; + +const MFA_SETUP_TTL_MS = 15 * 60 * 1000; +const mfaSetupPending = new Map(); +const MFA_BACKUP_CODE_COUNT = 10; + +const ADMIN_SETTINGS_KEYS = [ + 'allow_registration', 'allowed_file_types', 'require_mfa', + 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify', + 'notification_webhook_url', 'notification_channel', + 'notify_trip_invite', 'notify_booking_change', 'notify_trip_reminder', + 'notify_vacay_invite', 'notify_photos_shared', 'notify_collab_message', 'notify_packing_tagged', +]; + +const avatarDir = path.join(__dirname, '../../uploads/avatars'); +if (!fs.existsSync(avatarDir)) fs.mkdirSync(avatarDir, { recursive: true }); + +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', 'Sudkorea', 'Indonesia', 'Indonesien', 'Turkey', 'Turkei', 'Turkiye', + 'Greece', 'Griechenland', 'Portugal', 'Netherlands', 'Niederlande', 'Belgium', 'Belgien', + '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', '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', + 'Cuba', 'Kuba', 'Costa Rica', 'Panama', 'Ecuador', 'Bolivia', 'Bolivien', 'Uruguay', 'Paraguay', + 'Luxembourg', 'Luxemburg', 'Malta', 'Cyprus', 'Zypern', 'Estonia', 'Estland', + 'Latvia', 'Lettland', 'Lithuania', 'Litauen', 'Slovakia', 'Slowakei', 'Slovenia', 'Slowenien', + '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', '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', 'Weissrussland', +]); + +// --------------------------------------------------------------------------- +// Helpers (exported for route-level use where needed) +// --------------------------------------------------------------------------- + +export function utcSuffix(ts: string | null | undefined): string | null { + if (!ts) return null; + return ts.endsWith('Z') ? ts : ts.replace(' ', 'T') + 'Z'; +} + +export function stripUserForClient(user: User): Record { + const { + password_hash: _p, + maps_api_key: _m, + openweather_api_key: _o, + unsplash_api_key: _u, + mfa_secret: _mf, + mfa_backup_codes: _mbc, + ...rest + } = user; + return { + ...rest, + created_at: utcSuffix(rest.created_at), + updated_at: utcSuffix(rest.updated_at), + last_login: utcSuffix(rest.last_login), + mfa_enabled: !!(user.mfa_enabled === 1 || user.mfa_enabled === true), + must_change_password: !!(user.must_change_password === 1 || user.must_change_password === true), + }; +} + +export function maskKey(key: string | null | undefined): string | null { + if (!key) return null; + if (key.length <= 8) return '--------'; + return '----' + key.slice(-4); +} + +export function mask_stored_api_key(key: string | null | undefined): string | null { + const plain = decrypt_api_key(key); + return maskKey(plain); +} + +export function avatarUrl(user: { avatar?: string | null }): string | null { + return user.avatar ? `/uploads/avatars/${user.avatar}` : null; +} + +export function isOidcOnlyMode(): boolean { + const get = (key: string) => + (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || null; + const enabled = process.env.OIDC_ONLY === 'true' || get('oidc_only') === 'true'; + if (!enabled) return false; + const oidcConfigured = !!( + (process.env.OIDC_ISSUER || get('oidc_issuer')) && + (process.env.OIDC_CLIENT_ID || get('oidc_client_id')) + ); + return oidcConfigured; +} + +export function generateToken(user: { id: number | bigint }) { + return jwt.sign( + { id: user.id }, + JWT_SECRET, + { expiresIn: '24h', algorithm: 'HS256' } + ); +} + +// --------------------------------------------------------------------------- +// MFA helpers +// --------------------------------------------------------------------------- + +export function normalizeBackupCode(input: string): string { + return String(input || '').toUpperCase().replace(/[^A-Z0-9]/g, ''); +} + +export function hashBackupCode(input: string): string { + return crypto.createHash('sha256').update(normalizeBackupCode(input)).digest('hex'); +} + +export function generateBackupCodes(count = MFA_BACKUP_CODE_COUNT): string[] { + const codes: string[] = []; + while (codes.length < count) { + const raw = crypto.randomBytes(4).toString('hex').toUpperCase(); + const code = `${raw.slice(0, 4)}-${raw.slice(4)}`; + if (!codes.includes(code)) codes.push(code); + } + return codes; +} + +export function parseBackupCodeHashes(raw: string | null | undefined): string[] { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter(v => typeof v === 'string') : []; + } catch { + return []; + } +} + +export function getPendingMfaSecret(userId: number): string | null { + const row = mfaSetupPending.get(userId); + if (!row || Date.now() > row.exp) { + mfaSetupPending.delete(userId); + return null; + } + return row.secret; +} + +// --------------------------------------------------------------------------- +// App config (public) +// --------------------------------------------------------------------------- + +export function getAppConfig(authenticatedUser: { id: number } | null) { + 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 = process.env.OIDC_DISPLAY_NAME || + (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_display_name'").get() as { value: string } | undefined)?.value || null; + const oidcConfigured = !!( + (process.env.OIDC_ISSUER || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_issuer'").get() as { value: string } | undefined)?.value) && + (process.env.OIDC_CLIENT_ID || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_client_id'").get() as { value: string } | undefined)?.value) + ); + const oidcOnlySetting = process.env.OIDC_ONLY || + (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_only'").get() as { value: string } | undefined)?.value; + const oidcOnlyMode = oidcConfigured && oidcOnlySetting === 'true'; + const requireMfaRow = db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined; + const notifChannel = (db.prepare("SELECT value FROM app_settings WHERE key = 'notification_channel'").get() as { value: string } | undefined)?.value || 'none'; + const tripReminderSetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'notify_trip_reminder'").get() as { value: string } | undefined)?.value; + const hasSmtpHost = !!(process.env.SMTP_HOST || (db.prepare("SELECT value FROM app_settings WHERE key = 'smtp_host'").get() as { value: string } | undefined)?.value); + const hasWebhookUrl = !!(process.env.NOTIFICATION_WEBHOOK_URL || (db.prepare("SELECT value FROM app_settings WHERE key = 'notification_webhook_url'").get() as { value: string } | undefined)?.value); + const channelConfigured = (notifChannel === 'email' && hasSmtpHost) || (notifChannel === 'webhook' && hasWebhookUrl); + const tripRemindersEnabled = channelConfigured && tripReminderSetting !== 'false'; + const setupComplete = userCount > 0 && !(db.prepare("SELECT id FROM users WHERE role = 'admin' AND must_change_password = 1 LIMIT 1").get()); + + return { + allow_registration: isDemo ? false : allowRegistration, + has_users: userCount > 0, + setup_complete: setupComplete, + version, + has_maps_key: hasGoogleKey, + oidc_configured: oidcConfigured, + oidc_display_name: oidcConfigured ? (oidcDisplayName || 'SSO') : undefined, + oidc_only_mode: oidcOnlyMode, + require_mfa: requireMfaRow?.value === 'true', + 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@trek.app' : undefined, + demo_password: isDemo ? 'demo12345' : undefined, + timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC', + notification_channel: notifChannel, + trip_reminders_enabled: tripRemindersEnabled, + permissions: authenticatedUser ? getAllPermissions() : undefined, + }; +} + +// --------------------------------------------------------------------------- +// Auth: register, login, demo +// --------------------------------------------------------------------------- + +export function demoLogin(): { error?: string; status?: number; token?: string; user?: Record } { + if (process.env.DEMO_MODE !== 'true') { + return { error: 'Not found', status: 404 }; + } + const user = db.prepare('SELECT * FROM users WHERE email = ?').get('demo@trek.app') as User | undefined; + if (!user) return { error: 'Demo user not found', status: 500 }; + const token = generateToken(user); + const safe = stripUserForClient(user) as Record; + return { token, user: { ...safe, avatar_url: avatarUrl(user) } }; +} + +export function validateInviteToken(token: string): { error?: string; status?: number; valid?: boolean; max_uses?: number; used_count?: number; expires_at?: string } { + const invite = db.prepare('SELECT * FROM invite_tokens WHERE token = ?').get(token) as any; + if (!invite) return { error: 'Invalid invite link', status: 404 }; + if (invite.max_uses > 0 && invite.used_count >= invite.max_uses) return { error: 'Invite link has been fully used', status: 410 }; + if (invite.expires_at && new Date(invite.expires_at) < new Date()) return { error: 'Invite link has expired', status: 410 }; + return { valid: true, max_uses: invite.max_uses, used_count: invite.used_count, expires_at: invite.expires_at }; +} + +export function registerUser(body: { + username?: string; + email?: string; + password?: string; + invite_token?: string; +}): { error?: string; status?: number; token?: string; user?: Record; auditUserId?: number; auditDetails?: Record } { + const { username, email, password, invite_token } = body; + + const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count; + + let validInvite: any = null; + if (invite_token) { + validInvite = db.prepare('SELECT * FROM invite_tokens WHERE token = ?').get(invite_token); + if (!validInvite) return { error: 'Invalid invite link', status: 400 }; + if (validInvite.max_uses > 0 && validInvite.used_count >= validInvite.max_uses) return { error: 'Invite link has been fully used', status: 410 }; + if (validInvite.expires_at && new Date(validInvite.expires_at) < new Date()) return { error: 'Invite link has expired', status: 410 }; + } + + if (userCount > 0 && !validInvite) { + if (isOidcOnlyMode()) { + return { error: 'Password authentication is disabled. Please sign in with SSO.', status: 403 }; + } + const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get() as { value: string } | undefined; + if (setting?.value === 'false') { + return { error: 'Registration is disabled. Contact your administrator.', status: 403 }; + } + } + + if (!username || !email || !password) { + return { error: 'Username, email and password are required', status: 400 }; + } + + const pwCheck = validatePassword(password); + if (!pwCheck.ok) return { error: pwCheck.reason, status: 400 }; + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + return { error: 'Invalid email format', status: 400 }; + } + + const existingUser = db.prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?) OR LOWER(username) = LOWER(?)').get(email, username); + if (existingUser) { + return { error: 'Registration failed. Please try different credentials.', status: 409 }; + } + + const password_hash = bcrypt.hashSync(password, 12); + const isFirstUser = userCount === 0; + const role = isFirstUser ? 'admin' : 'user'; + + try { + const result = db.prepare( + 'INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)' + ).run(username, email, password_hash, role); + + const user = { id: result.lastInsertRowid, username, email, role, avatar: null, mfa_enabled: false }; + const token = generateToken(user); + + if (validInvite) { + const updated = db.prepare( + 'UPDATE invite_tokens SET used_count = used_count + 1 WHERE id = ? AND (max_uses = 0 OR used_count < max_uses) RETURNING used_count' + ).get(validInvite.id); + if (!updated) { + console.warn(`[Auth] Invite token ${validInvite.token.slice(0, 8)}... exceeded max_uses due to race condition`); + } + } + + return { + token, + user: { ...user, avatar_url: null }, + auditUserId: Number(result.lastInsertRowid), + auditDetails: { username, email, role }, + }; + } catch { + return { error: 'Error creating user', status: 500 }; + } +} + +export function loginUser(body: { + email?: string; + password?: string; +}): { + error?: string; + status?: number; + token?: string; + user?: Record; + mfa_required?: boolean; + mfa_token?: string; + auditUserId?: number | null; + auditAction?: string; + auditDetails?: Record; +} { + if (isOidcOnlyMode()) { + return { error: 'Password authentication is disabled. Please sign in with SSO.', status: 403 }; + } + + const { email, password } = body; + if (!email || !password) { + return { error: 'Email and password are required', status: 400 }; + } + + const user = db.prepare('SELECT * FROM users WHERE LOWER(email) = LOWER(?)').get(email) as User | undefined; + if (!user) { + return { + error: 'Invalid email or password', status: 401, + auditUserId: null, auditAction: 'user.login_failed', auditDetails: { email, reason: 'unknown_email' }, + }; + } + + const validPassword = bcrypt.compareSync(password, user.password_hash!); + if (!validPassword) { + return { + error: 'Invalid email or password', status: 401, + auditUserId: Number(user.id), auditAction: 'user.login_failed', auditDetails: { email, reason: 'wrong_password' }, + }; + } + + if (user.mfa_enabled === 1 || user.mfa_enabled === true) { + const mfa_token = jwt.sign( + { id: Number(user.id), purpose: 'mfa_login' }, + JWT_SECRET, + { expiresIn: '5m', algorithm: 'HS256' } + ); + return { mfa_required: true, mfa_token }; + } + + db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(user.id); + const token = generateToken(user); + const userSafe = stripUserForClient(user) as Record; + + return { + token, + user: { ...userSafe, avatar_url: avatarUrl(user) }, + auditUserId: Number(user.id), + auditAction: 'user.login', + auditDetails: { email }, + }; +} + +// --------------------------------------------------------------------------- +// Session +// --------------------------------------------------------------------------- + +export function getCurrentUser(userId: number) { + const user = db.prepare( + 'SELECT id, username, email, role, avatar, oidc_issuer, created_at, mfa_enabled, must_change_password FROM users WHERE id = ?' + ).get(userId) as User | undefined; + if (!user) return null; + const base = stripUserForClient(user as User) as Record; + return { ...base, avatar_url: avatarUrl(user) }; +} + +// --------------------------------------------------------------------------- +// Password & account +// --------------------------------------------------------------------------- + +export function changePassword( + userId: number, + userEmail: string, + body: { current_password?: string; new_password?: string } +): { error?: string; status?: number; success?: boolean } { + if (isOidcOnlyMode()) { + return { error: 'Password authentication is disabled.', status: 403 }; + } + if (process.env.DEMO_MODE === 'true' && userEmail === 'demo@trek.app') { + return { error: 'Password change is disabled in demo mode.', status: 403 }; + } + + const { current_password, new_password } = body; + if (!current_password) return { error: 'Current password is required', status: 400 }; + if (!new_password) return { error: 'New password is required', status: 400 }; + + const pwCheck = validatePassword(new_password); + if (!pwCheck.ok) return { error: pwCheck.reason, status: 400 }; + + const user = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(userId) as { password_hash: string } | undefined; + if (!user || !bcrypt.compareSync(current_password, user.password_hash)) { + return { error: 'Current password is incorrect', status: 401 }; + } + + const hash = bcrypt.hashSync(new_password, 12); + db.prepare('UPDATE users SET password_hash = ?, must_change_password = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(hash, userId); + return { success: true }; +} + +export function deleteAccount(userId: number, userEmail: string, userRole: string): { error?: string; status?: number; success?: boolean } { + if (process.env.DEMO_MODE === 'true' && userEmail === 'demo@trek.app') { + return { error: 'Account deletion is disabled in demo mode.', status: 403 }; + } + if (userRole === 'admin') { + const adminCount = (db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin'").get() as { count: number }).count; + if (adminCount <= 1) { + return { error: 'Cannot delete the last admin account', status: 400 }; + } + } + db.prepare('DELETE FROM users WHERE id = ?').run(userId); + return { success: true }; +} + +// --------------------------------------------------------------------------- +// API keys +// --------------------------------------------------------------------------- + +export function updateMapsKey(userId: number, maps_api_key: string | null | undefined) { + db.prepare( + 'UPDATE users SET maps_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?' + ).run(maybe_encrypt_api_key(maps_api_key), userId); + return { success: true, maps_api_key: mask_stored_api_key(maps_api_key) }; +} + +export function updateApiKeys( + userId: number, + body: { maps_api_key?: string; openweather_api_key?: string } +) { + const current = db.prepare('SELECT maps_api_key, openweather_api_key FROM users WHERE id = ?').get(userId) as Pick | undefined; + + db.prepare( + 'UPDATE users SET maps_api_key = ?, openweather_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?' + ).run( + body.maps_api_key !== undefined ? maybe_encrypt_api_key(body.maps_api_key) : current!.maps_api_key, + body.openweather_api_key !== undefined ? maybe_encrypt_api_key(body.openweather_api_key) : current!.openweather_api_key, + userId + ); + + const updated = db.prepare( + 'SELECT id, username, email, role, maps_api_key, openweather_api_key, avatar, mfa_enabled FROM users WHERE id = ?' + ).get(userId) as Pick | undefined; + + const u = updated ? { ...updated, mfa_enabled: !!(updated.mfa_enabled === 1 || updated.mfa_enabled === true) } : undefined; + return { + success: true, + user: { ...u, maps_api_key: mask_stored_api_key(u?.maps_api_key), openweather_api_key: mask_stored_api_key(u?.openweather_api_key), avatar_url: avatarUrl(updated || {}) }, + }; +} + +export function updateSettings( + userId: number, + body: { maps_api_key?: string; openweather_api_key?: string; username?: string; email?: string } +): { error?: string; status?: number; success?: boolean; user?: Record } { + const { maps_api_key, openweather_api_key, username, email } = body; + + if (username !== undefined) { + const trimmed = username.trim(); + if (!trimmed || trimmed.length < 2 || trimmed.length > 50) { + return { error: 'Username must be between 2 and 50 characters', status: 400 }; + } + if (!/^[a-zA-Z0-9_.-]+$/.test(trimmed)) { + return { error: 'Username can only contain letters, numbers, underscores, dots and hyphens', status: 400 }; + } + const conflict = db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?) AND id != ?').get(trimmed, userId); + if (conflict) return { error: 'Username already taken', status: 409 }; + } + + if (email !== undefined) { + const trimmed = email.trim(); + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!trimmed || !emailRegex.test(trimmed)) { + return { error: 'Invalid email format', status: 400 }; + } + const conflict = db.prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?) AND id != ?').get(trimmed, userId); + if (conflict) return { error: 'Email already taken', status: 409 }; + } + + const updates: string[] = []; + const params: (string | number | null)[] = []; + + if (maps_api_key !== undefined) { updates.push('maps_api_key = ?'); params.push(maybe_encrypt_api_key(maps_api_key)); } + if (openweather_api_key !== undefined) { updates.push('openweather_api_key = ?'); params.push(maybe_encrypt_api_key(openweather_api_key)); } + 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(userId); + 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, mfa_enabled FROM users WHERE id = ?' + ).get(userId) as Pick | undefined; + + const u = updated ? { ...updated, mfa_enabled: !!(updated.mfa_enabled === 1 || updated.mfa_enabled === true) } : undefined; + return { + success: true, + user: { ...u, maps_api_key: mask_stored_api_key(u?.maps_api_key), openweather_api_key: mask_stored_api_key(u?.openweather_api_key), avatar_url: avatarUrl(updated || {}) }, + }; +} + +export function getSettings(userId: number): { error?: string; status?: number; settings?: Record } { + const user = db.prepare( + 'SELECT role, maps_api_key, openweather_api_key FROM users WHERE id = ?' + ).get(userId) as Pick | undefined; + if (user?.role !== 'admin') return { error: 'Admin access required', status: 403 }; + + return { + settings: { + maps_api_key: decrypt_api_key(user.maps_api_key), + openweather_api_key: decrypt_api_key(user.openweather_api_key), + }, + }; +} + +// --------------------------------------------------------------------------- +// Avatar +// --------------------------------------------------------------------------- + +export function saveAvatar(userId: number, filename: string) { + const current = db.prepare('SELECT avatar FROM users WHERE id = ?').get(userId) as { avatar: string | null } | undefined; + if (current && current.avatar) { + const oldPath = path.join(avatarDir, current.avatar); + if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath); + } + + db.prepare('UPDATE users SET avatar = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(filename, userId); + + const updated = db.prepare('SELECT id, username, email, role, avatar FROM users WHERE id = ?').get(userId) as Pick | undefined; + return { success: true, avatar_url: avatarUrl(updated || {}) }; +} + +export function deleteAvatar(userId: number) { + const current = db.prepare('SELECT avatar FROM users WHERE id = ?').get(userId) 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(userId); + return { success: true }; +} + +// --------------------------------------------------------------------------- +// User directory +// --------------------------------------------------------------------------- + +export function listUsers(excludeUserId: number) { + const users = db.prepare( + 'SELECT id, username, avatar FROM users WHERE id != ? ORDER BY username ASC' + ).all(excludeUserId) as Pick[]; + return users.map(u => ({ ...u, avatar_url: avatarUrl(u) })); +} + +// --------------------------------------------------------------------------- +// Key validation +// --------------------------------------------------------------------------- + +export async function validateKeys(userId: number): Promise<{ error?: string; status?: number; maps: boolean; weather: boolean; maps_details: null | { ok: boolean; status: number | null; status_text: string | null; error_message: string | null; error_status: string | null; error_raw: string | null } }> { + const user = db.prepare('SELECT role, maps_api_key, openweather_api_key FROM users WHERE id = ?').get(userId) as Pick | undefined; + if (user?.role !== 'admin') return { error: 'Admin access required', status: 403, maps: false, weather: false, maps_details: null }; + + const result: { + maps: boolean; + weather: boolean; + maps_details: null | { + ok: boolean; + status: number | null; + status_text: string | null; + error_message: string | null; + error_status: string | null; + error_raw: string | null; + }; + } = { maps: false, weather: false, maps_details: null }; + + const maps_api_key = decrypt_api_key(user.maps_api_key); + if (maps_api_key) { + try { + const mapsRes = await fetch( + `https://places.googleapis.com/v1/places:searchText`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Goog-Api-Key': maps_api_key, + 'X-Goog-FieldMask': 'places.displayName', + }, + body: JSON.stringify({ textQuery: 'test' }), + } + ); + result.maps = mapsRes.status === 200; + let error_text: string | null = null; + let error_json: any = null; + if (!result.maps) { + try { + error_text = await mapsRes.text(); + try { error_json = JSON.parse(error_text); } catch { error_json = null; } + } catch { error_text = null; error_json = null; } + } + result.maps_details = { + ok: result.maps, + status: mapsRes.status, + status_text: mapsRes.statusText || null, + error_message: error_json?.error?.message || null, + error_status: error_json?.error?.status || null, + error_raw: error_text, + }; + } catch (err: unknown) { + result.maps = false; + result.maps_details = { + ok: false, + status: null, + status_text: null, + error_message: err instanceof Error ? err.message : 'Request failed', + error_status: 'FETCH_ERROR', + error_raw: null, + }; + } + } + + const openweather_api_key = decrypt_api_key(user.openweather_api_key); + if (openweather_api_key) { + try { + const weatherRes = await fetch( + `https://api.openweathermap.org/data/2.5/weather?q=London&appid=${openweather_api_key}` + ); + result.weather = weatherRes.status === 200; + } catch { + result.weather = false; + } + } + + return result; +} + +// --------------------------------------------------------------------------- +// Admin settings +// --------------------------------------------------------------------------- + +export function getAppSettings(userId: number): { error?: string; status?: number; data?: Record } { + const user = db.prepare('SELECT role FROM users WHERE id = ?').get(userId) as { role: string } | undefined; + if (user?.role !== 'admin') return { error: 'Admin access required', status: 403 }; + + const result: Record = {}; + for (const key of ADMIN_SETTINGS_KEYS) { + const row = db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined; + if (row) result[key] = key === 'smtp_pass' ? '••••••••' : row.value; + } + return { data: result }; +} + +export function updateAppSettings( + userId: number, + body: Record +): { + error?: string; + status?: number; + success?: boolean; + auditSummary?: Record; + auditDebugDetails?: Record; + shouldRestartScheduler?: boolean; +} { + const user = db.prepare('SELECT role FROM users WHERE id = ?').get(userId) as { role: string } | undefined; + if (user?.role !== 'admin') return { error: 'Admin access required', status: 403 }; + + const { require_mfa } = body; + if (require_mfa === true || require_mfa === 'true') { + const adminMfa = db.prepare('SELECT mfa_enabled FROM users WHERE id = ?').get(userId) as { mfa_enabled: number } | undefined; + if (!(adminMfa?.mfa_enabled === 1)) { + return { + error: 'Enable two-factor authentication on your own account before requiring it for all users.', + status: 400, + }; + } + } + + for (const key of ADMIN_SETTINGS_KEYS) { + if (body[key] !== undefined) { + let val = String(body[key]); + if (key === 'require_mfa') { + val = body[key] === true || val === 'true' ? 'true' : 'false'; + } + if (key === 'smtp_pass' && val === '••••••••') continue; + if (key === 'smtp_pass') val = encrypt_api_key(val); + db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val); + } + } + + const changedKeys = ADMIN_SETTINGS_KEYS.filter(k => body[k] !== undefined && !(k === 'smtp_pass' && String(body[k]) === '••••••••')); + + const summary: Record = {}; + const smtpChanged = changedKeys.some(k => k.startsWith('smtp_')); + const eventsChanged = changedKeys.some(k => k.startsWith('notify_')); + if (changedKeys.includes('notification_channel')) summary.notification_channel = body.notification_channel; + if (changedKeys.includes('notification_webhook_url')) summary.webhook_url_updated = true; + if (smtpChanged) summary.smtp_settings_updated = true; + if (eventsChanged) summary.notification_events_updated = true; + if (changedKeys.includes('allow_registration')) summary.allow_registration = body.allow_registration; + if (changedKeys.includes('allowed_file_types')) summary.allowed_file_types_updated = true; + if (changedKeys.includes('require_mfa')) summary.require_mfa = body.require_mfa; + + const debugDetails: Record = {}; + for (const k of changedKeys) { + debugDetails[k] = k === 'smtp_pass' ? '***' : body[k]; + } + + const notifRelated = ['notification_channel', 'notification_webhook_url', 'smtp_host', 'notify_trip_reminder']; + const shouldRestartScheduler = changedKeys.some(k => notifRelated.includes(k)); + if (shouldRestartScheduler) { + startTripReminders(); + } + + return { success: true, auditSummary: summary, auditDebugDetails: debugDetails, shouldRestartScheduler }; +} + +// --------------------------------------------------------------------------- +// Travel stats +// --------------------------------------------------------------------------- + +export function getTravelStats(userId: number) { + 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) as { address: string | null; lat: number | null; lng: number | null }[]; + + const tripStats = db.prepare(` + SELECT COUNT(DISTINCT t.id) as trips, + COUNT(DISTINCT d.id) as days + FROM trips t + 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) as { trips: number; days: number } | undefined; + + const countries = new Set(); + const cities = new Set(); + const coords: { lat: number; lng: number }[] = []; + + places.forEach(p => { + if (p.lat && p.lng) coords.push({ lat: p.lat, lng: p.lng }); + if (p.address) { + const parts = p.address.split(',').map(s => s.trim().replace(/\d{3,}/g, '').trim()); + for (const part of parts) { + if (KNOWN_COUNTRIES.has(part)) { countries.add(part); break; } + } + const cityPart = parts.find(s => !KNOWN_COUNTRIES.has(s) && /^[A-Za-z\u00C0-\u00FF\s-]{2,}$/.test(s)); + if (cityPart) cities.add(cityPart); + } + }); + + return { + countries: [...countries], + cities: [...cities], + coords, + totalTrips: tripStats?.trips || 0, + totalDays: tripStats?.days || 0, + totalPlaces: places.length, + }; +} + +// --------------------------------------------------------------------------- +// MFA +// --------------------------------------------------------------------------- + +export function setupMfa(userId: number, userEmail: string): { error?: string; status?: number; secret?: string; otpauth_url?: string; qrPromise?: Promise } { + if (process.env.DEMO_MODE === 'true' && userEmail === 'demo@nomad.app') { + return { error: 'MFA is not available in demo mode.', status: 403 }; + } + const row = db.prepare('SELECT mfa_enabled FROM users WHERE id = ?').get(userId) as { mfa_enabled: number } | undefined; + if (row?.mfa_enabled) { + return { error: 'MFA is already enabled', status: 400 }; + } + let secret: string, otpauth_url: string; + try { + secret = authenticator.generateSecret(); + mfaSetupPending.set(userId, { secret, exp: Date.now() + MFA_SETUP_TTL_MS }); + otpauth_url = authenticator.keyuri(userEmail, 'TREK', secret); + } catch (err) { + console.error('[MFA] Setup error:', err); + return { error: 'MFA setup failed', status: 500 }; + } + return { secret, otpauth_url, qrPromise: QRCode.toDataURL(otpauth_url) }; +} + +export function enableMfa(userId: number, code?: string): { error?: string; status?: number; success?: boolean; mfa_enabled?: boolean; backup_codes?: string[] } { + if (!code) { + return { error: 'Verification code is required', status: 400 }; + } + const pending = getPendingMfaSecret(userId); + if (!pending) { + return { error: 'No MFA setup in progress. Start the setup again.', status: 400 }; + } + const tokenStr = String(code).replace(/\s/g, ''); + const ok = authenticator.verify({ token: tokenStr, secret: pending }); + if (!ok) { + return { error: 'Invalid verification code', status: 401 }; + } + const backupCodes = generateBackupCodes(); + const backupHashes = backupCodes.map(hashBackupCode); + const enc = encryptMfaSecret(pending); + db.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ?, mfa_backup_codes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run( + enc, + JSON.stringify(backupHashes), + userId + ); + mfaSetupPending.delete(userId); + return { success: true, mfa_enabled: true, backup_codes: backupCodes }; +} + +export function disableMfa( + userId: number, + userEmail: string, + body: { password?: string; code?: string } +): { error?: string; status?: number; success?: boolean; mfa_enabled?: boolean } { + if (process.env.DEMO_MODE === 'true' && userEmail === 'demo@nomad.app') { + return { error: 'MFA cannot be changed in demo mode.', status: 403 }; + } + const policy = db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined; + if (policy?.value === 'true') { + return { error: 'Two-factor authentication cannot be disabled while it is required for all users.', status: 403 }; + } + const { password, code } = body; + if (!password || !code) { + return { error: 'Password and authenticator code are required', status: 400 }; + } + const user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId) as User | undefined; + if (!user?.mfa_enabled || !user.mfa_secret) { + return { error: 'MFA is not enabled', status: 400 }; + } + if (!user.password_hash || !bcrypt.compareSync(password, user.password_hash)) { + return { error: 'Incorrect password', status: 401 }; + } + const secret = decryptMfaSecret(user.mfa_secret); + const tokenStr = String(code).replace(/\s/g, ''); + const ok = authenticator.verify({ token: tokenStr, secret }); + if (!ok) { + return { error: 'Invalid verification code', status: 401 }; + } + db.prepare('UPDATE users SET mfa_enabled = 0, mfa_secret = NULL, mfa_backup_codes = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run( + userId + ); + mfaSetupPending.delete(userId); + return { success: true, mfa_enabled: false }; +} + +export function verifyMfaLogin(body: { + mfa_token?: string; + code?: string; +}): { + error?: string; + status?: number; + token?: string; + user?: Record; + auditUserId?: number; +} { + const { mfa_token, code } = body; + if (!mfa_token || !code) { + return { error: 'Verification token and code are required', status: 400 }; + } + try { + const decoded = jwt.verify(mfa_token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number; purpose?: string }; + if (decoded.purpose !== 'mfa_login') { + return { error: 'Invalid verification token', status: 401 }; + } + const user = db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.id) as User | undefined; + if (!user || !(user.mfa_enabled === 1 || user.mfa_enabled === true) || !user.mfa_secret) { + return { error: 'Invalid session', status: 401 }; + } + const secret = decryptMfaSecret(user.mfa_secret); + const tokenStr = String(code).trim(); + const okTotp = authenticator.verify({ token: tokenStr.replace(/\s/g, ''), secret }); + if (!okTotp) { + const hashes = parseBackupCodeHashes(user.mfa_backup_codes); + const candidateHash = hashBackupCode(tokenStr); + const idx = hashes.findIndex(h => h === candidateHash); + if (idx === -1) { + return { error: 'Invalid verification code', status: 401 }; + } + hashes.splice(idx, 1); + db.prepare('UPDATE users SET mfa_backup_codes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run( + JSON.stringify(hashes), + user.id + ); + } + db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(user.id); + const sessionToken = generateToken(user); + const userSafe = stripUserForClient(user) as Record; + return { + token: sessionToken, + user: { ...userSafe, avatar_url: avatarUrl(user) }, + auditUserId: Number(user.id), + }; + } catch { + return { error: 'Invalid or expired verification token', status: 401 }; + } +} + +// --------------------------------------------------------------------------- +// MCP tokens +// --------------------------------------------------------------------------- + +export function listMcpTokens(userId: number) { + return db.prepare( + 'SELECT id, name, token_prefix, created_at, last_used_at FROM mcp_tokens WHERE user_id = ? ORDER BY created_at DESC' + ).all(userId); +} + +export function createMcpToken(userId: number, name?: string): { error?: string; status?: number; token?: Record } { + if (!name?.trim()) return { error: 'Token name is required', status: 400 }; + if (name.trim().length > 100) return { error: 'Token name must be 100 characters or less', status: 400 }; + + const tokenCount = (db.prepare('SELECT COUNT(*) as count FROM mcp_tokens WHERE user_id = ?').get(userId) as { count: number }).count; + if (tokenCount >= 10) return { error: 'Maximum of 10 tokens per user reached', status: 400 }; + + const rawToken = 'trek_' + randomBytes(24).toString('hex'); + const tokenHash = createHash('sha256').update(rawToken).digest('hex'); + const tokenPrefix = rawToken.slice(0, 13); + + const result = db.prepare( + 'INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)' + ).run(userId, name.trim(), tokenHash, tokenPrefix); + + const token = db.prepare( + 'SELECT id, name, token_prefix, created_at, last_used_at FROM mcp_tokens WHERE id = ?' + ).get(result.lastInsertRowid); + + return { token: { ...(token as object), raw_token: rawToken } }; +} + +export function deleteMcpToken(userId: number, tokenId: string): { error?: string; status?: number; success?: boolean } { + const token = db.prepare('SELECT id FROM mcp_tokens WHERE id = ? AND user_id = ?').get(tokenId, userId); + if (!token) return { error: 'Token not found', status: 404 }; + db.prepare('DELETE FROM mcp_tokens WHERE id = ?').run(tokenId); + revokeUserSessions(userId); + return { success: true }; +} + +// --------------------------------------------------------------------------- +// Ephemeral tokens +// --------------------------------------------------------------------------- + +export function createWsToken(userId: number): { error?: string; status?: number; token?: string } { + const token = createEphemeralToken(userId, 'ws'); + if (!token) return { error: 'Service unavailable', status: 503 }; + return { token }; +} + +export function createResourceToken(userId: number, purpose?: string): { error?: string; status?: number; token?: string } { + if (purpose !== 'download' && purpose !== 'immich') { + return { error: 'Invalid purpose', status: 400 }; + } + const token = createEphemeralToken(userId, purpose); + if (!token) return { error: 'Service unavailable', status: 503 }; + return { token }; +} diff --git a/server/src/services/backupService.ts b/server/src/services/backupService.ts new file mode 100644 index 0000000..cfc8f97 --- /dev/null +++ b/server/src/services/backupService.ts @@ -0,0 +1,291 @@ +import archiver from 'archiver'; +import unzipper from 'unzipper'; +import path from 'path'; +import fs from 'fs'; +import Database from 'better-sqlite3'; +import { db, closeDb, reinitialize } from '../db/database'; +import * as scheduler from '../scheduler'; + +// --------------------------------------------------------------------------- +// Paths +// --------------------------------------------------------------------------- + +const dataDir = path.join(__dirname, '../../data'); +const backupsDir = path.join(dataDir, 'backups'); +const uploadsDir = path.join(__dirname, '../../uploads'); + +export const MAX_BACKUP_UPLOAD_SIZE = 500 * 1024 * 1024; // 500 MB + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +export function ensureBackupsDir(): void { + if (!fs.existsSync(backupsDir)) fs.mkdirSync(backupsDir, { recursive: true }); +} + +export 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`; +} + +export function parseIntField(raw: unknown, fallback: number): number { + if (typeof raw === 'number' && Number.isFinite(raw)) return Math.floor(raw); + if (typeof raw === 'string' && raw.trim() !== '') { + const n = parseInt(raw, 10); + if (Number.isFinite(n)) return n; + } + return fallback; +} + +export function parseAutoBackupBody(body: Record): { + enabled: boolean; + interval: string; + keep_days: number; + hour: number; + day_of_week: number; + day_of_month: number; +} { + const enabled = body.enabled === true || body.enabled === 'true' || body.enabled === 1; + const rawInterval = body.interval; + const interval = + typeof rawInterval === 'string' && scheduler.VALID_INTERVALS.includes(rawInterval) + ? rawInterval + : 'daily'; + const keep_days = Math.max(0, parseIntField(body.keep_days, 7)); + const hour = Math.min(23, Math.max(0, parseIntField(body.hour, 2))); + const day_of_week = Math.min(6, Math.max(0, parseIntField(body.day_of_week, 0))); + const day_of_month = Math.min(28, Math.max(1, parseIntField(body.day_of_month, 1))); + return { enabled, interval, keep_days, hour, day_of_week, day_of_month }; +} + +export function isValidBackupFilename(filename: string): boolean { + return /^backup-[\w\-]+\.zip$/.test(filename); +} + +export function backupFilePath(filename: string): string { + return path.join(backupsDir, filename); +} + +export function backupFileExists(filename: string): boolean { + return fs.existsSync(path.join(backupsDir, filename)); +} + +// --------------------------------------------------------------------------- +// Rate limiter state (shared across requests) +// --------------------------------------------------------------------------- + +export const BACKUP_RATE_WINDOW = 60 * 60 * 1000; // 1 hour + +const backupAttempts = new Map(); + +/** Returns true if the request is allowed, false if rate-limited. */ +export function checkRateLimit(key: string, maxAttempts: number, windowMs: number): boolean { + const now = Date.now(); + const record = backupAttempts.get(key); + if (record && record.count >= maxAttempts && now - record.first < windowMs) { + return false; + } + if (!record || now - record.first >= windowMs) { + backupAttempts.set(key, { count: 1, first: now }); + } else { + record.count++; + } + return true; +} + +// --------------------------------------------------------------------------- +// List backups +// --------------------------------------------------------------------------- + +export interface BackupInfo { + filename: string; + size: number; + sizeText: string; + created_at: string; +} + +export function listBackups(): BackupInfo[] { + ensureBackupsDir(); + return fs.readdirSync(backupsDir) + .filter(f => f.endsWith('.zip')) + .map(filename => { + const filePath = path.join(backupsDir, filename); + const stat = fs.statSync(filePath); + return { + filename, + size: stat.size, + sizeText: formatSize(stat.size), + created_at: stat.birthtime.toISOString(), + }; + }) + .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); +} + +// --------------------------------------------------------------------------- +// Create backup +// --------------------------------------------------------------------------- + +export async function createBackup(): Promise { + ensureBackupsDir(); + + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + const filename = `backup-${timestamp}.zip`; + const outputPath = path.join(backupsDir, filename); + + try { + try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {} + + await new Promise((resolve, reject) => { + const output = fs.createWriteStream(outputPath); + const archive = archiver('zip', { zlib: { level: 9 } }); + + output.on('close', resolve); + archive.on('error', reject); + + archive.pipe(output); + + const dbPath = path.join(dataDir, 'travel.db'); + if (fs.existsSync(dbPath)) { + archive.file(dbPath, { name: 'travel.db' }); + } + + if (fs.existsSync(uploadsDir)) { + archive.directory(uploadsDir, 'uploads'); + } + + archive.finalize(); + }); + + const stat = fs.statSync(outputPath); + return { + filename, + size: stat.size, + sizeText: formatSize(stat.size), + created_at: stat.birthtime.toISOString(), + }; + } catch (err: unknown) { + console.error('Backup error:', err); + if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath); + throw err; + } +} + +// --------------------------------------------------------------------------- +// Restore from ZIP +// --------------------------------------------------------------------------- + +export interface RestoreResult { + success: boolean; + error?: string; + status?: number; +} + +export async function restoreFromZip(zipPath: string): Promise { + const extractDir = path.join(dataDir, `restore-${Date.now()}`); + try { + await fs.createReadStream(zipPath) + .pipe(unzipper.Extract({ path: extractDir })) + .promise(); + + const extractedDb = path.join(extractDir, 'travel.db'); + if (!fs.existsSync(extractedDb)) { + fs.rmSync(extractDir, { recursive: true, force: true }); + return { success: false, error: 'Invalid backup: travel.db not found', status: 400 }; + } + + let uploadedDb: InstanceType | null = null; + try { + uploadedDb = new Database(extractedDb, { readonly: true }); + + const integrityResult = uploadedDb.prepare('PRAGMA integrity_check').get() as { integrity_check: string }; + if (integrityResult.integrity_check !== 'ok') { + fs.rmSync(extractDir, { recursive: true, force: true }); + return { success: false, error: `Uploaded database failed integrity check: ${integrityResult.integrity_check}`, status: 400 }; + } + + const requiredTables = ['users', 'trips', 'trip_members', 'places', 'days']; + const existingTables = uploadedDb + .prepare("SELECT name FROM sqlite_master WHERE type='table'") + .all() as { name: string }[]; + const tableNames = new Set(existingTables.map(t => t.name)); + for (const table of requiredTables) { + if (!tableNames.has(table)) { + fs.rmSync(extractDir, { recursive: true, force: true }); + return { success: false, error: `Uploaded database is missing required table: ${table}. This does not appear to be a TREK backup.`, status: 400 }; + } + } + } catch (err) { + fs.rmSync(extractDir, { recursive: true, force: true }); + return { success: false, error: 'Uploaded file is not a valid SQLite database', status: 400 }; + } finally { + uploadedDb?.close(); + } + + closeDb(); + + try { + const dbDest = path.join(dataDir, 'travel.db'); + for (const ext of ['', '-wal', '-shm']) { + try { fs.unlinkSync(dbDest + ext); } catch (e) {} + } + fs.copyFileSync(extractedDb, dbDest); + + const extractedUploads = path.join(extractDir, 'uploads'); + if (fs.existsSync(extractedUploads)) { + for (const sub of fs.readdirSync(uploadsDir)) { + const subPath = path.join(uploadsDir, sub); + if (fs.statSync(subPath).isDirectory()) { + for (const file of fs.readdirSync(subPath)) { + try { fs.unlinkSync(path.join(subPath, file)); } catch (e) {} + } + } + } + fs.cpSync(extractedUploads, uploadsDir, { recursive: true, force: true }); + } + } finally { + reinitialize(); + } + + fs.rmSync(extractDir, { recursive: true, force: true }); + return { success: true }; + } catch (err: unknown) { + console.error('Restore error:', err); + if (fs.existsSync(extractDir)) fs.rmSync(extractDir, { recursive: true, force: true }); + throw err; + } +} + +// --------------------------------------------------------------------------- +// Auto-backup settings +// --------------------------------------------------------------------------- + +export function getAutoSettings(): { settings: ReturnType; timezone: string } { + const tz = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; + return { settings: scheduler.loadSettings(), timezone: tz }; +} + +export function updateAutoSettings(body: Record): ReturnType { + const settings = parseAutoBackupBody(body); + scheduler.saveSettings(settings); + scheduler.start(); + return settings; +} + +// --------------------------------------------------------------------------- +// Delete backup +// --------------------------------------------------------------------------- + +export function deleteBackup(filename: string): void { + const filePath = path.join(backupsDir, filename); + fs.unlinkSync(filePath); +} + +// --------------------------------------------------------------------------- +// Upload config (multer dest) +// --------------------------------------------------------------------------- + +export function getUploadTmpDir(): string { + return path.join(dataDir, 'tmp/'); +} diff --git a/server/src/services/budgetService.ts b/server/src/services/budgetService.ts new file mode 100644 index 0000000..36927c2 --- /dev/null +++ b/server/src/services/budgetService.ts @@ -0,0 +1,256 @@ +import { db, canAccessTrip } from '../db/database'; +import { BudgetItem, BudgetItemMember } from '../types'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +export function avatarUrl(user: { avatar?: string | null }): string | null { + return user.avatar ? `/uploads/avatars/${user.avatar}` : null; +} + +export function verifyTripAccess(tripId: string | number, userId: number) { + return canAccessTrip(tripId, userId); +} + +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) as BudgetItemMember[]; +} + +// --------------------------------------------------------------------------- +// CRUD +// --------------------------------------------------------------------------- + +export function listBudgetItems(tripId: string | number) { + const items = db.prepare( + 'SELECT * FROM budget_items WHERE trip_id = ? ORDER BY category ASC, created_at ASC' + ).all(tripId) as BudgetItem[]; + + const itemIds = items.map(i => i.id); + const membersByItem: Record = {}; + + 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) 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({ + user_id: m.user_id, paid: m.paid, username: m.username, avatar_url: avatarUrl(m), + }); + } + } + + items.forEach(item => { item.members = membersByItem[item.id] || []; }); + return items; +} + +export function createBudgetItem( + tripId: string | number, + data: { category?: string; name: string; total_price?: number; persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null }, +) { + 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( + 'INSERT INTO budget_items (trip_id, category, name, total_price, persons, days, note, sort_order, expense_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' + ).run( + tripId, + data.category || 'Other', + data.name, + data.total_price || 0, + data.persons != null ? data.persons : null, + data.days !== undefined && data.days !== null ? data.days : null, + data.note || null, + sortOrder, + data.expense_date || null, + ); + + const item = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(result.lastInsertRowid) as BudgetItem & { members?: BudgetItemMember[] }; + item.members = []; + return item; +} + +export function updateBudgetItem( + id: string | number, + tripId: string | number, + data: { category?: string; name?: string; total_price?: number; persons?: number | null; days?: number | null; note?: string | null; sort_order?: number; expense_date?: string | null }, +) { + const item = db.prepare('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId); + if (!item) return null; + + db.prepare(` + UPDATE budget_items SET + category = COALESCE(?, category), + name = COALESCE(?, name), + total_price = CASE WHEN ? IS NOT NULL THEN ? ELSE total_price END, + persons = CASE WHEN ? IS NOT NULL THEN ? ELSE persons END, + days = CASE WHEN ? THEN ? ELSE days END, + note = CASE WHEN ? THEN ? ELSE note END, + sort_order = CASE WHEN ? IS NOT NULL THEN ? ELSE sort_order END, + expense_date = CASE WHEN ? THEN ? ELSE expense_date END + WHERE id = ? + `).run( + data.category || null, + data.name || null, + data.total_price !== undefined ? 1 : null, data.total_price !== undefined ? data.total_price : 0, + data.persons !== undefined ? 1 : null, data.persons !== undefined ? data.persons : null, + data.days !== undefined ? 1 : 0, data.days !== undefined ? data.days : null, + data.note !== undefined ? 1 : 0, data.note !== undefined ? data.note : null, + data.sort_order !== undefined ? 1 : null, data.sort_order !== undefined ? data.sort_order : 0, + data.expense_date !== undefined ? 1 : 0, data.expense_date !== undefined ? (data.expense_date || null) : null, + id, + ); + + const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id) as BudgetItem & { members?: BudgetItemMember[] }; + updated.members = loadItemMembers(id); + return updated; +} + +export function deleteBudgetItem(id: string | number, tripId: string | number): boolean { + const item = db.prepare('SELECT id FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId); + if (!item) return false; + db.prepare('DELETE FROM budget_items WHERE id = ?').run(id); + return true; +} + +// --------------------------------------------------------------------------- +// Members +// --------------------------------------------------------------------------- + +export function updateMembers(id: string | number, tripId: string | number, userIds: number[]) { + const item = db.prepare('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId); + if (!item) return null; + + const existingPaid: Record = {}; + 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 (userIds.length > 0) { + const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, ?)'); + for (const userId of userIds) insert.run(id, userId, existingPaid[userId] || 0); + db.prepare('UPDATE budget_items SET persons = ? WHERE id = ?').run(userIds.length, id); + } else { + db.prepare('UPDATE budget_items SET persons = NULL WHERE id = ?').run(id); + } + + const members = loadItemMembers(id).map(m => ({ ...m, avatar_url: avatarUrl(m) })); + const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id) as BudgetItem; + return { members, item: updated }; +} + +export function toggleMemberPaid(id: string | number, userId: string | number, paid: boolean) { + db.prepare('UPDATE budget_item_members SET paid = ? WHERE budget_item_id = ? AND user_id = ?') + .run(paid ? 1 : 0, id, userId); + + const member = 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 = ? AND bm.user_id = ? + `).get(id, userId) as BudgetItemMember | undefined; + + return member ? { ...member, avatar_url: avatarUrl(member) } : null; +} + +// --------------------------------------------------------------------------- +// Per-person summary +// --------------------------------------------------------------------------- + +export function getPerPersonSummary(tripId: string | number) { + const summary = db.prepare(` + SELECT bm.user_id, u.username, u.avatar, + SUM(bi.total_price * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id)) as total_assigned, + SUM(CASE WHEN bm.paid = 1 THEN bi.total_price * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id) ELSE 0 END) as total_paid, + COUNT(bi.id) as items_count + FROM budget_item_members bm + JOIN budget_items bi ON bm.budget_item_id = bi.id + JOIN users u ON bm.user_id = u.id + WHERE bi.trip_id = ? + GROUP BY bm.user_id + `).all(tripId) as { user_id: number; username: string; avatar: string | null; total_assigned: number; total_paid: number; items_count: number }[]; + + return summary.map(s => ({ ...s, avatar_url: avatarUrl(s) })); +} + +// --------------------------------------------------------------------------- +// Settlement calculation (greedy debt matching) +// --------------------------------------------------------------------------- + +export function calculateSettlement(tripId: string | number) { + const items = db.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(tripId) as BudgetItem[]; + 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 (SELECT id FROM budget_items WHERE trip_id = ?) + `).all(tripId) as (BudgetItemMember & { budget_item_id: number })[]; + + // Calculate net balance per user: positive = is owed money, negative = owes money + const balances: Record = {}; + + for (const item of items) { + const members = allMembers.filter(m => m.budget_item_id === item.id); + if (members.length === 0) continue; + + const payers = members.filter(m => m.paid); + if (payers.length === 0) continue; // no one marked as paid + + const sharePerMember = item.total_price / members.length; + const paidPerPayer = item.total_price / payers.length; + + for (const m of members) { + if (!balances[m.user_id]) { + balances[m.user_id] = { user_id: m.user_id, username: m.username, avatar_url: avatarUrl(m), balance: 0 }; + } + // Everyone owes their share + balances[m.user_id].balance -= sharePerMember; + // Payers get credited what they paid + if (m.paid) balances[m.user_id].balance += paidPerPayer; + } + } + + // Calculate optimized payment flows (greedy algorithm) + const people = Object.values(balances).filter(b => Math.abs(b.balance) > 0.01); + const debtors = people.filter(p => p.balance < -0.01).map(p => ({ ...p, amount: -p.balance })); + const creditors = people.filter(p => p.balance > 0.01).map(p => ({ ...p, amount: p.balance })); + + // Sort by amount descending for efficient matching + debtors.sort((a, b) => b.amount - a.amount); + creditors.sort((a, b) => b.amount - a.amount); + + const flows: { from: { user_id: number; username: string; avatar_url: string | null }; to: { user_id: number; username: string; avatar_url: string | null }; amount: number }[] = []; + + let di = 0, ci = 0; + while (di < debtors.length && ci < creditors.length) { + const transfer = Math.min(debtors[di].amount, creditors[ci].amount); + if (transfer > 0.01) { + flows.push({ + from: { user_id: debtors[di].user_id, username: debtors[di].username, avatar_url: debtors[di].avatar_url }, + to: { user_id: creditors[ci].user_id, username: creditors[ci].username, avatar_url: creditors[ci].avatar_url }, + amount: Math.round(transfer * 100) / 100, + }); + } + debtors[di].amount -= transfer; + creditors[ci].amount -= transfer; + if (debtors[di].amount < 0.01) di++; + if (creditors[ci].amount < 0.01) ci++; + } + + return { + balances: Object.values(balances).map(b => ({ ...b, balance: Math.round(b.balance * 100) / 100 })), + flows, + }; +} diff --git a/server/src/services/categoryService.ts b/server/src/services/categoryService.ts new file mode 100644 index 0000000..a533d1a --- /dev/null +++ b/server/src/services/categoryService.ts @@ -0,0 +1,31 @@ +import { db } from '../db/database'; + +export function listCategories() { + return db.prepare('SELECT * FROM categories ORDER BY name ASC').all(); +} + +export function createCategory(userId: number, name: string, color?: string, icon?: string) { + const result = db.prepare( + 'INSERT INTO categories (name, color, icon, user_id) VALUES (?, ?, ?, ?)' + ).run(name, color || '#6366f1', icon || '\uD83D\uDCCD', userId); + return db.prepare('SELECT * FROM categories WHERE id = ?').get(result.lastInsertRowid); +} + +export function getCategoryById(categoryId: number | string) { + return db.prepare('SELECT * FROM categories WHERE id = ?').get(categoryId); +} + +export function updateCategory(categoryId: number | string, name?: string, color?: string, icon?: string) { + db.prepare(` + UPDATE categories SET + name = COALESCE(?, name), + color = COALESCE(?, color), + icon = COALESCE(?, icon) + WHERE id = ? + `).run(name || null, color || null, icon || null, categoryId); + return db.prepare('SELECT * FROM categories WHERE id = ?').get(categoryId); +} + +export function deleteCategory(categoryId: number | string) { + db.prepare('DELETE FROM categories WHERE id = ?').run(categoryId); +} diff --git a/server/src/services/collabService.ts b/server/src/services/collabService.ts new file mode 100644 index 0000000..cb5af9a --- /dev/null +++ b/server/src/services/collabService.ts @@ -0,0 +1,441 @@ +import path from 'path'; +import fs from 'fs'; +import { db, canAccessTrip } from '../db/database'; +import { CollabNote, CollabPoll, CollabMessage, TripFile } from '../types'; +import { checkSsrf, createPinnedAgent } from '../utils/ssrfGuard'; + +/* ------------------------------------------------------------------ */ +/* Internal row types */ +/* ------------------------------------------------------------------ */ + +export interface ReactionRow { + emoji: string; + user_id: number; + username: string; + message_id?: number; +} + +export interface PollVoteRow { + option_index: number; + user_id: number; + username: string; + avatar: string | null; +} + +export interface NoteFileRow { + id: number; + filename: string; + original_name?: string; + file_size?: number; + mime_type?: string; +} + +export interface GroupedReaction { + emoji: string; + users: { user_id: number; username: string }[]; + count: number; +} + +export interface LinkPreviewResult { + title: string | null; + description: string | null; + image: string | null; + site_name?: string | null; + url: string; +} + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +export function avatarUrl(user: { avatar?: string | null }): string | null { + return user.avatar ? `/uploads/avatars/${user.avatar}` : null; +} + +export function verifyTripAccess(tripId: string | number, userId: number) { + return canAccessTrip(tripId, userId); +} + +/* ------------------------------------------------------------------ */ +/* Reactions */ +/* ------------------------------------------------------------------ */ + +export function loadReactions(messageId: number | string): ReactionRow[] { + 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) as ReactionRow[]; +} + +export function groupReactions(reactions: ReactionRow[]): GroupedReaction[] { + const map: Record = {}; + for (const r of reactions) { + if (!map[r.emoji]) map[r.emoji] = []; + map[r.emoji].push({ user_id: r.user_id, username: r.username }); + } + return Object.entries(map).map(([emoji, users]) => ({ emoji, users, count: users.length })); +} + +export function addOrRemoveReaction(messageId: number | string, tripId: number | string, userId: number, emoji: string): { found: boolean; reactions: GroupedReaction[] } { + const msg = db.prepare('SELECT id FROM collab_messages WHERE id = ? AND trip_id = ?').get(messageId, tripId); + if (!msg) return { found: false, reactions: [] }; + + const existing = db.prepare('SELECT id FROM collab_message_reactions WHERE message_id = ? AND user_id = ? AND emoji = ?').get(messageId, userId, 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(messageId, userId, emoji); + } + + return { found: true, reactions: groupReactions(loadReactions(messageId)) }; +} + +/* ------------------------------------------------------------------ */ +/* Notes */ +/* ------------------------------------------------------------------ */ + +export 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), + attachments: attachments.map(a => ({ ...a, url: `/uploads/${a.filename}` })), + }; +} + +export function listNotes(tripId: string | number) { + const notes = db.prepare(` + SELECT n.*, u.username, u.avatar + FROM collab_notes n + JOIN users u ON n.user_id = u.id + WHERE n.trip_id = ? + ORDER BY n.pinned DESC, n.updated_at DESC + `).all(tripId) as CollabNote[]; + + return notes.map(formatNote); +} + +export function createNote(tripId: string | number, userId: number, data: { title: string; content?: string; category?: string; color?: string; website?: string }) { + const result = db.prepare(` + INSERT INTO collab_notes (trip_id, user_id, title, content, category, color, website) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run(tripId, userId, data.title, data.content || null, data.category || 'General', data.color || '#6366f1', data.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) as CollabNote; + + return formatNote(note); +} + +export function updateNote(tripId: string | number, noteId: string | number, data: { title?: string; content?: string; category?: string; color?: string; pinned?: number | boolean; website?: string }): ReturnType | null { + const existing = db.prepare('SELECT * FROM collab_notes WHERE id = ? AND trip_id = ?').get(noteId, tripId); + if (!existing) return null; + + db.prepare(` + UPDATE collab_notes SET + title = COALESCE(?, title), + content = CASE WHEN ? THEN ? ELSE content END, + category = COALESCE(?, category), + color = COALESCE(?, color), + pinned = CASE WHEN ? IS NOT NULL THEN ? ELSE pinned END, + website = CASE WHEN ? THEN ? ELSE website END, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `).run( + data.title || null, + data.content !== undefined ? 1 : 0, data.content !== undefined ? data.content : null, + data.category || null, + data.color || null, + data.pinned !== undefined ? 1 : null, data.pinned ? 1 : 0, + data.website !== undefined ? 1 : 0, data.website !== undefined ? data.website : null, + noteId + ); + + 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(noteId) as CollabNote; + + return formatNote(note); +} + +export function deleteNote(tripId: string | number, noteId: string | number): boolean { + const existing = db.prepare('SELECT id FROM collab_notes WHERE id = ? AND trip_id = ?').get(noteId, tripId); + if (!existing) return false; + + // Clean up attached files from disk + const noteFiles = db.prepare('SELECT id, filename FROM trip_files WHERE note_id = ?').all(noteId) as NoteFileRow[]; + for (const f of noteFiles) { + const filePath = path.join(__dirname, '../../uploads', f.filename); + try { fs.unlinkSync(filePath); } catch { /* ignore */ } + } + db.prepare('DELETE FROM trip_files WHERE note_id = ?').run(noteId); + + db.prepare('DELETE FROM collab_notes WHERE id = ?').run(noteId); + return true; +} + +/* ------------------------------------------------------------------ */ +/* Note files */ +/* ------------------------------------------------------------------ */ + +export function addNoteFile(tripId: string | number, noteId: string | number, file: { filename: string; originalname: string; size: number; mimetype: string }): { file: TripFile & { url: string } } | null { + const note = db.prepare('SELECT id FROM collab_notes WHERE id = ? AND trip_id = ?').get(noteId, tripId); + if (!note) return null; + + const result = db.prepare( + 'INSERT INTO trip_files (trip_id, note_id, filename, original_name, file_size, mime_type) VALUES (?, ?, ?, ?, ?, ?)' + ).run(tripId, noteId, `files/${file.filename}`, file.originalname, file.size, file.mimetype); + + const saved = db.prepare('SELECT * FROM trip_files WHERE id = ?').get(result.lastInsertRowid) as TripFile; + return { file: { ...saved, url: `/uploads/${saved.filename}` } }; +} + +export function getFormattedNoteById(noteId: string | number) { + 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(noteId) as CollabNote; + return formatNote(note); +} + +export function deleteNoteFile(noteId: string | number, fileId: string | number): boolean { + const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND note_id = ?').get(fileId, noteId) as TripFile | undefined; + if (!file) return false; + + const filePath = path.join(__dirname, '../../uploads', file.filename); + try { fs.unlinkSync(filePath); } catch { /* ignore */ } + + db.prepare('DELETE FROM trip_files WHERE id = ?').run(fileId); + return true; +} + +/* ------------------------------------------------------------------ */ +/* Polls */ +/* ------------------------------------------------------------------ */ + +export 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) as CollabPoll | undefined; + + if (!poll) return null; + + 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) as PollVoteRow[]; + + 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) + .map(v => ({ id: v.user_id, user_id: v.user_id, username: v.username, avatar: v.avatar, avatar_url: avatarUrl(v) })), + })); + + return { + ...poll, + avatar_url: avatarUrl(poll), + options: formattedOptions, + is_closed: !!poll.closed, + multiple_choice: !!poll.multiple, + }; +} + +export function listPolls(tripId: string | number) { + const rows = db.prepare(` + SELECT id FROM collab_polls WHERE trip_id = ? ORDER BY created_at DESC + `).all(tripId) as { id: number }[]; + + return rows.map(row => getPollWithVotes(row.id)).filter(Boolean); +} + +export function createPoll(tripId: string | number, userId: number, data: { question: string; options: unknown[]; multiple?: boolean; multiple_choice?: boolean; deadline?: string }) { + const isMultiple = data.multiple || data.multiple_choice; + + const result = db.prepare(` + INSERT INTO collab_polls (trip_id, user_id, question, options, multiple, deadline) + VALUES (?, ?, ?, ?, ?, ?) + `).run(tripId, userId, data.question, JSON.stringify(data.options), isMultiple ? 1 : 0, data.deadline || null); + + return getPollWithVotes(result.lastInsertRowid); +} + +export function votePoll(tripId: string | number, pollId: string | number, userId: number, optionIndex: number): { error?: string; poll?: ReturnType } { + const poll = db.prepare('SELECT * FROM collab_polls WHERE id = ? AND trip_id = ?').get(pollId, tripId) as CollabPoll | undefined; + if (!poll) return { error: 'not_found' }; + if (poll.closed) return { error: 'closed' }; + + const options = JSON.parse(poll.options); + if (optionIndex < 0 || optionIndex >= options.length) { + return { error: 'invalid_index' }; + } + + const existingVote = db.prepare( + 'SELECT id FROM collab_poll_votes WHERE poll_id = ? AND user_id = ? AND option_index = ?' + ).get(pollId, userId, optionIndex) 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(pollId, userId); + } + db.prepare('INSERT INTO collab_poll_votes (poll_id, user_id, option_index) VALUES (?, ?, ?)').run(pollId, userId, optionIndex); + } + + return { poll: getPollWithVotes(pollId) }; +} + +export function closePoll(tripId: string | number, pollId: string | number): ReturnType | null { + const poll = db.prepare('SELECT * FROM collab_polls WHERE id = ? AND trip_id = ?').get(pollId, tripId); + if (!poll) return null; + + db.prepare('UPDATE collab_polls SET closed = 1 WHERE id = ?').run(pollId); + return getPollWithVotes(pollId); +} + +export function deletePoll(tripId: string | number, pollId: string | number): boolean { + const poll = db.prepare('SELECT id FROM collab_polls WHERE id = ? AND trip_id = ?').get(pollId, tripId); + if (!poll) return false; + + db.prepare('DELETE FROM collab_polls WHERE id = ?').run(pollId); + return true; +} + +/* ------------------------------------------------------------------ */ +/* Messages */ +/* ------------------------------------------------------------------ */ + +export function formatMessage(msg: CollabMessage, reactions?: GroupedReaction[]) { + return { ...msg, user_avatar: avatarUrl(msg), avatar_url: avatarUrl(msg), reactions: reactions || [] }; +} + +export function listMessages(tripId: string | number, before?: string | number) { + const query = ` + SELECT m.*, u.username, u.avatar, + rm.text AS reply_text, ru.username AS reply_username + FROM collab_messages m + JOIN users u ON m.user_id = u.id + LEFT JOIN collab_messages rm ON m.reply_to = rm.id + LEFT JOIN users ru ON rm.user_id = ru.id + WHERE m.trip_id = ?${before ? ' AND m.id < ?' : ''} + ORDER BY m.id DESC + LIMIT 100 + `; + + const messages = before + ? db.prepare(query).all(tripId, before) as CollabMessage[] + : db.prepare(query).all(tripId) as CollabMessage[]; + + messages.reverse(); + + const msgIds = messages.map(m => m.id); + const reactionsByMsg: Record = {}; + 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) 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); + } + } + + return messages.map(m => formatMessage(m, groupReactions(reactionsByMsg[m.id] || []))); +} + +export function createMessage(tripId: string | number, userId: number, text: string, replyTo?: number | null): { error?: string; message?: ReturnType } { + if (replyTo) { + const replyMsg = db.prepare('SELECT id FROM collab_messages WHERE id = ? AND trip_id = ?').get(replyTo, tripId); + if (!replyMsg) return { error: 'reply_not_found' }; + } + + const result = db.prepare(` + INSERT INTO collab_messages (trip_id, user_id, text, reply_to) VALUES (?, ?, ?, ?) + `).run(tripId, userId, text.trim(), replyTo || null); + + const message = db.prepare(` + SELECT m.*, u.username, u.avatar, + rm.text AS reply_text, ru.username AS reply_username + FROM collab_messages m + JOIN users u ON m.user_id = u.id + 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) as CollabMessage; + + return { message: formatMessage(message) }; +} + +export function deleteMessage(tripId: string | number, messageId: string | number, userId: number): { error?: string; username?: string } { + const message = db.prepare('SELECT * FROM collab_messages WHERE id = ? AND trip_id = ?').get(messageId, tripId) as CollabMessage | undefined; + if (!message) return { error: 'not_found' }; + if (Number(message.user_id) !== Number(userId)) return { error: 'not_owner' }; + + db.prepare('UPDATE collab_messages SET deleted = 1 WHERE id = ?').run(messageId); + return { username: message.username }; +} + +/* ------------------------------------------------------------------ */ +/* Link preview */ +/* ------------------------------------------------------------------ */ + +export async function fetchLinkPreview(url: string): Promise { + const fallback: LinkPreviewResult = { title: null, description: null, image: null, url }; + + const parsed = new URL(url); + const ssrf = await checkSsrf(url); + if (!ssrf.allowed) { + return { ...fallback, error: ssrf.error } as LinkPreviewResult & { error?: string }; + } + + try { + const nodeFetch = require('node-fetch'); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + + try { + const r: { ok: boolean; text: () => Promise } = await nodeFetch(url, { + redirect: 'error', + signal: controller.signal, + agent: createPinnedAgent(ssrf.resolvedIp!, parsed.protocol), + headers: { 'User-Agent': 'Mozilla/5.0 (compatible; NOMAD/1.0; +https://github.com/mauriceboe/NOMAD)' }, + }); + clearTimeout(timeout); + if (!r.ok) throw new Error('Fetch failed'); + + const html = await r.text(); + const get = (prop: string) => { + const m = html.match(new RegExp(`]*property=["']og:${prop}["'][^>]*content=["']([^"']*)["']`, 'i')) + || html.match(new RegExp(`]*content=["']([^"']*)["'][^>]*property=["']og:${prop}["']`, 'i')); + return m ? m[1] : null; + }; + const titleTag = html.match(/]*>([^<]*)<\/title>/i); + const descMeta = html.match(/]*name=["']description["'][^>]*content=["']([^"']*)["']/i) + || html.match(/]*content=["']([^"']*)["'][^>]*name=["']description["']/i); + + return { + title: get('title') || (titleTag ? titleTag[1].trim() : null), + description: get('description') || (descMeta ? descMeta[1].trim() : null), + image: get('image') || null, + site_name: get('site_name') || null, + url, + }; + } catch { + clearTimeout(timeout); + return fallback; + } + } catch { + return fallback; + } +} diff --git a/server/src/services/dayNoteService.ts b/server/src/services/dayNoteService.ts new file mode 100644 index 0000000..436df86 --- /dev/null +++ b/server/src/services/dayNoteService.ts @@ -0,0 +1,44 @@ +import { db, canAccessTrip } from '../db/database'; +import { DayNote } from '../types'; + +export function verifyTripAccess(tripId: string | number, userId: number) { + return canAccessTrip(tripId, userId); +} + +export function listNotes(dayId: string | number, tripId: string | number) { + return db.prepare( + 'SELECT * FROM day_notes WHERE day_id = ? AND trip_id = ? ORDER BY sort_order ASC, created_at ASC' + ).all(dayId, tripId); +} + +export function dayExists(dayId: string | number, tripId: string | number) { + return db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId); +} + +export function createNote(dayId: string | number, tripId: string | number, text: string, time?: string, icon?: string, sort_order?: number) { + 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 || '\uD83D\uDCDD', sort_order ?? 9999); + return db.prepare('SELECT * FROM day_notes WHERE id = ?').get(result.lastInsertRowid); +} + +export function getNote(id: string | number, dayId: string | number, tripId: string | number) { + return db.prepare('SELECT * FROM day_notes WHERE id = ? AND day_id = ? AND trip_id = ?').get(id, dayId, tripId) as DayNote | undefined; +} + +export function updateNote(id: string | number, current: DayNote, fields: { text?: string; time?: string; icon?: string; sort_order?: number }) { + db.prepare( + 'UPDATE day_notes SET text = ?, time = ?, icon = ?, sort_order = ? WHERE id = ?' + ).run( + fields.text !== undefined ? fields.text.trim() : current.text, + fields.time !== undefined ? fields.time : current.time, + fields.icon !== undefined ? fields.icon : current.icon, + fields.sort_order !== undefined ? fields.sort_order : current.sort_order, + id + ); + return db.prepare('SELECT * FROM day_notes WHERE id = ?').get(id); +} + +export function deleteNote(id: string | number) { + db.prepare('DELETE FROM day_notes WHERE id = ?').run(id); +} diff --git a/server/src/services/dayService.ts b/server/src/services/dayService.ts new file mode 100644 index 0000000..11edba0 --- /dev/null +++ b/server/src/services/dayService.ts @@ -0,0 +1,298 @@ +import { db, canAccessTrip } from '../db/database'; +import { loadTagsByPlaceIds, loadParticipantsByAssignmentIds, formatAssignmentWithPlace } from './queryHelpers'; +import { AssignmentRow, Day, DayNote } from '../types'; + +export function verifyTripAccess(tripId: string | number, userId: number) { + return canAccessTrip(tripId, userId); +} + +// --------------------------------------------------------------------------- +// Day assignment helpers +// --------------------------------------------------------------------------- + +export 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, + COALESCE(da.assignment_time, p.place_time) as place_time, + COALESCE(da.assignment_end_time, p.end_time) as end_time, + p.duration_minutes, p.notes as place_notes, + p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone, + c.name as category_name, c.color as category_color, c.icon as category_icon + FROM day_assignments da + JOIN places p ON da.place_id = p.id + 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) as AssignmentRow[]; + + return assignments.map(a => { + const tags = db.prepare(` + SELECT t.* FROM tags t + JOIN place_tags pt ON t.id = pt.tag_id + WHERE pt.place_id = ? + `).all(a.place_id); + + return { + id: a.id, + day_id: a.day_id, + order_index: a.order_index, + notes: a.notes, + 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, + } + }; + }); +} + +// --------------------------------------------------------------------------- +// Day CRUD +// --------------------------------------------------------------------------- + +export function listDays(tripId: string | number) { + const days = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(tripId) as Day[]; + + if (days.length === 0) { + return { days: [] }; + } + + const dayIds = days.map(d => d.id); + const dayPlaceholders = dayIds.map(() => '?').join(','); + + 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, + COALESCE(da.assignment_time, p.place_time) as place_time, + COALESCE(da.assignment_end_time, p.end_time) as end_time, + p.duration_minutes, p.notes as place_notes, + p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone, + c.name as category_name, c.color as category_color, c.icon as category_icon + FROM day_assignments da + JOIN places p ON da.place_id = p.id + 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) as AssignmentRow[]; + + const placeIds = [...new Set(allAssignments.map(a => a.place_id))]; + const tagsByPlaceId = loadTagsByPlaceIds(placeIds, { compact: true }); + + const allAssignmentIds = allAssignments.map(a => a.id); + const participantsByAssignment = loadParticipantsByAssignmentIds(allAssignmentIds); + + const assignmentsByDayId: Record[]> = {}; + for (const a of allAssignments) { + if (!assignmentsByDayId[a.day_id]) assignmentsByDayId[a.day_id] = []; + assignmentsByDayId[a.day_id].push(formatAssignmentWithPlace(a, tagsByPlaceId[a.place_id] || [], participantsByAssignment[a.id] || [])); + } + + const allNotes = db.prepare( + `SELECT * FROM day_notes WHERE day_id IN (${dayPlaceholders}) ORDER BY sort_order ASC, created_at ASC` + ).all(...dayIds) as DayNote[]; + const notesByDayId: Record = {}; + for (const note of allNotes) { + if (!notesByDayId[note.day_id]) notesByDayId[note.day_id] = []; + notesByDayId[note.day_id].push(note); + } + + const daysWithAssignments = days.map(day => ({ + ...day, + assignments: assignmentsByDayId[day.id] || [], + notes_items: notesByDayId[day.id] || [], + })); + + return { days: daysWithAssignments }; +} + +export function createDay(tripId: string | number, date?: string, notes?: string) { + 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) as Day; + return { ...day, assignments: [] }; +} + +export function getDay(id: string | number, tripId: string | number) { + return db.prepare('SELECT * FROM days WHERE id = ? AND trip_id = ?').get(id, tripId) as Day | undefined; +} + +export function updateDay(id: string | number, current: Day, fields: { notes?: string; title?: string }) { + db.prepare('UPDATE days SET notes = ?, title = ? WHERE id = ?').run( + fields.notes || null, + fields.title !== undefined ? fields.title : current.title, + id + ); + const updatedDay = db.prepare('SELECT * FROM days WHERE id = ?').get(id) as Day; + return { ...updatedDay, assignments: getAssignmentsForDay(id) }; +} + +export function deleteDay(id: string | number) { + db.prepare('DELETE FROM days WHERE id = ?').run(id); +} + +// --------------------------------------------------------------------------- +// Accommodation helpers +// --------------------------------------------------------------------------- + +export 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; +} + +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 + JOIN places p ON a.place_id = p.id + WHERE a.id = ? + `).get(id); +} + +// --------------------------------------------------------------------------- +// Accommodation CRUD +// --------------------------------------------------------------------------- + +export function listAccommodations(tripId: string | number) { + 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 + JOIN places p ON a.place_id = p.id + WHERE a.trip_id = ? + ORDER BY a.created_at ASC + `).all(tripId); +} + +export function validateAccommodationRefs(tripId: string | number, placeId?: number, startDayId?: number, endDayId?: number) { + const errors: { field: string; message: string }[] = []; + if (placeId !== undefined) { + const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId); + if (!place) errors.push({ field: 'place_id', message: 'Place not found' }); + } + if (startDayId !== undefined) { + const startDay = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(startDayId, tripId); + if (!startDay) errors.push({ field: 'start_day_id', message: 'Start day not found' }); + } + if (endDayId !== undefined) { + const endDay = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(endDayId, tripId); + if (!endDay) errors.push({ field: 'end_day_id', message: 'End day not found' }); + } + return errors; +} + +interface CreateAccommodationData { + place_id: number; + start_day_id: number; + end_day_id: number; + check_in?: string; + check_out?: string; + confirmation?: string; + notes?: string; +} + +export function createAccommodation(tripId: string | number, data: CreateAccommodationData) { + const { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes } = data; + + 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 (?, ?, ?, ?, ?, ?, ?, ?)' + ).run(tripId, place_id, start_day_id, end_day_id, check_in || null, check_out || null, confirmation || null, notes || null); + + const accommodationId = result.lastInsertRowid; + + // Auto-create linked reservation for this accommodation + const placeName = (db.prepare('SELECT name FROM places WHERE id = ?').get(place_id) as { name: string } | undefined)?.name || 'Hotel'; + const startDayDate = (db.prepare('SELECT date FROM days WHERE id = ?').get(start_day_id) as { date: string } | undefined)?.date || null; + const meta: Record = {}; + if (check_in) meta.check_in_time = check_in; + if (check_out) meta.check_out_time = check_out; + db.prepare(` + INSERT INTO reservations (trip_id, day_id, title, reservation_time, location, confirmation_number, notes, status, type, accommodation_id, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, 'confirmed', 'hotel', ?, ?) + `).run( + tripId, start_day_id, placeName, startDayDate || null, null, + confirmation || null, notes || null, accommodationId, + Object.keys(meta).length > 0 ? JSON.stringify(meta) : null + ); + + return getAccommodationWithPlace(accommodationId); +} + +export function getAccommodation(id: string | number, tripId: string | number) { + return db.prepare('SELECT * FROM day_accommodations WHERE id = ? AND trip_id = ?').get(id, tripId) as DayAccommodation | undefined; +} + +export function updateAccommodation(id: string | number, existing: DayAccommodation, fields: { + place_id?: number; start_day_id?: number; end_day_id?: number; + check_in?: string; check_out?: string; confirmation?: string; notes?: string; +}) { + const newPlaceId = fields.place_id !== undefined ? fields.place_id : existing.place_id; + const newStartDayId = fields.start_day_id !== undefined ? fields.start_day_id : existing.start_day_id; + const newEndDayId = fields.end_day_id !== undefined ? fields.end_day_id : existing.end_day_id; + const newCheckIn = fields.check_in !== undefined ? fields.check_in : existing.check_in; + const newCheckOut = fields.check_out !== undefined ? fields.check_out : existing.check_out; + const newConfirmation = fields.confirmation !== undefined ? fields.confirmation : existing.confirmation; + const newNotes = fields.notes !== undefined ? fields.notes : existing.notes; + + 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); + + // Sync check-in/out/confirmation to linked reservation + const linkedRes = db.prepare('SELECT id, metadata FROM reservations WHERE accommodation_id = ?').get(Number(id)) as { id: number; metadata: string | null } | undefined; + if (linkedRes) { + const meta = linkedRes.metadata ? JSON.parse(linkedRes.metadata) : {}; + if (newCheckIn) meta.check_in_time = newCheckIn; + if (newCheckOut) meta.check_out_time = newCheckOut; + db.prepare('UPDATE reservations SET metadata = ?, confirmation_number = COALESCE(?, confirmation_number) WHERE id = ?') + .run(JSON.stringify(meta), newConfirmation || null, linkedRes.id); + } + + return getAccommodationWithPlace(Number(id)); +} + +/** Delete accommodation and its linked reservation. Returns the linked reservation id if one existed. */ +export function deleteAccommodation(id: string | number): { linkedReservationId: number | null } { + // Delete linked reservation + const linkedRes = db.prepare('SELECT id FROM reservations WHERE accommodation_id = ?').get(Number(id)) as { id: number } | undefined; + if (linkedRes) { + db.prepare('DELETE FROM reservations WHERE id = ?').run(linkedRes.id); + } + + db.prepare('DELETE FROM day_accommodations WHERE id = ?').run(id); + return { linkedReservationId: linkedRes ? linkedRes.id : null }; +} diff --git a/server/src/services/fileService.ts b/server/src/services/fileService.ts new file mode 100644 index 0000000..04ed311 --- /dev/null +++ b/server/src/services/fileService.ts @@ -0,0 +1,244 @@ +import path from 'path'; +import fs from 'fs'; +import jwt from 'jsonwebtoken'; +import { JWT_SECRET } from '../config'; +import { db, canAccessTrip } from '../db/database'; +import { consumeEphemeralToken } from './ephemeralTokens'; +import { TripFile } from '../types'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +export const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB +export const DEFAULT_ALLOWED_EXTENSIONS = 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv'; +export const BLOCKED_EXTENSIONS = ['.svg', '.html', '.htm', '.xml']; +export const filesDir = path.join(__dirname, '../../uploads/files'); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +export function verifyTripAccess(tripId: string | number, userId: number) { + return canAccessTrip(tripId, userId); +} + +export function getAllowedExtensions(): string { + try { + 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 FILE_SELECT = ` + SELECT f.*, r.title as reservation_title, u.username as uploaded_by_name, u.avatar as uploaded_by_avatar + FROM trip_files f + LEFT JOIN reservations r ON f.reservation_id = r.id + LEFT JOIN users u ON f.uploaded_by = u.id +`; + +export function formatFile(file: TripFile & { trip_id?: number }) { + const tripId = file.trip_id; + return { + ...file, + url: `/api/trips/${tripId}/files/${file.id}/download`, + }; +} + +// --------------------------------------------------------------------------- +// File path resolution & validation +// --------------------------------------------------------------------------- + +export function resolveFilePath(filename: string): { resolved: string; safe: boolean } { + const safeName = path.basename(filename); + const filePath = path.join(filesDir, safeName); + const resolved = path.resolve(filePath); + const safe = resolved.startsWith(path.resolve(filesDir)); + return { resolved, safe }; +} + +// --------------------------------------------------------------------------- +// Token-based download auth +// --------------------------------------------------------------------------- + +export function authenticateDownload(bearerToken: string | undefined, queryToken: string | undefined): { userId: number } | { error: string; status: number } { + if (!bearerToken && !queryToken) { + return { error: 'Authentication required', status: 401 }; + } + + if (bearerToken) { + try { + const decoded = jwt.verify(bearerToken, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number }; + return { userId: decoded.id }; + } catch { + return { error: 'Invalid or expired token', status: 401 }; + } + } + + const uid = consumeEphemeralToken(queryToken!, 'download'); + if (!uid) return { error: 'Invalid or expired token', status: 401 }; + return { userId: uid }; +} + +// --------------------------------------------------------------------------- +// CRUD +// --------------------------------------------------------------------------- + +export interface FileLink { + file_id: number; + reservation_id: number | null; + place_id: number | null; +} + +export function getFileById(id: string | number, tripId: string | number): TripFile | undefined { + return db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, tripId) as TripFile | undefined; +} + +export function getFileByIdFull(id: string | number): TripFile { + return db.prepare(`${FILE_SELECT} WHERE f.id = ?`).get(id) as TripFile; +} + +export function getDeletedFile(id: string | number, tripId: string | number): TripFile | undefined { + return db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ? AND deleted_at IS NOT NULL').get(id, tripId) as TripFile | undefined; +} + +export function listFiles(tripId: string | number, showTrash: boolean) { + const where = showTrash ? 'f.trip_id = ? AND f.deleted_at IS NOT NULL' : 'f.trip_id = ? AND f.deleted_at IS NULL'; + const files = db.prepare(`${FILE_SELECT} WHERE ${where} ORDER BY f.starred DESC, f.created_at DESC`).all(tripId) as TripFile[]; + + const fileIds = files.map(f => f.id); + let linksMap: Record = {}; + if (fileIds.length > 0) { + const placeholders = fileIds.map(() => '?').join(','); + const links = db.prepare(`SELECT file_id, reservation_id, place_id FROM file_links WHERE file_id IN (${placeholders})`).all(...fileIds) as FileLink[]; + for (const link of links) { + if (!linksMap[link.file_id]) linksMap[link.file_id] = []; + linksMap[link.file_id].push(link); + } + } + + return files.map(f => { + const fileLinks = linksMap[f.id] || []; + return { + ...formatFile(f), + linked_reservation_ids: fileLinks.filter(l => l.reservation_id).map(l => l.reservation_id), + linked_place_ids: fileLinks.filter(l => l.place_id).map(l => l.place_id), + }; + }); +} + +export function createFile( + tripId: string | number, + file: { filename: string; originalname: string; size: number; mimetype: string }, + uploadedBy: number, + opts: { place_id?: string | null; reservation_id?: string | null; description?: string | null } +) { + const result = db.prepare(` + INSERT INTO trip_files (trip_id, place_id, reservation_id, filename, original_name, file_size, mime_type, description, uploaded_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + tripId, + opts.place_id || null, + opts.reservation_id || null, + file.filename, + file.originalname, + file.size, + file.mimetype, + opts.description || null, + uploadedBy + ); + + const created = db.prepare(`${FILE_SELECT} WHERE f.id = ?`).get(result.lastInsertRowid) as TripFile; + return formatFile(created); +} + +export function updateFile( + id: string | number, + current: TripFile, + updates: { description?: string; place_id?: string | null; reservation_id?: string | null } +) { + db.prepare(` + UPDATE trip_files SET + description = ?, + place_id = ?, + reservation_id = ? + WHERE id = ? + `).run( + updates.description !== undefined ? updates.description : current.description, + updates.place_id !== undefined ? (updates.place_id || null) : current.place_id, + updates.reservation_id !== undefined ? (updates.reservation_id || null) : current.reservation_id, + id + ); + + const updated = db.prepare(`${FILE_SELECT} WHERE f.id = ?`).get(id) as TripFile; + return formatFile(updated); +} + +export function toggleStarred(id: string | number, currentStarred: number | undefined) { + const newStarred = currentStarred ? 0 : 1; + db.prepare('UPDATE trip_files SET starred = ? WHERE id = ?').run(newStarred, id); + + const updated = db.prepare(`${FILE_SELECT} WHERE f.id = ?`).get(id) as TripFile; + return formatFile(updated); +} + +export function softDeleteFile(id: string | number) { + db.prepare('UPDATE trip_files SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?').run(id); +} + +export function restoreFile(id: string | number) { + db.prepare('UPDATE trip_files SET deleted_at = NULL WHERE id = ?').run(id); + const restored = db.prepare(`${FILE_SELECT} WHERE f.id = ?`).get(id) as TripFile; + return formatFile(restored); +} + +export function permanentDeleteFile(file: TripFile) { + const { resolved } = resolveFilePath(file.filename); + if (fs.existsSync(resolved)) { + try { fs.unlinkSync(resolved); } catch (e) { console.error('Error deleting file:', e); } + } + db.prepare('DELETE FROM trip_files WHERE id = ?').run(file.id); +} + +export function emptyTrash(tripId: string | number): number { + const trashed = db.prepare('SELECT * FROM trip_files WHERE trip_id = ? AND deleted_at IS NOT NULL').all(tripId) as TripFile[]; + for (const file of trashed) { + const { resolved } = resolveFilePath(file.filename); + if (fs.existsSync(resolved)) { + try { fs.unlinkSync(resolved); } catch (e) { console.error('Error deleting file:', e); } + } + } + db.prepare('DELETE FROM trip_files WHERE trip_id = ? AND deleted_at IS NOT NULL').run(tripId); + return trashed.length; +} + +// --------------------------------------------------------------------------- +// File links (many-to-many) +// --------------------------------------------------------------------------- + +export function createFileLink( + fileId: string | number, + opts: { reservation_id?: string | null; assignment_id?: string | null; place_id?: string | null } +) { + try { + db.prepare('INSERT OR IGNORE INTO file_links (file_id, reservation_id, assignment_id, place_id) VALUES (?, ?, ?, ?)').run( + fileId, opts.reservation_id || null, opts.assignment_id || null, opts.place_id || null + ); + } catch (err) { + console.error('[Files] Error creating file link:', err instanceof Error ? err.message : err); + } + return db.prepare('SELECT * FROM file_links WHERE file_id = ?').all(fileId); +} + +export function deleteFileLink(linkId: string | number, fileId: string | number) { + db.prepare('DELETE FROM file_links WHERE id = ? AND file_id = ?').run(linkId, fileId); +} + +export function getFileLinks(fileId: string | number) { + return db.prepare(` + SELECT fl.*, r.title as reservation_title + FROM file_links fl + LEFT JOIN reservations r ON fl.reservation_id = r.id + WHERE fl.file_id = ? + `).all(fileId); +} diff --git a/server/src/services/immichService.ts b/server/src/services/immichService.ts new file mode 100644 index 0000000..9dd3de5 --- /dev/null +++ b/server/src/services/immichService.ts @@ -0,0 +1,394 @@ +import { db, canAccessTrip } from '../db/database'; +import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto'; +import { checkSsrf } from '../utils/ssrfGuard'; +import { writeAudit } from './auditLog'; + +// ── Credentials ──────────────────────────────────────────────────────────── + +export function getImmichCredentials(userId: number) { + const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(userId) as any; + if (!user?.immich_url || !user?.immich_api_key) return null; + return { immich_url: user.immich_url as string, immich_api_key: decrypt_api_key(user.immich_api_key) as string }; +} + +/** Validate that an asset ID is a safe UUID-like string (no path traversal). */ +export function isValidAssetId(id: string): boolean { + return /^[a-zA-Z0-9_-]+$/.test(id) && id.length <= 100; +} + +// ── Connection Settings ──────────────────────────────────────────────────── + +export function getConnectionSettings(userId: number) { + const creds = getImmichCredentials(userId); + return { + immich_url: creds?.immich_url || '', + connected: !!(creds?.immich_url && creds?.immich_api_key), + }; +} + +export async function saveImmichSettings( + userId: number, + immichUrl: string | undefined, + immichApiKey: string | undefined, + clientIp: string | null +): Promise<{ success: boolean; warning?: string; error?: string }> { + if (immichUrl) { + const ssrf = await checkSsrf(immichUrl.trim()); + if (!ssrf.allowed) { + return { success: false, error: `Invalid Immich URL: ${ssrf.error}` }; + } + db.prepare('UPDATE users SET immich_url = ?, immich_api_key = ? WHERE id = ?').run( + immichUrl.trim(), + maybe_encrypt_api_key(immichApiKey), + userId + ); + if (ssrf.isPrivate) { + writeAudit({ + userId, + action: 'immich.private_ip_configured', + ip: clientIp, + details: { immich_url: immichUrl.trim(), resolved_ip: ssrf.resolvedIp }, + }); + return { + success: true, + warning: `Immich URL resolves to a private IP address (${ssrf.resolvedIp}). Make sure this is intentional.`, + }; + } + } else { + db.prepare('UPDATE users SET immich_url = ?, immich_api_key = ? WHERE id = ?').run( + null, + maybe_encrypt_api_key(immichApiKey), + userId + ); + } + return { success: true }; +} + +// ── Connection Test / Status ─────────────────────────────────────────────── + +export async function testConnection( + immichUrl: string, + immichApiKey: string +): Promise<{ connected: boolean; error?: string; user?: { name?: string; email?: string } }> { + const ssrf = await checkSsrf(immichUrl); + if (!ssrf.allowed) return { connected: false, error: ssrf.error ?? 'Invalid Immich URL' }; + try { + const resp = await fetch(`${immichUrl}/api/users/me`, { + headers: { 'x-api-key': immichApiKey, 'Accept': 'application/json' }, + signal: AbortSignal.timeout(10000), + }); + if (!resp.ok) return { connected: false, error: `HTTP ${resp.status}` }; + const data = await resp.json() as { name?: string; email?: string }; + return { connected: true, user: { name: data.name, email: data.email } }; + } catch (err: unknown) { + return { connected: false, error: err instanceof Error ? err.message : 'Connection failed' }; + } +} + +export async function getConnectionStatus( + userId: number +): Promise<{ connected: boolean; error?: string; user?: { name?: string; email?: string } }> { + const creds = getImmichCredentials(userId); + if (!creds) return { connected: false, error: 'Not configured' }; + try { + const resp = await fetch(`${creds.immich_url}/api/users/me`, { + headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, + signal: AbortSignal.timeout(10000), + }); + if (!resp.ok) return { connected: false, error: `HTTP ${resp.status}` }; + const data = await resp.json() as { name?: string; email?: string }; + return { connected: true, user: { name: data.name, email: data.email } }; + } catch (err: unknown) { + return { connected: false, error: err instanceof Error ? err.message : 'Connection failed' }; + } +} + +// ── Browse Timeline / Search ─────────────────────────────────────────────── + +export async function browseTimeline( + userId: number +): Promise<{ buckets?: any; error?: string; status?: number }> { + const creds = getImmichCredentials(userId); + if (!creds) return { error: 'Immich not configured', status: 400 }; + + try { + const resp = await fetch(`${creds.immich_url}/api/timeline/buckets`, { + method: 'GET', + headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, + signal: AbortSignal.timeout(15000), + }); + if (!resp.ok) return { error: 'Failed to fetch from Immich', status: resp.status }; + const buckets = await resp.json(); + return { buckets }; + } catch { + return { error: 'Could not reach Immich', status: 502 }; + } +} + +export async function searchPhotos( + userId: number, + from?: string, + to?: string +): Promise<{ assets?: any[]; error?: string; status?: number }> { + const creds = getImmichCredentials(userId); + if (!creds) return { error: 'Immich not configured', status: 400 }; + + try { + // Paginate through all results (Immich limits per-page to 1000) + const allAssets: any[] = []; + let page = 1; + const pageSize = 1000; + while (true) { + const resp = await fetch(`${creds.immich_url}/api/search/metadata`, { + method: 'POST', + headers: { 'x-api-key': creds.immich_api_key, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + takenAfter: from ? `${from}T00:00:00.000Z` : undefined, + takenBefore: to ? `${to}T23:59:59.999Z` : undefined, + type: 'IMAGE', + size: pageSize, + page, + }), + signal: AbortSignal.timeout(15000), + }); + if (!resp.ok) return { error: 'Search failed', status: resp.status }; + const data = await resp.json() as { assets?: { items?: any[] } }; + const items = data.assets?.items || []; + allAssets.push(...items); + if (items.length < pageSize) break; // Last page + page++; + if (page > 20) break; // Safety limit (20k photos max) + } + const assets = allAssets.map((a: any) => ({ + id: a.id, + takenAt: a.fileCreatedAt || a.createdAt, + city: a.exifInfo?.city || null, + country: a.exifInfo?.country || null, + })); + return { assets }; + } catch { + return { error: 'Could not reach Immich', status: 502 }; + } +} + +// ── Trip Photos ──────────────────────────────────────────────────────────── + +export function listTripPhotos(tripId: string, userId: number) { + return db.prepare(` + SELECT tp.immich_asset_id, tp.user_id, tp.shared, tp.added_at, + u.username, u.avatar, u.immich_url + FROM trip_photos tp + JOIN users u ON tp.user_id = u.id + WHERE tp.trip_id = ? + AND (tp.user_id = ? OR tp.shared = 1) + ORDER BY tp.added_at ASC + `).all(tripId, userId); +} + +export function addTripPhotos( + tripId: string, + userId: number, + assetIds: string[], + shared: boolean +): number { + const insert = db.prepare( + 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, immich_asset_id, shared) VALUES (?, ?, ?, ?)' + ); + let added = 0; + for (const assetId of assetIds) { + const result = insert.run(tripId, userId, assetId, shared ? 1 : 0); + if (result.changes > 0) added++; + } + return added; +} + +export function removeTripPhoto(tripId: string, userId: number, assetId: string) { + db.prepare('DELETE FROM trip_photos WHERE trip_id = ? AND user_id = ? AND immich_asset_id = ?') + .run(tripId, userId, assetId); +} + +export function togglePhotoSharing(tripId: string, userId: number, assetId: string, shared: boolean) { + db.prepare('UPDATE trip_photos SET shared = ? WHERE trip_id = ? AND user_id = ? AND immich_asset_id = ?') + .run(shared ? 1 : 0, tripId, userId, assetId); +} + +// ── Asset Info / Proxy ───────────────────────────────────────────────────── + +export async function getAssetInfo( + userId: number, + assetId: string +): Promise<{ data?: any; error?: string; status?: number }> { + const creds = getImmichCredentials(userId); + if (!creds) return { error: 'Not found', status: 404 }; + + try { + const resp = await fetch(`${creds.immich_url}/api/assets/${assetId}`, { + headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, + signal: AbortSignal.timeout(10000), + }); + if (!resp.ok) return { error: 'Failed', status: resp.status }; + const asset = await resp.json() as any; + return { + data: { + id: asset.id, + takenAt: asset.fileCreatedAt || asset.createdAt, + width: asset.exifInfo?.exifImageWidth || null, + height: asset.exifInfo?.exifImageHeight || null, + camera: asset.exifInfo?.make && asset.exifInfo?.model ? `${asset.exifInfo.make} ${asset.exifInfo.model}` : null, + lens: asset.exifInfo?.lensModel || null, + focalLength: asset.exifInfo?.focalLength ? `${asset.exifInfo.focalLength}mm` : null, + aperture: asset.exifInfo?.fNumber ? `f/${asset.exifInfo.fNumber}` : null, + shutter: asset.exifInfo?.exposureTime || null, + iso: asset.exifInfo?.iso || null, + city: asset.exifInfo?.city || null, + state: asset.exifInfo?.state || null, + country: asset.exifInfo?.country || null, + lat: asset.exifInfo?.latitude || null, + lng: asset.exifInfo?.longitude || null, + fileSize: asset.exifInfo?.fileSizeInByte || null, + fileName: asset.originalFileName || null, + }, + }; + } catch { + return { error: 'Proxy error', status: 502 }; + } +} + +export async function proxyThumbnail( + userId: number, + assetId: string +): Promise<{ buffer?: Buffer; contentType?: string; error?: string; status?: number }> { + const creds = getImmichCredentials(userId); + if (!creds) return { error: 'Not found', status: 404 }; + + try { + const resp = await fetch(`${creds.immich_url}/api/assets/${assetId}/thumbnail`, { + headers: { 'x-api-key': creds.immich_api_key }, + signal: AbortSignal.timeout(10000), + }); + if (!resp.ok) return { error: 'Failed', status: resp.status }; + const buffer = Buffer.from(await resp.arrayBuffer()); + const contentType = resp.headers.get('content-type') || 'image/webp'; + return { buffer, contentType }; + } catch { + return { error: 'Proxy error', status: 502 }; + } +} + +export async function proxyOriginal( + userId: number, + assetId: string +): Promise<{ buffer?: Buffer; contentType?: string; error?: string; status?: number }> { + const creds = getImmichCredentials(userId); + if (!creds) return { error: 'Not found', status: 404 }; + + try { + const resp = await fetch(`${creds.immich_url}/api/assets/${assetId}/original`, { + headers: { 'x-api-key': creds.immich_api_key }, + signal: AbortSignal.timeout(30000), + }); + if (!resp.ok) return { error: 'Failed', status: resp.status }; + const buffer = Buffer.from(await resp.arrayBuffer()); + const contentType = resp.headers.get('content-type') || 'image/jpeg'; + return { buffer, contentType }; + } catch { + return { error: 'Proxy error', status: 502 }; + } +} + +// ── Albums ────────────────────────────────────────────────────────────────── + +export async function listAlbums( + userId: number +): Promise<{ albums?: any[]; error?: string; status?: number }> { + const creds = getImmichCredentials(userId); + if (!creds) return { error: 'Immich not configured', status: 400 }; + + try { + const resp = await fetch(`${creds.immich_url}/api/albums`, { + headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, + signal: AbortSignal.timeout(10000), + }); + if (!resp.ok) return { error: 'Failed to fetch albums', status: resp.status }; + const albums = (await resp.json() as any[]).map((a: any) => ({ + id: a.id, + albumName: a.albumName, + assetCount: a.assetCount || 0, + startDate: a.startDate, + endDate: a.endDate, + albumThumbnailAssetId: a.albumThumbnailAssetId, + })); + return { albums }; + } catch { + return { error: 'Could not reach Immich', status: 502 }; + } +} + +export function listAlbumLinks(tripId: string) { + return db.prepare(` + SELECT tal.*, u.username + FROM trip_album_links tal + JOIN users u ON tal.user_id = u.id + WHERE tal.trip_id = ? + ORDER BY tal.created_at ASC + `).all(tripId); +} + +export function createAlbumLink( + tripId: string, + userId: number, + albumId: string, + albumName: string +): { success: boolean; error?: string } { + try { + db.prepare( + 'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, immich_album_id, album_name) VALUES (?, ?, ?, ?)' + ).run(tripId, userId, albumId, albumName || ''); + return { success: true }; + } catch { + return { success: false, error: 'Album already linked' }; + } +} + +export function deleteAlbumLink(linkId: string, tripId: string, userId: number) { + db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?') + .run(linkId, tripId, userId); +} + +export async function syncAlbumAssets( + tripId: string, + linkId: string, + userId: number +): Promise<{ success?: boolean; added?: number; total?: number; error?: string; status?: number }> { + const link = db.prepare('SELECT * FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?') + .get(linkId, tripId, userId) as any; + if (!link) return { error: 'Album link not found', status: 404 }; + + const creds = getImmichCredentials(userId); + if (!creds) return { error: 'Immich not configured', status: 400 }; + + try { + const resp = await fetch(`${creds.immich_url}/api/albums/${link.immich_album_id}`, { + headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, + signal: AbortSignal.timeout(15000), + }); + if (!resp.ok) return { error: 'Failed to fetch album', status: resp.status }; + const albumData = await resp.json() as { assets?: any[] }; + const assets = (albumData.assets || []).filter((a: any) => a.type === 'IMAGE'); + + const insert = db.prepare( + 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, immich_asset_id, shared) VALUES (?, ?, ?, 1)' + ); + let added = 0; + for (const asset of assets) { + const r = insert.run(tripId, userId, asset.id); + if (r.changes > 0) added++; + } + + db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId); + + return { success: true, added, total: assets.length }; + } catch { + return { error: 'Could not reach Immich', status: 502 }; + } +} diff --git a/server/src/services/mapsService.ts b/server/src/services/mapsService.ts new file mode 100644 index 0000000..de16e9c --- /dev/null +++ b/server/src/services/mapsService.ts @@ -0,0 +1,527 @@ +import fetch from 'node-fetch'; +import { db } from '../db/database'; +import { decrypt_api_key } from './apiKeyCrypto'; + +// ── Interfaces ─────────────────────────────────────────────────────────────── + +interface NominatimResult { + osm_type: string; + osm_id: string; + name?: string; + display_name?: string; + lat: string; + lon: string; +} + +interface OverpassElement { + tags?: Record; +} + +interface WikiCommonsPage { + imageinfo?: { url?: string; extmetadata?: { Artist?: { value?: 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 }[] }[]; +} + +// ── Constants ──────────────────────────────────────────────────────────────── + +const UA = 'TREK Travel Planner (https://github.com/mauriceboe/NOMAD)'; + +// ── Photo cache ────────────────────────────────────────────────────────────── + +const photoCache = new Map(); +const PHOTO_TTL = 12 * 60 * 60 * 1000; // 12 hours +const ERROR_TTL = 5 * 60 * 1000; // 5 min for errors +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 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); + +// ── API key retrieval ──────────────────────────────────────────────────────── + +export 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; + const user_key = decrypt_api_key(user?.maps_api_key); + if (user_key) return user_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() as { maps_api_key: string } | undefined; + return decrypt_api_key(admin?.maps_api_key) || null; +} + +// ── Nominatim search ───────────────────────────────────────────────────────── + +export async function searchNominatim(query: string, lang?: string) { + const params = new URLSearchParams({ + q: query, + format: 'json', + addressdetails: '1', + limit: '10', + 'accept-language': lang || 'en', + }); + const response = await fetch(`https://nominatim.openstreetmap.org/search?${params}`, { + headers: { 'User-Agent': UA }, + }); + if (!response.ok) throw new Error('Nominatim API error'); + const data = await response.json() as NominatimResult[]; + return data.map(item => ({ + google_place_id: null, + osm_id: `${item.osm_type}:${item.osm_id}`, + name: item.name || item.display_name?.split(',')[0] || '', + address: item.display_name || '', + lat: parseFloat(item.lat) || null, + lng: parseFloat(item.lon) || null, + rating: null, + website: null, + phone: null, + source: 'openstreetmap', + })); +} + +// ── Overpass API (OSM details) ─────────────────────────────────────────────── + +export async function fetchOverpassDetails(osmType: string, osmId: string): Promise { + const typeMap: Record = { node: 'node', way: 'way', relation: 'rel' }; + const oType = typeMap[osmType]; + if (!oType) return null; + const query = `[out:json][timeout:5];${oType}(${osmId});out tags;`; + try { + const res = await fetch('https://overpass-api.de/api/interpreter', { + method: 'POST', + headers: { 'User-Agent': UA, 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `data=${encodeURIComponent(query)}`, + }); + if (!res.ok) return null; + const data = await res.json() as { elements?: OverpassElement[] }; + return data.elements?.[0] || null; + } catch { return null; } +} + +// ── Opening hours parsing ──────────────────────────────────────────────────── + +export function parseOpeningHours(ohString: string): { weekdayDescriptions: string[]; openNow: boolean | null } { + const DAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']; + const LONG = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; + const result: string[] = LONG.map(d => `${d}: ?`); + + // Parse segments like "Mo-Fr 09:00-18:00; Sa 10:00-14:00" + for (const segment of ohString.split(';')) { + const trimmed = segment.trim(); + if (!trimmed) continue; + const match = trimmed.match(/^((?:Mo|Tu|We|Th|Fr|Sa|Su)(?:\s*-\s*(?:Mo|Tu|We|Th|Fr|Sa|Su))?(?:\s*,\s*(?:Mo|Tu|We|Th|Fr|Sa|Su)(?:\s*-\s*(?:Mo|Tu|We|Th|Fr|Sa|Su))?)*)\s+(.+)$/i); + if (!match) continue; + const [, daysPart, timePart] = match; + const dayIndices = new Set(); + for (const range of daysPart.split(',')) { + const parts = range.trim().split('-').map(d => DAYS.indexOf(d.trim())); + if (parts.length === 2 && parts[0] >= 0 && parts[1] >= 0) { + for (let i = parts[0]; i !== (parts[1] + 1) % 7; i = (i + 1) % 7) dayIndices.add(i); + dayIndices.add(parts[1]); + } else if (parts[0] >= 0) { + dayIndices.add(parts[0]); + } + } + for (const idx of dayIndices) { + result[idx] = `${LONG[idx]}: ${timePart.trim()}`; + } + } + + // Compute openNow + let openNow: boolean | null = null; + try { + const now = new Date(); + const jsDay = now.getDay(); + const dayIdx = jsDay === 0 ? 6 : jsDay - 1; + const todayLine = result[dayIdx]; + const timeRanges = [...todayLine.matchAll(/(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})/g)]; + if (timeRanges.length > 0) { + const nowMins = now.getHours() * 60 + now.getMinutes(); + openNow = timeRanges.some(m => { + const start = parseInt(m[1]) * 60 + parseInt(m[2]); + const end = parseInt(m[3]) * 60 + parseInt(m[4]); + return end > start ? nowMins >= start && nowMins < end : nowMins >= start || nowMins < end; + }); + } + } catch { /* best effort */ } + + return { weekdayDescriptions: result, openNow }; +} + +// ── Build standardized OSM details ─────────────────────────────────────────── + +export function buildOsmDetails(tags: Record, osmType: string, osmId: string) { + let opening_hours: string[] | null = null; + let open_now: boolean | null = null; + if (tags.opening_hours) { + const parsed = parseOpeningHours(tags.opening_hours); + const hasData = parsed.weekdayDescriptions.some(line => !line.endsWith('?')); + if (hasData) { + opening_hours = parsed.weekdayDescriptions; + open_now = parsed.openNow; + } + } + return { + website: tags['contact:website'] || tags.website || null, + phone: tags['contact:phone'] || tags.phone || null, + opening_hours, + open_now, + osm_url: `https://www.openstreetmap.org/${osmType}/${osmId}`, + summary: tags.description || null, + source: 'openstreetmap' as const, + }; +} + +// ── Wikimedia Commons photo lookup ─────────────────────────────────────────── + +export async function fetchWikimediaPhoto(lat: number, lng: number, name?: string): Promise<{ photoUrl: string; attribution: string | null } | null> { + // Strategy 1: Search Wikipedia for the place name -> get the article image + if (name) { + try { + const searchParams = new URLSearchParams({ + action: 'query', format: 'json', + titles: name, + prop: 'pageimages', + piprop: 'thumbnail', + pithumbsize: '400', + pilimit: '1', + redirects: '1', + }); + const res = await fetch(`https://en.wikipedia.org/w/api.php?${searchParams}`, { headers: { 'User-Agent': UA } }); + if (res.ok) { + const data = await res.json() as { query?: { pages?: Record } }; + const pages = data.query?.pages; + if (pages) { + for (const page of Object.values(pages)) { + if (page.thumbnail?.source) { + return { photoUrl: page.thumbnail.source, attribution: 'Wikipedia' }; + } + } + } + } + } catch { /* fall through to geosearch */ } + } + + // Strategy 2: Wikimedia Commons geosearch by coordinates + const params = new URLSearchParams({ + action: 'query', format: 'json', + generator: 'geosearch', + ggsprimary: 'all', + ggsnamespace: '6', + ggsradius: '300', + ggscoord: `${lat}|${lng}`, + ggslimit: '5', + prop: 'imageinfo', + iiprop: 'url|extmetadata|mime', + iiurlwidth: '400', + }); + try { + const res = await fetch(`https://commons.wikimedia.org/w/api.php?${params}`, { headers: { 'User-Agent': UA } }); + if (!res.ok) return null; + const data = await res.json() as { query?: { pages?: Record } }; + const pages = data.query?.pages; + if (!pages) return null; + for (const page of Object.values(pages)) { + const info = page.imageinfo?.[0]; + // Only use actual photos (JPEG/PNG), skip SVGs and PDFs + const mime = (info as { mime?: string })?.mime || ''; + if (info?.url && (mime.startsWith('image/jpeg') || mime.startsWith('image/png'))) { + const attribution = info.extmetadata?.Artist?.value?.replace(/<[^>]+>/g, '').trim() || null; + return { photoUrl: info.url, attribution }; + } + } + return null; + } catch { return null; } +} + +// ── Search places (Google or Nominatim fallback) ───────────────────────────── + +export async function searchPlaces(userId: number, query: string, lang?: string): Promise<{ places: Record[]; source: string }> { + const apiKey = getMapsKey(userId); + + if (!apiKey) { + const places = await searchNominatim(query, lang); + return { places, source: 'openstreetmap' }; + } + + const response = await fetch('https://places.googleapis.com/v1/places:searchText', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + '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: lang || 'en' }), + }); + + const data = await response.json() as { places?: GooglePlaceResult[]; error?: { message?: string } }; + + if (!response.ok) { + const err = new Error(data.error?.message || 'Google Places API error') as Error & { status: number }; + err.status = response.status; + throw err; + } + + const places = (data.places || []).map((p: GooglePlaceResult) => ({ + google_place_id: p.id, + name: p.displayName?.text || '', + address: p.formattedAddress || '', + lat: p.location?.latitude || null, + lng: p.location?.longitude || null, + rating: p.rating || null, + website: p.websiteUri || null, + phone: p.nationalPhoneNumber || null, + source: 'google', + })); + + return { places, source: 'google' }; +} + +// ── Place details (Google or OSM) ──────────────────────────────────────────── + +export async function getPlaceDetails(userId: number, placeId: string, lang?: string): Promise<{ place: Record }> { + // OSM details: placeId is "node:123456" or "way:123456" etc. + if (placeId.includes(':')) { + const [osmType, osmId] = placeId.split(':'); + const element = await fetchOverpassDetails(osmType, osmId); + if (!element?.tags) return { place: buildOsmDetails({}, osmType, osmId) }; + return { place: buildOsmDetails(element.tags, osmType, osmId) }; + } + + // Google details + const apiKey = getMapsKey(userId); + if (!apiKey) { + throw Object.assign(new Error('Google Maps API key not configured'), { status: 400 }); + } + + const response = await fetch(`https://places.googleapis.com/v1/places/${placeId}?languageCode=${lang || 'de'}`, { + method: 'GET', + headers: { + 'X-Goog-Api-Key': apiKey, + 'X-Goog-FieldMask': 'id,displayName,formattedAddress,location,rating,userRatingCount,websiteUri,nationalPhoneNumber,regularOpeningHours,googleMapsUri,reviews,editorialSummary', + }, + }); + + const data = await response.json() as GooglePlaceDetails & { error?: { message?: string } }; + + if (!response.ok) { + const err = new Error(data.error?.message || 'Google Places API error') as Error & { status: number }; + err.status = response.status; + throw err; + } + + const place = { + google_place_id: data.id, + name: data.displayName?.text || '', + address: data.formattedAddress || '', + lat: data.location?.latitude || null, + lng: data.location?.longitude || null, + rating: data.rating || null, + rating_count: data.userRatingCount || null, + website: data.websiteUri || null, + phone: data.nationalPhoneNumber || null, + opening_hours: data.regularOpeningHours?.weekdayDescriptions || null, + 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: NonNullable[number]) => ({ + author: r.authorAttribution?.displayName || null, + rating: r.rating || null, + text: r.text?.text || null, + time: r.relativePublishTimeDescription || null, + photo: r.authorAttribution?.photoUri || null, + })), + source: 'google' as const, + }; + + return { place }; +} + +// ── Place photo (Google or Wikimedia, with caching + DB persistence) ───────── + +export async function getPlacePhoto( + userId: number, + placeId: string, + lat: number, + lng: number, + name?: string, +): Promise<{ photoUrl: string; attribution: string | null }> { + // Check cache first + const cached = photoCache.get(placeId); + if (cached) { + const ttl = cached.error ? ERROR_TTL : PHOTO_TTL; + if (Date.now() - cached.fetchedAt < ttl) { + if (cached.error) throw Object.assign(new Error('(Cache) No photo available'), { status: 404 }); + return { photoUrl: cached.photoUrl, attribution: cached.attribution }; + } + photoCache.delete(placeId); + } + + const apiKey = getMapsKey(userId); + const isCoordLookup = placeId.startsWith('coords:'); + + // No Google key or coordinate-only lookup -> try Wikimedia + if (!apiKey || isCoordLookup) { + if (!isNaN(lat) && !isNaN(lng)) { + try { + const wiki = await fetchWikimediaPhoto(lat, lng, name); + if (wiki) { + photoCache.set(placeId, { ...wiki, fetchedAt: Date.now() }); + return wiki; + } else { + photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true }); + } + } catch { /* fall through */ } + } + throw Object.assign(new Error('(Wikimedia) No photo available'), { status: 404 }); + } + + // Google Photos + 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() as GooglePlaceDetails & { error?: { message?: string } }; + + if (!detailsRes.ok) { + console.error('Google Places photo details error:', details.error?.message || detailsRes.status); + photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true }); + throw Object.assign(new Error('(Google Places) Photo could not be retrieved'), { status: 404 }); + } + + if (!details.photos?.length) { + photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true }); + throw Object.assign(new Error('(Google Places) No photo available'), { status: 404 }); + } + + const photo = details.photos[0]; + const photoName = photo.name; + const attribution = photo.authorAttributions?.[0]?.displayName || null; + + const mediaRes = await fetch( + `https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=400&skipHttpRedirect=true`, + { headers: { 'X-Goog-Api-Key': apiKey } } + ); + const mediaData = await mediaRes.json() as { photoUri?: string }; + const photoUrl = mediaData.photoUri; + + if (!photoUrl) { + photoCache.set(placeId, { photoUrl: '', attribution, fetchedAt: Date.now(), error: true }); + throw Object.assign(new Error('(Google Places) Photo URL not available'), { status: 404 }); + } + + photoCache.set(placeId, { photoUrl, attribution, fetchedAt: Date.now() }); + + // Persist photo URL to database + try { + db.prepare( + 'UPDATE places SET image_url = ?, updated_at = CURRENT_TIMESTAMP WHERE google_place_id = ? AND (image_url IS NULL OR image_url = ?)' + ).run(photoUrl, placeId, ''); + } catch (dbErr) { + console.error('Failed to persist photo URL to database:', dbErr); + } + + return { photoUrl, attribution }; +} + +// ── Reverse geocoding ──────────────────────────────────────────────────────── + +export async function reverseGeocode(lat: string, lng: string, lang?: string): Promise<{ name: string | null; address: string | null }> { + const params = new URLSearchParams({ + lat, lon: lng, format: 'json', addressdetails: '1', zoom: '18', + 'accept-language': lang || 'en', + }); + const response = await fetch(`https://nominatim.openstreetmap.org/reverse?${params}`, { + headers: { 'User-Agent': UA }, + }); + if (!response.ok) return { name: null, address: null }; + const data = await response.json() as { name?: string; display_name?: string; address?: Record }; + const addr = data.address || {}; + const name = data.name || addr.tourism || addr.amenity || addr.shop || addr.building || addr.road || null; + return { name, address: data.display_name || null }; +} + +// ── Resolve Google Maps URL ────────────────────────────────────────────────── + +export async function resolveGoogleMapsUrl(url: string): Promise<{ lat: number; lng: number; name: string | null; address: string | null }> { + let resolvedUrl = url; + + // Follow redirects for short URLs (goo.gl, maps.app.goo.gl) + if (url.includes('goo.gl') || url.includes('maps.app')) { + const redirectRes = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(10000) }); + resolvedUrl = redirectRes.url; + } + + // Extract coordinates from Google Maps URL patterns: + // /@48.8566,2.3522,15z or /place/.../@48.8566,2.3522 + // ?q=48.8566,2.3522 or ?ll=48.8566,2.3522 + let lat: number | null = null; + let lng: number | null = null; + let placeName: string | null = null; + + // Pattern: /@lat,lng + const atMatch = resolvedUrl.match(/@(-?\d+\.?\d*),(-?\d+\.?\d*)/); + if (atMatch) { lat = parseFloat(atMatch[1]); lng = parseFloat(atMatch[2]); } + + // Pattern: !3dlat!4dlng (Google Maps data params) + if (!lat) { + const dataMatch = resolvedUrl.match(/!3d(-?\d+\.?\d*)!4d(-?\d+\.?\d*)/); + if (dataMatch) { lat = parseFloat(dataMatch[1]); lng = parseFloat(dataMatch[2]); } + } + + // Pattern: ?q=lat,lng or &q=lat,lng + if (!lat) { + const qMatch = resolvedUrl.match(/[?&]q=(-?\d+\.?\d*),(-?\d+\.?\d*)/); + if (qMatch) { lat = parseFloat(qMatch[1]); lng = parseFloat(qMatch[2]); } + } + + // Extract place name from URL path: /place/Place+Name/@... + const placeMatch = resolvedUrl.match(/\/place\/([^/@]+)/); + if (placeMatch) { + placeName = decodeURIComponent(placeMatch[1].replace(/\+/g, ' ')); + } + + if (!lat || !lng || isNaN(lat) || isNaN(lng)) { + throw Object.assign(new Error('Could not extract coordinates from URL'), { status: 400 }); + } + + // Reverse geocode to get address + const nominatimRes = await fetch( + `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&addressdetails=1`, + { headers: { 'User-Agent': 'TREK-Travel-Planner/1.0' }, signal: AbortSignal.timeout(8000) } + ); + const nominatim = await nominatimRes.json() as { display_name?: string; name?: string; address?: Record }; + + const name = placeName || nominatim.name || nominatim.address?.tourism || nominatim.address?.building || null; + const address = nominatim.display_name || null; + + return { lat, lng, name, address }; +} diff --git a/server/src/services/notificationPreferencesService.ts b/server/src/services/notificationPreferencesService.ts new file mode 100644 index 0000000..ebd810f --- /dev/null +++ b/server/src/services/notificationPreferencesService.ts @@ -0,0 +1,40 @@ +import { db } from '../db/database'; + +export function getPreferences(userId: number) { + let prefs = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(userId); + if (!prefs) { + db.prepare('INSERT INTO notification_preferences (user_id) VALUES (?)').run(userId); + prefs = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(userId); + } + return prefs; +} + +export function updatePreferences( + userId: number, + fields: { + notify_trip_invite?: boolean; + notify_booking_change?: boolean; + notify_trip_reminder?: boolean; + notify_webhook?: boolean; + } +) { + const existing = db.prepare('SELECT id FROM notification_preferences WHERE user_id = ?').get(userId); + if (!existing) { + db.prepare('INSERT INTO notification_preferences (user_id) VALUES (?)').run(userId); + } + + db.prepare(`UPDATE notification_preferences SET + notify_trip_invite = COALESCE(?, notify_trip_invite), + notify_booking_change = COALESCE(?, notify_booking_change), + notify_trip_reminder = COALESCE(?, notify_trip_reminder), + notify_webhook = COALESCE(?, notify_webhook) + WHERE user_id = ?`).run( + fields.notify_trip_invite !== undefined ? (fields.notify_trip_invite ? 1 : 0) : null, + fields.notify_booking_change !== undefined ? (fields.notify_booking_change ? 1 : 0) : null, + fields.notify_trip_reminder !== undefined ? (fields.notify_trip_reminder ? 1 : 0) : null, + fields.notify_webhook !== undefined ? (fields.notify_webhook ? 1 : 0) : null, + userId + ); + + return db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(userId); +} diff --git a/server/src/services/oidcService.ts b/server/src/services/oidcService.ts new file mode 100644 index 0000000..6ca6cc1 --- /dev/null +++ b/server/src/services/oidcService.ts @@ -0,0 +1,314 @@ +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'; +import { decrypt_api_key } from './apiKeyCrypto'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface OidcDiscoveryDoc { + authorization_endpoint: string; + token_endpoint: string; + userinfo_endpoint: string; + _issuer?: string; +} + +export interface OidcTokenResponse { + access_token?: string; + id_token?: string; + token_type?: string; +} + +export interface OidcUserInfo { + sub: string; + email?: string; + name?: string; + preferred_username?: string; + groups?: string[]; + roles?: string[]; + [key: string]: unknown; +} + +export interface OidcConfig { + issuer: string; + clientId: string; + clientSecret: string; + displayName: string; + discoveryUrl: string | null; +} + +// --------------------------------------------------------------------------- +// Constants / TTLs +// --------------------------------------------------------------------------- + +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 DISCOVERY_TTL = 60 * 60 * 1000; // 1 hour + +// --------------------------------------------------------------------------- +// State management – pending OIDC states +// --------------------------------------------------------------------------- + +const pendingStates = new Map(); + +setInterval(() => { + const now = Date.now(); + for (const [state, data] of pendingStates) { + if (now - data.createdAt > STATE_TTL) pendingStates.delete(state); + } +}, STATE_CLEANUP); + +export function createState(redirectUri: string, inviteToken?: string): string { + const state = crypto.randomBytes(32).toString('hex'); + pendingStates.set(state, { createdAt: Date.now(), redirectUri, inviteToken }); + return state; +} + +export function consumeState(state: string) { + const pending = pendingStates.get(state); + if (!pending) return null; + pendingStates.delete(state); + return pending; +} + +// --------------------------------------------------------------------------- +// Auth code management – short-lived codes exchanged for JWT +// --------------------------------------------------------------------------- + +const authCodes = new Map(); + +setInterval(() => { + const now = Date.now(); + for (const [code, entry] of authCodes) { + if (now - entry.created > AUTH_CODE_TTL) authCodes.delete(code); + } +}, AUTH_CODE_CLEANUP); + +export function createAuthCode(token: string): string { + const { v4: uuidv4 } = require('uuid'); + const authCode: string = uuidv4(); + authCodes.set(authCode, { token, created: Date.now() }); + return authCode; +} + +export function consumeAuthCode(code: string): { token: string } | { error: string } { + const entry = authCodes.get(code); + if (!entry) return { error: 'Invalid or expired code' }; + authCodes.delete(code); + if (Date.now() - entry.created > AUTH_CODE_TTL) return { error: 'Code expired' }; + return { token: entry.token }; +} + +// --------------------------------------------------------------------------- +// OIDC configuration (env + DB) +// --------------------------------------------------------------------------- + +export function getOidcConfig(): OidcConfig | null { + const get = (key: string) => + (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || null; + + const issuer = process.env.OIDC_ISSUER || get('oidc_issuer'); + const clientId = process.env.OIDC_CLIENT_ID || get('oidc_client_id'); + const clientSecret = process.env.OIDC_CLIENT_SECRET || decrypt_api_key(get('oidc_client_secret')); + const displayName = process.env.OIDC_DISPLAY_NAME || get('oidc_display_name') || 'SSO'; + const discoveryUrl = process.env.OIDC_DISCOVERY_URL || get('oidc_discovery_url') || null; + + if (!issuer || !clientId || !clientSecret) return null; + return { issuer: issuer.replace(/\/+$/, ''), clientId, clientSecret, displayName, discoveryUrl }; +} + +// --------------------------------------------------------------------------- +// Discovery document (cached, 1 h TTL) +// --------------------------------------------------------------------------- + +let discoveryCache: OidcDiscoveryDoc | null = null; +let discoveryCacheTime = 0; + +export async function discover(issuer: string, discoveryUrl?: string | null): Promise { + const url = discoveryUrl || `${issuer}/.well-known/openid-configuration`; + if (discoveryCache && Date.now() - discoveryCacheTime < DISCOVERY_TTL && discoveryCache._issuer === url) { + return discoveryCache; + } + const res = await fetch(url); + if (!res.ok) throw new Error('Failed to fetch OIDC discovery document'); + const doc = (await res.json()) as OidcDiscoveryDoc; + doc._issuer = url; + discoveryCache = doc; + discoveryCacheTime = Date.now(); + return doc; +} + +// --------------------------------------------------------------------------- +// Role resolution via OIDC claims +// --------------------------------------------------------------------------- + +export function resolveOidcRole(userInfo: OidcUserInfo, isFirstUser: boolean): 'admin' | 'user' { + if (isFirstUser) return 'admin'; + const adminValue = process.env.OIDC_ADMIN_VALUE; + if (!adminValue) return 'user'; + const claimKey = process.env.OIDC_ADMIN_CLAIM || 'groups'; + const claimData = userInfo[claimKey]; + if (Array.isArray(claimData)) { + return claimData.some((v) => String(v) === adminValue) ? 'admin' : 'user'; + } + if (typeof claimData === 'string') { + return claimData === adminValue ? 'admin' : 'user'; + } + return 'user'; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +export function frontendUrl(path: string): string { + const base = process.env.NODE_ENV === 'production' ? '' : 'http://localhost:5173'; + return base + path; +} + +export function generateToken(user: { id: number }): string { + return jwt.sign({ id: user.id }, JWT_SECRET, { expiresIn: '24h', algorithm: 'HS256' }); +} + +export function getAppUrl(): string | null { + return ( + process.env.APP_URL || + (db.prepare("SELECT value FROM app_settings WHERE key = 'app_url'").get() as { value: string } | undefined)?.value || + null + ); +} + +// --------------------------------------------------------------------------- +// Token exchange with OIDC provider +// --------------------------------------------------------------------------- + +export async function exchangeCodeForToken( + doc: OidcDiscoveryDoc, + code: string, + redirectUri: string, + clientId: string, + clientSecret: string, +): Promise { + const tokenRes = await fetch(doc.token_endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + client_id: clientId, + client_secret: clientSecret, + }), + }); + const tokenData = (await tokenRes.json()) as OidcTokenResponse; + return { ...tokenData, _ok: tokenRes.ok, _status: tokenRes.status }; +} + +// --------------------------------------------------------------------------- +// Fetch userinfo from OIDC provider +// --------------------------------------------------------------------------- + +export async function getUserInfo(userinfoEndpoint: string, accessToken: string): Promise { + const res = await fetch(userinfoEndpoint, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + return (await res.json()) as OidcUserInfo; +} + +// --------------------------------------------------------------------------- +// Find or create user by OIDC sub / email +// --------------------------------------------------------------------------- + +export function findOrCreateUser( + userInfo: OidcUserInfo, + config: OidcConfig, + inviteToken?: string, +): { user: User } | { error: string } { + const email = userInfo.email!.toLowerCase(); + const name = userInfo.name || userInfo.preferred_username || email.split('@')[0]; + const sub = userInfo.sub; + + // Try to find existing user by sub, then by email + 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) as User | undefined; + } + + if (user) { + // Link OIDC identity if not yet linked + if (!user.oidc_sub) { + db.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?').run(sub, config.issuer, user.id); + } + // Update role based on OIDC claims on every login (if claim mapping is configured) + if (process.env.OIDC_ADMIN_VALUE) { + const newRole = resolveOidcRole(userInfo, false); + if (user.role !== newRole) { + db.prepare('UPDATE users SET role = ? WHERE id = ?').run(newRole, user.id); + user = { ...user, role: newRole } as User; + } + } + return { user }; + } + + // --- New user registration --- + const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count; + const isFirstUser = userCount === 0; + + let validInvite: any = null; + if (inviteToken) { + validInvite = db.prepare('SELECT * FROM invite_tokens WHERE token = ?').get(inviteToken); + if (validInvite) { + if (validInvite.max_uses > 0 && validInvite.used_count >= validInvite.max_uses) validInvite = null; + if (validInvite?.expires_at && new Date(validInvite.expires_at) < new Date()) validInvite = null; + } + } + + if (!isFirstUser && !validInvite) { + const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get() as + | { value: string } + | undefined; + if (setting?.value === 'false') { + return { error: 'registration_disabled' }; + } + } + + const role = resolveOidcRole(userInfo, isFirstUser); + const randomPass = crypto.randomBytes(32).toString('hex'); + const bcrypt = require('bcryptjs'); + const hash = bcrypt.hashSync(randomPass, 10); + + // Username: sanitize and avoid collisions + 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}`; + + const result = db.prepare( + 'INSERT INTO users (username, email, password_hash, role, oidc_sub, oidc_issuer) VALUES (?, ?, ?, ?, ?, ?)', + ).run(username, email, hash, role, sub, config.issuer); + + if (validInvite) { + const updated = db.prepare( + 'UPDATE invite_tokens SET used_count = used_count + 1 WHERE id = ? AND (max_uses = 0 OR used_count < max_uses)', + ).run(validInvite.id); + if (updated.changes === 0) { + console.warn(`[OIDC] Invite token ${inviteToken?.slice(0, 8)}... exceeded max_uses (race condition)`); + } + } + + user = { id: Number(result.lastInsertRowid), username, email, role } as User; + return { user }; +} + +// --------------------------------------------------------------------------- +// Update last_login timestamp +// --------------------------------------------------------------------------- + +export function touchLastLogin(userId: number): void { + db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(userId); +} diff --git a/server/src/services/packingService.ts b/server/src/services/packingService.ts new file mode 100644 index 0000000..4cbd911 --- /dev/null +++ b/server/src/services/packingService.ts @@ -0,0 +1,223 @@ +import { db, canAccessTrip } from '../db/database'; + +const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b']; + +export function verifyTripAccess(tripId: string | number, userId: number) { + return canAccessTrip(tripId, userId); +} + +// ── Items ────────────────────────────────────────────────────────────────── + +export function listItems(tripId: string | number) { + return db.prepare( + 'SELECT * FROM packing_items WHERE trip_id = ? ORDER BY sort_order ASC, created_at ASC' + ).all(tripId); +} + +export function createItem(tripId: string | number, data: { name: string; category?: string; checked?: boolean }) { + 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( + 'INSERT INTO packing_items (trip_id, name, checked, category, sort_order) VALUES (?, ?, ?, ?, ?)' + ).run(tripId, data.name, data.checked ? 1 : 0, data.category || 'Allgemein', sortOrder); + + return db.prepare('SELECT * FROM packing_items WHERE id = ?').get(result.lastInsertRowid); +} + +export function updateItem( + tripId: string | number, + id: string | number, + data: { name?: string; checked?: number; category?: string; weight_grams?: number | null; bag_id?: number | null }, + bodyKeys: string[] +) { + const item = db.prepare('SELECT * FROM packing_items WHERE id = ? AND trip_id = ?').get(id, tripId); + if (!item) return null; + + db.prepare(` + UPDATE packing_items SET + name = COALESCE(?, name), + checked = CASE WHEN ? IS NOT NULL THEN ? ELSE checked END, + category = COALESCE(?, category), + weight_grams = CASE WHEN ? THEN ? ELSE weight_grams END, + bag_id = CASE WHEN ? THEN ? ELSE bag_id END + WHERE id = ? + `).run( + data.name || null, + data.checked !== undefined ? 1 : null, + data.checked ? 1 : 0, + data.category || null, + bodyKeys.includes('weight_grams') ? 1 : 0, + data.weight_grams ?? null, + bodyKeys.includes('bag_id') ? 1 : 0, + data.bag_id ?? null, + id + ); + + return db.prepare('SELECT * FROM packing_items WHERE id = ?').get(id); +} + +export function deleteItem(tripId: string | number, id: string | number) { + const item = db.prepare('SELECT id FROM packing_items WHERE id = ? AND trip_id = ?').get(id, tripId); + if (!item) return false; + + db.prepare('DELETE FROM packing_items WHERE id = ?').run(id); + return true; +} + +// ── Bulk Import ──────────────────────────────────────────────────────────── + +interface ImportItem { + name?: string; + checked?: boolean; + category?: string; + weight_grams?: string | number; + bag?: string; +} + +export function bulkImport(tripId: string | number, items: ImportItem[]) { + const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_items WHERE trip_id = ?').get(tripId) as { max: number | null }; + let sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1; + + const stmt = db.prepare('INSERT INTO packing_items (trip_id, name, checked, category, weight_grams, bag_id, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)'); + const created: any[] = []; + + const insertAll = db.transaction(() => { + for (const item of items) { + if (!item.name?.trim()) continue; + const checked = item.checked ? 1 : 0; + const weight = item.weight_grams ? parseInt(String(item.weight_grams)) || null : null; + + // Resolve bag by name if provided + let bagId = null; + if (item.bag?.trim()) { + const bagName = item.bag.trim(); + const existing = db.prepare('SELECT id FROM packing_bags WHERE trip_id = ? AND name = ?').get(tripId, bagName) as { id: number } | undefined; + if (existing) { + bagId = existing.id; + } else { + const bagCount = (db.prepare('SELECT COUNT(*) as c FROM packing_bags WHERE trip_id = ?').get(tripId) as { c: number }).c; + const newBag = db.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(tripId, bagName, BAG_COLORS[bagCount % BAG_COLORS.length]); + bagId = newBag.lastInsertRowid; + } + } + + const result = stmt.run(tripId, item.name.trim(), checked, item.category?.trim() || 'Other', weight, bagId, sortOrder++); + created.push(db.prepare('SELECT * FROM packing_items WHERE id = ?').get(result.lastInsertRowid)); + } + }); + + insertAll(); + return created; +} + +// ── Bags ─────────────────────────────────────────────────────────────────── + +export function listBags(tripId: string | number) { + return db.prepare('SELECT * FROM packing_bags WHERE trip_id = ? ORDER BY sort_order, id').all(tripId); +} + +export function createBag(tripId: string | number, data: { name: string; color?: string }) { + const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_bags WHERE trip_id = ?').get(tripId) as { max: number | null }; + const result = db.prepare('INSERT INTO packing_bags (trip_id, name, color, sort_order) VALUES (?, ?, ?, ?)').run( + tripId, data.name.trim(), data.color || '#6366f1', (maxOrder.max ?? -1) + 1 + ); + return db.prepare('SELECT * FROM packing_bags WHERE id = ?').get(result.lastInsertRowid); +} + +export function updateBag( + tripId: string | number, + bagId: string | number, + data: { name?: string; color?: string; weight_limit_grams?: number | null } +) { + const bag = db.prepare('SELECT * FROM packing_bags WHERE id = ? AND trip_id = ?').get(bagId, tripId); + if (!bag) return null; + + db.prepare('UPDATE packing_bags SET name = COALESCE(?, name), color = COALESCE(?, color), weight_limit_grams = ? WHERE id = ?').run( + data.name?.trim() || null, data.color || null, data.weight_limit_grams ?? null, bagId + ); + return db.prepare('SELECT * FROM packing_bags WHERE id = ?').get(bagId); +} + +export function deleteBag(tripId: string | number, bagId: string | number) { + const bag = db.prepare('SELECT * FROM packing_bags WHERE id = ? AND trip_id = ?').get(bagId, tripId); + if (!bag) return false; + + db.prepare('DELETE FROM packing_bags WHERE id = ?').run(bagId); + return true; +} + +// ── Apply Template ───────────────────────────────────────────────────────── + +export function applyTemplate(tripId: string | number, templateId: string | number) { + const templateItems = db.prepare(` + SELECT ti.name, tc.name as category + FROM packing_template_items ti + JOIN packing_template_categories tc ON ti.category_id = tc.id + WHERE tc.template_id = ? + ORDER BY tc.sort_order, ti.sort_order + `).all(templateId) as { name: string; category: string }[]; + + if (templateItems.length === 0) return null; + + const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_items WHERE trip_id = ?').get(tripId) as { max: number | null }; + let sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1; + + const insert = db.prepare('INSERT INTO packing_items (trip_id, name, checked, category, sort_order) VALUES (?, ?, 0, ?, ?)'); + const added: any[] = []; + for (const ti of templateItems) { + const result = insert.run(tripId, ti.name, ti.category, sortOrder++); + const item = db.prepare('SELECT * FROM packing_items WHERE id = ?').get(result.lastInsertRowid); + added.push(item); + } + + return added; +} + +// ── Category Assignees ───────────────────────────────────────────────────── + +export function getCategoryAssignees(tripId: string | number) { + const rows = db.prepare(` + SELECT pca.category_name, pca.user_id, u.username, u.avatar + FROM packing_category_assignees pca + JOIN users u ON pca.user_id = u.id + WHERE pca.trip_id = ? + `).all(tripId); + + // Group by category + const assignees: Record = {}; + for (const row of rows as any[]) { + if (!assignees[row.category_name]) assignees[row.category_name] = []; + assignees[row.category_name].push({ user_id: row.user_id, username: row.username, avatar: row.avatar }); + } + + return assignees; +} + +export function updateCategoryAssignees(tripId: string | number, categoryName: string, userIds: number[] | undefined) { + db.prepare('DELETE FROM packing_category_assignees WHERE trip_id = ? AND category_name = ?').run(tripId, categoryName); + + if (Array.isArray(userIds) && userIds.length > 0) { + const insert = db.prepare('INSERT OR IGNORE INTO packing_category_assignees (trip_id, category_name, user_id) VALUES (?, ?, ?)'); + for (const uid of userIds) insert.run(tripId, categoryName, uid); + } + + return db.prepare(` + SELECT pca.user_id, u.username, u.avatar + FROM packing_category_assignees pca + JOIN users u ON pca.user_id = u.id + WHERE pca.trip_id = ? AND pca.category_name = ? + `).all(tripId, categoryName); +} + +// ── Reorder ──────────────────────────────────────────────────────────────── + +export function reorderItems(tripId: string | number, orderedIds: number[]) { + const update = db.prepare('UPDATE packing_items SET sort_order = ? WHERE id = ? AND trip_id = ?'); + const updateMany = db.transaction((ids: number[]) => { + ids.forEach((id, index) => { + update.run(index, id, tripId); + }); + }); + updateMany(orderedIds); +} diff --git a/server/src/services/placeService.ts b/server/src/services/placeService.ts new file mode 100644 index 0000000..438bb60 --- /dev/null +++ b/server/src/services/placeService.ts @@ -0,0 +1,440 @@ +import fetch from 'node-fetch'; +import { db, getPlaceWithTags } from '../db/database'; +import { loadTagsByPlaceIds } from './queryHelpers'; +import { 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[]; +} + +// --------------------------------------------------------------------------- +// GPX helpers +// --------------------------------------------------------------------------- + +function parseCoords(attrs: string): { lat: number; lng: number } | null { + const latMatch = attrs.match(/lat=["']([^"']+)["']/i); + const lonMatch = attrs.match(/lon=["']([^"']+)["']/i); + if (!latMatch || !lonMatch) return null; + const lat = parseFloat(latMatch[1]); + const lng = parseFloat(lonMatch[1]); + return (!isNaN(lat) && !isNaN(lng)) ? { lat, lng } : null; +} + +function stripCdata(s: string) { + return s.replace(//g, '$1').trim(); +} + +function extractName(body: string) { + const m = body.match(/]*>([\s\S]*?)<\/name>/i); + return m ? stripCdata(m[1]) : null; +} + +function extractDesc(body: string) { + const m = body.match(/]*>([\s\S]*?)<\/desc>/i); + return m ? stripCdata(m[1]) : null; +} + +// --------------------------------------------------------------------------- +// List places +// --------------------------------------------------------------------------- + +export function listPlaces( + tripId: string, + filters: { search?: string; category?: string; tag?: string }, +) { + 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: (string | number)[] = [tripId]; + + if (filters.search) { + query += ' AND (p.name LIKE ? OR p.address LIKE ? OR p.description LIKE ?)'; + const searchParam = `%${filters.search}%`; + params.push(searchParam, searchParam, searchParam); + } + + if (filters.category) { + query += ' AND p.category_id = ?'; + params.push(filters.category); + } + + if (filters.tag) { + query += ' AND p.id IN (SELECT place_id FROM place_tags WHERE tag_id = ?)'; + params.push(filters.tag); + } + + query += ' ORDER BY p.created_at DESC'; + + const places = db.prepare(query).all(...params) as PlaceWithCategory[]; + + const placeIds = places.map(p => p.id); + const tagsByPlaceId = loadTagsByPlaceIds(placeIds); + + return places.map(p => ({ + ...p, + category: p.category_id ? { + id: p.category_id, + name: p.category_name, + color: p.category_color, + icon: p.category_icon, + } : null, + tags: tagsByPlaceId[p.id] || [], + })); +} + +// --------------------------------------------------------------------------- +// Create place +// --------------------------------------------------------------------------- + +export function createPlace( + tripId: string, + body: { + name: string; description?: string; lat?: number; lng?: number; address?: string; + category_id?: number; price?: number; currency?: string; + place_time?: string; end_time?: string; + duration_minutes?: number; notes?: string; image_url?: string; + google_place_id?: string; osm_id?: string; website?: string; phone?: string; + transport_mode?: string; tags?: number[]; + }, +) { + const { + name, description, lat, lng, address, category_id, price, currency, + place_time, end_time, + duration_minutes, notes, image_url, google_place_id, osm_id, website, phone, + transport_mode, tags = [], + } = body; + + const result = db.prepare(` + INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency, + place_time, end_time, + duration_minutes, notes, image_url, google_place_id, osm_id, website, phone, transport_mode) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + tripId, name, description || null, lat || null, lng || null, address || null, + category_id || null, price || null, currency || null, + place_time || null, end_time || null, duration_minutes || 60, notes || null, image_url || null, + google_place_id || null, osm_id || null, website || null, phone || null, transport_mode || 'walking', + ); + + const placeId = result.lastInsertRowid; + + if (tags && tags.length > 0) { + const insertTag = db.prepare('INSERT OR IGNORE INTO place_tags (place_id, tag_id) VALUES (?, ?)'); + for (const tagId of tags) { + insertTag.run(placeId, tagId); + } + } + + return getPlaceWithTags(Number(placeId)); +} + +// --------------------------------------------------------------------------- +// Get single place +// --------------------------------------------------------------------------- + +export function getPlace(tripId: string, placeId: string) { + const placeCheck = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId); + if (!placeCheck) return null; + return getPlaceWithTags(placeId); +} + +// --------------------------------------------------------------------------- +// Update place +// --------------------------------------------------------------------------- + +export function updatePlace( + tripId: string, + placeId: string, + body: { + name?: string; description?: string; lat?: number; lng?: number; address?: string; + category_id?: number; price?: number; currency?: string; + place_time?: string; end_time?: string; + duration_minutes?: number; notes?: string; image_url?: string; + google_place_id?: string; website?: string; phone?: string; + transport_mode?: string; tags?: number[]; + }, +) { + const existingPlace = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId) as Place | undefined; + if (!existingPlace) return null; + + const { + name, description, lat, lng, address, category_id, price, currency, + place_time, end_time, + duration_minutes, notes, image_url, google_place_id, website, phone, + transport_mode, tags, + } = body; + + db.prepare(` + UPDATE places SET + name = COALESCE(?, name), + description = ?, + lat = ?, + lng = ?, + address = ?, + category_id = ?, + price = ?, + currency = COALESCE(?, currency), + place_time = ?, + end_time = ?, + duration_minutes = COALESCE(?, duration_minutes), + notes = ?, + image_url = ?, + google_place_id = ?, + website = ?, + phone = ?, + transport_mode = COALESCE(?, transport_mode), + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `).run( + name || null, + description !== undefined ? description : existingPlace.description, + lat !== undefined ? lat : existingPlace.lat, + lng !== undefined ? lng : existingPlace.lng, + address !== undefined ? address : existingPlace.address, + category_id !== undefined ? category_id : existingPlace.category_id, + price !== undefined ? price : existingPlace.price, + currency || null, + place_time !== undefined ? place_time : existingPlace.place_time, + end_time !== undefined ? end_time : existingPlace.end_time, + duration_minutes || null, + notes !== undefined ? notes : existingPlace.notes, + image_url !== undefined ? image_url : existingPlace.image_url, + google_place_id !== undefined ? google_place_id : existingPlace.google_place_id, + website !== undefined ? website : existingPlace.website, + phone !== undefined ? phone : existingPlace.phone, + transport_mode || null, + placeId, + ); + + if (tags !== undefined) { + db.prepare('DELETE FROM place_tags WHERE place_id = ?').run(placeId); + if (tags.length > 0) { + const insertTag = db.prepare('INSERT OR IGNORE INTO place_tags (place_id, tag_id) VALUES (?, ?)'); + for (const tagId of tags) { + insertTag.run(placeId, tagId); + } + } + } + + return getPlaceWithTags(placeId); +} + +// --------------------------------------------------------------------------- +// Delete place +// --------------------------------------------------------------------------- + +export function deletePlace(tripId: string, placeId: string): boolean { + const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId); + if (!place) return false; + db.prepare('DELETE FROM places WHERE id = ?').run(placeId); + return true; +} + +// --------------------------------------------------------------------------- +// Import GPX +// --------------------------------------------------------------------------- + +export function importGpx(tripId: string, fileBuffer: Buffer) { + const xml = fileBuffer.toString('utf-8'); + + const waypoints: { name: string; lat: number; lng: number; description: string | null; routeGeometry?: string }[] = []; + + // 1) Parse elements (named waypoints / POIs) + const wptRegex = /]+)>([\s\S]*?)<\/wpt>/gi; + let match; + while ((match = wptRegex.exec(xml)) !== null) { + const coords = parseCoords(match[1]); + if (!coords) continue; + const name = extractName(match[2]) || `Waypoint ${waypoints.length + 1}`; + waypoints.push({ ...coords, name, description: extractDesc(match[2]) }); + } + + // 2) If no , try (route points) + if (waypoints.length === 0) { + const rteptRegex = /]+)>([\s\S]*?)<\/rtept>/gi; + while ((match = rteptRegex.exec(xml)) !== null) { + const coords = parseCoords(match[1]); + if (!coords) continue; + const name = extractName(match[2]) || `Route Point ${waypoints.length + 1}`; + waypoints.push({ ...coords, name, description: extractDesc(match[2]) }); + } + } + + // 3) If still nothing, extract full track geometry from + if (waypoints.length === 0) { + const trackNameMatch = xml.match(/]*>[\s\S]*?]*>([\s\S]*?)<\/name>/i); + const trackName = trackNameMatch?.[1]?.trim() || 'GPX Track'; + const trackDesc = (() => { const m = xml.match(/]*>[\s\S]*?]*>([\s\S]*?)<\/desc>/i); return m ? stripCdata(m[1]) : null; })(); + const trkptRegex = /]*?)(?:\/>|>([\s\S]*?)<\/trkpt>)/gi; + const trackPoints: { lat: number; lng: number; ele: number | null }[] = []; + while ((match = trkptRegex.exec(xml)) !== null) { + const coords = parseCoords(match[1]); + if (!coords) continue; + const eleMatch = match[2]?.match(/]*>([\s\S]*?)<\/ele>/i); + const ele = eleMatch ? parseFloat(eleMatch[1]) : null; + trackPoints.push({ ...coords, ele: (ele !== null && !isNaN(ele)) ? ele : null }); + } + if (trackPoints.length > 0) { + const start = trackPoints[0]; + const hasAllEle = trackPoints.every(p => p.ele !== null); + const routeGeometry = trackPoints.map(p => hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng]); + waypoints.push({ ...start, name: trackName, description: trackDesc, routeGeometry: JSON.stringify(routeGeometry) }); + } + } + + if (waypoints.length === 0) { + return null; + } + + const insertStmt = db.prepare(` + INSERT INTO places (trip_id, name, description, lat, lng, transport_mode, route_geometry) + VALUES (?, ?, ?, ?, ?, 'walking', ?) + `); + const created: any[] = []; + const insertAll = db.transaction(() => { + for (const wp of waypoints) { + const result = insertStmt.run(tripId, wp.name, wp.description, wp.lat, wp.lng, wp.routeGeometry || null); + const place = getPlaceWithTags(Number(result.lastInsertRowid)); + created.push(place); + } + }); + insertAll(); + + return created; +} + +// --------------------------------------------------------------------------- +// Import Google Maps list +// --------------------------------------------------------------------------- + +export async function importGoogleList(tripId: string, url: string) { + let listId: string | null = null; + let resolvedUrl = url; + + // Follow redirects for short URLs (maps.app.goo.gl, goo.gl) + if (url.includes('goo.gl') || url.includes('maps.app')) { + const redirectRes = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(10000) }); + resolvedUrl = redirectRes.url; + } + + // Pattern: /placelists/list/{ID} + const plMatch = resolvedUrl.match(/placelists\/list\/([A-Za-z0-9_-]+)/); + if (plMatch) listId = plMatch[1]; + + // Pattern: !2s{ID} in data URL params + if (!listId) { + const dataMatch = resolvedUrl.match(/!2s([A-Za-z0-9_-]{15,})/); + if (dataMatch) listId = dataMatch[1]; + } + + if (!listId) { + return { error: 'Could not extract list ID from URL. Please use a shared Google Maps list link.', status: 400 }; + } + + // Fetch list data from Google Maps internal API + const apiUrl = `https://www.google.com/maps/preview/entitylist/getlist?authuser=0&hl=en&gl=us&pb=!1m1!1s${encodeURIComponent(listId)}!2e2!3e2!4i500!16b1`; + const apiRes = await fetch(apiUrl, { + headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' }, + signal: AbortSignal.timeout(15000), + }); + + if (!apiRes.ok) { + return { error: 'Failed to fetch list from Google Maps', status: 502 }; + } + + const rawText = await apiRes.text(); + const jsonStr = rawText.substring(rawText.indexOf('\n') + 1); + const listData = JSON.parse(jsonStr); + + const meta = listData[0]; + if (!meta) { + return { error: 'Invalid list data received from Google Maps', status: 400 }; + } + + const listName = meta[4] || 'Google Maps List'; + const items = meta[8]; + + if (!Array.isArray(items) || items.length === 0) { + return { error: 'List is empty or could not be read', status: 400 }; + } + + // Parse place data from items + const places: { name: string; lat: number; lng: number; notes: string | null }[] = []; + for (const item of items) { + const coords = item?.[1]?.[5]; + const lat = coords?.[2]; + const lng = coords?.[3]; + const name = item?.[2]; + const note = item?.[3] || null; + + if (name && typeof lat === 'number' && typeof lng === 'number' && !isNaN(lat) && !isNaN(lng)) { + places.push({ name, lat, lng, notes: note || null }); + } + } + + if (places.length === 0) { + return { error: 'No places with coordinates found in list', status: 400 }; + } + + // Insert places into trip + const insertStmt = db.prepare(` + INSERT INTO places (trip_id, name, lat, lng, notes, transport_mode) + VALUES (?, ?, ?, ?, ?, 'walking') + `); + const created: any[] = []; + const insertAll = db.transaction(() => { + for (const p of places) { + const result = insertStmt.run(tripId, p.name, p.lat, p.lng, p.notes); + const place = getPlaceWithTags(Number(result.lastInsertRowid)); + created.push(place); + } + }); + insertAll(); + + return { places: created, listName }; +} + +// --------------------------------------------------------------------------- +// Search place image (Unsplash) +// --------------------------------------------------------------------------- + +export async function searchPlaceImage(tripId: string, placeId: string, userId: number) { + const place = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId) as Place | undefined; + if (!place) return { error: 'Place not found', status: 404 }; + + const user = db.prepare('SELECT unsplash_api_key FROM users WHERE id = ?').get(userId) as { unsplash_api_key: string | null } | undefined; + if (!user || !user.unsplash_api_key) { + return { error: 'No Unsplash API key configured', status: 400 }; + } + + const query = encodeURIComponent(place.name + (place.address ? ' ' + place.address : '')); + 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() as UnsplashSearchResponse; + + if (!response.ok) { + return { error: data.errors?.[0] || 'Unsplash API error', status: response.status }; + } + + const photos = (data.results || []).map((p: NonNullable[number]) => ({ + id: p.id, + url: p.urls?.regular, + thumb: p.urls?.thumb, + description: p.description || p.alt_description, + photographer: p.user?.name, + link: p.links?.html, + })); + + return { photos }; +} diff --git a/server/src/services/reservationService.ts b/server/src/services/reservationService.ts new file mode 100644 index 0000000..bbb5d18 --- /dev/null +++ b/server/src/services/reservationService.ts @@ -0,0 +1,242 @@ +import { db, canAccessTrip } from '../db/database'; +import { Reservation } from '../types'; + +export function verifyTripAccess(tripId: string | number, userId: number) { + return canAccessTrip(tripId, userId); +} + +export function listReservations(tripId: string | number) { + return db.prepare(` + SELECT r.*, d.day_number, p.name as place_name, r.assignment_id, + ap.place_id as accommodation_place_id, acc_p.name as accommodation_name + FROM reservations r + LEFT JOIN days d ON r.day_id = d.id + LEFT JOIN places p ON r.place_id = p.id + LEFT JOIN day_accommodations ap ON r.accommodation_id = ap.id + LEFT JOIN places acc_p ON ap.place_id = acc_p.id + WHERE r.trip_id = ? + ORDER BY r.reservation_time ASC, r.created_at ASC + `).all(tripId); +} + +export function getReservationWithJoins(id: string | number) { + return db.prepare(` + SELECT r.*, d.day_number, p.name as place_name, r.assignment_id, + ap.place_id as accommodation_place_id, acc_p.name as accommodation_name + FROM reservations r + LEFT JOIN days d ON r.day_id = d.id + LEFT JOIN places p ON r.place_id = p.id + LEFT JOIN day_accommodations ap ON r.accommodation_id = ap.id + LEFT JOIN places acc_p ON ap.place_id = acc_p.id + WHERE r.id = ? + `).get(id); +} + +interface CreateAccommodation { + place_id?: number; + start_day_id?: number; + end_day_id?: number; + check_in?: string; + check_out?: string; + confirmation?: string; +} + +interface CreateReservationData { + title: string; + reservation_time?: string; + reservation_end_time?: string; + location?: string; + confirmation_number?: string; + notes?: string; + day_id?: number; + place_id?: number; + assignment_id?: number; + status?: string; + type?: string; + accommodation_id?: number; + metadata?: any; + create_accommodation?: CreateAccommodation; +} + +export function createReservation(tripId: string | number, data: CreateReservationData): { reservation: any; accommodationCreated: boolean } { + const { + title, reservation_time, reservation_end_time, location, + confirmation_number, notes, day_id, place_id, assignment_id, + status, type, accommodation_id, metadata, create_accommodation + } = data; + + let accommodationCreated = false; + + // Auto-create accommodation for hotel reservations + let resolvedAccommodationId: number | null = accommodation_id || null; + if (type === 'hotel' && !resolvedAccommodationId && create_accommodation) { + const { place_id: accPlaceId, start_day_id, end_day_id, check_in, check_out, confirmation: accConf } = create_accommodation; + if (accPlaceId && start_day_id && end_day_id) { + const accResult = db.prepare( + 'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)' + ).run(tripId, accPlaceId, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null); + resolvedAccommodationId = Number(accResult.lastInsertRowid); + accommodationCreated = true; + } + } + + const result = db.prepare(` + INSERT INTO reservations (trip_id, day_id, place_id, assignment_id, title, reservation_time, reservation_end_time, location, confirmation_number, notes, status, type, accommodation_id, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + tripId, + day_id || null, + place_id || null, + assignment_id || null, + title, + reservation_time || null, + reservation_end_time || null, + location || null, + confirmation_number || null, + notes || null, + status || 'pending', + type || 'other', + resolvedAccommodationId, + metadata ? JSON.stringify(metadata) : null + ); + + // Sync check-in/out to accommodation if linked + if (accommodation_id && metadata) { + const meta = typeof metadata === 'string' ? JSON.parse(metadata) : metadata; + if (meta.check_in_time || meta.check_out_time) { + db.prepare('UPDATE day_accommodations SET check_in = COALESCE(?, check_in), check_out = COALESCE(?, check_out) WHERE id = ?') + .run(meta.check_in_time || null, meta.check_out_time || null, accommodation_id); + } + if (confirmation_number) { + db.prepare('UPDATE day_accommodations SET confirmation = COALESCE(?, confirmation) WHERE id = ?') + .run(confirmation_number, accommodation_id); + } + } + + const reservation = getReservationWithJoins(Number(result.lastInsertRowid)); + return { reservation, accommodationCreated }; +} + +export function updatePositions(tripId: string | number, positions: { id: number; day_plan_position: number }[]) { + const stmt = db.prepare('UPDATE reservations SET day_plan_position = ? WHERE id = ? AND trip_id = ?'); + const updateMany = db.transaction((items: { id: number; day_plan_position: number }[]) => { + for (const item of items) { + stmt.run(item.day_plan_position, item.id, tripId); + } + }); + updateMany(positions); +} + +export function getReservation(id: string | number, tripId: string | number) { + return db.prepare('SELECT * FROM reservations WHERE id = ? AND trip_id = ?').get(id, tripId) as Reservation | undefined; +} + +interface UpdateReservationData { + title?: string; + reservation_time?: string; + reservation_end_time?: string; + location?: string; + confirmation_number?: string; + notes?: string; + day_id?: number; + place_id?: number; + assignment_id?: number; + status?: string; + type?: string; + accommodation_id?: number; + metadata?: any; + create_accommodation?: CreateAccommodation; +} + +export function updateReservation(id: string | number, tripId: string | number, data: UpdateReservationData, current: Reservation): { reservation: any; accommodationChanged: boolean } { + const { + title, reservation_time, reservation_end_time, location, + confirmation_number, notes, day_id, place_id, assignment_id, + status, type, accommodation_id, metadata, create_accommodation + } = data; + + let accommodationChanged = false; + + // Update or create accommodation for hotel reservations + let resolvedAccId: number | null = accommodation_id !== undefined ? (accommodation_id || null) : (current.accommodation_id ?? null); + if (type === 'hotel' && create_accommodation) { + const { place_id: accPlaceId, start_day_id, end_day_id, check_in, check_out, confirmation: accConf } = create_accommodation; + if (accPlaceId && start_day_id && end_day_id) { + if (resolvedAccId) { + db.prepare('UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ? WHERE id = ?') + .run(accPlaceId, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null, resolvedAccId); + } else { + const accResult = db.prepare( + 'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)' + ).run(tripId, accPlaceId, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null); + resolvedAccId = Number(accResult.lastInsertRowid); + } + accommodationChanged = true; + } + } + + db.prepare(` + UPDATE reservations SET + title = COALESCE(?, title), + reservation_time = ?, + reservation_end_time = ?, + location = ?, + confirmation_number = ?, + notes = ?, + day_id = ?, + place_id = ?, + assignment_id = ?, + status = COALESCE(?, status), + type = COALESCE(?, type), + accommodation_id = ?, + metadata = ? + WHERE id = ? + `).run( + title || null, + reservation_time !== undefined ? (reservation_time || null) : current.reservation_time, + reservation_end_time !== undefined ? (reservation_end_time || null) : current.reservation_end_time, + location !== undefined ? (location || null) : current.location, + confirmation_number !== undefined ? (confirmation_number || null) : current.confirmation_number, + notes !== undefined ? (notes || null) : current.notes, + day_id !== undefined ? (day_id || null) : current.day_id, + place_id !== undefined ? (place_id || null) : current.place_id, + assignment_id !== undefined ? (assignment_id || null) : current.assignment_id, + status || null, + type || null, + resolvedAccId, + metadata !== undefined ? (metadata ? JSON.stringify(metadata) : null) : current.metadata, + id + ); + + // Sync check-in/out to accommodation if linked + const resolvedMeta = metadata !== undefined ? metadata : (current.metadata ? JSON.parse(current.metadata as string) : null); + if (resolvedAccId && resolvedMeta) { + const meta = typeof resolvedMeta === 'string' ? JSON.parse(resolvedMeta) : resolvedMeta; + if (meta.check_in_time || meta.check_out_time) { + db.prepare('UPDATE day_accommodations SET check_in = COALESCE(?, check_in), check_out = COALESCE(?, check_out) WHERE id = ?') + .run(meta.check_in_time || null, meta.check_out_time || null, resolvedAccId); + } + const resolvedConf = confirmation_number !== undefined ? confirmation_number : current.confirmation_number; + if (resolvedConf) { + db.prepare('UPDATE day_accommodations SET confirmation = COALESCE(?, confirmation) WHERE id = ?') + .run(resolvedConf, resolvedAccId); + } + } + + const reservation = getReservationWithJoins(id); + return { reservation, accommodationChanged }; +} + +export function deleteReservation(id: string | number, tripId: string | number): { deleted: { id: number; title: string; type: string; accommodation_id: number | null } | undefined; accommodationDeleted: boolean } { + const reservation = db.prepare('SELECT id, title, type, accommodation_id FROM reservations WHERE id = ? AND trip_id = ?').get(id, tripId) as { id: number; title: string; type: string; accommodation_id: number | null } | undefined; + if (!reservation) return { deleted: undefined, accommodationDeleted: false }; + + let accommodationDeleted = false; + if (reservation.accommodation_id) { + db.prepare('DELETE FROM day_accommodations WHERE id = ?').run(reservation.accommodation_id); + accommodationDeleted = true; + } + + db.prepare('DELETE FROM reservations WHERE id = ?').run(id); + return { deleted: reservation, accommodationDeleted }; +} diff --git a/server/src/services/settingsService.ts b/server/src/services/settingsService.ts new file mode 100644 index 0000000..7aa49d8 --- /dev/null +++ b/server/src/services/settingsService.ts @@ -0,0 +1,41 @@ +import { db } from '../db/database'; + +export function getUserSettings(userId: number): Record { + const rows = db.prepare('SELECT key, value FROM settings WHERE user_id = ?').all(userId) as { key: string; value: string }[]; + const settings: Record = {}; + for (const row of rows) { + try { + settings[row.key] = JSON.parse(row.value); + } catch { + settings[row.key] = row.value; + } + } + return settings; +} + +export function upsertSetting(userId: number, key: string, value: unknown) { + const serialized = typeof value === 'object' ? JSON.stringify(value) : String(value !== undefined ? value : ''); + db.prepare(` + INSERT INTO settings (user_id, key, value) VALUES (?, ?, ?) + ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value + `).run(userId, key, serialized); +} + +export function bulkUpsertSettings(userId: number, settings: Record) { + const upsert = db.prepare(` + INSERT INTO settings (user_id, key, value) VALUES (?, ?, ?) + ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value + `); + db.exec('BEGIN'); + try { + for (const [key, value] of Object.entries(settings)) { + const serialized = typeof value === 'object' ? JSON.stringify(value) : String(value !== undefined ? value : ''); + upsert.run(userId, key, serialized); + } + db.exec('COMMIT'); + } catch (err) { + db.exec('ROLLBACK'); + throw err; + } + return Object.keys(settings).length; +} diff --git a/server/src/services/shareService.ts b/server/src/services/shareService.ts new file mode 100644 index 0000000..157e9cc --- /dev/null +++ b/server/src/services/shareService.ts @@ -0,0 +1,189 @@ +import { db, canAccessTrip } from '../db/database'; +import crypto from 'crypto'; +import { loadTagsByPlaceIds } from './queryHelpers'; + +interface SharePermissions { + share_map?: boolean; + share_bookings?: boolean; + share_packing?: boolean; + share_budget?: boolean; + share_collab?: boolean; +} + +interface ShareTokenInfo { + token: string; + created_at: string; + share_map: boolean; + share_bookings: boolean; + share_packing: boolean; + share_budget: boolean; + share_collab: boolean; +} + +/** + * Creates a new share link or updates the permissions on an existing one. + * Returns an object with the token string and whether it was newly created. + */ +export function createOrUpdateShareLink( + tripId: string, + createdBy: number, + permissions: SharePermissions +): { token: string; created: boolean } { + const { + share_map = true, + share_bookings = true, + share_packing = false, + share_budget = false, + share_collab = false, + } = permissions; + + const existing = db.prepare('SELECT token FROM share_tokens WHERE trip_id = ?').get(tripId) as { token: string } | undefined; + if (existing) { + db.prepare('UPDATE share_tokens SET share_map = ?, share_bookings = ?, share_packing = ?, share_budget = ?, share_collab = ? WHERE trip_id = ?') + .run(share_map ? 1 : 0, share_bookings ? 1 : 0, share_packing ? 1 : 0, share_budget ? 1 : 0, share_collab ? 1 : 0, tripId); + return { token: existing.token, created: false }; + } + + const token = crypto.randomBytes(24).toString('base64url'); + db.prepare('INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab) VALUES (?, ?, ?, ?, ?, ?, ?, ?)') + .run(tripId, token, createdBy, share_map ? 1 : 0, share_bookings ? 1 : 0, share_packing ? 1 : 0, share_budget ? 1 : 0, share_collab ? 1 : 0); + return { token, created: true }; +} + +/** + * Returns share token info for a trip, or null if no share link exists. + */ +export function getShareLink(tripId: string): ShareTokenInfo | null { + const row = db.prepare('SELECT * FROM share_tokens WHERE trip_id = ?').get(tripId) as any; + if (!row) return null; + return { + token: row.token, + created_at: row.created_at, + share_map: !!row.share_map, + share_bookings: !!row.share_bookings, + share_packing: !!row.share_packing, + share_budget: !!row.share_budget, + share_collab: !!row.share_collab, + }; +} + +/** + * Deletes the share token for a trip. + */ +export function deleteShareLink(tripId: string): void { + db.prepare('DELETE FROM share_tokens WHERE trip_id = ?').run(tripId); +} + +/** + * Loads the full public trip data for a share token, filtered by the token's + * permission flags. Returns null if the token is invalid or the trip is gone. + */ +export function getSharedTripData(token: string): Record | null { + const shareRow = db.prepare('SELECT * FROM share_tokens WHERE token = ?').get(token) as any; + if (!shareRow) return null; + + const tripId = shareRow.trip_id; + + // Trip + const trip = db.prepare('SELECT id, title, description, start_date, end_date, cover_image, currency FROM trips WHERE id = ?').get(tripId); + if (!trip) return null; + + // Days with assignments + const days = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(tripId) as any[]; + const dayIds = days.map(d => d.id); + + let assignments: Record = {}; + let dayNotes: Record = {}; + if (dayIds.length > 0) { + const ph = dayIds.map(() => '?').join(','); + 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, + COALESCE(da.assignment_time, p.place_time) as place_time, + COALESCE(da.assignment_end_time, p.end_time) as end_time, + p.duration_minutes, p.notes as place_notes, p.image_url, p.transport_mode, + c.name as category_name, c.color as category_color, c.icon as category_icon + FROM day_assignments da + JOIN places p ON da.place_id = p.id + LEFT JOIN categories c ON p.category_id = c.id + WHERE da.day_id IN (${ph}) + ORDER BY da.order_index ASC + `).all(...dayIds); + + const placeIds = [...new Set(allAssignments.map((a: any) => a.place_id))]; + const tagsByPlace = loadTagsByPlaceIds(placeIds, { compact: true }); + + const byDay: Record = {}; + for (const a of allAssignments as any[]) { + if (!byDay[a.day_id]) byDay[a.day_id] = []; + byDay[a.day_id].push({ + id: a.id, day_id: a.day_id, order_index: a.order_index, notes: a.notes, + 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, place_time: a.place_time, end_time: a.end_time, + image_url: a.image_url, transport_mode: a.transport_mode, + category: a.category_id ? { id: a.category_id, name: a.category_name, color: a.category_color, icon: a.category_icon } : null, + tags: tagsByPlace[a.place_id] || [], + } + }); + } + assignments = byDay; + + const allNotes = db.prepare(`SELECT * FROM day_notes WHERE day_id IN (${ph}) ORDER BY sort_order ASC`).all(...dayIds); + const notesByDay: Record = {}; + for (const n of allNotes as any[]) { + if (!notesByDay[n.day_id]) notesByDay[n.day_id] = []; + notesByDay[n.day_id].push(n); + } + dayNotes = notesByDay; + } + + // Places + const places = 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.trip_id = ? ORDER BY p.created_at DESC + `).all(tripId); + + // Reservations + const reservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ? ORDER BY reservation_time ASC').all(tripId); + + // Accommodations + const accommodations = db.prepare(` + SELECT a.*, p.name as place_name, p.address as place_address, p.lat as place_lat, p.lng as place_lng + FROM day_accommodations a JOIN places p ON a.place_id = p.id + WHERE a.trip_id = ? + `).all(tripId); + + // Packing + const packing = db.prepare('SELECT * FROM packing_items WHERE trip_id = ? ORDER BY sort_order ASC').all(tripId); + + // Budget + const budget = db.prepare('SELECT * FROM budget_items WHERE trip_id = ? ORDER BY category ASC').all(tripId); + + // Categories + const categories = db.prepare('SELECT * FROM categories').all(); + + const permissions = { + share_map: !!shareRow.share_map, + share_bookings: !!shareRow.share_bookings, + share_packing: !!shareRow.share_packing, + share_budget: !!shareRow.share_budget, + share_collab: !!shareRow.share_collab, + }; + + // Collab messages (only if owner chose to share) + const collabMessages = permissions.share_collab + ? db.prepare('SELECT m.*, u.username, u.avatar FROM collab_messages m JOIN users u ON m.user_id = u.id WHERE m.trip_id = ? ORDER BY m.created_at ASC').all(tripId) + : []; + + return { + trip, days, assignments, dayNotes, places, categories, permissions, + reservations: permissions.share_bookings ? reservations : [], + accommodations: permissions.share_bookings ? accommodations : [], + packing: permissions.share_packing ? packing : [], + budget: permissions.share_budget ? budget : [], + collab: collabMessages, + }; +} diff --git a/server/src/services/tagService.ts b/server/src/services/tagService.ts new file mode 100644 index 0000000..ef17b58 --- /dev/null +++ b/server/src/services/tagService.ts @@ -0,0 +1,26 @@ +import { db } from '../db/database'; + +export function listTags(userId: number) { + return db.prepare('SELECT * FROM tags WHERE user_id = ? ORDER BY name ASC').all(userId); +} + +export function createTag(userId: number, name: string, color?: string) { + const result = db.prepare( + 'INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)' + ).run(userId, name, color || '#10b981'); + return db.prepare('SELECT * FROM tags WHERE id = ?').get(result.lastInsertRowid); +} + +export function getTagByIdAndUser(tagId: number | string, userId: number) { + return db.prepare('SELECT * FROM tags WHERE id = ? AND user_id = ?').get(tagId, userId); +} + +export function updateTag(tagId: number | string, name?: string, color?: string) { + db.prepare('UPDATE tags SET name = COALESCE(?, name), color = COALESCE(?, color) WHERE id = ?') + .run(name || null, color || null, tagId); + return db.prepare('SELECT * FROM tags WHERE id = ?').get(tagId); +} + +export function deleteTag(tagId: number | string) { + db.prepare('DELETE FROM tags WHERE id = ?').run(tagId); +} diff --git a/server/src/services/tripService.ts b/server/src/services/tripService.ts new file mode 100644 index 0000000..ca7c570 --- /dev/null +++ b/server/src/services/tripService.ts @@ -0,0 +1,415 @@ +import path from 'path'; +import fs from 'fs'; +import { db, canAccessTrip, isOwner } from '../db/database'; +import { Trip, User } from '../types'; + +export const MS_PER_DAY = 86400000; +export const MAX_TRIP_DAYS = 365; + +const TRIP_SELECT = ` + SELECT t.*, + (SELECT COUNT(*) FROM days d WHERE d.trip_id = t.id) as day_count, + (SELECT COUNT(*) FROM places p WHERE p.trip_id = t.id) as place_count, + CASE WHEN t.user_id = :userId THEN 1 ELSE 0 END as is_owner, + u.username as owner_username, + (SELECT COUNT(*) FROM trip_members tm WHERE tm.trip_id = t.id) as shared_count + FROM trips t + JOIN users u ON u.id = t.user_id +`; + +// ── Access helpers ──────────────────────────────────────────────────────── + +export function verifyTripAccess(tripId: string | number, userId: number) { + return canAccessTrip(tripId, userId); +} + +export { isOwner }; + +// ── Day generation ──────────────────────────────────────────────────────── + +export 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) { + const datelessExisting = existing.filter(d => !d.date).sort((a, b) => a.day_number - b.day_number); + 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); + } + 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) { + const toRemove = datelessExisting.slice(7); + const del = db.prepare('DELETE FROM days WHERE id = ?'); + for (const d of toRemove) del.run(d.id); + } + 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; + } + + 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) / MS_PER_DAY) + 1, MAX_TRIP_DAYS); + + const targetDates: string[] = []; + for (let i = 0; i < numDays; i++) { + 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}`); + } + + const existingByDate = new Map(); + for (const d of existing) { + if (d.date) existingByDate.set(d.date, d); + } + + const targetDateSet = new Set(targetDates); + + const toDelete = existing.filter(d => d.date && !targetDateSet.has(d.date)); + 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); + + 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); + + const insert = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)'); + const update = db.prepare('UPDATE days SET day_number = ? WHERE id = ?'); + + for (let i = 0; i < targetDates.length; i++) { + const date = targetDates[i]; + const ex = existingByDate.get(date); + if (ex) { + update.run(i + 1, ex.id); + } else { + insert.run(tripId, i + 1, date); + } + } +} + +// ── Trip CRUD ───────────────────────────────────────────────────────────── + +export function listTrips(userId: number, archived: number) { + return db.prepare(` + ${TRIP_SELECT} + LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId + WHERE (t.user_id = :userId OR m.user_id IS NOT NULL) AND t.is_archived = :archived + ORDER BY t.created_at DESC + `).all({ userId, archived }); +} + +interface CreateTripData { + title: string; + description?: string | null; + start_date?: string | null; + end_date?: string | null; + currency?: string; + reminder_days?: number; +} + +export function createTrip(userId: number, data: CreateTripData) { + const rd = data.reminder_days !== undefined + ? (Number(data.reminder_days) >= 0 && Number(data.reminder_days) <= 30 ? Number(data.reminder_days) : 3) + : 3; + + const result = db.prepare(` + INSERT INTO trips (user_id, title, description, start_date, end_date, currency, reminder_days) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run(userId, data.title, data.description || null, data.start_date || null, data.end_date || null, data.currency || 'EUR', rd); + + const tripId = result.lastInsertRowid; + generateDays(tripId, data.start_date || null, data.end_date || null); + + const trip = db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId, tripId }); + return { trip, tripId: Number(tripId), reminderDays: rd }; +} + +export function getTrip(tripId: string | number, userId: number) { + return db.prepare(` + ${TRIP_SELECT} + LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId + WHERE t.id = :tripId AND (t.user_id = :userId OR m.user_id IS NOT NULL) + `).get({ userId, tripId }); +} + +interface UpdateTripData { + title?: string; + description?: string; + start_date?: string; + end_date?: string; + currency?: string; + is_archived?: boolean | number; + cover_image?: string; + reminder_days?: number; +} + +export interface UpdateTripResult { + updatedTrip: any; + changes: Record; + isAdminEdit: boolean; + ownerEmail?: string; + newTitle: string; + newReminder: number; + oldReminder: number; +} + +export function updateTrip(tripId: string | number, userId: number, data: UpdateTripData, userRole: string): UpdateTripResult { + const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as Trip & { reminder_days?: number } | undefined; + if (!trip) throw new NotFoundError('Trip not found'); + + const { title, description, start_date, end_date, currency, is_archived, cover_image, reminder_days } = data; + + if (start_date && end_date && new Date(end_date) < new Date(start_date)) + throw new ValidationError('End date must be after start date'); + + const newTitle = title || trip.title; + const newDesc = description !== undefined ? description : trip.description; + const newStart = start_date !== undefined ? start_date : trip.start_date; + const newEnd = end_date !== undefined ? end_date : trip.end_date; + const newCurrency = currency || trip.currency; + const newArchived = is_archived !== undefined ? (is_archived ? 1 : 0) : trip.is_archived; + const newCover = cover_image !== undefined ? cover_image : trip.cover_image; + const oldReminder = (trip as any).reminder_days ?? 3; + const newReminder = reminder_days !== undefined + ? (Number(reminder_days) >= 0 && Number(reminder_days) <= 30 ? Number(reminder_days) : oldReminder) + : oldReminder; + + db.prepare(` + UPDATE trips SET title=?, description=?, start_date=?, end_date=?, + currency=?, is_archived=?, cover_image=?, reminder_days=?, updated_at=CURRENT_TIMESTAMP + WHERE id=? + `).run(newTitle, newDesc, newStart || null, newEnd || null, newCurrency, newArchived, newCover, newReminder, tripId); + + if (newStart !== trip.start_date || newEnd !== trip.end_date) + generateDays(tripId, newStart || null, newEnd || null); + + const changes: Record = {}; + if (title && title !== trip.title) changes.title = title; + if (newStart !== trip.start_date) changes.start_date = newStart; + if (newEnd !== trip.end_date) changes.end_date = newEnd; + if (newReminder !== oldReminder) changes.reminder_days = newReminder === 0 ? 'none' : `${newReminder} days`; + if (is_archived !== undefined && newArchived !== trip.is_archived) changes.archived = !!newArchived; + + const isAdminEdit = userRole === 'admin' && trip.user_id !== userId; + let ownerEmail: string | undefined; + if (Object.keys(changes).length > 0 && isAdminEdit) { + ownerEmail = (db.prepare('SELECT email FROM users WHERE id = ?').get(trip.user_id) as { email: string } | undefined)?.email; + } + + const updatedTrip = db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId, tripId }); + + return { updatedTrip, changes, isAdminEdit, ownerEmail, newTitle, newReminder, oldReminder }; +} + +// ── Delete ───────────────────────────────────────────────────────────────── + +export interface DeleteTripInfo { + tripId: number; + title: string; + ownerId: number; + isAdminDelete: boolean; + ownerEmail?: string; +} + +export function deleteTrip(tripId: string | number, userId: number, userRole: string): DeleteTripInfo { + const trip = db.prepare('SELECT title, user_id FROM trips WHERE id = ?').get(tripId) as { title: string; user_id: number } | undefined; + if (!trip) throw new NotFoundError('Trip not found'); + + const isAdminDelete = userRole === 'admin' && trip.user_id !== userId; + let ownerEmail: string | undefined; + if (isAdminDelete) { + ownerEmail = (db.prepare('SELECT email FROM users WHERE id = ?').get(trip.user_id) as { email: string } | undefined)?.email; + } + + db.prepare('DELETE FROM trips WHERE id = ?').run(tripId); + + return { tripId: Number(tripId), title: trip.title, ownerId: trip.user_id, isAdminDelete, ownerEmail }; +} + +// ── Cover image ─────────────────────────────────────────────────────────── + +export function deleteOldCover(coverImage: string | null | undefined) { + if (!coverImage) return; + const oldPath = path.join(__dirname, '../../', coverImage.replace(/^\//, '')); + const resolvedPath = path.resolve(oldPath); + const uploadsDir = path.resolve(__dirname, '../../uploads'); + if (resolvedPath.startsWith(uploadsDir) && fs.existsSync(resolvedPath)) { + fs.unlinkSync(resolvedPath); + } +} + +export function updateCoverImage(tripId: string | number, coverUrl: string) { + db.prepare('UPDATE trips SET cover_image=?, updated_at=CURRENT_TIMESTAMP WHERE id=?').run(coverUrl, tripId); +} + +export function getTripRaw(tripId: string | number): Trip | undefined { + return db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as Trip | undefined; +} + +export function getTripOwner(tripId: string | number): { user_id: number } | undefined { + return db.prepare('SELECT user_id FROM trips WHERE id = ?').get(tripId) as { user_id: number } | undefined; +} + +// ── Members ─────────────────────────────────────────────────────────────── + +export function listMembers(tripId: string | number, tripOwnerId: 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, + m.added_at, + ib.username as invited_by_username + FROM trip_members m + JOIN users u ON u.id = m.user_id + LEFT JOIN users ib ON ib.id = m.invited_by + WHERE m.trip_id = ? + ORDER BY m.added_at ASC + `).all(tripOwnerId, tripId) 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(tripOwnerId) as Pick; + + return { + 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 })), + }; +} + +export interface AddMemberResult { + member: { id: number; username: string; email: string; avatar?: string | null; role: string; avatar_url: string | null }; + targetUserId: number; + tripTitle: string; +} + +export function addMember(tripId: string | number, identifier: string, tripOwnerId: number, invitedByUserId: number): AddMemberResult { + if (!identifier) throw new ValidationError('Email or username required'); + + const target = db.prepare( + 'SELECT id, username, email, avatar FROM users WHERE email = ? OR username = ?' + ).get(identifier.trim(), identifier.trim()) as Pick | undefined; + + if (!target) throw new NotFoundError('User not found'); + + if (target.id === tripOwnerId) + throw new ValidationError('Trip owner is already a member'); + + const existing = db.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(tripId, target.id); + if (existing) throw new ValidationError('User already has access'); + + db.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(tripId, target.id, invitedByUserId); + + const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined; + + return { + member: { ...target, role: 'member', avatar_url: target.avatar ? `/uploads/avatars/${target.avatar}` : null }, + targetUserId: target.id, + tripTitle: tripInfo?.title || 'Untitled', + }; +} + +export function removeMember(tripId: string | number, targetUserId: number) { + db.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(tripId, targetUserId); +} + +// ── ICS export ──────────────────────────────────────────────────────────── + +export function exportICS(tripId: string | number): { ics: string; filename: string } { + const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as any; + if (!trip) throw new NotFoundError('Trip not found'); + + const reservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ?').all(tripId) as any[]; + + const esc = (s: string) => s + .replace(/\\/g, '\\\\') + .replace(/;/g, '\\;') + .replace(/,/g, '\\,') + .replace(/\r?\n/g, '\\n') + .replace(/\r/g, ''); + const fmtDate = (d: string) => d.replace(/-/g, ''); + const now = new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'; + const uid = (id: number, type: string) => `trek-${type}-${id}@trek`; + + // Format datetime: handles full ISO "2026-03-30T09:00" and time-only "10:00" + const fmtDateTime = (d: string, refDate?: string) => { + if (d.includes('T')) return d.replace(/[-:]/g, '').split('.')[0]; + // Time-only: combine with reference date + if (refDate && d.match(/^\d{2}:\d{2}/)) { + const datePart = refDate.split('T')[0]; + return `${datePart}T${d.replace(/:/g, '')}00`.replace(/-/g, ''); + } + return d.replace(/[-:]/g, ''); + }; + + let ics = 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//TREK//Travel Planner//EN\r\nCALSCALE:GREGORIAN\r\nMETHOD:PUBLISH\r\n'; + ics += `X-WR-CALNAME:${esc(trip.title || 'TREK Trip')}\r\n`; + + // Trip as all-day event + if (trip.start_date && trip.end_date) { + const endNext = new Date(trip.end_date + 'T00:00:00'); + endNext.setDate(endNext.getDate() + 1); + const endStr = endNext.toISOString().split('T')[0].replace(/-/g, ''); + ics += `BEGIN:VEVENT\r\nUID:${uid(trip.id, 'trip')}\r\nDTSTAMP:${now}\r\nDTSTART;VALUE=DATE:${fmtDate(trip.start_date)}\r\nDTEND;VALUE=DATE:${endStr}\r\nSUMMARY:${esc(trip.title || 'Trip')}\r\n`; + if (trip.description) ics += `DESCRIPTION:${esc(trip.description)}\r\n`; + ics += `END:VEVENT\r\n`; + } + + // Reservations as events + for (const r of reservations) { + if (!r.reservation_time) continue; + const hasTime = r.reservation_time.includes('T'); + const meta = r.metadata ? (typeof r.metadata === 'string' ? JSON.parse(r.metadata) : r.metadata) : {}; + + ics += `BEGIN:VEVENT\r\nUID:${uid(r.id, 'res')}\r\nDTSTAMP:${now}\r\n`; + if (hasTime) { + ics += `DTSTART:${fmtDateTime(r.reservation_time)}\r\n`; + if (r.reservation_end_time) { + const endDt = fmtDateTime(r.reservation_end_time, r.reservation_time); + if (endDt.length >= 15) ics += `DTEND:${endDt}\r\n`; + } + } else { + ics += `DTSTART;VALUE=DATE:${fmtDate(r.reservation_time)}\r\n`; + } + ics += `SUMMARY:${esc(r.title)}\r\n`; + + let desc = r.type ? `Type: ${r.type}` : ''; + if (r.confirmation_number) desc += `\nConfirmation: ${r.confirmation_number}`; + if (meta.airline) desc += `\nAirline: ${meta.airline}`; + if (meta.flight_number) desc += `\nFlight: ${meta.flight_number}`; + if (meta.departure_airport) desc += `\nFrom: ${meta.departure_airport}`; + if (meta.arrival_airport) desc += `\nTo: ${meta.arrival_airport}`; + if (meta.train_number) desc += `\nTrain: ${meta.train_number}`; + if (r.notes) desc += `\n${r.notes}`; + if (desc) ics += `DESCRIPTION:${esc(desc)}\r\n`; + if (r.location) ics += `LOCATION:${esc(r.location)}\r\n`; + ics += `END:VEVENT\r\n`; + } + + ics += 'END:VCALENDAR\r\n'; + + const safeFilename = (trip.title || 'trek-trip').replace(/["\r\n]/g, '').replace(/[^\w\s.-]/g, '_'); + return { ics, filename: `${safeFilename}.ics` }; +} + +// ── Custom error types ──────────────────────────────────────────────────── + +export class NotFoundError extends Error { + constructor(message: string) { + super(message); + this.name = 'NotFoundError'; + } +} + +export class ValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'ValidationError'; + } +} diff --git a/server/src/services/vacayService.ts b/server/src/services/vacayService.ts new file mode 100644 index 0000000..ba5aa43 --- /dev/null +++ b/server/src/services/vacayService.ts @@ -0,0 +1,665 @@ +import { db } from '../db/database'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export 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; +} + +export interface VacayUserYear { + user_id: number; + plan_id: number; + year: number; + vacation_days: number; + carried_over: number; +} + +export interface VacayUser { + id: number; + username: string; + email: string; +} + +export interface VacayPlanMember { + id: number; + plan_id: number; + user_id: number; + status: string; + created_at?: string; +} + +export interface Holiday { + date: string; + localName?: string; + name?: string; + global?: boolean; + counties?: string[] | null; +} + +export interface VacayHolidayCalendar { + id: number; + plan_id: number; + region: string; + label: string | null; + color: string; + sort_order: number; +} + +// --------------------------------------------------------------------------- +// Holiday cache (shared in-process) +// --------------------------------------------------------------------------- + +const holidayCache = new Map(); +const CACHE_TTL = 24 * 60 * 60 * 1000; + +// --------------------------------------------------------------------------- +// Color palette for auto-assign +// --------------------------------------------------------------------------- + +const COLORS = [ + '#6366f1', '#ec4899', '#14b8a6', '#8b5cf6', '#ef4444', + '#3b82f6', '#22c55e', '#06b6d4', '#f43f5e', '#a855f7', + '#10b981', '#0ea5e9', '#64748b', '#be185d', '#0d9488', +]; + +// --------------------------------------------------------------------------- +// Plan management +// --------------------------------------------------------------------------- + +export function getOwnPlan(userId: number): VacayPlan { + 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) as VacayPlan; + const yr = new Date().getFullYear(); + db.prepare('INSERT OR IGNORE INTO vacay_years (plan_id, year) VALUES (?, ?)').run(plan.id, yr); + 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); + db.prepare('INSERT OR IGNORE INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)').run(userId, plan.id, '#6366f1'); + } + return plan; +} + +export function getActivePlan(userId: number): VacayPlan { + const membership = db.prepare(` + SELECT plan_id FROM vacay_plan_members WHERE user_id = ? AND status = 'accepted' + `).get(userId) as { plan_id: number } | undefined; + if (membership) { + return db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(membership.plan_id) as VacayPlan; + } + return getOwnPlan(userId); +} + +export function getActivePlanId(userId: number): number { + return getActivePlan(userId).id; +} + +export function getPlanUsers(planId: number): VacayUser[] { + 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) 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) as VacayUser[]; + return [owner, ...members]; +} + +// --------------------------------------------------------------------------- +// WebSocket notifications +// --------------------------------------------------------------------------- + +export function notifyPlanUsers(planId: number, excludeSid: string | undefined, event = 'vacay:update'): void { + try { + const { broadcastToUser } = require('../websocket'); + 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) as { user_id: number }[]; + members.forEach(m => userIds.push(m.user_id)); + userIds.forEach(id => broadcastToUser(id, { type: event }, excludeSid)); + } catch { /* websocket not available */ } +} + +// --------------------------------------------------------------------------- +// Holiday calendar helpers +// --------------------------------------------------------------------------- + +export async function applyHolidayCalendars(planId: number): Promise { + const plan = db.prepare('SELECT holidays_enabled FROM vacay_plans WHERE id = ?').get(planId) as { holidays_enabled: number } | undefined; + if (!plan?.holidays_enabled) return; + const calendars = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? ORDER BY sort_order, id').all(planId) as VacayHolidayCalendar[]; + if (calendars.length === 0) return; + const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ?').all(planId) as { year: number }[]; + for (const cal of calendars) { + const country = cal.region.split('-')[0]; + const region = cal.region.includes('-') ? cal.region : null; + for (const { year } of years) { + try { + const cacheKey = `${year}-${country}`; + 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() as Holiday[]; + holidayCache.set(cacheKey, { data: holidays, time: Date.now() }); + } + const hasRegions = holidays.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))) { + db.prepare('DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?').run(planId, h.date); + db.prepare('DELETE FROM vacay_company_holidays WHERE plan_id = ? AND date = ?').run(planId, h.date); + } + } + } catch { /* API error, skip */ } + } + } +} + +export async function migrateHolidayCalendars(planId: number, plan: VacayPlan): Promise { + const existing = db.prepare('SELECT id FROM vacay_holiday_calendars WHERE plan_id = ?').get(planId); + if (existing) return; + if (plan.holidays_enabled && plan.holidays_region) { + db.prepare( + 'INSERT INTO vacay_holiday_calendars (plan_id, region, label, color, sort_order) VALUES (?, ?, NULL, ?, 0)' + ).run(planId, plan.holidays_region, '#fecaca'); + } +} + +// --------------------------------------------------------------------------- +// Plan settings +// --------------------------------------------------------------------------- + +export interface UpdatePlanBody { + block_weekends?: boolean; + holidays_enabled?: boolean; + holidays_region?: string; + company_holidays_enabled?: boolean; + carry_over_enabled?: boolean; + weekend_days?: string; +} + +export async function updatePlan(planId: number, body: UpdatePlanBody, socketId: string | undefined) { + const { block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled, weekend_days } = body; + + 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 (weekend_days !== undefined) { updates.push('weekend_days = ?'); params.push(String(weekend_days)); } + + if (updates.length > 0) { + params.push(planId); + db.prepare(`UPDATE vacay_plans SET ${updates.join(', ')} WHERE id = ?`).run(...params); + } + + if (company_holidays_enabled === true) { + 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); + } + } + + const updatedPlan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan; + await migrateHolidayCalendars(planId, updatedPlan); + await applyHolidayCalendars(planId); + + if (carry_over_enabled === false) { + db.prepare('UPDATE vacay_user_years SET carried_over = 0 WHERE plan_id = ?').run(planId); + } + + if (carry_over_enabled === true) { + 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}-%`) 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(` + INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, ?) + ON CONFLICT(user_id, plan_id, year) DO UPDATE SET carried_over = ? + `).run(u.id, planId, nextYr, carry, carry); + } + } + } + + notifyPlanUsers(planId, socketId, 'vacay:settings'); + + const updated = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan; + const updatedCalendars = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? ORDER BY sort_order, id').all(planId) as VacayHolidayCalendar[]; + return { + 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, + holiday_calendars: updatedCalendars, + }, + }; +} + +// --------------------------------------------------------------------------- +// Holiday calendars CRUD +// --------------------------------------------------------------------------- + +export function addHolidayCalendar(planId: number, region: string, label: string | null, color: string | undefined, sortOrder: number | undefined, socketId: string | undefined) { + const result = db.prepare( + 'INSERT INTO vacay_holiday_calendars (plan_id, region, label, color, sort_order) VALUES (?, ?, ?, ?, ?)' + ).run(planId, region, label || null, color || '#fecaca', sortOrder ?? 0); + const cal = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ?').get(result.lastInsertRowid) as VacayHolidayCalendar; + notifyPlanUsers(planId, socketId, 'vacay:settings'); + return cal; +} + +export function updateHolidayCalendar( + calId: number, + planId: number, + body: { region?: string; label?: string | null; color?: string; sort_order?: number }, + socketId: string | undefined, +): VacayHolidayCalendar | null { + const cal = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ? AND plan_id = ?').get(calId, planId) as VacayHolidayCalendar | undefined; + if (!cal) return null; + const { region, label, color, sort_order } = body; + const updates: string[] = []; + const params: (string | number | null)[] = []; + if (region !== undefined) { updates.push('region = ?'); params.push(region); } + if (label !== undefined) { updates.push('label = ?'); params.push(label); } + if (color !== undefined) { updates.push('color = ?'); params.push(color); } + if (sort_order !== undefined) { updates.push('sort_order = ?'); params.push(sort_order); } + if (updates.length > 0) { + params.push(calId); + db.prepare(`UPDATE vacay_holiday_calendars SET ${updates.join(', ')} WHERE id = ?`).run(...params); + } + const updated = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ?').get(calId) as VacayHolidayCalendar; + notifyPlanUsers(planId, socketId, 'vacay:settings'); + return updated; +} + +export function deleteHolidayCalendar(calId: number, planId: number, socketId: string | undefined): boolean { + const cal = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ? AND plan_id = ?').get(calId, planId); + if (!cal) return false; + db.prepare('DELETE FROM vacay_holiday_calendars WHERE id = ?').run(calId); + notifyPlanUsers(planId, socketId, 'vacay:settings'); + return true; +} + +// --------------------------------------------------------------------------- +// User colors +// --------------------------------------------------------------------------- + +export function setUserColor(userId: number, planId: number, color: string | undefined, socketId: string | undefined): void { + db.prepare(` + 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, socketId, 'vacay:update'); +} + +// --------------------------------------------------------------------------- +// Invitations +// --------------------------------------------------------------------------- + +export function sendInvite(planId: number, inviterId: number, inviterUsername: string, inviterEmail: string, targetUserId: number): { error?: string; status?: number } { + if (targetUserId === inviterId) return { error: 'Cannot invite yourself', status: 400 }; + + const targetUser = db.prepare('SELECT id, username FROM users WHERE id = ?').get(targetUserId); + if (!targetUser) return { error: 'User not found', status: 404 }; + + const existing = db.prepare('SELECT id, status FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?').get(planId, targetUserId) as { id: number; status: string } | undefined; + if (existing) { + if (existing.status === 'accepted') return { error: 'Already fused', status: 400 }; + if (existing.status === 'pending') return { error: 'Invite already pending', status: 400 }; + } + + const targetFusion = db.prepare("SELECT id FROM vacay_plan_members WHERE user_id = ? AND status = 'accepted'").get(targetUserId); + if (targetFusion) return { error: 'User is already fused with another plan', status: 400 }; + + db.prepare('INSERT INTO vacay_plan_members (plan_id, user_id, status) VALUES (?, ?, ?)').run(planId, targetUserId, 'pending'); + + try { + const { broadcastToUser } = require('../websocket'); + broadcastToUser(targetUserId, { + type: 'vacay:invite', + from: { id: inviterId, username: inviterUsername }, + planId, + }); + } catch { /* websocket not available */ } + + // Notify invited user + import('../services/notifications').then(({ notify }) => { + notify({ userId: targetUserId, event: 'vacay_invite', params: { actor: inviterEmail } }).catch(() => {}); + }); + + return {}; +} + +export function acceptInvite(userId: number, planId: number, socketId: string | undefined): { error?: string; status?: number } { + const invite = db.prepare("SELECT * FROM vacay_plan_members WHERE plan_id = ? AND user_id = ? AND status = 'pending'").get(planId, userId) as VacayPlanMember | undefined; + if (!invite) return { error: 'No pending invite', status: 404 }; + + db.prepare("UPDATE vacay_plan_members SET status = 'accepted' WHERE id = ?").run(invite.id); + + // Migrate data from user's own plan + const ownPlan = db.prepare('SELECT id FROM vacay_plans WHERE owner_id = ?').get(userId) as { id: number } | undefined; + if (ownPlan && ownPlan.id !== planId) { + db.prepare('UPDATE vacay_entries SET plan_id = ? WHERE plan_id = ? AND user_id = ?').run(planId, ownPlan.id, userId); + const ownYears = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ?').all(userId, 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(userId, planId, y.year, y.vacation_days, y.carried_over); + } + const colorRow = db.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?').get(userId, ownPlan.id) as { color: string } | undefined; + if (colorRow) { + db.prepare('INSERT OR IGNORE INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)').run(userId, planId, colorRow.color); + } + } + + // Auto-assign unique color + const existingColors = (db.prepare('SELECT color FROM vacay_user_colors WHERE plan_id = ? AND user_id != ?').all(planId, userId) as { color: string }[]).map(r => r.color); + const myColor = db.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?').get(userId, planId) as { color: string } | undefined; + const effectiveColor = myColor?.color || '#6366f1'; + if (existingColors.includes(effectiveColor)) { + const available = COLORS.find(c => !existingColors.includes(c)); + if (available) { + db.prepare(`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, available); + } + } else if (!myColor) { + db.prepare('INSERT OR IGNORE INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)').run(userId, planId, effectiveColor); + } + + // Ensure user has rows for all plan years + const targetYears = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ?').all(planId) 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(userId, planId, y.year); + } + + notifyPlanUsers(planId, socketId, 'vacay:accepted'); + return {}; +} + +export function declineInvite(userId: number, planId: number, socketId: string | undefined): void { + db.prepare("DELETE FROM vacay_plan_members WHERE plan_id = ? AND user_id = ? AND status = 'pending'").run(planId, userId); + notifyPlanUsers(planId, socketId, 'vacay:declined'); +} + +export function cancelInvite(planId: number, targetUserId: number): void { + db.prepare("DELETE FROM vacay_plan_members WHERE plan_id = ? AND user_id = ? AND status = 'pending'").run(planId, targetUserId); + + try { + const { broadcastToUser } = require('../websocket'); + broadcastToUser(targetUserId, { type: 'vacay:cancelled' }); + } catch { /* */ } +} + +// --------------------------------------------------------------------------- +// Plan dissolution +// --------------------------------------------------------------------------- + +export function dissolvePlan(userId: number, socketId: string | undefined): void { + const plan = getActivePlan(userId); + const isOwnerFlag = plan.owner_id === userId; + + 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) as { date: string; note: string }[]; + + 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); + 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(userId); + db.prepare('UPDATE vacay_entries SET plan_id = ? WHERE plan_id = ? AND user_id = ?').run(ownPlan.id, plan.id, userId); + 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, userId); + } + + try { + const { broadcastToUser } = require('../websocket'); + allUserIds.filter(id => id !== userId).forEach(id => broadcastToUser(id, { type: 'vacay:dissolved' })); + } catch { /* */ } +} + +// --------------------------------------------------------------------------- +// Available users +// --------------------------------------------------------------------------- + +export function getAvailableUsers(userId: number, planId: number) { + return db.prepare(` + SELECT u.id, u.username, u.email FROM users u + WHERE u.id != ? + AND u.id NOT IN (SELECT user_id FROM vacay_plan_members WHERE plan_id = ?) + AND u.id NOT IN (SELECT user_id FROM vacay_plan_members WHERE status = 'accepted') + AND u.id NOT IN (SELECT owner_id FROM vacay_plans WHERE id IN ( + SELECT plan_id FROM vacay_plan_members WHERE status = 'accepted' + )) + ORDER BY u.username + `).all(userId, planId); +} + +// --------------------------------------------------------------------------- +// Years +// --------------------------------------------------------------------------- + +export function listYears(planId: number): number[] { + const rows = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ? ORDER BY year').all(planId) as { year: number }[]; + return rows.map(y => y.year); +} + +export function addYear(planId: number, year: number, socketId: string | undefined): number[] { + 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) as VacayPlan | undefined; + const carryOverEnabled = plan ? !!plan.carry_over_enabled : true; + const users = getPlanUsers(planId); + for (const u of users) { + 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) 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}-%`) as { count: number }).count; + const total = prevConfig.vacation_days + prevConfig.carried_over; + carriedOver = Math.max(0, total - used); + } + } + 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 { /* year already exists */ } + notifyPlanUsers(planId, socketId, 'vacay:settings'); + return listYears(planId); +} + +export function deleteYear(planId: number, year: number, socketId: string | undefined): number[] { + 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, socketId, 'vacay:settings'); + return listYears(planId); +} + +// --------------------------------------------------------------------------- +// Entries +// --------------------------------------------------------------------------- + +export function getEntries(planId: number, year: string) { + const entries = db.prepare(` + SELECT e.*, u.username as person_name, COALESCE(c.color, '#6366f1') as person_color + FROM vacay_entries e + JOIN users u ON e.user_id = u.id + LEFT JOIN vacay_user_colors c ON c.user_id = e.user_id AND c.plan_id = e.plan_id + WHERE e.plan_id = ? AND e.date LIKE ? + `).all(planId, `${year}-%`); + const companyHolidays = db.prepare("SELECT * FROM vacay_company_holidays WHERE plan_id = ? AND date LIKE ?").all(planId, `${year}-%`); + return { entries, companyHolidays }; +} + +export function toggleEntry(userId: number, planId: number, date: string, socketId: string | undefined): { action: string } { + 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, socketId); + return { action: 'removed' }; + } else { + db.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)').run(planId, userId, date, ''); + notifyPlanUsers(planId, socketId); + return { action: 'added' }; + } +} + +export function toggleCompanyHoliday(planId: number, date: string, note: string | undefined, socketId: string | undefined): { action: string } { + 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, socketId); + return { action: 'removed' }; + } else { + db.prepare('INSERT INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)').run(planId, date, note || ''); + db.prepare('DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?').run(planId, date); + notifyPlanUsers(planId, socketId); + return { action: 'added' }; + } +} + +// --------------------------------------------------------------------------- +// Stats +// --------------------------------------------------------------------------- + +export function getStats(planId: number, year: number) { + 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); + + return 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}-%`) 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) as { color: string } | undefined; + + 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); + db.prepare(` + INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, ?) + ON CONFLICT(user_id, plan_id, year) DO UPDATE SET carried_over = ? + `).run(u.id, planId, year + 1, carry, carry); + } + + return { + user_id: u.id, person_name: u.username, person_color: colorRow?.color || '#6366f1', + year, vacation_days: vacationDays, carried_over: carriedOver, + total_available: total, used, remaining, + }; + }); +} + +export function updateStats(userId: number, planId: number, year: number, vacationDays: number, socketId: string | undefined): void { + db.prepare(` + 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, vacationDays); + notifyPlanUsers(planId, socketId); +} + +// --------------------------------------------------------------------------- +// GET /plan composite +// --------------------------------------------------------------------------- + +export function getPlanData(userId: number) { + const plan = getActivePlan(userId); + const activePlanId = plan.id; + + 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) as { color: string } | undefined; + return { ...u, color: colorRow?.color || '#6366f1' }; + }); + + 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); + + 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(userId); + + const holidayCalendars = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? ORDER BY sort_order, id').all(activePlanId) as VacayHolidayCalendar[]; + + return { + plan: { + ...plan, + block_weekends: !!plan.block_weekends, + holidays_enabled: !!plan.holidays_enabled, + company_holidays_enabled: !!plan.company_holidays_enabled, + carry_over_enabled: !!plan.carry_over_enabled, + holiday_calendars: holidayCalendars, + }, + users, + pendingInvites, + incomingInvites, + isOwner: plan.owner_id === userId, + isFused: users.length > 1, + }; +} + +// --------------------------------------------------------------------------- +// Holidays (nager.at proxy with cache) +// --------------------------------------------------------------------------- + +export async function getCountries(): Promise<{ data?: unknown; error?: string }> { + const cacheKey = 'countries'; + const cached = holidayCache.get(cacheKey); + if (cached && Date.now() - cached.time < CACHE_TTL) return { data: cached.data }; + try { + const resp = await fetch('https://date.nager.at/api/v3/AvailableCountries'); + const data = await resp.json(); + holidayCache.set(cacheKey, { data, time: Date.now() }); + return { data }; + } catch { + return { error: 'Failed to fetch countries' }; + } +} + +export async function getHolidays(year: string, country: string): Promise<{ data?: unknown; error?: string }> { + const cacheKey = `${year}-${country}`; + const cached = holidayCache.get(cacheKey); + if (cached && Date.now() - cached.time < CACHE_TTL) return { data: cached.data }; + try { + const resp = await fetch(`https://date.nager.at/api/v3/PublicHolidays/${year}/${country}`); + const data = await resp.json(); + holidayCache.set(cacheKey, { data, time: Date.now() }); + return { data }; + } catch { + return { error: 'Failed to fetch holidays' }; + } +} diff --git a/server/src/services/weatherService.ts b/server/src/services/weatherService.ts new file mode 100644 index 0000000..328563e --- /dev/null +++ b/server/src/services/weatherService.ts @@ -0,0 +1,440 @@ +import fetch from 'node-fetch'; + +// ── Interfaces ────────────────────────────────────────────────────────── + +export 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; +} + +export interface HourlyEntry { + hour: number; + temp: number; + precipitation: number; + precipitation_probability: number; + main: string; + wind: number; + humidity: number; +} + +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[]; + }; +} + +// ── WMO code mappings ─────────────────────────────────────────────────── + +const WMO_MAP: Record = { + 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: Record = { + 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: Record = { + 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', +}; + +// ── Cache management ──────────────────────────────────────────────────── + +const weatherCache = new Map(); +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: string): WeatherResult | null { + const entry = weatherCache.get(key); + if (!entry) return null; + if (Date.now() > entry.expiresAt) { + weatherCache.delete(key); + return null; + } + return entry.data; +} + +function setCache(key: string, data: WeatherResult, ttlMs: number): void { + weatherCache.set(key, { data, expiresAt: Date.now() + ttlMs }); +} + +// ── Helpers ───────────────────────────────────────────────────────────── + +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'; +} + +// ── getWeather ────────────────────────────────────────────────────────── + +export async function getWeather( + lat: string, + lng: string, + date: string | undefined, + lang: string, +): Promise { + const ck = cacheKey(lat, lng, date); + + if (date) { + const cached = getCached(ck); + if (cached) return cached; + + const targetDate = new Date(date); + const now = new Date(); + const diffDays = (targetDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24); + + // Forecast range (-1 .. +16 days) + 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() as OpenMeteoForecast; + + if (!response.ok || data.error) { + throw new ApiError(response.status || 500, data.reason || 'Open-Meteo API error'); + } + + const dateStr = targetDate.toISOString().slice(0, 10); + const idx = (data.daily?.time || []).indexOf(dateStr); + + if (idx !== -1) { + const code = data.daily!.weathercode[idx]; + const descriptions = lang === 'de' ? WMO_DESCRIPTION_DE : WMO_DESCRIPTION_EN; + + const result: WeatherResult = { + temp: Math.round((data.daily!.temperature_2m_max[idx] + data.daily!.temperature_2m_min[idx]) / 2), + temp_max: Math.round(data.daily!.temperature_2m_max[idx]), + temp_min: Math.round(data.daily!.temperature_2m_min[idx]), + main: WMO_MAP[code] || 'Clouds', + description: descriptions[code] || '', + type: 'forecast', + }; + + setCache(ck, result, TTL_FORECAST_MS); + return result; + } + } + + // Climate / archive fallback (far-future dates) + if (diffDays > -1) { + const month = targetDate.getMonth() + 1; + const day = targetDate.getDate(); + const refYear = targetDate.getFullYear() - 1; + const startDate = new Date(refYear, month - 1, day - 2); + const endDate = new Date(refYear, month - 1, day + 2); + const startStr = startDate.toISOString().slice(0, 10); + const endStr = endDate.toISOString().slice(0, 10); + + 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() as OpenMeteoForecast; + + if (!response.ok || data.error) { + throw new ApiError(response.status || 500, data.reason || 'Open-Meteo Climate API error'); + } + + const daily = data.daily; + if (!daily || !daily.time || daily.time.length === 0) { + return { temp: 0, main: '', description: '', type: '', error: 'no_forecast' }; + } + + 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) { + sumMax += daily.temperature_2m_max[i]; + sumMin += daily.temperature_2m_min[i]; + sumPrecip += daily.precipitation_sum![i] || 0; + count++; + } + } + + if (count === 0) { + return { temp: 0, main: '', description: '', type: '', error: 'no_forecast' }; + } + + const avgMax = sumMax / count; + const avgMin = sumMin / count; + const avgTemp = (avgMax + avgMin) / 2; + const avgPrecip = sumPrecip / count; + const main = estimateCondition(avgTemp, avgPrecip); + + const result: WeatherResult = { + temp: Math.round(avgTemp), + temp_max: Math.round(avgMax), + temp_min: Math.round(avgMin), + main, + description: '', + type: 'climate', + }; + + setCache(ck, result, TTL_CLIMATE_MS); + return result; + } + + return { temp: 0, main: '', description: '', type: '', error: 'no_forecast' }; + } + + // No date supplied -> current weather + const cached = getCached(ck); + if (cached) return 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() as OpenMeteoForecast; + + if (!response.ok || data.error) { + throw new ApiError(response.status || 500, data.reason || 'Open-Meteo API error'); + } + + const code = data.current!.weathercode; + const descriptions = lang === 'de' ? WMO_DESCRIPTION_DE : WMO_DESCRIPTION_EN; + + const result: WeatherResult = { + temp: Math.round(data.current!.temperature_2m), + main: WMO_MAP[code] || 'Clouds', + description: descriptions[code] || '', + type: 'current', + }; + + setCache(ck, result, TTL_CURRENT_MS); + return result; +} + +// ── getDetailedWeather ────────────────────────────────────────────────── + +export async function getDetailedWeather( + lat: string, + lng: string, + date: string, + lang: string, +): Promise { + const ck = `detailed_${cacheKey(lat, lng, date)}`; + + const cached = getCached(ck); + if (cached) return cached; + + const targetDate = new Date(date); + const now = new Date(); + 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; + + // Climate / archive path (> 16 days out) + if (diffDays > 16) { + const refYear = targetDate.getFullYear() - 1; + const refDateStr = `${refYear}-${String(targetDate.getMonth() + 1).padStart(2, '0')}-${String(targetDate.getDate()).padStart(2, '0')}`; + + const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${lat}&longitude=${lng}` + + `&start_date=${refDateStr}&end_date=${refDateStr}` + + `&hourly=temperature_2m,precipitation,weathercode,windspeed_10m,relativehumidity_2m` + + `&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() as OpenMeteoForecast; + + if (!response.ok || data.error) { + throw new ApiError(response.status || 500, data.reason || 'Open-Meteo Climate API error'); + } + + const daily = data.daily; + const hourly = data.hourly; + if (!daily || !daily.time || daily.time.length === 0) { + return { temp: 0, main: '', description: '', type: '', error: 'no_forecast' }; + } + + const idx = 0; + const code = daily.weathercode?.[idx]; + const avgMax = daily.temperature_2m_max[idx]; + const avgMin = daily.temperature_2m_min[idx]; + + const hourlyData: HourlyEntry[] = []; + if (hourly?.time) { + for (let i = 0; i < hourly.time.length; i++) { + const hour = new Date(hourly.time[i]).getHours(); + const hCode = hourly.weathercode?.[i]; + hourlyData.push({ + hour, + temp: Math.round(hourly.temperature_2m[i]), + precipitation: hourly.precipitation?.[i] || 0, + precipitation_probability: 0, + main: WMO_MAP[hCode!] || 'Clouds', + wind: Math.round(hourly.windspeed_10m?.[i] || 0), + humidity: hourly.relativehumidity_2m?.[i] || 0, + }); + } + } + + 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); + + const result: WeatherResult = { + type: 'climate', + temp: Math.round((avgMax + avgMin) / 2), + temp_max: Math.round(avgMax), + temp_min: Math.round(avgMin), + main: WMO_MAP[code!] || estimateCondition((avgMax + avgMin) / 2, daily.precipitation_sum?.[idx] || 0), + description: descriptions[code!] || '', + precipitation_sum: Math.round((daily.precipitation_sum?.[idx] || 0) * 10) / 10, + wind_max: Math.round(daily.windspeed_10m_max?.[idx] || 0), + sunrise, + sunset, + hourly: hourlyData, + }; + + setCache(ck, result, TTL_CLIMATE_MS); + return result; + } + + // Forecast path (<= 16 days) + 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() as OpenMeteoForecast; + + if (!response.ok || data.error) { + throw new ApiError(response.status || 500, data.reason || 'Open-Meteo API error'); + } + + const daily = data.daily; + const hourly = data.hourly; + + if (!daily || !daily.time || daily.time.length === 0) { + return { temp: 0, main: '', description: '', type: '', error: 'no_forecast' }; + } + + const dayIdx = 0; + const code = daily.weathercode[dayIdx]; + + 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')}`; + }; + + const hourlyData: HourlyEntry[] = []; + if (hourly && hourly.time) { + for (let i = 0; i < hourly.time.length; i++) { + const h = new Date(hourly.time[i]).getHours(); + hourlyData.push({ + hour: h, + temp: Math.round(hourly.temperature_2m[i]), + precipitation_probability: hourly.precipitation_probability![i] || 0, + precipitation: hourly.precipitation![i] || 0, + main: WMO_MAP[hourly.weathercode![i]] || 'Clouds', + wind: Math.round(hourly.windspeed_10m![i] || 0), + humidity: Math.round(hourly.relativehumidity_2m![i] || 0), + }); + } + } + + const result: WeatherResult = { + type: 'forecast', + temp: Math.round((daily.temperature_2m_max[dayIdx] + daily.temperature_2m_min[dayIdx]) / 2), + temp_max: Math.round(daily.temperature_2m_max[dayIdx]), + temp_min: Math.round(daily.temperature_2m_min[dayIdx]), + main: WMO_MAP[code] || 'Clouds', + description: descriptions[code] || '', + sunrise: formatTime(daily.sunrise![dayIdx]), + sunset: formatTime(daily.sunset![dayIdx]), + precipitation_sum: daily.precipitation_sum![dayIdx] || 0, + precipitation_probability_max: daily.precipitation_probability_max![dayIdx] || 0, + wind_max: Math.round(daily.windspeed_10m_max![dayIdx] || 0), + hourly: hourlyData, + }; + + setCache(ck, result, TTL_FORECAST_MS); + return result; +} + +// ── ApiError ──────────────────────────────────────────────────────────── + +export class ApiError extends Error { + status: number; + constructor(status: number, message: string) { + super(message); + this.status = status; + } +}