refactor: extract business logic from routes into reusable service modules

This commit is contained in:
Maurice
2026-04-02 17:14:53 +02:00
parent f0131632a7
commit 979322025d
48 changed files with 8851 additions and 6182 deletions
+516
View File
@@ -0,0 +1,516 @@
import bcrypt from 'bcryptjs';
import crypto from 'crypto';
import path from 'path';
import fs from 'fs';
import { db } from '../db/database';
import { User, Addon } from '../types';
import { updateJwtSecret } from '../config';
import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto';
import { getAllPermissions, savePermissions as savePerms, PERMISSION_ACTIONS } from './permissions';
import { revokeUserSessions } from '../mcp';
import { validatePassword } from './passwordPolicy';
// ── Helpers ────────────────────────────────────────────────────────────────
export function utcSuffix(ts: string | null | undefined): string | null {
if (!ts) return null;
return ts.endsWith('Z') ? ts : ts.replace(' ', 'T') + 'Z';
}
export function compareVersions(a: string, b: string): number {
const pa = a.split('.').map(Number);
const pb = b.split('.').map(Number);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const na = pa[i] || 0, nb = pb[i] || 0;
if (na > nb) return 1;
if (na < nb) return -1;
}
return 0;
}
export const isDocker = (() => {
try {
return fs.existsSync('/.dockerenv') || (fs.existsSync('/proc/1/cgroup') && fs.readFileSync('/proc/1/cgroup', 'utf8').includes('docker'));
} catch { return false; }
})();
// ── User CRUD ──────────────────────────────────────────────────────────────
export function listUsers() {
const users = db.prepare(
'SELECT id, username, email, role, created_at, updated_at, last_login FROM users ORDER BY created_at DESC'
).all() as Pick<User, 'id' | 'username' | 'email' | 'role' | 'created_at' | 'updated_at' | 'last_login'>[];
let onlineUserIds = new Set<number>();
try {
const { getOnlineUserIds } = require('../websocket');
onlineUserIds = getOnlineUserIds();
} catch { /* */ }
return users.map(u => ({
...u,
created_at: utcSuffix(u.created_at),
updated_at: utcSuffix(u.updated_at as string),
last_login: utcSuffix(u.last_login),
online: onlineUserIds.has(u.id),
}));
}
export function createUser(data: { username: string; email: string; password: string; role?: string }) {
const username = data.username?.trim();
const email = data.email?.trim();
const password = data.password?.trim();
if (!username || !email || !password) {
return { error: 'Username, email and password are required', status: 400 };
}
const pwCheck = validatePassword(password);
if (!pwCheck.ok) return { error: pwCheck.reason, status: 400 };
if (data.role && !['user', 'admin'].includes(data.role)) {
return { error: 'Invalid role', status: 400 };
}
const existingUsername = db.prepare('SELECT id FROM users WHERE username = ?').get(username);
if (existingUsername) return { error: 'Username already taken', status: 409 };
const existingEmail = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
if (existingEmail) return { error: 'Email already taken', status: 409 };
const passwordHash = bcrypt.hashSync(password, 12);
const result = db.prepare(
'INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)'
).run(username, email, passwordHash, data.role || 'user');
const user = db.prepare(
'SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?'
).get(result.lastInsertRowid);
return {
user,
insertedId: Number(result.lastInsertRowid),
auditDetails: { username, email, role: data.role || 'user' },
};
}
export function updateUser(id: string, data: { username?: string; email?: string; role?: string; password?: string }) {
const { username, email, role, password } = data;
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(id) as User | undefined;
if (!user) return { error: 'User not found', status: 404 };
if (role && !['user', 'admin'].includes(role)) {
return { error: 'Invalid role', status: 400 };
}
if (username && username !== user.username) {
const conflict = db.prepare('SELECT id FROM users WHERE username = ? AND id != ?').get(username, id);
if (conflict) return { error: 'Username already taken', status: 409 };
}
if (email && email !== user.email) {
const conflict = db.prepare('SELECT id FROM users WHERE email = ? AND id != ?').get(email, id);
if (conflict) return { error: 'Email already taken', status: 409 };
}
if (password) {
const pwCheck = validatePassword(password);
if (!pwCheck.ok) return { error: pwCheck.reason, status: 400 };
}
const passwordHash = password ? bcrypt.hashSync(password, 12) : null;
db.prepare(`
UPDATE users SET
username = COALESCE(?, username),
email = COALESCE(?, email),
role = COALESCE(?, role),
password_hash = COALESCE(?, password_hash),
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(username || null, email || null, role || null, passwordHash, id);
const updated = db.prepare(
'SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?'
).get(id);
const changed: string[] = [];
if (username) changed.push('username');
if (email) changed.push('email');
if (role) changed.push('role');
if (password) changed.push('password');
return {
user: updated,
previousEmail: user.email,
changed,
};
}
export function deleteUser(id: string, currentUserId: number) {
if (parseInt(id) === currentUserId) {
return { error: 'Cannot delete own account', status: 400 };
}
const userToDel = db.prepare('SELECT id, email FROM users WHERE id = ?').get(id) as { id: number; email: string } | undefined;
if (!userToDel) return { error: 'User not found', status: 404 };
db.prepare('DELETE FROM users WHERE id = ?').run(id);
return { email: userToDel.email };
}
// ── Stats ──────────────────────────────────────────────────────────────────
export function getStats() {
const totalUsers = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
const totalTrips = (db.prepare('SELECT COUNT(*) as count FROM trips').get() as { count: number }).count;
const totalPlaces = (db.prepare('SELECT COUNT(*) as count FROM places').get() as { count: number }).count;
const totalFiles = (db.prepare('SELECT COUNT(*) as count FROM trip_files').get() as { count: number }).count;
return { totalUsers, totalTrips, totalPlaces, totalFiles };
}
// ── Permissions ────────────────────────────────────────────────────────────
export function getPermissions() {
const current = getAllPermissions();
const actions = PERMISSION_ACTIONS.map(a => ({
key: a.key,
level: current[a.key],
defaultLevel: a.defaultLevel,
allowedLevels: a.allowedLevels,
}));
return { permissions: actions };
}
export function savePermissions(permissions: Record<string, string>) {
const { skipped } = savePerms(permissions);
return { permissions: getAllPermissions(), skipped };
}
// ── Audit Log ──────────────────────────────────────────────────────────────
export function getAuditLog(query: { limit?: string; offset?: string }) {
const limitRaw = parseInt(String(query.limit || '100'), 10);
const offsetRaw = parseInt(String(query.offset || '0'), 10);
const limit = Math.min(Math.max(Number.isFinite(limitRaw) ? limitRaw : 100, 1), 500);
const offset = Math.max(Number.isFinite(offsetRaw) ? offsetRaw : 0, 0);
type Row = {
id: number;
created_at: string;
user_id: number | null;
username: string | null;
user_email: string | null;
action: string;
resource: string | null;
details: string | null;
ip: string | null;
};
const rows = db.prepare(`
SELECT a.id, a.created_at, a.user_id, u.username, u.email as user_email, a.action, a.resource, a.details, a.ip
FROM audit_log a
LEFT JOIN users u ON u.id = a.user_id
ORDER BY a.id DESC
LIMIT ? OFFSET ?
`).all(limit, offset) as Row[];
const total = (db.prepare('SELECT COUNT(*) as c FROM audit_log').get() as { c: number }).c;
const entries = rows.map((r) => {
let details: Record<string, unknown> | null = null;
if (r.details) {
try {
details = JSON.parse(r.details) as Record<string, unknown>;
} catch {
details = { _parse_error: true };
}
}
const created_at = r.created_at && !r.created_at.endsWith('Z') ? r.created_at.replace(' ', 'T') + 'Z' : r.created_at;
return { ...r, created_at, details };
});
return { entries, total, limit, offset };
}
// ── OIDC Settings ──────────────────────────────────────────────────────────
export function getOidcSettings() {
const get = (key: string) => (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || '';
const secret = decrypt_api_key(get('oidc_client_secret'));
return {
issuer: get('oidc_issuer'),
client_id: get('oidc_client_id'),
client_secret_set: !!secret,
display_name: get('oidc_display_name'),
oidc_only: get('oidc_only') === 'true',
discovery_url: get('oidc_discovery_url'),
};
}
export function updateOidcSettings(data: {
issuer?: string;
client_id?: string;
client_secret?: string;
display_name?: string;
oidc_only?: boolean;
discovery_url?: string;
}) {
const set = (key: string, val: string) => db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val || '');
set('oidc_issuer', data.issuer ?? '');
set('oidc_client_id', data.client_id ?? '');
if (data.client_secret !== undefined) set('oidc_client_secret', maybe_encrypt_api_key(data.client_secret) ?? '');
set('oidc_display_name', data.display_name ?? '');
set('oidc_only', data.oidc_only ? 'true' : 'false');
set('oidc_discovery_url', data.discovery_url ?? '');
}
// ── Demo Baseline ──────────────────────────────────────────────────────────
export function saveDemoBaseline(): { error?: string; status?: number; message?: string } {
if (process.env.DEMO_MODE !== 'true') {
return { error: 'Not found', status: 404 };
}
try {
const { saveBaseline } = require('../demo/demo-reset');
saveBaseline();
return { message: 'Demo baseline saved. Hourly resets will restore to this state.' };
} catch (err: unknown) {
console.error(err);
return { error: 'Failed to save baseline', status: 500 };
}
}
// ── GitHub Integration ─────────────────────────────────────────────────────
export async function getGithubReleases(perPage: string = '10', page: string = '1') {
try {
const resp = await fetch(
`https://api.github.com/repos/mauriceboe/TREK/releases?per_page=${perPage}&page=${page}`,
{ headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } }
);
if (!resp.ok) return [];
const data = await resp.json();
return Array.isArray(data) ? data : [];
} catch {
return [];
}
}
export async function checkVersion() {
const { version: currentVersion } = require('../../package.json');
try {
const resp = await fetch(
'https://api.github.com/repos/mauriceboe/TREK/releases/latest',
{ headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } }
);
if (!resp.ok) return { current: currentVersion, latest: currentVersion, update_available: false };
const data = await resp.json() as { tag_name?: string; html_url?: string };
const latest = (data.tag_name || '').replace(/^v/, '');
const update_available = latest && latest !== currentVersion && compareVersions(latest, currentVersion) > 0;
return { current: currentVersion, latest, update_available, release_url: data.html_url || '', is_docker: isDocker };
} catch {
return { current: currentVersion, latest: currentVersion, update_available: false, is_docker: isDocker };
}
}
// ── Invite Tokens ──────────────────────────────────────────────────────────
export function listInvites() {
return db.prepare(`
SELECT i.*, u.username as created_by_name
FROM invite_tokens i
JOIN users u ON i.created_by = u.id
ORDER BY i.created_at DESC
`).all();
}
export function createInvite(createdBy: number, data: { max_uses?: string | number; expires_in_days?: string | number }) {
const rawUses = parseInt(String(data.max_uses));
const uses = rawUses === 0 ? 0 : Math.min(Math.max(rawUses || 1, 1), 5);
const token = crypto.randomBytes(16).toString('hex');
const expiresAt = data.expires_in_days
? new Date(Date.now() + parseInt(String(data.expires_in_days)) * 86400000).toISOString()
: null;
const ins = db.prepare(
'INSERT INTO invite_tokens (token, max_uses, expires_at, created_by) VALUES (?, ?, ?, ?)'
).run(token, uses, expiresAt, createdBy);
const inviteId = Number(ins.lastInsertRowid);
const invite = db.prepare(`
SELECT i.*, u.username as created_by_name
FROM invite_tokens i
JOIN users u ON i.created_by = u.id
WHERE i.id = ?
`).get(inviteId);
return { invite, inviteId, uses, expiresInDays: data.expires_in_days ?? null };
}
export function deleteInvite(id: string) {
const invite = db.prepare('SELECT id FROM invite_tokens WHERE id = ?').get(id);
if (!invite) return { error: 'Invite not found', status: 404 };
db.prepare('DELETE FROM invite_tokens WHERE id = ?').run(id);
return {};
}
// ── Bag Tracking ───────────────────────────────────────────────────────────
export function getBagTracking() {
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'bag_tracking_enabled'").get() as { value: string } | undefined;
return { enabled: row?.value === 'true' };
}
export function updateBagTracking(enabled: boolean) {
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('bag_tracking_enabled', ?)").run(enabled ? 'true' : 'false');
return { enabled: !!enabled };
}
// ── Packing Templates ──────────────────────────────────────────────────────
export function listPackingTemplates() {
return db.prepare(`
SELECT pt.*, u.username as created_by_name,
(SELECT COUNT(*) FROM packing_template_items ti JOIN packing_template_categories tc ON ti.category_id = tc.id WHERE tc.template_id = pt.id) as item_count,
(SELECT COUNT(*) FROM packing_template_categories WHERE template_id = pt.id) as category_count
FROM packing_templates pt
JOIN users u ON pt.created_by = u.id
ORDER BY pt.created_at DESC
`).all();
}
export function getPackingTemplate(id: string) {
const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(id);
if (!template) return { error: 'Template not found', status: 404 };
const categories = db.prepare('SELECT * FROM packing_template_categories WHERE template_id = ? ORDER BY sort_order, id').all(id) as any[];
const items = db.prepare(`
SELECT ti.* FROM packing_template_items ti
JOIN packing_template_categories tc ON ti.category_id = tc.id
WHERE tc.template_id = ? ORDER BY ti.sort_order, ti.id
`).all(id);
return { template, categories, items };
}
export function createPackingTemplate(name: string, createdBy: number) {
if (!name?.trim()) return { error: 'Name is required', status: 400 };
const result = db.prepare('INSERT INTO packing_templates (name, created_by) VALUES (?, ?)').run(name.trim(), createdBy);
const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(result.lastInsertRowid);
return { template };
}
export function updatePackingTemplate(id: string, data: { name?: string }) {
const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(id);
if (!template) return { error: 'Template not found', status: 404 };
if (data.name?.trim()) db.prepare('UPDATE packing_templates SET name = ? WHERE id = ?').run(data.name.trim(), id);
return { template: db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(id) };
}
export function deletePackingTemplate(id: string) {
const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(id) as { name?: string } | undefined;
if (!template) return { error: 'Template not found', status: 404 };
db.prepare('DELETE FROM packing_templates WHERE id = ?').run(id);
return { name: template.name };
}
// Template categories
export function createTemplateCategory(templateId: string, name: string) {
if (!name?.trim()) return { error: 'Category name is required', status: 400 };
const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(templateId);
if (!template) return { error: 'Template not found', status: 404 };
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_template_categories WHERE template_id = ?').get(templateId) as { max: number | null };
const result = db.prepare('INSERT INTO packing_template_categories (template_id, name, sort_order) VALUES (?, ?, ?)').run(templateId, name.trim(), (maxOrder.max ?? -1) + 1);
return { category: db.prepare('SELECT * FROM packing_template_categories WHERE id = ?').get(result.lastInsertRowid) };
}
export function updateTemplateCategory(templateId: string, catId: string, data: { name?: string }) {
const cat = db.prepare('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?').get(catId, templateId);
if (!cat) return { error: 'Category not found', status: 404 };
if (data.name?.trim()) db.prepare('UPDATE packing_template_categories SET name = ? WHERE id = ?').run(data.name.trim(), catId);
return { category: db.prepare('SELECT * FROM packing_template_categories WHERE id = ?').get(catId) };
}
export function deleteTemplateCategory(templateId: string, catId: string) {
const cat = db.prepare('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?').get(catId, templateId);
if (!cat) return { error: 'Category not found', status: 404 };
db.prepare('DELETE FROM packing_template_categories WHERE id = ?').run(catId);
return {};
}
// Template items
export function createTemplateItem(templateId: string, catId: string, name: string) {
if (!name?.trim()) return { error: 'Item name is required', status: 400 };
const cat = db.prepare('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?').get(catId, templateId);
if (!cat) return { error: 'Category not found', status: 404 };
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_template_items WHERE category_id = ?').get(catId) as { max: number | null };
const result = db.prepare('INSERT INTO packing_template_items (category_id, name, sort_order) VALUES (?, ?, ?)').run(catId, name.trim(), (maxOrder.max ?? -1) + 1);
return { item: db.prepare('SELECT * FROM packing_template_items WHERE id = ?').get(result.lastInsertRowid) };
}
export function updateTemplateItem(itemId: string, data: { name?: string }) {
const item = db.prepare('SELECT * FROM packing_template_items WHERE id = ?').get(itemId);
if (!item) return { error: 'Item not found', status: 404 };
if (data.name?.trim()) db.prepare('UPDATE packing_template_items SET name = ? WHERE id = ?').run(data.name.trim(), itemId);
return { item: db.prepare('SELECT * FROM packing_template_items WHERE id = ?').get(itemId) };
}
export function deleteTemplateItem(itemId: string) {
const item = db.prepare('SELECT * FROM packing_template_items WHERE id = ?').get(itemId);
if (!item) return { error: 'Item not found', status: 404 };
db.prepare('DELETE FROM packing_template_items WHERE id = ?').run(itemId);
return {};
}
// ── Addons ─────────────────────────────────────────────────────────────────
export function listAddons() {
const addons = db.prepare('SELECT * FROM addons ORDER BY sort_order, id').all() as Addon[];
return addons.map(a => ({ ...a, enabled: !!a.enabled, config: JSON.parse(a.config || '{}') }));
}
export function updateAddon(id: string, data: { enabled?: boolean; config?: Record<string, unknown> }) {
const addon = db.prepare('SELECT * FROM addons WHERE id = ?').get(id);
if (!addon) return { error: 'Addon not found', status: 404 };
if (data.enabled !== undefined) db.prepare('UPDATE addons SET enabled = ? WHERE id = ?').run(data.enabled ? 1 : 0, id);
if (data.config !== undefined) db.prepare('UPDATE addons SET config = ? WHERE id = ?').run(JSON.stringify(data.config), id);
const updated = db.prepare('SELECT * FROM addons WHERE id = ?').get(id) as Addon;
return {
addon: { ...updated, enabled: !!updated.enabled, config: JSON.parse(updated.config || '{}') },
auditDetails: { enabled: data.enabled !== undefined ? !!data.enabled : undefined, config_changed: data.config !== undefined },
};
}
// ── MCP Tokens ─────────────────────────────────────────────────────────────
export function listMcpTokens() {
return db.prepare(`
SELECT t.id, t.name, t.token_prefix, t.created_at, t.last_used_at, t.user_id, u.username
FROM mcp_tokens t
JOIN users u ON u.id = t.user_id
ORDER BY t.created_at DESC
`).all();
}
export function deleteMcpToken(id: string) {
const token = db.prepare('SELECT id, user_id FROM mcp_tokens WHERE id = ?').get(id) as { id: number; user_id: number } | undefined;
if (!token) return { error: 'Token not found', status: 404 };
db.prepare('DELETE FROM mcp_tokens WHERE id = ?').run(id);
revokeUserSessions(token.user_id);
return {};
}
// ── JWT Rotation ───────────────────────────────────────────────────────────
export function rotateJwtSecret(): { error?: string; status?: number } {
const newSecret = crypto.randomBytes(32).toString('hex');
const dataDir = path.resolve(__dirname, '../../data');
const secretFile = path.join(dataDir, '.jwt_secret');
try {
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
fs.writeFileSync(secretFile, newSecret, { mode: 0o600 });
} catch (err: unknown) {
return { error: 'Failed to persist new JWT secret to disk', status: 500 };
}
updateJwtSecret(newSecret);
return {};
}
+184
View File
@@ -0,0 +1,184 @@
import { db } from '../db/database';
import { loadTagsByPlaceIds, loadParticipantsByAssignmentIds, formatAssignmentWithPlace } from './queryHelpers';
import { AssignmentRow, DayAssignment } from '../types';
export function getAssignmentWithPlace(assignmentId: number | bigint) {
const a = db.prepare(`
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
COALESCE(da.assignment_time, p.place_time) as place_time,
COALESCE(da.assignment_end_time, p.end_time) as end_time,
p.duration_minutes, p.notes as place_notes,
p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone,
c.name as category_name, c.color as category_color, c.icon as category_icon
FROM day_assignments da
JOIN places p ON da.place_id = p.id
LEFT JOIN categories c ON p.category_id = c.id
WHERE da.id = ?
`).get(assignmentId) as AssignmentRow | undefined;
if (!a) return null;
const tags = db.prepare(`
SELECT t.* FROM tags t
JOIN place_tags pt ON t.id = pt.tag_id
WHERE pt.place_id = ?
`).all(a.place_id);
const participants = db.prepare(`
SELECT ap.user_id, u.username, u.avatar
FROM assignment_participants ap
JOIN users u ON ap.user_id = u.id
WHERE ap.assignment_id = ?
`).all(a.id);
return {
id: a.id,
day_id: a.day_id,
order_index: a.order_index,
notes: a.notes,
participants,
created_at: a.created_at,
place: {
id: a.place_id,
name: a.place_name,
description: a.place_description,
lat: a.lat,
lng: a.lng,
address: a.address,
category_id: a.category_id,
price: a.price,
currency: a.place_currency,
place_time: a.place_time,
end_time: a.end_time,
duration_minutes: a.duration_minutes,
notes: a.place_notes,
image_url: a.image_url,
transport_mode: a.transport_mode,
google_place_id: a.google_place_id,
website: a.website,
phone: a.phone,
category: a.category_id ? {
id: a.category_id,
name: a.category_name,
color: a.category_color,
icon: a.category_icon,
} : null,
tags,
}
};
}
export function listDayAssignments(dayId: string | number) {
const assignments = db.prepare(`
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
COALESCE(da.assignment_time, p.place_time) as place_time,
COALESCE(da.assignment_end_time, p.end_time) as end_time,
p.duration_minutes, p.notes as place_notes,
p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone,
c.name as category_name, c.color as category_color, c.icon as category_icon
FROM day_assignments da
JOIN places p ON da.place_id = p.id
LEFT JOIN categories c ON p.category_id = c.id
WHERE da.day_id = ?
ORDER BY da.order_index ASC, da.created_at ASC
`).all(dayId) as AssignmentRow[];
const placeIds = [...new Set(assignments.map(a => a.place_id))];
const tagsByPlaceId = loadTagsByPlaceIds(placeIds, { compact: true });
const assignmentIds = assignments.map(a => a.id);
const participantsByAssignment = loadParticipantsByAssignmentIds(assignmentIds);
return assignments.map(a => {
return formatAssignmentWithPlace(a, tagsByPlaceId[a.place_id] || [], participantsByAssignment[a.id] || []);
});
}
export function dayExists(dayId: string | number, tripId: string | number) {
return !!db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
}
export function placeExists(placeId: string | number, tripId: string | number) {
return !!db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId);
}
export function createAssignment(dayId: string | number, placeId: string | number, notes: string | null) {
const maxOrder = db.prepare('SELECT MAX(order_index) as max FROM day_assignments WHERE day_id = ?').get(dayId) as { max: number | null };
const orderIndex = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
const result = db.prepare(
'INSERT INTO day_assignments (day_id, place_id, order_index, notes) VALUES (?, ?, ?, ?)'
).run(dayId, placeId, orderIndex, notes || null);
return getAssignmentWithPlace(result.lastInsertRowid);
}
export function assignmentExistsInDay(id: string | number, dayId: string | number, tripId: string | number) {
return !!db.prepare(
'SELECT da.id FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE da.id = ? AND da.day_id = ? AND d.trip_id = ?'
).get(id, dayId, tripId);
}
export function deleteAssignment(id: string | number) {
db.prepare('DELETE FROM day_assignments WHERE id = ?').run(id);
}
export function reorderAssignments(dayId: string | number, orderedIds: number[]) {
const update = db.prepare('UPDATE day_assignments SET order_index = ? WHERE id = ? AND day_id = ?');
db.exec('BEGIN');
try {
orderedIds.forEach((id: number, index: number) => {
update.run(index, id, dayId);
});
db.exec('COMMIT');
} catch (e) {
db.exec('ROLLBACK');
throw e;
}
}
export function getAssignmentForTrip(id: string | number, tripId: string | number) {
return db.prepare(`
SELECT da.* FROM day_assignments da
JOIN days d ON da.day_id = d.id
WHERE da.id = ? AND d.trip_id = ?
`).get(id, tripId) as DayAssignment | undefined;
}
export function moveAssignment(id: string | number, newDayId: string | number, orderIndex: number, oldDayId: number) {
db.prepare('UPDATE day_assignments SET day_id = ?, order_index = ? WHERE id = ?').run(newDayId, orderIndex || 0, id);
const updated = getAssignmentWithPlace(Number(id));
return { assignment: updated, oldDayId };
}
export function getParticipants(assignmentId: string | number) {
return db.prepare(`
SELECT ap.user_id, u.username, u.avatar
FROM assignment_participants ap
JOIN users u ON ap.user_id = u.id
WHERE ap.assignment_id = ?
`).all(assignmentId);
}
export function updateTime(id: string | number, placeTime: string | null, endTime: string | null) {
db.prepare('UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ? WHERE id = ?')
.run(placeTime ?? null, endTime ?? null, id);
return getAssignmentWithPlace(Number(id));
}
export function setParticipants(assignmentId: string | number, userIds: number[]) {
db.prepare('DELETE FROM assignment_participants WHERE assignment_id = ?').run(assignmentId);
if (userIds.length > 0) {
const insert = db.prepare('INSERT OR IGNORE INTO assignment_participants (assignment_id, user_id) VALUES (?, ?)');
for (const userId of userIds) insert.run(assignmentId, userId);
}
return db.prepare(`
SELECT ap.user_id, u.username, u.avatar
FROM assignment_participants ap
JOIN users u ON ap.user_id = u.id
WHERE ap.assignment_id = ?
`).all(assignmentId);
}
+378
View File
@@ -0,0 +1,378 @@
import fetch from 'node-fetch';
import { db } from '../db/database';
import { Trip, Place } from '../types';
// ── Geocode cache ───────────────────────────────────────────────────────────
const geocodeCache = new Map<string, string | null>();
function roundKey(lat: number, lng: number): string {
return `${lat.toFixed(3)},${lng.toFixed(3)}`;
}
function cacheKey(lat: number, lng: number): string {
return roundKey(lat, lng);
}
export function getCached(lat: number, lng: number): string | null | undefined {
const key = cacheKey(lat, lng);
if (geocodeCache.has(key)) return geocodeCache.get(key)!;
return undefined;
}
export function setCache(lat: number, lng: number, code: string | null): void {
geocodeCache.set(cacheKey(lat, lng), code);
}
// Periodically trim the cache so it doesn't grow unbounded
const CACHE_MAX = 50_000;
const CACHE_CLEANUP_MS = 10 * 60 * 1000;
setInterval(() => {
if (geocodeCache.size > CACHE_MAX) {
const keys = [...geocodeCache.keys()];
const toDelete = keys.slice(0, keys.length - CACHE_MAX);
for (const k of toDelete) geocodeCache.delete(k);
}
}, CACHE_CLEANUP_MS).unref();
// ── Bounding-box lookup tables ──────────────────────────────────────────────
export const COUNTRY_BOXES: Record<string, [number, number, number, number]> = {
AF:[60.5,29.4,75,38.5],AL:[19,39.6,21.1,42.7],DZ:[-8.7,19,12,37.1],AD:[1.4,42.4,1.8,42.7],AO:[11.7,-18.1,24.1,-4.4],
AR:[-73.6,-55.1,-53.6,-21.8],AM:[43.4,38.8,46.6,41.3],AU:[112.9,-43.6,153.6,-10.7],AT:[9.5,46.4,17.2,49],AZ:[44.8,38.4,50.4,41.9],
BD:[88.0,20.7,92.7,26.6],BR:[-73.9,-33.8,-34.8,5.3],BE:[2.5,49.5,6.4,51.5],BG:[22.4,41.2,28.6,44.2],CA:[-141,41.7,-52.6,83.1],CL:[-75.6,-55.9,-66.9,-17.5],
CN:[73.6,18.2,134.8,53.6],CO:[-79.1,-4.3,-66.9,12.5],HR:[13.5,42.4,19.5,46.6],CZ:[12.1,48.6,18.9,51.1],DK:[8,54.6,15.2,57.8],
EG:[24.7,22,37,31.7],EE:[21.8,57.5,28.2,59.7],FI:[20.6,59.8,31.6,70.1],FR:[-5.1,41.3,9.6,51.1],DE:[5.9,47.3,15.1,55.1],
GR:[19.4,34.8,29.7,41.8],HU:[16,45.7,22.9,48.6],IS:[-24.5,63.4,-13.5,66.6],IN:[68.2,6.7,97.4,35.5],ID:[95.3,-11,141,5.9],
IR:[44.1,25.1,63.3,39.8],IQ:[38.8,29.1,48.6,37.4],IE:[-10.5,51.4,-6,55.4],IL:[34.3,29.5,35.9,33.3],IT:[6.6,36.6,18.5,47.1],
JP:[129.4,31.1,145.5,45.5],KE:[33.9,-4.7,41.9,5.5],KR:[126,33.2,129.6,38.6],LV:[21,55.7,28.2,58.1],LT:[21,53.9,26.8,56.5],
LU:[5.7,49.4,6.5,50.2],MY:[99.6,0.9,119.3,7.4],MX:[-118.4,14.5,-86.7,32.7],MA:[-13.2,27.7,-1,35.9],NL:[3.4,50.8,7.2,53.5],
NZ:[166.4,-47.3,178.5,-34.4],NO:[4.6,58,31.1,71.2],PK:[60.9,23.7,77.1,37.1],PE:[-81.3,-18.4,-68.7,-0.1],PH:[117,5,126.6,18.5],
PL:[14.1,49,24.1,54.9],PT:[-9.5,36.8,-6.2,42.2],RO:[20.2,43.6,29.7,48.3],RU:[19.6,41.2,180,81.9],SA:[34.6,16.4,55.7,32.2],
RS:[18.8,42.2,23,46.2],SK:[16.8,47.7,22.6,49.6],SI:[13.4,45.4,16.6,46.9],ZA:[16.5,-34.8,32.9,-22.1],ES:[-9.4,36,-0.2,43.8],
SE:[11.1,55.3,24.2,69.1],CH:[6,45.8,10.5,47.8],TH:[97.3,5.6,105.6,20.5],TR:[26,36,44.8,42.1],UA:[22.1,44.4,40.2,52.4],
AE:[51.6,22.6,56.4,26.1],GB:[-8,49.9,2,60.9],US:[-125,24.5,-66.9,49.4],VN:[102.1,8.6,109.5,23.4],
};
export const NAME_TO_CODE: Record<string, string> = {
'germany':'DE','deutschland':'DE','france':'FR','frankreich':'FR','spain':'ES','spanien':'ES',
'italy':'IT','italien':'IT','united kingdom':'GB','uk':'GB','england':'GB','united states':'US',
'usa':'US','netherlands':'NL','niederlande':'NL','austria':'AT','osterreich':'AT','switzerland':'CH',
'schweiz':'CH','portugal':'PT','greece':'GR','griechenland':'GR','turkey':'TR','turkei':'TR',
'croatia':'HR','kroatien':'HR','czech republic':'CZ','tschechien':'CZ','czechia':'CZ',
'poland':'PL','polen':'PL','sweden':'SE','schweden':'SE','norway':'NO','norwegen':'NO',
'denmark':'DK','danemark':'DK','finland':'FI','finnland':'FI','belgium':'BE','belgien':'BE',
'ireland':'IE','irland':'IE','hungary':'HU','ungarn':'HU','romania':'RO','rumanien':'RO',
'bulgaria':'BG','bulgarien':'BG','japan':'JP','china':'CN','australia':'AU','australien':'AU',
'canada':'CA','kanada':'CA','mexico':'MX','mexiko':'MX','brazil':'BR','brasilien':'BR',
'argentina':'AR','argentinien':'AR','thailand':'TH','indonesia':'ID','indonesien':'ID',
'india':'IN','indien':'IN','egypt':'EG','agypten':'EG','morocco':'MA','marokko':'MA',
'south africa':'ZA','sudafrika':'ZA','new zealand':'NZ','neuseeland':'NZ','iceland':'IS','island':'IS',
'luxembourg':'LU','luxemburg':'LU','slovenia':'SI','slowenien':'SI','slovakia':'SK','slowakei':'SK',
'estonia':'EE','estland':'EE','latvia':'LV','lettland':'LV','lithuania':'LT','litauen':'LT',
'serbia':'RS','serbien':'RS','israel':'IL','russia':'RU','russland':'RU','ukraine':'UA',
'vietnam':'VN','south korea':'KR','sudkorea':'KR','philippines':'PH','philippinen':'PH',
'malaysia':'MY','colombia':'CO','kolumbien':'CO','peru':'PE','chile':'CL','iran':'IR',
'iraq':'IQ','irak':'IQ','pakistan':'PK','kenya':'KE','kenia':'KE','nigeria':'NG',
'saudi arabia':'SA','saudi-arabien':'SA','albania':'AL','albanien':'AL',
};
export const CONTINENT_MAP: Record<string, string> = {
AF:'Africa',AL:'Europe',DZ:'Africa',AD:'Europe',AO:'Africa',AR:'South America',AM:'Asia',AU:'Oceania',AT:'Europe',AZ:'Asia',
BR:'South America',BE:'Europe',BG:'Europe',CA:'North America',CL:'South America',CN:'Asia',CO:'South America',HR:'Europe',CZ:'Europe',DK:'Europe',
EG:'Africa',EE:'Europe',FI:'Europe',FR:'Europe',DE:'Europe',GR:'Europe',HU:'Europe',IS:'Europe',IN:'Asia',ID:'Asia',
IR:'Asia',IQ:'Asia',IE:'Europe',IL:'Asia',IT:'Europe',JP:'Asia',KE:'Africa',KR:'Asia',LV:'Europe',LT:'Europe',
LU:'Europe',MY:'Asia',MX:'North America',MA:'Africa',NL:'Europe',NZ:'Oceania',NO:'Europe',PK:'Asia',PE:'South America',PH:'Asia',
PL:'Europe',PT:'Europe',RO:'Europe',RU:'Europe',SA:'Asia',RS:'Europe',SK:'Europe',SI:'Europe',ZA:'Africa',ES:'Europe',
SE:'Europe',CH:'Europe',TH:'Asia',TR:'Europe',UA:'Europe',AE:'Asia',GB:'Europe',US:'North America',VN:'Asia',NG:'Africa',
};
// ── Geocoding helpers ───────────────────────────────────────────────────────
export async function reverseGeocodeCountry(lat: number, lng: number): Promise<string | null> {
const key = roundKey(lat, lng);
if (geocodeCache.has(key)) return geocodeCache.get(key)!;
try {
const res = await fetch(`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&zoom=3&accept-language=en`, {
headers: { 'User-Agent': 'TREK Travel Planner' },
});
if (!res.ok) return null;
const data = await res.json() as { address?: { country_code?: string } };
const code = data.address?.country_code?.toUpperCase() || null;
geocodeCache.set(key, code);
return code;
} catch {
return null;
}
}
export function getCountryFromCoords(lat: number, lng: number): string | null {
let bestCode: string | null = null;
let bestArea = Infinity;
for (const [code, [minLng, minLat, maxLng, maxLat]] of Object.entries(COUNTRY_BOXES)) {
if (lat >= minLat && lat <= maxLat && lng >= minLng && lng <= maxLng) {
const area = (maxLng - minLng) * (maxLat - minLat);
if (area < bestArea) {
bestArea = area;
bestCode = code;
}
}
}
return bestCode;
}
export function getCountryFromAddress(address: string | null): string | null {
if (!address) return null;
const parts = address.split(',').map(s => s.trim()).filter(Boolean);
if (parts.length === 0) return null;
const last = parts[parts.length - 1];
const normalized = last.toLowerCase();
if (NAME_TO_CODE[normalized]) return NAME_TO_CODE[normalized];
if (NAME_TO_CODE[last]) return NAME_TO_CODE[last];
if (last.length === 2 && last === last.toUpperCase()) return last;
return null;
}
// ── Resolve a place to a country code (address -> geocode -> bbox) ──────────
async function resolveCountryCode(place: Place): Promise<string | null> {
let code = getCountryFromAddress(place.address);
if (!code && place.lat && place.lng) {
code = await reverseGeocodeCountry(place.lat, place.lng);
}
if (!code && place.lat && place.lng) {
code = getCountryFromCoords(place.lat, place.lng);
}
return code;
}
function resolveCountryCodeSync(place: Place): string | null {
let code = getCountryFromAddress(place.address);
if (!code && place.lat && place.lng) {
code = getCountryFromCoords(place.lat, place.lng);
}
return code;
}
// ── Shared query: all trips the user owns or is a member of ─────────────────
function getUserTrips(userId: number): Trip[] {
return db.prepare(`
SELECT DISTINCT t.* FROM trips t
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
WHERE t.user_id = ? OR m.user_id = ?
ORDER BY t.start_date DESC
`).all(userId, userId, userId) as Trip[];
}
function getPlacesForTrips(tripIds: number[]): Place[] {
if (tripIds.length === 0) return [];
const placeholders = tripIds.map(() => '?').join(',');
return db.prepare(`SELECT * FROM places WHERE trip_id IN (${placeholders})`).all(...tripIds) as Place[];
}
// ── getStats ────────────────────────────────────────────────────────────────
export async function getStats(userId: number) {
const trips = getUserTrips(userId);
const tripIds = trips.map(t => t.id);
if (tripIds.length === 0) {
const manualCountries = db.prepare('SELECT country_code FROM visited_countries WHERE user_id = ?').all(userId) as { country_code: string }[];
const countries = manualCountries.map(mc => ({ code: mc.country_code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }));
return { countries, trips: [], stats: { totalTrips: 0, totalPlaces: 0, totalCountries: countries.length, totalDays: 0 } };
}
const places = getPlacesForTrips(tripIds);
interface CountryEntry { code: string; places: { id: number; name: string; lat: number | null; lng: number | null }[]; tripIds: Set<number> }
const countrySet = new Map<string, CountryEntry>();
for (const place of places) {
const code = await resolveCountryCode(place);
if (code) {
if (!countrySet.has(code)) {
countrySet.set(code, { code, places: [], tripIds: new Set() });
}
countrySet.get(code)!.places.push({ id: place.id, name: place.name, lat: place.lat ?? null, lng: place.lng ?? null });
countrySet.get(code)!.tripIds.add(place.trip_id);
}
}
let totalDays = 0;
for (const trip of trips) {
if (trip.start_date && trip.end_date) {
const start = new Date(trip.start_date);
const end = new Date(trip.end_date);
const diff = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) + 1;
if (diff > 0) totalDays += diff;
}
}
const countries = [...countrySet.values()].map(c => {
const countryTrips = trips.filter(t => c.tripIds.has(t.id));
const dates = countryTrips.map(t => t.start_date).filter(Boolean).sort();
return {
code: c.code,
placeCount: c.places.length,
tripCount: c.tripIds.size,
firstVisit: dates[0] || null,
lastVisit: dates[dates.length - 1] || null,
};
});
const citySet = new Set<string>();
for (const place of places) {
if (place.address) {
const parts = place.address.split(',').map((s: string) => s.trim()).filter(Boolean);
let raw = parts.length >= 2 ? parts[parts.length - 2] : parts[0];
if (raw) {
const city = raw.replace(/[\d\-\u2212\u3012]+/g, '').trim().toLowerCase();
if (city) citySet.add(city);
}
}
}
const totalCities = citySet.size;
// Merge manually marked countries
const manualCountries = db.prepare('SELECT country_code FROM visited_countries WHERE user_id = ?').all(userId) as { country_code: string }[];
for (const mc of manualCountries) {
if (!countries.find(c => c.code === mc.country_code)) {
countries.push({ code: mc.country_code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null });
}
}
const mostVisited = countries.length > 0 ? countries.reduce((a, b) => a.placeCount > b.placeCount ? a : b) : null;
const continents: Record<string, number> = {};
countries.forEach(c => {
const cont = CONTINENT_MAP[c.code] || 'Other';
continents[cont] = (continents[cont] || 0) + 1;
});
const now = new Date().toISOString().split('T')[0];
const pastTrips = trips.filter(t => t.end_date && t.end_date <= now).sort((a, b) => b.end_date!.localeCompare(a.end_date!));
const lastTrip: { id: number; title: string; start_date?: string | null; end_date?: string | null; countryCode?: string } | null = pastTrips[0]
? { id: pastTrips[0].id, title: pastTrips[0].title, start_date: pastTrips[0].start_date, end_date: pastTrips[0].end_date }
: null;
if (lastTrip) {
const lastTripPlaces = places.filter(p => p.trip_id === lastTrip.id);
for (const p of lastTripPlaces) {
const code = resolveCountryCodeSync(p);
if (code) { lastTrip.countryCode = code; break; }
}
}
const futureTrips = trips.filter(t => t.start_date && t.start_date > now).sort((a, b) => a.start_date!.localeCompare(b.start_date!));
const nextTrip: { id: number; title: string; start_date?: string | null; daysUntil?: number } | null = futureTrips[0]
? { id: futureTrips[0].id, title: futureTrips[0].title, start_date: futureTrips[0].start_date }
: null;
if (nextTrip) {
const diff = Math.ceil((new Date(nextTrip.start_date!).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24));
nextTrip.daysUntil = Math.max(0, diff);
}
const tripYears = new Set(trips.filter(t => t.start_date).map(t => parseInt(t.start_date!.split('-')[0])));
let streak = 0;
const currentYear = new Date().getFullYear();
for (let y = currentYear; y >= 2000; y--) {
if (tripYears.has(y)) streak++;
else break;
}
const firstYear = tripYears.size > 0 ? Math.min(...tripYears) : null;
return {
countries,
stats: {
totalTrips: trips.length,
totalPlaces: places.length,
totalCountries: countries.length,
totalDays,
totalCities,
},
mostVisited,
continents,
lastTrip,
nextTrip,
streak,
firstYear,
tripsThisYear: trips.filter(t => t.start_date && t.start_date.startsWith(String(currentYear))).length,
};
}
// ── getCountryPlaces ────────────────────────────────────────────────────────
export function getCountryPlaces(userId: number, code: string) {
const trips = getUserTrips(userId);
const tripIds = trips.map(t => t.id);
if (tripIds.length === 0) return { places: [], trips: [], manually_marked: false };
const places = getPlacesForTrips(tripIds);
const matchingPlaces: { id: number; name: string; address: string | null; lat: number | null; lng: number | null; trip_id: number }[] = [];
const matchingTripIds = new Set<number>();
for (const place of places) {
const pCode = resolveCountryCodeSync(place);
if (pCode === code) {
matchingPlaces.push({ id: place.id, name: place.name, address: place.address ?? null, lat: place.lat ?? null, lng: place.lng ?? null, trip_id: place.trip_id });
matchingTripIds.add(place.trip_id);
}
}
const matchingTrips = trips.filter(t => matchingTripIds.has(t.id)).map(t => ({ id: t.id, title: t.title, start_date: t.start_date, end_date: t.end_date }));
const isManuallyMarked = !!(db.prepare('SELECT 1 FROM visited_countries WHERE user_id = ? AND country_code = ?').get(userId, code));
return { places: matchingPlaces, trips: matchingTrips, manually_marked: isManuallyMarked };
}
// ── Mark / unmark country ───────────────────────────────────────────────────
export function markCountryVisited(userId: number, code: string): void {
db.prepare('INSERT OR IGNORE INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(userId, code);
}
export function unmarkCountryVisited(userId: number, code: string): void {
db.prepare('DELETE FROM visited_countries WHERE user_id = ? AND country_code = ?').run(userId, code);
}
// ── Bucket list CRUD ────────────────────────────────────────────────────────
export function listBucketList(userId: number) {
return db.prepare('SELECT * FROM bucket_list WHERE user_id = ? ORDER BY created_at DESC').all(userId);
}
export function createBucketItem(userId: number, data: { name: string; lat?: number | null; lng?: number | null; country_code?: string | null; notes?: string | null; target_date?: string | null }) {
const result = db.prepare('INSERT INTO bucket_list (user_id, name, lat, lng, country_code, notes, target_date) VALUES (?, ?, ?, ?, ?, ?, ?)').run(
userId, data.name.trim(), data.lat ?? null, data.lng ?? null, data.country_code ?? null, data.notes ?? null, data.target_date ?? null
);
return db.prepare('SELECT * FROM bucket_list WHERE id = ?').get(result.lastInsertRowid);
}
export function updateBucketItem(userId: number, itemId: string | number, data: { name?: string; notes?: string; lat?: number | null; lng?: number | null; country_code?: string | null; target_date?: string | null }) {
const item = db.prepare('SELECT * FROM bucket_list WHERE id = ? AND user_id = ?').get(itemId, userId);
if (!item) return null;
db.prepare(`UPDATE bucket_list SET
name = COALESCE(?, name),
notes = CASE WHEN ? THEN ? ELSE notes END,
lat = CASE WHEN ? THEN ? ELSE lat END,
lng = CASE WHEN ? THEN ? ELSE lng END,
country_code = CASE WHEN ? THEN ? ELSE country_code END,
target_date = CASE WHEN ? THEN ? ELSE target_date END
WHERE id = ?`).run(
data.name?.trim() || null,
data.notes !== undefined ? 1 : 0, data.notes !== undefined ? (data.notes || null) : null,
data.lat !== undefined ? 1 : 0, data.lat !== undefined ? (data.lat || null) : null,
data.lng !== undefined ? 1 : 0, data.lng !== undefined ? (data.lng || null) : null,
data.country_code !== undefined ? 1 : 0, data.country_code !== undefined ? (data.country_code || null) : null,
data.target_date !== undefined ? 1 : 0, data.target_date !== undefined ? (data.target_date || null) : null,
itemId
);
return db.prepare('SELECT * FROM bucket_list WHERE id = ?').get(itemId);
}
export function deleteBucketItem(userId: number, itemId: string | number): boolean {
const item = db.prepare('SELECT * FROM bucket_list WHERE id = ? AND user_id = ?').get(itemId, userId);
if (!item) return false;
db.prepare('DELETE FROM bucket_list WHERE id = ?').run(itemId);
return true;
}
+989
View File
@@ -0,0 +1,989 @@
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import path from 'path';
import fs from 'fs';
import fetch from 'node-fetch';
import { authenticator } from 'otplib';
import QRCode from 'qrcode';
import { randomBytes, createHash } from 'crypto';
import { db } from '../db/database';
import { JWT_SECRET } from '../config';
import { validatePassword } from './passwordPolicy';
import { encryptMfaSecret, decryptMfaSecret } from './mfaCrypto';
import { getAllPermissions } from './permissions';
import { decrypt_api_key, maybe_encrypt_api_key, encrypt_api_key } from './apiKeyCrypto';
import { createEphemeralToken } from './ephemeralTokens';
import { revokeUserSessions } from '../mcp';
import { startTripReminders } from '../scheduler';
import { User } from '../types';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
authenticator.options = { window: 1 };
const MFA_SETUP_TTL_MS = 15 * 60 * 1000;
const mfaSetupPending = new Map<number, { secret: string; exp: number }>();
const MFA_BACKUP_CODE_COUNT = 10;
const ADMIN_SETTINGS_KEYS = [
'allow_registration', 'allowed_file_types', 'require_mfa',
'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify',
'notification_webhook_url', 'notification_channel',
'notify_trip_invite', 'notify_booking_change', 'notify_trip_reminder',
'notify_vacay_invite', 'notify_photos_shared', 'notify_collab_message', 'notify_packing_tagged',
];
const avatarDir = path.join(__dirname, '../../uploads/avatars');
if (!fs.existsSync(avatarDir)) fs.mkdirSync(avatarDir, { recursive: true });
const KNOWN_COUNTRIES = new Set([
'Japan', 'Germany', 'Deutschland', 'France', 'Frankreich', 'Italy', 'Italien', 'Spain', 'Spanien',
'United States', 'USA', 'United Kingdom', 'UK', 'Thailand', 'Australia', 'Australien',
'Canada', 'Kanada', 'Mexico', 'Mexiko', 'Brazil', 'Brasilien', 'China', 'India', 'Indien',
'South Korea', 'Sudkorea', 'Indonesia', 'Indonesien', 'Turkey', 'Turkei', 'Turkiye',
'Greece', 'Griechenland', 'Portugal', 'Netherlands', 'Niederlande', 'Belgium', 'Belgien',
'Switzerland', 'Schweiz', 'Austria', 'Osterreich', 'Sweden', 'Schweden', 'Norway', 'Norwegen',
'Denmark', 'Danemark', 'Finland', 'Finnland', 'Poland', 'Polen', 'Czech Republic', 'Tschechien',
'Czechia', 'Hungary', 'Ungarn', 'Croatia', 'Kroatien', 'Romania', 'Rumanien',
'Ireland', 'Irland', 'Iceland', 'Island', 'New Zealand', 'Neuseeland',
'Singapore', 'Singapur', 'Malaysia', 'Vietnam', 'Philippines', 'Philippinen',
'Egypt', 'Agypten', 'Morocco', 'Marokko', 'South Africa', 'Sudafrika', 'Kenya', 'Kenia',
'Argentina', 'Argentinien', 'Chile', 'Colombia', 'Kolumbien', 'Peru',
'Russia', 'Russland', 'United Arab Emirates', 'UAE', 'Vereinigte Arabische Emirate',
'Israel', 'Jordan', 'Jordanien', 'Taiwan', 'Hong Kong', 'Hongkong',
'Cuba', 'Kuba', 'Costa Rica', 'Panama', 'Ecuador', 'Bolivia', 'Bolivien', 'Uruguay', 'Paraguay',
'Luxembourg', 'Luxemburg', 'Malta', 'Cyprus', 'Zypern', 'Estonia', 'Estland',
'Latvia', 'Lettland', 'Lithuania', 'Litauen', 'Slovakia', 'Slowakei', 'Slovenia', 'Slowenien',
'Bulgaria', 'Bulgarien', 'Serbia', 'Serbien', 'Montenegro', 'Albania', 'Albanien',
'Sri Lanka', 'Nepal', 'Cambodia', 'Kambodscha', 'Laos', 'Myanmar', 'Mongolia', 'Mongolei',
'Saudi Arabia', 'Saudi-Arabien', 'Qatar', 'Katar', 'Oman', 'Bahrain', 'Kuwait',
'Tanzania', 'Tansania', 'Ethiopia', 'Athiopien', 'Nigeria', 'Ghana', 'Tunisia', 'Tunesien',
'Dominican Republic', 'Dominikanische Republik', 'Jamaica', 'Jamaika',
'Ukraine', 'Georgia', 'Georgien', 'Armenia', 'Armenien', 'Pakistan', 'Bangladesh', 'Bangladesch',
'Senegal', 'Mozambique', 'Mosambik', 'Moldova', 'Moldawien', 'Belarus', 'Weissrussland',
]);
// ---------------------------------------------------------------------------
// Helpers (exported for route-level use where needed)
// ---------------------------------------------------------------------------
export function utcSuffix(ts: string | null | undefined): string | null {
if (!ts) return null;
return ts.endsWith('Z') ? ts : ts.replace(' ', 'T') + 'Z';
}
export function stripUserForClient(user: User): Record<string, unknown> {
const {
password_hash: _p,
maps_api_key: _m,
openweather_api_key: _o,
unsplash_api_key: _u,
mfa_secret: _mf,
mfa_backup_codes: _mbc,
...rest
} = user;
return {
...rest,
created_at: utcSuffix(rest.created_at),
updated_at: utcSuffix(rest.updated_at),
last_login: utcSuffix(rest.last_login),
mfa_enabled: !!(user.mfa_enabled === 1 || user.mfa_enabled === true),
must_change_password: !!(user.must_change_password === 1 || user.must_change_password === true),
};
}
export function maskKey(key: string | null | undefined): string | null {
if (!key) return null;
if (key.length <= 8) return '--------';
return '----' + key.slice(-4);
}
export function mask_stored_api_key(key: string | null | undefined): string | null {
const plain = decrypt_api_key(key);
return maskKey(plain);
}
export function avatarUrl(user: { avatar?: string | null }): string | null {
return user.avatar ? `/uploads/avatars/${user.avatar}` : null;
}
export function isOidcOnlyMode(): boolean {
const get = (key: string) =>
(db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || null;
const enabled = process.env.OIDC_ONLY === 'true' || get('oidc_only') === 'true';
if (!enabled) return false;
const oidcConfigured = !!(
(process.env.OIDC_ISSUER || get('oidc_issuer')) &&
(process.env.OIDC_CLIENT_ID || get('oidc_client_id'))
);
return oidcConfigured;
}
export function generateToken(user: { id: number | bigint }) {
return jwt.sign(
{ id: user.id },
JWT_SECRET,
{ expiresIn: '24h', algorithm: 'HS256' }
);
}
// ---------------------------------------------------------------------------
// MFA helpers
// ---------------------------------------------------------------------------
export function normalizeBackupCode(input: string): string {
return String(input || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
}
export function hashBackupCode(input: string): string {
return crypto.createHash('sha256').update(normalizeBackupCode(input)).digest('hex');
}
export function generateBackupCodes(count = MFA_BACKUP_CODE_COUNT): string[] {
const codes: string[] = [];
while (codes.length < count) {
const raw = crypto.randomBytes(4).toString('hex').toUpperCase();
const code = `${raw.slice(0, 4)}-${raw.slice(4)}`;
if (!codes.includes(code)) codes.push(code);
}
return codes;
}
export function parseBackupCodeHashes(raw: string | null | undefined): string[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.filter(v => typeof v === 'string') : [];
} catch {
return [];
}
}
export function getPendingMfaSecret(userId: number): string | null {
const row = mfaSetupPending.get(userId);
if (!row || Date.now() > row.exp) {
mfaSetupPending.delete(userId);
return null;
}
return row.secret;
}
// ---------------------------------------------------------------------------
// App config (public)
// ---------------------------------------------------------------------------
export function getAppConfig(authenticatedUser: { id: number } | null) {
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get() as { value: string } | undefined;
const allowRegistration = userCount === 0 || (setting?.value ?? 'true') === 'true';
const isDemo = process.env.DEMO_MODE === 'true';
const { version } = require('../../package.json');
const hasGoogleKey = !!db.prepare("SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1").get();
const oidcDisplayName = process.env.OIDC_DISPLAY_NAME ||
(db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_display_name'").get() as { value: string } | undefined)?.value || null;
const oidcConfigured = !!(
(process.env.OIDC_ISSUER || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_issuer'").get() as { value: string } | undefined)?.value) &&
(process.env.OIDC_CLIENT_ID || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_client_id'").get() as { value: string } | undefined)?.value)
);
const oidcOnlySetting = process.env.OIDC_ONLY ||
(db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_only'").get() as { value: string } | undefined)?.value;
const oidcOnlyMode = oidcConfigured && oidcOnlySetting === 'true';
const requireMfaRow = db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined;
const notifChannel = (db.prepare("SELECT value FROM app_settings WHERE key = 'notification_channel'").get() as { value: string } | undefined)?.value || 'none';
const tripReminderSetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'notify_trip_reminder'").get() as { value: string } | undefined)?.value;
const hasSmtpHost = !!(process.env.SMTP_HOST || (db.prepare("SELECT value FROM app_settings WHERE key = 'smtp_host'").get() as { value: string } | undefined)?.value);
const hasWebhookUrl = !!(process.env.NOTIFICATION_WEBHOOK_URL || (db.prepare("SELECT value FROM app_settings WHERE key = 'notification_webhook_url'").get() as { value: string } | undefined)?.value);
const channelConfigured = (notifChannel === 'email' && hasSmtpHost) || (notifChannel === 'webhook' && hasWebhookUrl);
const tripRemindersEnabled = channelConfigured && tripReminderSetting !== 'false';
const setupComplete = userCount > 0 && !(db.prepare("SELECT id FROM users WHERE role = 'admin' AND must_change_password = 1 LIMIT 1").get());
return {
allow_registration: isDemo ? false : allowRegistration,
has_users: userCount > 0,
setup_complete: setupComplete,
version,
has_maps_key: hasGoogleKey,
oidc_configured: oidcConfigured,
oidc_display_name: oidcConfigured ? (oidcDisplayName || 'SSO') : undefined,
oidc_only_mode: oidcOnlyMode,
require_mfa: requireMfaRow?.value === 'true',
allowed_file_types: (db.prepare("SELECT value FROM app_settings WHERE key = 'allowed_file_types'").get() as { value: string } | undefined)?.value || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv',
demo_mode: isDemo,
demo_email: isDemo ? 'demo@trek.app' : undefined,
demo_password: isDemo ? 'demo12345' : undefined,
timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
notification_channel: notifChannel,
trip_reminders_enabled: tripRemindersEnabled,
permissions: authenticatedUser ? getAllPermissions() : undefined,
};
}
// ---------------------------------------------------------------------------
// Auth: register, login, demo
// ---------------------------------------------------------------------------
export function demoLogin(): { error?: string; status?: number; token?: string; user?: Record<string, unknown> } {
if (process.env.DEMO_MODE !== 'true') {
return { error: 'Not found', status: 404 };
}
const user = db.prepare('SELECT * FROM users WHERE email = ?').get('demo@trek.app') as User | undefined;
if (!user) return { error: 'Demo user not found', status: 500 };
const token = generateToken(user);
const safe = stripUserForClient(user) as Record<string, unknown>;
return { token, user: { ...safe, avatar_url: avatarUrl(user) } };
}
export function validateInviteToken(token: string): { error?: string; status?: number; valid?: boolean; max_uses?: number; used_count?: number; expires_at?: string } {
const invite = db.prepare('SELECT * FROM invite_tokens WHERE token = ?').get(token) as any;
if (!invite) return { error: 'Invalid invite link', status: 404 };
if (invite.max_uses > 0 && invite.used_count >= invite.max_uses) return { error: 'Invite link has been fully used', status: 410 };
if (invite.expires_at && new Date(invite.expires_at) < new Date()) return { error: 'Invite link has expired', status: 410 };
return { valid: true, max_uses: invite.max_uses, used_count: invite.used_count, expires_at: invite.expires_at };
}
export function registerUser(body: {
username?: string;
email?: string;
password?: string;
invite_token?: string;
}): { error?: string; status?: number; token?: string; user?: Record<string, unknown>; auditUserId?: number; auditDetails?: Record<string, unknown> } {
const { username, email, password, invite_token } = body;
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
let validInvite: any = null;
if (invite_token) {
validInvite = db.prepare('SELECT * FROM invite_tokens WHERE token = ?').get(invite_token);
if (!validInvite) return { error: 'Invalid invite link', status: 400 };
if (validInvite.max_uses > 0 && validInvite.used_count >= validInvite.max_uses) return { error: 'Invite link has been fully used', status: 410 };
if (validInvite.expires_at && new Date(validInvite.expires_at) < new Date()) return { error: 'Invite link has expired', status: 410 };
}
if (userCount > 0 && !validInvite) {
if (isOidcOnlyMode()) {
return { error: 'Password authentication is disabled. Please sign in with SSO.', status: 403 };
}
const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get() as { value: string } | undefined;
if (setting?.value === 'false') {
return { error: 'Registration is disabled. Contact your administrator.', status: 403 };
}
}
if (!username || !email || !password) {
return { error: 'Username, email and password are required', status: 400 };
}
const pwCheck = validatePassword(password);
if (!pwCheck.ok) return { error: pwCheck.reason, status: 400 };
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return { error: 'Invalid email format', status: 400 };
}
const existingUser = db.prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?) OR LOWER(username) = LOWER(?)').get(email, username);
if (existingUser) {
return { error: 'Registration failed. Please try different credentials.', status: 409 };
}
const password_hash = bcrypt.hashSync(password, 12);
const isFirstUser = userCount === 0;
const role = isFirstUser ? 'admin' : 'user';
try {
const result = db.prepare(
'INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)'
).run(username, email, password_hash, role);
const user = { id: result.lastInsertRowid, username, email, role, avatar: null, mfa_enabled: false };
const token = generateToken(user);
if (validInvite) {
const updated = db.prepare(
'UPDATE invite_tokens SET used_count = used_count + 1 WHERE id = ? AND (max_uses = 0 OR used_count < max_uses) RETURNING used_count'
).get(validInvite.id);
if (!updated) {
console.warn(`[Auth] Invite token ${validInvite.token.slice(0, 8)}... exceeded max_uses due to race condition`);
}
}
return {
token,
user: { ...user, avatar_url: null },
auditUserId: Number(result.lastInsertRowid),
auditDetails: { username, email, role },
};
} catch {
return { error: 'Error creating user', status: 500 };
}
}
export function loginUser(body: {
email?: string;
password?: string;
}): {
error?: string;
status?: number;
token?: string;
user?: Record<string, unknown>;
mfa_required?: boolean;
mfa_token?: string;
auditUserId?: number | null;
auditAction?: string;
auditDetails?: Record<string, unknown>;
} {
if (isOidcOnlyMode()) {
return { error: 'Password authentication is disabled. Please sign in with SSO.', status: 403 };
}
const { email, password } = body;
if (!email || !password) {
return { error: 'Email and password are required', status: 400 };
}
const user = db.prepare('SELECT * FROM users WHERE LOWER(email) = LOWER(?)').get(email) as User | undefined;
if (!user) {
return {
error: 'Invalid email or password', status: 401,
auditUserId: null, auditAction: 'user.login_failed', auditDetails: { email, reason: 'unknown_email' },
};
}
const validPassword = bcrypt.compareSync(password, user.password_hash!);
if (!validPassword) {
return {
error: 'Invalid email or password', status: 401,
auditUserId: Number(user.id), auditAction: 'user.login_failed', auditDetails: { email, reason: 'wrong_password' },
};
}
if (user.mfa_enabled === 1 || user.mfa_enabled === true) {
const mfa_token = jwt.sign(
{ id: Number(user.id), purpose: 'mfa_login' },
JWT_SECRET,
{ expiresIn: '5m', algorithm: 'HS256' }
);
return { mfa_required: true, mfa_token };
}
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(user.id);
const token = generateToken(user);
const userSafe = stripUserForClient(user) as Record<string, unknown>;
return {
token,
user: { ...userSafe, avatar_url: avatarUrl(user) },
auditUserId: Number(user.id),
auditAction: 'user.login',
auditDetails: { email },
};
}
// ---------------------------------------------------------------------------
// Session
// ---------------------------------------------------------------------------
export function getCurrentUser(userId: number) {
const user = db.prepare(
'SELECT id, username, email, role, avatar, oidc_issuer, created_at, mfa_enabled, must_change_password FROM users WHERE id = ?'
).get(userId) as User | undefined;
if (!user) return null;
const base = stripUserForClient(user as User) as Record<string, unknown>;
return { ...base, avatar_url: avatarUrl(user) };
}
// ---------------------------------------------------------------------------
// Password & account
// ---------------------------------------------------------------------------
export function changePassword(
userId: number,
userEmail: string,
body: { current_password?: string; new_password?: string }
): { error?: string; status?: number; success?: boolean } {
if (isOidcOnlyMode()) {
return { error: 'Password authentication is disabled.', status: 403 };
}
if (process.env.DEMO_MODE === 'true' && userEmail === 'demo@trek.app') {
return { error: 'Password change is disabled in demo mode.', status: 403 };
}
const { current_password, new_password } = body;
if (!current_password) return { error: 'Current password is required', status: 400 };
if (!new_password) return { error: 'New password is required', status: 400 };
const pwCheck = validatePassword(new_password);
if (!pwCheck.ok) return { error: pwCheck.reason, status: 400 };
const user = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(userId) as { password_hash: string } | undefined;
if (!user || !bcrypt.compareSync(current_password, user.password_hash)) {
return { error: 'Current password is incorrect', status: 401 };
}
const hash = bcrypt.hashSync(new_password, 12);
db.prepare('UPDATE users SET password_hash = ?, must_change_password = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(hash, userId);
return { success: true };
}
export function deleteAccount(userId: number, userEmail: string, userRole: string): { error?: string; status?: number; success?: boolean } {
if (process.env.DEMO_MODE === 'true' && userEmail === 'demo@trek.app') {
return { error: 'Account deletion is disabled in demo mode.', status: 403 };
}
if (userRole === 'admin') {
const adminCount = (db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin'").get() as { count: number }).count;
if (adminCount <= 1) {
return { error: 'Cannot delete the last admin account', status: 400 };
}
}
db.prepare('DELETE FROM users WHERE id = ?').run(userId);
return { success: true };
}
// ---------------------------------------------------------------------------
// API keys
// ---------------------------------------------------------------------------
export function updateMapsKey(userId: number, maps_api_key: string | null | undefined) {
db.prepare(
'UPDATE users SET maps_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
).run(maybe_encrypt_api_key(maps_api_key), userId);
return { success: true, maps_api_key: mask_stored_api_key(maps_api_key) };
}
export function updateApiKeys(
userId: number,
body: { maps_api_key?: string; openweather_api_key?: string }
) {
const current = db.prepare('SELECT maps_api_key, openweather_api_key FROM users WHERE id = ?').get(userId) as Pick<User, 'maps_api_key' | 'openweather_api_key'> | undefined;
db.prepare(
'UPDATE users SET maps_api_key = ?, openweather_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
).run(
body.maps_api_key !== undefined ? maybe_encrypt_api_key(body.maps_api_key) : current!.maps_api_key,
body.openweather_api_key !== undefined ? maybe_encrypt_api_key(body.openweather_api_key) : current!.openweather_api_key,
userId
);
const updated = db.prepare(
'SELECT id, username, email, role, maps_api_key, openweather_api_key, avatar, mfa_enabled FROM users WHERE id = ?'
).get(userId) as Pick<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'avatar' | 'mfa_enabled'> | undefined;
const u = updated ? { ...updated, mfa_enabled: !!(updated.mfa_enabled === 1 || updated.mfa_enabled === true) } : undefined;
return {
success: true,
user: { ...u, maps_api_key: mask_stored_api_key(u?.maps_api_key), openweather_api_key: mask_stored_api_key(u?.openweather_api_key), avatar_url: avatarUrl(updated || {}) },
};
}
export function updateSettings(
userId: number,
body: { maps_api_key?: string; openweather_api_key?: string; username?: string; email?: string }
): { error?: string; status?: number; success?: boolean; user?: Record<string, unknown> } {
const { maps_api_key, openweather_api_key, username, email } = body;
if (username !== undefined) {
const trimmed = username.trim();
if (!trimmed || trimmed.length < 2 || trimmed.length > 50) {
return { error: 'Username must be between 2 and 50 characters', status: 400 };
}
if (!/^[a-zA-Z0-9_.-]+$/.test(trimmed)) {
return { error: 'Username can only contain letters, numbers, underscores, dots and hyphens', status: 400 };
}
const conflict = db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?) AND id != ?').get(trimmed, userId);
if (conflict) return { error: 'Username already taken', status: 409 };
}
if (email !== undefined) {
const trimmed = email.trim();
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!trimmed || !emailRegex.test(trimmed)) {
return { error: 'Invalid email format', status: 400 };
}
const conflict = db.prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?) AND id != ?').get(trimmed, userId);
if (conflict) return { error: 'Email already taken', status: 409 };
}
const updates: string[] = [];
const params: (string | number | null)[] = [];
if (maps_api_key !== undefined) { updates.push('maps_api_key = ?'); params.push(maybe_encrypt_api_key(maps_api_key)); }
if (openweather_api_key !== undefined) { updates.push('openweather_api_key = ?'); params.push(maybe_encrypt_api_key(openweather_api_key)); }
if (username !== undefined) { updates.push('username = ?'); params.push(username.trim()); }
if (email !== undefined) { updates.push('email = ?'); params.push(email.trim()); }
if (updates.length > 0) {
updates.push('updated_at = CURRENT_TIMESTAMP');
params.push(userId);
db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...params);
}
const updated = db.prepare(
'SELECT id, username, email, role, maps_api_key, openweather_api_key, avatar, mfa_enabled FROM users WHERE id = ?'
).get(userId) as Pick<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'avatar' | 'mfa_enabled'> | undefined;
const u = updated ? { ...updated, mfa_enabled: !!(updated.mfa_enabled === 1 || updated.mfa_enabled === true) } : undefined;
return {
success: true,
user: { ...u, maps_api_key: mask_stored_api_key(u?.maps_api_key), openweather_api_key: mask_stored_api_key(u?.openweather_api_key), avatar_url: avatarUrl(updated || {}) },
};
}
export function getSettings(userId: number): { error?: string; status?: number; settings?: Record<string, unknown> } {
const user = db.prepare(
'SELECT role, maps_api_key, openweather_api_key FROM users WHERE id = ?'
).get(userId) as Pick<User, 'role' | 'maps_api_key' | 'openweather_api_key'> | undefined;
if (user?.role !== 'admin') return { error: 'Admin access required', status: 403 };
return {
settings: {
maps_api_key: decrypt_api_key(user.maps_api_key),
openweather_api_key: decrypt_api_key(user.openweather_api_key),
},
};
}
// ---------------------------------------------------------------------------
// Avatar
// ---------------------------------------------------------------------------
export function saveAvatar(userId: number, filename: string) {
const current = db.prepare('SELECT avatar FROM users WHERE id = ?').get(userId) as { avatar: string | null } | undefined;
if (current && current.avatar) {
const oldPath = path.join(avatarDir, current.avatar);
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
}
db.prepare('UPDATE users SET avatar = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(filename, userId);
const updated = db.prepare('SELECT id, username, email, role, avatar FROM users WHERE id = ?').get(userId) as Pick<User, 'id' | 'username' | 'email' | 'role' | 'avatar'> | undefined;
return { success: true, avatar_url: avatarUrl(updated || {}) };
}
export function deleteAvatar(userId: number) {
const current = db.prepare('SELECT avatar FROM users WHERE id = ?').get(userId) as { avatar: string | null } | undefined;
if (current && current.avatar) {
const filePath = path.join(avatarDir, current.avatar);
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
}
db.prepare('UPDATE users SET avatar = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(userId);
return { success: true };
}
// ---------------------------------------------------------------------------
// User directory
// ---------------------------------------------------------------------------
export function listUsers(excludeUserId: number) {
const users = db.prepare(
'SELECT id, username, avatar FROM users WHERE id != ? ORDER BY username ASC'
).all(excludeUserId) as Pick<User, 'id' | 'username' | 'avatar'>[];
return users.map(u => ({ ...u, avatar_url: avatarUrl(u) }));
}
// ---------------------------------------------------------------------------
// Key validation
// ---------------------------------------------------------------------------
export async function validateKeys(userId: number): Promise<{ error?: string; status?: number; maps: boolean; weather: boolean; maps_details: null | { ok: boolean; status: number | null; status_text: string | null; error_message: string | null; error_status: string | null; error_raw: string | null } }> {
const user = db.prepare('SELECT role, maps_api_key, openweather_api_key FROM users WHERE id = ?').get(userId) as Pick<User, 'role' | 'maps_api_key' | 'openweather_api_key'> | undefined;
if (user?.role !== 'admin') return { error: 'Admin access required', status: 403, maps: false, weather: false, maps_details: null };
const result: {
maps: boolean;
weather: boolean;
maps_details: null | {
ok: boolean;
status: number | null;
status_text: string | null;
error_message: string | null;
error_status: string | null;
error_raw: string | null;
};
} = { maps: false, weather: false, maps_details: null };
const maps_api_key = decrypt_api_key(user.maps_api_key);
if (maps_api_key) {
try {
const mapsRes = await fetch(
`https://places.googleapis.com/v1/places:searchText`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Goog-Api-Key': maps_api_key,
'X-Goog-FieldMask': 'places.displayName',
},
body: JSON.stringify({ textQuery: 'test' }),
}
);
result.maps = mapsRes.status === 200;
let error_text: string | null = null;
let error_json: any = null;
if (!result.maps) {
try {
error_text = await mapsRes.text();
try { error_json = JSON.parse(error_text); } catch { error_json = null; }
} catch { error_text = null; error_json = null; }
}
result.maps_details = {
ok: result.maps,
status: mapsRes.status,
status_text: mapsRes.statusText || null,
error_message: error_json?.error?.message || null,
error_status: error_json?.error?.status || null,
error_raw: error_text,
};
} catch (err: unknown) {
result.maps = false;
result.maps_details = {
ok: false,
status: null,
status_text: null,
error_message: err instanceof Error ? err.message : 'Request failed',
error_status: 'FETCH_ERROR',
error_raw: null,
};
}
}
const openweather_api_key = decrypt_api_key(user.openweather_api_key);
if (openweather_api_key) {
try {
const weatherRes = await fetch(
`https://api.openweathermap.org/data/2.5/weather?q=London&appid=${openweather_api_key}`
);
result.weather = weatherRes.status === 200;
} catch {
result.weather = false;
}
}
return result;
}
// ---------------------------------------------------------------------------
// Admin settings
// ---------------------------------------------------------------------------
export function getAppSettings(userId: number): { error?: string; status?: number; data?: Record<string, string> } {
const user = db.prepare('SELECT role FROM users WHERE id = ?').get(userId) as { role: string } | undefined;
if (user?.role !== 'admin') return { error: 'Admin access required', status: 403 };
const result: Record<string, string> = {};
for (const key of ADMIN_SETTINGS_KEYS) {
const row = db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined;
if (row) result[key] = key === 'smtp_pass' ? '••••••••' : row.value;
}
return { data: result };
}
export function updateAppSettings(
userId: number,
body: Record<string, unknown>
): {
error?: string;
status?: number;
success?: boolean;
auditSummary?: Record<string, unknown>;
auditDebugDetails?: Record<string, unknown>;
shouldRestartScheduler?: boolean;
} {
const user = db.prepare('SELECT role FROM users WHERE id = ?').get(userId) as { role: string } | undefined;
if (user?.role !== 'admin') return { error: 'Admin access required', status: 403 };
const { require_mfa } = body;
if (require_mfa === true || require_mfa === 'true') {
const adminMfa = db.prepare('SELECT mfa_enabled FROM users WHERE id = ?').get(userId) as { mfa_enabled: number } | undefined;
if (!(adminMfa?.mfa_enabled === 1)) {
return {
error: 'Enable two-factor authentication on your own account before requiring it for all users.',
status: 400,
};
}
}
for (const key of ADMIN_SETTINGS_KEYS) {
if (body[key] !== undefined) {
let val = String(body[key]);
if (key === 'require_mfa') {
val = body[key] === true || val === 'true' ? 'true' : 'false';
}
if (key === 'smtp_pass' && val === '••••••••') continue;
if (key === 'smtp_pass') val = encrypt_api_key(val);
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val);
}
}
const changedKeys = ADMIN_SETTINGS_KEYS.filter(k => body[k] !== undefined && !(k === 'smtp_pass' && String(body[k]) === '••••••••'));
const summary: Record<string, unknown> = {};
const smtpChanged = changedKeys.some(k => k.startsWith('smtp_'));
const eventsChanged = changedKeys.some(k => k.startsWith('notify_'));
if (changedKeys.includes('notification_channel')) summary.notification_channel = body.notification_channel;
if (changedKeys.includes('notification_webhook_url')) summary.webhook_url_updated = true;
if (smtpChanged) summary.smtp_settings_updated = true;
if (eventsChanged) summary.notification_events_updated = true;
if (changedKeys.includes('allow_registration')) summary.allow_registration = body.allow_registration;
if (changedKeys.includes('allowed_file_types')) summary.allowed_file_types_updated = true;
if (changedKeys.includes('require_mfa')) summary.require_mfa = body.require_mfa;
const debugDetails: Record<string, unknown> = {};
for (const k of changedKeys) {
debugDetails[k] = k === 'smtp_pass' ? '***' : body[k];
}
const notifRelated = ['notification_channel', 'notification_webhook_url', 'smtp_host', 'notify_trip_reminder'];
const shouldRestartScheduler = changedKeys.some(k => notifRelated.includes(k));
if (shouldRestartScheduler) {
startTripReminders();
}
return { success: true, auditSummary: summary, auditDebugDetails: debugDetails, shouldRestartScheduler };
}
// ---------------------------------------------------------------------------
// Travel stats
// ---------------------------------------------------------------------------
export function getTravelStats(userId: number) {
const places = db.prepare(`
SELECT DISTINCT p.address, p.lat, p.lng
FROM places p
JOIN trips t ON p.trip_id = t.id
LEFT JOIN trip_members tm ON t.id = tm.trip_id
WHERE t.user_id = ? OR tm.user_id = ?
`).all(userId, userId) as { address: string | null; lat: number | null; lng: number | null }[];
const tripStats = db.prepare(`
SELECT COUNT(DISTINCT t.id) as trips,
COUNT(DISTINCT d.id) as days
FROM trips t
LEFT JOIN days d ON d.trip_id = t.id
LEFT JOIN trip_members tm ON t.id = tm.trip_id
WHERE (t.user_id = ? OR tm.user_id = ?) AND t.is_archived = 0
`).get(userId, userId) as { trips: number; days: number } | undefined;
const countries = new Set<string>();
const cities = new Set<string>();
const coords: { lat: number; lng: number }[] = [];
places.forEach(p => {
if (p.lat && p.lng) coords.push({ lat: p.lat, lng: p.lng });
if (p.address) {
const parts = p.address.split(',').map(s => s.trim().replace(/\d{3,}/g, '').trim());
for (const part of parts) {
if (KNOWN_COUNTRIES.has(part)) { countries.add(part); break; }
}
const cityPart = parts.find(s => !KNOWN_COUNTRIES.has(s) && /^[A-Za-z\u00C0-\u00FF\s-]{2,}$/.test(s));
if (cityPart) cities.add(cityPart);
}
});
return {
countries: [...countries],
cities: [...cities],
coords,
totalTrips: tripStats?.trips || 0,
totalDays: tripStats?.days || 0,
totalPlaces: places.length,
};
}
// ---------------------------------------------------------------------------
// MFA
// ---------------------------------------------------------------------------
export function setupMfa(userId: number, userEmail: string): { error?: string; status?: number; secret?: string; otpauth_url?: string; qrPromise?: Promise<string> } {
if (process.env.DEMO_MODE === 'true' && userEmail === 'demo@nomad.app') {
return { error: 'MFA is not available in demo mode.', status: 403 };
}
const row = db.prepare('SELECT mfa_enabled FROM users WHERE id = ?').get(userId) as { mfa_enabled: number } | undefined;
if (row?.mfa_enabled) {
return { error: 'MFA is already enabled', status: 400 };
}
let secret: string, otpauth_url: string;
try {
secret = authenticator.generateSecret();
mfaSetupPending.set(userId, { secret, exp: Date.now() + MFA_SETUP_TTL_MS });
otpauth_url = authenticator.keyuri(userEmail, 'TREK', secret);
} catch (err) {
console.error('[MFA] Setup error:', err);
return { error: 'MFA setup failed', status: 500 };
}
return { secret, otpauth_url, qrPromise: QRCode.toDataURL(otpauth_url) };
}
export function enableMfa(userId: number, code?: string): { error?: string; status?: number; success?: boolean; mfa_enabled?: boolean; backup_codes?: string[] } {
if (!code) {
return { error: 'Verification code is required', status: 400 };
}
const pending = getPendingMfaSecret(userId);
if (!pending) {
return { error: 'No MFA setup in progress. Start the setup again.', status: 400 };
}
const tokenStr = String(code).replace(/\s/g, '');
const ok = authenticator.verify({ token: tokenStr, secret: pending });
if (!ok) {
return { error: 'Invalid verification code', status: 401 };
}
const backupCodes = generateBackupCodes();
const backupHashes = backupCodes.map(hashBackupCode);
const enc = encryptMfaSecret(pending);
db.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ?, mfa_backup_codes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(
enc,
JSON.stringify(backupHashes),
userId
);
mfaSetupPending.delete(userId);
return { success: true, mfa_enabled: true, backup_codes: backupCodes };
}
export function disableMfa(
userId: number,
userEmail: string,
body: { password?: string; code?: string }
): { error?: string; status?: number; success?: boolean; mfa_enabled?: boolean } {
if (process.env.DEMO_MODE === 'true' && userEmail === 'demo@nomad.app') {
return { error: 'MFA cannot be changed in demo mode.', status: 403 };
}
const policy = db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined;
if (policy?.value === 'true') {
return { error: 'Two-factor authentication cannot be disabled while it is required for all users.', status: 403 };
}
const { password, code } = body;
if (!password || !code) {
return { error: 'Password and authenticator code are required', status: 400 };
}
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId) as User | undefined;
if (!user?.mfa_enabled || !user.mfa_secret) {
return { error: 'MFA is not enabled', status: 400 };
}
if (!user.password_hash || !bcrypt.compareSync(password, user.password_hash)) {
return { error: 'Incorrect password', status: 401 };
}
const secret = decryptMfaSecret(user.mfa_secret);
const tokenStr = String(code).replace(/\s/g, '');
const ok = authenticator.verify({ token: tokenStr, secret });
if (!ok) {
return { error: 'Invalid verification code', status: 401 };
}
db.prepare('UPDATE users SET mfa_enabled = 0, mfa_secret = NULL, mfa_backup_codes = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(
userId
);
mfaSetupPending.delete(userId);
return { success: true, mfa_enabled: false };
}
export function verifyMfaLogin(body: {
mfa_token?: string;
code?: string;
}): {
error?: string;
status?: number;
token?: string;
user?: Record<string, unknown>;
auditUserId?: number;
} {
const { mfa_token, code } = body;
if (!mfa_token || !code) {
return { error: 'Verification token and code are required', status: 400 };
}
try {
const decoded = jwt.verify(mfa_token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number; purpose?: string };
if (decoded.purpose !== 'mfa_login') {
return { error: 'Invalid verification token', status: 401 };
}
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(decoded.id) as User | undefined;
if (!user || !(user.mfa_enabled === 1 || user.mfa_enabled === true) || !user.mfa_secret) {
return { error: 'Invalid session', status: 401 };
}
const secret = decryptMfaSecret(user.mfa_secret);
const tokenStr = String(code).trim();
const okTotp = authenticator.verify({ token: tokenStr.replace(/\s/g, ''), secret });
if (!okTotp) {
const hashes = parseBackupCodeHashes(user.mfa_backup_codes);
const candidateHash = hashBackupCode(tokenStr);
const idx = hashes.findIndex(h => h === candidateHash);
if (idx === -1) {
return { error: 'Invalid verification code', status: 401 };
}
hashes.splice(idx, 1);
db.prepare('UPDATE users SET mfa_backup_codes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(
JSON.stringify(hashes),
user.id
);
}
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(user.id);
const sessionToken = generateToken(user);
const userSafe = stripUserForClient(user) as Record<string, unknown>;
return {
token: sessionToken,
user: { ...userSafe, avatar_url: avatarUrl(user) },
auditUserId: Number(user.id),
};
} catch {
return { error: 'Invalid or expired verification token', status: 401 };
}
}
// ---------------------------------------------------------------------------
// MCP tokens
// ---------------------------------------------------------------------------
export function listMcpTokens(userId: number) {
return db.prepare(
'SELECT id, name, token_prefix, created_at, last_used_at FROM mcp_tokens WHERE user_id = ? ORDER BY created_at DESC'
).all(userId);
}
export function createMcpToken(userId: number, name?: string): { error?: string; status?: number; token?: Record<string, unknown> } {
if (!name?.trim()) return { error: 'Token name is required', status: 400 };
if (name.trim().length > 100) return { error: 'Token name must be 100 characters or less', status: 400 };
const tokenCount = (db.prepare('SELECT COUNT(*) as count FROM mcp_tokens WHERE user_id = ?').get(userId) as { count: number }).count;
if (tokenCount >= 10) return { error: 'Maximum of 10 tokens per user reached', status: 400 };
const rawToken = 'trek_' + randomBytes(24).toString('hex');
const tokenHash = createHash('sha256').update(rawToken).digest('hex');
const tokenPrefix = rawToken.slice(0, 13);
const result = db.prepare(
'INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)'
).run(userId, name.trim(), tokenHash, tokenPrefix);
const token = db.prepare(
'SELECT id, name, token_prefix, created_at, last_used_at FROM mcp_tokens WHERE id = ?'
).get(result.lastInsertRowid);
return { token: { ...(token as object), raw_token: rawToken } };
}
export function deleteMcpToken(userId: number, tokenId: string): { error?: string; status?: number; success?: boolean } {
const token = db.prepare('SELECT id FROM mcp_tokens WHERE id = ? AND user_id = ?').get(tokenId, userId);
if (!token) return { error: 'Token not found', status: 404 };
db.prepare('DELETE FROM mcp_tokens WHERE id = ?').run(tokenId);
revokeUserSessions(userId);
return { success: true };
}
// ---------------------------------------------------------------------------
// Ephemeral tokens
// ---------------------------------------------------------------------------
export function createWsToken(userId: number): { error?: string; status?: number; token?: string } {
const token = createEphemeralToken(userId, 'ws');
if (!token) return { error: 'Service unavailable', status: 503 };
return { token };
}
export function createResourceToken(userId: number, purpose?: string): { error?: string; status?: number; token?: string } {
if (purpose !== 'download' && purpose !== 'immich') {
return { error: 'Invalid purpose', status: 400 };
}
const token = createEphemeralToken(userId, purpose);
if (!token) return { error: 'Service unavailable', status: 503 };
return { token };
}
+291
View File
@@ -0,0 +1,291 @@
import archiver from 'archiver';
import unzipper from 'unzipper';
import path from 'path';
import fs from 'fs';
import Database from 'better-sqlite3';
import { db, closeDb, reinitialize } from '../db/database';
import * as scheduler from '../scheduler';
// ---------------------------------------------------------------------------
// Paths
// ---------------------------------------------------------------------------
const dataDir = path.join(__dirname, '../../data');
const backupsDir = path.join(dataDir, 'backups');
const uploadsDir = path.join(__dirname, '../../uploads');
export const MAX_BACKUP_UPLOAD_SIZE = 500 * 1024 * 1024; // 500 MB
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
export function ensureBackupsDir(): void {
if (!fs.existsSync(backupsDir)) fs.mkdirSync(backupsDir, { recursive: true });
}
export function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
export function parseIntField(raw: unknown, fallback: number): number {
if (typeof raw === 'number' && Number.isFinite(raw)) return Math.floor(raw);
if (typeof raw === 'string' && raw.trim() !== '') {
const n = parseInt(raw, 10);
if (Number.isFinite(n)) return n;
}
return fallback;
}
export function parseAutoBackupBody(body: Record<string, unknown>): {
enabled: boolean;
interval: string;
keep_days: number;
hour: number;
day_of_week: number;
day_of_month: number;
} {
const enabled = body.enabled === true || body.enabled === 'true' || body.enabled === 1;
const rawInterval = body.interval;
const interval =
typeof rawInterval === 'string' && scheduler.VALID_INTERVALS.includes(rawInterval)
? rawInterval
: 'daily';
const keep_days = Math.max(0, parseIntField(body.keep_days, 7));
const hour = Math.min(23, Math.max(0, parseIntField(body.hour, 2)));
const day_of_week = Math.min(6, Math.max(0, parseIntField(body.day_of_week, 0)));
const day_of_month = Math.min(28, Math.max(1, parseIntField(body.day_of_month, 1)));
return { enabled, interval, keep_days, hour, day_of_week, day_of_month };
}
export function isValidBackupFilename(filename: string): boolean {
return /^backup-[\w\-]+\.zip$/.test(filename);
}
export function backupFilePath(filename: string): string {
return path.join(backupsDir, filename);
}
export function backupFileExists(filename: string): boolean {
return fs.existsSync(path.join(backupsDir, filename));
}
// ---------------------------------------------------------------------------
// Rate limiter state (shared across requests)
// ---------------------------------------------------------------------------
export const BACKUP_RATE_WINDOW = 60 * 60 * 1000; // 1 hour
const backupAttempts = new Map<string, { count: number; first: number }>();
/** Returns true if the request is allowed, false if rate-limited. */
export function checkRateLimit(key: string, maxAttempts: number, windowMs: number): boolean {
const now = Date.now();
const record = backupAttempts.get(key);
if (record && record.count >= maxAttempts && now - record.first < windowMs) {
return false;
}
if (!record || now - record.first >= windowMs) {
backupAttempts.set(key, { count: 1, first: now });
} else {
record.count++;
}
return true;
}
// ---------------------------------------------------------------------------
// List backups
// ---------------------------------------------------------------------------
export interface BackupInfo {
filename: string;
size: number;
sizeText: string;
created_at: string;
}
export function listBackups(): BackupInfo[] {
ensureBackupsDir();
return fs.readdirSync(backupsDir)
.filter(f => f.endsWith('.zip'))
.map(filename => {
const filePath = path.join(backupsDir, filename);
const stat = fs.statSync(filePath);
return {
filename,
size: stat.size,
sizeText: formatSize(stat.size),
created_at: stat.birthtime.toISOString(),
};
})
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
}
// ---------------------------------------------------------------------------
// Create backup
// ---------------------------------------------------------------------------
export async function createBackup(): Promise<BackupInfo> {
ensureBackupsDir();
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const filename = `backup-${timestamp}.zip`;
const outputPath = path.join(backupsDir, filename);
try {
try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {}
await new Promise<void>((resolve, reject) => {
const output = fs.createWriteStream(outputPath);
const archive = archiver('zip', { zlib: { level: 9 } });
output.on('close', resolve);
archive.on('error', reject);
archive.pipe(output);
const dbPath = path.join(dataDir, 'travel.db');
if (fs.existsSync(dbPath)) {
archive.file(dbPath, { name: 'travel.db' });
}
if (fs.existsSync(uploadsDir)) {
archive.directory(uploadsDir, 'uploads');
}
archive.finalize();
});
const stat = fs.statSync(outputPath);
return {
filename,
size: stat.size,
sizeText: formatSize(stat.size),
created_at: stat.birthtime.toISOString(),
};
} catch (err: unknown) {
console.error('Backup error:', err);
if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath);
throw err;
}
}
// ---------------------------------------------------------------------------
// Restore from ZIP
// ---------------------------------------------------------------------------
export interface RestoreResult {
success: boolean;
error?: string;
status?: number;
}
export async function restoreFromZip(zipPath: string): Promise<RestoreResult> {
const extractDir = path.join(dataDir, `restore-${Date.now()}`);
try {
await fs.createReadStream(zipPath)
.pipe(unzipper.Extract({ path: extractDir }))
.promise();
const extractedDb = path.join(extractDir, 'travel.db');
if (!fs.existsSync(extractedDb)) {
fs.rmSync(extractDir, { recursive: true, force: true });
return { success: false, error: 'Invalid backup: travel.db not found', status: 400 };
}
let uploadedDb: InstanceType<typeof Database> | null = null;
try {
uploadedDb = new Database(extractedDb, { readonly: true });
const integrityResult = uploadedDb.prepare('PRAGMA integrity_check').get() as { integrity_check: string };
if (integrityResult.integrity_check !== 'ok') {
fs.rmSync(extractDir, { recursive: true, force: true });
return { success: false, error: `Uploaded database failed integrity check: ${integrityResult.integrity_check}`, status: 400 };
}
const requiredTables = ['users', 'trips', 'trip_members', 'places', 'days'];
const existingTables = uploadedDb
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
.all() as { name: string }[];
const tableNames = new Set(existingTables.map(t => t.name));
for (const table of requiredTables) {
if (!tableNames.has(table)) {
fs.rmSync(extractDir, { recursive: true, force: true });
return { success: false, error: `Uploaded database is missing required table: ${table}. This does not appear to be a TREK backup.`, status: 400 };
}
}
} catch (err) {
fs.rmSync(extractDir, { recursive: true, force: true });
return { success: false, error: 'Uploaded file is not a valid SQLite database', status: 400 };
} finally {
uploadedDb?.close();
}
closeDb();
try {
const dbDest = path.join(dataDir, 'travel.db');
for (const ext of ['', '-wal', '-shm']) {
try { fs.unlinkSync(dbDest + ext); } catch (e) {}
}
fs.copyFileSync(extractedDb, dbDest);
const extractedUploads = path.join(extractDir, 'uploads');
if (fs.existsSync(extractedUploads)) {
for (const sub of fs.readdirSync(uploadsDir)) {
const subPath = path.join(uploadsDir, sub);
if (fs.statSync(subPath).isDirectory()) {
for (const file of fs.readdirSync(subPath)) {
try { fs.unlinkSync(path.join(subPath, file)); } catch (e) {}
}
}
}
fs.cpSync(extractedUploads, uploadsDir, { recursive: true, force: true });
}
} finally {
reinitialize();
}
fs.rmSync(extractDir, { recursive: true, force: true });
return { success: true };
} catch (err: unknown) {
console.error('Restore error:', err);
if (fs.existsSync(extractDir)) fs.rmSync(extractDir, { recursive: true, force: true });
throw err;
}
}
// ---------------------------------------------------------------------------
// Auto-backup settings
// ---------------------------------------------------------------------------
export function getAutoSettings(): { settings: ReturnType<typeof scheduler.loadSettings>; timezone: string } {
const tz = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
return { settings: scheduler.loadSettings(), timezone: tz };
}
export function updateAutoSettings(body: Record<string, unknown>): ReturnType<typeof parseAutoBackupBody> {
const settings = parseAutoBackupBody(body);
scheduler.saveSettings(settings);
scheduler.start();
return settings;
}
// ---------------------------------------------------------------------------
// Delete backup
// ---------------------------------------------------------------------------
export function deleteBackup(filename: string): void {
const filePath = path.join(backupsDir, filename);
fs.unlinkSync(filePath);
}
// ---------------------------------------------------------------------------
// Upload config (multer dest)
// ---------------------------------------------------------------------------
export function getUploadTmpDir(): string {
return path.join(dataDir, 'tmp/');
}
+256
View File
@@ -0,0 +1,256 @@
import { db, canAccessTrip } from '../db/database';
import { BudgetItem, BudgetItemMember } from '../types';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
export function avatarUrl(user: { avatar?: string | null }): string | null {
return user.avatar ? `/uploads/avatars/${user.avatar}` : null;
}
export function verifyTripAccess(tripId: string | number, userId: number) {
return canAccessTrip(tripId, userId);
}
function loadItemMembers(itemId: number | string) {
return db.prepare(`
SELECT bm.user_id, bm.paid, u.username, u.avatar
FROM budget_item_members bm
JOIN users u ON bm.user_id = u.id
WHERE bm.budget_item_id = ?
`).all(itemId) as BudgetItemMember[];
}
// ---------------------------------------------------------------------------
// CRUD
// ---------------------------------------------------------------------------
export function listBudgetItems(tripId: string | number) {
const items = db.prepare(
'SELECT * FROM budget_items WHERE trip_id = ? ORDER BY category ASC, created_at ASC'
).all(tripId) as BudgetItem[];
const itemIds = items.map(i => i.id);
const membersByItem: Record<number, (BudgetItemMember & { avatar_url: string | null })[]> = {};
if (itemIds.length > 0) {
const allMembers = db.prepare(`
SELECT bm.budget_item_id, bm.user_id, bm.paid, u.username, u.avatar
FROM budget_item_members bm
JOIN users u ON bm.user_id = u.id
WHERE bm.budget_item_id IN (${itemIds.map(() => '?').join(',')})
`).all(...itemIds) as (BudgetItemMember & { budget_item_id: number })[];
for (const m of allMembers) {
if (!membersByItem[m.budget_item_id]) membersByItem[m.budget_item_id] = [];
membersByItem[m.budget_item_id].push({
user_id: m.user_id, paid: m.paid, username: m.username, avatar_url: avatarUrl(m),
});
}
}
items.forEach(item => { item.members = membersByItem[item.id] || []; });
return items;
}
export function createBudgetItem(
tripId: string | number,
data: { category?: string; name: string; total_price?: number; persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null },
) {
const maxOrder = db.prepare(
'SELECT MAX(sort_order) as max FROM budget_items WHERE trip_id = ?'
).get(tripId) as { max: number | null };
const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
const result = db.prepare(
'INSERT INTO budget_items (trip_id, category, name, total_price, persons, days, note, sort_order, expense_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(
tripId,
data.category || 'Other',
data.name,
data.total_price || 0,
data.persons != null ? data.persons : null,
data.days !== undefined && data.days !== null ? data.days : null,
data.note || null,
sortOrder,
data.expense_date || null,
);
const item = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(result.lastInsertRowid) as BudgetItem & { members?: BudgetItemMember[] };
item.members = [];
return item;
}
export function updateBudgetItem(
id: string | number,
tripId: string | number,
data: { category?: string; name?: string; total_price?: number; persons?: number | null; days?: number | null; note?: string | null; sort_order?: number; expense_date?: string | null },
) {
const item = db.prepare('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!item) return null;
db.prepare(`
UPDATE budget_items SET
category = COALESCE(?, category),
name = COALESCE(?, name),
total_price = CASE WHEN ? IS NOT NULL THEN ? ELSE total_price END,
persons = CASE WHEN ? IS NOT NULL THEN ? ELSE persons END,
days = CASE WHEN ? THEN ? ELSE days END,
note = CASE WHEN ? THEN ? ELSE note END,
sort_order = CASE WHEN ? IS NOT NULL THEN ? ELSE sort_order END,
expense_date = CASE WHEN ? THEN ? ELSE expense_date END
WHERE id = ?
`).run(
data.category || null,
data.name || null,
data.total_price !== undefined ? 1 : null, data.total_price !== undefined ? data.total_price : 0,
data.persons !== undefined ? 1 : null, data.persons !== undefined ? data.persons : null,
data.days !== undefined ? 1 : 0, data.days !== undefined ? data.days : null,
data.note !== undefined ? 1 : 0, data.note !== undefined ? data.note : null,
data.sort_order !== undefined ? 1 : null, data.sort_order !== undefined ? data.sort_order : 0,
data.expense_date !== undefined ? 1 : 0, data.expense_date !== undefined ? (data.expense_date || null) : null,
id,
);
const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id) as BudgetItem & { members?: BudgetItemMember[] };
updated.members = loadItemMembers(id);
return updated;
}
export function deleteBudgetItem(id: string | number, tripId: string | number): boolean {
const item = db.prepare('SELECT id FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!item) return false;
db.prepare('DELETE FROM budget_items WHERE id = ?').run(id);
return true;
}
// ---------------------------------------------------------------------------
// Members
// ---------------------------------------------------------------------------
export function updateMembers(id: string | number, tripId: string | number, userIds: number[]) {
const item = db.prepare('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!item) return null;
const existingPaid: Record<number, number> = {};
const existing = db.prepare('SELECT user_id, paid FROM budget_item_members WHERE budget_item_id = ?').all(id) as { user_id: number; paid: number }[];
for (const e of existing) existingPaid[e.user_id] = e.paid;
db.prepare('DELETE FROM budget_item_members WHERE budget_item_id = ?').run(id);
if (userIds.length > 0) {
const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, ?)');
for (const userId of userIds) insert.run(id, userId, existingPaid[userId] || 0);
db.prepare('UPDATE budget_items SET persons = ? WHERE id = ?').run(userIds.length, id);
} else {
db.prepare('UPDATE budget_items SET persons = NULL WHERE id = ?').run(id);
}
const members = loadItemMembers(id).map(m => ({ ...m, avatar_url: avatarUrl(m) }));
const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id) as BudgetItem;
return { members, item: updated };
}
export function toggleMemberPaid(id: string | number, userId: string | number, paid: boolean) {
db.prepare('UPDATE budget_item_members SET paid = ? WHERE budget_item_id = ? AND user_id = ?')
.run(paid ? 1 : 0, id, userId);
const member = db.prepare(`
SELECT bm.user_id, bm.paid, u.username, u.avatar
FROM budget_item_members bm JOIN users u ON bm.user_id = u.id
WHERE bm.budget_item_id = ? AND bm.user_id = ?
`).get(id, userId) as BudgetItemMember | undefined;
return member ? { ...member, avatar_url: avatarUrl(member) } : null;
}
// ---------------------------------------------------------------------------
// Per-person summary
// ---------------------------------------------------------------------------
export function getPerPersonSummary(tripId: string | number) {
const summary = db.prepare(`
SELECT bm.user_id, u.username, u.avatar,
SUM(bi.total_price * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id)) as total_assigned,
SUM(CASE WHEN bm.paid = 1 THEN bi.total_price * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id) ELSE 0 END) as total_paid,
COUNT(bi.id) as items_count
FROM budget_item_members bm
JOIN budget_items bi ON bm.budget_item_id = bi.id
JOIN users u ON bm.user_id = u.id
WHERE bi.trip_id = ?
GROUP BY bm.user_id
`).all(tripId) as { user_id: number; username: string; avatar: string | null; total_assigned: number; total_paid: number; items_count: number }[];
return summary.map(s => ({ ...s, avatar_url: avatarUrl(s) }));
}
// ---------------------------------------------------------------------------
// Settlement calculation (greedy debt matching)
// ---------------------------------------------------------------------------
export function calculateSettlement(tripId: string | number) {
const items = db.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(tripId) as BudgetItem[];
const allMembers = db.prepare(`
SELECT bm.budget_item_id, bm.user_id, bm.paid, u.username, u.avatar
FROM budget_item_members bm
JOIN users u ON bm.user_id = u.id
WHERE bm.budget_item_id IN (SELECT id FROM budget_items WHERE trip_id = ?)
`).all(tripId) as (BudgetItemMember & { budget_item_id: number })[];
// Calculate net balance per user: positive = is owed money, negative = owes money
const balances: Record<number, { user_id: number; username: string; avatar_url: string | null; balance: number }> = {};
for (const item of items) {
const members = allMembers.filter(m => m.budget_item_id === item.id);
if (members.length === 0) continue;
const payers = members.filter(m => m.paid);
if (payers.length === 0) continue; // no one marked as paid
const sharePerMember = item.total_price / members.length;
const paidPerPayer = item.total_price / payers.length;
for (const m of members) {
if (!balances[m.user_id]) {
balances[m.user_id] = { user_id: m.user_id, username: m.username, avatar_url: avatarUrl(m), balance: 0 };
}
// Everyone owes their share
balances[m.user_id].balance -= sharePerMember;
// Payers get credited what they paid
if (m.paid) balances[m.user_id].balance += paidPerPayer;
}
}
// Calculate optimized payment flows (greedy algorithm)
const people = Object.values(balances).filter(b => Math.abs(b.balance) > 0.01);
const debtors = people.filter(p => p.balance < -0.01).map(p => ({ ...p, amount: -p.balance }));
const creditors = people.filter(p => p.balance > 0.01).map(p => ({ ...p, amount: p.balance }));
// Sort by amount descending for efficient matching
debtors.sort((a, b) => b.amount - a.amount);
creditors.sort((a, b) => b.amount - a.amount);
const flows: { from: { user_id: number; username: string; avatar_url: string | null }; to: { user_id: number; username: string; avatar_url: string | null }; amount: number }[] = [];
let di = 0, ci = 0;
while (di < debtors.length && ci < creditors.length) {
const transfer = Math.min(debtors[di].amount, creditors[ci].amount);
if (transfer > 0.01) {
flows.push({
from: { user_id: debtors[di].user_id, username: debtors[di].username, avatar_url: debtors[di].avatar_url },
to: { user_id: creditors[ci].user_id, username: creditors[ci].username, avatar_url: creditors[ci].avatar_url },
amount: Math.round(transfer * 100) / 100,
});
}
debtors[di].amount -= transfer;
creditors[ci].amount -= transfer;
if (debtors[di].amount < 0.01) di++;
if (creditors[ci].amount < 0.01) ci++;
}
return {
balances: Object.values(balances).map(b => ({ ...b, balance: Math.round(b.balance * 100) / 100 })),
flows,
};
}
+31
View File
@@ -0,0 +1,31 @@
import { db } from '../db/database';
export function listCategories() {
return db.prepare('SELECT * FROM categories ORDER BY name ASC').all();
}
export function createCategory(userId: number, name: string, color?: string, icon?: string) {
const result = db.prepare(
'INSERT INTO categories (name, color, icon, user_id) VALUES (?, ?, ?, ?)'
).run(name, color || '#6366f1', icon || '\uD83D\uDCCD', userId);
return db.prepare('SELECT * FROM categories WHERE id = ?').get(result.lastInsertRowid);
}
export function getCategoryById(categoryId: number | string) {
return db.prepare('SELECT * FROM categories WHERE id = ?').get(categoryId);
}
export function updateCategory(categoryId: number | string, name?: string, color?: string, icon?: string) {
db.prepare(`
UPDATE categories SET
name = COALESCE(?, name),
color = COALESCE(?, color),
icon = COALESCE(?, icon)
WHERE id = ?
`).run(name || null, color || null, icon || null, categoryId);
return db.prepare('SELECT * FROM categories WHERE id = ?').get(categoryId);
}
export function deleteCategory(categoryId: number | string) {
db.prepare('DELETE FROM categories WHERE id = ?').run(categoryId);
}
+441
View File
@@ -0,0 +1,441 @@
import path from 'path';
import fs from 'fs';
import { db, canAccessTrip } from '../db/database';
import { CollabNote, CollabPoll, CollabMessage, TripFile } from '../types';
import { checkSsrf, createPinnedAgent } from '../utils/ssrfGuard';
/* ------------------------------------------------------------------ */
/* Internal row types */
/* ------------------------------------------------------------------ */
export interface ReactionRow {
emoji: string;
user_id: number;
username: string;
message_id?: number;
}
export interface PollVoteRow {
option_index: number;
user_id: number;
username: string;
avatar: string | null;
}
export interface NoteFileRow {
id: number;
filename: string;
original_name?: string;
file_size?: number;
mime_type?: string;
}
export interface GroupedReaction {
emoji: string;
users: { user_id: number; username: string }[];
count: number;
}
export interface LinkPreviewResult {
title: string | null;
description: string | null;
image: string | null;
site_name?: string | null;
url: string;
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
export function avatarUrl(user: { avatar?: string | null }): string | null {
return user.avatar ? `/uploads/avatars/${user.avatar}` : null;
}
export function verifyTripAccess(tripId: string | number, userId: number) {
return canAccessTrip(tripId, userId);
}
/* ------------------------------------------------------------------ */
/* Reactions */
/* ------------------------------------------------------------------ */
export function loadReactions(messageId: number | string): ReactionRow[] {
return db.prepare(`
SELECT r.emoji, r.user_id, u.username
FROM collab_message_reactions r
JOIN users u ON r.user_id = u.id
WHERE r.message_id = ?
`).all(messageId) as ReactionRow[];
}
export function groupReactions(reactions: ReactionRow[]): GroupedReaction[] {
const map: Record<string, { user_id: number; username: string }[]> = {};
for (const r of reactions) {
if (!map[r.emoji]) map[r.emoji] = [];
map[r.emoji].push({ user_id: r.user_id, username: r.username });
}
return Object.entries(map).map(([emoji, users]) => ({ emoji, users, count: users.length }));
}
export function addOrRemoveReaction(messageId: number | string, tripId: number | string, userId: number, emoji: string): { found: boolean; reactions: GroupedReaction[] } {
const msg = db.prepare('SELECT id FROM collab_messages WHERE id = ? AND trip_id = ?').get(messageId, tripId);
if (!msg) return { found: false, reactions: [] };
const existing = db.prepare('SELECT id FROM collab_message_reactions WHERE message_id = ? AND user_id = ? AND emoji = ?').get(messageId, userId, emoji) as { id: number } | undefined;
if (existing) {
db.prepare('DELETE FROM collab_message_reactions WHERE id = ?').run(existing.id);
} else {
db.prepare('INSERT INTO collab_message_reactions (message_id, user_id, emoji) VALUES (?, ?, ?)').run(messageId, userId, emoji);
}
return { found: true, reactions: groupReactions(loadReactions(messageId)) };
}
/* ------------------------------------------------------------------ */
/* Notes */
/* ------------------------------------------------------------------ */
export function formatNote(note: CollabNote) {
const attachments = db.prepare('SELECT id, filename, original_name, file_size, mime_type FROM trip_files WHERE note_id = ?').all(note.id) as NoteFileRow[];
return {
...note,
avatar_url: avatarUrl(note),
attachments: attachments.map(a => ({ ...a, url: `/uploads/${a.filename}` })),
};
}
export function listNotes(tripId: string | number) {
const notes = db.prepare(`
SELECT n.*, u.username, u.avatar
FROM collab_notes n
JOIN users u ON n.user_id = u.id
WHERE n.trip_id = ?
ORDER BY n.pinned DESC, n.updated_at DESC
`).all(tripId) as CollabNote[];
return notes.map(formatNote);
}
export function createNote(tripId: string | number, userId: number, data: { title: string; content?: string; category?: string; color?: string; website?: string }) {
const result = db.prepare(`
INSERT INTO collab_notes (trip_id, user_id, title, content, category, color, website)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(tripId, userId, data.title, data.content || null, data.category || 'General', data.color || '#6366f1', data.website || null);
const note = db.prepare(`
SELECT n.*, u.username, u.avatar FROM collab_notes n JOIN users u ON n.user_id = u.id WHERE n.id = ?
`).get(result.lastInsertRowid) as CollabNote;
return formatNote(note);
}
export function updateNote(tripId: string | number, noteId: string | number, data: { title?: string; content?: string; category?: string; color?: string; pinned?: number | boolean; website?: string }): ReturnType<typeof formatNote> | null {
const existing = db.prepare('SELECT * FROM collab_notes WHERE id = ? AND trip_id = ?').get(noteId, tripId);
if (!existing) return null;
db.prepare(`
UPDATE collab_notes SET
title = COALESCE(?, title),
content = CASE WHEN ? THEN ? ELSE content END,
category = COALESCE(?, category),
color = COALESCE(?, color),
pinned = CASE WHEN ? IS NOT NULL THEN ? ELSE pinned END,
website = CASE WHEN ? THEN ? ELSE website END,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(
data.title || null,
data.content !== undefined ? 1 : 0, data.content !== undefined ? data.content : null,
data.category || null,
data.color || null,
data.pinned !== undefined ? 1 : null, data.pinned ? 1 : 0,
data.website !== undefined ? 1 : 0, data.website !== undefined ? data.website : null,
noteId
);
const note = db.prepare(`
SELECT n.*, u.username, u.avatar FROM collab_notes n JOIN users u ON n.user_id = u.id WHERE n.id = ?
`).get(noteId) as CollabNote;
return formatNote(note);
}
export function deleteNote(tripId: string | number, noteId: string | number): boolean {
const existing = db.prepare('SELECT id FROM collab_notes WHERE id = ? AND trip_id = ?').get(noteId, tripId);
if (!existing) return false;
// Clean up attached files from disk
const noteFiles = db.prepare('SELECT id, filename FROM trip_files WHERE note_id = ?').all(noteId) as NoteFileRow[];
for (const f of noteFiles) {
const filePath = path.join(__dirname, '../../uploads', f.filename);
try { fs.unlinkSync(filePath); } catch { /* ignore */ }
}
db.prepare('DELETE FROM trip_files WHERE note_id = ?').run(noteId);
db.prepare('DELETE FROM collab_notes WHERE id = ?').run(noteId);
return true;
}
/* ------------------------------------------------------------------ */
/* Note files */
/* ------------------------------------------------------------------ */
export function addNoteFile(tripId: string | number, noteId: string | number, file: { filename: string; originalname: string; size: number; mimetype: string }): { file: TripFile & { url: string } } | null {
const note = db.prepare('SELECT id FROM collab_notes WHERE id = ? AND trip_id = ?').get(noteId, tripId);
if (!note) return null;
const result = db.prepare(
'INSERT INTO trip_files (trip_id, note_id, filename, original_name, file_size, mime_type) VALUES (?, ?, ?, ?, ?, ?)'
).run(tripId, noteId, `files/${file.filename}`, file.originalname, file.size, file.mimetype);
const saved = db.prepare('SELECT * FROM trip_files WHERE id = ?').get(result.lastInsertRowid) as TripFile;
return { file: { ...saved, url: `/uploads/${saved.filename}` } };
}
export function getFormattedNoteById(noteId: string | number) {
const note = db.prepare('SELECT n.*, u.username, u.avatar FROM collab_notes n JOIN users u ON n.user_id = u.id WHERE n.id = ?').get(noteId) as CollabNote;
return formatNote(note);
}
export function deleteNoteFile(noteId: string | number, fileId: string | number): boolean {
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND note_id = ?').get(fileId, noteId) as TripFile | undefined;
if (!file) return false;
const filePath = path.join(__dirname, '../../uploads', file.filename);
try { fs.unlinkSync(filePath); } catch { /* ignore */ }
db.prepare('DELETE FROM trip_files WHERE id = ?').run(fileId);
return true;
}
/* ------------------------------------------------------------------ */
/* Polls */
/* ------------------------------------------------------------------ */
export function getPollWithVotes(pollId: number | bigint | string) {
const poll = db.prepare(`
SELECT p.*, u.username, u.avatar
FROM collab_polls p
JOIN users u ON p.user_id = u.id
WHERE p.id = ?
`).get(pollId) as CollabPoll | undefined;
if (!poll) return null;
const options: (string | { label: string })[] = JSON.parse(poll.options);
const votes = db.prepare(`
SELECT v.option_index, v.user_id, u.username, u.avatar
FROM collab_poll_votes v
JOIN users u ON v.user_id = u.id
WHERE v.poll_id = ?
`).all(pollId) as PollVoteRow[];
const formattedOptions = options.map((label: string | { label: string }, idx: number) => ({
label: typeof label === 'string' ? label : label.label || label,
voters: votes
.filter(v => v.option_index === idx)
.map(v => ({ id: v.user_id, user_id: v.user_id, username: v.username, avatar: v.avatar, avatar_url: avatarUrl(v) })),
}));
return {
...poll,
avatar_url: avatarUrl(poll),
options: formattedOptions,
is_closed: !!poll.closed,
multiple_choice: !!poll.multiple,
};
}
export function listPolls(tripId: string | number) {
const rows = db.prepare(`
SELECT id FROM collab_polls WHERE trip_id = ? ORDER BY created_at DESC
`).all(tripId) as { id: number }[];
return rows.map(row => getPollWithVotes(row.id)).filter(Boolean);
}
export function createPoll(tripId: string | number, userId: number, data: { question: string; options: unknown[]; multiple?: boolean; multiple_choice?: boolean; deadline?: string }) {
const isMultiple = data.multiple || data.multiple_choice;
const result = db.prepare(`
INSERT INTO collab_polls (trip_id, user_id, question, options, multiple, deadline)
VALUES (?, ?, ?, ?, ?, ?)
`).run(tripId, userId, data.question, JSON.stringify(data.options), isMultiple ? 1 : 0, data.deadline || null);
return getPollWithVotes(result.lastInsertRowid);
}
export function votePoll(tripId: string | number, pollId: string | number, userId: number, optionIndex: number): { error?: string; poll?: ReturnType<typeof getPollWithVotes> } {
const poll = db.prepare('SELECT * FROM collab_polls WHERE id = ? AND trip_id = ?').get(pollId, tripId) as CollabPoll | undefined;
if (!poll) return { error: 'not_found' };
if (poll.closed) return { error: 'closed' };
const options = JSON.parse(poll.options);
if (optionIndex < 0 || optionIndex >= options.length) {
return { error: 'invalid_index' };
}
const existingVote = db.prepare(
'SELECT id FROM collab_poll_votes WHERE poll_id = ? AND user_id = ? AND option_index = ?'
).get(pollId, userId, optionIndex) as { id: number } | undefined;
if (existingVote) {
db.prepare('DELETE FROM collab_poll_votes WHERE id = ?').run(existingVote.id);
} else {
if (!poll.multiple) {
db.prepare('DELETE FROM collab_poll_votes WHERE poll_id = ? AND user_id = ?').run(pollId, userId);
}
db.prepare('INSERT INTO collab_poll_votes (poll_id, user_id, option_index) VALUES (?, ?, ?)').run(pollId, userId, optionIndex);
}
return { poll: getPollWithVotes(pollId) };
}
export function closePoll(tripId: string | number, pollId: string | number): ReturnType<typeof getPollWithVotes> | null {
const poll = db.prepare('SELECT * FROM collab_polls WHERE id = ? AND trip_id = ?').get(pollId, tripId);
if (!poll) return null;
db.prepare('UPDATE collab_polls SET closed = 1 WHERE id = ?').run(pollId);
return getPollWithVotes(pollId);
}
export function deletePoll(tripId: string | number, pollId: string | number): boolean {
const poll = db.prepare('SELECT id FROM collab_polls WHERE id = ? AND trip_id = ?').get(pollId, tripId);
if (!poll) return false;
db.prepare('DELETE FROM collab_polls WHERE id = ?').run(pollId);
return true;
}
/* ------------------------------------------------------------------ */
/* Messages */
/* ------------------------------------------------------------------ */
export function formatMessage(msg: CollabMessage, reactions?: GroupedReaction[]) {
return { ...msg, user_avatar: avatarUrl(msg), avatar_url: avatarUrl(msg), reactions: reactions || [] };
}
export function listMessages(tripId: string | number, before?: string | number) {
const query = `
SELECT m.*, u.username, u.avatar,
rm.text AS reply_text, ru.username AS reply_username
FROM collab_messages m
JOIN users u ON m.user_id = u.id
LEFT JOIN collab_messages rm ON m.reply_to = rm.id
LEFT JOIN users ru ON rm.user_id = ru.id
WHERE m.trip_id = ?${before ? ' AND m.id < ?' : ''}
ORDER BY m.id DESC
LIMIT 100
`;
const messages = before
? db.prepare(query).all(tripId, before) as CollabMessage[]
: db.prepare(query).all(tripId) as CollabMessage[];
messages.reverse();
const msgIds = messages.map(m => m.id);
const reactionsByMsg: Record<number, ReactionRow[]> = {};
if (msgIds.length > 0) {
const allReactions = db.prepare(`
SELECT r.message_id, r.emoji, r.user_id, u.username
FROM collab_message_reactions r
JOIN users u ON r.user_id = u.id
WHERE r.message_id IN (${msgIds.map(() => '?').join(',')})
`).all(...msgIds) as (ReactionRow & { message_id: number })[];
for (const r of allReactions) {
if (!reactionsByMsg[r.message_id]) reactionsByMsg[r.message_id] = [];
reactionsByMsg[r.message_id].push(r);
}
}
return messages.map(m => formatMessage(m, groupReactions(reactionsByMsg[m.id] || [])));
}
export function createMessage(tripId: string | number, userId: number, text: string, replyTo?: number | null): { error?: string; message?: ReturnType<typeof formatMessage> } {
if (replyTo) {
const replyMsg = db.prepare('SELECT id FROM collab_messages WHERE id = ? AND trip_id = ?').get(replyTo, tripId);
if (!replyMsg) return { error: 'reply_not_found' };
}
const result = db.prepare(`
INSERT INTO collab_messages (trip_id, user_id, text, reply_to) VALUES (?, ?, ?, ?)
`).run(tripId, userId, text.trim(), replyTo || null);
const message = db.prepare(`
SELECT m.*, u.username, u.avatar,
rm.text AS reply_text, ru.username AS reply_username
FROM collab_messages m
JOIN users u ON m.user_id = u.id
LEFT JOIN collab_messages rm ON m.reply_to = rm.id
LEFT JOIN users ru ON rm.user_id = ru.id
WHERE m.id = ?
`).get(result.lastInsertRowid) as CollabMessage;
return { message: formatMessage(message) };
}
export function deleteMessage(tripId: string | number, messageId: string | number, userId: number): { error?: string; username?: string } {
const message = db.prepare('SELECT * FROM collab_messages WHERE id = ? AND trip_id = ?').get(messageId, tripId) as CollabMessage | undefined;
if (!message) return { error: 'not_found' };
if (Number(message.user_id) !== Number(userId)) return { error: 'not_owner' };
db.prepare('UPDATE collab_messages SET deleted = 1 WHERE id = ?').run(messageId);
return { username: message.username };
}
/* ------------------------------------------------------------------ */
/* Link preview */
/* ------------------------------------------------------------------ */
export async function fetchLinkPreview(url: string): Promise<LinkPreviewResult> {
const fallback: LinkPreviewResult = { title: null, description: null, image: null, url };
const parsed = new URL(url);
const ssrf = await checkSsrf(url);
if (!ssrf.allowed) {
return { ...fallback, error: ssrf.error } as LinkPreviewResult & { error?: string };
}
try {
const nodeFetch = require('node-fetch');
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const r: { ok: boolean; text: () => Promise<string> } = await nodeFetch(url, {
redirect: 'error',
signal: controller.signal,
agent: createPinnedAgent(ssrf.resolvedIp!, parsed.protocol),
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; NOMAD/1.0; +https://github.com/mauriceboe/NOMAD)' },
});
clearTimeout(timeout);
if (!r.ok) throw new Error('Fetch failed');
const html = await r.text();
const get = (prop: string) => {
const m = html.match(new RegExp(`<meta[^>]*property=["']og:${prop}["'][^>]*content=["']([^"']*)["']`, 'i'))
|| html.match(new RegExp(`<meta[^>]*content=["']([^"']*)["'][^>]*property=["']og:${prop}["']`, 'i'));
return m ? m[1] : null;
};
const titleTag = html.match(/<title[^>]*>([^<]*)<\/title>/i);
const descMeta = html.match(/<meta[^>]*name=["']description["'][^>]*content=["']([^"']*)["']/i)
|| html.match(/<meta[^>]*content=["']([^"']*)["'][^>]*name=["']description["']/i);
return {
title: get('title') || (titleTag ? titleTag[1].trim() : null),
description: get('description') || (descMeta ? descMeta[1].trim() : null),
image: get('image') || null,
site_name: get('site_name') || null,
url,
};
} catch {
clearTimeout(timeout);
return fallback;
}
} catch {
return fallback;
}
}
+44
View File
@@ -0,0 +1,44 @@
import { db, canAccessTrip } from '../db/database';
import { DayNote } from '../types';
export function verifyTripAccess(tripId: string | number, userId: number) {
return canAccessTrip(tripId, userId);
}
export function listNotes(dayId: string | number, tripId: string | number) {
return db.prepare(
'SELECT * FROM day_notes WHERE day_id = ? AND trip_id = ? ORDER BY sort_order ASC, created_at ASC'
).all(dayId, tripId);
}
export function dayExists(dayId: string | number, tripId: string | number) {
return db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId);
}
export function createNote(dayId: string | number, tripId: string | number, text: string, time?: string, icon?: string, sort_order?: number) {
const result = db.prepare(
'INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order) VALUES (?, ?, ?, ?, ?, ?)'
).run(dayId, tripId, text.trim(), time || null, icon || '\uD83D\uDCDD', sort_order ?? 9999);
return db.prepare('SELECT * FROM day_notes WHERE id = ?').get(result.lastInsertRowid);
}
export function getNote(id: string | number, dayId: string | number, tripId: string | number) {
return db.prepare('SELECT * FROM day_notes WHERE id = ? AND day_id = ? AND trip_id = ?').get(id, dayId, tripId) as DayNote | undefined;
}
export function updateNote(id: string | number, current: DayNote, fields: { text?: string; time?: string; icon?: string; sort_order?: number }) {
db.prepare(
'UPDATE day_notes SET text = ?, time = ?, icon = ?, sort_order = ? WHERE id = ?'
).run(
fields.text !== undefined ? fields.text.trim() : current.text,
fields.time !== undefined ? fields.time : current.time,
fields.icon !== undefined ? fields.icon : current.icon,
fields.sort_order !== undefined ? fields.sort_order : current.sort_order,
id
);
return db.prepare('SELECT * FROM day_notes WHERE id = ?').get(id);
}
export function deleteNote(id: string | number) {
db.prepare('DELETE FROM day_notes WHERE id = ?').run(id);
}
+298
View File
@@ -0,0 +1,298 @@
import { db, canAccessTrip } from '../db/database';
import { loadTagsByPlaceIds, loadParticipantsByAssignmentIds, formatAssignmentWithPlace } from './queryHelpers';
import { AssignmentRow, Day, DayNote } from '../types';
export function verifyTripAccess(tripId: string | number, userId: number) {
return canAccessTrip(tripId, userId);
}
// ---------------------------------------------------------------------------
// Day assignment helpers
// ---------------------------------------------------------------------------
export function getAssignmentsForDay(dayId: number | string) {
const assignments = db.prepare(`
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
COALESCE(da.assignment_time, p.place_time) as place_time,
COALESCE(da.assignment_end_time, p.end_time) as end_time,
p.duration_minutes, p.notes as place_notes,
p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone,
c.name as category_name, c.color as category_color, c.icon as category_icon
FROM day_assignments da
JOIN places p ON da.place_id = p.id
LEFT JOIN categories c ON p.category_id = c.id
WHERE da.day_id = ?
ORDER BY da.order_index ASC, da.created_at ASC
`).all(dayId) as AssignmentRow[];
return assignments.map(a => {
const tags = db.prepare(`
SELECT t.* FROM tags t
JOIN place_tags pt ON t.id = pt.tag_id
WHERE pt.place_id = ?
`).all(a.place_id);
return {
id: a.id,
day_id: a.day_id,
order_index: a.order_index,
notes: a.notes,
created_at: a.created_at,
place: {
id: a.place_id,
name: a.place_name,
description: a.place_description,
lat: a.lat,
lng: a.lng,
address: a.address,
category_id: a.category_id,
price: a.price,
currency: a.place_currency,
place_time: a.place_time,
end_time: a.end_time,
duration_minutes: a.duration_minutes,
notes: a.place_notes,
image_url: a.image_url,
transport_mode: a.transport_mode,
google_place_id: a.google_place_id,
website: a.website,
phone: a.phone,
category: a.category_id ? {
id: a.category_id,
name: a.category_name,
color: a.category_color,
icon: a.category_icon,
} : null,
tags,
}
};
});
}
// ---------------------------------------------------------------------------
// Day CRUD
// ---------------------------------------------------------------------------
export function listDays(tripId: string | number) {
const days = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(tripId) as Day[];
if (days.length === 0) {
return { days: [] };
}
const dayIds = days.map(d => d.id);
const dayPlaceholders = dayIds.map(() => '?').join(',');
const allAssignments = db.prepare(`
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
COALESCE(da.assignment_time, p.place_time) as place_time,
COALESCE(da.assignment_end_time, p.end_time) as end_time,
p.duration_minutes, p.notes as place_notes,
p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone,
c.name as category_name, c.color as category_color, c.icon as category_icon
FROM day_assignments da
JOIN places p ON da.place_id = p.id
LEFT JOIN categories c ON p.category_id = c.id
WHERE da.day_id IN (${dayPlaceholders})
ORDER BY da.order_index ASC, da.created_at ASC
`).all(...dayIds) as AssignmentRow[];
const placeIds = [...new Set(allAssignments.map(a => a.place_id))];
const tagsByPlaceId = loadTagsByPlaceIds(placeIds, { compact: true });
const allAssignmentIds = allAssignments.map(a => a.id);
const participantsByAssignment = loadParticipantsByAssignmentIds(allAssignmentIds);
const assignmentsByDayId: Record<number, ReturnType<typeof formatAssignmentWithPlace>[]> = {};
for (const a of allAssignments) {
if (!assignmentsByDayId[a.day_id]) assignmentsByDayId[a.day_id] = [];
assignmentsByDayId[a.day_id].push(formatAssignmentWithPlace(a, tagsByPlaceId[a.place_id] || [], participantsByAssignment[a.id] || []));
}
const allNotes = db.prepare(
`SELECT * FROM day_notes WHERE day_id IN (${dayPlaceholders}) ORDER BY sort_order ASC, created_at ASC`
).all(...dayIds) as DayNote[];
const notesByDayId: Record<number, DayNote[]> = {};
for (const note of allNotes) {
if (!notesByDayId[note.day_id]) notesByDayId[note.day_id] = [];
notesByDayId[note.day_id].push(note);
}
const daysWithAssignments = days.map(day => ({
...day,
assignments: assignmentsByDayId[day.id] || [],
notes_items: notesByDayId[day.id] || [],
}));
return { days: daysWithAssignments };
}
export function createDay(tripId: string | number, date?: string, notes?: string) {
const maxDay = db.prepare('SELECT MAX(day_number) as max FROM days WHERE trip_id = ?').get(tripId) as { max: number | null };
const dayNumber = (maxDay.max || 0) + 1;
const result = db.prepare(
'INSERT INTO days (trip_id, day_number, date, notes) VALUES (?, ?, ?, ?)'
).run(tripId, dayNumber, date || null, notes || null);
const day = db.prepare('SELECT * FROM days WHERE id = ?').get(result.lastInsertRowid) as Day;
return { ...day, assignments: [] };
}
export function getDay(id: string | number, tripId: string | number) {
return db.prepare('SELECT * FROM days WHERE id = ? AND trip_id = ?').get(id, tripId) as Day | undefined;
}
export function updateDay(id: string | number, current: Day, fields: { notes?: string; title?: string }) {
db.prepare('UPDATE days SET notes = ?, title = ? WHERE id = ?').run(
fields.notes || null,
fields.title !== undefined ? fields.title : current.title,
id
);
const updatedDay = db.prepare('SELECT * FROM days WHERE id = ?').get(id) as Day;
return { ...updatedDay, assignments: getAssignmentsForDay(id) };
}
export function deleteDay(id: string | number) {
db.prepare('DELETE FROM days WHERE id = ?').run(id);
}
// ---------------------------------------------------------------------------
// Accommodation helpers
// ---------------------------------------------------------------------------
export interface DayAccommodation {
id: number;
trip_id: number;
place_id: number;
start_day_id: number;
end_day_id: number;
check_in: string | null;
check_out: string | null;
confirmation: string | null;
notes: string | null;
}
function getAccommodationWithPlace(id: number | bigint) {
return db.prepare(`
SELECT a.*, p.name as place_name, p.address as place_address, p.image_url as place_image, p.lat as place_lat, p.lng as place_lng
FROM day_accommodations a
JOIN places p ON a.place_id = p.id
WHERE a.id = ?
`).get(id);
}
// ---------------------------------------------------------------------------
// Accommodation CRUD
// ---------------------------------------------------------------------------
export function listAccommodations(tripId: string | number) {
return db.prepare(`
SELECT a.*, p.name as place_name, p.address as place_address, p.image_url as place_image, p.lat as place_lat, p.lng as place_lng
FROM day_accommodations a
JOIN places p ON a.place_id = p.id
WHERE a.trip_id = ?
ORDER BY a.created_at ASC
`).all(tripId);
}
export function validateAccommodationRefs(tripId: string | number, placeId?: number, startDayId?: number, endDayId?: number) {
const errors: { field: string; message: string }[] = [];
if (placeId !== undefined) {
const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId);
if (!place) errors.push({ field: 'place_id', message: 'Place not found' });
}
if (startDayId !== undefined) {
const startDay = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(startDayId, tripId);
if (!startDay) errors.push({ field: 'start_day_id', message: 'Start day not found' });
}
if (endDayId !== undefined) {
const endDay = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(endDayId, tripId);
if (!endDay) errors.push({ field: 'end_day_id', message: 'End day not found' });
}
return errors;
}
interface CreateAccommodationData {
place_id: number;
start_day_id: number;
end_day_id: number;
check_in?: string;
check_out?: string;
confirmation?: string;
notes?: string;
}
export function createAccommodation(tripId: string | number, data: CreateAccommodationData) {
const { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes } = data;
const result = db.prepare(
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
).run(tripId, place_id, start_day_id, end_day_id, check_in || null, check_out || null, confirmation || null, notes || null);
const accommodationId = result.lastInsertRowid;
// Auto-create linked reservation for this accommodation
const placeName = (db.prepare('SELECT name FROM places WHERE id = ?').get(place_id) as { name: string } | undefined)?.name || 'Hotel';
const startDayDate = (db.prepare('SELECT date FROM days WHERE id = ?').get(start_day_id) as { date: string } | undefined)?.date || null;
const meta: Record<string, string> = {};
if (check_in) meta.check_in_time = check_in;
if (check_out) meta.check_out_time = check_out;
db.prepare(`
INSERT INTO reservations (trip_id, day_id, title, reservation_time, location, confirmation_number, notes, status, type, accommodation_id, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, 'confirmed', 'hotel', ?, ?)
`).run(
tripId, start_day_id, placeName, startDayDate || null, null,
confirmation || null, notes || null, accommodationId,
Object.keys(meta).length > 0 ? JSON.stringify(meta) : null
);
return getAccommodationWithPlace(accommodationId);
}
export function getAccommodation(id: string | number, tripId: string | number) {
return db.prepare('SELECT * FROM day_accommodations WHERE id = ? AND trip_id = ?').get(id, tripId) as DayAccommodation | undefined;
}
export function updateAccommodation(id: string | number, existing: DayAccommodation, fields: {
place_id?: number; start_day_id?: number; end_day_id?: number;
check_in?: string; check_out?: string; confirmation?: string; notes?: string;
}) {
const newPlaceId = fields.place_id !== undefined ? fields.place_id : existing.place_id;
const newStartDayId = fields.start_day_id !== undefined ? fields.start_day_id : existing.start_day_id;
const newEndDayId = fields.end_day_id !== undefined ? fields.end_day_id : existing.end_day_id;
const newCheckIn = fields.check_in !== undefined ? fields.check_in : existing.check_in;
const newCheckOut = fields.check_out !== undefined ? fields.check_out : existing.check_out;
const newConfirmation = fields.confirmation !== undefined ? fields.confirmation : existing.confirmation;
const newNotes = fields.notes !== undefined ? fields.notes : existing.notes;
db.prepare(
'UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ?, notes = ? WHERE id = ?'
).run(newPlaceId, newStartDayId, newEndDayId, newCheckIn, newCheckOut, newConfirmation, newNotes, id);
// Sync check-in/out/confirmation to linked reservation
const linkedRes = db.prepare('SELECT id, metadata FROM reservations WHERE accommodation_id = ?').get(Number(id)) as { id: number; metadata: string | null } | undefined;
if (linkedRes) {
const meta = linkedRes.metadata ? JSON.parse(linkedRes.metadata) : {};
if (newCheckIn) meta.check_in_time = newCheckIn;
if (newCheckOut) meta.check_out_time = newCheckOut;
db.prepare('UPDATE reservations SET metadata = ?, confirmation_number = COALESCE(?, confirmation_number) WHERE id = ?')
.run(JSON.stringify(meta), newConfirmation || null, linkedRes.id);
}
return getAccommodationWithPlace(Number(id));
}
/** Delete accommodation and its linked reservation. Returns the linked reservation id if one existed. */
export function deleteAccommodation(id: string | number): { linkedReservationId: number | null } {
// Delete linked reservation
const linkedRes = db.prepare('SELECT id FROM reservations WHERE accommodation_id = ?').get(Number(id)) as { id: number } | undefined;
if (linkedRes) {
db.prepare('DELETE FROM reservations WHERE id = ?').run(linkedRes.id);
}
db.prepare('DELETE FROM day_accommodations WHERE id = ?').run(id);
return { linkedReservationId: linkedRes ? linkedRes.id : null };
}
+244
View File
@@ -0,0 +1,244 @@
import path from 'path';
import fs from 'fs';
import jwt from 'jsonwebtoken';
import { JWT_SECRET } from '../config';
import { db, canAccessTrip } from '../db/database';
import { consumeEphemeralToken } from './ephemeralTokens';
import { TripFile } from '../types';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
export const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
export const DEFAULT_ALLOWED_EXTENSIONS = 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv';
export const BLOCKED_EXTENSIONS = ['.svg', '.html', '.htm', '.xml'];
export const filesDir = path.join(__dirname, '../../uploads/files');
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
export function verifyTripAccess(tripId: string | number, userId: number) {
return canAccessTrip(tripId, userId);
}
export function getAllowedExtensions(): string {
try {
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'allowed_file_types'").get() as { value: string } | undefined;
return row?.value || DEFAULT_ALLOWED_EXTENSIONS;
} catch { return DEFAULT_ALLOWED_EXTENSIONS; }
}
const FILE_SELECT = `
SELECT f.*, r.title as reservation_title, u.username as uploaded_by_name, u.avatar as uploaded_by_avatar
FROM trip_files f
LEFT JOIN reservations r ON f.reservation_id = r.id
LEFT JOIN users u ON f.uploaded_by = u.id
`;
export function formatFile(file: TripFile & { trip_id?: number }) {
const tripId = file.trip_id;
return {
...file,
url: `/api/trips/${tripId}/files/${file.id}/download`,
};
}
// ---------------------------------------------------------------------------
// File path resolution & validation
// ---------------------------------------------------------------------------
export function resolveFilePath(filename: string): { resolved: string; safe: boolean } {
const safeName = path.basename(filename);
const filePath = path.join(filesDir, safeName);
const resolved = path.resolve(filePath);
const safe = resolved.startsWith(path.resolve(filesDir));
return { resolved, safe };
}
// ---------------------------------------------------------------------------
// Token-based download auth
// ---------------------------------------------------------------------------
export function authenticateDownload(bearerToken: string | undefined, queryToken: string | undefined): { userId: number } | { error: string; status: number } {
if (!bearerToken && !queryToken) {
return { error: 'Authentication required', status: 401 };
}
if (bearerToken) {
try {
const decoded = jwt.verify(bearerToken, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number };
return { userId: decoded.id };
} catch {
return { error: 'Invalid or expired token', status: 401 };
}
}
const uid = consumeEphemeralToken(queryToken!, 'download');
if (!uid) return { error: 'Invalid or expired token', status: 401 };
return { userId: uid };
}
// ---------------------------------------------------------------------------
// CRUD
// ---------------------------------------------------------------------------
export interface FileLink {
file_id: number;
reservation_id: number | null;
place_id: number | null;
}
export function getFileById(id: string | number, tripId: string | number): TripFile | undefined {
return db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, tripId) as TripFile | undefined;
}
export function getFileByIdFull(id: string | number): TripFile {
return db.prepare(`${FILE_SELECT} WHERE f.id = ?`).get(id) as TripFile;
}
export function getDeletedFile(id: string | number, tripId: string | number): TripFile | undefined {
return db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ? AND deleted_at IS NOT NULL').get(id, tripId) as TripFile | undefined;
}
export function listFiles(tripId: string | number, showTrash: boolean) {
const where = showTrash ? 'f.trip_id = ? AND f.deleted_at IS NOT NULL' : 'f.trip_id = ? AND f.deleted_at IS NULL';
const files = db.prepare(`${FILE_SELECT} WHERE ${where} ORDER BY f.starred DESC, f.created_at DESC`).all(tripId) as TripFile[];
const fileIds = files.map(f => f.id);
let linksMap: Record<number, FileLink[]> = {};
if (fileIds.length > 0) {
const placeholders = fileIds.map(() => '?').join(',');
const links = db.prepare(`SELECT file_id, reservation_id, place_id FROM file_links WHERE file_id IN (${placeholders})`).all(...fileIds) as FileLink[];
for (const link of links) {
if (!linksMap[link.file_id]) linksMap[link.file_id] = [];
linksMap[link.file_id].push(link);
}
}
return files.map(f => {
const fileLinks = linksMap[f.id] || [];
return {
...formatFile(f),
linked_reservation_ids: fileLinks.filter(l => l.reservation_id).map(l => l.reservation_id),
linked_place_ids: fileLinks.filter(l => l.place_id).map(l => l.place_id),
};
});
}
export function createFile(
tripId: string | number,
file: { filename: string; originalname: string; size: number; mimetype: string },
uploadedBy: number,
opts: { place_id?: string | null; reservation_id?: string | null; description?: string | null }
) {
const result = db.prepare(`
INSERT INTO trip_files (trip_id, place_id, reservation_id, filename, original_name, file_size, mime_type, description, uploaded_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
tripId,
opts.place_id || null,
opts.reservation_id || null,
file.filename,
file.originalname,
file.size,
file.mimetype,
opts.description || null,
uploadedBy
);
const created = db.prepare(`${FILE_SELECT} WHERE f.id = ?`).get(result.lastInsertRowid) as TripFile;
return formatFile(created);
}
export function updateFile(
id: string | number,
current: TripFile,
updates: { description?: string; place_id?: string | null; reservation_id?: string | null }
) {
db.prepare(`
UPDATE trip_files SET
description = ?,
place_id = ?,
reservation_id = ?
WHERE id = ?
`).run(
updates.description !== undefined ? updates.description : current.description,
updates.place_id !== undefined ? (updates.place_id || null) : current.place_id,
updates.reservation_id !== undefined ? (updates.reservation_id || null) : current.reservation_id,
id
);
const updated = db.prepare(`${FILE_SELECT} WHERE f.id = ?`).get(id) as TripFile;
return formatFile(updated);
}
export function toggleStarred(id: string | number, currentStarred: number | undefined) {
const newStarred = currentStarred ? 0 : 1;
db.prepare('UPDATE trip_files SET starred = ? WHERE id = ?').run(newStarred, id);
const updated = db.prepare(`${FILE_SELECT} WHERE f.id = ?`).get(id) as TripFile;
return formatFile(updated);
}
export function softDeleteFile(id: string | number) {
db.prepare('UPDATE trip_files SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?').run(id);
}
export function restoreFile(id: string | number) {
db.prepare('UPDATE trip_files SET deleted_at = NULL WHERE id = ?').run(id);
const restored = db.prepare(`${FILE_SELECT} WHERE f.id = ?`).get(id) as TripFile;
return formatFile(restored);
}
export function permanentDeleteFile(file: TripFile) {
const { resolved } = resolveFilePath(file.filename);
if (fs.existsSync(resolved)) {
try { fs.unlinkSync(resolved); } catch (e) { console.error('Error deleting file:', e); }
}
db.prepare('DELETE FROM trip_files WHERE id = ?').run(file.id);
}
export function emptyTrash(tripId: string | number): number {
const trashed = db.prepare('SELECT * FROM trip_files WHERE trip_id = ? AND deleted_at IS NOT NULL').all(tripId) as TripFile[];
for (const file of trashed) {
const { resolved } = resolveFilePath(file.filename);
if (fs.existsSync(resolved)) {
try { fs.unlinkSync(resolved); } catch (e) { console.error('Error deleting file:', e); }
}
}
db.prepare('DELETE FROM trip_files WHERE trip_id = ? AND deleted_at IS NOT NULL').run(tripId);
return trashed.length;
}
// ---------------------------------------------------------------------------
// File links (many-to-many)
// ---------------------------------------------------------------------------
export function createFileLink(
fileId: string | number,
opts: { reservation_id?: string | null; assignment_id?: string | null; place_id?: string | null }
) {
try {
db.prepare('INSERT OR IGNORE INTO file_links (file_id, reservation_id, assignment_id, place_id) VALUES (?, ?, ?, ?)').run(
fileId, opts.reservation_id || null, opts.assignment_id || null, opts.place_id || null
);
} catch (err) {
console.error('[Files] Error creating file link:', err instanceof Error ? err.message : err);
}
return db.prepare('SELECT * FROM file_links WHERE file_id = ?').all(fileId);
}
export function deleteFileLink(linkId: string | number, fileId: string | number) {
db.prepare('DELETE FROM file_links WHERE id = ? AND file_id = ?').run(linkId, fileId);
}
export function getFileLinks(fileId: string | number) {
return db.prepare(`
SELECT fl.*, r.title as reservation_title
FROM file_links fl
LEFT JOIN reservations r ON fl.reservation_id = r.id
WHERE fl.file_id = ?
`).all(fileId);
}
+394
View File
@@ -0,0 +1,394 @@
import { db, canAccessTrip } from '../db/database';
import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto';
import { checkSsrf } from '../utils/ssrfGuard';
import { writeAudit } from './auditLog';
// ── Credentials ────────────────────────────────────────────────────────────
export function getImmichCredentials(userId: number) {
const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(userId) as any;
if (!user?.immich_url || !user?.immich_api_key) return null;
return { immich_url: user.immich_url as string, immich_api_key: decrypt_api_key(user.immich_api_key) as string };
}
/** Validate that an asset ID is a safe UUID-like string (no path traversal). */
export function isValidAssetId(id: string): boolean {
return /^[a-zA-Z0-9_-]+$/.test(id) && id.length <= 100;
}
// ── Connection Settings ────────────────────────────────────────────────────
export function getConnectionSettings(userId: number) {
const creds = getImmichCredentials(userId);
return {
immich_url: creds?.immich_url || '',
connected: !!(creds?.immich_url && creds?.immich_api_key),
};
}
export async function saveImmichSettings(
userId: number,
immichUrl: string | undefined,
immichApiKey: string | undefined,
clientIp: string | null
): Promise<{ success: boolean; warning?: string; error?: string }> {
if (immichUrl) {
const ssrf = await checkSsrf(immichUrl.trim());
if (!ssrf.allowed) {
return { success: false, error: `Invalid Immich URL: ${ssrf.error}` };
}
db.prepare('UPDATE users SET immich_url = ?, immich_api_key = ? WHERE id = ?').run(
immichUrl.trim(),
maybe_encrypt_api_key(immichApiKey),
userId
);
if (ssrf.isPrivate) {
writeAudit({
userId,
action: 'immich.private_ip_configured',
ip: clientIp,
details: { immich_url: immichUrl.trim(), resolved_ip: ssrf.resolvedIp },
});
return {
success: true,
warning: `Immich URL resolves to a private IP address (${ssrf.resolvedIp}). Make sure this is intentional.`,
};
}
} else {
db.prepare('UPDATE users SET immich_url = ?, immich_api_key = ? WHERE id = ?').run(
null,
maybe_encrypt_api_key(immichApiKey),
userId
);
}
return { success: true };
}
// ── Connection Test / Status ───────────────────────────────────────────────
export async function testConnection(
immichUrl: string,
immichApiKey: string
): Promise<{ connected: boolean; error?: string; user?: { name?: string; email?: string } }> {
const ssrf = await checkSsrf(immichUrl);
if (!ssrf.allowed) return { connected: false, error: ssrf.error ?? 'Invalid Immich URL' };
try {
const resp = await fetch(`${immichUrl}/api/users/me`, {
headers: { 'x-api-key': immichApiKey, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000),
});
if (!resp.ok) return { connected: false, error: `HTTP ${resp.status}` };
const data = await resp.json() as { name?: string; email?: string };
return { connected: true, user: { name: data.name, email: data.email } };
} catch (err: unknown) {
return { connected: false, error: err instanceof Error ? err.message : 'Connection failed' };
}
}
export async function getConnectionStatus(
userId: number
): Promise<{ connected: boolean; error?: string; user?: { name?: string; email?: string } }> {
const creds = getImmichCredentials(userId);
if (!creds) return { connected: false, error: 'Not configured' };
try {
const resp = await fetch(`${creds.immich_url}/api/users/me`, {
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000),
});
if (!resp.ok) return { connected: false, error: `HTTP ${resp.status}` };
const data = await resp.json() as { name?: string; email?: string };
return { connected: true, user: { name: data.name, email: data.email } };
} catch (err: unknown) {
return { connected: false, error: err instanceof Error ? err.message : 'Connection failed' };
}
}
// ── Browse Timeline / Search ───────────────────────────────────────────────
export async function browseTimeline(
userId: number
): Promise<{ buckets?: any; error?: string; status?: number }> {
const creds = getImmichCredentials(userId);
if (!creds) return { error: 'Immich not configured', status: 400 };
try {
const resp = await fetch(`${creds.immich_url}/api/timeline/buckets`, {
method: 'GET',
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(15000),
});
if (!resp.ok) return { error: 'Failed to fetch from Immich', status: resp.status };
const buckets = await resp.json();
return { buckets };
} catch {
return { error: 'Could not reach Immich', status: 502 };
}
}
export async function searchPhotos(
userId: number,
from?: string,
to?: string
): Promise<{ assets?: any[]; error?: string; status?: number }> {
const creds = getImmichCredentials(userId);
if (!creds) return { error: 'Immich not configured', status: 400 };
try {
// Paginate through all results (Immich limits per-page to 1000)
const allAssets: any[] = [];
let page = 1;
const pageSize = 1000;
while (true) {
const resp = await fetch(`${creds.immich_url}/api/search/metadata`, {
method: 'POST',
headers: { 'x-api-key': creds.immich_api_key, 'Content-Type': 'application/json' },
body: JSON.stringify({
takenAfter: from ? `${from}T00:00:00.000Z` : undefined,
takenBefore: to ? `${to}T23:59:59.999Z` : undefined,
type: 'IMAGE',
size: pageSize,
page,
}),
signal: AbortSignal.timeout(15000),
});
if (!resp.ok) return { error: 'Search failed', status: resp.status };
const data = await resp.json() as { assets?: { items?: any[] } };
const items = data.assets?.items || [];
allAssets.push(...items);
if (items.length < pageSize) break; // Last page
page++;
if (page > 20) break; // Safety limit (20k photos max)
}
const assets = allAssets.map((a: any) => ({
id: a.id,
takenAt: a.fileCreatedAt || a.createdAt,
city: a.exifInfo?.city || null,
country: a.exifInfo?.country || null,
}));
return { assets };
} catch {
return { error: 'Could not reach Immich', status: 502 };
}
}
// ── Trip Photos ────────────────────────────────────────────────────────────
export function listTripPhotos(tripId: string, userId: number) {
return db.prepare(`
SELECT tp.immich_asset_id, tp.user_id, tp.shared, tp.added_at,
u.username, u.avatar, u.immich_url
FROM trip_photos tp
JOIN users u ON tp.user_id = u.id
WHERE tp.trip_id = ?
AND (tp.user_id = ? OR tp.shared = 1)
ORDER BY tp.added_at ASC
`).all(tripId, userId);
}
export function addTripPhotos(
tripId: string,
userId: number,
assetIds: string[],
shared: boolean
): number {
const insert = db.prepare(
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, immich_asset_id, shared) VALUES (?, ?, ?, ?)'
);
let added = 0;
for (const assetId of assetIds) {
const result = insert.run(tripId, userId, assetId, shared ? 1 : 0);
if (result.changes > 0) added++;
}
return added;
}
export function removeTripPhoto(tripId: string, userId: number, assetId: string) {
db.prepare('DELETE FROM trip_photos WHERE trip_id = ? AND user_id = ? AND immich_asset_id = ?')
.run(tripId, userId, assetId);
}
export function togglePhotoSharing(tripId: string, userId: number, assetId: string, shared: boolean) {
db.prepare('UPDATE trip_photos SET shared = ? WHERE trip_id = ? AND user_id = ? AND immich_asset_id = ?')
.run(shared ? 1 : 0, tripId, userId, assetId);
}
// ── Asset Info / Proxy ─────────────────────────────────────────────────────
export async function getAssetInfo(
userId: number,
assetId: string
): Promise<{ data?: any; error?: string; status?: number }> {
const creds = getImmichCredentials(userId);
if (!creds) return { error: 'Not found', status: 404 };
try {
const resp = await fetch(`${creds.immich_url}/api/assets/${assetId}`, {
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000),
});
if (!resp.ok) return { error: 'Failed', status: resp.status };
const asset = await resp.json() as any;
return {
data: {
id: asset.id,
takenAt: asset.fileCreatedAt || asset.createdAt,
width: asset.exifInfo?.exifImageWidth || null,
height: asset.exifInfo?.exifImageHeight || null,
camera: asset.exifInfo?.make && asset.exifInfo?.model ? `${asset.exifInfo.make} ${asset.exifInfo.model}` : null,
lens: asset.exifInfo?.lensModel || null,
focalLength: asset.exifInfo?.focalLength ? `${asset.exifInfo.focalLength}mm` : null,
aperture: asset.exifInfo?.fNumber ? `f/${asset.exifInfo.fNumber}` : null,
shutter: asset.exifInfo?.exposureTime || null,
iso: asset.exifInfo?.iso || null,
city: asset.exifInfo?.city || null,
state: asset.exifInfo?.state || null,
country: asset.exifInfo?.country || null,
lat: asset.exifInfo?.latitude || null,
lng: asset.exifInfo?.longitude || null,
fileSize: asset.exifInfo?.fileSizeInByte || null,
fileName: asset.originalFileName || null,
},
};
} catch {
return { error: 'Proxy error', status: 502 };
}
}
export async function proxyThumbnail(
userId: number,
assetId: string
): Promise<{ buffer?: Buffer; contentType?: string; error?: string; status?: number }> {
const creds = getImmichCredentials(userId);
if (!creds) return { error: 'Not found', status: 404 };
try {
const resp = await fetch(`${creds.immich_url}/api/assets/${assetId}/thumbnail`, {
headers: { 'x-api-key': creds.immich_api_key },
signal: AbortSignal.timeout(10000),
});
if (!resp.ok) return { error: 'Failed', status: resp.status };
const buffer = Buffer.from(await resp.arrayBuffer());
const contentType = resp.headers.get('content-type') || 'image/webp';
return { buffer, contentType };
} catch {
return { error: 'Proxy error', status: 502 };
}
}
export async function proxyOriginal(
userId: number,
assetId: string
): Promise<{ buffer?: Buffer; contentType?: string; error?: string; status?: number }> {
const creds = getImmichCredentials(userId);
if (!creds) return { error: 'Not found', status: 404 };
try {
const resp = await fetch(`${creds.immich_url}/api/assets/${assetId}/original`, {
headers: { 'x-api-key': creds.immich_api_key },
signal: AbortSignal.timeout(30000),
});
if (!resp.ok) return { error: 'Failed', status: resp.status };
const buffer = Buffer.from(await resp.arrayBuffer());
const contentType = resp.headers.get('content-type') || 'image/jpeg';
return { buffer, contentType };
} catch {
return { error: 'Proxy error', status: 502 };
}
}
// ── Albums ──────────────────────────────────────────────────────────────────
export async function listAlbums(
userId: number
): Promise<{ albums?: any[]; error?: string; status?: number }> {
const creds = getImmichCredentials(userId);
if (!creds) return { error: 'Immich not configured', status: 400 };
try {
const resp = await fetch(`${creds.immich_url}/api/albums`, {
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000),
});
if (!resp.ok) return { error: 'Failed to fetch albums', status: resp.status };
const albums = (await resp.json() as any[]).map((a: any) => ({
id: a.id,
albumName: a.albumName,
assetCount: a.assetCount || 0,
startDate: a.startDate,
endDate: a.endDate,
albumThumbnailAssetId: a.albumThumbnailAssetId,
}));
return { albums };
} catch {
return { error: 'Could not reach Immich', status: 502 };
}
}
export function listAlbumLinks(tripId: string) {
return db.prepare(`
SELECT tal.*, u.username
FROM trip_album_links tal
JOIN users u ON tal.user_id = u.id
WHERE tal.trip_id = ?
ORDER BY tal.created_at ASC
`).all(tripId);
}
export function createAlbumLink(
tripId: string,
userId: number,
albumId: string,
albumName: string
): { success: boolean; error?: string } {
try {
db.prepare(
'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, immich_album_id, album_name) VALUES (?, ?, ?, ?)'
).run(tripId, userId, albumId, albumName || '');
return { success: true };
} catch {
return { success: false, error: 'Album already linked' };
}
}
export function deleteAlbumLink(linkId: string, tripId: string, userId: number) {
db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?')
.run(linkId, tripId, userId);
}
export async function syncAlbumAssets(
tripId: string,
linkId: string,
userId: number
): Promise<{ success?: boolean; added?: number; total?: number; error?: string; status?: number }> {
const link = db.prepare('SELECT * FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?')
.get(linkId, tripId, userId) as any;
if (!link) return { error: 'Album link not found', status: 404 };
const creds = getImmichCredentials(userId);
if (!creds) return { error: 'Immich not configured', status: 400 };
try {
const resp = await fetch(`${creds.immich_url}/api/albums/${link.immich_album_id}`, {
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(15000),
});
if (!resp.ok) return { error: 'Failed to fetch album', status: resp.status };
const albumData = await resp.json() as { assets?: any[] };
const assets = (albumData.assets || []).filter((a: any) => a.type === 'IMAGE');
const insert = db.prepare(
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, immich_asset_id, shared) VALUES (?, ?, ?, 1)'
);
let added = 0;
for (const asset of assets) {
const r = insert.run(tripId, userId, asset.id);
if (r.changes > 0) added++;
}
db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId);
return { success: true, added, total: assets.length };
} catch {
return { error: 'Could not reach Immich', status: 502 };
}
}
+527
View File
@@ -0,0 +1,527 @@
import fetch from 'node-fetch';
import { db } from '../db/database';
import { decrypt_api_key } from './apiKeyCrypto';
// ── Interfaces ───────────────────────────────────────────────────────────────
interface NominatimResult {
osm_type: string;
osm_id: string;
name?: string;
display_name?: string;
lat: string;
lon: string;
}
interface OverpassElement {
tags?: Record<string, string>;
}
interface WikiCommonsPage {
imageinfo?: { url?: string; extmetadata?: { Artist?: { value?: string } } }[];
}
interface GooglePlaceResult {
id: string;
displayName?: { text: string };
formattedAddress?: string;
location?: { latitude: number; longitude: number };
rating?: number;
websiteUri?: string;
nationalPhoneNumber?: string;
types?: string[];
}
interface GooglePlaceDetails extends GooglePlaceResult {
userRatingCount?: number;
regularOpeningHours?: { weekdayDescriptions?: string[]; openNow?: boolean };
googleMapsUri?: string;
editorialSummary?: { text: string };
reviews?: { authorAttribution?: { displayName?: string; photoUri?: string }; rating?: number; text?: { text?: string }; relativePublishTimeDescription?: string }[];
photos?: { name: string; authorAttributions?: { displayName?: string }[] }[];
}
// ── Constants ────────────────────────────────────────────────────────────────
const UA = 'TREK Travel Planner (https://github.com/mauriceboe/NOMAD)';
// ── Photo cache ──────────────────────────────────────────────────────────────
const photoCache = new Map<string, { photoUrl: string; attribution: string | null; fetchedAt: number; error?: boolean }>();
const PHOTO_TTL = 12 * 60 * 60 * 1000; // 12 hours
const ERROR_TTL = 5 * 60 * 1000; // 5 min for errors
const CACHE_MAX_ENTRIES = 1000;
const CACHE_PRUNE_TARGET = 500;
const CACHE_CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 minutes
setInterval(() => {
const now = Date.now();
for (const [key, entry] of photoCache) {
if (now - entry.fetchedAt > PHOTO_TTL) photoCache.delete(key);
}
if (photoCache.size > CACHE_MAX_ENTRIES) {
const entries = [...photoCache.entries()].sort((a, b) => a[1].fetchedAt - b[1].fetchedAt);
const toDelete = entries.slice(0, entries.length - CACHE_PRUNE_TARGET);
toDelete.forEach(([key]) => photoCache.delete(key));
}
}, CACHE_CLEANUP_INTERVAL);
// ── API key retrieval ────────────────────────────────────────────────────────
export function getMapsKey(userId: number): string | null {
const user = db.prepare('SELECT maps_api_key FROM users WHERE id = ?').get(userId) as { maps_api_key: string | null } | undefined;
const user_key = decrypt_api_key(user?.maps_api_key);
if (user_key) return user_key;
const admin = db.prepare("SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1").get() as { maps_api_key: string } | undefined;
return decrypt_api_key(admin?.maps_api_key) || null;
}
// ── Nominatim search ─────────────────────────────────────────────────────────
export async function searchNominatim(query: string, lang?: string) {
const params = new URLSearchParams({
q: query,
format: 'json',
addressdetails: '1',
limit: '10',
'accept-language': lang || 'en',
});
const response = await fetch(`https://nominatim.openstreetmap.org/search?${params}`, {
headers: { 'User-Agent': UA },
});
if (!response.ok) throw new Error('Nominatim API error');
const data = await response.json() as NominatimResult[];
return data.map(item => ({
google_place_id: null,
osm_id: `${item.osm_type}:${item.osm_id}`,
name: item.name || item.display_name?.split(',')[0] || '',
address: item.display_name || '',
lat: parseFloat(item.lat) || null,
lng: parseFloat(item.lon) || null,
rating: null,
website: null,
phone: null,
source: 'openstreetmap',
}));
}
// ── Overpass API (OSM details) ───────────────────────────────────────────────
export async function fetchOverpassDetails(osmType: string, osmId: string): Promise<OverpassElement | null> {
const typeMap: Record<string, string> = { node: 'node', way: 'way', relation: 'rel' };
const oType = typeMap[osmType];
if (!oType) return null;
const query = `[out:json][timeout:5];${oType}(${osmId});out tags;`;
try {
const res = await fetch('https://overpass-api.de/api/interpreter', {
method: 'POST',
headers: { 'User-Agent': UA, 'Content-Type': 'application/x-www-form-urlencoded' },
body: `data=${encodeURIComponent(query)}`,
});
if (!res.ok) return null;
const data = await res.json() as { elements?: OverpassElement[] };
return data.elements?.[0] || null;
} catch { return null; }
}
// ── Opening hours parsing ────────────────────────────────────────────────────
export function parseOpeningHours(ohString: string): { weekdayDescriptions: string[]; openNow: boolean | null } {
const DAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
const LONG = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
const result: string[] = LONG.map(d => `${d}: ?`);
// Parse segments like "Mo-Fr 09:00-18:00; Sa 10:00-14:00"
for (const segment of ohString.split(';')) {
const trimmed = segment.trim();
if (!trimmed) continue;
const match = trimmed.match(/^((?:Mo|Tu|We|Th|Fr|Sa|Su)(?:\s*-\s*(?:Mo|Tu|We|Th|Fr|Sa|Su))?(?:\s*,\s*(?:Mo|Tu|We|Th|Fr|Sa|Su)(?:\s*-\s*(?:Mo|Tu|We|Th|Fr|Sa|Su))?)*)\s+(.+)$/i);
if (!match) continue;
const [, daysPart, timePart] = match;
const dayIndices = new Set<number>();
for (const range of daysPart.split(',')) {
const parts = range.trim().split('-').map(d => DAYS.indexOf(d.trim()));
if (parts.length === 2 && parts[0] >= 0 && parts[1] >= 0) {
for (let i = parts[0]; i !== (parts[1] + 1) % 7; i = (i + 1) % 7) dayIndices.add(i);
dayIndices.add(parts[1]);
} else if (parts[0] >= 0) {
dayIndices.add(parts[0]);
}
}
for (const idx of dayIndices) {
result[idx] = `${LONG[idx]}: ${timePart.trim()}`;
}
}
// Compute openNow
let openNow: boolean | null = null;
try {
const now = new Date();
const jsDay = now.getDay();
const dayIdx = jsDay === 0 ? 6 : jsDay - 1;
const todayLine = result[dayIdx];
const timeRanges = [...todayLine.matchAll(/(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})/g)];
if (timeRanges.length > 0) {
const nowMins = now.getHours() * 60 + now.getMinutes();
openNow = timeRanges.some(m => {
const start = parseInt(m[1]) * 60 + parseInt(m[2]);
const end = parseInt(m[3]) * 60 + parseInt(m[4]);
return end > start ? nowMins >= start && nowMins < end : nowMins >= start || nowMins < end;
});
}
} catch { /* best effort */ }
return { weekdayDescriptions: result, openNow };
}
// ── Build standardized OSM details ───────────────────────────────────────────
export function buildOsmDetails(tags: Record<string, string>, osmType: string, osmId: string) {
let opening_hours: string[] | null = null;
let open_now: boolean | null = null;
if (tags.opening_hours) {
const parsed = parseOpeningHours(tags.opening_hours);
const hasData = parsed.weekdayDescriptions.some(line => !line.endsWith('?'));
if (hasData) {
opening_hours = parsed.weekdayDescriptions;
open_now = parsed.openNow;
}
}
return {
website: tags['contact:website'] || tags.website || null,
phone: tags['contact:phone'] || tags.phone || null,
opening_hours,
open_now,
osm_url: `https://www.openstreetmap.org/${osmType}/${osmId}`,
summary: tags.description || null,
source: 'openstreetmap' as const,
};
}
// ── Wikimedia Commons photo lookup ───────────────────────────────────────────
export async function fetchWikimediaPhoto(lat: number, lng: number, name?: string): Promise<{ photoUrl: string; attribution: string | null } | null> {
// Strategy 1: Search Wikipedia for the place name -> get the article image
if (name) {
try {
const searchParams = new URLSearchParams({
action: 'query', format: 'json',
titles: name,
prop: 'pageimages',
piprop: 'thumbnail',
pithumbsize: '400',
pilimit: '1',
redirects: '1',
});
const res = await fetch(`https://en.wikipedia.org/w/api.php?${searchParams}`, { headers: { 'User-Agent': UA } });
if (res.ok) {
const data = await res.json() as { query?: { pages?: Record<string, { thumbnail?: { source?: string } }> } };
const pages = data.query?.pages;
if (pages) {
for (const page of Object.values(pages)) {
if (page.thumbnail?.source) {
return { photoUrl: page.thumbnail.source, attribution: 'Wikipedia' };
}
}
}
}
} catch { /* fall through to geosearch */ }
}
// Strategy 2: Wikimedia Commons geosearch by coordinates
const params = new URLSearchParams({
action: 'query', format: 'json',
generator: 'geosearch',
ggsprimary: 'all',
ggsnamespace: '6',
ggsradius: '300',
ggscoord: `${lat}|${lng}`,
ggslimit: '5',
prop: 'imageinfo',
iiprop: 'url|extmetadata|mime',
iiurlwidth: '400',
});
try {
const res = await fetch(`https://commons.wikimedia.org/w/api.php?${params}`, { headers: { 'User-Agent': UA } });
if (!res.ok) return null;
const data = await res.json() as { query?: { pages?: Record<string, WikiCommonsPage & { imageinfo?: { mime?: string }[] }> } };
const pages = data.query?.pages;
if (!pages) return null;
for (const page of Object.values(pages)) {
const info = page.imageinfo?.[0];
// Only use actual photos (JPEG/PNG), skip SVGs and PDFs
const mime = (info as { mime?: string })?.mime || '';
if (info?.url && (mime.startsWith('image/jpeg') || mime.startsWith('image/png'))) {
const attribution = info.extmetadata?.Artist?.value?.replace(/<[^>]+>/g, '').trim() || null;
return { photoUrl: info.url, attribution };
}
}
return null;
} catch { return null; }
}
// ── Search places (Google or Nominatim fallback) ─────────────────────────────
export async function searchPlaces(userId: number, query: string, lang?: string): Promise<{ places: Record<string, unknown>[]; source: string }> {
const apiKey = getMapsKey(userId);
if (!apiKey) {
const places = await searchNominatim(query, lang);
return { places, source: 'openstreetmap' };
}
const response = await fetch('https://places.googleapis.com/v1/places:searchText', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Goog-Api-Key': apiKey,
'X-Goog-FieldMask': 'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types',
},
body: JSON.stringify({ textQuery: query, languageCode: lang || 'en' }),
});
const data = await response.json() as { places?: GooglePlaceResult[]; error?: { message?: string } };
if (!response.ok) {
const err = new Error(data.error?.message || 'Google Places API error') as Error & { status: number };
err.status = response.status;
throw err;
}
const places = (data.places || []).map((p: GooglePlaceResult) => ({
google_place_id: p.id,
name: p.displayName?.text || '',
address: p.formattedAddress || '',
lat: p.location?.latitude || null,
lng: p.location?.longitude || null,
rating: p.rating || null,
website: p.websiteUri || null,
phone: p.nationalPhoneNumber || null,
source: 'google',
}));
return { places, source: 'google' };
}
// ── Place details (Google or OSM) ────────────────────────────────────────────
export async function getPlaceDetails(userId: number, placeId: string, lang?: string): Promise<{ place: Record<string, unknown> }> {
// OSM details: placeId is "node:123456" or "way:123456" etc.
if (placeId.includes(':')) {
const [osmType, osmId] = placeId.split(':');
const element = await fetchOverpassDetails(osmType, osmId);
if (!element?.tags) return { place: buildOsmDetails({}, osmType, osmId) };
return { place: buildOsmDetails(element.tags, osmType, osmId) };
}
// Google details
const apiKey = getMapsKey(userId);
if (!apiKey) {
throw Object.assign(new Error('Google Maps API key not configured'), { status: 400 });
}
const response = await fetch(`https://places.googleapis.com/v1/places/${placeId}?languageCode=${lang || 'de'}`, {
method: 'GET',
headers: {
'X-Goog-Api-Key': apiKey,
'X-Goog-FieldMask': 'id,displayName,formattedAddress,location,rating,userRatingCount,websiteUri,nationalPhoneNumber,regularOpeningHours,googleMapsUri,reviews,editorialSummary',
},
});
const data = await response.json() as GooglePlaceDetails & { error?: { message?: string } };
if (!response.ok) {
const err = new Error(data.error?.message || 'Google Places API error') as Error & { status: number };
err.status = response.status;
throw err;
}
const place = {
google_place_id: data.id,
name: data.displayName?.text || '',
address: data.formattedAddress || '',
lat: data.location?.latitude || null,
lng: data.location?.longitude || null,
rating: data.rating || null,
rating_count: data.userRatingCount || null,
website: data.websiteUri || null,
phone: data.nationalPhoneNumber || null,
opening_hours: data.regularOpeningHours?.weekdayDescriptions || null,
open_now: data.regularOpeningHours?.openNow ?? null,
google_maps_url: data.googleMapsUri || null,
summary: data.editorialSummary?.text || null,
reviews: (data.reviews || []).slice(0, 5).map((r: NonNullable<GooglePlaceDetails['reviews']>[number]) => ({
author: r.authorAttribution?.displayName || null,
rating: r.rating || null,
text: r.text?.text || null,
time: r.relativePublishTimeDescription || null,
photo: r.authorAttribution?.photoUri || null,
})),
source: 'google' as const,
};
return { place };
}
// ── Place photo (Google or Wikimedia, with caching + DB persistence) ─────────
export async function getPlacePhoto(
userId: number,
placeId: string,
lat: number,
lng: number,
name?: string,
): Promise<{ photoUrl: string; attribution: string | null }> {
// Check cache first
const cached = photoCache.get(placeId);
if (cached) {
const ttl = cached.error ? ERROR_TTL : PHOTO_TTL;
if (Date.now() - cached.fetchedAt < ttl) {
if (cached.error) throw Object.assign(new Error('(Cache) No photo available'), { status: 404 });
return { photoUrl: cached.photoUrl, attribution: cached.attribution };
}
photoCache.delete(placeId);
}
const apiKey = getMapsKey(userId);
const isCoordLookup = placeId.startsWith('coords:');
// No Google key or coordinate-only lookup -> try Wikimedia
if (!apiKey || isCoordLookup) {
if (!isNaN(lat) && !isNaN(lng)) {
try {
const wiki = await fetchWikimediaPhoto(lat, lng, name);
if (wiki) {
photoCache.set(placeId, { ...wiki, fetchedAt: Date.now() });
return wiki;
} else {
photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true });
}
} catch { /* fall through */ }
}
throw Object.assign(new Error('(Wikimedia) No photo available'), { status: 404 });
}
// Google Photos
const detailsRes = await fetch(`https://places.googleapis.com/v1/places/${placeId}`, {
headers: {
'X-Goog-Api-Key': apiKey,
'X-Goog-FieldMask': 'photos',
},
});
const details = await detailsRes.json() as GooglePlaceDetails & { error?: { message?: string } };
if (!detailsRes.ok) {
console.error('Google Places photo details error:', details.error?.message || detailsRes.status);
photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true });
throw Object.assign(new Error('(Google Places) Photo could not be retrieved'), { status: 404 });
}
if (!details.photos?.length) {
photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true });
throw Object.assign(new Error('(Google Places) No photo available'), { status: 404 });
}
const photo = details.photos[0];
const photoName = photo.name;
const attribution = photo.authorAttributions?.[0]?.displayName || null;
const mediaRes = await fetch(
`https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=400&skipHttpRedirect=true`,
{ headers: { 'X-Goog-Api-Key': apiKey } }
);
const mediaData = await mediaRes.json() as { photoUri?: string };
const photoUrl = mediaData.photoUri;
if (!photoUrl) {
photoCache.set(placeId, { photoUrl: '', attribution, fetchedAt: Date.now(), error: true });
throw Object.assign(new Error('(Google Places) Photo URL not available'), { status: 404 });
}
photoCache.set(placeId, { photoUrl, attribution, fetchedAt: Date.now() });
// Persist photo URL to database
try {
db.prepare(
'UPDATE places SET image_url = ?, updated_at = CURRENT_TIMESTAMP WHERE google_place_id = ? AND (image_url IS NULL OR image_url = ?)'
).run(photoUrl, placeId, '');
} catch (dbErr) {
console.error('Failed to persist photo URL to database:', dbErr);
}
return { photoUrl, attribution };
}
// ── Reverse geocoding ────────────────────────────────────────────────────────
export async function reverseGeocode(lat: string, lng: string, lang?: string): Promise<{ name: string | null; address: string | null }> {
const params = new URLSearchParams({
lat, lon: lng, format: 'json', addressdetails: '1', zoom: '18',
'accept-language': lang || 'en',
});
const response = await fetch(`https://nominatim.openstreetmap.org/reverse?${params}`, {
headers: { 'User-Agent': UA },
});
if (!response.ok) return { name: null, address: null };
const data = await response.json() as { name?: string; display_name?: string; address?: Record<string, string> };
const addr = data.address || {};
const name = data.name || addr.tourism || addr.amenity || addr.shop || addr.building || addr.road || null;
return { name, address: data.display_name || null };
}
// ── Resolve Google Maps URL ──────────────────────────────────────────────────
export async function resolveGoogleMapsUrl(url: string): Promise<{ lat: number; lng: number; name: string | null; address: string | null }> {
let resolvedUrl = url;
// Follow redirects for short URLs (goo.gl, maps.app.goo.gl)
if (url.includes('goo.gl') || url.includes('maps.app')) {
const redirectRes = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(10000) });
resolvedUrl = redirectRes.url;
}
// Extract coordinates from Google Maps URL patterns:
// /@48.8566,2.3522,15z or /place/.../@48.8566,2.3522
// ?q=48.8566,2.3522 or ?ll=48.8566,2.3522
let lat: number | null = null;
let lng: number | null = null;
let placeName: string | null = null;
// Pattern: /@lat,lng
const atMatch = resolvedUrl.match(/@(-?\d+\.?\d*),(-?\d+\.?\d*)/);
if (atMatch) { lat = parseFloat(atMatch[1]); lng = parseFloat(atMatch[2]); }
// Pattern: !3dlat!4dlng (Google Maps data params)
if (!lat) {
const dataMatch = resolvedUrl.match(/!3d(-?\d+\.?\d*)!4d(-?\d+\.?\d*)/);
if (dataMatch) { lat = parseFloat(dataMatch[1]); lng = parseFloat(dataMatch[2]); }
}
// Pattern: ?q=lat,lng or &q=lat,lng
if (!lat) {
const qMatch = resolvedUrl.match(/[?&]q=(-?\d+\.?\d*),(-?\d+\.?\d*)/);
if (qMatch) { lat = parseFloat(qMatch[1]); lng = parseFloat(qMatch[2]); }
}
// Extract place name from URL path: /place/Place+Name/@...
const placeMatch = resolvedUrl.match(/\/place\/([^/@]+)/);
if (placeMatch) {
placeName = decodeURIComponent(placeMatch[1].replace(/\+/g, ' '));
}
if (!lat || !lng || isNaN(lat) || isNaN(lng)) {
throw Object.assign(new Error('Could not extract coordinates from URL'), { status: 400 });
}
// Reverse geocode to get address
const nominatimRes = await fetch(
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&addressdetails=1`,
{ headers: { 'User-Agent': 'TREK-Travel-Planner/1.0' }, signal: AbortSignal.timeout(8000) }
);
const nominatim = await nominatimRes.json() as { display_name?: string; name?: string; address?: Record<string, string> };
const name = placeName || nominatim.name || nominatim.address?.tourism || nominatim.address?.building || null;
const address = nominatim.display_name || null;
return { lat, lng, name, address };
}
@@ -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
View File
@@ -0,0 +1,314 @@
import crypto from 'crypto';
import fetch from 'node-fetch';
import jwt from 'jsonwebtoken';
import { db } from '../db/database';
import { JWT_SECRET } from '../config';
import { User } from '../types';
import { decrypt_api_key } from './apiKeyCrypto';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface OidcDiscoveryDoc {
authorization_endpoint: string;
token_endpoint: string;
userinfo_endpoint: string;
_issuer?: string;
}
export interface OidcTokenResponse {
access_token?: string;
id_token?: string;
token_type?: string;
}
export interface OidcUserInfo {
sub: string;
email?: string;
name?: string;
preferred_username?: string;
groups?: string[];
roles?: string[];
[key: string]: unknown;
}
export interface OidcConfig {
issuer: string;
clientId: string;
clientSecret: string;
displayName: string;
discoveryUrl: string | null;
}
// ---------------------------------------------------------------------------
// Constants / TTLs
// ---------------------------------------------------------------------------
const AUTH_CODE_TTL = 60000; // 1 minute
const AUTH_CODE_CLEANUP = 30000; // 30 seconds
const STATE_TTL = 5 * 60 * 1000; // 5 minutes
const STATE_CLEANUP = 60 * 1000; // 1 minute
const DISCOVERY_TTL = 60 * 60 * 1000; // 1 hour
// ---------------------------------------------------------------------------
// State management pending OIDC states
// ---------------------------------------------------------------------------
const pendingStates = new Map<string, { createdAt: number; redirectUri: string; inviteToken?: string }>();
setInterval(() => {
const now = Date.now();
for (const [state, data] of pendingStates) {
if (now - data.createdAt > STATE_TTL) pendingStates.delete(state);
}
}, STATE_CLEANUP);
export function createState(redirectUri: string, inviteToken?: string): string {
const state = crypto.randomBytes(32).toString('hex');
pendingStates.set(state, { createdAt: Date.now(), redirectUri, inviteToken });
return state;
}
export function consumeState(state: string) {
const pending = pendingStates.get(state);
if (!pending) return null;
pendingStates.delete(state);
return pending;
}
// ---------------------------------------------------------------------------
// Auth code management short-lived codes exchanged for JWT
// ---------------------------------------------------------------------------
const authCodes = new Map<string, { token: string; created: number }>();
setInterval(() => {
const now = Date.now();
for (const [code, entry] of authCodes) {
if (now - entry.created > AUTH_CODE_TTL) authCodes.delete(code);
}
}, AUTH_CODE_CLEANUP);
export function createAuthCode(token: string): string {
const { v4: uuidv4 } = require('uuid');
const authCode: string = uuidv4();
authCodes.set(authCode, { token, created: Date.now() });
return authCode;
}
export function consumeAuthCode(code: string): { token: string } | { error: string } {
const entry = authCodes.get(code);
if (!entry) return { error: 'Invalid or expired code' };
authCodes.delete(code);
if (Date.now() - entry.created > AUTH_CODE_TTL) return { error: 'Code expired' };
return { token: entry.token };
}
// ---------------------------------------------------------------------------
// OIDC configuration (env + DB)
// ---------------------------------------------------------------------------
export function getOidcConfig(): OidcConfig | null {
const get = (key: string) =>
(db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || null;
const issuer = process.env.OIDC_ISSUER || get('oidc_issuer');
const clientId = process.env.OIDC_CLIENT_ID || get('oidc_client_id');
const clientSecret = process.env.OIDC_CLIENT_SECRET || decrypt_api_key(get('oidc_client_secret'));
const displayName = process.env.OIDC_DISPLAY_NAME || get('oidc_display_name') || 'SSO';
const discoveryUrl = process.env.OIDC_DISCOVERY_URL || get('oidc_discovery_url') || null;
if (!issuer || !clientId || !clientSecret) return null;
return { issuer: issuer.replace(/\/+$/, ''), clientId, clientSecret, displayName, discoveryUrl };
}
// ---------------------------------------------------------------------------
// Discovery document (cached, 1 h TTL)
// ---------------------------------------------------------------------------
let discoveryCache: OidcDiscoveryDoc | null = null;
let discoveryCacheTime = 0;
export async function discover(issuer: string, discoveryUrl?: string | null): Promise<OidcDiscoveryDoc> {
const url = discoveryUrl || `${issuer}/.well-known/openid-configuration`;
if (discoveryCache && Date.now() - discoveryCacheTime < DISCOVERY_TTL && discoveryCache._issuer === url) {
return discoveryCache;
}
const res = await fetch(url);
if (!res.ok) throw new Error('Failed to fetch OIDC discovery document');
const doc = (await res.json()) as OidcDiscoveryDoc;
doc._issuer = url;
discoveryCache = doc;
discoveryCacheTime = Date.now();
return doc;
}
// ---------------------------------------------------------------------------
// Role resolution via OIDC claims
// ---------------------------------------------------------------------------
export function resolveOidcRole(userInfo: OidcUserInfo, isFirstUser: boolean): 'admin' | 'user' {
if (isFirstUser) return 'admin';
const adminValue = process.env.OIDC_ADMIN_VALUE;
if (!adminValue) return 'user';
const claimKey = process.env.OIDC_ADMIN_CLAIM || 'groups';
const claimData = userInfo[claimKey];
if (Array.isArray(claimData)) {
return claimData.some((v) => String(v) === adminValue) ? 'admin' : 'user';
}
if (typeof claimData === 'string') {
return claimData === adminValue ? 'admin' : 'user';
}
return 'user';
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
export function frontendUrl(path: string): string {
const base = process.env.NODE_ENV === 'production' ? '' : 'http://localhost:5173';
return base + path;
}
export function generateToken(user: { id: number }): string {
return jwt.sign({ id: user.id }, JWT_SECRET, { expiresIn: '24h', algorithm: 'HS256' });
}
export function getAppUrl(): string | null {
return (
process.env.APP_URL ||
(db.prepare("SELECT value FROM app_settings WHERE key = 'app_url'").get() as { value: string } | undefined)?.value ||
null
);
}
// ---------------------------------------------------------------------------
// Token exchange with OIDC provider
// ---------------------------------------------------------------------------
export async function exchangeCodeForToken(
doc: OidcDiscoveryDoc,
code: string,
redirectUri: string,
clientId: string,
clientSecret: string,
): Promise<OidcTokenResponse & { _ok: boolean; _status: number }> {
const tokenRes = await fetch(doc.token_endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
client_id: clientId,
client_secret: clientSecret,
}),
});
const tokenData = (await tokenRes.json()) as OidcTokenResponse;
return { ...tokenData, _ok: tokenRes.ok, _status: tokenRes.status };
}
// ---------------------------------------------------------------------------
// Fetch userinfo from OIDC provider
// ---------------------------------------------------------------------------
export async function getUserInfo(userinfoEndpoint: string, accessToken: string): Promise<OidcUserInfo> {
const res = await fetch(userinfoEndpoint, {
headers: { Authorization: `Bearer ${accessToken}` },
});
return (await res.json()) as OidcUserInfo;
}
// ---------------------------------------------------------------------------
// Find or create user by OIDC sub / email
// ---------------------------------------------------------------------------
export function findOrCreateUser(
userInfo: OidcUserInfo,
config: OidcConfig,
inviteToken?: string,
): { user: User } | { error: string } {
const email = userInfo.email!.toLowerCase();
const name = userInfo.name || userInfo.preferred_username || email.split('@')[0];
const sub = userInfo.sub;
// Try to find existing user by sub, then by email
let user = db.prepare('SELECT * FROM users WHERE oidc_sub = ? AND oidc_issuer = ?').get(sub, config.issuer) as User | undefined;
if (!user) {
user = db.prepare('SELECT * FROM users WHERE LOWER(email) = ?').get(email) as User | undefined;
}
if (user) {
// Link OIDC identity if not yet linked
if (!user.oidc_sub) {
db.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?').run(sub, config.issuer, user.id);
}
// Update role based on OIDC claims on every login (if claim mapping is configured)
if (process.env.OIDC_ADMIN_VALUE) {
const newRole = resolveOidcRole(userInfo, false);
if (user.role !== newRole) {
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(newRole, user.id);
user = { ...user, role: newRole } as User;
}
}
return { user };
}
// --- New user registration ---
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
const isFirstUser = userCount === 0;
let validInvite: any = null;
if (inviteToken) {
validInvite = db.prepare('SELECT * FROM invite_tokens WHERE token = ?').get(inviteToken);
if (validInvite) {
if (validInvite.max_uses > 0 && validInvite.used_count >= validInvite.max_uses) validInvite = null;
if (validInvite?.expires_at && new Date(validInvite.expires_at) < new Date()) validInvite = null;
}
}
if (!isFirstUser && !validInvite) {
const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get() as
| { value: string }
| undefined;
if (setting?.value === 'false') {
return { error: 'registration_disabled' };
}
}
const role = resolveOidcRole(userInfo, isFirstUser);
const randomPass = crypto.randomBytes(32).toString('hex');
const bcrypt = require('bcryptjs');
const hash = bcrypt.hashSync(randomPass, 10);
// Username: sanitize and avoid collisions
let username = name.replace(/[^a-zA-Z0-9_-]/g, '').substring(0, 30) || 'user';
const existing = db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?)').get(username);
if (existing) username = `${username}_${Date.now() % 10000}`;
const result = db.prepare(
'INSERT INTO users (username, email, password_hash, role, oidc_sub, oidc_issuer) VALUES (?, ?, ?, ?, ?, ?)',
).run(username, email, hash, role, sub, config.issuer);
if (validInvite) {
const updated = db.prepare(
'UPDATE invite_tokens SET used_count = used_count + 1 WHERE id = ? AND (max_uses = 0 OR used_count < max_uses)',
).run(validInvite.id);
if (updated.changes === 0) {
console.warn(`[OIDC] Invite token ${inviteToken?.slice(0, 8)}... exceeded max_uses (race condition)`);
}
}
user = { id: Number(result.lastInsertRowid), username, email, role } as User;
return { user };
}
// ---------------------------------------------------------------------------
// Update last_login timestamp
// ---------------------------------------------------------------------------
export function touchLastLogin(userId: number): void {
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(userId);
}
+223
View File
@@ -0,0 +1,223 @@
import { db, canAccessTrip } from '../db/database';
const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b'];
export function verifyTripAccess(tripId: string | number, userId: number) {
return canAccessTrip(tripId, userId);
}
// ── Items ──────────────────────────────────────────────────────────────────
export function listItems(tripId: string | number) {
return db.prepare(
'SELECT * FROM packing_items WHERE trip_id = ? ORDER BY sort_order ASC, created_at ASC'
).all(tripId);
}
export function createItem(tripId: string | number, data: { name: string; category?: string; checked?: boolean }) {
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_items WHERE trip_id = ?').get(tripId) as { max: number | null };
const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
const result = db.prepare(
'INSERT INTO packing_items (trip_id, name, checked, category, sort_order) VALUES (?, ?, ?, ?, ?)'
).run(tripId, data.name, data.checked ? 1 : 0, data.category || 'Allgemein', sortOrder);
return db.prepare('SELECT * FROM packing_items WHERE id = ?').get(result.lastInsertRowid);
}
export function updateItem(
tripId: string | number,
id: string | number,
data: { name?: string; checked?: number; category?: string; weight_grams?: number | null; bag_id?: number | null },
bodyKeys: string[]
) {
const item = db.prepare('SELECT * FROM packing_items WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!item) return null;
db.prepare(`
UPDATE packing_items SET
name = COALESCE(?, name),
checked = CASE WHEN ? IS NOT NULL THEN ? ELSE checked END,
category = COALESCE(?, category),
weight_grams = CASE WHEN ? THEN ? ELSE weight_grams END,
bag_id = CASE WHEN ? THEN ? ELSE bag_id END
WHERE id = ?
`).run(
data.name || null,
data.checked !== undefined ? 1 : null,
data.checked ? 1 : 0,
data.category || null,
bodyKeys.includes('weight_grams') ? 1 : 0,
data.weight_grams ?? null,
bodyKeys.includes('bag_id') ? 1 : 0,
data.bag_id ?? null,
id
);
return db.prepare('SELECT * FROM packing_items WHERE id = ?').get(id);
}
export function deleteItem(tripId: string | number, id: string | number) {
const item = db.prepare('SELECT id FROM packing_items WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!item) return false;
db.prepare('DELETE FROM packing_items WHERE id = ?').run(id);
return true;
}
// ── Bulk Import ────────────────────────────────────────────────────────────
interface ImportItem {
name?: string;
checked?: boolean;
category?: string;
weight_grams?: string | number;
bag?: string;
}
export function bulkImport(tripId: string | number, items: ImportItem[]) {
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_items WHERE trip_id = ?').get(tripId) as { max: number | null };
let sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
const stmt = db.prepare('INSERT INTO packing_items (trip_id, name, checked, category, weight_grams, bag_id, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)');
const created: any[] = [];
const insertAll = db.transaction(() => {
for (const item of items) {
if (!item.name?.trim()) continue;
const checked = item.checked ? 1 : 0;
const weight = item.weight_grams ? parseInt(String(item.weight_grams)) || null : null;
// Resolve bag by name if provided
let bagId = null;
if (item.bag?.trim()) {
const bagName = item.bag.trim();
const existing = db.prepare('SELECT id FROM packing_bags WHERE trip_id = ? AND name = ?').get(tripId, bagName) as { id: number } | undefined;
if (existing) {
bagId = existing.id;
} else {
const bagCount = (db.prepare('SELECT COUNT(*) as c FROM packing_bags WHERE trip_id = ?').get(tripId) as { c: number }).c;
const newBag = db.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(tripId, bagName, BAG_COLORS[bagCount % BAG_COLORS.length]);
bagId = newBag.lastInsertRowid;
}
}
const result = stmt.run(tripId, item.name.trim(), checked, item.category?.trim() || 'Other', weight, bagId, sortOrder++);
created.push(db.prepare('SELECT * FROM packing_items WHERE id = ?').get(result.lastInsertRowid));
}
});
insertAll();
return created;
}
// ── Bags ───────────────────────────────────────────────────────────────────
export function listBags(tripId: string | number) {
return db.prepare('SELECT * FROM packing_bags WHERE trip_id = ? ORDER BY sort_order, id').all(tripId);
}
export function createBag(tripId: string | number, data: { name: string; color?: string }) {
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_bags WHERE trip_id = ?').get(tripId) as { max: number | null };
const result = db.prepare('INSERT INTO packing_bags (trip_id, name, color, sort_order) VALUES (?, ?, ?, ?)').run(
tripId, data.name.trim(), data.color || '#6366f1', (maxOrder.max ?? -1) + 1
);
return db.prepare('SELECT * FROM packing_bags WHERE id = ?').get(result.lastInsertRowid);
}
export function updateBag(
tripId: string | number,
bagId: string | number,
data: { name?: string; color?: string; weight_limit_grams?: number | null }
) {
const bag = db.prepare('SELECT * FROM packing_bags WHERE id = ? AND trip_id = ?').get(bagId, tripId);
if (!bag) return null;
db.prepare('UPDATE packing_bags SET name = COALESCE(?, name), color = COALESCE(?, color), weight_limit_grams = ? WHERE id = ?').run(
data.name?.trim() || null, data.color || null, data.weight_limit_grams ?? null, bagId
);
return db.prepare('SELECT * FROM packing_bags WHERE id = ?').get(bagId);
}
export function deleteBag(tripId: string | number, bagId: string | number) {
const bag = db.prepare('SELECT * FROM packing_bags WHERE id = ? AND trip_id = ?').get(bagId, tripId);
if (!bag) return false;
db.prepare('DELETE FROM packing_bags WHERE id = ?').run(bagId);
return true;
}
// ── Apply Template ─────────────────────────────────────────────────────────
export function applyTemplate(tripId: string | number, templateId: string | number) {
const templateItems = db.prepare(`
SELECT ti.name, tc.name as category
FROM packing_template_items ti
JOIN packing_template_categories tc ON ti.category_id = tc.id
WHERE tc.template_id = ?
ORDER BY tc.sort_order, ti.sort_order
`).all(templateId) as { name: string; category: string }[];
if (templateItems.length === 0) return null;
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_items WHERE trip_id = ?').get(tripId) as { max: number | null };
let sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
const insert = db.prepare('INSERT INTO packing_items (trip_id, name, checked, category, sort_order) VALUES (?, ?, 0, ?, ?)');
const added: any[] = [];
for (const ti of templateItems) {
const result = insert.run(tripId, ti.name, ti.category, sortOrder++);
const item = db.prepare('SELECT * FROM packing_items WHERE id = ?').get(result.lastInsertRowid);
added.push(item);
}
return added;
}
// ── Category Assignees ─────────────────────────────────────────────────────
export function getCategoryAssignees(tripId: string | number) {
const rows = db.prepare(`
SELECT pca.category_name, pca.user_id, u.username, u.avatar
FROM packing_category_assignees pca
JOIN users u ON pca.user_id = u.id
WHERE pca.trip_id = ?
`).all(tripId);
// Group by category
const assignees: Record<string, { user_id: number; username: string; avatar: string | null }[]> = {};
for (const row of rows as any[]) {
if (!assignees[row.category_name]) assignees[row.category_name] = [];
assignees[row.category_name].push({ user_id: row.user_id, username: row.username, avatar: row.avatar });
}
return assignees;
}
export function updateCategoryAssignees(tripId: string | number, categoryName: string, userIds: number[] | undefined) {
db.prepare('DELETE FROM packing_category_assignees WHERE trip_id = ? AND category_name = ?').run(tripId, categoryName);
if (Array.isArray(userIds) && userIds.length > 0) {
const insert = db.prepare('INSERT OR IGNORE INTO packing_category_assignees (trip_id, category_name, user_id) VALUES (?, ?, ?)');
for (const uid of userIds) insert.run(tripId, categoryName, uid);
}
return db.prepare(`
SELECT pca.user_id, u.username, u.avatar
FROM packing_category_assignees pca
JOIN users u ON pca.user_id = u.id
WHERE pca.trip_id = ? AND pca.category_name = ?
`).all(tripId, categoryName);
}
// ── Reorder ────────────────────────────────────────────────────────────────
export function reorderItems(tripId: string | number, orderedIds: number[]) {
const update = db.prepare('UPDATE packing_items SET sort_order = ? WHERE id = ? AND trip_id = ?');
const updateMany = db.transaction((ids: number[]) => {
ids.forEach((id, index) => {
update.run(index, id, tripId);
});
});
updateMany(orderedIds);
}
+440
View File
@@ -0,0 +1,440 @@
import fetch from 'node-fetch';
import { db, getPlaceWithTags } from '../db/database';
import { loadTagsByPlaceIds } from './queryHelpers';
import { Place } from '../types';
interface PlaceWithCategory extends Place {
category_name: string | null;
category_color: string | null;
category_icon: string | null;
}
interface UnsplashSearchResponse {
results?: { id: string; urls?: { regular?: string; thumb?: string }; description?: string; alt_description?: string; user?: { name?: string }; links?: { html?: string } }[];
errors?: string[];
}
// ---------------------------------------------------------------------------
// GPX helpers
// ---------------------------------------------------------------------------
function parseCoords(attrs: string): { lat: number; lng: number } | null {
const latMatch = attrs.match(/lat=["']([^"']+)["']/i);
const lonMatch = attrs.match(/lon=["']([^"']+)["']/i);
if (!latMatch || !lonMatch) return null;
const lat = parseFloat(latMatch[1]);
const lng = parseFloat(lonMatch[1]);
return (!isNaN(lat) && !isNaN(lng)) ? { lat, lng } : null;
}
function stripCdata(s: string) {
return s.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1').trim();
}
function extractName(body: string) {
const m = body.match(/<name[^>]*>([\s\S]*?)<\/name>/i);
return m ? stripCdata(m[1]) : null;
}
function extractDesc(body: string) {
const m = body.match(/<desc[^>]*>([\s\S]*?)<\/desc>/i);
return m ? stripCdata(m[1]) : null;
}
// ---------------------------------------------------------------------------
// List places
// ---------------------------------------------------------------------------
export function listPlaces(
tripId: string,
filters: { search?: string; category?: string; tag?: string },
) {
let query = `
SELECT DISTINCT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
FROM places p
LEFT JOIN categories c ON p.category_id = c.id
WHERE p.trip_id = ?
`;
const params: (string | number)[] = [tripId];
if (filters.search) {
query += ' AND (p.name LIKE ? OR p.address LIKE ? OR p.description LIKE ?)';
const searchParam = `%${filters.search}%`;
params.push(searchParam, searchParam, searchParam);
}
if (filters.category) {
query += ' AND p.category_id = ?';
params.push(filters.category);
}
if (filters.tag) {
query += ' AND p.id IN (SELECT place_id FROM place_tags WHERE tag_id = ?)';
params.push(filters.tag);
}
query += ' ORDER BY p.created_at DESC';
const places = db.prepare(query).all(...params) as PlaceWithCategory[];
const placeIds = places.map(p => p.id);
const tagsByPlaceId = loadTagsByPlaceIds(placeIds);
return places.map(p => ({
...p,
category: p.category_id ? {
id: p.category_id,
name: p.category_name,
color: p.category_color,
icon: p.category_icon,
} : null,
tags: tagsByPlaceId[p.id] || [],
}));
}
// ---------------------------------------------------------------------------
// Create place
// ---------------------------------------------------------------------------
export function createPlace(
tripId: string,
body: {
name: string; description?: string; lat?: number; lng?: number; address?: string;
category_id?: number; price?: number; currency?: string;
place_time?: string; end_time?: string;
duration_minutes?: number; notes?: string; image_url?: string;
google_place_id?: string; osm_id?: string; website?: string; phone?: string;
transport_mode?: string; tags?: number[];
},
) {
const {
name, description, lat, lng, address, category_id, price, currency,
place_time, end_time,
duration_minutes, notes, image_url, google_place_id, osm_id, website, phone,
transport_mode, tags = [],
} = body;
const result = db.prepare(`
INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency,
place_time, end_time,
duration_minutes, notes, image_url, google_place_id, osm_id, website, phone, transport_mode)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
tripId, name, description || null, lat || null, lng || null, address || null,
category_id || null, price || null, currency || null,
place_time || null, end_time || null, duration_minutes || 60, notes || null, image_url || null,
google_place_id || null, osm_id || null, website || null, phone || null, transport_mode || 'walking',
);
const placeId = result.lastInsertRowid;
if (tags && tags.length > 0) {
const insertTag = db.prepare('INSERT OR IGNORE INTO place_tags (place_id, tag_id) VALUES (?, ?)');
for (const tagId of tags) {
insertTag.run(placeId, tagId);
}
}
return getPlaceWithTags(Number(placeId));
}
// ---------------------------------------------------------------------------
// Get single place
// ---------------------------------------------------------------------------
export function getPlace(tripId: string, placeId: string) {
const placeCheck = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId);
if (!placeCheck) return null;
return getPlaceWithTags(placeId);
}
// ---------------------------------------------------------------------------
// Update place
// ---------------------------------------------------------------------------
export function updatePlace(
tripId: string,
placeId: string,
body: {
name?: string; description?: string; lat?: number; lng?: number; address?: string;
category_id?: number; price?: number; currency?: string;
place_time?: string; end_time?: string;
duration_minutes?: number; notes?: string; image_url?: string;
google_place_id?: string; website?: string; phone?: string;
transport_mode?: string; tags?: number[];
},
) {
const existingPlace = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId) as Place | undefined;
if (!existingPlace) return null;
const {
name, description, lat, lng, address, category_id, price, currency,
place_time, end_time,
duration_minutes, notes, image_url, google_place_id, website, phone,
transport_mode, tags,
} = body;
db.prepare(`
UPDATE places SET
name = COALESCE(?, name),
description = ?,
lat = ?,
lng = ?,
address = ?,
category_id = ?,
price = ?,
currency = COALESCE(?, currency),
place_time = ?,
end_time = ?,
duration_minutes = COALESCE(?, duration_minutes),
notes = ?,
image_url = ?,
google_place_id = ?,
website = ?,
phone = ?,
transport_mode = COALESCE(?, transport_mode),
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(
name || null,
description !== undefined ? description : existingPlace.description,
lat !== undefined ? lat : existingPlace.lat,
lng !== undefined ? lng : existingPlace.lng,
address !== undefined ? address : existingPlace.address,
category_id !== undefined ? category_id : existingPlace.category_id,
price !== undefined ? price : existingPlace.price,
currency || null,
place_time !== undefined ? place_time : existingPlace.place_time,
end_time !== undefined ? end_time : existingPlace.end_time,
duration_minutes || null,
notes !== undefined ? notes : existingPlace.notes,
image_url !== undefined ? image_url : existingPlace.image_url,
google_place_id !== undefined ? google_place_id : existingPlace.google_place_id,
website !== undefined ? website : existingPlace.website,
phone !== undefined ? phone : existingPlace.phone,
transport_mode || null,
placeId,
);
if (tags !== undefined) {
db.prepare('DELETE FROM place_tags WHERE place_id = ?').run(placeId);
if (tags.length > 0) {
const insertTag = db.prepare('INSERT OR IGNORE INTO place_tags (place_id, tag_id) VALUES (?, ?)');
for (const tagId of tags) {
insertTag.run(placeId, tagId);
}
}
}
return getPlaceWithTags(placeId);
}
// ---------------------------------------------------------------------------
// Delete place
// ---------------------------------------------------------------------------
export function deletePlace(tripId: string, placeId: string): boolean {
const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId);
if (!place) return false;
db.prepare('DELETE FROM places WHERE id = ?').run(placeId);
return true;
}
// ---------------------------------------------------------------------------
// Import GPX
// ---------------------------------------------------------------------------
export function importGpx(tripId: string, fileBuffer: Buffer) {
const xml = fileBuffer.toString('utf-8');
const waypoints: { name: string; lat: number; lng: number; description: string | null; routeGeometry?: string }[] = [];
// 1) Parse <wpt> elements (named waypoints / POIs)
const wptRegex = /<wpt\s([^>]+)>([\s\S]*?)<\/wpt>/gi;
let match;
while ((match = wptRegex.exec(xml)) !== null) {
const coords = parseCoords(match[1]);
if (!coords) continue;
const name = extractName(match[2]) || `Waypoint ${waypoints.length + 1}`;
waypoints.push({ ...coords, name, description: extractDesc(match[2]) });
}
// 2) If no <wpt>, try <rtept> (route points)
if (waypoints.length === 0) {
const rteptRegex = /<rtept\s([^>]+)>([\s\S]*?)<\/rtept>/gi;
while ((match = rteptRegex.exec(xml)) !== null) {
const coords = parseCoords(match[1]);
if (!coords) continue;
const name = extractName(match[2]) || `Route Point ${waypoints.length + 1}`;
waypoints.push({ ...coords, name, description: extractDesc(match[2]) });
}
}
// 3) If still nothing, extract full track geometry from <trkpt>
if (waypoints.length === 0) {
const trackNameMatch = xml.match(/<trk[^>]*>[\s\S]*?<name[^>]*>([\s\S]*?)<\/name>/i);
const trackName = trackNameMatch?.[1]?.trim() || 'GPX Track';
const trackDesc = (() => { const m = xml.match(/<trk[^>]*>[\s\S]*?<desc[^>]*>([\s\S]*?)<\/desc>/i); return m ? stripCdata(m[1]) : null; })();
const trkptRegex = /<trkpt\s([^>]*?)(?:\/>|>([\s\S]*?)<\/trkpt>)/gi;
const trackPoints: { lat: number; lng: number; ele: number | null }[] = [];
while ((match = trkptRegex.exec(xml)) !== null) {
const coords = parseCoords(match[1]);
if (!coords) continue;
const eleMatch = match[2]?.match(/<ele[^>]*>([\s\S]*?)<\/ele>/i);
const ele = eleMatch ? parseFloat(eleMatch[1]) : null;
trackPoints.push({ ...coords, ele: (ele !== null && !isNaN(ele)) ? ele : null });
}
if (trackPoints.length > 0) {
const start = trackPoints[0];
const hasAllEle = trackPoints.every(p => p.ele !== null);
const routeGeometry = trackPoints.map(p => hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng]);
waypoints.push({ ...start, name: trackName, description: trackDesc, routeGeometry: JSON.stringify(routeGeometry) });
}
}
if (waypoints.length === 0) {
return null;
}
const insertStmt = db.prepare(`
INSERT INTO places (trip_id, name, description, lat, lng, transport_mode, route_geometry)
VALUES (?, ?, ?, ?, ?, 'walking', ?)
`);
const created: any[] = [];
const insertAll = db.transaction(() => {
for (const wp of waypoints) {
const result = insertStmt.run(tripId, wp.name, wp.description, wp.lat, wp.lng, wp.routeGeometry || null);
const place = getPlaceWithTags(Number(result.lastInsertRowid));
created.push(place);
}
});
insertAll();
return created;
}
// ---------------------------------------------------------------------------
// Import Google Maps list
// ---------------------------------------------------------------------------
export async function importGoogleList(tripId: string, url: string) {
let listId: string | null = null;
let resolvedUrl = url;
// Follow redirects for short URLs (maps.app.goo.gl, goo.gl)
if (url.includes('goo.gl') || url.includes('maps.app')) {
const redirectRes = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(10000) });
resolvedUrl = redirectRes.url;
}
// Pattern: /placelists/list/{ID}
const plMatch = resolvedUrl.match(/placelists\/list\/([A-Za-z0-9_-]+)/);
if (plMatch) listId = plMatch[1];
// Pattern: !2s{ID} in data URL params
if (!listId) {
const dataMatch = resolvedUrl.match(/!2s([A-Za-z0-9_-]{15,})/);
if (dataMatch) listId = dataMatch[1];
}
if (!listId) {
return { error: 'Could not extract list ID from URL. Please use a shared Google Maps list link.', status: 400 };
}
// Fetch list data from Google Maps internal API
const apiUrl = `https://www.google.com/maps/preview/entitylist/getlist?authuser=0&hl=en&gl=us&pb=!1m1!1s${encodeURIComponent(listId)}!2e2!3e2!4i500!16b1`;
const apiRes = await fetch(apiUrl, {
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' },
signal: AbortSignal.timeout(15000),
});
if (!apiRes.ok) {
return { error: 'Failed to fetch list from Google Maps', status: 502 };
}
const rawText = await apiRes.text();
const jsonStr = rawText.substring(rawText.indexOf('\n') + 1);
const listData = JSON.parse(jsonStr);
const meta = listData[0];
if (!meta) {
return { error: 'Invalid list data received from Google Maps', status: 400 };
}
const listName = meta[4] || 'Google Maps List';
const items = meta[8];
if (!Array.isArray(items) || items.length === 0) {
return { error: 'List is empty or could not be read', status: 400 };
}
// Parse place data from items
const places: { name: string; lat: number; lng: number; notes: string | null }[] = [];
for (const item of items) {
const coords = item?.[1]?.[5];
const lat = coords?.[2];
const lng = coords?.[3];
const name = item?.[2];
const note = item?.[3] || null;
if (name && typeof lat === 'number' && typeof lng === 'number' && !isNaN(lat) && !isNaN(lng)) {
places.push({ name, lat, lng, notes: note || null });
}
}
if (places.length === 0) {
return { error: 'No places with coordinates found in list', status: 400 };
}
// Insert places into trip
const insertStmt = db.prepare(`
INSERT INTO places (trip_id, name, lat, lng, notes, transport_mode)
VALUES (?, ?, ?, ?, ?, 'walking')
`);
const created: any[] = [];
const insertAll = db.transaction(() => {
for (const p of places) {
const result = insertStmt.run(tripId, p.name, p.lat, p.lng, p.notes);
const place = getPlaceWithTags(Number(result.lastInsertRowid));
created.push(place);
}
});
insertAll();
return { places: created, listName };
}
// ---------------------------------------------------------------------------
// Search place image (Unsplash)
// ---------------------------------------------------------------------------
export async function searchPlaceImage(tripId: string, placeId: string, userId: number) {
const place = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId) as Place | undefined;
if (!place) return { error: 'Place not found', status: 404 };
const user = db.prepare('SELECT unsplash_api_key FROM users WHERE id = ?').get(userId) as { unsplash_api_key: string | null } | undefined;
if (!user || !user.unsplash_api_key) {
return { error: 'No Unsplash API key configured', status: 400 };
}
const query = encodeURIComponent(place.name + (place.address ? ' ' + place.address : ''));
const response = await fetch(
`https://api.unsplash.com/search/photos?query=${query}&per_page=5&client_id=${user.unsplash_api_key}`,
);
const data = await response.json() as UnsplashSearchResponse;
if (!response.ok) {
return { error: data.errors?.[0] || 'Unsplash API error', status: response.status };
}
const photos = (data.results || []).map((p: NonNullable<UnsplashSearchResponse['results']>[number]) => ({
id: p.id,
url: p.urls?.regular,
thumb: p.urls?.thumb,
description: p.description || p.alt_description,
photographer: p.user?.name,
link: p.links?.html,
}));
return { photos };
}
+242
View File
@@ -0,0 +1,242 @@
import { db, canAccessTrip } from '../db/database';
import { Reservation } from '../types';
export function verifyTripAccess(tripId: string | number, userId: number) {
return canAccessTrip(tripId, userId);
}
export function listReservations(tripId: string | number) {
return db.prepare(`
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id,
ap.place_id as accommodation_place_id, acc_p.name as accommodation_name
FROM reservations r
LEFT JOIN days d ON r.day_id = d.id
LEFT JOIN places p ON r.place_id = p.id
LEFT JOIN day_accommodations ap ON r.accommodation_id = ap.id
LEFT JOIN places acc_p ON ap.place_id = acc_p.id
WHERE r.trip_id = ?
ORDER BY r.reservation_time ASC, r.created_at ASC
`).all(tripId);
}
export function getReservationWithJoins(id: string | number) {
return db.prepare(`
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id,
ap.place_id as accommodation_place_id, acc_p.name as accommodation_name
FROM reservations r
LEFT JOIN days d ON r.day_id = d.id
LEFT JOIN places p ON r.place_id = p.id
LEFT JOIN day_accommodations ap ON r.accommodation_id = ap.id
LEFT JOIN places acc_p ON ap.place_id = acc_p.id
WHERE r.id = ?
`).get(id);
}
interface CreateAccommodation {
place_id?: number;
start_day_id?: number;
end_day_id?: number;
check_in?: string;
check_out?: string;
confirmation?: string;
}
interface CreateReservationData {
title: string;
reservation_time?: string;
reservation_end_time?: string;
location?: string;
confirmation_number?: string;
notes?: string;
day_id?: number;
place_id?: number;
assignment_id?: number;
status?: string;
type?: string;
accommodation_id?: number;
metadata?: any;
create_accommodation?: CreateAccommodation;
}
export function createReservation(tripId: string | number, data: CreateReservationData): { reservation: any; accommodationCreated: boolean } {
const {
title, reservation_time, reservation_end_time, location,
confirmation_number, notes, day_id, place_id, assignment_id,
status, type, accommodation_id, metadata, create_accommodation
} = data;
let accommodationCreated = false;
// Auto-create accommodation for hotel reservations
let resolvedAccommodationId: number | null = accommodation_id || null;
if (type === 'hotel' && !resolvedAccommodationId && create_accommodation) {
const { place_id: accPlaceId, start_day_id, end_day_id, check_in, check_out, confirmation: accConf } = create_accommodation;
if (accPlaceId && start_day_id && end_day_id) {
const accResult = db.prepare(
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(tripId, accPlaceId, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null);
resolvedAccommodationId = Number(accResult.lastInsertRowid);
accommodationCreated = true;
}
}
const result = db.prepare(`
INSERT INTO reservations (trip_id, day_id, place_id, assignment_id, title, reservation_time, reservation_end_time, location, confirmation_number, notes, status, type, accommodation_id, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
tripId,
day_id || null,
place_id || null,
assignment_id || null,
title,
reservation_time || null,
reservation_end_time || null,
location || null,
confirmation_number || null,
notes || null,
status || 'pending',
type || 'other',
resolvedAccommodationId,
metadata ? JSON.stringify(metadata) : null
);
// Sync check-in/out to accommodation if linked
if (accommodation_id && metadata) {
const meta = typeof metadata === 'string' ? JSON.parse(metadata) : metadata;
if (meta.check_in_time || meta.check_out_time) {
db.prepare('UPDATE day_accommodations SET check_in = COALESCE(?, check_in), check_out = COALESCE(?, check_out) WHERE id = ?')
.run(meta.check_in_time || null, meta.check_out_time || null, accommodation_id);
}
if (confirmation_number) {
db.prepare('UPDATE day_accommodations SET confirmation = COALESCE(?, confirmation) WHERE id = ?')
.run(confirmation_number, accommodation_id);
}
}
const reservation = getReservationWithJoins(Number(result.lastInsertRowid));
return { reservation, accommodationCreated };
}
export function updatePositions(tripId: string | number, positions: { id: number; day_plan_position: number }[]) {
const stmt = db.prepare('UPDATE reservations SET day_plan_position = ? WHERE id = ? AND trip_id = ?');
const updateMany = db.transaction((items: { id: number; day_plan_position: number }[]) => {
for (const item of items) {
stmt.run(item.day_plan_position, item.id, tripId);
}
});
updateMany(positions);
}
export function getReservation(id: string | number, tripId: string | number) {
return db.prepare('SELECT * FROM reservations WHERE id = ? AND trip_id = ?').get(id, tripId) as Reservation | undefined;
}
interface UpdateReservationData {
title?: string;
reservation_time?: string;
reservation_end_time?: string;
location?: string;
confirmation_number?: string;
notes?: string;
day_id?: number;
place_id?: number;
assignment_id?: number;
status?: string;
type?: string;
accommodation_id?: number;
metadata?: any;
create_accommodation?: CreateAccommodation;
}
export function updateReservation(id: string | number, tripId: string | number, data: UpdateReservationData, current: Reservation): { reservation: any; accommodationChanged: boolean } {
const {
title, reservation_time, reservation_end_time, location,
confirmation_number, notes, day_id, place_id, assignment_id,
status, type, accommodation_id, metadata, create_accommodation
} = data;
let accommodationChanged = false;
// Update or create accommodation for hotel reservations
let resolvedAccId: number | null = accommodation_id !== undefined ? (accommodation_id || null) : (current.accommodation_id ?? null);
if (type === 'hotel' && create_accommodation) {
const { place_id: accPlaceId, start_day_id, end_day_id, check_in, check_out, confirmation: accConf } = create_accommodation;
if (accPlaceId && start_day_id && end_day_id) {
if (resolvedAccId) {
db.prepare('UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ? WHERE id = ?')
.run(accPlaceId, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null, resolvedAccId);
} else {
const accResult = db.prepare(
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(tripId, accPlaceId, start_day_id, end_day_id, check_in || null, check_out || null, accConf || confirmation_number || null);
resolvedAccId = Number(accResult.lastInsertRowid);
}
accommodationChanged = true;
}
}
db.prepare(`
UPDATE reservations SET
title = COALESCE(?, title),
reservation_time = ?,
reservation_end_time = ?,
location = ?,
confirmation_number = ?,
notes = ?,
day_id = ?,
place_id = ?,
assignment_id = ?,
status = COALESCE(?, status),
type = COALESCE(?, type),
accommodation_id = ?,
metadata = ?
WHERE id = ?
`).run(
title || null,
reservation_time !== undefined ? (reservation_time || null) : current.reservation_time,
reservation_end_time !== undefined ? (reservation_end_time || null) : current.reservation_end_time,
location !== undefined ? (location || null) : current.location,
confirmation_number !== undefined ? (confirmation_number || null) : current.confirmation_number,
notes !== undefined ? (notes || null) : current.notes,
day_id !== undefined ? (day_id || null) : current.day_id,
place_id !== undefined ? (place_id || null) : current.place_id,
assignment_id !== undefined ? (assignment_id || null) : current.assignment_id,
status || null,
type || null,
resolvedAccId,
metadata !== undefined ? (metadata ? JSON.stringify(metadata) : null) : current.metadata,
id
);
// Sync check-in/out to accommodation if linked
const resolvedMeta = metadata !== undefined ? metadata : (current.metadata ? JSON.parse(current.metadata as string) : null);
if (resolvedAccId && resolvedMeta) {
const meta = typeof resolvedMeta === 'string' ? JSON.parse(resolvedMeta) : resolvedMeta;
if (meta.check_in_time || meta.check_out_time) {
db.prepare('UPDATE day_accommodations SET check_in = COALESCE(?, check_in), check_out = COALESCE(?, check_out) WHERE id = ?')
.run(meta.check_in_time || null, meta.check_out_time || null, resolvedAccId);
}
const resolvedConf = confirmation_number !== undefined ? confirmation_number : current.confirmation_number;
if (resolvedConf) {
db.prepare('UPDATE day_accommodations SET confirmation = COALESCE(?, confirmation) WHERE id = ?')
.run(resolvedConf, resolvedAccId);
}
}
const reservation = getReservationWithJoins(id);
return { reservation, accommodationChanged };
}
export function deleteReservation(id: string | number, tripId: string | number): { deleted: { id: number; title: string; type: string; accommodation_id: number | null } | undefined; accommodationDeleted: boolean } {
const reservation = db.prepare('SELECT id, title, type, accommodation_id FROM reservations WHERE id = ? AND trip_id = ?').get(id, tripId) as { id: number; title: string; type: string; accommodation_id: number | null } | undefined;
if (!reservation) return { deleted: undefined, accommodationDeleted: false };
let accommodationDeleted = false;
if (reservation.accommodation_id) {
db.prepare('DELETE FROM day_accommodations WHERE id = ?').run(reservation.accommodation_id);
accommodationDeleted = true;
}
db.prepare('DELETE FROM reservations WHERE id = ?').run(id);
return { deleted: reservation, accommodationDeleted };
}
+41
View File
@@ -0,0 +1,41 @@
import { db } from '../db/database';
export function getUserSettings(userId: number): Record<string, unknown> {
const rows = db.prepare('SELECT key, value FROM settings WHERE user_id = ?').all(userId) as { key: string; value: string }[];
const settings: Record<string, unknown> = {};
for (const row of rows) {
try {
settings[row.key] = JSON.parse(row.value);
} catch {
settings[row.key] = row.value;
}
}
return settings;
}
export function upsertSetting(userId: number, key: string, value: unknown) {
const serialized = typeof value === 'object' ? JSON.stringify(value) : String(value !== undefined ? value : '');
db.prepare(`
INSERT INTO settings (user_id, key, value) VALUES (?, ?, ?)
ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value
`).run(userId, key, serialized);
}
export function bulkUpsertSettings(userId: number, settings: Record<string, unknown>) {
const upsert = db.prepare(`
INSERT INTO settings (user_id, key, value) VALUES (?, ?, ?)
ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value
`);
db.exec('BEGIN');
try {
for (const [key, value] of Object.entries(settings)) {
const serialized = typeof value === 'object' ? JSON.stringify(value) : String(value !== undefined ? value : '');
upsert.run(userId, key, serialized);
}
db.exec('COMMIT');
} catch (err) {
db.exec('ROLLBACK');
throw err;
}
return Object.keys(settings).length;
}
+189
View File
@@ -0,0 +1,189 @@
import { db, canAccessTrip } from '../db/database';
import crypto from 'crypto';
import { loadTagsByPlaceIds } from './queryHelpers';
interface SharePermissions {
share_map?: boolean;
share_bookings?: boolean;
share_packing?: boolean;
share_budget?: boolean;
share_collab?: boolean;
}
interface ShareTokenInfo {
token: string;
created_at: string;
share_map: boolean;
share_bookings: boolean;
share_packing: boolean;
share_budget: boolean;
share_collab: boolean;
}
/**
* Creates a new share link or updates the permissions on an existing one.
* Returns an object with the token string and whether it was newly created.
*/
export function createOrUpdateShareLink(
tripId: string,
createdBy: number,
permissions: SharePermissions
): { token: string; created: boolean } {
const {
share_map = true,
share_bookings = true,
share_packing = false,
share_budget = false,
share_collab = false,
} = permissions;
const existing = db.prepare('SELECT token FROM share_tokens WHERE trip_id = ?').get(tripId) as { token: string } | undefined;
if (existing) {
db.prepare('UPDATE share_tokens SET share_map = ?, share_bookings = ?, share_packing = ?, share_budget = ?, share_collab = ? WHERE trip_id = ?')
.run(share_map ? 1 : 0, share_bookings ? 1 : 0, share_packing ? 1 : 0, share_budget ? 1 : 0, share_collab ? 1 : 0, tripId);
return { token: existing.token, created: false };
}
const token = crypto.randomBytes(24).toString('base64url');
db.prepare('INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
.run(tripId, token, createdBy, share_map ? 1 : 0, share_bookings ? 1 : 0, share_packing ? 1 : 0, share_budget ? 1 : 0, share_collab ? 1 : 0);
return { token, created: true };
}
/**
* Returns share token info for a trip, or null if no share link exists.
*/
export function getShareLink(tripId: string): ShareTokenInfo | null {
const row = db.prepare('SELECT * FROM share_tokens WHERE trip_id = ?').get(tripId) as any;
if (!row) return null;
return {
token: row.token,
created_at: row.created_at,
share_map: !!row.share_map,
share_bookings: !!row.share_bookings,
share_packing: !!row.share_packing,
share_budget: !!row.share_budget,
share_collab: !!row.share_collab,
};
}
/**
* Deletes the share token for a trip.
*/
export function deleteShareLink(tripId: string): void {
db.prepare('DELETE FROM share_tokens WHERE trip_id = ?').run(tripId);
}
/**
* Loads the full public trip data for a share token, filtered by the token's
* permission flags. Returns null if the token is invalid or the trip is gone.
*/
export function getSharedTripData(token: string): Record<string, any> | null {
const shareRow = db.prepare('SELECT * FROM share_tokens WHERE token = ?').get(token) as any;
if (!shareRow) return null;
const tripId = shareRow.trip_id;
// Trip
const trip = db.prepare('SELECT id, title, description, start_date, end_date, cover_image, currency FROM trips WHERE id = ?').get(tripId);
if (!trip) return null;
// Days with assignments
const days = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(tripId) as any[];
const dayIds = days.map(d => d.id);
let assignments: Record<number, any[]> = {};
let dayNotes: Record<number, any[]> = {};
if (dayIds.length > 0) {
const ph = dayIds.map(() => '?').join(',');
const allAssignments = db.prepare(`
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
COALESCE(da.assignment_time, p.place_time) as place_time,
COALESCE(da.assignment_end_time, p.end_time) as end_time,
p.duration_minutes, p.notes as place_notes, p.image_url, p.transport_mode,
c.name as category_name, c.color as category_color, c.icon as category_icon
FROM day_assignments da
JOIN places p ON da.place_id = p.id
LEFT JOIN categories c ON p.category_id = c.id
WHERE da.day_id IN (${ph})
ORDER BY da.order_index ASC
`).all(...dayIds);
const placeIds = [...new Set(allAssignments.map((a: any) => a.place_id))];
const tagsByPlace = loadTagsByPlaceIds(placeIds, { compact: true });
const byDay: Record<number, any[]> = {};
for (const a of allAssignments as any[]) {
if (!byDay[a.day_id]) byDay[a.day_id] = [];
byDay[a.day_id].push({
id: a.id, day_id: a.day_id, order_index: a.order_index, notes: a.notes,
place: {
id: a.place_id, name: a.place_name, description: a.place_description,
lat: a.lat, lng: a.lng, address: a.address, category_id: a.category_id,
price: a.price, place_time: a.place_time, end_time: a.end_time,
image_url: a.image_url, transport_mode: a.transport_mode,
category: a.category_id ? { id: a.category_id, name: a.category_name, color: a.category_color, icon: a.category_icon } : null,
tags: tagsByPlace[a.place_id] || [],
}
});
}
assignments = byDay;
const allNotes = db.prepare(`SELECT * FROM day_notes WHERE day_id IN (${ph}) ORDER BY sort_order ASC`).all(...dayIds);
const notesByDay: Record<number, any[]> = {};
for (const n of allNotes as any[]) {
if (!notesByDay[n.day_id]) notesByDay[n.day_id] = [];
notesByDay[n.day_id].push(n);
}
dayNotes = notesByDay;
}
// Places
const places = db.prepare(`
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
FROM places p LEFT JOIN categories c ON p.category_id = c.id
WHERE p.trip_id = ? ORDER BY p.created_at DESC
`).all(tripId);
// Reservations
const reservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ? ORDER BY reservation_time ASC').all(tripId);
// Accommodations
const accommodations = db.prepare(`
SELECT a.*, p.name as place_name, p.address as place_address, p.lat as place_lat, p.lng as place_lng
FROM day_accommodations a JOIN places p ON a.place_id = p.id
WHERE a.trip_id = ?
`).all(tripId);
// Packing
const packing = db.prepare('SELECT * FROM packing_items WHERE trip_id = ? ORDER BY sort_order ASC').all(tripId);
// Budget
const budget = db.prepare('SELECT * FROM budget_items WHERE trip_id = ? ORDER BY category ASC').all(tripId);
// Categories
const categories = db.prepare('SELECT * FROM categories').all();
const permissions = {
share_map: !!shareRow.share_map,
share_bookings: !!shareRow.share_bookings,
share_packing: !!shareRow.share_packing,
share_budget: !!shareRow.share_budget,
share_collab: !!shareRow.share_collab,
};
// Collab messages (only if owner chose to share)
const collabMessages = permissions.share_collab
? db.prepare('SELECT m.*, u.username, u.avatar FROM collab_messages m JOIN users u ON m.user_id = u.id WHERE m.trip_id = ? ORDER BY m.created_at ASC').all(tripId)
: [];
return {
trip, days, assignments, dayNotes, places, categories, permissions,
reservations: permissions.share_bookings ? reservations : [],
accommodations: permissions.share_bookings ? accommodations : [],
packing: permissions.share_packing ? packing : [],
budget: permissions.share_budget ? budget : [],
collab: collabMessages,
};
}
+26
View File
@@ -0,0 +1,26 @@
import { db } from '../db/database';
export function listTags(userId: number) {
return db.prepare('SELECT * FROM tags WHERE user_id = ? ORDER BY name ASC').all(userId);
}
export function createTag(userId: number, name: string, color?: string) {
const result = db.prepare(
'INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)'
).run(userId, name, color || '#10b981');
return db.prepare('SELECT * FROM tags WHERE id = ?').get(result.lastInsertRowid);
}
export function getTagByIdAndUser(tagId: number | string, userId: number) {
return db.prepare('SELECT * FROM tags WHERE id = ? AND user_id = ?').get(tagId, userId);
}
export function updateTag(tagId: number | string, name?: string, color?: string) {
db.prepare('UPDATE tags SET name = COALESCE(?, name), color = COALESCE(?, color) WHERE id = ?')
.run(name || null, color || null, tagId);
return db.prepare('SELECT * FROM tags WHERE id = ?').get(tagId);
}
export function deleteTag(tagId: number | string) {
db.prepare('DELETE FROM tags WHERE id = ?').run(tagId);
}
+415
View File
@@ -0,0 +1,415 @@
import path from 'path';
import fs from 'fs';
import { db, canAccessTrip, isOwner } from '../db/database';
import { Trip, User } from '../types';
export const MS_PER_DAY = 86400000;
export const MAX_TRIP_DAYS = 365;
const TRIP_SELECT = `
SELECT t.*,
(SELECT COUNT(*) FROM days d WHERE d.trip_id = t.id) as day_count,
(SELECT COUNT(*) FROM places p WHERE p.trip_id = t.id) as place_count,
CASE WHEN t.user_id = :userId THEN 1 ELSE 0 END as is_owner,
u.username as owner_username,
(SELECT COUNT(*) FROM trip_members tm WHERE tm.trip_id = t.id) as shared_count
FROM trips t
JOIN users u ON u.id = t.user_id
`;
// ── Access helpers ────────────────────────────────────────────────────────
export function verifyTripAccess(tripId: string | number, userId: number) {
return canAccessTrip(tripId, userId);
}
export { isOwner };
// ── Day generation ────────────────────────────────────────────────────────
export function generateDays(tripId: number | bigint | string, startDate: string | null, endDate: string | null) {
const existing = db.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ?').all(tripId) as { id: number; day_number: number; date: string | null }[];
if (!startDate || !endDate) {
const datelessExisting = existing.filter(d => !d.date).sort((a, b) => a.day_number - b.day_number);
const withDates = existing.filter(d => d.date);
if (withDates.length > 0) {
db.prepare(`DELETE FROM days WHERE trip_id = ? AND date IS NOT NULL`).run(tripId);
}
const needed = 7 - datelessExisting.length;
if (needed > 0) {
const insert = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, NULL)');
for (let i = 0; i < needed; i++) insert.run(tripId, datelessExisting.length + i + 1);
} else if (needed < 0) {
const toRemove = datelessExisting.slice(7);
const del = db.prepare('DELETE FROM days WHERE id = ?');
for (const d of toRemove) del.run(d.id);
}
const remaining = db.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as { id: number }[];
const tmpUpd = db.prepare('UPDATE days SET day_number = ? WHERE id = ?');
remaining.forEach((d, i) => tmpUpd.run(-(i + 1), d.id));
remaining.forEach((d, i) => tmpUpd.run(i + 1, d.id));
return;
}
const [sy, sm, sd] = startDate.split('-').map(Number);
const [ey, em, ed] = endDate.split('-').map(Number);
const startMs = Date.UTC(sy, sm - 1, sd);
const endMs = Date.UTC(ey, em - 1, ed);
const numDays = Math.min(Math.floor((endMs - startMs) / MS_PER_DAY) + 1, MAX_TRIP_DAYS);
const targetDates: string[] = [];
for (let i = 0; i < numDays; i++) {
const d = new Date(startMs + i * MS_PER_DAY);
const yyyy = d.getUTCFullYear();
const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
const dd = String(d.getUTCDate()).padStart(2, '0');
targetDates.push(`${yyyy}-${mm}-${dd}`);
}
const existingByDate = new Map<string, { id: number; day_number: number; date: string | null }>();
for (const d of existing) {
if (d.date) existingByDate.set(d.date, d);
}
const targetDateSet = new Set(targetDates);
const toDelete = existing.filter(d => d.date && !targetDateSet.has(d.date));
const datelessToDelete = existing.filter(d => !d.date);
const del = db.prepare('DELETE FROM days WHERE id = ?');
for (const d of [...toDelete, ...datelessToDelete]) del.run(d.id);
const setTemp = db.prepare('UPDATE days SET day_number = ? WHERE id = ?');
const kept = existing.filter(d => d.date && targetDateSet.has(d.date));
for (let i = 0; i < kept.length; i++) setTemp.run(-(i + 1), kept[i].id);
const insert = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)');
const update = db.prepare('UPDATE days SET day_number = ? WHERE id = ?');
for (let i = 0; i < targetDates.length; i++) {
const date = targetDates[i];
const ex = existingByDate.get(date);
if (ex) {
update.run(i + 1, ex.id);
} else {
insert.run(tripId, i + 1, date);
}
}
}
// ── Trip CRUD ─────────────────────────────────────────────────────────────
export function listTrips(userId: number, archived: number) {
return db.prepare(`
${TRIP_SELECT}
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
WHERE (t.user_id = :userId OR m.user_id IS NOT NULL) AND t.is_archived = :archived
ORDER BY t.created_at DESC
`).all({ userId, archived });
}
interface CreateTripData {
title: string;
description?: string | null;
start_date?: string | null;
end_date?: string | null;
currency?: string;
reminder_days?: number;
}
export function createTrip(userId: number, data: CreateTripData) {
const rd = data.reminder_days !== undefined
? (Number(data.reminder_days) >= 0 && Number(data.reminder_days) <= 30 ? Number(data.reminder_days) : 3)
: 3;
const result = db.prepare(`
INSERT INTO trips (user_id, title, description, start_date, end_date, currency, reminder_days)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(userId, data.title, data.description || null, data.start_date || null, data.end_date || null, data.currency || 'EUR', rd);
const tripId = result.lastInsertRowid;
generateDays(tripId, data.start_date || null, data.end_date || null);
const trip = db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId, tripId });
return { trip, tripId: Number(tripId), reminderDays: rd };
}
export function getTrip(tripId: string | number, userId: number) {
return db.prepare(`
${TRIP_SELECT}
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
WHERE t.id = :tripId AND (t.user_id = :userId OR m.user_id IS NOT NULL)
`).get({ userId, tripId });
}
interface UpdateTripData {
title?: string;
description?: string;
start_date?: string;
end_date?: string;
currency?: string;
is_archived?: boolean | number;
cover_image?: string;
reminder_days?: number;
}
export interface UpdateTripResult {
updatedTrip: any;
changes: Record<string, unknown>;
isAdminEdit: boolean;
ownerEmail?: string;
newTitle: string;
newReminder: number;
oldReminder: number;
}
export function updateTrip(tripId: string | number, userId: number, data: UpdateTripData, userRole: string): UpdateTripResult {
const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as Trip & { reminder_days?: number } | undefined;
if (!trip) throw new NotFoundError('Trip not found');
const { title, description, start_date, end_date, currency, is_archived, cover_image, reminder_days } = data;
if (start_date && end_date && new Date(end_date) < new Date(start_date))
throw new ValidationError('End date must be after start date');
const newTitle = title || trip.title;
const newDesc = description !== undefined ? description : trip.description;
const newStart = start_date !== undefined ? start_date : trip.start_date;
const newEnd = end_date !== undefined ? end_date : trip.end_date;
const newCurrency = currency || trip.currency;
const newArchived = is_archived !== undefined ? (is_archived ? 1 : 0) : trip.is_archived;
const newCover = cover_image !== undefined ? cover_image : trip.cover_image;
const oldReminder = (trip as any).reminder_days ?? 3;
const newReminder = reminder_days !== undefined
? (Number(reminder_days) >= 0 && Number(reminder_days) <= 30 ? Number(reminder_days) : oldReminder)
: oldReminder;
db.prepare(`
UPDATE trips SET title=?, description=?, start_date=?, end_date=?,
currency=?, is_archived=?, cover_image=?, reminder_days=?, updated_at=CURRENT_TIMESTAMP
WHERE id=?
`).run(newTitle, newDesc, newStart || null, newEnd || null, newCurrency, newArchived, newCover, newReminder, tripId);
if (newStart !== trip.start_date || newEnd !== trip.end_date)
generateDays(tripId, newStart || null, newEnd || null);
const changes: Record<string, unknown> = {};
if (title && title !== trip.title) changes.title = title;
if (newStart !== trip.start_date) changes.start_date = newStart;
if (newEnd !== trip.end_date) changes.end_date = newEnd;
if (newReminder !== oldReminder) changes.reminder_days = newReminder === 0 ? 'none' : `${newReminder} days`;
if (is_archived !== undefined && newArchived !== trip.is_archived) changes.archived = !!newArchived;
const isAdminEdit = userRole === 'admin' && trip.user_id !== userId;
let ownerEmail: string | undefined;
if (Object.keys(changes).length > 0 && isAdminEdit) {
ownerEmail = (db.prepare('SELECT email FROM users WHERE id = ?').get(trip.user_id) as { email: string } | undefined)?.email;
}
const updatedTrip = db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId, tripId });
return { updatedTrip, changes, isAdminEdit, ownerEmail, newTitle, newReminder, oldReminder };
}
// ── Delete ─────────────────────────────────────────────────────────────────
export interface DeleteTripInfo {
tripId: number;
title: string;
ownerId: number;
isAdminDelete: boolean;
ownerEmail?: string;
}
export function deleteTrip(tripId: string | number, userId: number, userRole: string): DeleteTripInfo {
const trip = db.prepare('SELECT title, user_id FROM trips WHERE id = ?').get(tripId) as { title: string; user_id: number } | undefined;
if (!trip) throw new NotFoundError('Trip not found');
const isAdminDelete = userRole === 'admin' && trip.user_id !== userId;
let ownerEmail: string | undefined;
if (isAdminDelete) {
ownerEmail = (db.prepare('SELECT email FROM users WHERE id = ?').get(trip.user_id) as { email: string } | undefined)?.email;
}
db.prepare('DELETE FROM trips WHERE id = ?').run(tripId);
return { tripId: Number(tripId), title: trip.title, ownerId: trip.user_id, isAdminDelete, ownerEmail };
}
// ── Cover image ───────────────────────────────────────────────────────────
export function deleteOldCover(coverImage: string | null | undefined) {
if (!coverImage) return;
const oldPath = path.join(__dirname, '../../', coverImage.replace(/^\//, ''));
const resolvedPath = path.resolve(oldPath);
const uploadsDir = path.resolve(__dirname, '../../uploads');
if (resolvedPath.startsWith(uploadsDir) && fs.existsSync(resolvedPath)) {
fs.unlinkSync(resolvedPath);
}
}
export function updateCoverImage(tripId: string | number, coverUrl: string) {
db.prepare('UPDATE trips SET cover_image=?, updated_at=CURRENT_TIMESTAMP WHERE id=?').run(coverUrl, tripId);
}
export function getTripRaw(tripId: string | number): Trip | undefined {
return db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as Trip | undefined;
}
export function getTripOwner(tripId: string | number): { user_id: number } | undefined {
return db.prepare('SELECT user_id FROM trips WHERE id = ?').get(tripId) as { user_id: number } | undefined;
}
// ── Members ───────────────────────────────────────────────────────────────
export function listMembers(tripId: string | number, tripOwnerId: number) {
const members = db.prepare(`
SELECT u.id, u.username, u.email, u.avatar,
CASE WHEN u.id = ? THEN 'owner' ELSE 'member' END as role,
m.added_at,
ib.username as invited_by_username
FROM trip_members m
JOIN users u ON u.id = m.user_id
LEFT JOIN users ib ON ib.id = m.invited_by
WHERE m.trip_id = ?
ORDER BY m.added_at ASC
`).all(tripOwnerId, tripId) as { id: number; username: string; email: string; avatar: string | null; role: string; added_at: string; invited_by_username: string | null }[];
const owner = db.prepare('SELECT id, username, email, avatar FROM users WHERE id = ?').get(tripOwnerId) as Pick<User, 'id' | 'username' | 'email' | 'avatar'>;
return {
owner: { ...owner, role: 'owner', avatar_url: owner.avatar ? `/uploads/avatars/${owner.avatar}` : null },
members: members.map(m => ({ ...m, avatar_url: m.avatar ? `/uploads/avatars/${m.avatar}` : null })),
};
}
export interface AddMemberResult {
member: { id: number; username: string; email: string; avatar?: string | null; role: string; avatar_url: string | null };
targetUserId: number;
tripTitle: string;
}
export function addMember(tripId: string | number, identifier: string, tripOwnerId: number, invitedByUserId: number): AddMemberResult {
if (!identifier) throw new ValidationError('Email or username required');
const target = db.prepare(
'SELECT id, username, email, avatar FROM users WHERE email = ? OR username = ?'
).get(identifier.trim(), identifier.trim()) as Pick<User, 'id' | 'username' | 'email' | 'avatar'> | undefined;
if (!target) throw new NotFoundError('User not found');
if (target.id === tripOwnerId)
throw new ValidationError('Trip owner is already a member');
const existing = db.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(tripId, target.id);
if (existing) throw new ValidationError('User already has access');
db.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(tripId, target.id, invitedByUserId);
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
return {
member: { ...target, role: 'member', avatar_url: target.avatar ? `/uploads/avatars/${target.avatar}` : null },
targetUserId: target.id,
tripTitle: tripInfo?.title || 'Untitled',
};
}
export function removeMember(tripId: string | number, targetUserId: number) {
db.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(tripId, targetUserId);
}
// ── ICS export ────────────────────────────────────────────────────────────
export function exportICS(tripId: string | number): { ics: string; filename: string } {
const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as any;
if (!trip) throw new NotFoundError('Trip not found');
const reservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ?').all(tripId) as any[];
const esc = (s: string) => s
.replace(/\\/g, '\\\\')
.replace(/;/g, '\\;')
.replace(/,/g, '\\,')
.replace(/\r?\n/g, '\\n')
.replace(/\r/g, '');
const fmtDate = (d: string) => d.replace(/-/g, '');
const now = new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
const uid = (id: number, type: string) => `trek-${type}-${id}@trek`;
// Format datetime: handles full ISO "2026-03-30T09:00" and time-only "10:00"
const fmtDateTime = (d: string, refDate?: string) => {
if (d.includes('T')) return d.replace(/[-:]/g, '').split('.')[0];
// Time-only: combine with reference date
if (refDate && d.match(/^\d{2}:\d{2}/)) {
const datePart = refDate.split('T')[0];
return `${datePart}T${d.replace(/:/g, '')}00`.replace(/-/g, '');
}
return d.replace(/[-:]/g, '');
};
let ics = 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//TREK//Travel Planner//EN\r\nCALSCALE:GREGORIAN\r\nMETHOD:PUBLISH\r\n';
ics += `X-WR-CALNAME:${esc(trip.title || 'TREK Trip')}\r\n`;
// Trip as all-day event
if (trip.start_date && trip.end_date) {
const endNext = new Date(trip.end_date + 'T00:00:00');
endNext.setDate(endNext.getDate() + 1);
const endStr = endNext.toISOString().split('T')[0].replace(/-/g, '');
ics += `BEGIN:VEVENT\r\nUID:${uid(trip.id, 'trip')}\r\nDTSTAMP:${now}\r\nDTSTART;VALUE=DATE:${fmtDate(trip.start_date)}\r\nDTEND;VALUE=DATE:${endStr}\r\nSUMMARY:${esc(trip.title || 'Trip')}\r\n`;
if (trip.description) ics += `DESCRIPTION:${esc(trip.description)}\r\n`;
ics += `END:VEVENT\r\n`;
}
// Reservations as events
for (const r of reservations) {
if (!r.reservation_time) continue;
const hasTime = r.reservation_time.includes('T');
const meta = r.metadata ? (typeof r.metadata === 'string' ? JSON.parse(r.metadata) : r.metadata) : {};
ics += `BEGIN:VEVENT\r\nUID:${uid(r.id, 'res')}\r\nDTSTAMP:${now}\r\n`;
if (hasTime) {
ics += `DTSTART:${fmtDateTime(r.reservation_time)}\r\n`;
if (r.reservation_end_time) {
const endDt = fmtDateTime(r.reservation_end_time, r.reservation_time);
if (endDt.length >= 15) ics += `DTEND:${endDt}\r\n`;
}
} else {
ics += `DTSTART;VALUE=DATE:${fmtDate(r.reservation_time)}\r\n`;
}
ics += `SUMMARY:${esc(r.title)}\r\n`;
let desc = r.type ? `Type: ${r.type}` : '';
if (r.confirmation_number) desc += `\nConfirmation: ${r.confirmation_number}`;
if (meta.airline) desc += `\nAirline: ${meta.airline}`;
if (meta.flight_number) desc += `\nFlight: ${meta.flight_number}`;
if (meta.departure_airport) desc += `\nFrom: ${meta.departure_airport}`;
if (meta.arrival_airport) desc += `\nTo: ${meta.arrival_airport}`;
if (meta.train_number) desc += `\nTrain: ${meta.train_number}`;
if (r.notes) desc += `\n${r.notes}`;
if (desc) ics += `DESCRIPTION:${esc(desc)}\r\n`;
if (r.location) ics += `LOCATION:${esc(r.location)}\r\n`;
ics += `END:VEVENT\r\n`;
}
ics += 'END:VCALENDAR\r\n';
const safeFilename = (trip.title || 'trek-trip').replace(/["\r\n]/g, '').replace(/[^\w\s.-]/g, '_');
return { ics, filename: `${safeFilename}.ics` };
}
// ── Custom error types ────────────────────────────────────────────────────
export class NotFoundError extends Error {
constructor(message: string) {
super(message);
this.name = 'NotFoundError';
}
}
export class ValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'ValidationError';
}
}
+665
View File
@@ -0,0 +1,665 @@
import { db } from '../db/database';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface VacayPlan {
id: number;
owner_id: number;
block_weekends: number;
holidays_enabled: number;
holidays_region: string | null;
company_holidays_enabled: number;
carry_over_enabled: number;
}
export interface VacayUserYear {
user_id: number;
plan_id: number;
year: number;
vacation_days: number;
carried_over: number;
}
export interface VacayUser {
id: number;
username: string;
email: string;
}
export interface VacayPlanMember {
id: number;
plan_id: number;
user_id: number;
status: string;
created_at?: string;
}
export interface Holiday {
date: string;
localName?: string;
name?: string;
global?: boolean;
counties?: string[] | null;
}
export interface VacayHolidayCalendar {
id: number;
plan_id: number;
region: string;
label: string | null;
color: string;
sort_order: number;
}
// ---------------------------------------------------------------------------
// Holiday cache (shared in-process)
// ---------------------------------------------------------------------------
const holidayCache = new Map<string, { data: unknown; time: number }>();
const CACHE_TTL = 24 * 60 * 60 * 1000;
// ---------------------------------------------------------------------------
// Color palette for auto-assign
// ---------------------------------------------------------------------------
const COLORS = [
'#6366f1', '#ec4899', '#14b8a6', '#8b5cf6', '#ef4444',
'#3b82f6', '#22c55e', '#06b6d4', '#f43f5e', '#a855f7',
'#10b981', '#0ea5e9', '#64748b', '#be185d', '#0d9488',
];
// ---------------------------------------------------------------------------
// Plan management
// ---------------------------------------------------------------------------
export function getOwnPlan(userId: number): VacayPlan {
let plan = db.prepare('SELECT * FROM vacay_plans WHERE owner_id = ?').get(userId) as VacayPlan | undefined;
if (!plan) {
db.prepare('INSERT INTO vacay_plans (owner_id) VALUES (?)').run(userId);
plan = db.prepare('SELECT * FROM vacay_plans WHERE owner_id = ?').get(userId) as VacayPlan;
const yr = new Date().getFullYear();
db.prepare('INSERT OR IGNORE INTO vacay_years (plan_id, year) VALUES (?, ?)').run(plan.id, yr);
db.prepare('INSERT OR IGNORE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, 0)').run(userId, plan.id, yr);
db.prepare('INSERT OR IGNORE INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)').run(userId, plan.id, '#6366f1');
}
return plan;
}
export function getActivePlan(userId: number): VacayPlan {
const membership = db.prepare(`
SELECT plan_id FROM vacay_plan_members WHERE user_id = ? AND status = 'accepted'
`).get(userId) as { plan_id: number } | undefined;
if (membership) {
return db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(membership.plan_id) as VacayPlan;
}
return getOwnPlan(userId);
}
export function getActivePlanId(userId: number): number {
return getActivePlan(userId).id;
}
export function getPlanUsers(planId: number): VacayUser[] {
const plan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan | undefined;
if (!plan) return [];
const owner = db.prepare('SELECT id, username, email FROM users WHERE id = ?').get(plan.owner_id) as VacayUser;
const members = db.prepare(`
SELECT u.id, u.username, u.email FROM vacay_plan_members m
JOIN users u ON m.user_id = u.id
WHERE m.plan_id = ? AND m.status = 'accepted'
`).all(planId) as VacayUser[];
return [owner, ...members];
}
// ---------------------------------------------------------------------------
// WebSocket notifications
// ---------------------------------------------------------------------------
export function notifyPlanUsers(planId: number, excludeSid: string | undefined, event = 'vacay:update'): void {
try {
const { broadcastToUser } = require('../websocket');
const plan = db.prepare('SELECT owner_id FROM vacay_plans WHERE id = ?').get(planId) as { owner_id: number } | undefined;
if (!plan) return;
const userIds = [plan.owner_id];
const members = db.prepare("SELECT user_id FROM vacay_plan_members WHERE plan_id = ? AND status = 'accepted'").all(planId) as { user_id: number }[];
members.forEach(m => userIds.push(m.user_id));
userIds.forEach(id => broadcastToUser(id, { type: event }, excludeSid));
} catch { /* websocket not available */ }
}
// ---------------------------------------------------------------------------
// Holiday calendar helpers
// ---------------------------------------------------------------------------
export async function applyHolidayCalendars(planId: number): Promise<void> {
const plan = db.prepare('SELECT holidays_enabled FROM vacay_plans WHERE id = ?').get(planId) as { holidays_enabled: number } | undefined;
if (!plan?.holidays_enabled) return;
const calendars = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? ORDER BY sort_order, id').all(planId) as VacayHolidayCalendar[];
if (calendars.length === 0) return;
const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ?').all(planId) as { year: number }[];
for (const cal of calendars) {
const country = cal.region.split('-')[0];
const region = cal.region.includes('-') ? cal.region : null;
for (const { year } of years) {
try {
const cacheKey = `${year}-${country}`;
let holidays = holidayCache.get(cacheKey)?.data as Holiday[] | undefined;
if (!holidays) {
const resp = await fetch(`https://date.nager.at/api/v3/PublicHolidays/${year}/${country}`);
holidays = await resp.json() as Holiday[];
holidayCache.set(cacheKey, { data: holidays, time: Date.now() });
}
const hasRegions = holidays.some((h: Holiday) => h.counties && h.counties.length > 0);
if (hasRegions && !region) continue;
for (const h of holidays) {
if (h.global || !h.counties || (region && h.counties.includes(region))) {
db.prepare('DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?').run(planId, h.date);
db.prepare('DELETE FROM vacay_company_holidays WHERE plan_id = ? AND date = ?').run(planId, h.date);
}
}
} catch { /* API error, skip */ }
}
}
}
export async function migrateHolidayCalendars(planId: number, plan: VacayPlan): Promise<void> {
const existing = db.prepare('SELECT id FROM vacay_holiday_calendars WHERE plan_id = ?').get(planId);
if (existing) return;
if (plan.holidays_enabled && plan.holidays_region) {
db.prepare(
'INSERT INTO vacay_holiday_calendars (plan_id, region, label, color, sort_order) VALUES (?, ?, NULL, ?, 0)'
).run(planId, plan.holidays_region, '#fecaca');
}
}
// ---------------------------------------------------------------------------
// Plan settings
// ---------------------------------------------------------------------------
export interface UpdatePlanBody {
block_weekends?: boolean;
holidays_enabled?: boolean;
holidays_region?: string;
company_holidays_enabled?: boolean;
carry_over_enabled?: boolean;
weekend_days?: string;
}
export async function updatePlan(planId: number, body: UpdatePlanBody, socketId: string | undefined) {
const { block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled, weekend_days } = body;
const updates: string[] = [];
const params: (string | number)[] = [];
if (block_weekends !== undefined) { updates.push('block_weekends = ?'); params.push(block_weekends ? 1 : 0); }
if (holidays_enabled !== undefined) { updates.push('holidays_enabled = ?'); params.push(holidays_enabled ? 1 : 0); }
if (holidays_region !== undefined) { updates.push('holidays_region = ?'); params.push(holidays_region); }
if (company_holidays_enabled !== undefined) { updates.push('company_holidays_enabled = ?'); params.push(company_holidays_enabled ? 1 : 0); }
if (carry_over_enabled !== undefined) { updates.push('carry_over_enabled = ?'); params.push(carry_over_enabled ? 1 : 0); }
if (weekend_days !== undefined) { updates.push('weekend_days = ?'); params.push(String(weekend_days)); }
if (updates.length > 0) {
params.push(planId);
db.prepare(`UPDATE vacay_plans SET ${updates.join(', ')} WHERE id = ?`).run(...params);
}
if (company_holidays_enabled === true) {
const companyDates = db.prepare('SELECT date FROM vacay_company_holidays WHERE plan_id = ?').all(planId) as { date: string }[];
for (const { date } of companyDates) {
db.prepare('DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?').run(planId, date);
}
}
const updatedPlan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan;
await migrateHolidayCalendars(planId, updatedPlan);
await applyHolidayCalendars(planId);
if (carry_over_enabled === false) {
db.prepare('UPDATE vacay_user_years SET carried_over = 0 WHERE plan_id = ?').run(planId);
}
if (carry_over_enabled === true) {
const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ? ORDER BY year').all(planId) as { year: number }[];
const users = getPlanUsers(planId);
for (let i = 0; i < years.length - 1; i++) {
const yr = years[i].year;
const nextYr = years[i + 1].year;
for (const u of users) {
const used = (db.prepare("SELECT COUNT(*) as count FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date LIKE ?").get(u.id, planId, `${yr}-%`) as { count: number }).count;
const config = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?').get(u.id, planId, yr) as VacayUserYear | undefined;
const total = (config ? config.vacation_days : 30) + (config ? config.carried_over : 0);
const carry = Math.max(0, total - used);
db.prepare(`
INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, ?)
ON CONFLICT(user_id, plan_id, year) DO UPDATE SET carried_over = ?
`).run(u.id, planId, nextYr, carry, carry);
}
}
}
notifyPlanUsers(planId, socketId, 'vacay:settings');
const updated = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan;
const updatedCalendars = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? ORDER BY sort_order, id').all(planId) as VacayHolidayCalendar[];
return {
plan: {
...updated,
block_weekends: !!updated.block_weekends,
holidays_enabled: !!updated.holidays_enabled,
company_holidays_enabled: !!updated.company_holidays_enabled,
carry_over_enabled: !!updated.carry_over_enabled,
holiday_calendars: updatedCalendars,
},
};
}
// ---------------------------------------------------------------------------
// Holiday calendars CRUD
// ---------------------------------------------------------------------------
export function addHolidayCalendar(planId: number, region: string, label: string | null, color: string | undefined, sortOrder: number | undefined, socketId: string | undefined) {
const result = db.prepare(
'INSERT INTO vacay_holiday_calendars (plan_id, region, label, color, sort_order) VALUES (?, ?, ?, ?, ?)'
).run(planId, region, label || null, color || '#fecaca', sortOrder ?? 0);
const cal = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ?').get(result.lastInsertRowid) as VacayHolidayCalendar;
notifyPlanUsers(planId, socketId, 'vacay:settings');
return cal;
}
export function updateHolidayCalendar(
calId: number,
planId: number,
body: { region?: string; label?: string | null; color?: string; sort_order?: number },
socketId: string | undefined,
): VacayHolidayCalendar | null {
const cal = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ? AND plan_id = ?').get(calId, planId) as VacayHolidayCalendar | undefined;
if (!cal) return null;
const { region, label, color, sort_order } = body;
const updates: string[] = [];
const params: (string | number | null)[] = [];
if (region !== undefined) { updates.push('region = ?'); params.push(region); }
if (label !== undefined) { updates.push('label = ?'); params.push(label); }
if (color !== undefined) { updates.push('color = ?'); params.push(color); }
if (sort_order !== undefined) { updates.push('sort_order = ?'); params.push(sort_order); }
if (updates.length > 0) {
params.push(calId);
db.prepare(`UPDATE vacay_holiday_calendars SET ${updates.join(', ')} WHERE id = ?`).run(...params);
}
const updated = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ?').get(calId) as VacayHolidayCalendar;
notifyPlanUsers(planId, socketId, 'vacay:settings');
return updated;
}
export function deleteHolidayCalendar(calId: number, planId: number, socketId: string | undefined): boolean {
const cal = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ? AND plan_id = ?').get(calId, planId);
if (!cal) return false;
db.prepare('DELETE FROM vacay_holiday_calendars WHERE id = ?').run(calId);
notifyPlanUsers(planId, socketId, 'vacay:settings');
return true;
}
// ---------------------------------------------------------------------------
// User colors
// ---------------------------------------------------------------------------
export function setUserColor(userId: number, planId: number, color: string | undefined, socketId: string | undefined): void {
db.prepare(`
INSERT INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)
ON CONFLICT(user_id, plan_id) DO UPDATE SET color = excluded.color
`).run(userId, planId, color || '#6366f1');
notifyPlanUsers(planId, socketId, 'vacay:update');
}
// ---------------------------------------------------------------------------
// Invitations
// ---------------------------------------------------------------------------
export function sendInvite(planId: number, inviterId: number, inviterUsername: string, inviterEmail: string, targetUserId: number): { error?: string; status?: number } {
if (targetUserId === inviterId) return { error: 'Cannot invite yourself', status: 400 };
const targetUser = db.prepare('SELECT id, username FROM users WHERE id = ?').get(targetUserId);
if (!targetUser) return { error: 'User not found', status: 404 };
const existing = db.prepare('SELECT id, status FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?').get(planId, targetUserId) as { id: number; status: string } | undefined;
if (existing) {
if (existing.status === 'accepted') return { error: 'Already fused', status: 400 };
if (existing.status === 'pending') return { error: 'Invite already pending', status: 400 };
}
const targetFusion = db.prepare("SELECT id FROM vacay_plan_members WHERE user_id = ? AND status = 'accepted'").get(targetUserId);
if (targetFusion) return { error: 'User is already fused with another plan', status: 400 };
db.prepare('INSERT INTO vacay_plan_members (plan_id, user_id, status) VALUES (?, ?, ?)').run(planId, targetUserId, 'pending');
try {
const { broadcastToUser } = require('../websocket');
broadcastToUser(targetUserId, {
type: 'vacay:invite',
from: { id: inviterId, username: inviterUsername },
planId,
});
} catch { /* websocket not available */ }
// Notify invited user
import('../services/notifications').then(({ notify }) => {
notify({ userId: targetUserId, event: 'vacay_invite', params: { actor: inviterEmail } }).catch(() => {});
});
return {};
}
export function acceptInvite(userId: number, planId: number, socketId: string | undefined): { error?: string; status?: number } {
const invite = db.prepare("SELECT * FROM vacay_plan_members WHERE plan_id = ? AND user_id = ? AND status = 'pending'").get(planId, userId) as VacayPlanMember | undefined;
if (!invite) return { error: 'No pending invite', status: 404 };
db.prepare("UPDATE vacay_plan_members SET status = 'accepted' WHERE id = ?").run(invite.id);
// Migrate data from user's own plan
const ownPlan = db.prepare('SELECT id FROM vacay_plans WHERE owner_id = ?').get(userId) as { id: number } | undefined;
if (ownPlan && ownPlan.id !== planId) {
db.prepare('UPDATE vacay_entries SET plan_id = ? WHERE plan_id = ? AND user_id = ?').run(planId, ownPlan.id, userId);
const ownYears = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ?').all(userId, ownPlan.id) as VacayUserYear[];
for (const y of ownYears) {
db.prepare('INSERT OR IGNORE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, ?, ?)').run(userId, planId, y.year, y.vacation_days, y.carried_over);
}
const colorRow = db.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?').get(userId, ownPlan.id) as { color: string } | undefined;
if (colorRow) {
db.prepare('INSERT OR IGNORE INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)').run(userId, planId, colorRow.color);
}
}
// Auto-assign unique color
const existingColors = (db.prepare('SELECT color FROM vacay_user_colors WHERE plan_id = ? AND user_id != ?').all(planId, userId) as { color: string }[]).map(r => r.color);
const myColor = db.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?').get(userId, planId) as { color: string } | undefined;
const effectiveColor = myColor?.color || '#6366f1';
if (existingColors.includes(effectiveColor)) {
const available = COLORS.find(c => !existingColors.includes(c));
if (available) {
db.prepare(`INSERT INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)
ON CONFLICT(user_id, plan_id) DO UPDATE SET color = excluded.color`).run(userId, planId, available);
}
} else if (!myColor) {
db.prepare('INSERT OR IGNORE INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)').run(userId, planId, effectiveColor);
}
// Ensure user has rows for all plan years
const targetYears = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ?').all(planId) as { year: number }[];
for (const y of targetYears) {
db.prepare('INSERT OR IGNORE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, 0)').run(userId, planId, y.year);
}
notifyPlanUsers(planId, socketId, 'vacay:accepted');
return {};
}
export function declineInvite(userId: number, planId: number, socketId: string | undefined): void {
db.prepare("DELETE FROM vacay_plan_members WHERE plan_id = ? AND user_id = ? AND status = 'pending'").run(planId, userId);
notifyPlanUsers(planId, socketId, 'vacay:declined');
}
export function cancelInvite(planId: number, targetUserId: number): void {
db.prepare("DELETE FROM vacay_plan_members WHERE plan_id = ? AND user_id = ? AND status = 'pending'").run(planId, targetUserId);
try {
const { broadcastToUser } = require('../websocket');
broadcastToUser(targetUserId, { type: 'vacay:cancelled' });
} catch { /* */ }
}
// ---------------------------------------------------------------------------
// Plan dissolution
// ---------------------------------------------------------------------------
export function dissolvePlan(userId: number, socketId: string | undefined): void {
const plan = getActivePlan(userId);
const isOwnerFlag = plan.owner_id === userId;
const allUserIds = getPlanUsers(plan.id).map(u => u.id);
const companyHolidays = db.prepare('SELECT date, note FROM vacay_company_holidays WHERE plan_id = ?').all(plan.id) as { date: string; note: string }[];
if (isOwnerFlag) {
const members = db.prepare("SELECT user_id FROM vacay_plan_members WHERE plan_id = ? AND status = 'accepted'").all(plan.id) as { user_id: number }[];
for (const m of members) {
const memberPlan = getOwnPlan(m.user_id);
db.prepare('UPDATE vacay_entries SET plan_id = ? WHERE plan_id = ? AND user_id = ?').run(memberPlan.id, plan.id, m.user_id);
for (const ch of companyHolidays) {
db.prepare('INSERT OR IGNORE INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)').run(memberPlan.id, ch.date, ch.note);
}
}
db.prepare('DELETE FROM vacay_plan_members WHERE plan_id = ?').run(plan.id);
} else {
const ownPlan = getOwnPlan(userId);
db.prepare('UPDATE vacay_entries SET plan_id = ? WHERE plan_id = ? AND user_id = ?').run(ownPlan.id, plan.id, userId);
for (const ch of companyHolidays) {
db.prepare('INSERT OR IGNORE INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)').run(ownPlan.id, ch.date, ch.note);
}
db.prepare("DELETE FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?").run(plan.id, userId);
}
try {
const { broadcastToUser } = require('../websocket');
allUserIds.filter(id => id !== userId).forEach(id => broadcastToUser(id, { type: 'vacay:dissolved' }));
} catch { /* */ }
}
// ---------------------------------------------------------------------------
// Available users
// ---------------------------------------------------------------------------
export function getAvailableUsers(userId: number, planId: number) {
return db.prepare(`
SELECT u.id, u.username, u.email FROM users u
WHERE u.id != ?
AND u.id NOT IN (SELECT user_id FROM vacay_plan_members WHERE plan_id = ?)
AND u.id NOT IN (SELECT user_id FROM vacay_plan_members WHERE status = 'accepted')
AND u.id NOT IN (SELECT owner_id FROM vacay_plans WHERE id IN (
SELECT plan_id FROM vacay_plan_members WHERE status = 'accepted'
))
ORDER BY u.username
`).all(userId, planId);
}
// ---------------------------------------------------------------------------
// Years
// ---------------------------------------------------------------------------
export function listYears(planId: number): number[] {
const rows = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ? ORDER BY year').all(planId) as { year: number }[];
return rows.map(y => y.year);
}
export function addYear(planId: number, year: number, socketId: string | undefined): number[] {
try {
db.prepare('INSERT INTO vacay_years (plan_id, year) VALUES (?, ?)').run(planId, year);
const plan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan | undefined;
const carryOverEnabled = plan ? !!plan.carry_over_enabled : true;
const users = getPlanUsers(planId);
for (const u of users) {
let carriedOver = 0;
if (carryOverEnabled) {
const prevConfig = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?').get(u.id, planId, year - 1) as VacayUserYear | undefined;
if (prevConfig) {
const used = (db.prepare("SELECT COUNT(*) as count FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date LIKE ?").get(u.id, planId, `${year - 1}-%`) as { count: number }).count;
const total = prevConfig.vacation_days + prevConfig.carried_over;
carriedOver = Math.max(0, total - used);
}
}
db.prepare('INSERT OR IGNORE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, ?)').run(u.id, planId, year, carriedOver);
}
} catch { /* year already exists */ }
notifyPlanUsers(planId, socketId, 'vacay:settings');
return listYears(planId);
}
export function deleteYear(planId: number, year: number, socketId: string | undefined): number[] {
db.prepare('DELETE FROM vacay_years WHERE plan_id = ? AND year = ?').run(planId, year);
db.prepare("DELETE FROM vacay_entries WHERE plan_id = ? AND date LIKE ?").run(planId, `${year}-%`);
db.prepare("DELETE FROM vacay_company_holidays WHERE plan_id = ? AND date LIKE ?").run(planId, `${year}-%`);
notifyPlanUsers(planId, socketId, 'vacay:settings');
return listYears(planId);
}
// ---------------------------------------------------------------------------
// Entries
// ---------------------------------------------------------------------------
export function getEntries(planId: number, year: string) {
const entries = db.prepare(`
SELECT e.*, u.username as person_name, COALESCE(c.color, '#6366f1') as person_color
FROM vacay_entries e
JOIN users u ON e.user_id = u.id
LEFT JOIN vacay_user_colors c ON c.user_id = e.user_id AND c.plan_id = e.plan_id
WHERE e.plan_id = ? AND e.date LIKE ?
`).all(planId, `${year}-%`);
const companyHolidays = db.prepare("SELECT * FROM vacay_company_holidays WHERE plan_id = ? AND date LIKE ?").all(planId, `${year}-%`);
return { entries, companyHolidays };
}
export function toggleEntry(userId: number, planId: number, date: string, socketId: string | undefined): { action: string } {
const existing = db.prepare('SELECT id FROM vacay_entries WHERE user_id = ? AND date = ? AND plan_id = ?').get(userId, date, planId) as { id: number } | undefined;
if (existing) {
db.prepare('DELETE FROM vacay_entries WHERE id = ?').run(existing.id);
notifyPlanUsers(planId, socketId);
return { action: 'removed' };
} else {
db.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)').run(planId, userId, date, '');
notifyPlanUsers(planId, socketId);
return { action: 'added' };
}
}
export function toggleCompanyHoliday(planId: number, date: string, note: string | undefined, socketId: string | undefined): { action: string } {
const existing = db.prepare('SELECT id FROM vacay_company_holidays WHERE plan_id = ? AND date = ?').get(planId, date) as { id: number } | undefined;
if (existing) {
db.prepare('DELETE FROM vacay_company_holidays WHERE id = ?').run(existing.id);
notifyPlanUsers(planId, socketId);
return { action: 'removed' };
} else {
db.prepare('INSERT INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)').run(planId, date, note || '');
db.prepare('DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?').run(planId, date);
notifyPlanUsers(planId, socketId);
return { action: 'added' };
}
}
// ---------------------------------------------------------------------------
// Stats
// ---------------------------------------------------------------------------
export function getStats(planId: number, year: number) {
const plan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan | undefined;
const carryOverEnabled = plan ? !!plan.carry_over_enabled : true;
const users = getPlanUsers(planId);
return users.map(u => {
const used = (db.prepare("SELECT COUNT(*) as count FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date LIKE ?").get(u.id, planId, `${year}-%`) as { count: number }).count;
const config = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?').get(u.id, planId, year) as VacayUserYear | undefined;
const vacationDays = config ? config.vacation_days : 30;
const carriedOver = carryOverEnabled ? (config ? config.carried_over : 0) : 0;
const total = vacationDays + carriedOver;
const remaining = total - used;
const colorRow = db.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?').get(u.id, planId) as { color: string } | undefined;
const nextYearExists = db.prepare('SELECT id FROM vacay_years WHERE plan_id = ? AND year = ?').get(planId, year + 1);
if (nextYearExists && carryOverEnabled) {
const carry = Math.max(0, remaining);
db.prepare(`
INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, ?)
ON CONFLICT(user_id, plan_id, year) DO UPDATE SET carried_over = ?
`).run(u.id, planId, year + 1, carry, carry);
}
return {
user_id: u.id, person_name: u.username, person_color: colorRow?.color || '#6366f1',
year, vacation_days: vacationDays, carried_over: carriedOver,
total_available: total, used, remaining,
};
});
}
export function updateStats(userId: number, planId: number, year: number, vacationDays: number, socketId: string | undefined): void {
db.prepare(`
INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, ?, 0)
ON CONFLICT(user_id, plan_id, year) DO UPDATE SET vacation_days = excluded.vacation_days
`).run(userId, planId, year, vacationDays);
notifyPlanUsers(planId, socketId);
}
// ---------------------------------------------------------------------------
// GET /plan composite
// ---------------------------------------------------------------------------
export function getPlanData(userId: number) {
const plan = getActivePlan(userId);
const activePlanId = plan.id;
const users = getPlanUsers(activePlanId).map(u => {
const colorRow = db.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?').get(u.id, activePlanId) as { color: string } | undefined;
return { ...u, color: colorRow?.color || '#6366f1' };
});
const pendingInvites = db.prepare(`
SELECT m.id, m.user_id, u.username, u.email, m.created_at
FROM vacay_plan_members m JOIN users u ON m.user_id = u.id
WHERE m.plan_id = ? AND m.status = 'pending'
`).all(activePlanId);
const incomingInvites = db.prepare(`
SELECT m.id, m.plan_id, u.username, u.email, m.created_at
FROM vacay_plan_members m
JOIN vacay_plans p ON m.plan_id = p.id
JOIN users u ON p.owner_id = u.id
WHERE m.user_id = ? AND m.status = 'pending'
`).all(userId);
const holidayCalendars = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? ORDER BY sort_order, id').all(activePlanId) as VacayHolidayCalendar[];
return {
plan: {
...plan,
block_weekends: !!plan.block_weekends,
holidays_enabled: !!plan.holidays_enabled,
company_holidays_enabled: !!plan.company_holidays_enabled,
carry_over_enabled: !!plan.carry_over_enabled,
holiday_calendars: holidayCalendars,
},
users,
pendingInvites,
incomingInvites,
isOwner: plan.owner_id === userId,
isFused: users.length > 1,
};
}
// ---------------------------------------------------------------------------
// Holidays (nager.at proxy with cache)
// ---------------------------------------------------------------------------
export async function getCountries(): Promise<{ data?: unknown; error?: string }> {
const cacheKey = 'countries';
const cached = holidayCache.get(cacheKey);
if (cached && Date.now() - cached.time < CACHE_TTL) return { data: cached.data };
try {
const resp = await fetch('https://date.nager.at/api/v3/AvailableCountries');
const data = await resp.json();
holidayCache.set(cacheKey, { data, time: Date.now() });
return { data };
} catch {
return { error: 'Failed to fetch countries' };
}
}
export async function getHolidays(year: string, country: string): Promise<{ data?: unknown; error?: string }> {
const cacheKey = `${year}-${country}`;
const cached = holidayCache.get(cacheKey);
if (cached && Date.now() - cached.time < CACHE_TTL) return { data: cached.data };
try {
const resp = await fetch(`https://date.nager.at/api/v3/PublicHolidays/${year}/${country}`);
const data = await resp.json();
holidayCache.set(cacheKey, { data, time: Date.now() });
return { data };
} catch {
return { error: 'Failed to fetch holidays' };
}
}
+440
View File
@@ -0,0 +1,440 @@
import fetch from 'node-fetch';
// ── Interfaces ──────────────────────────────────────────────────────────
export interface WeatherResult {
temp: number;
temp_max?: number;
temp_min?: number;
main: string;
description: string;
type: string;
sunrise?: string | null;
sunset?: string | null;
precipitation_sum?: number;
precipitation_probability_max?: number;
wind_max?: number;
hourly?: HourlyEntry[];
error?: string;
}
export interface HourlyEntry {
hour: number;
temp: number;
precipitation: number;
precipitation_probability: number;
main: string;
wind: number;
humidity: number;
}
interface OpenMeteoForecast {
error?: boolean;
reason?: string;
current?: { temperature_2m: number; weathercode: number };
daily?: {
time: string[];
temperature_2m_max: number[];
temperature_2m_min: number[];
weathercode: number[];
precipitation_sum?: number[];
precipitation_probability_max?: number[];
windspeed_10m_max?: number[];
sunrise?: string[];
sunset?: string[];
};
hourly?: {
time: string[];
temperature_2m: number[];
precipitation_probability?: number[];
precipitation?: number[];
weathercode?: number[];
windspeed_10m?: number[];
relativehumidity_2m?: number[];
};
}
// ── WMO code mappings ───────────────────────────────────────────────────
const WMO_MAP: Record<number, string> = {
0: 'Clear', 1: 'Clear', 2: 'Clouds', 3: 'Clouds',
45: 'Fog', 48: 'Fog',
51: 'Drizzle', 53: 'Drizzle', 55: 'Drizzle', 56: 'Drizzle', 57: 'Drizzle',
61: 'Rain', 63: 'Rain', 65: 'Rain', 66: 'Rain', 67: 'Rain',
71: 'Snow', 73: 'Snow', 75: 'Snow', 77: 'Snow',
80: 'Rain', 81: 'Rain', 82: 'Rain',
85: 'Snow', 86: 'Snow',
95: 'Thunderstorm', 96: 'Thunderstorm', 99: 'Thunderstorm',
};
const WMO_DESCRIPTION_DE: Record<number, string> = {
0: 'Klar', 1: 'Uberwiegend klar', 2: 'Teilweise bewolkt', 3: 'Bewolkt',
45: 'Nebel', 48: 'Nebel mit Reif',
51: 'Leichter Nieselregen', 53: 'Nieselregen', 55: 'Starker Nieselregen',
56: 'Gefrierender Nieselregen', 57: 'Starker gefr. Nieselregen',
61: 'Leichter Regen', 63: 'Regen', 65: 'Starker Regen',
66: 'Gefrierender Regen', 67: 'Starker gefr. Regen',
71: 'Leichter Schneefall', 73: 'Schneefall', 75: 'Starker Schneefall', 77: 'Schneekorner',
80: 'Leichte Regenschauer', 81: 'Regenschauer', 82: 'Starke Regenschauer',
85: 'Leichte Schneeschauer', 86: 'Starke Schneeschauer',
95: 'Gewitter', 96: 'Gewitter mit Hagel', 99: 'Starkes Gewitter mit Hagel',
};
const WMO_DESCRIPTION_EN: Record<number, string> = {
0: 'Clear sky', 1: 'Mainly clear', 2: 'Partly cloudy', 3: 'Overcast',
45: 'Fog', 48: 'Rime fog',
51: 'Light drizzle', 53: 'Drizzle', 55: 'Heavy drizzle',
56: 'Freezing drizzle', 57: 'Heavy freezing drizzle',
61: 'Light rain', 63: 'Rain', 65: 'Heavy rain',
66: 'Freezing rain', 67: 'Heavy freezing rain',
71: 'Light snowfall', 73: 'Snowfall', 75: 'Heavy snowfall', 77: 'Snow grains',
80: 'Light rain showers', 81: 'Rain showers', 82: 'Heavy rain showers',
85: 'Light snow showers', 86: 'Heavy snow showers',
95: 'Thunderstorm', 96: 'Thunderstorm with hail', 99: 'Severe thunderstorm with hail',
};
// ── Cache management ────────────────────────────────────────────────────
const weatherCache = new Map<string, { data: WeatherResult; expiresAt: number }>();
const CACHE_MAX_ENTRIES = 1000;
const CACHE_PRUNE_TARGET = 500;
const CACHE_CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 minutes
setInterval(() => {
const now = Date.now();
for (const [key, entry] of weatherCache) {
if (now > entry.expiresAt) weatherCache.delete(key);
}
if (weatherCache.size > CACHE_MAX_ENTRIES) {
const entries = [...weatherCache.entries()].sort((a, b) => a[1].expiresAt - b[1].expiresAt);
const toDelete = entries.slice(0, entries.length - CACHE_PRUNE_TARGET);
toDelete.forEach(([key]) => weatherCache.delete(key));
}
}, CACHE_CLEANUP_INTERVAL);
const TTL_FORECAST_MS = 60 * 60 * 1000; // 1 hour
const TTL_CURRENT_MS = 15 * 60 * 1000; // 15 minutes
const TTL_CLIMATE_MS = 24 * 60 * 60 * 1000; // 24 hours
function cacheKey(lat: string, lng: string, date?: string): string {
const rlat = parseFloat(lat).toFixed(2);
const rlng = parseFloat(lng).toFixed(2);
return `${rlat}_${rlng}_${date || 'current'}`;
}
function getCached(key: string): WeatherResult | null {
const entry = weatherCache.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
weatherCache.delete(key);
return null;
}
return entry.data;
}
function setCache(key: string, data: WeatherResult, ttlMs: number): void {
weatherCache.set(key, { data, expiresAt: Date.now() + ttlMs });
}
// ── Helpers ─────────────────────────────────────────────────────────────
function estimateCondition(tempAvg: number, precipMm: number): string {
if (precipMm > 5) return tempAvg <= 0 ? 'Snow' : 'Rain';
if (precipMm > 1) return tempAvg <= 0 ? 'Snow' : 'Drizzle';
if (precipMm > 0.3) return 'Clouds';
return tempAvg > 15 ? 'Clear' : 'Clouds';
}
// ── getWeather ──────────────────────────────────────────────────────────
export async function getWeather(
lat: string,
lng: string,
date: string | undefined,
lang: string,
): Promise<WeatherResult> {
const ck = cacheKey(lat, lng, date);
if (date) {
const cached = getCached(ck);
if (cached) return cached;
const targetDate = new Date(date);
const now = new Date();
const diffDays = (targetDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24);
// Forecast range (-1 .. +16 days)
if (diffDays >= -1 && diffDays <= 16) {
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}&daily=temperature_2m_max,temperature_2m_min,weathercode&timezone=auto&forecast_days=16`;
const response = await fetch(url);
const data = await response.json() as OpenMeteoForecast;
if (!response.ok || data.error) {
throw new ApiError(response.status || 500, data.reason || 'Open-Meteo API error');
}
const dateStr = targetDate.toISOString().slice(0, 10);
const idx = (data.daily?.time || []).indexOf(dateStr);
if (idx !== -1) {
const code = data.daily!.weathercode[idx];
const descriptions = lang === 'de' ? WMO_DESCRIPTION_DE : WMO_DESCRIPTION_EN;
const result: WeatherResult = {
temp: Math.round((data.daily!.temperature_2m_max[idx] + data.daily!.temperature_2m_min[idx]) / 2),
temp_max: Math.round(data.daily!.temperature_2m_max[idx]),
temp_min: Math.round(data.daily!.temperature_2m_min[idx]),
main: WMO_MAP[code] || 'Clouds',
description: descriptions[code] || '',
type: 'forecast',
};
setCache(ck, result, TTL_FORECAST_MS);
return result;
}
}
// Climate / archive fallback (far-future dates)
if (diffDays > -1) {
const month = targetDate.getMonth() + 1;
const day = targetDate.getDate();
const refYear = targetDate.getFullYear() - 1;
const startDate = new Date(refYear, month - 1, day - 2);
const endDate = new Date(refYear, month - 1, day + 2);
const startStr = startDate.toISOString().slice(0, 10);
const endStr = endDate.toISOString().slice(0, 10);
const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${lat}&longitude=${lng}&start_date=${startStr}&end_date=${endStr}&daily=temperature_2m_max,temperature_2m_min,precipitation_sum&timezone=auto`;
const response = await fetch(url);
const data = await response.json() as OpenMeteoForecast;
if (!response.ok || data.error) {
throw new ApiError(response.status || 500, data.reason || 'Open-Meteo Climate API error');
}
const daily = data.daily;
if (!daily || !daily.time || daily.time.length === 0) {
return { temp: 0, main: '', description: '', type: '', error: 'no_forecast' };
}
let sumMax = 0, sumMin = 0, sumPrecip = 0, count = 0;
for (let i = 0; i < daily.time.length; i++) {
if (daily.temperature_2m_max[i] != null && daily.temperature_2m_min[i] != null) {
sumMax += daily.temperature_2m_max[i];
sumMin += daily.temperature_2m_min[i];
sumPrecip += daily.precipitation_sum![i] || 0;
count++;
}
}
if (count === 0) {
return { temp: 0, main: '', description: '', type: '', error: 'no_forecast' };
}
const avgMax = sumMax / count;
const avgMin = sumMin / count;
const avgTemp = (avgMax + avgMin) / 2;
const avgPrecip = sumPrecip / count;
const main = estimateCondition(avgTemp, avgPrecip);
const result: WeatherResult = {
temp: Math.round(avgTemp),
temp_max: Math.round(avgMax),
temp_min: Math.round(avgMin),
main,
description: '',
type: 'climate',
};
setCache(ck, result, TTL_CLIMATE_MS);
return result;
}
return { temp: 0, main: '', description: '', type: '', error: 'no_forecast' };
}
// No date supplied -> current weather
const cached = getCached(ck);
if (cached) return cached;
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}&current=temperature_2m,weathercode&timezone=auto`;
const response = await fetch(url);
const data = await response.json() as OpenMeteoForecast;
if (!response.ok || data.error) {
throw new ApiError(response.status || 500, data.reason || 'Open-Meteo API error');
}
const code = data.current!.weathercode;
const descriptions = lang === 'de' ? WMO_DESCRIPTION_DE : WMO_DESCRIPTION_EN;
const result: WeatherResult = {
temp: Math.round(data.current!.temperature_2m),
main: WMO_MAP[code] || 'Clouds',
description: descriptions[code] || '',
type: 'current',
};
setCache(ck, result, TTL_CURRENT_MS);
return result;
}
// ── getDetailedWeather ──────────────────────────────────────────────────
export async function getDetailedWeather(
lat: string,
lng: string,
date: string,
lang: string,
): Promise<WeatherResult> {
const ck = `detailed_${cacheKey(lat, lng, date)}`;
const cached = getCached(ck);
if (cached) return cached;
const targetDate = new Date(date);
const now = new Date();
const diffDays = (targetDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24);
const dateStr = targetDate.toISOString().slice(0, 10);
const descriptions = lang === 'de' ? WMO_DESCRIPTION_DE : WMO_DESCRIPTION_EN;
// Climate / archive path (> 16 days out)
if (diffDays > 16) {
const refYear = targetDate.getFullYear() - 1;
const refDateStr = `${refYear}-${String(targetDate.getMonth() + 1).padStart(2, '0')}-${String(targetDate.getDate()).padStart(2, '0')}`;
const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${lat}&longitude=${lng}`
+ `&start_date=${refDateStr}&end_date=${refDateStr}`
+ `&hourly=temperature_2m,precipitation,weathercode,windspeed_10m,relativehumidity_2m`
+ `&daily=temperature_2m_max,temperature_2m_min,weathercode,precipitation_sum,windspeed_10m_max,sunrise,sunset`
+ `&timezone=auto`;
const response = await fetch(url);
const data = await response.json() as OpenMeteoForecast;
if (!response.ok || data.error) {
throw new ApiError(response.status || 500, data.reason || 'Open-Meteo Climate API error');
}
const daily = data.daily;
const hourly = data.hourly;
if (!daily || !daily.time || daily.time.length === 0) {
return { temp: 0, main: '', description: '', type: '', error: 'no_forecast' };
}
const idx = 0;
const code = daily.weathercode?.[idx];
const avgMax = daily.temperature_2m_max[idx];
const avgMin = daily.temperature_2m_min[idx];
const hourlyData: HourlyEntry[] = [];
if (hourly?.time) {
for (let i = 0; i < hourly.time.length; i++) {
const hour = new Date(hourly.time[i]).getHours();
const hCode = hourly.weathercode?.[i];
hourlyData.push({
hour,
temp: Math.round(hourly.temperature_2m[i]),
precipitation: hourly.precipitation?.[i] || 0,
precipitation_probability: 0,
main: WMO_MAP[hCode!] || 'Clouds',
wind: Math.round(hourly.windspeed_10m?.[i] || 0),
humidity: hourly.relativehumidity_2m?.[i] || 0,
});
}
}
let sunrise: string | null = null, sunset: string | null = null;
if (daily.sunrise?.[idx]) sunrise = daily.sunrise[idx].split('T')[1]?.slice(0, 5);
if (daily.sunset?.[idx]) sunset = daily.sunset[idx].split('T')[1]?.slice(0, 5);
const result: WeatherResult = {
type: 'climate',
temp: Math.round((avgMax + avgMin) / 2),
temp_max: Math.round(avgMax),
temp_min: Math.round(avgMin),
main: WMO_MAP[code!] || estimateCondition((avgMax + avgMin) / 2, daily.precipitation_sum?.[idx] || 0),
description: descriptions[code!] || '',
precipitation_sum: Math.round((daily.precipitation_sum?.[idx] || 0) * 10) / 10,
wind_max: Math.round(daily.windspeed_10m_max?.[idx] || 0),
sunrise,
sunset,
hourly: hourlyData,
};
setCache(ck, result, TTL_CLIMATE_MS);
return result;
}
// Forecast path (<= 16 days)
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}`
+ `&hourly=temperature_2m,precipitation_probability,precipitation,weathercode,windspeed_10m,relativehumidity_2m`
+ `&daily=temperature_2m_max,temperature_2m_min,weathercode,sunrise,sunset,precipitation_probability_max,precipitation_sum,windspeed_10m_max`
+ `&timezone=auto&start_date=${dateStr}&end_date=${dateStr}`;
const response = await fetch(url);
const data = await response.json() as OpenMeteoForecast;
if (!response.ok || data.error) {
throw new ApiError(response.status || 500, data.reason || 'Open-Meteo API error');
}
const daily = data.daily;
const hourly = data.hourly;
if (!daily || !daily.time || daily.time.length === 0) {
return { temp: 0, main: '', description: '', type: '', error: 'no_forecast' };
}
const dayIdx = 0;
const code = daily.weathercode[dayIdx];
const formatTime = (isoStr: string) => {
if (!isoStr) return '';
const d = new Date(isoStr);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
};
const hourlyData: HourlyEntry[] = [];
if (hourly && hourly.time) {
for (let i = 0; i < hourly.time.length; i++) {
const h = new Date(hourly.time[i]).getHours();
hourlyData.push({
hour: h,
temp: Math.round(hourly.temperature_2m[i]),
precipitation_probability: hourly.precipitation_probability![i] || 0,
precipitation: hourly.precipitation![i] || 0,
main: WMO_MAP[hourly.weathercode![i]] || 'Clouds',
wind: Math.round(hourly.windspeed_10m![i] || 0),
humidity: Math.round(hourly.relativehumidity_2m![i] || 0),
});
}
}
const result: WeatherResult = {
type: 'forecast',
temp: Math.round((daily.temperature_2m_max[dayIdx] + daily.temperature_2m_min[dayIdx]) / 2),
temp_max: Math.round(daily.temperature_2m_max[dayIdx]),
temp_min: Math.round(daily.temperature_2m_min[dayIdx]),
main: WMO_MAP[code] || 'Clouds',
description: descriptions[code] || '',
sunrise: formatTime(daily.sunrise![dayIdx]),
sunset: formatTime(daily.sunset![dayIdx]),
precipitation_sum: daily.precipitation_sum![dayIdx] || 0,
precipitation_probability_max: daily.precipitation_probability_max![dayIdx] || 0,
wind_max: Math.round(daily.windspeed_10m_max![dayIdx] || 0),
hourly: hourlyData,
};
setCache(ck, result, TTL_FORECAST_MS);
return result;
}
// ── ApiError ────────────────────────────────────────────────────────────
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}