Initial release: collaborative trip planner
Multi-user trip planning web app in a single Docker container. Mullvad-style token accounts, trip sharing via join codes, day-by-day calendar with typed entries (activity, hotel, travel, flight, rental car, immigration, note), multi-leg flight segments with bundled IATA airport dataset, Leaflet/OSM map with per-leg great-circle km (air vs ground), rough km-driven vs rental included-km comparison, cost splitting with settle-up suggestions, flip-clock departure countdown. Node 20 + Express + SQLite (WAL, additive migrations), vanilla JS SPA, 44 API tests.
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
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';
|
||||
|
||||
const ENTRY_TYPES = new Set([
|
||||
'flight',
|
||||
'immigration',
|
||||
'travel',
|
||||
'hotel',
|
||||
'activity',
|
||||
'rental',
|
||||
'note',
|
||||
]);
|
||||
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;
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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 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 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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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, type, title, details, start_time, end_time,
|
||||
location_name, lat, lng, sort_order, price, paid_by, split_mode, segments, rental)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
tripId,
|
||||
f.date,
|
||||
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
|
||||
);
|
||||
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);
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user