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:
2026-07-18 23:15:29 +07:00
commit fe89bb2b1c
54 changed files with 8565 additions and 0 deletions
+376
View File
@@ -0,0 +1,376 @@
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;
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'),
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;
}