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,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;
|
||||
}
|
||||
Reference in New Issue
Block a user