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 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 { authenticate, adminOnly } from '../middleware/auth';
|
||||||
import { AuthRequest, User, Addon } from '../types';
|
import { AuthRequest } from '../types';
|
||||||
import { writeAudit, getClientIp, logInfo } from '../services/auditLog';
|
import { writeAudit, getClientIp, logInfo } from '../services/auditLog';
|
||||||
import { getAllPermissions, savePermissions, PERMISSION_ACTIONS } from '../services/permissions';
|
import * as svc from '../services/adminService';
|
||||||
import { revokeUserSessions } from '../mcp';
|
|
||||||
import { maybe_encrypt_api_key, decrypt_api_key } from '../services/apiKeyCrypto';
|
|
||||||
import { validatePassword } from '../services/passwordPolicy';
|
|
||||||
import { updateJwtSecret } from '../config';
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.use(authenticate, adminOnly);
|
router.use(authenticate, adminOnly);
|
||||||
|
|
||||||
function utcSuffix(ts: string | null | undefined): string | null {
|
// ── User CRUD ──────────────────────────────────────────────────────────────
|
||||||
if (!ts) return null;
|
|
||||||
return ts.endsWith('Z') ? ts : ts.replace(' ', 'T') + 'Z';
|
|
||||||
}
|
|
||||||
|
|
||||||
router.get('/users', (req: Request, res: Response) => {
|
router.get('/users', (_req: Request, res: Response) => {
|
||||||
const users = db.prepare(
|
res.json({ users: svc.listUsers() });
|
||||||
'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.post('/users', (req: Request, res: Response) => {
|
router.post('/users', (req: Request, res: Response) => {
|
||||||
const { username, email, password, role } = req.body;
|
const result = svc.createUser(req.body);
|
||||||
|
if ('error' in result) return res.status(result.status!).json({ error: result.error });
|
||||||
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 authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
writeAudit({
|
writeAudit({
|
||||||
userId: authReq.user.id,
|
userId: authReq.user.id,
|
||||||
action: 'admin.user_create',
|
action: 'admin.user_create',
|
||||||
resource: String(result.lastInsertRowid),
|
resource: String(result.insertedId),
|
||||||
ip: getClientIp(req),
|
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) => {
|
router.put('/users/:id', (req: Request, res: Response) => {
|
||||||
const { username, email, role, password } = req.body;
|
const result = svc.updateUser(req.params.id, req.body);
|
||||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id) as User | undefined;
|
if ('error' in result) return res.status(result.status!).json({ error: result.error });
|
||||||
|
|
||||||
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 authReq = req as AuthRequest;
|
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({
|
writeAudit({
|
||||||
userId: authReq.user.id,
|
userId: authReq.user.id,
|
||||||
action: 'admin.user_update',
|
action: 'admin.user_update',
|
||||||
resource: String(req.params.id),
|
resource: String(req.params.id),
|
||||||
ip: getClientIp(req),
|
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(', ')})`);
|
logInfo(`Admin ${authReq.user.email} edited user ${result.previousEmail} (fields: ${result.changed.join(', ')})`);
|
||||||
res.json({ user: updated });
|
res.json({ user: result.user });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/users/:id', (req: Request, res: Response) => {
|
router.delete('/users/:id', (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
if (parseInt(req.params.id as string) === authReq.user.id) {
|
const result = svc.deleteUser(req.params.id, authReq.user.id);
|
||||||
return res.status(400).json({ error: 'Cannot delete own account' });
|
if ('error' in result) return res.status(result.status!).json({ error: result.error });
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
writeAudit({
|
writeAudit({
|
||||||
userId: authReq.user.id,
|
userId: authReq.user.id,
|
||||||
action: 'admin.user_delete',
|
action: 'admin.user_delete',
|
||||||
resource: String(req.params.id),
|
resource: String(req.params.id),
|
||||||
ip: getClientIp(req),
|
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 });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/stats', (_req: Request, res: Response) => {
|
// ── Stats ──────────────────────────────────────────────────────────────────
|
||||||
const totalUsers = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
|
|
||||||
const totalTrips = (db.prepare('SELECT COUNT(*) as count FROM trips').get() as { count: number }).count;
|
|
||||||
const totalPlaces = (db.prepare('SELECT COUNT(*) as count FROM places').get() as { count: number }).count;
|
|
||||||
const totalFiles = (db.prepare('SELECT COUNT(*) as count FROM trip_files').get() as { count: number }).count;
|
|
||||||
|
|
||||||
res.json({ totalUsers, totalTrips, totalPlaces, totalFiles });
|
router.get('/stats', (_req: Request, res: Response) => {
|
||||||
|
res.json(svc.getStats());
|
||||||
});
|
});
|
||||||
|
|
||||||
// Permissions management
|
// ── Permissions ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
router.get('/permissions', (_req: Request, res: Response) => {
|
router.get('/permissions', (_req: Request, res: Response) => {
|
||||||
const current = getAllPermissions();
|
res.json(svc.getPermissions());
|
||||||
const actions = PERMISSION_ACTIONS.map(a => ({
|
|
||||||
key: a.key,
|
|
||||||
level: current[a.key],
|
|
||||||
defaultLevel: a.defaultLevel,
|
|
||||||
allowedLevels: a.allowedLevels,
|
|
||||||
}));
|
|
||||||
res.json({ permissions: actions });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/permissions', (req: Request, res: Response) => {
|
router.put('/permissions', (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
|
||||||
const { permissions } = req.body;
|
const { permissions } = req.body;
|
||||||
if (!permissions || typeof permissions !== 'object') {
|
if (!permissions || typeof permissions !== 'object') {
|
||||||
return res.status(400).json({ error: 'permissions object required' });
|
return res.status(400).json({ error: 'permissions object required' });
|
||||||
}
|
}
|
||||||
const { skipped } = savePermissions(permissions);
|
const authReq = req as AuthRequest;
|
||||||
|
const result = svc.savePermissions(permissions);
|
||||||
writeAudit({
|
writeAudit({
|
||||||
userId: authReq.user.id,
|
userId: authReq.user.id,
|
||||||
action: 'admin.permissions_update',
|
action: 'admin.permissions_update',
|
||||||
@@ -194,198 +84,76 @@ router.put('/permissions', (req: Request, res: Response) => {
|
|||||||
ip: getClientIp(req),
|
ip: getClientIp(req),
|
||||||
details: permissions,
|
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) => {
|
router.get('/audit-log', (req: Request, res: Response) => {
|
||||||
const limitRaw = parseInt(String(req.query.limit || '100'), 10);
|
res.json(svc.getAuditLog(req.query as { limit?: string; offset?: string }));
|
||||||
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,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── OIDC Settings ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
router.get('/oidc', (_req: Request, res: Response) => {
|
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 || '';
|
res.json(svc.getOidcSettings());
|
||||||
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'),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/oidc', (req: Request, res: Response) => {
|
router.put('/oidc', (req: Request, res: Response) => {
|
||||||
const { issuer, client_id, client_secret, display_name, oidc_only, discovery_url } = req.body;
|
svc.updateOidcSettings(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);
|
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
writeAudit({
|
writeAudit({
|
||||||
userId: authReq.user.id,
|
userId: authReq.user.id,
|
||||||
action: 'admin.oidc_update',
|
action: 'admin.oidc_update',
|
||||||
ip: getClientIp(req),
|
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 });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Demo Baseline ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
router.post('/save-demo-baseline', (req: Request, res: Response) => {
|
router.post('/save-demo-baseline', (req: Request, res: Response) => {
|
||||||
if (process.env.DEMO_MODE !== 'true') {
|
const result = svc.saveDemoBaseline();
|
||||||
return res.status(404).json({ error: 'Not found' });
|
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||||
}
|
const authReq = req as AuthRequest;
|
||||||
try {
|
writeAudit({ userId: authReq.user.id, action: 'admin.demo_baseline_save', ip: getClientIp(req) });
|
||||||
const { saveBaseline } = require('../demo/demo-reset');
|
res.json({ success: true, message: result.message });
|
||||||
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 isDocker = (() => {
|
// ── GitHub / Version ───────────────────────────────────────────────────────
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
router.get('/github-releases', async (req: Request, res: Response) => {
|
router.get('/github-releases', async (req: Request, res: Response) => {
|
||||||
const { per_page = '10', page = '1' } = req.query;
|
const { per_page = '10', page = '1' } = req.query;
|
||||||
try {
|
res.json(await svc.getGithubReleases(String(per_page), String(page)));
|
||||||
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([]);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/version-check', async (_req: Request, res: Response) => {
|
router.get('/version-check', async (_req: Request, res: Response) => {
|
||||||
const { version: currentVersion } = require('../../package.json');
|
res.json(await svc.checkVersion());
|
||||||
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 });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Invite Tokens ───────────────────────────────────────────────────────────
|
// ── Invite Tokens ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
router.get('/invites', (_req: Request, res: Response) => {
|
router.get('/invites', (_req: Request, res: Response) => {
|
||||||
const invites = db.prepare(`
|
res.json({ invites: svc.listInvites() });
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/invites', (req: Request, res: Response) => {
|
router.post('/invites', (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { max_uses, expires_in_days } = req.body;
|
const result = svc.createInvite(authReq.user.id, 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);
|
|
||||||
|
|
||||||
writeAudit({
|
writeAudit({
|
||||||
userId: authReq.user.id,
|
userId: authReq.user.id,
|
||||||
action: 'admin.invite_create',
|
action: 'admin.invite_create',
|
||||||
resource: String(inviteId),
|
resource: String(result.inviteId),
|
||||||
ip: getClientIp(req),
|
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) => {
|
router.delete('/invites/:id', (req: Request, res: Response) => {
|
||||||
const invite = db.prepare('SELECT id FROM invite_tokens WHERE id = ?').get(req.params.id);
|
const result = svc.deleteInvite(req.params.id);
|
||||||
if (!invite) return res.status(404).json({ error: 'Invite not found' });
|
if ('error' in result) return res.status(result.status!).json({ error: result.error });
|
||||||
db.prepare('DELETE FROM invite_tokens WHERE id = ?').run(req.params.id);
|
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
writeAudit({
|
writeAudit({
|
||||||
userId: authReq.user.id,
|
userId: authReq.user.id,
|
||||||
@@ -396,190 +164,141 @@ router.delete('/invites/:id', (req: Request, res: Response) => {
|
|||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Bag Tracking Setting ────────────────────────────────────────────────────
|
// ── Bag Tracking ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
router.get('/bag-tracking', (_req: Request, res: Response) => {
|
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(svc.getBagTracking());
|
||||||
res.json({ enabled: row?.value === 'true' });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/bag-tracking', (req: Request, res: Response) => {
|
router.put('/bag-tracking', (req: Request, res: Response) => {
|
||||||
const { enabled } = req.body;
|
const result = svc.updateBagTracking(req.body.enabled);
|
||||||
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('bag_tracking_enabled', ?)").run(enabled ? 'true' : 'false');
|
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
writeAudit({
|
writeAudit({
|
||||||
userId: authReq.user.id,
|
userId: authReq.user.id,
|
||||||
action: 'admin.bag_tracking',
|
action: 'admin.bag_tracking',
|
||||||
ip: getClientIp(req),
|
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) => {
|
router.get('/packing-templates', (_req: Request, res: Response) => {
|
||||||
const templates = db.prepare(`
|
res.json({ templates: svc.listPackingTemplates() });
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/packing-templates/:id', (_req: Request, res: Response) => {
|
router.get('/packing-templates/:id', (req: Request, res: Response) => {
|
||||||
const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(_req.params.id);
|
const result = svc.getPackingTemplate(req.params.id);
|
||||||
if (!template) return res.status(404).json({ error: 'Template not found' });
|
if ('error' in result) return res.status(result.status!).json({ error: result.error });
|
||||||
const categories = db.prepare('SELECT * FROM packing_template_categories WHERE template_id = ? ORDER BY sort_order, id').all(_req.params.id) as any[];
|
res.json(result);
|
||||||
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.post('/packing-templates', (req: Request, res: Response) => {
|
router.post('/packing-templates', (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { name } = req.body;
|
const result = svc.createPackingTemplate(req.body.name, authReq.user.id);
|
||||||
if (!name?.trim()) return res.status(400).json({ error: 'Name is required' });
|
if ('error' in result) return res.status(result.status!).json({ error: result.error });
|
||||||
const result = db.prepare('INSERT INTO packing_templates (name, created_by) VALUES (?, ?)').run(name.trim(), authReq.user.id);
|
res.status(201).json(result);
|
||||||
const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(result.lastInsertRowid);
|
|
||||||
res.status(201).json({ template });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/packing-templates/:id', (req: Request, res: Response) => {
|
router.put('/packing-templates/:id', (req: Request, res: Response) => {
|
||||||
const { name } = req.body;
|
const result = svc.updatePackingTemplate(req.params.id, req.body);
|
||||||
const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(req.params.id);
|
if ('error' in result) return res.status(result.status!).json({ error: result.error });
|
||||||
if (!template) return res.status(404).json({ error: 'Template not found' });
|
res.json(result);
|
||||||
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) });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/packing-templates/:id', (req: Request, res: Response) => {
|
router.delete('/packing-templates/:id', (req: Request, res: Response) => {
|
||||||
const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(req.params.id);
|
const result = svc.deletePackingTemplate(req.params.id);
|
||||||
if (!template) return res.status(404).json({ error: 'Template not found' });
|
if ('error' in result) return res.status(result.status!).json({ error: result.error });
|
||||||
db.prepare('DELETE FROM packing_templates WHERE id = ?').run(req.params.id);
|
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const t = template as { name?: string };
|
|
||||||
writeAudit({
|
writeAudit({
|
||||||
userId: authReq.user.id,
|
userId: authReq.user.id,
|
||||||
action: 'admin.packing_template_delete',
|
action: 'admin.packing_template_delete',
|
||||||
resource: String(req.params.id),
|
resource: String(req.params.id),
|
||||||
ip: getClientIp(req),
|
ip: getClientIp(req),
|
||||||
details: { name: t.name },
|
details: { name: result.name },
|
||||||
});
|
});
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Template categories
|
// Template categories
|
||||||
|
|
||||||
router.post('/packing-templates/:id/categories', (req: Request, res: Response) => {
|
router.post('/packing-templates/:id/categories', (req: Request, res: Response) => {
|
||||||
const { name } = req.body;
|
const result = svc.createTemplateCategory(req.params.id, req.body.name);
|
||||||
if (!name?.trim()) return res.status(400).json({ error: 'Category name is required' });
|
if ('error' in result) return res.status(result.status!).json({ error: result.error });
|
||||||
const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(req.params.id);
|
res.status(201).json(result);
|
||||||
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) });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/packing-templates/:templateId/categories/:catId', (req: Request, res: Response) => {
|
router.put('/packing-templates/:templateId/categories/:catId', (req: Request, res: Response) => {
|
||||||
const { name } = req.body;
|
const result = svc.updateTemplateCategory(req.params.templateId, req.params.catId, req.body);
|
||||||
const cat = db.prepare('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?').get(req.params.catId, req.params.templateId);
|
if ('error' in result) return res.status(result.status!).json({ error: result.error });
|
||||||
if (!cat) return res.status(404).json({ error: 'Category not found' });
|
res.json(result);
|
||||||
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) });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/packing-templates/:templateId/categories/:catId', (_req: Request, res: Response) => {
|
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);
|
const result = svc.deleteTemplateCategory(req.params.templateId, req.params.catId);
|
||||||
if (!cat) return res.status(404).json({ error: 'Category not found' });
|
if ('error' in result) return res.status(result.status!).json({ error: result.error });
|
||||||
db.prepare('DELETE FROM packing_template_categories WHERE id = ?').run(_req.params.catId);
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Template items
|
// Template items
|
||||||
|
|
||||||
router.post('/packing-templates/:templateId/categories/:catId/items', (req: Request, res: Response) => {
|
router.post('/packing-templates/:templateId/categories/:catId/items', (req: Request, res: Response) => {
|
||||||
const { name } = req.body;
|
const result = svc.createTemplateItem(req.params.templateId, req.params.catId, req.body.name);
|
||||||
if (!name?.trim()) return res.status(400).json({ error: 'Item name is required' });
|
if ('error' in result) return res.status(result.status!).json({ error: result.error });
|
||||||
const cat = db.prepare('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?').get(req.params.catId, req.params.templateId);
|
res.status(201).json(result);
|
||||||
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) });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/packing-templates/:templateId/items/:itemId', (req: Request, res: Response) => {
|
router.put('/packing-templates/:templateId/items/:itemId', (req: Request, res: Response) => {
|
||||||
const { name } = req.body;
|
const result = svc.updateTemplateItem(req.params.itemId, req.body);
|
||||||
const item = db.prepare('SELECT * FROM packing_template_items WHERE id = ?').get(req.params.itemId);
|
if ('error' in result) return res.status(result.status!).json({ error: result.error });
|
||||||
if (!item) return res.status(404).json({ error: 'Item not found' });
|
res.json(result);
|
||||||
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) });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/packing-templates/:templateId/items/:itemId', (_req: Request, res: Response) => {
|
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);
|
const result = svc.deleteTemplateItem(req.params.itemId);
|
||||||
if (!item) return res.status(404).json({ error: 'Item not found' });
|
if ('error' in result) return res.status(result.status!).json({ error: result.error });
|
||||||
db.prepare('DELETE FROM packing_template_items WHERE id = ?').run(_req.params.itemId);
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Addons ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
router.get('/addons', (_req: Request, res: Response) => {
|
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: svc.listAddons() });
|
||||||
res.json({ addons: addons.map(a => ({ ...a, enabled: !!a.enabled, config: JSON.parse(a.config || '{}') })) });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/addons/:id', (req: Request, res: Response) => {
|
router.put('/addons/:id', (req: Request, res: Response) => {
|
||||||
const addon = db.prepare('SELECT * FROM addons WHERE id = ?').get(req.params.id);
|
const result = svc.updateAddon(req.params.id, req.body);
|
||||||
if (!addon) return res.status(404).json({ error: 'Addon not found' });
|
if ('error' in result) return res.status(result.status!).json({ error: result.error });
|
||||||
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 authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
writeAudit({
|
writeAudit({
|
||||||
userId: authReq.user.id,
|
userId: authReq.user.id,
|
||||||
action: 'admin.addon_update',
|
action: 'admin.addon_update',
|
||||||
resource: String(req.params.id),
|
resource: String(req.params.id),
|
||||||
ip: getClientIp(req),
|
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) => {
|
// ── MCP Tokens ─────────────────────────────────────────────────────────────
|
||||||
const tokens = db.prepare(`
|
|
||||||
SELECT t.id, t.name, t.token_prefix, t.created_at, t.last_used_at, t.user_id, u.username
|
router.get('/mcp-tokens', (_req: Request, res: Response) => {
|
||||||
FROM mcp_tokens t
|
res.json({ tokens: svc.listMcpTokens() });
|
||||||
JOIN users u ON u.id = t.user_id
|
|
||||||
ORDER BY t.created_at DESC
|
|
||||||
`).all();
|
|
||||||
res.json({ tokens });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/mcp-tokens/:id', (req: Request, res: Response) => {
|
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;
|
const result = svc.deleteMcpToken(req.params.id);
|
||||||
if (!token) return res.status(404).json({ error: 'Token not found' });
|
if ('error' in result) return res.status(result.status!).json({ error: result.error });
|
||||||
db.prepare('DELETE FROM mcp_tokens WHERE id = ?').run(req.params.id);
|
|
||||||
revokeUserSessions(token.user_id);
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── JWT Rotation ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
router.post('/rotate-jwt-secret', (req: Request, res: Response) => {
|
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 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({
|
writeAudit({
|
||||||
user_id: authReq.user?.id ?? null,
|
user_id: authReq.user?.id ?? null,
|
||||||
username: authReq.user?.username ?? 'unknown',
|
username: authReq.user?.username ?? 'unknown',
|
||||||
|
|||||||
@@ -1,112 +1,33 @@
|
|||||||
import express, { Request, Response } from 'express';
|
import express, { Request, Response } from 'express';
|
||||||
import { db } from '../db/database';
|
|
||||||
import { authenticate } from '../middleware/auth';
|
import { authenticate } from '../middleware/auth';
|
||||||
import { requireTripAccess } from '../middleware/tripAccess';
|
import { requireTripAccess } from '../middleware/tripAccess';
|
||||||
import { broadcast } from '../websocket';
|
import { broadcast } from '../websocket';
|
||||||
import { loadTagsByPlaceIds, loadParticipantsByAssignmentIds, formatAssignmentWithPlace } from '../services/queryHelpers';
|
|
||||||
import { checkPermission } from '../services/permissions';
|
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 });
|
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) => {
|
router.get('/trips/:tripId/days/:dayId/assignments', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
||||||
const { tripId, dayId } = req.params;
|
const { tripId, dayId } = req.params;
|
||||||
|
|
||||||
const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
|
if (!dayExists(dayId, tripId)) return res.status(404).json({ error: 'Day not found' });
|
||||||
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] || []);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
const result = listDayAssignments(dayId);
|
||||||
res.json({ assignments: result });
|
res.json({ assignments: result });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -118,20 +39,10 @@ router.post('/trips/:tripId/days/:dayId/assignments', authenticate, requireTripA
|
|||||||
const { tripId, dayId } = req.params;
|
const { tripId, dayId } = req.params;
|
||||||
const { place_id, notes } = req.body;
|
const { place_id, notes } = req.body;
|
||||||
|
|
||||||
const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
|
if (!dayExists(dayId, tripId)) return res.status(404).json({ error: 'Day not found' });
|
||||||
if (!day) 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);
|
const assignment = createAssignment(dayId, place_id, notes);
|
||||||
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);
|
|
||||||
res.status(201).json({ assignment });
|
res.status(201).json({ assignment });
|
||||||
broadcast(tripId, 'assignment:created', { assignment }, req.headers['x-socket-id'] as string);
|
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 { tripId, dayId, id } = req.params;
|
||||||
|
|
||||||
const assignment = db.prepare(
|
if (!assignmentExistsInDay(id, dayId, tripId)) return res.status(404).json({ error: 'Assignment not found' });
|
||||||
'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 (!assignment) return res.status(404).json({ error: 'Assignment not found' });
|
deleteAssignment(id);
|
||||||
|
|
||||||
db.prepare('DELETE FROM day_assignments WHERE id = ?').run(id);
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
broadcast(tripId, 'assignment:deleted', { assignmentId: Number(id), dayId: Number(dayId) }, req.headers['x-socket-id'] as string);
|
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 { tripId, dayId } = req.params;
|
||||||
const { orderedIds } = req.body;
|
const { orderedIds } = req.body;
|
||||||
|
|
||||||
const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
|
if (!dayExists(dayId, tripId)) return res.status(404).json({ error: 'Day not found' });
|
||||||
if (!day) return res.status(404).json({ error: 'Day not found' });
|
|
||||||
|
|
||||||
const update = db.prepare('UPDATE day_assignments SET order_index = ? WHERE id = ? AND day_id = ?');
|
reorderAssignments(dayId, orderedIds);
|
||||||
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;
|
|
||||||
}
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
broadcast(tripId, 'assignment:reordered', { dayId: Number(dayId), orderedIds }, req.headers['x-socket-id'] as string);
|
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 { tripId, id } = req.params;
|
||||||
const { new_day_id, order_index } = req.body;
|
const { new_day_id, order_index } = req.body;
|
||||||
|
|
||||||
const assignment = db.prepare(`
|
const existing = getAssignmentForTrip(id, tripId);
|
||||||
SELECT da.* FROM day_assignments da
|
if (!existing) return res.status(404).json({ error: 'Assignment not found' });
|
||||||
JOIN days d ON da.day_id = d.id
|
|
||||||
WHERE da.id = ? AND d.trip_id = ?
|
|
||||||
`).get(id, tripId) as DayAssignment | undefined;
|
|
||||||
|
|
||||||
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);
|
const oldDayId = existing.day_id;
|
||||||
if (!newDay) return res.status(404).json({ error: 'Target day not found' });
|
const { assignment: updated } = moveAssignment(id, new_day_id, order_index, oldDayId);
|
||||||
|
|
||||||
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));
|
|
||||||
res.json({ assignment: updated });
|
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);
|
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) => {
|
router.get('/trips/:tripId/assignments/:id/participants', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
||||||
const { tripId, id } = req.params;
|
const { 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 participants = getParticipants(id);
|
||||||
res.json({ participants });
|
res.json({ participants });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -227,18 +109,11 @@ router.put('/trips/:tripId/assignments/:id/time', authenticate, requireTripAcces
|
|||||||
|
|
||||||
const { tripId, id } = req.params;
|
const { tripId, id } = req.params;
|
||||||
|
|
||||||
const assignment = db.prepare(`
|
const existing = getAssignmentForTrip(id, tripId);
|
||||||
SELECT da.* FROM day_assignments da
|
if (!existing) return res.status(404).json({ error: 'Assignment not found' });
|
||||||
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 { place_time, end_time } = req.body;
|
const { place_time, end_time } = req.body;
|
||||||
db.prepare('UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ? WHERE id = ?')
|
const updated = updateTime(id, place_time, end_time);
|
||||||
.run(place_time ?? null, end_time ?? null, id);
|
|
||||||
|
|
||||||
const updated = getAssignmentWithPlace(Number(id));
|
|
||||||
res.json({ assignment: updated });
|
res.json({ assignment: updated });
|
||||||
broadcast(Number(tripId), 'assignment:updated', { assignment: updated }, req.headers['x-socket-id'] as string);
|
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' });
|
return res.status(403).json({ error: 'No permission' });
|
||||||
|
|
||||||
const { tripId, id } = req.params;
|
const { tripId, id } = req.params;
|
||||||
|
|
||||||
const { user_ids } = req.body;
|
const { user_ids } = req.body;
|
||||||
if (!Array.isArray(user_ids)) return res.status(400).json({ error: 'user_ids must be an array' });
|
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);
|
const participants = setParticipants(id, user_ids);
|
||||||
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);
|
|
||||||
|
|
||||||
res.json({ participants });
|
res.json({ participants });
|
||||||
broadcast(Number(tripId), 'assignment:participants', { assignmentId: Number(id), participants }, req.headers['x-socket-id'] as string);
|
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 express, { Request, Response } from 'express';
|
||||||
import fetch from 'node-fetch';
|
|
||||||
import { db } from '../db/database';
|
|
||||||
import { authenticate } from '../middleware/auth';
|
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();
|
const router = express.Router();
|
||||||
router.use(authenticate);
|
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) => {
|
router.get('/stats', async (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const userId = (req as AuthRequest).user.id;
|
||||||
const userId = authReq.user.id;
|
const data = await getStats(userId);
|
||||||
|
res.json(data);
|
||||||
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,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/country/:code', (req: Request, res: Response) => {
|
router.get('/country/:code', (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const userId = (req as AuthRequest).user.id;
|
||||||
const userId = authReq.user.id;
|
|
||||||
const code = req.params.code.toUpperCase();
|
const code = req.params.code.toUpperCase();
|
||||||
|
res.json(getCountryPlaces(userId, code));
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mark/unmark country as visited
|
|
||||||
router.post('/country/:code/mark', (req: Request, res: Response) => {
|
router.post('/country/:code/mark', (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const userId = (req as AuthRequest).user.id;
|
||||||
db.prepare('INSERT OR IGNORE INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(authReq.user.id, req.params.code.toUpperCase());
|
markCountryVisited(userId, req.params.code.toUpperCase());
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/country/:code/mark', (req: Request, res: Response) => {
|
router.delete('/country/:code/mark', (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const userId = (req as AuthRequest).user.id;
|
||||||
db.prepare('DELETE FROM visited_countries WHERE user_id = ? AND country_code = ?').run(authReq.user.id, req.params.code.toUpperCase());
|
unmarkCountryVisited(userId, req.params.code.toUpperCase());
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Bucket List ─────────────────────────────────────────────────────────────
|
// ── Bucket List ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
router.get('/bucket-list', (req: Request, res: Response) => {
|
router.get('/bucket-list', (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const userId = (req as AuthRequest).user.id;
|
||||||
const items = db.prepare('SELECT * FROM bucket_list WHERE user_id = ? ORDER BY created_at DESC').all(authReq.user.id);
|
res.json({ items: listBucketList(userId) });
|
||||||
res.json({ items });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/bucket-list', (req: Request, res: Response) => {
|
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;
|
const { name, lat, lng, country_code, notes, target_date } = req.body;
|
||||||
if (!name?.trim()) return res.status(400).json({ error: 'Name is required' });
|
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(
|
const item = createBucketItem(userId, { name, lat, lng, country_code, notes, target_date });
|
||||||
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);
|
|
||||||
res.status(201).json({ item });
|
res.status(201).json({ item });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/bucket-list/:id', (req: Request, res: Response) => {
|
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 { 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' });
|
if (!item) return res.status(404).json({ error: 'Item not found' });
|
||||||
db.prepare(`UPDATE bucket_list SET
|
res.json({ item });
|
||||||
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) });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/bucket-list/:id', (req: Request, res: Response) => {
|
router.delete('/bucket-list/:id', (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const userId = (req as AuthRequest).user.id;
|
||||||
const item = db.prepare('SELECT * FROM bucket_list WHERE id = ? AND user_id = ?').get(req.params.id, authReq.user.id);
|
const deleted = deleteBucketItem(userId, req.params.id);
|
||||||
if (!item) return res.status(404).json({ error: 'Item not found' });
|
if (!deleted) return res.status(404).json({ error: 'Item not found' });
|
||||||
db.prepare('DELETE FROM bucket_list WHERE id = ?').run(req.params.id);
|
|
||||||
res.json({ success: true });
|
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 express, { Request, Response, NextFunction } from 'express';
|
||||||
import archiver from 'archiver';
|
|
||||||
import unzipper from 'unzipper';
|
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import path from 'path';
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import Database from 'better-sqlite3';
|
|
||||||
import { authenticate, adminOnly } from '../middleware/auth';
|
import { authenticate, adminOnly } from '../middleware/auth';
|
||||||
import * as scheduler from '../scheduler';
|
|
||||||
import { db, closeDb, reinitialize } from '../db/database';
|
|
||||||
import { AuthRequest } from '../types';
|
import { AuthRequest } from '../types';
|
||||||
import { writeAudit, getClientIp } from '../services/auditLog';
|
import { writeAudit, getClientIp } from '../services/auditLog';
|
||||||
|
import {
|
||||||
type RestoreAuditInfo = { userId: number; ip: string | null; source: 'backup.restore' | 'backup.upload_restore'; label: string };
|
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();
|
const router = express.Router();
|
||||||
|
|
||||||
router.use(authenticate, adminOnly);
|
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) {
|
function backupRateLimiter(maxAttempts: number, windowMs: number) {
|
||||||
return (req: Request, res: Response, next: NextFunction) => {
|
return (req: Request, res: Response, next: NextFunction) => {
|
||||||
const key = req.ip || 'unknown';
|
const key = req.ip || 'unknown';
|
||||||
const now = Date.now();
|
if (!checkRateLimit(key, maxAttempts, windowMs)) {
|
||||||
const record = backupAttempts.get(key);
|
|
||||||
if (record && record.count >= maxAttempts && now - record.first < windowMs) {
|
|
||||||
return res.status(429).json({ error: 'Too many backup requests. Please try again later.' });
|
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();
|
next();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const dataDir = path.join(__dirname, '../../data');
|
// ---------------------------------------------------------------------------
|
||||||
const backupsDir = path.join(dataDir, 'backups');
|
// Routes
|
||||||
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`;
|
|
||||||
}
|
|
||||||
|
|
||||||
router.get('/list', (_req: Request, res: Response) => {
|
router.get('/list', (_req: Request, res: Response) => {
|
||||||
ensureBackupsDir();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const files = fs.readdirSync(backupsDir)
|
res.json({ backups: listBackups() });
|
||||||
.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 });
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
res.status(500).json({ error: 'Error loading backups' });
|
res.status(500).json({ error: 'Error loading backups' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/create', backupRateLimiter(3, BACKUP_RATE_WINDOW), async (_req: Request, res: Response) => {
|
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);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {}
|
const backup = await createBackup();
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
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;
|
|
||||||
writeAudit({
|
writeAudit({
|
||||||
userId: authReq.user.id,
|
userId: authReq.user.id,
|
||||||
action: 'backup.create',
|
action: 'backup.create',
|
||||||
resource: filename,
|
resource: backup.filename,
|
||||||
ip: getClientIp(_req),
|
ip: getClientIp(req),
|
||||||
details: { size: stat.size },
|
details: { size: backup.size },
|
||||||
});
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
backup: {
|
|
||||||
filename,
|
|
||||||
size: stat.size,
|
|
||||||
sizeText: formatSize(stat.size),
|
|
||||||
created_at: stat.birthtime.toISOString(),
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
res.json({ success: true, backup });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('Backup error:', err);
|
|
||||||
if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath);
|
|
||||||
res.status(500).json({ error: 'Error creating backup' });
|
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) => {
|
router.get('/download/:filename', (req: Request, res: Response) => {
|
||||||
const { filename } = req.params;
|
const { filename } = req.params;
|
||||||
|
|
||||||
if (!/^backup-[\w\-]+\.zip$/.test(filename)) {
|
if (!isValidBackupFilename(filename)) {
|
||||||
return res.status(400).json({ error: 'Invalid filename' });
|
return res.status(400).json({ error: 'Invalid filename' });
|
||||||
}
|
}
|
||||||
|
if (!backupFileExists(filename)) {
|
||||||
const filePath = path.join(backupsDir, filename);
|
|
||||||
if (!fs.existsSync(filePath)) {
|
|
||||||
return res.status(404).json({ error: 'Backup not found' });
|
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) => {
|
router.post('/restore/:filename', async (req: Request, res: Response) => {
|
||||||
const { filename } = req.params;
|
const { filename } = req.params;
|
||||||
if (!/^backup-[\w\-]+\.zip$/.test(filename)) {
|
if (!isValidBackupFilename(filename)) {
|
||||||
return res.status(400).json({ error: 'Invalid filename' });
|
return res.status(400).json({ error: 'Invalid filename' });
|
||||||
}
|
}
|
||||||
const zipPath = path.join(backupsDir, filename);
|
const zipPath = backupFilePath(filename);
|
||||||
if (!fs.existsSync(zipPath)) {
|
if (!backupFileExists(filename)) {
|
||||||
return res.status(404).json({ error: 'Backup not found' });
|
return res.status(404).json({ error: 'Backup not found' });
|
||||||
}
|
}
|
||||||
const authReq = req as AuthRequest;
|
|
||||||
await restoreFromZip(zipPath, res, {
|
try {
|
||||||
userId: authReq.user.id,
|
const result = await restoreFromZip(zipPath);
|
||||||
ip: getClientIp(req),
|
if (!result.success) {
|
||||||
source: 'backup.restore',
|
return res.status(result.status || 400).json({ error: result.error });
|
||||||
label: filename,
|
}
|
||||||
});
|
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({
|
const uploadTmp = multer({
|
||||||
dest: path.join(dataDir, 'tmp/'),
|
dest: getUploadTmpDir(),
|
||||||
fileFilter: (_req, file, cb) => {
|
fileFilter: (_req, file, cb) => {
|
||||||
if (file.originalname.endsWith('.zip')) cb(null, true);
|
if (file.originalname.endsWith('.zip')) cb(null, true);
|
||||||
else cb(new Error('Only ZIP files allowed'));
|
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) => {
|
router.post('/upload-restore', uploadTmp.single('backup'), async (req: Request, res: Response) => {
|
||||||
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||||
const zipPath = req.file.path;
|
const zipPath = req.file.path;
|
||||||
const authReq = req as AuthRequest;
|
|
||||||
const origName = req.file.originalname || 'upload.zip';
|
const origName = req.file.originalname || 'upload.zip';
|
||||||
await restoreFromZip(zipPath, res, {
|
|
||||||
userId: authReq.user.id,
|
try {
|
||||||
ip: getClientIp(req),
|
const result = await restoreFromZip(zipPath);
|
||||||
source: 'backup.upload_restore',
|
if (!result.success) {
|
||||||
label: origName,
|
return res.status(result.status || 400).json({ error: result.error });
|
||||||
});
|
}
|
||||||
if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath);
|
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) => {
|
router.get('/auto-settings', (_req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const tz = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
const data = getAutoSettings();
|
||||||
res.json({ settings: scheduler.loadSettings(), timezone: tz });
|
res.json(data);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('[backup] GET auto-settings:', err);
|
console.error('[backup] GET auto-settings:', err);
|
||||||
res.status(500).json({ error: 'Could not load backup settings' });
|
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) => {
|
router.put('/auto-settings', (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const settings = parseAutoBackupBody((req.body || {}) as Record<string, unknown>);
|
const settings = updateAutoSettings((req.body || {}) as Record<string, unknown>);
|
||||||
scheduler.saveSettings(settings);
|
|
||||||
scheduler.start();
|
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
writeAudit({
|
writeAudit({
|
||||||
userId: authReq.user.id,
|
userId: authReq.user.id,
|
||||||
@@ -338,16 +176,14 @@ router.put('/auto-settings', (req: Request, res: Response) => {
|
|||||||
router.delete('/:filename', (req: Request, res: Response) => {
|
router.delete('/:filename', (req: Request, res: Response) => {
|
||||||
const { filename } = req.params;
|
const { filename } = req.params;
|
||||||
|
|
||||||
if (!/^backup-[\w\-]+\.zip$/.test(filename)) {
|
if (!isValidBackupFilename(filename)) {
|
||||||
return res.status(400).json({ error: 'Invalid filename' });
|
return res.status(400).json({ error: 'Invalid filename' });
|
||||||
}
|
}
|
||||||
|
if (!backupFileExists(filename)) {
|
||||||
const filePath = path.join(backupsDir, filename);
|
|
||||||
if (!fs.existsSync(filePath)) {
|
|
||||||
return res.status(404).json({ error: 'Backup not found' });
|
return res.status(404).json({ error: 'Backup not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.unlinkSync(filePath);
|
deleteBackup(filename);
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
writeAudit({
|
writeAudit({
|
||||||
userId: authReq.user.id,
|
userId: authReq.user.id,
|
||||||
|
|||||||
@@ -1,113 +1,56 @@
|
|||||||
import express, { Request, Response } from 'express';
|
import express, { Request, Response } from 'express';
|
||||||
import { db, canAccessTrip } from '../db/database';
|
|
||||||
import { authenticate } from '../middleware/auth';
|
import { authenticate } from '../middleware/auth';
|
||||||
import { broadcast } from '../websocket';
|
import { broadcast } from '../websocket';
|
||||||
import { checkPermission } from '../services/permissions';
|
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 });
|
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) => {
|
router.get('/', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { tripId } = req.params;
|
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 (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
const items = db.prepare(
|
res.json({ items: listBudgetItems(tripId) });
|
||||||
'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 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/summary/per-person', authenticate, (req: Request, res: Response) => {
|
router.get('/summary/per-person', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { tripId } = req.params;
|
const { tripId } = req.params;
|
||||||
if (!canAccessTrip(Number(tripId), authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
|
||||||
|
|
||||||
const summary = db.prepare(`
|
if (!verifyTripAccess(Number(tripId), authReq.user.id))
|
||||||
SELECT bm.user_id, u.username, u.avatar,
|
return res.status(404).json({ error: 'Trip not found' });
|
||||||
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 }[];
|
|
||||||
|
|
||||||
res.json({ summary: summary.map(s => ({ ...s, avatar_url: avatarUrl(s) })) });
|
res.json({ summary: getPerPersonSummary(tripId) });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/', authenticate, (req: Request, res: Response) => {
|
router.post('/', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { tripId } = req.params;
|
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 (!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))
|
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' });
|
return res.status(403).json({ error: 'No permission' });
|
||||||
|
|
||||||
|
const { name } = req.body;
|
||||||
if (!name) return res.status(400).json({ error: 'Name is required' });
|
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 item = createBudgetItem(tripId, req.body);
|
||||||
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 = [];
|
|
||||||
res.status(201).json({ item });
|
res.status(201).json({ item });
|
||||||
broadcast(tripId, 'budget:created', { item }, req.headers['x-socket-id'] as string);
|
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) => {
|
router.put('/:id', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { tripId, id } = req.params;
|
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 (!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))
|
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' });
|
return res.status(403).json({ error: 'No permission' });
|
||||||
|
|
||||||
const item = db.prepare('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
|
const updated = updateBudgetItem(id, tripId, req.body);
|
||||||
if (!item) return res.status(404).json({ error: 'Budget item not found' });
|
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 });
|
res.json({ item: updated });
|
||||||
broadcast(tripId, 'budget:updated', { item: updated }, req.headers['x-socket-id'] as string);
|
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) => {
|
router.put('/:id/members', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { tripId, id } = req.params;
|
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 (!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))
|
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' });
|
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;
|
const { user_ids } = req.body;
|
||||||
if (!Array.isArray(user_ids)) return res.status(400).json({ error: 'user_ids must be an array' });
|
if (!Array.isArray(user_ids)) return res.status(400).json({ error: 'user_ids must be an array' });
|
||||||
|
|
||||||
const existingPaid: Record<number, number> = {};
|
const result = updateMembers(id, tripId, user_ids);
|
||||||
const existing = db.prepare('SELECT user_id, paid FROM budget_item_members WHERE budget_item_id = ?').all(id) as { user_id: number; paid: number }[];
|
if (!result) return res.status(404).json({ error: 'Budget item not found' });
|
||||||
for (const e of existing) existingPaid[e.user_id] = e.paid;
|
|
||||||
|
|
||||||
db.prepare('DELETE FROM budget_item_members WHERE budget_item_id = ?').run(id);
|
res.json({ members: result.members, item: result.item });
|
||||||
if (user_ids.length > 0) {
|
broadcast(Number(tripId), 'budget:members-updated', { itemId: Number(id), members: result.members, persons: result.item.persons }, req.headers['x-socket-id'] as string);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/:id/members/:userId/paid', authenticate, (req: Request, res: Response) => {
|
router.put('/:id/members/:userId/paid', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { tripId, id, userId } = req.params;
|
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 (!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))
|
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' });
|
return res.status(403).json({ error: 'No permission' });
|
||||||
|
|
||||||
const { paid } = req.body;
|
const { paid } = req.body;
|
||||||
db.prepare('UPDATE budget_item_members SET paid = ? WHERE budget_item_id = ? AND user_id = ?')
|
const member = toggleMemberPaid(id, userId, paid);
|
||||||
.run(paid ? 1 : 0, id, userId);
|
res.json({ member });
|
||||||
|
|
||||||
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 });
|
|
||||||
broadcast(Number(tripId), 'budget:member-paid-updated', { itemId: Number(id), userId: Number(userId), paid: paid ? 1 : 0 }, req.headers['x-socket-id'] as string);
|
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) => {
|
router.get('/settlement', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { tripId } = req.params;
|
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[];
|
if (!verifyTripAccess(Number(tripId), authReq.user.id))
|
||||||
const allMembers = db.prepare(`
|
return res.status(404).json({ error: 'Trip not found' });
|
||||||
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
|
res.json(calculateSettlement(tripId));
|
||||||
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,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/:id', authenticate, (req: Request, res: Response) => {
|
router.delete('/:id', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { tripId, id } = req.params;
|
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 (!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))
|
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' });
|
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 (!deleteBudgetItem(id, tripId))
|
||||||
if (!item) return res.status(404).json({ error: 'Budget item not found' });
|
return res.status(404).json({ error: 'Budget item not found' });
|
||||||
|
|
||||||
db.prepare('DELETE FROM budget_items WHERE id = ?').run(id);
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
broadcast(tripId, 'budget:deleted', { itemId: Number(id) }, req.headers['x-socket-id'] as string);
|
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 express, { Request, Response } from 'express';
|
||||||
import { db } from '../db/database';
|
|
||||||
import { authenticate, adminOnly } from '../middleware/auth';
|
import { authenticate, adminOnly } from '../middleware/auth';
|
||||||
import { AuthRequest } from '../types';
|
import { AuthRequest } from '../types';
|
||||||
|
import * as categoryService from '../services/categoryService';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.get('/', authenticate, (_req: Request, res: Response) => {
|
router.get('/', authenticate, (_req: Request, res: Response) => {
|
||||||
const categories = db.prepare(
|
res.json({ categories: categoryService.listCategories() });
|
||||||
'SELECT * FROM categories ORDER BY name ASC'
|
|
||||||
).all();
|
|
||||||
res.json({ categories });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/', authenticate, adminOnly, (req: Request, res: Response) => {
|
router.post('/', authenticate, adminOnly, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { name, color, icon } = req.body;
|
const { name, color, icon } = req.body;
|
||||||
|
|
||||||
if (!name) return res.status(400).json({ error: 'Category name is required' });
|
if (!name) return res.status(400).json({ error: 'Category name is required' });
|
||||||
|
const category = categoryService.createCategory(authReq.user.id, name, color, icon);
|
||||||
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);
|
|
||||||
res.status(201).json({ category });
|
res.status(201).json({ category });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/:id', authenticate, adminOnly, (req: Request, res: Response) => {
|
router.put('/:id', authenticate, adminOnly, (req: Request, res: Response) => {
|
||||||
const { name, color, icon } = req.body;
|
const { name, color, icon } = req.body;
|
||||||
const category = db.prepare('SELECT * FROM categories WHERE id = ?').get(req.params.id);
|
if (!categoryService.getCategoryById(req.params.id))
|
||||||
|
return res.status(404).json({ error: 'Category not found' });
|
||||||
if (!category) return res.status(404).json({ error: 'Category not found' });
|
const category = categoryService.updateCategory(req.params.id, name, color, icon);
|
||||||
|
res.json({ category });
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/:id', authenticate, adminOnly, (req: Request, res: Response) => {
|
router.delete('/:id', authenticate, adminOnly, (req: Request, res: Response) => {
|
||||||
const category = db.prepare('SELECT * FROM categories WHERE id = ?').get(req.params.id);
|
if (!categoryService.getCategoryById(req.params.id))
|
||||||
|
return res.status(404).json({ error: 'Category not found' });
|
||||||
if (!category) return res.status(404).json({ error: 'Category not found' });
|
categoryService.deleteCategory(req.params.id);
|
||||||
|
|
||||||
db.prepare('DELETE FROM categories WHERE id = ?').run(req.params.id);
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,35 +3,32 @@ import multer from 'multer';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { db, canAccessTrip } from '../db/database';
|
|
||||||
import { authenticate } from '../middleware/auth';
|
import { authenticate } from '../middleware/auth';
|
||||||
import { broadcast } from '../websocket';
|
import { broadcast } from '../websocket';
|
||||||
import { validateStringLengths } from '../middleware/validate';
|
import { validateStringLengths } from '../middleware/validate';
|
||||||
import { checkPermission } from '../services/permissions';
|
import { checkPermission } from '../services/permissions';
|
||||||
import { AuthRequest, CollabNote, CollabPoll, CollabMessage, TripFile } from '../types';
|
import { AuthRequest } from '../types';
|
||||||
import { checkSsrf, createPinnedAgent } from '../utils/ssrfGuard';
|
import { db } from '../db/database';
|
||||||
|
import {
|
||||||
interface ReactionRow {
|
verifyTripAccess,
|
||||||
emoji: string;
|
listNotes,
|
||||||
user_id: number;
|
createNote,
|
||||||
username: string;
|
updateNote,
|
||||||
message_id?: number;
|
deleteNote,
|
||||||
}
|
addNoteFile,
|
||||||
|
getFormattedNoteById,
|
||||||
interface PollVoteRow {
|
deleteNoteFile,
|
||||||
option_index: number;
|
listPolls,
|
||||||
user_id: number;
|
createPoll,
|
||||||
username: string;
|
votePoll,
|
||||||
avatar: string | null;
|
closePoll,
|
||||||
}
|
deletePoll,
|
||||||
|
listMessages,
|
||||||
interface NoteFileRow {
|
createMessage,
|
||||||
id: number;
|
deleteMessage,
|
||||||
filename: string;
|
addOrRemoveReaction,
|
||||||
original_name?: string;
|
fetchLinkPreview,
|
||||||
file_size?: number;
|
} from '../services/collabService';
|
||||||
mime_type?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_NOTE_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
|
const MAX_NOTE_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
|
||||||
const filesDir = path.join(__dirname, '../../uploads/files');
|
const filesDir = path.join(__dirname, '../../uploads/files');
|
||||||
@@ -54,59 +51,16 @@ const noteUpload = multer({
|
|||||||
|
|
||||||
const router = express.Router({ mergeParams: true });
|
const router = express.Router({ mergeParams: true });
|
||||||
|
|
||||||
function verifyTripAccess(tripId: string | number, userId: number) {
|
/* ------------------------------------------------------------------ */
|
||||||
return canAccessTrip(tripId, userId);
|
/* Notes */
|
||||||
}
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
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 || [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
router.get('/notes', authenticate, (req: Request, res: Response) => {
|
router.get('/notes', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { tripId } = req.params;
|
const { tripId } = req.params;
|
||||||
if (!verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
if (!verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
const notes = db.prepare(`
|
res.json({ notes: listNotes(tripId) });
|
||||||
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) });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/notes', authenticate, (req: Request, res: Response) => {
|
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' });
|
return res.status(403).json({ error: 'No permission' });
|
||||||
if (!title) return res.status(400).json({ error: 'Title is required' });
|
if (!title) return res.status(400).json({ error: 'Title is required' });
|
||||||
|
|
||||||
const result = db.prepare(`
|
const formatted = createNote(tripId, authReq.user.id, { title, content, category, color, website });
|
||||||
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);
|
|
||||||
res.status(201).json({ note: formatted });
|
res.status(201).json({ note: formatted });
|
||||||
broadcast(tripId, 'collab:note:created', { note: formatted }, req.headers['x-socket-id'] as string);
|
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))
|
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' });
|
return res.status(403).json({ error: 'No permission' });
|
||||||
|
|
||||||
const existing = db.prepare('SELECT * FROM collab_notes WHERE id = ? AND trip_id = ?').get(id, tripId);
|
const formatted = updateNote(tripId, id, { title, content, category, color, pinned, website });
|
||||||
if (!existing) return res.status(404).json({ error: 'Note not found' });
|
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 });
|
res.json({ note: formatted });
|
||||||
broadcast(tripId, 'collab:note:updated', { note: formatted }, req.headers['x-socket-id'] as string);
|
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))
|
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' });
|
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 (!deleteNote(tripId, id)) return res.status(404).json({ error: 'Note not found' });
|
||||||
if (!existing) 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 });
|
res.json({ success: true });
|
||||||
broadcast(tripId, 'collab:note:deleted', { noteId: Number(id) }, req.headers['x-socket-id'] as string);
|
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) => {
|
router.post('/notes/:id/files', authenticate, noteUpload.single('file'), (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { tripId, id } = req.params;
|
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' });
|
return res.status(403).json({ error: 'No permission to upload files' });
|
||||||
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
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);
|
const result = addNoteFile(tripId, id, req.file);
|
||||||
if (!note) return res.status(404).json({ error: 'Note not found' });
|
if (!result) return res.status(404).json({ error: 'Note not found' });
|
||||||
|
|
||||||
const result = db.prepare(
|
res.status(201).json(result);
|
||||||
'INSERT INTO trip_files (trip_id, note_id, filename, original_name, file_size, mime_type) VALUES (?, ?, ?, ?, ?, ?)'
|
broadcast(Number(tripId), 'collab:note:updated', { note: getFormattedNoteById(id) }, req.headers['x-socket-id'] as string);
|
||||||
).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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/notes/:id/files/:fileId', authenticate, (req: Request, res: Response) => {
|
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))
|
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' });
|
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 (!deleteNoteFile(id, fileId)) return res.status(404).json({ error: 'File not found' });
|
||||||
if (!file) 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 });
|
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(`
|
/* Polls */
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
router.get('/polls', authenticate, (req: Request, res: Response) => {
|
router.get('/polls', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { tripId } = req.params;
|
const { tripId } = req.params;
|
||||||
if (!verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
if (!verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
const rows = db.prepare(`
|
res.json({ polls: listPolls(tripId) });
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/polls', authenticate, (req: Request, res: Response) => {
|
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' });
|
return res.status(400).json({ error: 'At least 2 options are required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const isMultiple = multiple || multiple_choice;
|
const poll = createPoll(tripId, authReq.user.id, { question, options, multiple, multiple_choice, deadline });
|
||||||
|
|
||||||
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);
|
|
||||||
res.status(201).json({ poll });
|
res.status(201).json({ poll });
|
||||||
broadcast(tripId, 'collab:poll:created', { poll }, req.headers['x-socket-id'] as string);
|
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))
|
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' });
|
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;
|
const result = votePoll(tripId, id, authReq.user.id, option_index);
|
||||||
if (!poll) return res.status(404).json({ error: 'Poll not found' });
|
if (result.error === 'not_found') return res.status(404).json({ error: 'Poll not found' });
|
||||||
if (poll.closed) return res.status(400).json({ error: 'Poll is closed' });
|
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);
|
res.json({ poll: result.poll });
|
||||||
if (option_index < 0 || option_index >= options.length) {
|
broadcast(tripId, 'collab:poll:voted', { poll: result.poll }, req.headers['x-socket-id'] as string);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/polls/:id/close', authenticate, (req: Request, res: Response) => {
|
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))
|
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' });
|
return res.status(403).json({ error: 'No permission' });
|
||||||
|
|
||||||
const poll = db.prepare('SELECT * FROM collab_polls WHERE id = ? AND trip_id = ?').get(id, tripId);
|
const updatedPoll = closePoll(tripId, id);
|
||||||
if (!poll) return res.status(404).json({ error: 'Poll not found' });
|
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 });
|
res.json({ poll: updatedPoll });
|
||||||
broadcast(tripId, 'collab:poll:closed', { poll: updatedPoll }, req.headers['x-socket-id'] as string);
|
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))
|
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' });
|
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 (!deletePoll(tripId, id)) return res.status(404).json({ error: 'Poll not found' });
|
||||||
if (!poll) return res.status(404).json({ error: 'Poll not found' });
|
|
||||||
|
|
||||||
db.prepare('DELETE FROM collab_polls WHERE id = ?').run(id);
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
broadcast(tripId, 'collab:poll:deleted', { pollId: Number(id) }, req.headers['x-socket-id'] as string);
|
broadcast(tripId, 'collab:poll:deleted', { pollId: Number(id) }, req.headers['x-socket-id'] as string);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Messages */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
router.get('/messages', authenticate, (req: Request, res: Response) => {
|
router.get('/messages', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { tripId } = req.params;
|
const { tripId } = req.params;
|
||||||
const { before } = req.query;
|
const { before } = req.query;
|
||||||
if (!verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
if (!verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
const query = `
|
res.json({ messages: listMessages(tripId, before as string | undefined) });
|
||||||
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] || []))) });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/messages', authenticate, validateStringLengths({ text: 5000 }), (req: Request, res: Response) => {
|
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' });
|
return res.status(403).json({ error: 'No permission' });
|
||||||
if (!text || !text.trim()) return res.status(400).json({ error: 'Message text is required' });
|
if (!text || !text.trim()) return res.status(400).json({ error: 'Message text is required' });
|
||||||
|
|
||||||
if (reply_to) {
|
const result = createMessage(tripId, authReq.user.id, text, reply_to);
|
||||||
const replyMsg = db.prepare('SELECT id FROM collab_messages WHERE id = ? AND trip_id = ?').get(reply_to, tripId);
|
if (result.error === 'reply_not_found') return res.status(400).json({ error: 'Reply target message not found' });
|
||||||
if (!replyMsg) return res.status(400).json({ error: 'Reply target message not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = db.prepare(`
|
res.status(201).json({ message: result.message });
|
||||||
INSERT INTO collab_messages (trip_id, user_id, text, reply_to) VALUES (?, ?, ?, ?)
|
broadcast(tripId, 'collab:message:created', { message: result.message }, req.headers['x-socket-id'] as string);
|
||||||
`).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);
|
|
||||||
|
|
||||||
// Notify trip members about new chat message
|
// Notify trip members about new chat message
|
||||||
import('../services/notifications').then(({ notifyTripMembers }) => {
|
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) => {
|
router.post('/messages/:id/react', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { tripId, id } = req.params;
|
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' });
|
return res.status(403).json({ error: 'No permission' });
|
||||||
if (!emoji) return res.status(400).json({ error: 'Emoji is required' });
|
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);
|
const result = addOrRemoveReaction(id, tripId, authReq.user.id, emoji);
|
||||||
if (!msg) return res.status(404).json({ error: 'Message not found' });
|
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;
|
res.json({ reactions: result.reactions });
|
||||||
if (existing) {
|
broadcast(Number(tripId), 'collab:message:reacted', { messageId: Number(id), reactions: result.reactions }, req.headers['x-socket-id'] as string);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Delete message */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
router.delete('/messages/:id', authenticate, (req: Request, res: Response) => {
|
router.delete('/messages/:id', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { tripId, id } = req.params;
|
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))
|
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' });
|
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;
|
const result = deleteMessage(tripId, id, authReq.user.id);
|
||||||
if (!message) return res.status(404).json({ error: 'Message not found' });
|
if (result.error === 'not_found') 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' });
|
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 });
|
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) => {
|
router.get('/link-preview', authenticate, async (req: Request, res: Response) => {
|
||||||
const { url } = req.query as { url?: string };
|
const { url } = req.query as { url?: string };
|
||||||
if (!url) return res.status(400).json({ error: 'URL is required' });
|
if (!url) return res.status(400).json({ error: 'URL is required' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(url);
|
const preview = await fetchLinkPreview(url);
|
||||||
const ssrf = await checkSsrf(url);
|
const asAny = preview as any;
|
||||||
if (!ssrf.allowed) {
|
if (asAny.error) return res.status(400).json({ error: asAny.error });
|
||||||
return res.status(400).json({ error: ssrf.error });
|
res.json(preview);
|
||||||
}
|
|
||||||
|
|
||||||
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 });
|
|
||||||
});
|
|
||||||
} catch {
|
} catch {
|
||||||
res.json({ title: null, description: null, image: null, url });
|
res.json({ title: null, description: null, image: null, url });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +1,33 @@
|
|||||||
import express, { Request, Response } from 'express';
|
import express, { Request, Response } from 'express';
|
||||||
import { db, canAccessTrip } from '../db/database';
|
|
||||||
import { authenticate } from '../middleware/auth';
|
import { authenticate } from '../middleware/auth';
|
||||||
import { broadcast } from '../websocket';
|
import { broadcast } from '../websocket';
|
||||||
import { validateStringLengths } from '../middleware/validate';
|
import { validateStringLengths } from '../middleware/validate';
|
||||||
import { checkPermission } from '../services/permissions';
|
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 });
|
const router = express.Router({ mergeParams: true });
|
||||||
|
|
||||||
function verifyAccess(tripId: string | number, userId: number) {
|
|
||||||
return canAccessTrip(tripId, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
router.get('/', authenticate, (req: Request, res: Response) => {
|
router.get('/', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { tripId, dayId } = req.params;
|
const { tripId, dayId } = req.params;
|
||||||
if (!verifyAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
if (!dayNoteService.verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
res.json({ notes: dayNoteService.listNotes(dayId, tripId) });
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/', authenticate, validateStringLengths({ text: 500, time: 150 }), (req: Request, res: Response) => {
|
router.post('/', authenticate, validateStringLengths({ text: 500, time: 150 }), (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { tripId, dayId } = req.params;
|
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 (!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))
|
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' });
|
return res.status(403).json({ error: 'No permission' });
|
||||||
|
if (!dayNoteService.dayExists(dayId, tripId)) return res.status(404).json({ error: 'Day not found' });
|
||||||
const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
|
|
||||||
if (!day) return res.status(404).json({ error: 'Day not found' });
|
|
||||||
|
|
||||||
const { text, time, icon, sort_order } = req.body;
|
const { text, time, icon, sort_order } = req.body;
|
||||||
if (!text?.trim()) return res.status(400).json({ error: 'Text required' });
|
if (!text?.trim()) return res.status(400).json({ error: 'Text required' });
|
||||||
|
|
||||||
const result = db.prepare(
|
const note = dayNoteService.createNote(dayId, tripId, text, time, icon, sort_order);
|
||||||
'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);
|
|
||||||
res.status(201).json({ note });
|
res.status(201).json({ note });
|
||||||
broadcast(tripId, 'dayNote:created', { dayId: Number(dayId), note }, req.headers['x-socket-id'] as string);
|
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) => {
|
router.put('/:id', authenticate, validateStringLengths({ text: 500, time: 150 }), (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { tripId, dayId, id } = req.params;
|
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 (!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))
|
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' });
|
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;
|
const current = dayNoteService.getNote(id, dayId, tripId);
|
||||||
if (!note) return res.status(404).json({ error: 'Note not found' });
|
if (!current) return res.status(404).json({ error: 'Note not found' });
|
||||||
|
|
||||||
const { text, time, icon, sort_order } = req.body;
|
const { text, time, icon, sort_order } = req.body;
|
||||||
db.prepare(
|
const updated = dayNoteService.updateNote(id, current, { text, time, icon, sort_order });
|
||||||
'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);
|
|
||||||
res.json({ note: updated });
|
res.json({ note: updated });
|
||||||
broadcast(tripId, 'dayNote:updated', { dayId: Number(dayId), note: updated }, req.headers['x-socket-id'] as string);
|
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) => {
|
router.delete('/:id', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { tripId, dayId, id } = req.params;
|
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 (!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))
|
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' });
|
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 (!dayNoteService.getNote(id, dayId, tripId)) return res.status(404).json({ error: 'Note not found' });
|
||||||
if (!note) return res.status(404).json({ error: 'Note not found' });
|
dayNoteService.deleteNote(id);
|
||||||
|
|
||||||
db.prepare('DELETE FROM day_notes WHERE id = ?').run(id);
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
broadcast(tripId, 'dayNote:deleted', { noteId: Number(id), dayId: Number(dayId) }, req.headers['x-socket-id'] as string);
|
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 express, { Request, Response } from 'express';
|
||||||
import { db } from '../db/database';
|
|
||||||
import { authenticate } from '../middleware/auth';
|
import { authenticate } from '../middleware/auth';
|
||||||
import { requireTripAccess } from '../middleware/tripAccess';
|
import { requireTripAccess } from '../middleware/tripAccess';
|
||||||
import { broadcast } from '../websocket';
|
import { broadcast } from '../websocket';
|
||||||
import { loadTagsByPlaceIds, loadParticipantsByAssignmentIds, formatAssignmentWithPlace } from '../services/queryHelpers';
|
|
||||||
import { checkPermission } from '../services/permissions';
|
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 });
|
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) => {
|
router.get('/', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
||||||
const { tripId } = req.params;
|
const { tripId } = req.params;
|
||||||
|
res.json(dayService.listDays(tripId));
|
||||||
const days = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(tripId) as Day[];
|
|
||||||
|
|
||||||
if (days.length === 0) {
|
|
||||||
return res.json({ days: [] });
|
|
||||||
}
|
|
||||||
|
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
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 { tripId } = req.params;
|
||||||
const { date, notes } = req.body;
|
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 day = dayService.createDay(tripId, date, notes);
|
||||||
const dayNumber = (maxDay.max || 0) + 1;
|
res.status(201).json({ day });
|
||||||
|
broadcast(tripId, 'day:created', { day }, req.headers['x-socket-id'] as string);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
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 { tripId, id } = req.params;
|
||||||
|
|
||||||
const day = db.prepare('SELECT * FROM days WHERE id = ? AND trip_id = ?').get(id, tripId) as Day | undefined;
|
const current = dayService.getDay(id, tripId);
|
||||||
if (!day) {
|
if (!current) return res.status(404).json({ error: 'Day not found' });
|
||||||
return res.status(404).json({ error: 'Day not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { notes, title } = req.body;
|
const { notes, title } = req.body;
|
||||||
db.prepare('UPDATE days SET notes = ?, title = ? WHERE id = ?').run(notes || null, title !== undefined ? title : day.title, id);
|
const day = dayService.updateDay(id, current, { notes, title });
|
||||||
|
res.json({ day });
|
||||||
const updatedDay = db.prepare('SELECT * FROM days WHERE id = ?').get(id) as Day;
|
broadcast(tripId, 'day:updated', { day }, req.headers['x-socket-id'] as string);
|
||||||
const dayWithAssignments = { ...updatedDay, assignments: getAssignmentsForDay(id) };
|
|
||||||
res.json({ day: dayWithAssignments });
|
|
||||||
broadcast(tripId, 'day:updated', { day: dayWithAssignments }, req.headers['x-socket-id'] as string);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
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 { tripId, id } = req.params;
|
||||||
|
|
||||||
const day = db.prepare('SELECT * FROM days WHERE id = ? AND trip_id = ?').get(id, tripId);
|
if (!dayService.getDay(id, tripId)) return res.status(404).json({ error: 'Day not found' });
|
||||||
if (!day) {
|
|
||||||
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 });
|
res.json({ success: true });
|
||||||
broadcast(tripId, 'day:deleted', { dayId: Number(id) }, req.headers['x-socket-id'] as string);
|
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) {
|
const accommodationsRouter = express.Router({ mergeParams: true });
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
accommodationsRouter.get('/', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
accommodationsRouter.get('/', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
||||||
const { tripId } = req.params;
|
const { tripId } = req.params;
|
||||||
|
res.json({ accommodations: dayService.listAccommodations(tripId) });
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
accommodationsRouter.post('/', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
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' });
|
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);
|
const errors = dayService.validateAccommodationRefs(tripId, place_id, start_day_id, end_day_id);
|
||||||
if (!place) return res.status(404).json({ error: 'Place not found' });
|
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);
|
const accommodation = dayService.createAccommodation(tripId, { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes });
|
||||||
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);
|
|
||||||
res.status(201).json({ accommodation });
|
res.status(201).json({ accommodation });
|
||||||
broadcast(tripId, 'accommodation:created', { accommodation }, req.headers['x-socket-id'] as string);
|
broadcast(tripId, 'accommodation:created', { accommodation }, req.headers['x-socket-id'] as string);
|
||||||
broadcast(tripId, 'reservation:created', {}, 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;
|
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 = dayService.getAccommodation(id, tripId);
|
||||||
const existing = db.prepare('SELECT * FROM day_accommodations WHERE id = ? AND trip_id = ?').get(id, tripId) as DayAccommodation | undefined;
|
|
||||||
if (!existing) return res.status(404).json({ error: 'Accommodation not found' });
|
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 { 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 errors = dayService.validateAccommodationRefs(tripId, place_id, start_day_id, end_day_id);
|
||||||
const newStartDayId = start_day_id !== undefined ? start_day_id : existing.start_day_id;
|
if (errors.length > 0) return res.status(404).json({ error: errors[0].message });
|
||||||
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;
|
|
||||||
|
|
||||||
if (place_id !== undefined) {
|
const accommodation = dayService.updateAccommodation(id, existing, { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes });
|
||||||
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));
|
|
||||||
res.json({ accommodation });
|
res.json({ accommodation });
|
||||||
broadcast(tripId, 'accommodation:updated', { accommodation }, req.headers['x-socket-id'] as string);
|
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 { tripId, id } = req.params;
|
||||||
|
|
||||||
const existing = db.prepare('SELECT * FROM day_accommodations WHERE id = ? AND trip_id = ?').get(id, tripId);
|
if (!dayService.getAccommodation(id, tripId)) return res.status(404).json({ error: 'Accommodation not found' });
|
||||||
if (!existing) return res.status(404).json({ error: 'Accommodation not found' });
|
|
||||||
|
|
||||||
// Delete linked reservation
|
const { linkedReservationId } = dayService.deleteAccommodation(id);
|
||||||
const linkedRes = db.prepare('SELECT id FROM reservations WHERE accommodation_id = ?').get(Number(id)) as { id: number } | undefined;
|
if (linkedReservationId) {
|
||||||
if (linkedRes) {
|
broadcast(tripId, 'reservation:deleted', { reservationId: linkedReservationId }, req.headers['x-socket-id'] as string);
|
||||||
db.prepare('DELETE FROM reservations WHERE id = ?').run(linkedRes.id);
|
|
||||||
broadcast(tripId, 'reservation:deleted', { reservationId: linkedRes.id }, req.headers['x-socket-id'] as string);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
db.prepare('DELETE FROM day_accommodations WHERE id = ?').run(id);
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
broadcast(tripId, 'accommodation:deleted', { accommodationId: Number(id) }, req.headers['x-socket-id'] as string);
|
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 path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
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 { authenticate, demoUploadBlock } from '../middleware/auth';
|
||||||
import { requireTripAccess } from '../middleware/tripAccess';
|
import { requireTripAccess } from '../middleware/tripAccess';
|
||||||
import { broadcast } from '../websocket';
|
import { broadcast } from '../websocket';
|
||||||
import { AuthRequest, TripFile } from '../types';
|
import { AuthRequest } from '../types';
|
||||||
import { checkPermission } from '../services/permissions';
|
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 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({
|
const storage = multer.diskStorage({
|
||||||
destination: (_req, _file, cb) => {
|
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({
|
const upload = multer({
|
||||||
storage,
|
storage,
|
||||||
limits: { fileSize: MAX_FILE_SIZE },
|
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 = `
|
// Authenticated file download (supports Bearer header or ?token= query param)
|
||||||
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)
|
|
||||||
router.get('/:id/download', (req: Request, res: Response) => {
|
router.get('/:id/download', (req: Request, res: Response) => {
|
||||||
const { tripId, id } = req.params;
|
const { tripId, id } = req.params;
|
||||||
|
|
||||||
// Accept token from Authorization header (JWT) or query parameter (ephemeral token)
|
|
||||||
const authHeader = req.headers['authorization'];
|
const authHeader = req.headers['authorization'];
|
||||||
const bearerToken = authHeader && authHeader.split(' ')[1];
|
const bearerToken = authHeader && authHeader.split(' ')[1];
|
||||||
const queryToken = req.query.token as string | undefined;
|
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;
|
const trip = verifyTripAccess(tripId, auth.userId);
|
||||||
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);
|
|
||||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
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' });
|
if (!file) return res.status(404).json({ error: 'File not found' });
|
||||||
|
|
||||||
const safeName = path.basename(file.filename);
|
const { resolved, safe } = resolveFilePath(file.filename);
|
||||||
const filePath = path.join(filesDir, safeName);
|
if (!safe) return res.status(403).json({ error: 'Forbidden' });
|
||||||
const resolved = path.resolve(filePath);
|
|
||||||
if (!resolved.startsWith(path.resolve(filesDir))) {
|
|
||||||
return res.status(403).json({ error: 'Forbidden' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fs.existsSync(resolved)) return res.status(404).json({ error: 'File not found' });
|
if (!fs.existsSync(resolved)) return res.status(404).json({ error: 'File not found' });
|
||||||
|
|
||||||
res.sendFile(resolved);
|
res.sendFile(resolved);
|
||||||
});
|
});
|
||||||
|
|
||||||
// List files (excludes soft-deleted by default)
|
// 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) => {
|
router.get('/', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { tripId } = req.params;
|
const { tripId } = req.params;
|
||||||
const showTrash = req.query.trash === 'true';
|
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' });
|
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';
|
res.json({ files: listFiles(tripId, showTrash) });
|
||||||
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),
|
|
||||||
};
|
|
||||||
})});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Upload file
|
// Upload file
|
||||||
@@ -170,30 +116,13 @@ router.post('/', authenticate, requireTripAccess, demoUploadBlock, upload.single
|
|||||||
const { user_id: tripOwnerId } = authReq.trip!;
|
const { user_id: tripOwnerId } = authReq.trip!;
|
||||||
if (!checkPermission('file_upload', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id))
|
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' });
|
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;
|
const { place_id, description, reservation_id } = req.body;
|
||||||
|
const created = createFile(tripId, req.file, authReq.user.id, { place_id, description, reservation_id });
|
||||||
if (!req.file) {
|
res.status(201).json({ file: created });
|
||||||
return res.status(400).json({ error: 'No file uploaded' });
|
broadcast(tripId, 'file:created', { file: created }, req.headers['x-socket-id'] as string);
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update file metadata
|
// Update file metadata
|
||||||
@@ -202,30 +131,17 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
|
|||||||
const { tripId, id } = req.params;
|
const { tripId, id } = req.params;
|
||||||
const { description, place_id, reservation_id } = req.body;
|
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 (!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))
|
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' });
|
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' });
|
if (!file) return res.status(404).json({ error: 'File not found' });
|
||||||
|
|
||||||
db.prepare(`
|
const updated = updateFile(id, file, { description, place_id, reservation_id });
|
||||||
UPDATE trip_files SET
|
res.json({ file: updated });
|
||||||
description = ?,
|
broadcast(tripId, 'file:updated', { file: updated }, req.headers['x-socket-id'] as string);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Toggle starred
|
// Toggle starred
|
||||||
@@ -233,20 +149,17 @@ router.patch('/:id/star', authenticate, (req: Request, res: Response) => {
|
|||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { tripId, id } = req.params;
|
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 (!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))
|
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' });
|
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' });
|
if (!file) return res.status(404).json({ error: 'File not found' });
|
||||||
|
|
||||||
const newStarred = file.starred ? 0 : 1;
|
const updated = toggleStarred(id, file.starred);
|
||||||
db.prepare('UPDATE trip_files SET starred = ? WHERE id = ?').run(newStarred, id);
|
res.json({ file: updated });
|
||||||
|
broadcast(tripId, 'file:updated', { file: updated }, req.headers['x-socket-id'] as string);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Soft-delete (move to trash)
|
// Soft-delete (move to trash)
|
||||||
@@ -254,15 +167,15 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => {
|
|||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { tripId, id } = req.params;
|
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 (!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))
|
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' });
|
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' });
|
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 });
|
res.json({ success: true });
|
||||||
broadcast(tripId, 'file:deleted', { fileId: Number(id) }, req.headers['x-socket-id'] as string);
|
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 authReq = req as AuthRequest;
|
||||||
const { tripId, id } = req.params;
|
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 (!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))
|
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' });
|
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' });
|
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 = restoreFile(id);
|
||||||
|
res.json({ file: restored });
|
||||||
const restored = db.prepare(`${FILE_SELECT} WHERE f.id = ?`).get(id) as TripFile;
|
broadcast(tripId, 'file:created', { file: restored }, req.headers['x-socket-id'] as string);
|
||||||
res.json({ file: formatFile(restored) });
|
|
||||||
broadcast(tripId, 'file:created', { file: formatFile(restored) }, req.headers['x-socket-id'] as string);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Permanently delete from trash
|
// Permanently delete from trash
|
||||||
@@ -292,20 +203,15 @@ router.delete('/:id/permanent', authenticate, (req: Request, res: Response) => {
|
|||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { tripId, id } = req.params;
|
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 (!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))
|
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' });
|
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' });
|
if (!file) return res.status(404).json({ error: 'File not found in trash' });
|
||||||
|
|
||||||
const filePath = path.join(filesDir, file.filename);
|
permanentDeleteFile(file);
|
||||||
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);
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
broadcast(tripId, 'file:deleted', { fileId: Number(id) }, req.headers['x-socket-id'] as string);
|
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 authReq = req as AuthRequest;
|
||||||
const { tripId } = req.params;
|
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 (!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))
|
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' });
|
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[];
|
const deleted = emptyTrash(tripId);
|
||||||
for (const file of trashed) {
|
res.json({ success: true, deleted });
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Link a file to a reservation (many-to-many)
|
// 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 { tripId, id } = req.params;
|
||||||
const { reservation_id, assignment_id, place_id } = req.body;
|
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 (!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))
|
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' });
|
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' });
|
if (!file) return res.status(404).json({ error: 'File not found' });
|
||||||
|
|
||||||
try {
|
const links = createFileLink(id, { reservation_id, assignment_id, place_id });
|
||||||
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);
|
|
||||||
res.json({ success: true, links });
|
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 authReq = req as AuthRequest;
|
||||||
const { tripId, id, linkId } = req.params;
|
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 (!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))
|
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' });
|
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 });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -377,15 +267,10 @@ router.get('/:id/links', authenticate, (req: Request, res: Response) => {
|
|||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { tripId, id } = req.params;
|
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 (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
const links = db.prepare(`
|
const links = getFileLinks(id);
|
||||||
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);
|
|
||||||
res.json({ links });
|
res.json({ links });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,295 +4,33 @@ import { authenticate } from '../middleware/auth';
|
|||||||
import { broadcast } from '../websocket';
|
import { broadcast } from '../websocket';
|
||||||
import { AuthRequest } from '../types';
|
import { AuthRequest } from '../types';
|
||||||
import { consumeEphemeralToken } from '../services/ephemeralTokens';
|
import { consumeEphemeralToken } from '../services/ephemeralTokens';
|
||||||
import { maybe_encrypt_api_key, decrypt_api_key } from '../services/apiKeyCrypto';
|
import { getClientIp } from '../services/auditLog';
|
||||||
import { checkSsrf } from '../utils/ssrfGuard';
|
import {
|
||||||
import { writeAudit, getClientIp } from '../services/auditLog';
|
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();
|
const router = express.Router();
|
||||||
|
|
||||||
function getImmichCredentials(userId: number) {
|
// ── Dual auth middleware (JWT or ephemeral token for <img> src) ─────────────
|
||||||
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). */
|
|
||||||
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) {
|
function authFromQuery(req: Request, res: Response, next: NextFunction) {
|
||||||
const queryToken = req.query.token as string | undefined;
|
const queryToken = req.query.token as string | undefined;
|
||||||
if (queryToken) {
|
if (queryToken) {
|
||||||
@@ -306,160 +44,174 @@ function authFromQuery(req: Request, res: Response, next: NextFunction) {
|
|||||||
return (authenticate as any)(req, res, next);
|
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) => {
|
router.get('/assets/:assetId/thumbnail', authFromQuery, async (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { assetId } = req.params;
|
const { assetId } = req.params;
|
||||||
if (!isValidAssetId(assetId)) return res.status(400).send('Invalid asset ID');
|
if (!isValidAssetId(assetId)) return res.status(400).send('Invalid asset ID');
|
||||||
|
const result = await proxyThumbnail(authReq.user.id, assetId);
|
||||||
// Only allow accessing own Immich credentials — prevent leaking other users' API keys
|
if (result.error) return res.status(result.status!).send(result.error);
|
||||||
const creds = getImmichCredentials(authReq.user.id);
|
res.set('Content-Type', result.contentType!);
|
||||||
if (!creds) return res.status(404).send('Not found');
|
res.set('Cache-Control', 'public, max-age=86400');
|
||||||
|
res.send(result.buffer);
|
||||||
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');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/assets/:assetId/original', authFromQuery, async (req: Request, res: Response) => {
|
router.get('/assets/:assetId/original', authFromQuery, async (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { assetId } = req.params;
|
const { assetId } = req.params;
|
||||||
if (!isValidAssetId(assetId)) return res.status(400).send('Invalid asset ID');
|
if (!isValidAssetId(assetId)) return res.status(400).send('Invalid asset ID');
|
||||||
|
const result = await proxyOriginal(authReq.user.id, assetId);
|
||||||
// Only allow accessing own Immich credentials — prevent leaking other users' API keys
|
if (result.error) return res.status(result.status!).send(result.error);
|
||||||
const creds = getImmichCredentials(authReq.user.id);
|
res.set('Content-Type', result.contentType!);
|
||||||
if (!creds) return res.status(404).send('Not found');
|
res.set('Cache-Control', 'public, max-age=86400');
|
||||||
|
res.send(result.buffer);
|
||||||
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');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Album Linking ──────────────────────────────────────────────────────────
|
// ── Album Linking ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// List user's Immich albums
|
|
||||||
router.get('/albums', authenticate, async (req: Request, res: Response) => {
|
router.get('/albums', authenticate, async (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const creds = getImmichCredentials(authReq.user.id);
|
const result = await listAlbums(authReq.user.id);
|
||||||
if (!creds) return res.status(400).json({ error: 'Immich not configured' });
|
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||||
|
res.json({ albums: result.albums });
|
||||||
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' });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get album links for a trip
|
|
||||||
router.get('/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => {
|
router.get('/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
if (!canAccessTrip(req.params.tripId, (authReq as AuthRequest).user.id)) return res.status(404).json({ error: 'Trip not found' });
|
if (!canAccessTrip(req.params.tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||||
const links = db.prepare(`
|
res.json({ links: listAlbumLinks(req.params.tripId) });
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Link an album to a trip
|
|
||||||
router.post('/trips/:tripId/album-links', authenticate, async (req: Request, res: Response) => {
|
router.post('/trips/:tripId/album-links', authenticate, async (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { tripId } = req.params;
|
const { tripId } = req.params;
|
||||||
if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||||
const { album_id, album_name } = req.body;
|
const { album_id, album_name } = req.body;
|
||||||
if (!album_id) return res.status(400).json({ error: 'album_id required' });
|
if (!album_id) return res.status(400).json({ error: 'album_id required' });
|
||||||
|
const result = createAlbumLink(tripId, authReq.user.id, album_id, album_name);
|
||||||
try {
|
if (!result.success) return res.status(400).json({ error: result.error });
|
||||||
db.prepare(
|
res.json({ success: true });
|
||||||
'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 });
|
router.delete('/trips/:tripId/album-links/:linkId', authenticate, (req: Request, res: Response) => {
|
||||||
} catch (err: any) {
|
const authReq = req as AuthRequest;
|
||||||
res.status(400).json({ error: 'Album already linked' });
|
deleteAlbumLink(req.params.linkId, req.params.tripId, authReq.user.id);
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
res.json({ success: true });
|
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) => {
|
router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { tripId, linkId } = req.params;
|
const { tripId, linkId } = req.params;
|
||||||
|
const result = await syncAlbumAssets(tripId, linkId, authReq.user.id);
|
||||||
const link = db.prepare('SELECT * FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?')
|
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||||
.get(linkId, tripId, authReq.user.id) as any;
|
res.json({ success: true, added: result.added, total: result.total });
|
||||||
if (!link) return res.status(404).json({ error: 'Album link not found' });
|
if (result.added! > 0) {
|
||||||
|
broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
|
||||||
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' });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,556 +1,94 @@
|
|||||||
import express, { Request, Response } from 'express';
|
import express, { Request, Response } from 'express';
|
||||||
import fetch from 'node-fetch';
|
|
||||||
import { db } from '../db/database';
|
|
||||||
import { authenticate } from '../middleware/auth';
|
import { authenticate } from '../middleware/auth';
|
||||||
import { AuthRequest } from '../types';
|
import { AuthRequest } from '../types';
|
||||||
import { decrypt_api_key } from '../services/apiKeyCrypto';
|
import {
|
||||||
|
searchPlaces,
|
||||||
interface NominatimResult {
|
getPlaceDetails,
|
||||||
osm_type: string;
|
getPlacePhoto,
|
||||||
osm_id: string;
|
reverseGeocode,
|
||||||
name?: string;
|
resolveGoogleMapsUrl,
|
||||||
display_name?: string;
|
} from '../services/mapsService';
|
||||||
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 }[] }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
function getMapsKey(userId: number): string | null {
|
// POST /search
|
||||||
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',
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
router.post('/search', authenticate, async (req: Request, res: Response) => {
|
router.post('/search', authenticate, async (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { query } = req.body;
|
const { query } = req.body;
|
||||||
|
|
||||||
if (!query) return res.status(400).json({ error: 'Search query is required' });
|
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 {
|
try {
|
||||||
const response = await fetch('https://places.googleapis.com/v1/places:searchText', {
|
const result = await searchPlaces(authReq.user.id, query, req.query.lang as string);
|
||||||
method: 'POST',
|
res.json(result);
|
||||||
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' });
|
|
||||||
} catch (err: unknown) {
|
} 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);
|
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) => {
|
router.get('/details/:placeId', authenticate, async (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { placeId } = req.params;
|
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 {
|
try {
|
||||||
const lang = (req.query.lang as string) || 'de';
|
const result = await getPlaceDetails(authReq.user.id, placeId, req.query.lang as string);
|
||||||
const response = await fetch(`https://places.googleapis.com/v1/places/${placeId}?languageCode=${lang}`, {
|
res.json(result);
|
||||||
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 });
|
|
||||||
} catch (err: unknown) {
|
} 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);
|
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) => {
|
router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { placeId } = req.params;
|
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 lat = parseFloat(req.query.lat as string);
|
||||||
const lng = parseFloat(req.query.lng 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 {
|
try {
|
||||||
const detailsRes = await fetch(`https://places.googleapis.com/v1/places/${placeId}`, {
|
const result = await getPlacePhoto(authReq.user.id, placeId, lat, lng, req.query.name as string);
|
||||||
headers: {
|
res.json(result);
|
||||||
'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 });
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('Place photo error:', err);
|
const status = (err as { status?: number }).status || 500;
|
||||||
photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true });
|
const message = err instanceof Error ? err.message : 'Error fetching photo';
|
||||||
res.status(500).json({ error: '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) => {
|
router.get('/reverse', authenticate, async (req: Request, res: Response) => {
|
||||||
const { lat, lng, lang } = req.query as { lat: string; lng: string; lang?: string };
|
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' });
|
if (!lat || !lng) return res.status(400).json({ error: 'lat and lng required' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const result = await reverseGeocode(lat, lng, lang);
|
||||||
lat, lon: lng, format: 'json', addressdetails: '1', zoom: '18',
|
res.json(result);
|
||||||
'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 });
|
|
||||||
} catch {
|
} catch {
|
||||||
res.json({ name: null, address: null });
|
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) => {
|
router.post('/resolve-url', authenticate, async (req: Request, res: Response) => {
|
||||||
const { url } = req.body;
|
const { url } = req.body;
|
||||||
if (!url || typeof url !== 'string') return res.status(400).json({ error: 'URL is required' });
|
if (!url || typeof url !== 'string') return res.status(400).json({ error: 'URL is required' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let resolvedUrl = url;
|
const result = await resolveGoogleMapsUrl(url);
|
||||||
|
res.json(result);
|
||||||
// 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 });
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('[Maps] URL resolve error:', err instanceof Error ? err.message : err);
|
const status = (err as { status?: number }).status || 400;
|
||||||
res.status(400).json({ error: 'Failed to resolve URL' });
|
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 express, { Request, Response } from 'express';
|
||||||
import { db } from '../db/database';
|
|
||||||
import { authenticate } from '../middleware/auth';
|
import { authenticate } from '../middleware/auth';
|
||||||
import { AuthRequest } from '../types';
|
import { AuthRequest } from '../types';
|
||||||
import { testSmtp, testWebhook } from '../services/notifications';
|
import { testSmtp, testWebhook } from '../services/notifications';
|
||||||
|
import * as prefsService from '../services/notificationPreferencesService';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Get user's notification preferences
|
|
||||||
router.get('/preferences', authenticate, (req: Request, res: Response) => {
|
router.get('/preferences', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
let prefs = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(authReq.user.id);
|
res.json({ preferences: prefsService.getPreferences(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 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update user's notification preferences
|
|
||||||
router.put('/preferences', authenticate, (req: Request, res: Response) => {
|
router.put('/preferences', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { notify_trip_invite, notify_booking_change, notify_trip_reminder, notify_webhook } = req.body;
|
const { notify_trip_invite, notify_booking_change, notify_trip_reminder, notify_webhook } = req.body;
|
||||||
|
const preferences = prefsService.updatePreferences(authReq.user.id, {
|
||||||
// Ensure row exists
|
notify_trip_invite, notify_booking_change, notify_trip_reminder, notify_webhook
|
||||||
const existing = db.prepare('SELECT id FROM notification_preferences WHERE user_id = ?').get(authReq.user.id);
|
});
|
||||||
if (!existing) {
|
res.json({ preferences });
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Admin: test SMTP configuration
|
|
||||||
router.post('/test-smtp', authenticate, async (req: Request, res: Response) => {
|
router.post('/test-smtp', authenticate, async (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
if (authReq.user.role !== 'admin') return res.status(403).json({ error: 'Admin only' });
|
if (authReq.user.role !== 'admin') return res.status(403).json({ error: 'Admin only' });
|
||||||
|
|
||||||
const { email } = req.body;
|
const { email } = req.body;
|
||||||
const result = await testSmtp(email || authReq.user.email);
|
res.json(await testSmtp(email || authReq.user.email));
|
||||||
res.json(result);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Admin: test webhook configuration
|
|
||||||
router.post('/test-webhook', authenticate, async (req: Request, res: Response) => {
|
router.post('/test-webhook', authenticate, async (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
if (authReq.user.role !== 'admin') return res.status(403).json({ error: 'Admin only' });
|
if (authReq.user.role !== 'admin') return res.status(403).json({ error: 'Admin only' });
|
||||||
|
res.json(await testWebhook());
|
||||||
const result = await testWebhook();
|
|
||||||
res.json(result);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,118 +1,24 @@
|
|||||||
import express, { Request, Response } from 'express';
|
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';
|
import { setAuthCookie } from '../services/cookie';
|
||||||
|
import {
|
||||||
interface OidcDiscoveryDoc {
|
getOidcConfig,
|
||||||
authorization_endpoint: string;
|
discover,
|
||||||
token_endpoint: string;
|
createState,
|
||||||
userinfo_endpoint: string;
|
consumeState,
|
||||||
_issuer?: string;
|
createAuthCode,
|
||||||
}
|
consumeAuthCode,
|
||||||
|
exchangeCodeForToken,
|
||||||
interface OidcTokenResponse {
|
getUserInfo,
|
||||||
access_token?: string;
|
findOrCreateUser,
|
||||||
id_token?: string;
|
touchLastLogin,
|
||||||
token_type?: string;
|
generateToken,
|
||||||
}
|
frontendUrl,
|
||||||
|
getAppUrl,
|
||||||
interface OidcUserInfo {
|
} from '../services/oidcService';
|
||||||
sub: string;
|
|
||||||
email?: string;
|
|
||||||
name?: string;
|
|
||||||
preferred_username?: string;
|
|
||||||
groups?: string[];
|
|
||||||
roles?: string[];
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
const AUTH_CODE_TTL = 60000; // 1 minute
|
// ---- GET /login ----------------------------------------------------------
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
router.get('/login', async (req: Request, res: Response) => {
|
router.get('/login', async (req: Request, res: Response) => {
|
||||||
const config = getOidcConfig();
|
const config = getOidcConfig();
|
||||||
@@ -124,15 +30,14 @@ router.get('/login', async (req: Request, res: Response) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const doc = await discover(config.issuer, config.discoveryUrl);
|
const doc = await discover(config.issuer, config.discoveryUrl);
|
||||||
const state = crypto.randomBytes(32).toString('hex');
|
const appUrl = getAppUrl();
|
||||||
const appUrl = process.env.APP_URL || (db.prepare("SELECT value FROM app_settings WHERE key = 'app_url'").get() as { value: string } | undefined)?.value;
|
|
||||||
if (!appUrl) {
|
if (!appUrl) {
|
||||||
return res.status(500).json({ error: 'APP_URL is not configured. OIDC cannot be used.' });
|
return res.status(500).json({ error: 'APP_URL is not configured. OIDC cannot be used.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const redirectUri = `${appUrl.replace(/\/+$/, '')}/api/auth/oidc/callback`;
|
const redirectUri = `${appUrl.replace(/\/+$/, '')}/api/auth/oidc/callback`;
|
||||||
const inviteToken = req.query.invite as string | undefined;
|
const inviteToken = req.query.invite as string | undefined;
|
||||||
|
const state = createState(redirectUri, inviteToken);
|
||||||
pendingStates.set(state, { createdAt: Date.now(), redirectUri, inviteToken });
|
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
response_type: 'code',
|
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) => {
|
router.get('/callback', async (req: Request, res: Response) => {
|
||||||
const { code, state, error: oidcError } = req.query as { code?: string; state?: string; error?: string };
|
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);
|
console.error('[OIDC] Provider error:', oidcError);
|
||||||
return res.redirect(frontendUrl('/login?oidc_error=' + encodeURIComponent(oidcError)));
|
return res.redirect(frontendUrl('/login?oidc_error=' + encodeURIComponent(oidcError)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!code || !state) {
|
if (!code || !state) {
|
||||||
return res.redirect(frontendUrl('/login?oidc_error=missing_params'));
|
return res.redirect(frontendUrl('/login?oidc_error=missing_params'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const pending = pendingStates.get(state);
|
const pending = consumeState(state);
|
||||||
if (!pending) {
|
if (!pending) {
|
||||||
return res.redirect(frontendUrl('/login?oidc_error=invalid_state'));
|
return res.redirect(frontendUrl('/login?oidc_error=invalid_state'));
|
||||||
}
|
}
|
||||||
pendingStates.delete(state);
|
|
||||||
|
|
||||||
const config = getOidcConfig();
|
const config = getOidcConfig();
|
||||||
if (!config) return res.redirect(frontendUrl('/login?oidc_error=not_configured'));
|
if (!config) return res.redirect(frontendUrl('/login?oidc_error=not_configured'));
|
||||||
@@ -177,105 +82,25 @@ router.get('/callback', async (req: Request, res: Response) => {
|
|||||||
try {
|
try {
|
||||||
const doc = await discover(config.issuer, config.discoveryUrl);
|
const doc = await discover(config.issuer, config.discoveryUrl);
|
||||||
|
|
||||||
const tokenRes = await fetch(doc.token_endpoint, {
|
const tokenData = await exchangeCodeForToken(doc, code, pending.redirectUri, config.clientId, config.clientSecret);
|
||||||
method: 'POST',
|
if (!tokenData._ok || !tokenData.access_token) {
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
console.error('[OIDC] Token exchange failed: status', tokenData._status);
|
||||||
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);
|
|
||||||
return res.redirect(frontendUrl('/login?oidc_error=token_failed'));
|
return res.redirect(frontendUrl('/login?oidc_error=token_failed'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const userInfoRes = await fetch(doc.userinfo_endpoint, {
|
const userInfo = await getUserInfo(doc.userinfo_endpoint, tokenData.access_token);
|
||||||
headers: { Authorization: `Bearer ${tokenData.access_token}` },
|
|
||||||
});
|
|
||||||
const userInfo = await userInfoRes.json() as OidcUserInfo;
|
|
||||||
|
|
||||||
if (!userInfo.email) {
|
if (!userInfo.email) {
|
||||||
return res.redirect(frontendUrl('/login?oidc_error=no_email'));
|
return res.redirect(frontendUrl('/login?oidc_error=no_email'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const email = userInfo.email.toLowerCase();
|
const result = findOrCreateUser(userInfo, config, pending.inviteToken);
|
||||||
const name = userInfo.name || userInfo.preferred_username || email.split('@')[0];
|
if ('error' in result) {
|
||||||
const sub = userInfo.sub;
|
return res.redirect(frontendUrl('/login?oidc_error=' + result.error));
|
||||||
|
|
||||||
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) {
|
touchLastLogin(result.user.id);
|
||||||
if (!user.oidc_sub) {
|
const jwtToken = generateToken(result.user);
|
||||||
db.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?').run(sub, config.issuer, user.id);
|
const authCode = createAuthCode(jwtToken);
|
||||||
}
|
|
||||||
// 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() });
|
|
||||||
res.redirect(frontendUrl('/login?oidc_code=' + authCode));
|
res.redirect(frontendUrl('/login?oidc_code=' + authCode));
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('[OIDC] Callback error:', err);
|
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) => {
|
router.get('/exchange', (req: Request, res: Response) => {
|
||||||
const { code } = req.query as { code?: string };
|
const { code } = req.query as { code?: string };
|
||||||
if (!code) return res.status(400).json({ error: 'Code required' });
|
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' });
|
const result = consumeAuthCode(code);
|
||||||
authCodes.delete(code);
|
if ('error' in result) return res.status(400).json({ error: result.error });
|
||||||
if (Date.now() - entry.created > AUTH_CODE_TTL) return res.status(400).json({ error: 'Code expired' });
|
|
||||||
setAuthCookie(res, entry.token);
|
setAuthCookie(res, result.token);
|
||||||
res.json({ token: entry.token });
|
res.json({ token: result.token });
|
||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,27 +1,36 @@
|
|||||||
import express, { Request, Response } from 'express';
|
import express, { Request, Response } from 'express';
|
||||||
import { db, canAccessTrip } from '../db/database';
|
import { db } from '../db/database';
|
||||||
import { authenticate } from '../middleware/auth';
|
import { authenticate } from '../middleware/auth';
|
||||||
import { broadcast } from '../websocket';
|
import { broadcast } from '../websocket';
|
||||||
import { checkPermission } from '../services/permissions';
|
import { checkPermission } from '../services/permissions';
|
||||||
import { AuthRequest } from '../types';
|
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 });
|
const router = express.Router({ mergeParams: true });
|
||||||
|
|
||||||
function verifyTripOwnership(tripId: string | number, userId: number) {
|
|
||||||
return canAccessTrip(tripId, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
router.get('/', authenticate, (req: Request, res: Response) => {
|
router.get('/', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { tripId } = req.params;
|
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 (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
const items = db.prepare(
|
const items = listItems(tripId);
|
||||||
'SELECT * FROM packing_items WHERE trip_id = ? ORDER BY sort_order ASC, created_at ASC'
|
|
||||||
).all(tripId);
|
|
||||||
|
|
||||||
res.json({ items });
|
res.json({ items });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -29,9 +38,9 @@ router.get('/', authenticate, (req: Request, res: Response) => {
|
|||||||
router.post('/import', authenticate, (req: Request, res: Response) => {
|
router.post('/import', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { tripId } = req.params;
|
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 (!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))
|
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' });
|
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 };
|
const created = bulkImport(tripId, items);
|
||||||
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();
|
|
||||||
|
|
||||||
res.status(201).json({ items: created, count: created.length });
|
res.status(201).json({ items: created, count: created.length });
|
||||||
for (const item of created) {
|
for (const item of created) {
|
||||||
@@ -80,7 +61,7 @@ router.post('/', authenticate, (req: Request, res: Response) => {
|
|||||||
const { tripId } = req.params;
|
const { tripId } = req.params;
|
||||||
const { name, category, checked } = req.body;
|
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 (!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))
|
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' });
|
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 item = createItem(tripId, { name, category, checked });
|
||||||
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);
|
|
||||||
res.status(201).json({ item });
|
res.status(201).json({ item });
|
||||||
broadcast(tripId, 'packing:created', { item }, req.headers['x-socket-id'] as string);
|
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 { tripId, id } = req.params;
|
||||||
const { name, checked, category, weight_grams, bag_id } = req.body;
|
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 (!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))
|
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' });
|
return res.status(403).json({ error: 'No permission' });
|
||||||
|
|
||||||
const item = db.prepare('SELECT * FROM packing_items WHERE id = ? AND trip_id = ?').get(id, tripId);
|
const updated = updateItem(tripId, id, { name, checked, category, weight_grams, bag_id }, Object.keys(req.body));
|
||||||
if (!item) return res.status(404).json({ error: 'Item not found' });
|
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 });
|
res.json({ item: updated });
|
||||||
broadcast(tripId, 'packing:updated', { item: updated }, req.headers['x-socket-id'] as string);
|
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 authReq = req as AuthRequest;
|
||||||
const { tripId, id } = req.params;
|
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 (!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))
|
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' });
|
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 (!deleteItem(tripId, id)) return res.status(404).json({ error: 'Item not found' });
|
||||||
if (!item) return res.status(404).json({ error: 'Item not found' });
|
|
||||||
|
|
||||||
db.prepare('DELETE FROM packing_items WHERE id = ?').run(id);
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
broadcast(tripId, 'packing:deleted', { itemId: Number(id) }, req.headers['x-socket-id'] as string);
|
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) => {
|
router.get('/bags', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { tripId } = req.params;
|
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 (!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 });
|
res.json({ bags });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -172,14 +123,12 @@ router.post('/bags', authenticate, (req: Request, res: Response) => {
|
|||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { tripId } = req.params;
|
const { tripId } = req.params;
|
||||||
const { name, color } = req.body;
|
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 (!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))
|
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' });
|
return res.status(403).json({ error: 'No permission' });
|
||||||
if (!name?.trim()) return res.status(400).json({ error: 'Name is required' });
|
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 bag = createBag(tripId, { name, color });
|
||||||
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);
|
|
||||||
res.status(201).json({ bag });
|
res.status(201).json({ bag });
|
||||||
broadcast(tripId, 'packing:bag-created', { bag }, req.headers['x-socket-id'] as string);
|
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 authReq = req as AuthRequest;
|
||||||
const { tripId, bagId } = req.params;
|
const { tripId, bagId } = req.params;
|
||||||
const { name, color, weight_limit_grams } = req.body;
|
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 (!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))
|
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' });
|
return res.status(403).json({ error: 'No permission' });
|
||||||
const bag = db.prepare('SELECT * FROM packing_bags WHERE id = ? AND trip_id = ?').get(bagId, tripId);
|
const updated = updateBag(tripId, bagId, { name, color, weight_limit_grams });
|
||||||
if (!bag) return res.status(404).json({ error: 'Bag not found' });
|
if (!updated) 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);
|
|
||||||
res.json({ bag: updated });
|
res.json({ bag: updated });
|
||||||
broadcast(tripId, 'packing:bag-updated', { bag: updated }, req.headers['x-socket-id'] as string);
|
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) => {
|
router.delete('/bags/:bagId', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { tripId, bagId } = req.params;
|
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 (!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))
|
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' });
|
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 (!deleteBag(tripId, bagId)) return res.status(404).json({ error: 'Bag not found' });
|
||||||
if (!bag) return res.status(404).json({ error: 'Bag not found' });
|
|
||||||
db.prepare('DELETE FROM packing_bags WHERE id = ?').run(bagId);
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
broadcast(tripId, 'packing:bag-deleted', { bagId: Number(bagId) }, req.headers['x-socket-id'] as string);
|
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 authReq = req as AuthRequest;
|
||||||
const { tripId, templateId } = req.params;
|
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 (!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))
|
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' });
|
return res.status(403).json({ error: 'No permission' });
|
||||||
|
|
||||||
const templateItems = db.prepare(`
|
const added = applyTemplate(tripId, templateId);
|
||||||
SELECT ti.name, tc.name as category
|
if (!added) return res.status(404).json({ error: 'Template not found or empty' });
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({ items: added, count: added.length });
|
res.json({ items: added, count: added.length });
|
||||||
broadcast(tripId, 'packing:template-applied', { items: added }, req.headers['x-socket-id'] as string);
|
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) => {
|
router.get('/category-assignees', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { tripId } = req.params;
|
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 (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
const rows = db.prepare(`
|
const assignees = getCategoryAssignees(tripId);
|
||||||
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 });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({ assignees });
|
res.json({ assignees });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -280,26 +195,14 @@ router.put('/category-assignees/:categoryName', authenticate, (req: Request, res
|
|||||||
const { tripId, categoryName } = req.params;
|
const { tripId, categoryName } = req.params;
|
||||||
const { user_ids } = req.body;
|
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 (!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))
|
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' });
|
return res.status(403).json({ error: 'No permission' });
|
||||||
|
|
||||||
const cat = decodeURIComponent(categoryName);
|
const cat = decodeURIComponent(categoryName);
|
||||||
db.prepare('DELETE FROM packing_category_assignees WHERE trip_id = ? AND category_name = ?').run(tripId, cat);
|
const rows = updateCategoryAssignees(tripId, cat, user_ids);
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
res.json({ assignees: rows });
|
res.json({ assignees: rows });
|
||||||
broadcast(tripId, 'packing:assignees', { category: cat, assignees: rows }, req.headers['x-socket-id'] as string);
|
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 { tripId } = req.params;
|
||||||
const { orderedIds } = req.body;
|
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 (!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))
|
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' });
|
return res.status(403).json({ error: 'No permission' });
|
||||||
|
|
||||||
const update = db.prepare('UPDATE packing_items SET sort_order = ? WHERE id = ? AND trip_id = ?');
|
reorderItems(tripId, orderedIds);
|
||||||
const updateMany = db.transaction((ids: number[]) => {
|
|
||||||
ids.forEach((id, index) => {
|
|
||||||
update.run(index, id, tripId);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
updateMany(orderedIds);
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,79 +1,37 @@
|
|||||||
import express, { Request, Response } from 'express';
|
import express, { Request, Response } from 'express';
|
||||||
import fetch from 'node-fetch';
|
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import { db, getPlaceWithTags } from '../db/database';
|
|
||||||
import { authenticate } from '../middleware/auth';
|
import { authenticate } from '../middleware/auth';
|
||||||
import { requireTripAccess } from '../middleware/tripAccess';
|
import { requireTripAccess } from '../middleware/tripAccess';
|
||||||
import { broadcast } from '../websocket';
|
import { broadcast } from '../websocket';
|
||||||
import { loadTagsByPlaceIds } from '../services/queryHelpers';
|
|
||||||
import { validateStringLengths } from '../middleware/validate';
|
import { validateStringLengths } from '../middleware/validate';
|
||||||
import { checkPermission } from '../services/permissions';
|
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 } });
|
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 });
|
const router = express.Router({ mergeParams: true });
|
||||||
|
|
||||||
router.get('/', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
router.get('/', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
||||||
const { tripId } = req.params
|
const { tripId } = req.params;
|
||||||
const { search, category, tag } = req.query;
|
const { search, category, tag } = req.query;
|
||||||
|
|
||||||
let query = `
|
const places = listPlaces(tripId, {
|
||||||
SELECT DISTINCT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
|
search: search as string | undefined,
|
||||||
FROM places p
|
category: category as string | undefined,
|
||||||
LEFT JOIN categories c ON p.category_id = c.id
|
tag: tag as string | undefined,
|
||||||
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] || [],
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ places: placesWithTags });
|
res.json({ places });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/', authenticate, requireTripAccess, validateStringLengths({ name: 200, description: 2000, address: 500, notes: 2000 }), (req: Request, res: Response) => {
|
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))
|
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' });
|
return res.status(403).json({ error: 'No permission' });
|
||||||
|
|
||||||
const { tripId } = req.params
|
const { tripId } = req.params;
|
||||||
|
const { name } = req.body;
|
||||||
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;
|
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return res.status(400).json({ error: 'Place name is required' });
|
return res.status(400).json({ error: 'Place name is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = db.prepare(`
|
const place = createPlace(tripId, req.body);
|
||||||
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));
|
|
||||||
res.status(201).json({ place });
|
res.status(201).json({ place });
|
||||||
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
|
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;
|
const file = (req as any).file;
|
||||||
if (!file) return res.status(400).json({ error: 'No file uploaded' });
|
if (!file) return res.status(400).json({ error: 'No file uploaded' });
|
||||||
|
|
||||||
const xml = file.buffer.toString('utf-8');
|
const created = importGpx(tripId, file.buffer);
|
||||||
|
if (!created) {
|
||||||
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) {
|
|
||||||
return res.status(400).json({ error: 'No waypoints found in GPX file' });
|
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 });
|
res.status(201).json({ places: created, count: created.length });
|
||||||
for (const place of created) {
|
for (const place of created) {
|
||||||
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
|
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' });
|
if (!url || typeof url !== 'string') return res.status(400).json({ error: 'URL is required' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Extract list ID from various Google Maps list URL formats
|
const result = await importGoogleList(tripId, url);
|
||||||
let listId: string | null = null;
|
|
||||||
let resolvedUrl = url;
|
|
||||||
|
|
||||||
// Follow redirects for short URLs (maps.app.goo.gl, goo.gl)
|
if ('error' in result) {
|
||||||
if (url.includes('goo.gl') || url.includes('maps.app')) {
|
return res.status(result.status).json({ error: result.error });
|
||||||
const redirectRes = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(10000) });
|
|
||||||
resolvedUrl = redirectRes.url;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pattern: /placelists/list/{ID}
|
res.status(201).json({ places: result.places, count: result.places.length, listName: result.listName });
|
||||||
const plMatch = resolvedUrl.match(/placelists\/list\/([A-Za-z0-9_-]+)/);
|
for (const place of result.places) {
|
||||||
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) {
|
|
||||||
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
|
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} 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) => {
|
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);
|
const place = getPlace(tripId, id);
|
||||||
if (!placeCheck) {
|
if (!place) {
|
||||||
return res.status(404).json({ error: 'Place not found' });
|
return res.status(404).json({ error: 'Place not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const place = getPlaceWithTags(id);
|
|
||||||
res.json({ place });
|
res.json({ place });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/:id/image', authenticate, requireTripAccess, async (req: Request, res: Response) => {
|
router.get('/:id/image', authenticate, requireTripAccess, async (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { tripId, id } = req.params
|
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' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const query = encodeURIComponent(place.name + (place.address ? ' ' + place.address : ''));
|
const result = await searchPlaceImage(tripId, id, authReq.user.id);
|
||||||
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) {
|
if ('error' in result) {
|
||||||
return res.status(response.status).json({ error: data.errors?.[0] || 'Unsplash API error' });
|
return res.status(result.status).json({ error: result.error });
|
||||||
}
|
}
|
||||||
|
|
||||||
const photos = (data.results || []).map((p: NonNullable<UnsplashSearchResponse['results']>[number]) => ({
|
res.json({ photos: result.photos });
|
||||||
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 });
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('Unsplash error:', err);
|
console.error('Unsplash error:', err);
|
||||||
res.status(500).json({ error: 'Error searching for image' });
|
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))
|
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' });
|
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;
|
const place = updatePlace(tripId, id, req.body);
|
||||||
if (!existingPlace) {
|
if (!place) {
|
||||||
return res.status(404).json({ error: 'Place not found' });
|
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 });
|
res.json({ place });
|
||||||
broadcast(tripId, 'place:updated', { place }, req.headers['x-socket-id'] as string);
|
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))
|
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' });
|
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);
|
const deleted = deletePlace(tripId, id);
|
||||||
if (!place) {
|
if (!deleted) {
|
||||||
return res.status(404).json({ error: 'Place not found' });
|
return res.status(404).json({ error: 'Place not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
db.prepare('DELETE FROM places WHERE id = ?').run(id);
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
broadcast(tripId, 'place:deleted', { placeId: Number(id) }, req.headers['x-socket-id'] as string);
|
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 express, { Request, Response } from 'express';
|
||||||
import { db, canAccessTrip } from '../db/database';
|
import { db } from '../db/database';
|
||||||
import { authenticate } from '../middleware/auth';
|
import { authenticate } from '../middleware/auth';
|
||||||
import { broadcast } from '../websocket';
|
import { broadcast } from '../websocket';
|
||||||
import { checkPermission } from '../services/permissions';
|
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 });
|
const router = express.Router({ mergeParams: true });
|
||||||
|
|
||||||
function verifyTripOwnership(tripId: string | number, userId: number) {
|
|
||||||
return canAccessTrip(tripId, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
router.get('/', authenticate, (req: Request, res: Response) => {
|
router.get('/', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { tripId } = req.params;
|
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 (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
const reservations = db.prepare(`
|
const reservations = listReservations(tripId);
|
||||||
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);
|
|
||||||
|
|
||||||
res.json({ reservations });
|
res.json({ reservations });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -38,7 +32,7 @@ router.post('/', authenticate, (req: Request, res: Response) => {
|
|||||||
const { tripId } = req.params;
|
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 { 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 (!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))
|
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' });
|
if (!title) return res.status(400).json({ error: 'Title is required' });
|
||||||
|
|
||||||
// Auto-create accommodation for hotel reservations
|
const { reservation, accommodationCreated } = createReservation(tripId, {
|
||||||
let resolvedAccommodationId = accommodation_id || null;
|
title, reservation_time, reservation_end_time, location,
|
||||||
if (type === 'hotel' && !resolvedAccommodationId && create_accommodation) {
|
confirmation_number, notes, day_id, place_id, assignment_id,
|
||||||
const { place_id: accPlaceId, start_day_id, end_day_id, check_in, check_out, confirmation: accConf } = create_accommodation;
|
status, type, accommodation_id, metadata, 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 (?, ?, ?, ?, ?, ?, ?)'
|
if (accommodationCreated) {
|
||||||
).run(tripId, accPlaceId, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null);
|
broadcast(tripId, 'accommodation:created', {}, req.headers['x-socket-id'] as string);
|
||||||
resolvedAccommodationId = accResult.lastInsertRowid;
|
|
||||||
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 });
|
res.status(201).json({ reservation });
|
||||||
broadcast(tripId, 'reservation:created', { reservation }, req.headers['x-socket-id'] as string);
|
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 { tripId } = req.params;
|
||||||
const { positions } = req.body;
|
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 (!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))
|
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' });
|
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 = ?');
|
updatePositions(tripId, positions);
|
||||||
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);
|
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
broadcast(tripId, 'reservation:positions', { positions }, req.headers['x-socket-id'] as string);
|
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 { 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 { 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 (!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))
|
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' });
|
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;
|
const current = getReservation(id, tripId);
|
||||||
if (!reservation) return res.status(404).json({ error: 'Reservation not found' });
|
if (!current) return res.status(404).json({ error: 'Reservation not found' });
|
||||||
|
|
||||||
// Update or create accommodation for hotel reservations
|
const { reservation, accommodationChanged } = updateReservation(id, tripId, {
|
||||||
let resolvedAccId = accommodation_id !== undefined ? (accommodation_id || null) : reservation.accommodation_id;
|
title, reservation_time, reservation_end_time, location,
|
||||||
if (type === 'hotel' && create_accommodation) {
|
confirmation_number, notes, day_id, place_id, assignment_id,
|
||||||
const { place_id: accPlaceId, start_day_id, end_day_id, check_in, check_out, confirmation: accConf } = create_accommodation;
|
status, type, accommodation_id, metadata, create_accommodation
|
||||||
if (accPlaceId && start_day_id && end_day_id) {
|
}, current);
|
||||||
if (resolvedAccId) {
|
|
||||||
db.prepare('UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ? WHERE id = ?')
|
if (accommodationChanged) {
|
||||||
.run(accPlaceId, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null, resolvedAccId);
|
broadcast(tripId, 'accommodation:updated', {}, req.headers['x-socket-id'] as string);
|
||||||
} 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
db.prepare(`
|
res.json({ reservation });
|
||||||
UPDATE reservations SET
|
broadcast(tripId, 'reservation:updated', { reservation }, req.headers['x-socket-id'] as string);
|
||||||
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);
|
|
||||||
|
|
||||||
import('../services/notifications').then(({ notifyTripMembers }) => {
|
import('../services/notifications').then(({ notifyTripMembers }) => {
|
||||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
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 authReq = req as AuthRequest;
|
||||||
const { tripId, id } = req.params;
|
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 (!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))
|
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' });
|
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) return res.status(404).json({ error: 'Reservation not found' });
|
||||||
|
|
||||||
if (reservation.accommodation_id) {
|
if (accommodationDeleted) {
|
||||||
db.prepare('DELETE FROM day_accommodations WHERE id = ?').run(reservation.accommodation_id);
|
|
||||||
broadcast(tripId, 'accommodation:deleted', { accommodationId: reservation.accommodation_id }, req.headers['x-socket-id'] as string);
|
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 });
|
res.json({ success: true });
|
||||||
broadcast(tripId, 'reservation:deleted', { reservationId: Number(id) }, req.headers['x-socket-id'] as string);
|
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 express, { Request, Response } from 'express';
|
||||||
import { db } from '../db/database';
|
|
||||||
import { authenticate } from '../middleware/auth';
|
import { authenticate } from '../middleware/auth';
|
||||||
import { AuthRequest } from '../types';
|
import { AuthRequest } from '../types';
|
||||||
|
import * as settingsService from '../services/settingsService';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.get('/', authenticate, (req: Request, res: Response) => {
|
router.get('/', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
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 }[];
|
res.json({ settings: settingsService.getUserSettings(authReq.user.id) });
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/', authenticate, (req: Request, res: Response) => {
|
router.put('/', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { key, value } = req.body;
|
const { key, value } = req.body;
|
||||||
|
|
||||||
if (!key) return res.status(400).json({ error: 'Key is required' });
|
if (!key) return res.status(400).json({ error: 'Key is required' });
|
||||||
|
settingsService.upsertSetting(authReq.user.id, key, value);
|
||||||
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);
|
|
||||||
|
|
||||||
res.json({ success: true, key, value });
|
res.json({ success: true, key, value });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/bulk', authenticate, (req: Request, res: Response) => {
|
router.post('/bulk', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { settings } = req.body;
|
const { settings } = req.body;
|
||||||
|
if (!settings || typeof settings !== 'object')
|
||||||
if (!settings || typeof settings !== 'object') {
|
|
||||||
return res.status(400).json({ error: 'Settings object is required' });
|
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 {
|
try {
|
||||||
db.exec('BEGIN');
|
const updated = settingsService.bulkUpsertSettings(authReq.user.id, settings);
|
||||||
for (const [key, value] of Object.entries(settings)) {
|
res.json({ success: true, updated });
|
||||||
const serialized = typeof value === 'object' ? JSON.stringify(value) : String(value !== undefined ? value : '');
|
} catch (err) {
|
||||||
upsert.run(authReq.user.id, key, serialized);
|
|
||||||
}
|
|
||||||
db.exec('COMMIT');
|
|
||||||
} catch (err: unknown) {
|
|
||||||
db.exec('ROLLBACK');
|
|
||||||
console.error('Error saving settings:', 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;
|
export default router;
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import express, { Request, Response } from 'express';
|
import express, { Request, Response } from 'express';
|
||||||
import crypto from 'crypto';
|
import { canAccessTrip } from '../db/database';
|
||||||
import { db, canAccessTrip } from '../db/database';
|
|
||||||
import { authenticate } from '../middleware/auth';
|
import { authenticate } from '../middleware/auth';
|
||||||
import { checkPermission } from '../services/permissions';
|
import { checkPermission } from '../services/permissions';
|
||||||
import { AuthRequest } from '../types';
|
import { AuthRequest } from '../types';
|
||||||
import { loadTagsByPlaceIds } from '../services/queryHelpers';
|
import * as shareService from '../services/shareService';
|
||||||
|
|
||||||
const router = express.Router();
|
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))
|
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' });
|
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
|
if (result.created) {
|
||||||
const existing = db.prepare('SELECT token FROM share_tokens WHERE trip_id = ?').get(tripId) as { token: string } | undefined;
|
return res.status(201).json({ token: result.token });
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
|
return res.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 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get share link status
|
// Get share link status
|
||||||
@@ -40,8 +33,8 @@ router.get('/trips/:tripId/share-link', authenticate, (req: Request, res: Respon
|
|||||||
const { tripId } = req.params;
|
const { tripId } = req.params;
|
||||||
if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
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;
|
const info = shareService.getShareLink(tripId);
|
||||||
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 });
|
res.json(info ? info : { token: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete share link
|
// 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))
|
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' });
|
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 });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Public read-only trip data (no auth required)
|
// Public read-only trip data (no auth required)
|
||||||
router.get('/shared/:token', (req: Request, res: Response) => {
|
router.get('/shared/:token', (req: Request, res: Response) => {
|
||||||
const { token } = req.params;
|
const { token } = req.params;
|
||||||
const shareRow = db.prepare('SELECT * FROM share_tokens WHERE token = ?').get(token) as any;
|
const data = shareService.getSharedTripData(token);
|
||||||
if (!shareRow) return res.status(404).json({ error: 'Invalid or expired link' });
|
if (!data) return res.status(404).json({ error: 'Invalid or expired link' });
|
||||||
|
res.json(data);
|
||||||
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,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,52 +1,37 @@
|
|||||||
import express, { Request, Response } from 'express';
|
import express, { Request, Response } from 'express';
|
||||||
import { db } from '../db/database';
|
|
||||||
import { authenticate } from '../middleware/auth';
|
import { authenticate } from '../middleware/auth';
|
||||||
import { AuthRequest } from '../types';
|
import { AuthRequest } from '../types';
|
||||||
|
import * as tagService from '../services/tagService';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.get('/', authenticate, (req: Request, res: Response) => {
|
router.get('/', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const tags = db.prepare(
|
res.json({ tags: tagService.listTags(authReq.user.id) });
|
||||||
'SELECT * FROM tags WHERE user_id = ? ORDER BY name ASC'
|
|
||||||
).all(authReq.user.id);
|
|
||||||
res.json({ tags });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/', authenticate, (req: Request, res: Response) => {
|
router.post('/', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { name, color } = req.body;
|
const { name, color } = req.body;
|
||||||
|
|
||||||
if (!name) return res.status(400).json({ error: 'Tag name is required' });
|
if (!name) return res.status(400).json({ error: 'Tag name is required' });
|
||||||
|
const tag = tagService.createTag(authReq.user.id, name, color);
|
||||||
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);
|
|
||||||
res.status(201).json({ tag });
|
res.status(201).json({ tag });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/:id', authenticate, (req: Request, res: Response) => {
|
router.put('/:id', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { name, color } = req.body;
|
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 (!tagService.getTagByIdAndUser(req.params.id, authReq.user.id))
|
||||||
|
return res.status(404).json({ error: 'Tag not found' });
|
||||||
if (!tag) return res.status(404).json({ error: 'Tag not found' });
|
const tag = tagService.updateTag(req.params.id, name, color);
|
||||||
|
res.json({ tag });
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/:id', authenticate, (req: Request, res: Response) => {
|
router.delete('/:id', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
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 (!tagService.getTagByIdAndUser(req.params.id, authReq.user.id))
|
||||||
if (!tag) return res.status(404).json({ error: 'Tag not found' });
|
return res.status(404).json({ error: 'Tag not found' });
|
||||||
|
tagService.deleteTag(req.params.id);
|
||||||
db.prepare('DELETE FROM tags WHERE id = ?').run(req.params.id);
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,17 +3,33 @@ import multer from 'multer';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { db, canAccessTrip } from '../db/database';
|
import { canAccessTrip } from '../db/database';
|
||||||
import { authenticate, demoUploadBlock } from '../middleware/auth';
|
import { authenticate, demoUploadBlock } from '../middleware/auth';
|
||||||
import { broadcast } from '../websocket';
|
import { broadcast } from '../websocket';
|
||||||
import { AuthRequest, Trip, User } from '../types';
|
import { AuthRequest } from '../types';
|
||||||
import { writeAudit, getClientIp, logInfo } from '../services/auditLog';
|
import { writeAudit, getClientIp, logInfo } from '../services/auditLog';
|
||||||
import { checkPermission } from '../services/permissions';
|
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 router = express.Router();
|
||||||
|
|
||||||
const MS_PER_DAY = 86400000;
|
|
||||||
const MAX_TRIP_DAYS = 365;
|
|
||||||
const MAX_COVER_SIZE = 20 * 1024 * 1024; // 20 MB
|
const MAX_COVER_SIZE = 20 * 1024 * 1024; // 20 MB
|
||||||
|
|
||||||
const coversDir = path.join(__dirname, '../../uploads/covers');
|
const coversDir = path.join(__dirname, '../../uploads/covers');
|
||||||
@@ -41,138 +57,48 @@ const uploadCover = multer({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const TRIP_SELECT = `
|
// ── List trips ────────────────────────────────────────────────────────────
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
router.get('/', authenticate, (req: Request, res: Response) => {
|
router.get('/', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const archived = req.query.archived === '1' ? 1 : 0;
|
const archived = req.query.archived === '1' ? 1 : 0;
|
||||||
const userId = authReq.user.id;
|
const trips = listTrips(authReq.user.id, archived);
|
||||||
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 });
|
|
||||||
res.json({ trips });
|
res.json({ trips });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Create trip ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
router.post('/', authenticate, (req: Request, res: Response) => {
|
router.post('/', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
if (!checkPermission('trip_create', authReq.user.role, null, authReq.user.id, false))
|
if (!checkPermission('trip_create', authReq.user.role, null, authReq.user.id, false))
|
||||||
return res.status(403).json({ error: 'No permission to create trips' });
|
return res.status(403).json({ error: 'No permission to create trips' });
|
||||||
|
|
||||||
const { title, description, start_date, end_date, currency, reminder_days } = req.body;
|
const { title, description, start_date, end_date, currency, reminder_days } = req.body;
|
||||||
if (!title) return res.status(400).json({ error: 'Title is required' });
|
if (!title) return res.status(400).json({ error: 'Title is required' });
|
||||||
if (start_date && end_date && new Date(end_date) < new Date(start_date))
|
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' });
|
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(`
|
writeAudit({ userId: authReq.user.id, action: 'trip.create', ip: getClientIp(req), details: { tripId, title, reminder_days: reminderDays === 0 ? 'none' : `${reminderDays} days` } });
|
||||||
INSERT INTO trips (user_id, title, description, start_date, end_date, currency, reminder_days)
|
if (reminderDays > 0) {
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
logInfo(`${authReq.user.email} set ${reminderDays}-day reminder for trip "${title}"`);
|
||||||
`).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}"`);
|
|
||||||
}
|
}
|
||||||
const trip = db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId: authReq.user.id, tripId });
|
|
||||||
res.status(201).json({ trip });
|
res.status(201).json({ trip });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Get trip ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
router.get('/:id', authenticate, (req: Request, res: Response) => {
|
router.get('/:id', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const userId = authReq.user.id;
|
const trip = getTrip(req.params.id, 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 });
|
|
||||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
res.json({ trip });
|
res.json({ trip });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Update trip ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
router.put('/:id', authenticate, (req: Request, res: Response) => {
|
router.put('/:id', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const access = canAccessTrip(req.params.id, authReq.user.id);
|
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' });
|
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;
|
try {
|
||||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
const result = updateTrip(req.params.id, authReq.user.id, req.body, authReq.user.role);
|
||||||
const { title, description, start_date, end_date, currency, is_archived, cover_image, reminder_days } = req.body;
|
|
||||||
|
|
||||||
if (start_date && end_date && new Date(end_date) < new Date(start_date))
|
if (Object.keys(result.changes).length > 0) {
|
||||||
return res.status(400).json({ error: 'End date must be after start date' });
|
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) {
|
||||||
const newTitle = title || trip.title;
|
logInfo(`Admin ${authReq.user.email} edited trip "${result.newTitle}" owned by ${result.ownerEmail}`);
|
||||||
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 (newReminder !== (trip as any).reminder_days) {
|
if (result.newReminder !== result.oldReminder) {
|
||||||
if (newReminder > 0) {
|
if (result.newReminder > 0) {
|
||||||
logInfo(`${authReq.user.email} set ${newReminder}-day reminder for trip "${newTitle}"`);
|
logInfo(`${authReq.user.email} set ${result.newReminder}-day reminder for trip "${result.newTitle}"`);
|
||||||
} else {
|
} else {
|
||||||
logInfo(`${authReq.user.email} removed reminder for trip "${newTitle}"`);
|
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: result.updatedTrip });
|
||||||
res.json({ trip: updatedTrip });
|
broadcast(req.params.id, 'trip:updated', { trip: result.updatedTrip }, req.headers['x-socket-id'] as string);
|
||||||
broadcast(req.params.id, 'trip:updated', { trip: 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) => {
|
router.post('/:id/cover', authenticate, demoUploadBlock, uploadCover.single('cover'), (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const access = canAccessTrip(req.params.id, authReq.user.id);
|
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))
|
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' });
|
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 (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
if (!req.file) return res.status(400).json({ error: 'No image uploaded' });
|
if (!req.file) return res.status(400).json({ error: 'No image uploaded' });
|
||||||
|
|
||||||
if (trip.cover_image) {
|
deleteOldCover(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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const coverUrl = `/uploads/covers/${req.file.filename}`;
|
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 });
|
res.json({ cover_image: coverUrl });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Delete trip ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
router.delete('/:id', authenticate, (req: Request, res: Response) => {
|
router.delete('/:id', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
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;
|
const tripOwner = getTripOwner(req.params.id);
|
||||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
if (!tripOwner) return res.status(404).json({ error: 'Trip not found' });
|
||||||
const tripOwnerId = trip.user_id;
|
const tripOwnerId = tripOwner.user_id;
|
||||||
const isMemberDel = tripOwnerId !== authReq.user.id;
|
const isMemberDel = tripOwnerId !== authReq.user.id;
|
||||||
if (!checkPermission('trip_delete', authReq.user.role, tripOwnerId, authReq.user.id, isMemberDel))
|
if (!checkPermission('trip_delete', authReq.user.role, tripOwnerId, authReq.user.id, isMemberDel))
|
||||||
return res.status(403).json({ error: 'No permission to delete this trip' });
|
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 info = deleteTrip(req.params.id, authReq.user.id, authReq.user.role);
|
||||||
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: info.tripId, trip: info.title, ...(info.ownerEmail ? { owner: info.ownerEmail } : {}) } });
|
||||||
writeAudit({ userId: authReq.user.id, action: 'trip.delete', ip: getClientIp(req), details: { tripId: deletedTripId, trip: delTrip?.title, ...(ownerEmail ? { owner: ownerEmail } : {}) } });
|
if (info.isAdminDelete && info.ownerEmail) {
|
||||||
if (isAdminDel && ownerEmail) {
|
logInfo(`Admin ${authReq.user.email} deleted trip "${info.title}" owned by ${info.ownerEmail}`);
|
||||||
logInfo(`Admin ${authReq.user.email} deleted trip "${delTrip!.title}" owned by ${ownerEmail}`);
|
|
||||||
}
|
}
|
||||||
db.prepare('DELETE FROM trips WHERE id = ?').run(req.params.id);
|
|
||||||
res.json({ success: true });
|
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) => {
|
router.get('/:id/members', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const access = canAccessTrip(req.params.id, authReq.user.id);
|
const access = canAccessTrip(req.params.id, authReq.user.id);
|
||||||
if (!access)
|
if (!access)
|
||||||
return res.status(404).json({ error: 'Trip not found' });
|
return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
const tripOwnerId = access.user_id;
|
const { owner, members } = listMembers(req.params.id, access.user_id);
|
||||||
const members = db.prepare(`
|
res.json({ owner, members, current_user_id: authReq.user.id });
|
||||||
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,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Add member ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
router.post('/:id/members', authenticate, (req: Request, res: Response) => {
|
router.post('/:id/members', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const access = canAccessTrip(req.params.id, authReq.user.id);
|
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' });
|
return res.status(403).json({ error: 'No permission to manage members' });
|
||||||
|
|
||||||
const { identifier } = req.body;
|
const { identifier } = req.body;
|
||||||
if (!identifier) return res.status(400).json({ error: 'Email or username required' });
|
|
||||||
|
|
||||||
const target = db.prepare(
|
try {
|
||||||
'SELECT id, username, email, avatar FROM users WHERE email = ? OR username = ?'
|
const result = addMember(req.params.id, identifier, tripOwnerId, authReq.user.id);
|
||||||
).get(identifier.trim(), identifier.trim()) as Pick<User, 'id' | 'username' | 'email' | 'avatar'> | undefined;
|
|
||||||
|
|
||||||
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)
|
res.status(201).json({ member: result.member });
|
||||||
return res.status(400).json({ error: 'Trip owner is already a member' });
|
} catch (e: any) {
|
||||||
|
if (e instanceof NotFoundError) return res.status(404).json({ error: e.message });
|
||||||
const existing = db.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(req.params.id, target.id);
|
if (e instanceof ValidationError) return res.status(400).json({ error: e.message });
|
||||||
if (existing) return res.status(400).json({ error: 'User already has access' });
|
throw e;
|
||||||
|
}
|
||||||
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 } });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Remove member ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
router.delete('/:id/members/:userId', authenticate, (req: Request, res: Response) => {
|
router.delete('/:id/members/:userId', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
if (!canAccessTrip(req.params.id, authReq.user.id))
|
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' });
|
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 });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ICS calendar export
|
// ── ICS calendar export ───────────────────────────────────────────────────
|
||||||
|
|
||||||
router.get('/:id/export.ics', authenticate, (req: Request, res: Response) => {
|
router.get('/:id/export.ics', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
if (!canAccessTrip(req.params.id, authReq.user.id))
|
if (!canAccessTrip(req.params.id, authReq.user.id))
|
||||||
return res.status(404).json({ error: 'Trip not found' });
|
return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(req.params.id) as any;
|
try {
|
||||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
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[];
|
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
|
||||||
const reservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ?').all(req.params.id) as any[];
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
|
res.send(ics);
|
||||||
const esc = (s: string) => s
|
} catch (e: any) {
|
||||||
.replace(/\\/g, '\\\\')
|
if (e instanceof NotFoundError) return res.status(404).json({ error: e.message });
|
||||||
.replace(/;/g, '\\;')
|
throw e;
|
||||||
.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';
|
|
||||||
|
|
||||||
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;
|
export default router;
|
||||||
|
|||||||
@@ -1,320 +1,60 @@
|
|||||||
import express, { Request, Response } from 'express';
|
import express, { Request, Response } from 'express';
|
||||||
import { db } from '../db/database';
|
|
||||||
import { authenticate } from '../middleware/auth';
|
import { authenticate } from '../middleware/auth';
|
||||||
import { AuthRequest } from '../types';
|
import { AuthRequest } from '../types';
|
||||||
|
import * as svc from '../services/vacayService';
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
router.use(authenticate);
|
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) => {
|
router.get('/plan', (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const plan = getActivePlan(authReq.user.id);
|
res.json(svc.getPlanData(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,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/plan', async (req: Request, res: Response) => {
|
router.put('/plan', async (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const planId = getActivePlanId(authReq.user.id);
|
const planId = svc.getActivePlanId(authReq.user.id);
|
||||||
const { block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled, weekend_days } = req.body;
|
const result = await svc.updatePlan(planId, req.body, req.headers['x-socket-id'] as string);
|
||||||
|
res.json(result);
|
||||||
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 }
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/plan/holiday-calendars', (req: Request, res: Response) => {
|
router.post('/plan/holiday-calendars', (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { region, label, color, sort_order } = req.body;
|
const { region, label, color, sort_order } = req.body;
|
||||||
if (!region) return res.status(400).json({ error: 'region required' });
|
if (!region) return res.status(400).json({ error: 'region required' });
|
||||||
const planId = getActivePlanId(authReq.user.id);
|
const planId = svc.getActivePlanId(authReq.user.id);
|
||||||
const result = db.prepare(
|
const calendar = svc.addHolidayCalendar(planId, region, label, color, sort_order, req.headers['x-socket-id'] as string);
|
||||||
'INSERT INTO vacay_holiday_calendars (plan_id, region, label, color, sort_order) VALUES (?, ?, ?, ?, ?)'
|
res.json({ calendar });
|
||||||
).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 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/plan/holiday-calendars/:id', (req: Request, res: Response) => {
|
router.put('/plan/holiday-calendars/:id', (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
const planId = getActivePlanId(authReq.user.id);
|
const planId = svc.getActivePlanId(authReq.user.id);
|
||||||
const cal = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ? AND plan_id = ?').get(id, planId) as VacayHolidayCalendar | undefined;
|
const calendar = svc.updateHolidayCalendar(id, planId, req.body, req.headers['x-socket-id'] as string);
|
||||||
if (!cal) return res.status(404).json({ error: 'Calendar not found' });
|
if (!calendar) return res.status(404).json({ error: 'Calendar not found' });
|
||||||
const { region, label, color, sort_order } = req.body;
|
res.json({ calendar });
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/plan/holiday-calendars/:id', (req: Request, res: Response) => {
|
router.delete('/plan/holiday-calendars/:id', (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const id = parseInt(req.params.id);
|
const id = parseInt(req.params.id);
|
||||||
const planId = getActivePlanId(authReq.user.id);
|
const planId = svc.getActivePlanId(authReq.user.id);
|
||||||
const cal = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ? AND plan_id = ?').get(id, planId);
|
const deleted = svc.deleteHolidayCalendar(id, planId, req.headers['x-socket-id'] as string);
|
||||||
if (!cal) return res.status(404).json({ error: 'Calendar not found' });
|
if (!deleted) 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');
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/color', (req: Request, res: Response) => {
|
router.put('/color', (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { color, target_user_id } = req.body;
|
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 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)) {
|
if (!planUsers.find(u => u.id === userId)) {
|
||||||
return res.status(403).json({ error: 'User not in plan' });
|
return res.status(403).json({ error: 'User not in plan' });
|
||||||
}
|
}
|
||||||
db.prepare(`
|
svc.setUserColor(userId, planId, color, req.headers['x-socket-id'] as string);
|
||||||
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');
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -322,345 +62,133 @@ router.post('/invite', (req: Request, res: Response) => {
|
|||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { user_id } = req.body;
|
const { user_id } = req.body;
|
||||||
if (!user_id) return res.status(400).json({ error: 'user_id required' });
|
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 plan = svc.getActivePlan(authReq.user.id);
|
||||||
|
const result = svc.sendInvite(plan.id, authReq.user.id, authReq.user.username, authReq.user.email, user_id);
|
||||||
const targetUser = db.prepare('SELECT id, username FROM users WHERE id = ?').get(user_id);
|
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||||
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(() => {});
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/invite/accept', (req: Request, res: Response) => {
|
router.post('/invite/accept', (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { plan_id } = req.body;
|
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;
|
const result = svc.acceptInvite(authReq.user.id, plan_id, req.headers['x-socket-id'] as string);
|
||||||
if (!invite) return res.status(404).json({ error: 'No pending invite' });
|
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||||
|
|
||||||
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');
|
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/invite/decline', (req: Request, res: Response) => {
|
router.post('/invite/decline', (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { plan_id } = req.body;
|
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);
|
svc.declineInvite(authReq.user.id, plan_id, req.headers['x-socket-id'] as string);
|
||||||
notifyPlanUsers(plan_id, req.headers['x-socket-id'] as string, 'vacay:declined');
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/invite/cancel', (req: Request, res: Response) => {
|
router.post('/invite/cancel', (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { user_id } = req.body;
|
const { user_id } = req.body;
|
||||||
const plan = getActivePlan(authReq.user.id);
|
const plan = svc.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);
|
svc.cancelInvite(plan.id, user_id);
|
||||||
|
|
||||||
try {
|
|
||||||
const { broadcastToUser } = require('../websocket');
|
|
||||||
broadcastToUser(user_id, { type: 'vacay:cancelled' });
|
|
||||||
} catch { /* */ }
|
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/dissolve', (req: Request, res: Response) => {
|
router.post('/dissolve', (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const plan = getActivePlan(authReq.user.id);
|
svc.dissolvePlan(authReq.user.id, req.headers['x-socket-id'] as string);
|
||||||
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 { /* */ }
|
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/available-users', (req: Request, res: Response) => {
|
router.get('/available-users', (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const planId = getActivePlanId(authReq.user.id);
|
const planId = svc.getActivePlanId(authReq.user.id);
|
||||||
const users = db.prepare(`
|
const users = svc.getAvailableUsers(authReq.user.id, planId);
|
||||||
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);
|
|
||||||
res.json({ users });
|
res.json({ users });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/years', (req: Request, res: Response) => {
|
router.get('/years', (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const planId = getActivePlanId(authReq.user.id);
|
const planId = svc.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: svc.listYears(planId) });
|
||||||
res.json({ years: years.map(y => y.year) });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/years', (req: Request, res: Response) => {
|
router.post('/years', (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { year } = req.body;
|
const { year } = req.body;
|
||||||
if (!year) return res.status(400).json({ error: 'Year required' });
|
if (!year) return res.status(400).json({ error: 'Year required' });
|
||||||
const planId = getActivePlanId(authReq.user.id);
|
const planId = svc.getActivePlanId(authReq.user.id);
|
||||||
try {
|
const years = svc.addYear(planId, year, req.headers['x-socket-id'] as string);
|
||||||
db.prepare('INSERT INTO vacay_years (plan_id, year) VALUES (?, ?)').run(planId, year);
|
res.json({ years });
|
||||||
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) });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/years/:year', (req: Request, res: Response) => {
|
router.delete('/years/:year', (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const year = parseInt(req.params.year);
|
const year = parseInt(req.params.year);
|
||||||
const planId = getActivePlanId(authReq.user.id);
|
const planId = svc.getActivePlanId(authReq.user.id);
|
||||||
db.prepare('DELETE FROM vacay_years WHERE plan_id = ? AND year = ?').run(planId, year);
|
const years = svc.deleteYear(planId, year, req.headers['x-socket-id'] as string);
|
||||||
db.prepare("DELETE FROM vacay_entries WHERE plan_id = ? AND date LIKE ?").run(planId, `${year}-%`);
|
res.json({ years });
|
||||||
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) });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/entries/:year', (req: Request, res: Response) => {
|
router.get('/entries/:year', (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const year = req.params.year;
|
const planId = svc.getActivePlanId(authReq.user.id);
|
||||||
const planId = getActivePlanId(authReq.user.id);
|
res.json(svc.getEntries(planId, req.params.year));
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/entries/toggle', (req: Request, res: Response) => {
|
router.post('/entries/toggle', (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { date, target_user_id } = req.body;
|
const { date, target_user_id } = req.body;
|
||||||
if (!date) return res.status(400).json({ error: 'date required' });
|
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;
|
let userId = authReq.user.id;
|
||||||
if (target_user_id && parseInt(target_user_id) !== 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);
|
const tid = parseInt(target_user_id);
|
||||||
if (!planUsers.find(u => u.id === tid)) {
|
if (!planUsers.find(u => u.id === tid)) {
|
||||||
return res.status(403).json({ error: 'User not in plan' });
|
return res.status(403).json({ error: 'User not in plan' });
|
||||||
}
|
}
|
||||||
userId = tid;
|
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;
|
res.json(svc.toggleEntry(userId, planId, date, req.headers['x-socket-id'] as string));
|
||||||
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' });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/entries/company-holiday', (req: Request, res: Response) => {
|
router.post('/entries/company-holiday', (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { date, note } = req.body;
|
const { date, note } = req.body;
|
||||||
const planId = getActivePlanId(authReq.user.id);
|
const planId = svc.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;
|
res.json(svc.toggleCompanyHoliday(planId, date, note, req.headers['x-socket-id'] as string));
|
||||||
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' });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/stats/:year', (req: Request, res: Response) => {
|
router.get('/stats/:year', (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const year = parseInt(req.params.year);
|
const year = parseInt(req.params.year);
|
||||||
const planId = getActivePlanId(authReq.user.id);
|
const planId = svc.getActivePlanId(authReq.user.id);
|
||||||
const plan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan | undefined;
|
res.json({ stats: svc.getStats(planId, year) });
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/stats/:year', (req: Request, res: Response) => {
|
router.put('/stats/:year', (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const year = parseInt(req.params.year);
|
const year = parseInt(req.params.year);
|
||||||
const { vacation_days, target_user_id } = req.body;
|
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 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)) {
|
if (!planUsers.find(u => u.id === userId)) {
|
||||||
return res.status(403).json({ error: 'User not in plan' });
|
return res.status(403).json({ error: 'User not in plan' });
|
||||||
}
|
}
|
||||||
db.prepare(`
|
svc.updateStats(userId, planId, year, vacation_days, req.headers['x-socket-id'] as string);
|
||||||
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);
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/holidays/countries', async (_req: Request, res: Response) => {
|
router.get('/holidays/countries', async (_req: Request, res: Response) => {
|
||||||
const cacheKey = 'countries';
|
const result = await svc.getCountries();
|
||||||
const cached = holidayCache.get(cacheKey);
|
if (result.error) return res.status(502).json({ error: result.error });
|
||||||
if (cached && Date.now() - cached.time < CACHE_TTL) return res.json(cached.data);
|
res.json(result.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' });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/holidays/:year/:country', async (req: Request, res: Response) => {
|
router.get('/holidays/:year/:country', async (req: Request, res: Response) => {
|
||||||
const { year, country } = req.params;
|
const { year, country } = req.params;
|
||||||
const cacheKey = `${year}-${country}`;
|
const result = await svc.getHolidays(year, country);
|
||||||
const cached = holidayCache.get(cacheKey);
|
if (result.error) return res.status(502).json({ error: result.error });
|
||||||
if (cached && Date.now() - cached.time < CACHE_TTL) return res.json(cached.data);
|
res.json(result.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' });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,146 +1,9 @@
|
|||||||
import express, { Request, Response } from 'express';
|
import express, { Request, Response } from 'express';
|
||||||
import fetch from 'node-fetch';
|
|
||||||
import { authenticate } from '../middleware/auth';
|
import { authenticate } from '../middleware/auth';
|
||||||
|
import { getWeather, getDetailedWeather, ApiError } from '../services/weatherService';
|
||||||
|
|
||||||
const router = express.Router();
|
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) => {
|
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 };
|
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' });
|
return res.status(400).json({ error: 'Latitude and longitude are required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const ck = cacheKey(lat, lng, date);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (date) {
|
const result = await getWeather(lat, lng, date, lang);
|
||||||
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);
|
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
return res.status(err.status).json({ error: err.message });
|
||||||
|
}
|
||||||
console.error('Weather error:', err);
|
console.error('Weather error:', err);
|
||||||
res.status(500).json({ error: 'Error fetching weather data' });
|
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' });
|
return res.status(400).json({ error: 'Latitude, longitude, and date are required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const ck = `detailed_${cacheKey(lat, lng, date)}`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cached = getCached(ck);
|
const result = await getDetailedWeather(lat, lng, date, lang);
|
||||||
if (cached) return res.json(cached);
|
res.json(result);
|
||||||
|
|
||||||
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);
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
return res.status(err.status).json({ error: err.message });
|
||||||
|
}
|
||||||
console.error('Detailed weather error:', err);
|
console.error('Detailed weather error:', err);
|
||||||
res.status(500).json({ error: 'Error fetching detailed weather data' });
|
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