Files
trip-plan/src/server/routes/entries.js
T
grabowski e2c3089c25 Add scenic waypoints for drive legs (OSRM via-routing)
- Transport entries carry an optional ordered waypoints array of
  lat/lng/name points; /route attaches them to the ground leg the
  transport bridges, and /api/directions accepts a via param so the
  drawn road route detours through them
- Day editor gains a geocoded "Scenic waypoints" list on transport
  entries; the map draws leg-coloured waypoint dots
- Escape waypoint names in the Leaflet tooltip (stored-XSS fix flagged
  by security review: names are user-typed and Leaflet renders string
  tooltips as HTML)
2026-07-20 14:57:26 +07:00

405 lines
15 KiB
JavaScript

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;
}