Files
trip-plan/docs/API.md
T
grabowski b452430415 Add drag & drop reordering of trips on the dashboard
- trip_members.sort_order (per-user, migrated) drives GET /api/trips order
- PATCH /api/trips/:id/order updates only the caller's membership row
- Trip cards get a drag handle reusing the generalized row-reorder helper;
  clicking the card still navigates
2026-07-20 09:41:43 +07:00

242 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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'
sort_order INTEGER NOT NULL DEFAULT 0, -- per-user dashboard order (drag & drop)
PRIMARY KEY (trip_id, user_id))
entries (id INTEGER PK, trip_id INTEGER NOT NULL REFERENCES trips(id),
date TEXT NOT NULL, -- start date
end_date TEXT, -- optional inclusive end date (multi-day entries; null = single day)
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)
rental TEXT, -- JSON object, rental entries only (see below)
transport_mode TEXT, -- transport 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 | transport | activity | rental | stay | note`.
**Legacy types** (removed 2026-07): `hotel`, `travel`, `immigration`. Idempotent data migrations run at startup: `hotel` rows become `stay`, `travel` rows become `transport`, `immigration` rows become `activity` with the title prefixed `🛂 `. POST/PATCH with a legacy type is a 400 (`invalid entry type`).
### Transport entries
`transport` covers ground/sea travel (the old `travel` type). Optional `transport_mode``train | bus | ferry | taxi | drive | other` (nullable; drives the icon in the UI). Validation: only allowed when the effective `type === 'transport'` (else 400 `transport_mode is only allowed on transport entries`); PATCH `transport_mode: null` clears it. Entry JSON always includes `transport_mode` (string or null).
**Auto-transport between stays**: when a `stay` entry is POSTed and it has a chronological neighbour stay (the nearest stay before and/or after it, ordered by `date`), the server auto-creates one `transport` entry per neighbour pair — titled `"<from short name> → <to short name>"` (short name = first comma-segment of the stay's `location_name`, fallback `title`), dated on the **later** stay's `date`, `transport_mode` null, no price/location, `sort_order` 0. Skipped when any `transport` or `flight` entry already exists with `date` between the earlier stay's end (`end_date` or `date`) and the later stay's `date` (inclusive). One-shot: fires only on stay **creation** (never PATCH), so deleting an auto-created transport does not resurrect it. The POST response is unchanged (`201 {entry}` = the stay); clients should refetch the entry list.
### Multi-day entries & stays
Any entry may carry an optional `end_date` (inclusive, `YYYY-MM-DD`, must be ≥ `date`; PATCH `end_date: null` clears it). Use cases:
- **Flights/travel longer than 24 h** — dep on `date` at `start_time`, arr on `end_date` at `end_time`.
- **`stay` entries ("area blocks")** — "3 days Venice", "7 days Berlin": a location plus a date range. The frontend renders stays as continuous bands across the calendar days (all-day-event style), not as chips; other multi-day entries show a chip on the start day and lightweight "…continues" markers on the days up to `end_date`.
Stays behave like normal entries otherwise: optional geocoded location (feeds the route as one stop, positioned at the start date), optional price/split (e.g. an apartment for the whole block).
Summary additions: `stays` (count) and `areas` — stay entries in chronological order as `[{ "name": "Venice", "days": 3 }]` where `name` is the stay's `location_name` (fallback: title) and `days` is the inclusive span (`end_date` null → 1). Example: `"areas": [{"name":"Venice","days":3},{"name":"Berlin","days":7}]`.
### 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 18 objects; each needs `from` and `to` with `code` (24 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 140 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, sort_order, member_count, entry_count}]}` (trips where user is member, ordered by the caller's `trip_members.sort_order`, then newest first for ties) |
| `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) |
| `PATCH /api/trips/:id/order` | `{sort_order}` | `200 {sort_order}` — updates the CALLER's own `trip_members.sort_order` for this trip (any member; integer required, else 400). Per-user: does not affect other members' dashboard order. |
| `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, end_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, end_date null or valid date ≥ date, 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; transport_mode per "Transport entries" above (POSTing a stay may auto-create a transport entry, see same section) |
| `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, end_date, type, title, details, start_time, end_time, location_name, lat, lng, sort_order, price, paid_by, split_mode, participants, segments, rental, transport_mode}` 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, "transports": 1, "activities": 4,
"rentals": 1, "includedKm": 1500,
"stays": 2, "areas": [{"name": "Venice", "days": 3}, {"name": "Berlin", "days": 7}],
"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`). Stops also carry `transport_mode` (the entry's value, or null) so the map can show mode-specific icons.
- `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 (stay→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, "transport": 300.0, "stay": 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.