refactor: extract business logic from routes into reusable service modules
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}¤t=temperature_2m,weathercode&timezone=auto`;
|
||||
const response = await fetch(url);
|
||||
const data = await response.json() as OpenMeteoForecast;
|
||||
|
||||
if (!response.ok || data.error) {
|
||||
return res.status(response.status || 500).json({ error: data.reason || 'Open-Meteo API error' });
|
||||
}
|
||||
|
||||
const code = data.current.weathercode;
|
||||
const descriptions = lang === 'de' ? WMO_DESCRIPTION_DE : WMO_DESCRIPTION_EN;
|
||||
|
||||
const result = {
|
||||
temp: Math.round(data.current.temperature_2m),
|
||||
main: WMO_MAP[code] || 'Clouds',
|
||||
description: descriptions[code] || '',
|
||||
type: 'current',
|
||||
};
|
||||
|
||||
setCache(ck, result, TTL_CURRENT_MS);
|
||||
const result = await getWeather(lat, lng, date, lang);
|
||||
res.json(result);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
return res.status(err.status).json({ error: err.message });
|
||||
}
|
||||
console.error('Weather error:', err);
|
||||
res.status(500).json({ error: 'Error fetching weather data' });
|
||||
}
|
||||
@@ -283,146 +30,13 @@ router.get('/detailed', authenticate, async (req: Request, res: Response) => {
|
||||
return res.status(400).json({ error: 'Latitude, longitude, and date are required' });
|
||||
}
|
||||
|
||||
const ck = `detailed_${cacheKey(lat, lng, date)}`;
|
||||
|
||||
try {
|
||||
const cached = getCached(ck);
|
||||
if (cached) return res.json(cached);
|
||||
|
||||
const targetDate = new Date(date);
|
||||
const now = new Date();
|
||||
const diffDays = (targetDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24);
|
||||
const dateStr = targetDate.toISOString().slice(0, 10);
|
||||
const descriptions = lang === 'de' ? WMO_DESCRIPTION_DE : WMO_DESCRIPTION_EN;
|
||||
|
||||
if (diffDays > 16) {
|
||||
const refYear = targetDate.getFullYear() - 1;
|
||||
const refDateStr = `${refYear}-${String(targetDate.getMonth() + 1).padStart(2, '0')}-${String(targetDate.getDate()).padStart(2, '0')}`;
|
||||
|
||||
const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${lat}&longitude=${lng}`
|
||||
+ `&start_date=${refDateStr}&end_date=${refDateStr}`
|
||||
+ `&hourly=temperature_2m,precipitation,weathercode,windspeed_10m,relativehumidity_2m`
|
||||
+ `&daily=temperature_2m_max,temperature_2m_min,weathercode,precipitation_sum,windspeed_10m_max,sunrise,sunset`
|
||||
+ `&timezone=auto`;
|
||||
const response = await fetch(url);
|
||||
const data = await response.json() as OpenMeteoForecast;
|
||||
|
||||
if (!response.ok || data.error) {
|
||||
return res.status(response.status || 500).json({ error: data.reason || 'Open-Meteo Climate API error' });
|
||||
}
|
||||
|
||||
const daily = data.daily;
|
||||
const hourly = data.hourly;
|
||||
if (!daily || !daily.time || daily.time.length === 0) {
|
||||
return res.json({ error: 'no_forecast' });
|
||||
}
|
||||
|
||||
const idx = 0;
|
||||
const code = daily.weathercode?.[idx];
|
||||
const avgMax = daily.temperature_2m_max[idx];
|
||||
const avgMin = daily.temperature_2m_min[idx];
|
||||
|
||||
const hourlyData: HourlyEntry[] = [];
|
||||
if (hourly?.time) {
|
||||
for (let i = 0; i < hourly.time.length; i++) {
|
||||
const hour = new Date(hourly.time[i]).getHours();
|
||||
const hCode = hourly.weathercode?.[i];
|
||||
hourlyData.push({
|
||||
hour,
|
||||
temp: Math.round(hourly.temperature_2m[i]),
|
||||
precipitation: hourly.precipitation?.[i] || 0,
|
||||
precipitation_probability: 0,
|
||||
main: WMO_MAP[hCode] || 'Clouds',
|
||||
wind: Math.round(hourly.windspeed_10m?.[i] || 0),
|
||||
humidity: hourly.relativehumidity_2m?.[i] || 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let sunrise: string | null = null, sunset: string | null = null;
|
||||
if (daily.sunrise?.[idx]) sunrise = daily.sunrise[idx].split('T')[1]?.slice(0, 5);
|
||||
if (daily.sunset?.[idx]) sunset = daily.sunset[idx].split('T')[1]?.slice(0, 5);
|
||||
|
||||
const result = {
|
||||
type: 'climate',
|
||||
temp: Math.round((avgMax + avgMin) / 2),
|
||||
temp_max: Math.round(avgMax),
|
||||
temp_min: Math.round(avgMin),
|
||||
main: WMO_MAP[code] || estimateCondition((avgMax + avgMin) / 2, daily.precipitation_sum?.[idx] || 0),
|
||||
description: descriptions[code] || '',
|
||||
precipitation_sum: Math.round((daily.precipitation_sum?.[idx] || 0) * 10) / 10,
|
||||
wind_max: Math.round(daily.windspeed_10m_max?.[idx] || 0),
|
||||
sunrise,
|
||||
sunset,
|
||||
hourly: hourlyData,
|
||||
};
|
||||
|
||||
setCache(ck, result, TTL_CLIMATE_MS);
|
||||
return res.json(result);
|
||||
}
|
||||
|
||||
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}`
|
||||
+ `&hourly=temperature_2m,precipitation_probability,precipitation,weathercode,windspeed_10m,relativehumidity_2m`
|
||||
+ `&daily=temperature_2m_max,temperature_2m_min,weathercode,sunrise,sunset,precipitation_probability_max,precipitation_sum,windspeed_10m_max`
|
||||
+ `&timezone=auto&start_date=${dateStr}&end_date=${dateStr}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
const data = await response.json() as OpenMeteoForecast;
|
||||
|
||||
if (!response.ok || data.error) {
|
||||
return res.status(response.status || 500).json({ error: data.reason || 'Open-Meteo API error' });
|
||||
}
|
||||
|
||||
const daily = data.daily;
|
||||
const hourly = data.hourly;
|
||||
|
||||
if (!daily || !daily.time || daily.time.length === 0) {
|
||||
return res.json({ error: 'no_forecast' });
|
||||
}
|
||||
|
||||
const dayIdx = 0;
|
||||
const code = daily.weathercode[dayIdx];
|
||||
|
||||
const formatTime = (isoStr: string) => {
|
||||
if (!isoStr) return '';
|
||||
const d = new Date(isoStr);
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const hourlyData: HourlyEntry[] = [];
|
||||
if (hourly && hourly.time) {
|
||||
for (let i = 0; i < hourly.time.length; i++) {
|
||||
const h = new Date(hourly.time[i]).getHours();
|
||||
hourlyData.push({
|
||||
hour: h,
|
||||
temp: Math.round(hourly.temperature_2m[i]),
|
||||
precipitation_probability: hourly.precipitation_probability[i] || 0,
|
||||
precipitation: hourly.precipitation[i] || 0,
|
||||
main: WMO_MAP[hourly.weathercode[i]] || 'Clouds',
|
||||
wind: Math.round(hourly.windspeed_10m[i] || 0),
|
||||
humidity: Math.round(hourly.relativehumidity_2m[i] || 0),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
type: 'forecast',
|
||||
temp: Math.round((daily.temperature_2m_max[dayIdx] + daily.temperature_2m_min[dayIdx]) / 2),
|
||||
temp_max: Math.round(daily.temperature_2m_max[dayIdx]),
|
||||
temp_min: Math.round(daily.temperature_2m_min[dayIdx]),
|
||||
main: WMO_MAP[code] || 'Clouds',
|
||||
description: descriptions[code] || '',
|
||||
sunrise: formatTime(daily.sunrise[dayIdx]),
|
||||
sunset: formatTime(daily.sunset[dayIdx]),
|
||||
precipitation_sum: daily.precipitation_sum[dayIdx] || 0,
|
||||
precipitation_probability_max: daily.precipitation_probability_max[dayIdx] || 0,
|
||||
wind_max: Math.round(daily.windspeed_10m_max[dayIdx] || 0),
|
||||
hourly: hourlyData,
|
||||
};
|
||||
|
||||
setCache(ck, result, TTL_FORECAST_MS);
|
||||
return res.json(result);
|
||||
const result = await getDetailedWeather(lat, lng, date, lang);
|
||||
res.json(result);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
return res.status(err.status).json({ error: err.message });
|
||||
}
|
||||
console.error('Detailed weather error:', err);
|
||||
res.status(500).json({ error: 'Error fetching detailed weather data' });
|
||||
}
|
||||
|
||||
516
server/src/services/adminService.ts
Normal file
516
server/src/services/adminService.ts
Normal 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 {};
|
||||
}
|
||||
184
server/src/services/assignmentService.ts
Normal file
184
server/src/services/assignmentService.ts
Normal 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);
|
||||
}
|
||||
378
server/src/services/atlasService.ts
Normal file
378
server/src/services/atlasService.ts
Normal 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;
|
||||
}
|
||||
989
server/src/services/authService.ts
Normal file
989
server/src/services/authService.ts
Normal 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 };
|
||||
}
|
||||
291
server/src/services/backupService.ts
Normal file
291
server/src/services/backupService.ts
Normal 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/');
|
||||
}
|
||||
256
server/src/services/budgetService.ts
Normal file
256
server/src/services/budgetService.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
31
server/src/services/categoryService.ts
Normal file
31
server/src/services/categoryService.ts
Normal 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);
|
||||
}
|
||||
441
server/src/services/collabService.ts
Normal file
441
server/src/services/collabService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
44
server/src/services/dayNoteService.ts
Normal file
44
server/src/services/dayNoteService.ts
Normal 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);
|
||||
}
|
||||
298
server/src/services/dayService.ts
Normal file
298
server/src/services/dayService.ts
Normal 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 };
|
||||
}
|
||||
244
server/src/services/fileService.ts
Normal file
244
server/src/services/fileService.ts
Normal 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);
|
||||
}
|
||||
394
server/src/services/immichService.ts
Normal file
394
server/src/services/immichService.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
527
server/src/services/mapsService.ts
Normal file
527
server/src/services/mapsService.ts
Normal 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 };
|
||||
}
|
||||
40
server/src/services/notificationPreferencesService.ts
Normal file
40
server/src/services/notificationPreferencesService.ts
Normal 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);
|
||||
}
|
||||
314
server/src/services/oidcService.ts
Normal file
314
server/src/services/oidcService.ts
Normal 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);
|
||||
}
|
||||
223
server/src/services/packingService.ts
Normal file
223
server/src/services/packingService.ts
Normal 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);
|
||||
}
|
||||
440
server/src/services/placeService.ts
Normal file
440
server/src/services/placeService.ts
Normal 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 };
|
||||
}
|
||||
242
server/src/services/reservationService.ts
Normal file
242
server/src/services/reservationService.ts
Normal 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 };
|
||||
}
|
||||
41
server/src/services/settingsService.ts
Normal file
41
server/src/services/settingsService.ts
Normal 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;
|
||||
}
|
||||
189
server/src/services/shareService.ts
Normal file
189
server/src/services/shareService.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
26
server/src/services/tagService.ts
Normal file
26
server/src/services/tagService.ts
Normal 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);
|
||||
}
|
||||
415
server/src/services/tripService.ts
Normal file
415
server/src/services/tripService.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
665
server/src/services/vacayService.ts
Normal file
665
server/src/services/vacayService.ts
Normal 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' };
|
||||
}
|
||||
}
|
||||
440
server/src/services/weatherService.ts
Normal file
440
server/src/services/weatherService.ts
Normal 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}¤t=temperature_2m,weathercode&timezone=auto`;
|
||||
const response = await fetch(url);
|
||||
const data = await response.json() as OpenMeteoForecast;
|
||||
|
||||
if (!response.ok || data.error) {
|
||||
throw new ApiError(response.status || 500, data.reason || 'Open-Meteo API error');
|
||||
}
|
||||
|
||||
const code = data.current!.weathercode;
|
||||
const descriptions = lang === 'de' ? WMO_DESCRIPTION_DE : WMO_DESCRIPTION_EN;
|
||||
|
||||
const result: WeatherResult = {
|
||||
temp: Math.round(data.current!.temperature_2m),
|
||||
main: WMO_MAP[code] || 'Clouds',
|
||||
description: descriptions[code] || '',
|
||||
type: 'current',
|
||||
};
|
||||
|
||||
setCache(ck, result, TTL_CURRENT_MS);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── getDetailedWeather ──────────────────────────────────────────────────
|
||||
|
||||
export async function getDetailedWeather(
|
||||
lat: string,
|
||||
lng: string,
|
||||
date: string,
|
||||
lang: string,
|
||||
): Promise<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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user