import express from 'express'; import { isValidDateStr } from '../util/dates.js'; import { membership } from '../util/access.js'; import { ENTRY_COLUMNS, attachParticipants } from '../util/entrySerialize.js'; import { validateSegments } from '../util/segments.js'; import { validateRental } from '../util/rental.js'; import { validateWaypoints } from '../util/waypoints.js'; import { autoTransportTitle } from '../util/autoTransport.js'; const ENTRY_TYPES = new Set(['flight', 'transport', 'activity', 'rental', 'stay', 'note']); const TRANSPORT_MODES = new Set(['train', 'bus', 'ferry', 'taxi', 'drive', 'other']); const SPLIT_MODES = new Set(['equal', 'own', 'payer']); const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/; function validTime(v) { return v === null || v === undefined || (typeof v === 'string' && TIME_RE.test(v)); } function validCoord(v, min, max) { return typeof v === 'number' && Number.isFinite(v) && v >= min && v <= max; } // Validate an entry body. `partial` = true for PATCH (only provided keys checked). // `existing` is the current row (PATCH merges for the payer/paid_by rule). // `memberIds` is a Set of the trip's member user ids. // Returns { error } or { fields, participants, hasParticipants } where // participants is null (= all members) or an array of ids. function validateEntry(body, { partial, existing, memberIds }) { const fields = {}; const has = (k) => k in body; if (!partial || has('type')) { if (!ENTRY_TYPES.has(body.type)) return { error: 'invalid entry type' }; fields.type = body.type; } if (!partial || has('date')) { if (!isValidDateStr(body.date)) { return { error: 'date must be a valid YYYY-MM-DD date' }; } fields.date = body.date; } // end_date: optional inclusive end, null clears, must be >= the (effective) date. // ISO YYYY-MM-DD strings compare correctly lexicographically. const effDate = 'date' in fields ? fields.date : existing?.date; if (has('end_date')) { if (body.end_date === null) { fields.end_date = null; } else { if (!isValidDateStr(body.end_date)) { return { error: 'end_date must be a valid YYYY-MM-DD date' }; } if (effDate && body.end_date < effDate) { return { error: 'end_date must be on or after date' }; } fields.end_date = body.end_date; } } else if (partial && 'date' in fields && existing?.end_date && existing.end_date < fields.date) { // Moving date past a stored end_date would invert the range. return { error: 'end_date would be before the new date; clear or update end_date' }; } if (!partial || has('title')) { if (typeof body.title !== 'string' || body.title.trim() === '') { return { error: 'title is required' }; } if (body.title.length > 200) { return { error: 'title must be at most 200 characters' }; } fields.title = body.title.trim(); } if (has('details')) { if (typeof body.details !== 'string') { return { error: 'details must be a string' }; } fields.details = body.details; } for (const key of ['start_time', 'end_time']) { if (has(key)) { if (!validTime(body[key])) return { error: `${key} must be HH:MM` }; fields[key] = body[key] ?? null; } } if (has('location_name')) { const v = body.location_name; if (v !== null && typeof v !== 'string') { return { error: 'location_name must be a string' }; } fields.location_name = v ?? null; } // lat/lng: both present or both absent. const hasLat = has('lat'); const hasLng = has('lng'); if (hasLat !== hasLng) { return { error: 'lat and lng must both be present or both absent' }; } if (hasLat && hasLng) { const bothNull = body.lat === null && body.lng === null; if (!bothNull) { if (!validCoord(body.lat, -90, 90)) return { error: 'lat must be in [-90, 90]' }; if (!validCoord(body.lng, -180, 180)) { return { error: 'lng must be in [-180, 180]' }; } } fields.lat = bothNull ? null : body.lat; fields.lng = bothNull ? null : body.lng; } if (has('sort_order')) { if (!Number.isInteger(body.sort_order)) { return { error: 'sort_order must be an integer' }; } fields.sort_order = body.sort_order; } // price: null or a number >= 0. if (has('price')) { const v = body.price; if (v !== null && !(typeof v === 'number' && Number.isFinite(v) && v >= 0)) { return { error: 'price must be null or a number >= 0' }; } fields.price = v; } // paid_by: null or a trip-member user id. if (has('paid_by')) { const v = body.paid_by; if (v !== null && !(Number.isInteger(v) && memberIds.has(v))) { return { error: 'paid_by must be null or a trip member id' }; } fields.paid_by = v; } // split_mode: enum. if (has('split_mode')) { if (!SPLIT_MODES.has(body.split_mode)) { return { error: 'split_mode must be one of equal, own, payer' }; } fields.split_mode = body.split_mode; } // 'payer' requires an effective paid_by (merging existing values on PATCH). const effMode = 'split_mode' in fields ? fields.split_mode : existing?.split_mode ?? 'equal'; const effPaidBy = 'paid_by' in fields ? fields.paid_by : existing?.paid_by ?? null; if (effMode === 'payer' && (effPaidBy === null || effPaidBy === undefined)) { return { error: "split_mode 'payer' requires paid_by" }; } // participants: null/[] (= all members) or array of trip-member ids. let participants; if (has('participants')) { const v = body.participants; if (v === null || (Array.isArray(v) && v.length === 0)) { participants = null; } else if (Array.isArray(v)) { for (const id of v) { if (!Number.isInteger(id) || !memberIds.has(id)) { return { error: 'participants must be trip member ids' }; } } participants = [...new Set(v)]; } else { return { error: 'participants must be null or an array of member ids' }; } } // segments: flight-only structured itinerary (null clears them). if (has('segments')) { if (body.segments === null) { fields.segments = null; } else { const effType = 'type' in fields ? fields.type : existing?.type; if (effType !== 'flight') { return { error: 'segments are only allowed on flight entries' }; } const checked = validateSegments(body.segments); if (checked.error) return { error: checked.error }; fields.segments = JSON.stringify(checked.value); } } // rental: rental-only structured details (null clears them). if (has('rental')) { if (body.rental === null) { fields.rental = null; } else { const effType = 'type' in fields ? fields.type : existing?.type; if (effType !== 'rental') { return { error: 'rental details are only allowed on rental entries' }; } const checked = validateRental(body.rental); if (checked.error) return { error: checked.error }; fields.rental = JSON.stringify(checked.value); } } // transport_mode: transport-only (null clears it). if (has('transport_mode')) { if (body.transport_mode === null) { fields.transport_mode = null; } else { const effType = 'type' in fields ? fields.type : existing?.type; if (effType !== 'transport') { return { error: 'transport_mode is only allowed on transport entries' }; } if (!TRANSPORT_MODES.has(body.transport_mode)) { return { error: 'transport_mode must be one of train, bus, ferry, taxi, drive, other' }; } fields.transport_mode = body.transport_mode; } } // waypoints: transport-only scenic via-points (null or [] clears them). if (has('waypoints')) { const v = body.waypoints; if (v === null || (Array.isArray(v) && v.length === 0)) { fields.waypoints = null; } else { const effType = 'type' in fields ? fields.type : existing?.type; if (effType !== 'transport') { return { error: 'waypoints are only allowed on transport entries' }; } const checked = validateWaypoints(v); if (checked.error) return { error: checked.error }; fields.waypoints = JSON.stringify(checked.value); } } return { fields, participants, hasParticipants: has('participants') }; } export default function entriesRoutes(db) { const router = express.Router(); const getEntry = db.prepare(`SELECT ${ENTRY_COLUMNS} FROM entries WHERE id = ?`); const getTripEntries = db.prepare( `SELECT ${ENTRY_COLUMNS} FROM entries WHERE trip_id = ? ORDER BY date, sort_order, id` ); const getEntryRow = db.prepare( 'SELECT trip_id, split_mode, paid_by, type, date, end_date FROM entries WHERE id = ?' ); const getMemberIds = db.prepare('SELECT user_id FROM trip_members WHERE trip_id = ?'); const insertParticipant = db.prepare( 'INSERT OR IGNORE INTO entry_participants (entry_id, user_id) VALUES (?, ?)' ); const clearParticipants = db.prepare('DELETE FROM entry_participants WHERE entry_id = ?'); const getStays = db.prepare( `SELECT id, date, end_date, location_name, title FROM entries WHERE trip_id = ? AND type = 'stay' ORDER BY date, id` ); const existsTransportOrFlightInWindow = db.prepare( `SELECT 1 FROM entries WHERE trip_id = ? AND type IN ('transport', 'flight') AND date >= ? AND date <= ? LIMIT 1` ); const insertAutoTransport = db.prepare( `INSERT INTO entries (trip_id, date, type, title, details, sort_order, split_mode, auto_ref) VALUES (?, ?, 'transport', ?, '', 0, 'equal', ?)` ); const memberIdSet = (tripId) => new Set(getMemberIds.all(tripId).map((r) => r.user_id)); function writeParticipants(entryId, participants) { clearParticipants.run(entryId); if (Array.isArray(participants)) { for (const uid of participants) insertParticipant.run(entryId, uid); } } // Auto-create a transport entry bridging `earlier` -> `later` (both stay // rows) unless a transport/flight already covers the gap, or the window is // inverted (overlapping stays). function maybeCreateAutoTransport(tripId, earlier, later) { const windowStart = earlier.end_date || earlier.date; const windowEnd = later.date; if (windowStart > windowEnd) return; if (existsTransportOrFlightInWindow.get(tripId, windowStart, windowEnd)) return; insertAutoTransport.run( tripId, later.date, autoTransportTitle(earlier, later), JSON.stringify({ from: earlier.id, to: later.id }) ); } // Fires once, on stay creation: bridges the new stay to its nearest // chronological neighbour stay(s) before/after with an auto-transport entry. function autoTransportForNewStay(tripId, newStayId) { const stays = getStays.all(tripId); const idx = stays.findIndex((s) => s.id === newStayId); if (idx === -1) return; const before = idx > 0 ? stays[idx - 1] : null; const after = idx < stays.length - 1 ? stays[idx + 1] : null; if (before) maybeCreateAutoTransport(tripId, before, stays[idx]); if (after) maybeCreateAutoTransport(tripId, stays[idx], after); } // GET /api/trips/:id/entries router.get('/trips/:id/entries', (req, res) => { const tripId = Number(req.params.id); if (!Number.isInteger(tripId) || !membership(db, tripId, req.session.userId)) { return res.status(404).json({ error: 'not found' }); } const entries = getTripEntries.all(tripId).map((r) => attachParticipants(db, r)); res.status(200).json({ entries }); }); // POST /api/trips/:id/entries router.post('/trips/:id/entries', (req, res) => { const tripId = Number(req.params.id); if (!Number.isInteger(tripId) || !membership(db, tripId, req.session.userId)) { return res.status(404).json({ error: 'not found' }); } const check = validateEntry(req.body || {}, { partial: false, existing: null, memberIds: memberIdSet(tripId), }); if (check.error) return res.status(400).json({ error: check.error }); const f = check.fields; const entry = db.transaction(() => { const info = db .prepare( `INSERT INTO entries (trip_id, date, end_date, type, title, details, start_time, end_time, location_name, lat, lng, sort_order, price, paid_by, split_mode, segments, rental, transport_mode, waypoints) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .run( tripId, f.date, f.end_date ?? null, f.type, f.title, f.details ?? '', f.start_time ?? null, f.end_time ?? null, f.location_name ?? null, f.lat ?? null, f.lng ?? null, f.sort_order ?? 0, f.price ?? null, f.paid_by ?? null, f.split_mode ?? 'equal', f.segments ?? null, f.rental ?? null, f.transport_mode ?? null, f.waypoints ?? null ); const id = Number(info.lastInsertRowid); // participants provided as an array -> store rows; null/absent -> all members. if (Array.isArray(check.participants)) writeParticipants(id, check.participants); if (f.type === 'stay') autoTransportForNewStay(tripId, id); return attachParticipants(db, getEntry.get(id)); })(); res.status(201).json({ entry }); }); // PATCH /api/entries/:id router.patch('/entries/:id', (req, res) => { const entryId = Number(req.params.id); const row = Number.isInteger(entryId) ? getEntryRow.get(entryId) : null; if (!row || !membership(db, row.trip_id, req.session.userId)) { return res.status(404).json({ error: 'not found' }); } const check = validateEntry(req.body || {}, { partial: true, existing: row, memberIds: memberIdSet(row.trip_id), }); if (check.error) return res.status(400).json({ error: check.error }); const entry = db.transaction(() => { const keys = Object.keys(check.fields); if (keys.length > 0) { const setClause = keys.map((k) => `${k} = ?`).join(', '); const values = keys.map((k) => check.fields[k]); db.prepare(`UPDATE entries SET ${setClause} WHERE id = ?`).run(...values, entryId); } // participants key present -> replace the whole set (null/[] = all members). if (check.hasParticipants) writeParticipants(entryId, check.participants); return attachParticipants(db, getEntry.get(entryId)); })(); res.status(200).json({ entry }); }); // DELETE /api/entries/:id router.delete('/entries/:id', (req, res) => { const entryId = Number(req.params.id); const row = Number.isInteger(entryId) ? getEntryRow.get(entryId) : null; if (!row || !membership(db, row.trip_id, req.session.userId)) { return res.status(404).json({ error: 'not found' }); } db.transaction(() => { clearParticipants.run(entryId); db.prepare('DELETE FROM entries WHERE id = ?').run(entryId); })(); res.status(204).end(); }); return router; }