refactor: extract business logic from routes into reusable service modules

This commit is contained in:
Maurice
2026-04-02 17:14:53 +02:00
parent f0131632a7
commit 979322025d
48 changed files with 8851 additions and 6182 deletions

View File

@@ -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<User, 'id' | 'username' | 'email' | 'role' | 'created_at' | 'updated_at' | 'last_login'>[];
let onlineUserIds = new Set<number>();
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<string, unknown> | null = null;
if (r.details) {
try {
details = JSON.parse(r.details) as Record<string, unknown>;
} 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',

View File

@@ -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);
});

View File

@@ -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<string, string | null>();
function roundKey(lat: number, lng: number): string {
return `${lat.toFixed(3)},${lng.toFixed(3)}`;
}
async function reverseGeocodeCountry(lat: number, lng: number): Promise<string | null> {
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<string, [number, number, number, number]> = {
AF:[60.5,29.4,75,38.5],AL:[19,39.6,21.1,42.7],DZ:[-8.7,19,12,37.1],AD:[1.4,42.4,1.8,42.7],AO:[11.7,-18.1,24.1,-4.4],
AR:[-73.6,-55.1,-53.6,-21.8],AM:[43.4,38.8,46.6,41.3],AU:[112.9,-43.6,153.6,-10.7],AT:[9.5,46.4,17.2,49],AZ:[44.8,38.4,50.4,41.9],
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<string, string> = {
'germany':'DE','deutschland':'DE','france':'FR','frankreich':'FR','spain':'ES','spanien':'ES',
'italy':'IT','italien':'IT','united kingdom':'GB','uk':'GB','england':'GB','united states':'US',
'usa':'US','netherlands':'NL','niederlande':'NL','austria':'AT','osterreich':'AT','switzerland':'CH',
'schweiz':'CH','portugal':'PT','greece':'GR','griechenland':'GR','turkey':'TR','turkei':'TR',
'croatia':'HR','kroatien':'HR','czech republic':'CZ','tschechien':'CZ','czechia':'CZ',
'poland':'PL','polen':'PL','sweden':'SE','schweden':'SE','norway':'NO','norwegen':'NO',
'denmark':'DK','danemark':'DK','finland':'FI','finnland':'FI','belgium':'BE','belgien':'BE',
'ireland':'IE','irland':'IE','hungary':'HU','ungarn':'HU','romania':'RO','rumanien':'RO',
'bulgaria':'BG','bulgarien':'BG','japan':'JP','china':'CN','australia':'AU','australien':'AU',
'canada':'CA','kanada':'CA','mexico':'MX','mexiko':'MX','brazil':'BR','brasilien':'BR',
'argentina':'AR','argentinien':'AR','thailand':'TH','indonesia':'ID','indonesien':'ID',
'india':'IN','indien':'IN','egypt':'EG','agypten':'EG','morocco':'MA','marokko':'MA',
'south africa':'ZA','sudafrika':'ZA','new zealand':'NZ','neuseeland':'NZ','iceland':'IS','island':'IS',
'luxembourg':'LU','luxemburg':'LU','slovenia':'SI','slowenien':'SI','slovakia':'SK','slowakei':'SK',
'estonia':'EE','estland':'EE','latvia':'LV','lettland':'LV','lithuania':'LT','litauen':'LT',
'serbia':'RS','serbien':'RS','israel':'IL','russia':'RU','russland':'RU','ukraine':'UA',
'vietnam':'VN','south korea':'KR','sudkorea':'KR','philippines':'PH','philippinen':'PH',
'malaysia':'MY','colombia':'CO','kolumbien':'CO','peru':'PE','chile':'CL','iran':'IR',
'iraq':'IQ','irak':'IQ','pakistan':'PK','kenya':'KE','kenia':'KE','nigeria':'NG',
'saudi arabia':'SA','saudi-arabien':'SA','albania':'AL','albanien':'AL',
};
function getCountryFromAddress(address: string | null): string | null {
if (!address) return null;
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<string, string> = {
AF:'Africa',AL:'Europe',DZ:'Africa',AD:'Europe',AO:'Africa',AR:'South America',AM:'Asia',AU:'Oceania',AT:'Europe',AZ:'Asia',
BR:'South America',BE:'Europe',BG:'Europe',CA:'North America',CL:'South America',CN:'Asia',CO:'South America',HR:'Europe',CZ:'Europe',DK:'Europe',
EG:'Africa',EE:'Europe',FI:'Europe',FR:'Europe',DE:'Europe',GR:'Europe',HU:'Europe',IS:'Europe',IN:'Asia',ID:'Asia',
IR:'Asia',IQ:'Asia',IE:'Europe',IL:'Asia',IT:'Europe',JP:'Asia',KE:'Africa',KR:'Asia',LV:'Europe',LT:'Europe',
LU:'Europe',MY:'Asia',MX:'North America',MA:'Africa',NL:'Europe',NZ:'Oceania',NO:'Europe',PK:'Asia',PE:'South America',PH:'Asia',
PL:'Europe',PT:'Europe',RO:'Europe',RU:'Europe',SA:'Asia',RS:'Europe',SK:'Europe',SI:'Europe',ZA:'Africa',ES:'Europe',
SE:'Europe',CH:'Europe',TH:'Asia',TR:'Europe',UA:'Europe',AE:'Asia',GB:'Europe',US:'North America',VN:'Asia',NG:'Africa',
};
router.get('/stats', 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<number> }
const countrySet = new Map<string, CountryEntry>();
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<string>();
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<string, number> = {};
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<number>();
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 });
});

