Entries gain an optional inclusive end_date (backfilled via migration): flights and travel longer than 24h show a continuation marker on following days, and a new stay entry type marks a time frame in one area (e.g. 3 days Venice) rendered as continuous bands across the calendar weeks. Stays feed the map route as stops; the route summary gains stays count and a chronological areas list with day spans. 49 API tests.
386 lines
13 KiB
JavaScript
386 lines
13 KiB
JavaScript
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;
|
|
}
|