import express from 'express'; import { isValidDateStr, daysInclusive } from '../util/dates.js'; import { haversineKm } from '../util/distance.js'; import { membership } from '../util/access.js'; import { computeCosts } from '../util/costs.js'; import { ENTRY_COLUMNS, attachParticipantsAll, parseSegments, parseRental, } from '../util/entrySerialize.js'; import { generateJoinCode, formatJoinCode, normalizeCode } from '../util/token.js'; const MAX_RANGE_DAYS = 365; const MIN_LEG_KM = 0.05; const CURRENCY_RE = /^[A-Z]{3}$/; // Trip JSON with the join_code shown in grouped display form. function tripJson(row) { if (!row) return row; return { ...row, join_code: formatJoinCode(row.join_code) }; } // Airport stops derived from a flight entry's coordinate-bearing segments: // `from` of the first segment then `to` of each, skipping coordless airports // and collapsing consecutive duplicate coordinates. Returns null if none. function airportStopsFor(entry) { const segs = entry.segments; if (!Array.isArray(segs) || segs.length === 0) return null; const candidates = [segs[0].from, ...segs.map((s) => s.to)]; const stops = []; for (const a of candidates) { if (!a || a.lat === null || a.lat === undefined || a.lng === null || a.lng === undefined) { continue; } const prev = stops[stops.length - 1]; if (prev && prev.lat === a.lat && prev.lng === a.lng) continue; stops.push({ code: a.code, name: a.name ?? null, lat: a.lat, lng: a.lng }); } return stops.length ? stops : null; } // Ordered map stops: flight entries with located segments expand into airport // stops; every other located entry is a single stop. function buildStops(entries) { const stops = []; for (const e of entries) { const airports = e.type === 'flight' ? airportStopsFor(e) : null; if (airports) { for (const a of airports) { stops.push({ entryId: e.id, date: e.date, type: e.type, title: e.title, kind: 'airport', code: a.code, location_name: a.name, lat: a.lat, lng: a.lng, }); } } else if (e.lat !== null && e.lng !== null) { stops.push({ entryId: e.id, date: e.date, type: e.type, title: e.title, location_name: e.location_name, lat: e.lat, lng: e.lng, }); } } return stops; } // Validate a {name, start_date, end_date, currency} object, merging with // existing values (for PATCH). Returns { error } or { values }. function validateTripFields(body, existing) { const name = 'name' in body ? body.name : existing?.name; const start = 'start_date' in body ? body.start_date : existing?.start_date; const end = 'end_date' in body ? body.end_date : existing?.end_date; const currency = 'currency' in body ? body.currency : existing?.currency ?? 'USD'; if (typeof name !== 'string' || name.trim() === '') { return { error: 'name is required' }; } if (name.length > 120) { return { error: 'name must be at most 120 characters' }; } if (!isValidDateStr(start)) { return { error: 'start_date must be a valid YYYY-MM-DD date' }; } if (!isValidDateStr(end)) { return { error: 'end_date must be a valid YYYY-MM-DD date' }; } if (end < start) { return { error: 'end_date must be on or after start_date' }; } if (daysInclusive(start, end) > MAX_RANGE_DAYS) { return { error: 'date range must be at most 365 days' }; } if (typeof currency !== 'string' || !CURRENCY_RE.test(currency)) { return { error: 'currency must be a 3-letter uppercase code' }; } return { values: { name: name.trim(), start_date: start, end_date: end, currency }, }; } export default function tripsRoutes(db) { const router = express.Router(); const listForUser = db.prepare(` SELECT t.id, t.name, t.start_date, t.end_date, t.owner_id, t.currency, tm.role, (SELECT COUNT(*) FROM trip_members WHERE trip_id = t.id) AS member_count, (SELECT COUNT(*) FROM entries WHERE trip_id = t.id) AS entry_count FROM trips t JOIN trip_members tm ON tm.trip_id = t.id AND tm.user_id = ? ORDER BY t.created_at DESC, t.id DESC `); const insertTrip = db.prepare( 'INSERT INTO trips (name, start_date, end_date, owner_id, currency, join_code) VALUES (?, ?, ?, ?, ?, ?)' ); const insertMember = db.prepare( 'INSERT INTO trip_members (trip_id, user_id, role) VALUES (?, ?, ?)' ); const getTrip = db.prepare( 'SELECT id, name, start_date, end_date, owner_id, currency, join_code FROM trips WHERE id = ?' ); const getMembers = db.prepare(` SELECT u.id, u.display_name, tm.role FROM trip_members tm JOIN users u ON u.id = tm.user_id WHERE tm.trip_id = ? ORDER BY tm.role = 'owner' DESC, u.display_name `); const getEntries = db.prepare( `SELECT ${ENTRY_COLUMNS} FROM entries WHERE trip_id = ? ORDER BY date, sort_order, id` ); const findTripByJoinCode = db.prepare('SELECT id FROM trips WHERE join_code = ?'); const joinCodeExists = db.prepare('SELECT 1 FROM trips WHERE join_code = ?'); // Generate a join_code not already in use. function uniqueJoinCode() { let code; do { code = generateJoinCode(); } while (joinCodeExists.get(code)); return code; } // Resolve :id as an integer and confirm membership. Sends 404 and returns // null when the trip does not exist or the user is not a member. function requireMember(req, res) { const tripId = Number(req.params.id); if (!Number.isInteger(tripId)) { res.status(404).json({ error: 'not found' }); return null; } const member = membership(db, tripId, req.session.userId); if (!member) { res.status(404).json({ error: 'not found' }); return null; } return { tripId, role: member.role }; } // GET /api/trips router.get('/', (req, res) => { const trips = listForUser.all(req.session.userId); res.status(200).json({ trips }); }); // POST /api/trips router.post('/', (req, res) => { const check = validateTripFields(req.body || {}, null); if (check.error) return res.status(400).json({ error: check.error }); const { name, start_date, end_date, currency } = check.values; const userId = req.session.userId; const trip = db.transaction(() => { const info = insertTrip.run( name, start_date, end_date, userId, currency, uniqueJoinCode() ); const id = Number(info.lastInsertRowid); insertMember.run(id, userId, 'owner'); return getTrip.get(id); })(); res.status(201).json({ trip: tripJson(trip) }); }); // POST /api/trips/join { code } — join by code as editor (idempotent). router.post('/join', (req, res) => { const code = normalizeCode((req.body || {}).code); if (!code) return res.status(404).json({ error: 'not found' }); const found = findTripByJoinCode.get(code); if (!found) return res.status(404).json({ error: 'not found' }); if (!membership(db, found.id, req.session.userId)) { insertMember.run(found.id, req.session.userId, 'editor'); } res.status(200).json({ trip: tripJson(getTrip.get(found.id)) }); }); // GET /api/trips/:id router.get('/:id', (req, res) => { const ctx = requireMember(req, res); if (!ctx) return; res.status(200).json({ trip: tripJson(getTrip.get(ctx.tripId)), members: getMembers.all(ctx.tripId), entries: attachParticipantsAll(db, getEntries.all(ctx.tripId)), }); }); // PATCH /api/trips/:id router.patch('/:id', (req, res) => { const ctx = requireMember(req, res); if (!ctx) return; const existing = getTrip.get(ctx.tripId); const check = validateTripFields(req.body || {}, existing); if (check.error) return res.status(400).json({ error: check.error }); const { name, start_date, end_date, currency } = check.values; db.prepare( 'UPDATE trips SET name = ?, start_date = ?, end_date = ?, currency = ? WHERE id = ?' ).run(name, start_date, end_date, currency, ctx.tripId); res.status(200).json({ trip: tripJson(getTrip.get(ctx.tripId)) }); }); // DELETE /api/trips/:id (owner only) router.delete('/:id', (req, res) => { const ctx = requireMember(req, res); if (!ctx) return; if (ctx.role !== 'owner') { return res.status(403).json({ error: 'only the owner can delete a trip' }); } db.transaction(() => { db.prepare('DELETE FROM entries WHERE trip_id = ?').run(ctx.tripId); db.prepare('DELETE FROM trip_members WHERE trip_id = ?').run(ctx.tripId); db.prepare('DELETE FROM trips WHERE id = ?').run(ctx.tripId); })(); res.status(204).end(); }); // POST /api/trips/:id/join-code (owner only) — regenerate the join code. router.post('/:id/join-code', (req, res) => { const ctx = requireMember(req, res); if (!ctx) return; if (ctx.role !== 'owner') { return res.status(403).json({ error: 'only the owner can regenerate the join code' }); } db.prepare('UPDATE trips SET join_code = ? WHERE id = ?').run( uniqueJoinCode(), ctx.tripId ); res.status(200).json({ trip: tripJson(getTrip.get(ctx.tripId)) }); }); // DELETE /api/trips/:id/members/:userId (owner only) router.delete('/:id/members/:userId', (req, res) => { const ctx = requireMember(req, res); if (!ctx) return; if (ctx.role !== 'owner') { return res.status(403).json({ error: 'only the owner can remove members' }); } const userId = Number(req.params.userId); if (userId === req.session.userId) { return res.status(400).json({ error: 'owner cannot remove themselves' }); } db.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run( ctx.tripId, userId ); res.status(204).end(); }); // GET /api/trips/:id/route (computed legs + summary) router.get('/:id/route', (req, res) => { const ctx = requireMember(req, res); if (!ctx) return; const trip = getTrip.get(ctx.tripId); const allEntries = getEntries .all(ctx.tripId) .map((r) => ({ ...r, segments: parseSegments(r.segments), rental: parseRental(r.rental), })); const stops = buildStops(allEntries); const legs = []; let totalKm = 0; let kmAir = 0; let kmDriven = 0; for (let i = 1; i < stops.length; i++) { const a = stops[i - 1]; const b = stops[i]; const km = haversineKm(a.lat, a.lng, b.lat, b.lng); if (km < MIN_LEG_KM) continue; // "air" only when both endpoints are airport stops from the same flight entry. const isAir = a.kind === 'airport' && b.kind === 'airport' && a.entryId === b.entryId; const mode = isAir ? 'air' : 'ground'; legs.push({ fromEntryId: a.entryId, toEntryId: b.entryId, km: Math.round(km * 10) / 10, mode, }); totalKm += km; if (isAir) kmAir += km; else kmDriven += km; } const countType = (t) => allEntries.filter((e) => e.type === t).length; const flightSegments = allEntries.reduce( (n, e) => n + (e.type === 'flight' && Array.isArray(e.segments) ? e.segments.length : 0), 0 ); // Sum non-null included_km across rentals; null when none specify one. let includedKm = null; for (const e of allEntries) { if (e.type === 'rental' && e.rental && typeof e.rental.included_km === 'number') { includedKm = (includedKm ?? 0) + e.rental.included_km; } } if (includedKm !== null) includedKm = Math.round(includedKm * 10) / 10; // Stay "area blocks" in chronological order (allEntries is date-ordered). const areas = allEntries .filter((e) => e.type === 'stay') .map((e) => ({ name: e.location_name || e.title, days: e.end_date ? daysInclusive(e.date, e.end_date) : 1, })); const days = daysInclusive(trip.start_date, trip.end_date); const locations = []; for (const s of stops) { if (s.location_name && !locations.includes(s.location_name)) { locations.push(s.location_name); } } res.status(200).json({ stops, legs, totalKm: Math.round(totalKm * 10) / 10, summary: { days, nights: Math.max(0, days - 1), flights: countType('flight'), flightSegments, hotels: countType('hotel'), travelLegs: countType('travel'), activities: countType('activity'), rentals: countType('rental'), stays: countType('stay'), areas, kmAir: Math.round(kmAir * 10) / 10, kmDriven: Math.round(kmDriven * 10) / 10, includedKm, locations, }, }); }); // GET /api/trips/:id/costs (computed cost split + settlements) router.get('/:id/costs', (req, res) => { const ctx = requireMember(req, res); if (!ctx) return; const trip = getTrip.get(ctx.tripId); const members = getMembers.all(ctx.tripId); const entries = attachParticipantsAll(db, getEntries.all(ctx.tripId)); res.status(200).json( computeCosts({ currency: trip.currency, members, entries }) ); }); return router; }