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.
15 KiB
Trip Plan — API Contract
All endpoints are JSON over REST, prefixed with /api. This document is the binding contract between backend and frontend. Do not deviate; extend only by agreement.
Conventions
- Dates:
YYYY-MM-DDstrings. Times:HH:MM24h strings (optional fields). - Auth: cookie session (
cookie-session). All endpoints exceptaccount(create) /loginrequire auth → otherwise401 {"error":"unauthorized"}. - Identity is Mullvad-style: an account is a random account token (the only credential — no username/password). Users additionally have a non-unique, editable
display_namefor humans. - Trip access: user must be a member of the trip → otherwise
404 {"error":"not found"}(don't leak existence). - Errors:
4xx/5xxwith body{"error": "<human readable message>"}. - IDs are integers.
Server layout
src/server/app.js— builds and exports the Express app (export function createApp(dbPath)andexport defaulta ready app is fine, butcreateAppmust exist for tests).src/server/index.js— reads env (PORTdefault 3000,DATA_DIRdefault./data,SESSION_SECRETdefault dev value with console warning), ensures DATA_DIR exists, starts listener, servespublic/statically.src/server/db.js—better-sqlite3connection + schema creation (idempotentCREATE TABLE IF NOT EXISTS).src/server/routes/—auth.js,trips.js,entries.js,geocode.js.src/server/util/distance.js—haversineKm(lat1, lng1, lat2, lng2)returns km (number).
Data model (SQLite)
users (id INTEGER PK, token_hash TEXT UNIQUE NOT NULL, -- sha256 hex of the account token
display_name TEXT NOT NULL, -- auto-generated, editable, NOT unique
created_at TEXT DEFAULT current_timestamp)
trips (id INTEGER PK, name TEXT NOT NULL, start_date TEXT NOT NULL, end_date TEXT NOT NULL,
owner_id INTEGER NOT NULL REFERENCES users(id),
currency TEXT NOT NULL DEFAULT 'USD', -- 3-letter code, display only (no FX)
join_code TEXT UNIQUE NOT NULL, -- share code for joining the trip
created_at TEXT DEFAULT current_timestamp)
trip_members (trip_id INTEGER REFERENCES trips(id), user_id INTEGER REFERENCES users(id),
role TEXT NOT NULL DEFAULT 'editor', -- 'owner' | 'editor'
PRIMARY KEY (trip_id, user_id))
entries (id INTEGER PK, trip_id INTEGER NOT NULL REFERENCES trips(id),
date TEXT NOT NULL, type TEXT NOT NULL,
title TEXT NOT NULL, details TEXT DEFAULT '',
start_time TEXT, end_time TEXT,
location_name TEXT, lat REAL, lng REAL,
sort_order INTEGER NOT NULL DEFAULT 0,
price REAL, -- null = no cost tracked
paid_by INTEGER REFERENCES users(id), -- who paid (null = unassigned)
split_mode TEXT NOT NULL DEFAULT 'equal', -- 'equal' | 'own' | 'payer'
segments TEXT, -- JSON array, flight entries only (see below)
created_at TEXT DEFAULT current_timestamp)
entry_participants (entry_id INTEGER REFERENCES entries(id), user_id INTEGER REFERENCES users(id),
PRIMARY KEY (entry_id, user_id))
-- no rows for an entry = "all trip members participate" (dynamic default)
Entry type ∈ flight | immigration | travel | hotel | activity | rental | note.
Rental car details
rental-type entries can carry structured rental data in entries.rental (TEXT, JSON — same storage pattern as flight segments). null/absent = plain entry.
"rental": {
"brand": "Toyota", "model": "Yaris Cross", "car_type": "SUV",
"booking_ref": "RC-889231", "included_km": 1500,
"pickup": { "date": "2026-08-01", "time": "09:00", "location_name": "CNX Airport",
"lat": 18.77, "lng": 98.96 },
"dropoff": { "date": "2026-08-07", "time": "18:00", "location_name": "Chiang Mai Old Town",
"lat": 18.79, "lng": 98.98 }
}
Validation (POST/PATCH entries): only allowed when type === 'rental' (else 400); all fields optional; brand/model ≤60, car_type ≤40, booking_ref ≤60 chars; included_km null (= unlimited/unknown) or number ≥ 0; pickup/dropoff objects with date (YYYY-MM-DD, required within the object), optional time HH:MM, location_name ≤120, lat/lng both-or-neither in range. PATCH rental: null clears. Entry JSON always includes rental (parsed or null). The entry's own date should be the pickup date (frontend sets this); the entry's price/split fields carry the rental cost as usual.
Summary impact: summary.rentals counts rental entries; summary.includedKm = sum of non-null included_km across rentals, or null when no rental specifies one — the frontend pairs it with kmDriven ("≈ 520 km driven / 1,500 km included"). Rental pickup/dropoff locations do NOT feed the route (use the entry's normal location field for a map point if wanted).
Flight segments
Flight entries can carry structured segments for multi-leg itineraries (e.g. CNX→BKK→DXB→FRA = 3 segments). Stored as JSON in entries.segments, returned parsed. null/absent = no segments (plain entry).
"segments": [
{ "flight_no": "TG103", "dep_time": "10:30", "arr_time": "11:45",
"from": {"code": "CNX", "name": "Chiang Mai Intl", "lat": 18.77, "lng": 98.96},
"to": {"code": "BKK", "name": "Suvarnabhumi", "lat": 13.68, "lng": 100.75} },
{ "flight_no": "EK385", "dep_time": "13:05", "arr_time": "17:10",
"from": {"code": "BKK", "name": "Suvarnabhumi", "lat": 13.68, "lng": 100.75},
"to": {"code": "DXB", "name": "Dubai Intl", "lat": 25.25, "lng": 55.36} }
]
Validation (POST/PATCH entries): only allowed when type === 'flight' (else 400); array of 1–8 objects; each needs from and to with code (2–4 uppercase alphanumerics); name optional ≤80 chars; lat/lng both-or-neither, same ranges as entry lat/lng; flight_no optional ≤12 chars; dep_time/arr_time optional HH:MM. PATCH with segments: null clears them.
Route expansion: an entry whose segments have coordinates contributes airport stops to /route instead of its own lat/lng — from of the first segment, then to of every segment, in order (airports without coords are skipped; consecutive duplicate coords are collapsed). Stops derived this way have "kind": "airport" and include "code". So CNX-BKK-DXB-FRA yields 4 stops and 3 measured legs on the map.
Airports lookup
GET /api/airports?q=<query> → 200 {results: [{code, name, city, country, lat, lng}]} (max 8; requires auth).
Backed by a bundled dataset at src/server/data/airports.json generated from the public-domain OurAirports data (filter: has IATA code + scheduled service; keep code/name/municipality/iso_country/lat/lng). Matching: exact/prefix IATA code match first (case-insensitive), then name/city substring. This makes CNX → Chiang Mai Intl instant and offline.
Cost semantics
- Only entries with non-null
pricecount toward costs. - Effective participants of an entry = its
entry_participantsrows, or all current trip members if it has none. split_mode:equal—priceis the TOTAL; split equally among effective participants ("rental car 50/50").paid_byis credited with having paid the total.own—priceis PER PERSON; each effective participant owes and pays their own ("everyone pays their own flight"). No debt created. Effective total = price × participant count.payer— personal expense:paid_byowes and pays the whole price alone (requirespaid_by; validation error otherwise).
Endpoints
Auth (Mullvad-style account tokens)
| Method & path | Body | Response |
|---|---|---|
POST /api/auth/account |
{} (empty) |
201 {user: {id, display_name}, token: "XXXX-XXXX-XXXX-XXXX"} — creates an account, logs it in. The raw token is returned ONLY here, never again. |
POST /api/auth/login |
{token} |
200 {user}; 401 on unknown token |
POST /api/auth/logout |
— | 204 |
GET /api/auth/me |
— | 200 {user: {id, display_name}} or 401 |
PATCH /api/auth/me |
{display_name} |
200 {user}; validates 1–40 chars after trim |
Token rules:
- 16 chars from the unambiguous alphabet
ABCDEFGHJKMNPQRSTUVWXYZ23456789(no I/L/O/0/1), generated withcrypto.randomBytes, displayed groupedXXXX-XXXX-XXXX-XXXX. - Login normalizes input: strip dashes/spaces, uppercase, then compare
sha256(token)againstusers.token_hash. Only the hash is stored. display_nameis auto-generated at account creation asadjective-animal(e.g.brave-otter) from two small built-in word lists; not unique; user-editable via PATCH.
User JSON shape everywhere: {id, display_name}.
Trips
| Method & path | Body | Response |
|---|---|---|
GET /api/trips |
— | 200 {trips: [{id, name, start_date, end_date, owner_id, currency, role, member_count, entry_count}]} (trips where user is member, newest first) |
POST /api/trips |
{name, start_date, end_date, currency?} |
201 {trip}; validates: name non-empty ≤120 chars, valid dates, end_date >= start_date, range ≤ 365 days, currency (if given) matches ^[A-Z]{3}$ (default USD). Creator becomes member with role owner. |
GET /api/trips/:id |
— | 200 {trip: {id, name, start_date, end_date, owner_id, currency, join_code}, members: [{id, display_name, role}], entries: [entry…]} entries ordered by (date, sort_order, id) |
PATCH /api/trips/:id |
any of {name, start_date, end_date, currency} |
200 {trip} (same validation; entries outside new range are kept) |
DELETE /api/trips/:id |
— | 204 — owner only, else 403. Deletes members + entries too. |
POST /api/trips/join |
{code} |
200 {trip} — joins the trip with that join_code as editor (idempotent: already a member → still 200 {trip}); 404 {"error":"not found"} on unknown code. Code normalized like tokens (strip dashes/spaces, uppercase). |
POST /api/trips/:id/join-code |
— | 200 {trip} — regenerates join_code, owner only (403 otherwise) |
DELETE /api/trips/:id/members/:userId |
— | 204 — owner only (owner cannot remove self) |
join_code: 8 chars, same alphabet as account tokens, generated at trip creation, displayed grouped XXXX-XXXX. Members see it in trip JSON (it's how they invite others); it is NOT a credential — it only grants membership of that one trip.
Entries
| Method & path | Body | Response |
|---|---|---|
POST /api/trips/:id/entries |
{date, type, title, details?, start_time?, end_time?, location_name?, lat?, lng?, sort_order?, price?, paid_by?, split_mode?, participants?, segments?} |
201 {entry}; validates type enum, date format, title non-empty ≤200 chars; lat/lng must both be present or both absent, lat ∈ [-90,90], lng ∈ [-180,180]; price null or number ≥ 0; paid_by null or a trip member's user id; split_mode ∈ equal|own|payer (payer requires paid_by); participants null/[] (= all members) or array of trip-member user ids; segments per "Flight segments" above |
PATCH /api/entries/:id |
any subset of the above | 200 {entry} (member of the entry's trip required; participants replaces the whole set) |
DELETE /api/entries/:id |
— | 204 (also deletes its entry_participants rows) |
Entry JSON shape (always full row): {id, trip_id, date, type, title, details, start_time, end_time, location_name, lat, lng, sort_order, price, paid_by, split_mode, participants, segments} where participants is an array of user ids ([] = all members) and segments is the parsed array or null.
Route & summary (computed)
GET /api/trips/:id/route →
{
"stops": [ {"entryId": 1, "date": "2026-08-01", "type": "flight",
"title": "BKK → CNX", "location_name": "Chiang Mai", "lat": 18.79, "lng": 98.98} ],
"legs": [ {"fromEntryId": 1, "toEntryId": 4, "km": 587.3, "mode": "ground"} ],
"totalKm": 587.3,
"summary": {
"days": 10, "nights": 9,
"flights": 2, "flightSegments": 4, "hotels": 3, "travelLegs": 1, "activities": 4,
"rentals": 1, "includedKm": 1500,
"kmAir": 0, "kmDriven": 587.3,
"locations": ["Bangkok", "Chiang Mai"]
}
}
stops= entries having lat/lng, ordered by(date, sort_order, id); flight entries with coordinate-bearing segments are expanded into airport stops instead (see "Flight segments" —kind: "airport", includescode).legs= consecutive stop pairs; skip zero-distance pairs (< 0.05 km) — still include the stop, just no leg.- Every leg has
mode:"air"when BOTH endpoints arekind:"airport"stops expanded from the SAME flight entry (i.e. actual flight segments);"ground"for everything else (hotel→airport transfers, city-to-city drives).summary.kmAir/summary.kmDrivenare the per-mode sums (1 decimal).kmDrivenis the rough rental-car figure: great-circle, so real road km will be somewhat higher. days= inclusive count from start_date to end_date;nights = days - 1(0 for single-day trips).locations= uniquelocation_namevalues in stop order.- km rounded to 1 decimal.
Costs & splitting (computed)
GET /api/trips/:id/costs →
{
"currency": "USD",
"totalCost": 1450.0,
"byType": { "flight": 800.0, "travel": 300.0, "hotel": 350.0 },
"perUser": [
{ "userId": 1, "displayName": "brave-otter", "share": 725.0, "paid": 950.0, "net": 225.0 },
{ "userId": 2, "displayName": "calm-heron", "share": 725.0, "paid": 500.0, "net": -225.0 }
],
"settlements": [ { "fromUserId": 2, "toUserId": 1, "amount": 225.0 } ],
"unassigned": 0.0
}
Computation (see Cost semantics above):
share= what the user's part of the trip costs;paid= what they actually paid;net = paid - share(positive → others owe them).equalwithpaid_bynull: the cost still counts into shares andbyType, but nobody is credited as payer — accumulate that amount intounassigned(frontend shows a hint to assign payers).own: addspriceto each participant'sshareANDpaid(self-paid, no debt).settlements: minimal-transfer greedy — repeatedly match the largest debtor with the largest creditor until all nets are settled; amounts rounded to 2 decimals, drop transfers < 0.01.totalCost= sum of effective totals of all priced entries;byTypegroups the same by entry type.- All money values rounded to 2 decimals in the response.
Geocoding proxy
GET /api/geocode?q=<query> → 200 {results: [{name, lat, lng}]} (max 5).
Proxies https://nominatim.openstreetmap.org/search?format=jsonv2&limit=5&accept-language=en&q=… server-side with header User-Agent: trip-plan-app/0.1 (self-hosted) (accept-language=en so results come back in Latin script, not the local language). Map Nominatim's display_name→name, parse lat/lon to numbers. On upstream failure return 502 {"error":"geocoding unavailable"}. Cache identical queries in-memory for 10 minutes.
Frontend contract notes
- SPA served from
public/; all non-/apiGETs fall back topublic/index.htmlis NOT required — a singleindex.htmlwith hash-based routing (#/login,#/trips,#/trip/:id) is the expected design, so no server-side fallback is needed. - Leaflet 1.9.x via unpkg CDN in
index.html. - Session cookie is httpOnly; frontend detects auth state via
GET /api/auth/meon load.