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:
+215
@@ -0,0 +1,215 @@
|
||||
# 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-DD` strings. Times: `HH:MM` 24h strings (optional fields).
|
||||
- Auth: cookie session (`cookie-session`). All endpoints except `account` (create) / `login` require auth → otherwise `401 {"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_name` for humans.
|
||||
- Trip access: user must be a member of the trip → otherwise `404 {"error":"not found"}` (don't leak existence).
|
||||
- Errors: `4xx/5xx` with body `{"error": "<human readable message>"}`.
|
||||
- IDs are integers.
|
||||
|
||||
## Server layout
|
||||
|
||||
- `src/server/app.js` — builds and **exports** the Express app (`export function createApp(dbPath)` and `export default` a ready app is fine, but `createApp` must exist for tests).
|
||||
- `src/server/index.js` — reads env (`PORT` default 3000, `DATA_DIR` default `./data`, `SESSION_SECRET` default dev value with console warning), ensures DATA_DIR exists, starts listener, serves `public/` statically.
|
||||
- `src/server/db.js` — `better-sqlite3` connection + schema creation (idempotent `CREATE 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)
|
||||
|
||||
```sql
|
||||
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.
|
||||
|
||||
```json
|
||||
"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).
|
||||
|
||||
```json
|
||||
"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 `price` count toward costs.
|
||||
- **Effective participants** of an entry = its `entry_participants` rows, or **all current trip members** if it has none.
|
||||
- `split_mode`:
|
||||
- `equal` — `price` is the TOTAL; split equally among effective participants ("rental car 50/50"). `paid_by` is credited with having paid the total.
|
||||
- `own` — `price` is 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_by` owes and pays the whole price alone (requires `paid_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 with `crypto.randomBytes`, displayed grouped `XXXX-XXXX-XXXX-XXXX`.
|
||||
- Login normalizes input: strip dashes/spaces, uppercase, then compare `sha256(token)` against `users.token_hash`. Only the hash is stored.
|
||||
- `display_name` is auto-generated at account creation as `adjective-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` →
|
||||
|
||||
```json
|
||||
{
|
||||
"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"`, includes `code`).
|
||||
- `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 are `kind:"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.kmDriven` are the per-mode sums (1 decimal). `kmDriven` is 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` = unique `location_name` values in stop order.
|
||||
- km rounded to 1 decimal.
|
||||
|
||||
### Costs & splitting (computed)
|
||||
|
||||
`GET /api/trips/:id/costs` →
|
||||
|
||||
```json
|
||||
{
|
||||
"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).
|
||||
- `equal` with `paid_by` null: the cost still counts into shares and `byType`, but nobody is credited as payer — accumulate that amount into `unassigned` (frontend shows a hint to assign payers).
|
||||
- `own`: adds `price` to each participant's `share` AND `paid` (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; `byType` groups 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-`/api` GETs fall back to `public/index.html` is NOT required — a single `index.html` with 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/me` on load.
|
||||
Reference in New Issue
Block a user