Files
trip-plan/docs/API.md
T
grabowski 4cedab0cf6 Route ground legs along real roads via an OSRM directions proxy
- GET /api/directions proxies OSRM (OSRM_URL env, default public demo
  server) with validation, Leaflet-order geometry, 24h capped cache
- Map draws straight lines first, then upgrades ground legs to
  road-following polylines with routed km (marked road) as directions
  arrive; silent fallback to great-circle on failure; air legs unchanged
- README documents OSRM_URL
2026-07-20 10:54:04 +07:00

22 KiB
Raw Blame History

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.jsbetter-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.jshaversineKm(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'
              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)
              auto_ref TEXT,                          -- JSON {from,to} stay ids; non-null = auto-created transport (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 typeflight | 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_modetrain | 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, and auto_ref set to {"from": <earlier stay id>, "to": <later stay id>} (stored JSON, returned parsed; null on every other entry — the marker for "auto-created"). 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.

Regenerating auto-transportsPOST /api/trips/:id/transports/regenerate (any member, no body) reconciles auto transports after stays have been moved/reshuffled:

  1. Compute the desired pairs: stays in chronological (date, id) order → each adjacent pair (earlier, later), skipping pairs whose window (earlier.end_date||date .. later.date, inclusive; skip if inverted/overlapping) contains a manual transport (auto_ref null) or any flight.
  2. Existing auto transports (type='transport', auto_ref non-null) whose {from,to} matches a desired pair are kept but re-synced: date set to the pair's current transition date (later.date), title regenerated to the current "A → B"; transport_mode, price/split, times, details are preserved. Auto transports matching no desired pair are deleted.
  3. Missing desired pairs get a fresh auto transport (same shape as creation-time).

Response: 200 {created, updated, deleted} (counts). Auto transports from databases predating auto_ref have it null and are treated as manual (never touched). Stays MAY overlap in dates (two locations on the same day, e.g. temporarily while reshuffling) — overlapping adjacent pairs simply produce no auto transport, and the calendar renders overlapping stays in stacked band lanes.

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.

"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 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:
    • equalprice is the TOTAL; split equally among effective participants ("rental car 50/50"). paid_by is credited with having paid the total.
    • ownprice 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, auto_ref} where participants is an array of user ids ([] = all members), segments is the parsed array or null, and auto_ref is the parsed {from,to} object or null. auto_ref is server-managed (not accepted in POST/PATCH bodies; editing an auto transport keeps its auto_ref).

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, "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

{
  "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_namename, parse lat/lon to numbers. On upstream failure return 502 {"error":"geocoding unavailable"}. Cache identical queries in-memory for 10 minutes.

Directions proxy (OSRM)

GET /api/directions?from=<lat>,<lng>&to=<lat>,<lng>200 {km, geometry} (requires auth).

  • Proxies ${OSRM_URL}/route/v1/driving/{fromLng},{fromLat};{toLng},{toLat}?overview=full&geometries=geojson server-side. OSRM_URL env var, default https://router.project-osrm.org (the public demo server — fine for light personal use; self-hosters can point it at their own OSRM). Send the same User-Agent header as the geocode proxy.
  • Validation: from/to must each be lat,lng with finite numbers in range (lat [-90,90], lng [-180,180]) → else 400. Upstream failure, non-Ok OSRM code, or no route → 502 {"error":"directions unavailable"}.
  • Response km = route distance / 1000 rounded to 1 decimal; geometry = the GeoJSON coordinates converted to [[lat,lng], …] (Leaflet order).
  • In-memory cache: key = both coord pairs rounded to 5 decimals, TTL 24 h, cap ~500 entries (evict oldest).

Consumer contract (map): the frontend requests directions for each mode: "ground" route leg and, on success, draws the road-following polyline instead of the straight line and shows the routed km for that leg ("via road"); on 502/failure it keeps the straight great-circle line silently. Air legs stay straight/dashed. /route itself (and summary.kmDriven) remains great-circle — the server does not call OSRM during route computation.

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.