File diff suppressed because it is too large Load Diff

View File

@@ -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<string, { count: number; first: number }>();
function backupRateLimiter(maxAttempts: number, windowMs: number) {
return (req: Request, res: Response, next: NextFunction) => {
const key = req.ip || 'unknown';
const now = Date.now();
const record = backupAttempts.get(key);
if (record && record.count >= maxAttempts && now - record.first < windowMs) {
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<void>((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<typeof Database> | 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<string, unknown>): {
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<string, unknown>);
scheduler.saveSettings(settings);
scheduler.start();
const settings = updateAutoSettings((req.body || {}) as Record<string, unknown>);
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,

View File

@@ -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<number, (BudgetItemMember & { avatar_url: string | null })[]> = {};
if (itemIds.length > 0) {
const allMembers = db.prepare(`
SELECT bm.budget_item_id, bm.user_id, bm.paid, u.username, u.avatar
FROM budget_item_members bm
JOIN users u ON bm.user_id = u.id
WHERE bm.budget_item_id IN (${itemIds.map(() => '?').join(',')})
`).all(...itemIds) 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<number, number> = {};
const existing = db.prepare('SELECT user_id, paid FROM budget_item_members WHERE budget_item_id = ?').all(id) as { user_id: number; paid: number }[];
for (const e of existing) existingPaid[e.user_id] = e.paid;
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<number, { user_id: number; username: string; avatar_url: string | null; balance: number }> = {};
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);
});

View File

@@ -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 });
});

View File

@@ -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<string, { user_id: number; username: string }[]> = {};
for (const r of reactions) {
if (!map[r.emoji]) map[r.emoji] = [];
map[r.emoji].push({ user_id: r.user_id, username: r.username });
}
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<number, ReactionRow[]> = {};
if (msgIds.length > 0) {
const allReactions = db.prepare(`
SELECT r.message_id, r.emoji, r.user_id, u.username
FROM collab_message_reactions r
JOIN users u ON r.user_id = u.id
WHERE r.message_id IN (${msgIds.map(() => '?').join(',')})
`).all(...msgIds) 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<string> }) => {
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(`<meta[^>]*property=["']og:${prop}["'][^>]*content=["']([^"']*)["']`, 'i'))
|| html.match(new RegExp(`<meta[^>]*content=["']([^"']*)["'][^>]*property=["']og:${prop}["']`, 'i'));
return m ? m[1] : null;
};
const titleTag = html.match(/<title[^>]*>([^<]*)<\/title>/i);
const descMeta = html.match(/<meta[^>]*name=["']description["'][^>]*content=["']([^"']*)["']/i)
|| html.match(/<meta[^>]*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 });
}

View File

@@ -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);
});

View File

@@ -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<number, ReturnType<typeof formatAssignmentWithPlace>[]> = {};
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<number, DayNote[]> = {};
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<string, string> = {};
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);
});

View File

@@ -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<number, FileLink[]> = {};
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 });
});

View File

@@ -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 <img> 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 <img> 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);
}
});

View File

@@ -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<string, string>;
}
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<OverpassElement | null> {
const typeMap: Record<string, string> = { 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<number>();
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<string, string>, 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<string, { thumbnail?: { source?: string } }> } };
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<string, WikiCommonsPage & { imageinfo?: { mime?: string }[] }> } };
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<string, { photoUrl: string; attribution: string | null; fetchedAt: number; error?: boolean }>();
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<GooglePlaceDetails['reviews']>[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<string, string> };
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<string, string> };
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 });
}
});

View File

@@ -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;

View File

@@ -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<string, { token: string; created: number }>();
setInterval(() => {
const now = Date.now();
for (const [code, entry] of authCodes) {
if (now - entry.created > AUTH_CODE_TTL) authCodes.delete(code);
}
}, AUTH_CODE_CLEANUP);
const pendingStates = new Map<string, { createdAt: number; redirectUri: string; inviteToken?: string }>();
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;

View File

@@ -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<string, { user_id: number; username: string; avatar: string | null }[]> = {};
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 });
});

View File

@@ -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(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1').trim();
const extractName = (body: string) => { const m = body.match(/<name[^>]*>([\s\S]*?)<\/name>/i); return m ? stripCdata(m[1]) : null };
const extractDesc = (body: string) => { const m = body.match(/<desc[^>]*>([\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 <wpt> elements (named waypoints / POIs)
const wptRegex = /<wpt\s([^>]+)>([\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 <wpt>, try <rtept> (route points)
if (waypoints.length === 0) {
const rteptRegex = /<rtept\s([^>]+)>([\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 <trkpt>
if (waypoints.length === 0) {
const trackNameMatch = xml.match(/<trk[^>]*>[\s\S]*?<name[^>]*>([\s\S]*?)<\/name>/i);
const trackName = trackNameMatch?.[1]?.trim() || 'GPX Track';
const trackDesc = (() => { const m = xml.match(/<trk[^>]*>[\s\S]*?<desc[^>]*>([\s\S]*?)<\/desc>/i); return m ? stripCdata(m[1]) : null })();
const trkptRegex = /<trkpt\s([^>]*?)(?:\/>|>([\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(/<ele[^>]*>([\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<UnsplashSearchResponse['results']>[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);
});

View File

@@ -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);

View File

@@ -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<string, unknown> = {};
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;

View File

@@ -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<number, any[]> = {};
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<number, any[]> = {};
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;

View File

@@ -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 });
});

View File

@@ -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<string, { id: number; day_number: number; date: string | null }>();
for (const d of existing) {
if (d.date) existingByDate.set(d.date, d);
}
const targetDateSet = new Set(targetDates);
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<string, unknown> = {};
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<User, 'id' | 'username' | 'email' | 'avatar'>;
res.json({
owner: { ...owner, role: 'owner', avatar_url: owner.avatar ? `/uploads/avatars/${owner.avatar}` : null },
members: members.map(m => ({ ...m, avatar_url: m.avatar ? `/uploads/avatars/${m.avatar}` : null })),
current_user_id: 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<User, 'id' | 'username' | 'email' | 'avatar'> | 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;

View File

@@ -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<string, { data: unknown; time: number }>();
const CACHE_TTL = 24 * 60 * 60 * 1000;
async function applyHolidayCalendars(planId: number): Promise<void> {
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<void> {
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;

View File

@@ -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<string, { data: WeatherResult; expiresAt: number }>();
const CACHE_MAX_ENTRIES = 1000;
const CACHE_PRUNE_TARGET = 500;
const CACHE_CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 minutes
setInterval(() => {
const now = Date.now();
for (const [key, entry] of weatherCache) {
if (now > entry.expiresAt) weatherCache.delete(key);
}
if (weatherCache.size > CACHE_MAX_ENTRIES) {
const entries = [...weatherCache.entries()].sort((a, b) => a[1].expiresAt - b[1].expiresAt);
const toDelete = entries.slice(0, entries.length - CACHE_PRUNE_TARGET);
toDelete.forEach(([key]) => weatherCache.delete(key));
}
}, CACHE_CLEANUP_INTERVAL);
const TTL_FORECAST_MS = 60 * 60 * 1000; // 1 hour
const TTL_CURRENT_MS = 15 * 60 * 1000; // 15 minutes
const TTL_CLIMATE_MS = 24 * 60 * 60 * 1000; // 24 hours
function cacheKey(lat: string, lng: string, date?: string): string {
const rlat = parseFloat(lat).toFixed(2);
const rlng = parseFloat(lng).toFixed(2);
return `${rlat}_${rlng}_${date || 'current'}`;
}
function getCached(key: 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<number, string> = {
0: 'Clear', 1: 'Clear', 2: 'Clouds', 3: 'Clouds',
45: 'Fog', 48: 'Fog',
51: 'Drizzle', 53: 'Drizzle', 55: 'Drizzle', 56: 'Drizzle', 57: 'Drizzle',
61: 'Rain', 63: 'Rain', 65: 'Rain', 66: 'Rain', 67: 'Rain',
71: 'Snow', 73: 'Snow', 75: 'Snow', 77: 'Snow',
80: 'Rain', 81: 'Rain', 82: 'Rain',
85: 'Snow', 86: 'Snow',
95: 'Thunderstorm', 96: 'Thunderstorm', 99: 'Thunderstorm',
};
const WMO_DESCRIPTION_DE: Record<number, string> = {
0: 'Klar', 1: 'Uberwiegend klar', 2: 'Teilweise bewolkt', 3: 'Bewolkt',
45: 'Nebel', 48: 'Nebel mit Reif',
51: 'Leichter Nieselregen', 53: 'Nieselregen', 55: 'Starker Nieselregen',
56: 'Gefrierender Nieselregen', 57: 'Starker gefr. Nieselregen',
61: 'Leichter Regen', 63: 'Regen', 65: 'Starker Regen',
66: 'Gefrierender Regen', 67: 'Starker gefr. Regen',
71: 'Leichter Schneefall', 73: 'Schneefall', 75: 'Starker Schneefall', 77: 'Schneekorner',
80: 'Leichte Regenschauer', 81: 'Regenschauer', 82: 'Starke Regenschauer',
85: 'Leichte Schneeschauer', 86: 'Starke Schneeschauer',
95: 'Gewitter', 96: 'Gewitter mit Hagel', 99: 'Starkes Gewitter mit Hagel',
};
const WMO_DESCRIPTION_EN: Record<number, string> = {
0: 'Clear sky', 1: 'Mainly clear', 2: 'Partly cloudy', 3: 'Overcast',
45: 'Fog', 48: 'Rime fog',
51: 'Light drizzle', 53: 'Drizzle', 55: 'Heavy drizzle',
56: 'Freezing drizzle', 57: 'Heavy freezing drizzle',
61: 'Light rain', 63: 'Rain', 65: 'Heavy rain',
66: 'Freezing rain', 67: 'Heavy freezing rain',
71: 'Light snowfall', 73: 'Snowfall', 75: 'Heavy snowfall', 77: 'Snow grains',
80: 'Light rain showers', 81: 'Rain showers', 82: 'Heavy rain showers',
85: 'Light snow showers', 86: 'Heavy snow showers',
95: 'Thunderstorm', 96: 'Thunderstorm with hail', 99: 'Severe thunderstorm with hail',
};
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}&current=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' });
}

View File

@@ -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<User, 'id' | 'username' | 'email' | 'role' | 'created_at' | 'updated_at' | 'last_login'>[];
let onlineUserIds = new Set<number>();
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<string, string>) {
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<string, unknown> | null = null;
if (r.details) {
try {
details = JSON.parse(r.details) as Record<string, unknown>;
} 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<string, unknown> }) {
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 {};
}

View File

@@ -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);
}

View File

@@ -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<string, string | null>();
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<string, [number, number, number, number]> = {
AF:[60.5,29.4,75,38.5],AL:[19,39.6,21.1,42.7],DZ:[-8.7,19,12,37.1],AD:[1.4,42.4,1.8,42.7],AO:[11.7,-18.1,24.1,-4.4],
AR:[-73.6,-55.1,-53.6,-21.8],AM:[43.4,38.8,46.6,41.3],AU:[112.9,-43.6,153.6,-10.7],AT:[9.5,46.4,17.2,49],AZ:[44.8,38.4,50.4,41.9],
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<string, string> = {
'germany':'DE','deutschland':'DE','france':'FR','frankreich':'FR','spain':'ES','spanien':'ES',
'italy':'IT','italien':'IT','united kingdom':'GB','uk':'GB','england':'GB','united states':'US',
'usa':'US','netherlands':'NL','niederlande':'NL','austria':'AT','osterreich':'AT','switzerland':'CH',
'schweiz':'CH','portugal':'PT','greece':'GR','griechenland':'GR','turkey':'TR','turkei':'TR',
'croatia':'HR','kroatien':'HR','czech republic':'CZ','tschechien':'CZ','czechia':'CZ',
'poland':'PL','polen':'PL','sweden':'SE','schweden':'SE','norway':'NO','norwegen':'NO',
'denmark':'DK','danemark':'DK','finland':'FI','finnland':'FI','belgium':'BE','belgien':'BE',
'ireland':'IE','irland':'IE','hungary':'HU','ungarn':'HU','romania':'RO','rumanien':'RO',
'bulgaria':'BG','bulgarien':'BG','japan':'JP','china':'CN','australia':'AU','australien':'AU',
'canada':'CA','kanada':'CA','mexico':'MX','mexiko':'MX','brazil':'BR','brasilien':'BR',
'argentina':'AR','argentinien':'AR','thailand':'TH','indonesia':'ID','indonesien':'ID',
'india':'IN','indien':'IN','egypt':'EG','agypten':'EG','morocco':'MA','marokko':'MA',
'south africa':'ZA','sudafrika':'ZA','new zealand':'NZ','neuseeland':'NZ','iceland':'IS','island':'IS',
'luxembourg':'LU','luxemburg':'LU','slovenia':'SI','slowenien':'SI','slovakia':'SK','slowakei':'SK',
'estonia':'EE','estland':'EE','latvia':'LV','lettland':'LV','lithuania':'LT','litauen':'LT',
'serbia':'RS','serbien':'RS','israel':'IL','russia':'RU','russland':'RU','ukraine':'UA',
'vietnam':'VN','south korea':'KR','sudkorea':'KR','philippines':'PH','philippinen':'PH',
'malaysia':'MY','colombia':'CO','kolumbien':'CO','peru':'PE','chile':'CL','iran':'IR',
'iraq':'IQ','irak':'IQ','pakistan':'PK','kenya':'KE','kenia':'KE','nigeria':'NG',
'saudi arabia':'SA','saudi-arabien':'SA','albania':'AL','albanien':'AL',
};
export const CONTINENT_MAP: Record<string, string> = {
AF:'Africa',AL:'Europe',DZ:'Africa',AD:'Europe',AO:'Africa',AR:'South America',AM:'Asia',AU:'Oceania',AT:'Europe',AZ:'Asia',
BR:'South America',BE:'Europe',BG:'Europe',CA:'North America',CL:'South America',CN:'Asia',CO:'South America',HR:'Europe',CZ:'Europe',DK:'Europe',
EG:'Africa',EE:'Europe',FI:'Europe',FR:'Europe',DE:'Europe',GR:'Europe',HU:'Europe',IS:'Europe',IN:'Asia',ID:'Asia',
IR:'Asia',IQ:'Asia',IE:'Europe',IL:'Asia',IT:'Europe',JP:'Asia',KE:'Africa',KR:'Asia',LV:'Europe',LT:'Europe',
LU:'Europe',MY:'Asia',MX:'North America',MA:'Africa',NL:'Europe',NZ:'Oceania',NO:'Europe',PK:'Asia',PE:'South America',PH:'Asia',
PL:'Europe',PT:'Europe',RO:'Europe',RU:'Europe',SA:'Asia',RS:'Europe',SK:'Europe',SI:'Europe',ZA:'Africa',ES:'Europe',
SE:'Europe',CH:'Europe',TH:'Asia',TR:'Europe',UA:'Europe',AE:'Asia',GB:'Europe',US:'North America',VN:'Asia',NG:'Africa',
};
// ── Geocoding helpers ───────────────────────────────────────────────────────
export async function reverseGeocodeCountry(lat: number, lng: number): Promise<string | null> {
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<string | null> {
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<number> }
const countrySet = new Map<string, CountryEntry>();
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<string>();
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<string, number> = {};
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<number>();
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;
}

View File

@@ -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<number, { secret: string; exp: number }>();
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<string, unknown> {
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<string, unknown> } {
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<string, unknown>;
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<string, unknown>; auditUserId?: number; auditDetails?: Record<string, unknown> } {
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<string, unknown>;
mfa_required?: boolean;
mfa_token?: string;
auditUserId?: number | null;
auditAction?: string;
auditDetails?: Record<string, unknown>;
} {
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<string, unknown>;
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<string, unknown>;
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<User, 'maps_api_key' | 'openweather_api_key'> | undefined;
db.prepare(
'UPDATE users SET maps_api_key = ?, openweather_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
).run(
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<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'avatar' | 'mfa_enabled'> | 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<string, unknown> } {
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<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'avatar' | 'mfa_enabled'> | 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<string, unknown> } {
const user = db.prepare(
'SELECT role, maps_api_key, openweather_api_key FROM users WHERE id = ?'
).get(userId) as Pick<User, 'role' | 'maps_api_key' | 'openweather_api_key'> | 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<User, 'id' | 'username' | 'email' | 'role' | 'avatar'> | 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<User, 'id' | 'username' | 'avatar'>[];
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<User, 'role' | 'maps_api_key' | 'openweather_api_key'> | 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<string, string> } {
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<string, string> = {};
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<string, unknown>
): {
error?: string;
status?: number;
success?: boolean;
auditSummary?: Record<string, unknown>;
auditDebugDetails?: Record<string, unknown>;
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<string, unknown> = {};
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<string, unknown> = {};
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<string>();
const cities = new Set<string>();
const coords: { lat: number; lng: number }[] = [];
places.forEach(p => {
if (p.lat && p.lng) coords.push({ lat: p.lat, lng: p.lng });
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<string> } {
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<string, unknown>;
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<string, unknown>;
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<string, unknown> } {
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 };
}

View File

@@ -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<string, unknown>): {
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<string, { count: number; first: number }>();
/** 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<BackupInfo> {
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<void>((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<RestoreResult> {
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<typeof Database> | 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<typeof scheduler.loadSettings>; timezone: string } {
const tz = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
return { settings: scheduler.loadSettings(), timezone: tz };
}
export function updateAutoSettings(body: Record<string, unknown>): ReturnType<typeof parseAutoBackupBody> {
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/');
}

View File

@@ -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<number, (BudgetItemMember & { avatar_url: string | null })[]> = {};
if (itemIds.length > 0) {
const allMembers = db.prepare(`
SELECT bm.budget_item_id, bm.user_id, bm.paid, u.username, u.avatar
FROM budget_item_members bm
JOIN users u ON bm.user_id = u.id
WHERE bm.budget_item_id IN (${itemIds.map(() => '?').join(',')})
`).all(...itemIds) 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<number, number> = {};
const existing = db.prepare('SELECT user_id, paid FROM budget_item_members WHERE budget_item_id = ?').all(id) as { user_id: number; paid: number }[];
for (const e of existing) existingPaid[e.user_id] = e.paid;
db.prepare('DELETE FROM budget_item_members WHERE budget_item_id = ?').run(id);
if (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<number, { user_id: number; username: string; avatar_url: string | null; balance: number }> = {};
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,
};
}

View File

@@ -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);
}

View File

@@ -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<string, { user_id: number; username: string }[]> = {};
for (const r of reactions) {
if (!map[r.emoji]) map[r.emoji] = [];
map[r.emoji].push({ user_id: r.user_id, username: r.username });
}
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<typeof formatNote> | 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<typeof getPollWithVotes> } {
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<typeof getPollWithVotes> | 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<number, ReactionRow[]> = {};
if (msgIds.length > 0) {
const allReactions = db.prepare(`
SELECT r.message_id, r.emoji, r.user_id, u.username
FROM collab_message_reactions r
JOIN users u ON r.user_id = u.id
WHERE r.message_id IN (${msgIds.map(() => '?').join(',')})
`).all(...msgIds) 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<typeof formatMessage> } {
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<LinkPreviewResult> {
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<string> } = 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(`<meta[^>]*property=["']og:${prop}["'][^>]*content=["']([^"']*)["']`, 'i'))
|| html.match(new RegExp(`<meta[^>]*content=["']([^"']*)["'][^>]*property=["']og:${prop}["']`, 'i'));
return m ? m[1] : null;
};
const titleTag = html.match(/<title[^>]*>([^<]*)<\/title>/i);
const descMeta = html.match(/<meta[^>]*name=["']description["'][^>]*content=["']([^"']*)["']/i)
|| html.match(/<meta[^>]*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;
}
}

View File

@@ -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);
}

View File

@@ -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<number, ReturnType<typeof formatAssignmentWithPlace>[]> = {};
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<number, DayNote[]> = {};
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<string, string> = {};
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 };
}

View File

@@ -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<number, FileLink[]> = {};
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);
}

View File

@@ -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 };
}
}

View File

@@ -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<string, string>;
}
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<string, { photoUrl: string; attribution: string | null; fetchedAt: number; error?: boolean }>();
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<OverpassElement | null> {
const typeMap: Record<string, string> = { 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<number>();
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<string, string>, 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<string, { thumbnail?: { source?: string } }> } };
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<string, WikiCommonsPage & { imageinfo?: { mime?: string }[] }> } };
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<string, unknown>[]; 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<string, unknown> }> {
// 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<GooglePlaceDetails['reviews']>[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<string, string> };
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<string, string> };
const name = placeName || nominatim.name || nominatim.address?.tourism || nominatim.address?.building || null;
const address = nominatim.display_name || null;
return { lat, lng, name, address };
}

View File

@@ -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);
}

View File

@@ -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<string, { createdAt: number; redirectUri: string; inviteToken?: string }>();
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<string, { token: string; created: number }>();
setInterval(() => {
const now = Date.now();
for (const [code, entry] of authCodes) {
if (now - entry.created > AUTH_CODE_TTL) authCodes.delete(code);
}
}, AUTH_CODE_CLEANUP);
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<OidcDiscoveryDoc> {
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<OidcTokenResponse & { _ok: boolean; _status: number }> {
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<OidcUserInfo> {
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);
}

View File

@@ -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<string, { user_id: number; username: string; avatar: string | null }[]> = {};
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);
}

View File

@@ -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(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1').trim();
}
function extractName(body: string) {
const m = body.match(/<name[^>]*>([\s\S]*?)<\/name>/i);
return m ? stripCdata(m[1]) : null;
}
function extractDesc(body: string) {
const m = body.match(/<desc[^>]*>([\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 <wpt> elements (named waypoints / POIs)
const wptRegex = /<wpt\s([^>]+)>([\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 <wpt>, try <rtept> (route points)
if (waypoints.length === 0) {
const rteptRegex = /<rtept\s([^>]+)>([\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 <trkpt>
if (waypoints.length === 0) {
const trackNameMatch = xml.match(/<trk[^>]*>[\s\S]*?<name[^>]*>([\s\S]*?)<\/name>/i);
const trackName = trackNameMatch?.[1]?.trim() || 'GPX Track';
const trackDesc = (() => { const m = xml.match(/<trk[^>]*>[\s\S]*?<desc[^>]*>([\s\S]*?)<\/desc>/i); return m ? stripCdata(m[1]) : null; })();
const trkptRegex = /<trkpt\s([^>]*?)(?:\/>|>([\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(/<ele[^>]*>([\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<UnsplashSearchResponse['results']>[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 };
}

View File

@@ -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 };
}

View File

@@ -0,0 +1,41 @@
import { db } from '../db/database';
export function getUserSettings(userId: number): Record<string, unknown> {
const rows = db.prepare('SELECT key, value FROM settings WHERE user_id = ?').all(userId) as { key: string; value: string }[];
const settings: Record<string, unknown> = {};
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<string, unknown>) {
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;
}

View File

@@ -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<string, any> | 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<number, any[]> = {};
let dayNotes: Record<number, any[]> = {};
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<number, any[]> = {};
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<number, any[]> = {};
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,
};
}

View File

@@ -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);
}

View File

@@ -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<string, { id: number; day_number: number; date: string | null }>();
for (const d of existing) {
if (d.date) existingByDate.set(d.date, d);
}
const targetDateSet = new Set(targetDates);
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<string, unknown>;
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<string, unknown> = {};
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<User, 'id' | 'username' | 'email' | 'avatar'>;
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<User, 'id' | 'username' | 'email' | 'avatar'> | 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';
}
}

View File

@@ -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<string, { data: unknown; time: number }>();
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<void> {
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<void> {
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' };
}
}

View File

@@ -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<number, string> = {
0: 'Clear', 1: 'Clear', 2: 'Clouds', 3: 'Clouds',
45: 'Fog', 48: 'Fog',
51: 'Drizzle', 53: 'Drizzle', 55: 'Drizzle', 56: 'Drizzle', 57: 'Drizzle',
61: 'Rain', 63: 'Rain', 65: 'Rain', 66: 'Rain', 67: 'Rain',
71: 'Snow', 73: 'Snow', 75: 'Snow', 77: 'Snow',
80: 'Rain', 81: 'Rain', 82: 'Rain',
85: 'Snow', 86: 'Snow',
95: 'Thunderstorm', 96: 'Thunderstorm', 99: 'Thunderstorm',
};
const WMO_DESCRIPTION_DE: Record<number, string> = {
0: 'Klar', 1: 'Uberwiegend klar', 2: 'Teilweise bewolkt', 3: 'Bewolkt',
45: 'Nebel', 48: 'Nebel mit Reif',
51: 'Leichter Nieselregen', 53: 'Nieselregen', 55: 'Starker Nieselregen',
56: 'Gefrierender Nieselregen', 57: 'Starker gefr. Nieselregen',
61: 'Leichter Regen', 63: 'Regen', 65: 'Starker Regen',
66: 'Gefrierender Regen', 67: 'Starker gefr. Regen',
71: 'Leichter Schneefall', 73: 'Schneefall', 75: 'Starker Schneefall', 77: 'Schneekorner',
80: 'Leichte Regenschauer', 81: 'Regenschauer', 82: 'Starke Regenschauer',
85: 'Leichte Schneeschauer', 86: 'Starke Schneeschauer',
95: 'Gewitter', 96: 'Gewitter mit Hagel', 99: 'Starkes Gewitter mit Hagel',
};
const WMO_DESCRIPTION_EN: Record<number, string> = {
0: 'Clear sky', 1: 'Mainly clear', 2: 'Partly cloudy', 3: 'Overcast',
45: 'Fog', 48: 'Rime fog',
51: 'Light drizzle', 53: 'Drizzle', 55: 'Heavy drizzle',
56: 'Freezing drizzle', 57: 'Heavy freezing drizzle',
61: 'Light rain', 63: 'Rain', 65: 'Heavy rain',
66: 'Freezing rain', 67: 'Heavy freezing rain',
71: 'Light snowfall', 73: 'Snowfall', 75: 'Heavy snowfall', 77: 'Snow grains',
80: 'Light rain showers', 81: 'Rain showers', 82: 'Heavy rain showers',
85: 'Light snow showers', 86: 'Heavy snow showers',
95: 'Thunderstorm', 96: 'Thunderstorm with hail', 99: 'Severe thunderstorm with hail',
};
// ── Cache management ────────────────────────────────────────────────────
const weatherCache = new Map<string, { data: WeatherResult; expiresAt: number }>();
const CACHE_MAX_ENTRIES = 1000;
const CACHE_PRUNE_TARGET = 500;
const CACHE_CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 minutes
setInterval(() => {
const now = Date.now();
for (const [key, entry] of weatherCache) {
if (now > entry.expiresAt) weatherCache.delete(key);
}
if (weatherCache.size > CACHE_MAX_ENTRIES) {
const entries = [...weatherCache.entries()].sort((a, b) => a[1].expiresAt - b[1].expiresAt);
const toDelete = entries.slice(0, entries.length - CACHE_PRUNE_TARGET);
toDelete.forEach(([key]) => weatherCache.delete(key));
}
}, CACHE_CLEANUP_INTERVAL);
const TTL_FORECAST_MS = 60 * 60 * 1000; // 1 hour
const TTL_CURRENT_MS = 15 * 60 * 1000; // 15 minutes
const TTL_CLIMATE_MS = 24 * 60 * 60 * 1000; // 24 hours
function cacheKey(lat: string, lng: string, date?: string): string {
const rlat = parseFloat(lat).toFixed(2);
const rlng = parseFloat(lng).toFixed(2);
return `${rlat}_${rlng}_${date || 'current'}`;
}
function getCached(key: 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<WeatherResult> {
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}&current=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<WeatherResult> {
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;
}
}