Standalone expenses (date, description, category, amount) with the same equal/own/payer split machinery as entry costs, merged into the costs panel and settle-up as a single expense bucket. Self-fetching card with per-day grouping, client-side sorting, and quick-add. CSV export interleaves expenses with priced entries, one share column per member (UTF-8 BOM, RFC 4180, formula-injection guard on text cells). Also fixes trip deletion, which hit a foreign-key violation and rolled back for any trip with checklist items.
408 lines
39 KiB
Markdown
408 lines
39 KiB
Markdown
# 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`, `checklist.js`, `expenses.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)
|
||
auto_ref TEXT, -- JSON {from,to} stay ids; non-null = auto-created transport (see below)
|
||
waypoints TEXT, -- JSON [{lat,lng,name?}] scenic via-points, 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)
|
||
checklist_items (id INTEGER PK, trip_id INTEGER NOT NULL REFERENCES trips(id),
|
||
user_id INTEGER REFERENCES users(id), -- null = shared trip item; else personal to that user
|
||
text TEXT NOT NULL, -- what to pack/do, ≤120 chars
|
||
category TEXT NOT NULL DEFAULT 'General',
|
||
qty INTEGER, -- optional count (null = unspecified)
|
||
checked INTEGER NOT NULL DEFAULT 0, -- 0/1
|
||
checked_by INTEGER REFERENCES users(id),-- who ticked it (null when unchecked)
|
||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||
suggestion_key TEXT, -- non-null = added from packing advice (dedupe key)
|
||
created_at TEXT DEFAULT current_timestamp)
|
||
expenses (id INTEGER PK, trip_id INTEGER NOT NULL REFERENCES trips(id),
|
||
date TEXT NOT NULL, -- YYYY-MM-DD, the day it was spent
|
||
description TEXT NOT NULL, -- what it was, ≤120 chars
|
||
category TEXT NOT NULL DEFAULT 'other', -- fixed enum, see Expenses section
|
||
amount REAL NOT NULL, -- ≥ 0, always in the trip currency
|
||
paid_by INTEGER REFERENCES users(id), -- who paid (null = unassigned)
|
||
split_mode TEXT NOT NULL DEFAULT 'equal', -- 'equal' | 'own' | 'payer' (same semantics as entries)
|
||
created_by INTEGER NOT NULL REFERENCES users(id), -- who logged it
|
||
created_at TEXT DEFAULT current_timestamp)
|
||
expense_participants (expense_id INTEGER REFERENCES expenses(id), user_id INTEGER REFERENCES users(id),
|
||
PRIMARY KEY (expense_id, user_id))
|
||
-- no rows for an expense = "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).
|
||
|
||
**Scenic waypoints** — a transport entry may carry `waypoints`: an ordered JSON array of via-points the road route should pass through (e.g. a mountain pass instead of the highway). Shape `[{"lat": 46.5, "lng": 10.45, "name": "Stelvio Pass"}]`. Validation: only allowed when the effective `type === 'transport'` (else 400 `waypoints are only allowed on transport entries`); array of 0–8 objects; each needs `lat`/`lng` (finite, lat [-90,90], lng [-180,180]); `name` optional string ≤120 chars; `waypoints: null` or `[]` clears them. Entry JSON always includes `waypoints` (parsed array or null; `[]` is normalized to null on store). Not accepted on non-transport types.
|
||
|
||
Waypoints attach to a **route leg** (see Route below): a ground leg between two located stops is drawn through the via-points of the transport entry that bridges it.
|
||
|
||
**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-transports** — `POST /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.
|
||
|
||
```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
|
||
|
||
These rules apply identically to **priced entries** (`entries.price` non-null) and **expenses** (the standalone daily-spending rows — see the Expenses section). For an expense read `amount` for `price` and `expense_participants` for `entry_participants` below.
|
||
|
||
- Only entries with non-null `price` count toward costs. Every expense counts (amount is required).
|
||
- **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, 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, waypoints}` where `participants` is an array of user ids (`[]` = all members), `segments` is the parsed array or `null`, `auto_ref` is the parsed `{from,to}` object or `null`, and `waypoints` is the parsed `[{lat,lng,name?}]` array or `null`. `auto_ref` is server-managed (not accepted in POST/PATCH bodies; editing an auto transport keeps its `auto_ref`).
|
||
|
||
### Checklist (packing list & advice)
|
||
|
||
Each trip has one flat checklist whose items are grouped by a free-text `category` ("Documents", "Clothing", …). Items are either **shared** (`user_id` null — every member sees and can tick them, e.g. "First-aid kit") or **personal** (`user_id` = the owner — only that user sees them, e.g. their own medication). There is no separate list entity: `category` is the grouping.
|
||
|
||
`GET /api/trips/:id/checklist` → `200`
|
||
|
||
```json
|
||
{
|
||
"items": [
|
||
{ "id": 7, "trip_id": 3, "text": "Passport (valid 6+ months)", "category": "Documents",
|
||
"qty": null, "checked": true, "checked_by": 1, "personal": false,
|
||
"user_id": null, "sort_order": 0, "suggestion_key": "doc-passport" },
|
||
{ "id": 9, "trip_id": 3, "text": "T-shirts", "category": "Clothing",
|
||
"qty": 8, "checked": false, "checked_by": null, "personal": true,
|
||
"user_id": 1, "sort_order": 1, "suggestion_key": "clothing-tshirts" }
|
||
],
|
||
"progress": { "total": 2, "checked": 1, "byCategory": [ { "category": "Documents", "total": 1, "checked": 1 } ] }
|
||
}
|
||
```
|
||
|
||
- Returns shared items **plus the caller's own personal items** — never another member's personal items.
|
||
- Item JSON is always the full row plus the derived boolean `personal` (`user_id !== null`); `checked` is a real boolean (not 0/1) and `qty`/`checked_by`/`suggestion_key` are `null` when unset.
|
||
- Order: by `category` first (fixed order `Documents, Clothing, Toiletries, Health, Electronics, Extras`, then any other category alphabetically), then `(sort_order, id)`.
|
||
- `progress.byCategory` follows the same category order and covers only categories present in `items`.
|
||
|
||
| Method & path | Body | Response |
|
||
|---|---|---|
|
||
| `POST /api/trips/:id/checklist` | `{text, category?, qty?, personal?, checked?, sort_order?}` | `201 {item}` — validates: `text` non-empty ≤120 chars after trim; `category` ≤40 chars after trim (default `General`); `qty` null or integer 1–99; `personal` boolean (default `false`) → sets `user_id` to the caller; `checked` boolean (default `false`); `sort_order` integer (default: max within the trip's list + 1). Member of the trip required (else `404`). |
|
||
| `PATCH /api/checklist/:itemId` | any subset of `{text, category, qty, checked, personal, sort_order}` | `200 {item}` — same validation. Ticking (`checked: true`) sets `checked_by` to the caller; `checked: false` clears it. `personal: true` claims the item for the caller (`user_id` = caller), `personal: false` makes it shared (`user_id` null). |
|
||
| `DELETE /api/checklist/:itemId` | — | `204` |
|
||
| `POST /api/trips/:id/checklist/reset` | `{}` | `200 {unchecked: <count>}` — unticks every item **visible to the caller** (shared + own personal) and clears their `checked_by`. For re-using a list on the return trip. |
|
||
|
||
Item routes resolve the trip via the item, then require membership. A non-existent item, an item in a trip the caller is not a member of, **or another user's personal item** all return `404 {"error":"not found"}` (no leaking).
|
||
|
||
**Packing advice (suggestions)** — deterministic, offline rules derived from the trip itself (duration, months, entry types, destination latitudes/countries). No external service, no LLM.
|
||
|
||
`GET /api/trips/:id/checklist/suggestions` → `200`
|
||
|
||
```json
|
||
{
|
||
"suggestions": [
|
||
{ "key": "doc-passport", "text": "Passport (valid 6+ months)", "category": "Documents",
|
||
"qty": null, "reason": "You have 2 flights", "added": true },
|
||
{ "key": "clothing-tshirts", "text": "T-shirts", "category": "Clothing",
|
||
"qty": 8, "reason": "9 nights", "added": false }
|
||
],
|
||
"context": { "days": 10, "nights": 9, "months": [8], "countries": ["Thailand"],
|
||
"climate": ["tropical"], "flights": 2, "rentals": 1, "transportModes": ["ferry"] }
|
||
}
|
||
```
|
||
|
||
- `key` is a stable identifier (kebab-case) — it is what `POST` takes and what gets stored in `checklist_items.suggestion_key`.
|
||
- `added` = an item with that `suggestion_key` already exists among the items visible to the caller (shared or own personal), so the UI can grey it out.
|
||
- `reason` is a short human string explaining why it was suggested ("9 nights", "Ferry crossing", "Thailand uses type A/B/C sockets"). Suggestions are ordered by category (same fixed order as items).
|
||
- Rules live in `src/server/util/packing.js` as a pure `buildSuggestions({trip, entries, days, nights})` so they are unit-testable and stable across calls with the same input. Rough rule set: always-on basics (ID/passport, cards+cash, medication, toothbrush, phone+charger, water bottle, day bag); nights-scaled clothing quantities (`qty = min(nights + 1, 10)` for t-shirts/underwear/socks, laundry kit over 7 nights); flight entries → liquids ≤100 ml, power bank in cabin, check-in done, plus neck pillow/compression socks on long-haul (any air leg > 5000 km); rental entries → driving licence, international driving permit, phone mount; transport modes → ferry ⇒ motion-sickness tablets, train ⇒ snacks + luggage lock; destination latitude/month → tropical (|lat| < 23.5) ⇒ sun cream, insect repellent, rain jacket, rehydration salts; cold (|lat| > 55 year-round, or |lat| ≥ 35 when the trip falls in that hemisphere's winter — the latitude floor stops a December trip to Bangkok being tagged both tropical and cold) ⇒ warm layers, hat + gloves; ≥3 stays ⇒ packing cubes; destination country → power-adapter suggestion naming the socket types (small built-in country table; unknown/mixed ⇒ "Universal travel adapter"). Countries come from the last comma-segment of `location_name` and from flight-segment airport codes. For flights only the **arrival** airport of each segment counts (for both climate and country): the first segment's departure airport is where the traveller starts out, not a destination — counting it would put "warm layers, hat & gloves" and a home-country plug adapter on a December Frankfurt→Bangkok beach trip. Flying back into a cold place later is still covered, since that arrival is a segment's `to`.
|
||
|
||
`POST /api/trips/:id/checklist/suggestions` `{keys: ["doc-passport", …], personal?: false}` → `201 {created: [item…], skipped: ["doc-passport"]}`
|
||
|
||
- Bulk-adds the given suggestions as checklist items (unchecked, `suggestion_key` set, appended in the current suggestion order). `keys` must be a non-empty array of ≤60 strings; an unknown key → `400 {"error":"unknown suggestion key: <key>"}`.
|
||
- Keys already present among the caller's visible items go to `skipped` instead of being duplicated.
|
||
|
||
### Expenses (daily spending log)
|
||
|
||
Standalone quick expenses (lunch, taxi, museum tickets) logged against a **date** without creating a calendar entry. They use the exact same split machinery as entry costs (see Cost semantics) and are merged into `GET /api/trips/:id/costs` and settle-up. All amounts are in the trip currency (no FX).
|
||
|
||
`category` ∈ `food | drinks | transport | activities | shopping | accommodation | other` (fixed enum; default `other`). Display metadata (icon + label + colour) lives in the frontend (`format.js` `expenseCategoryInfo()`), not the API.
|
||
|
||
`GET /api/trips/:id/expenses` → `200`
|
||
|
||
```json
|
||
{
|
||
"expenses": [
|
||
{ "id": 4, "trip_id": 3, "date": "2026-12-06", "description": "Street food dinner",
|
||
"category": "food", "amount": 380.0, "paid_by": 1, "split_mode": "equal",
|
||
"participants": [], "created_by": 1 }
|
||
],
|
||
"summary": {
|
||
"total": 380.0,
|
||
"byCategory": { "food": 380.0 },
|
||
"byDay": [ { "date": "2026-12-06", "total": 380.0 } ]
|
||
}
|
||
}
|
||
```
|
||
|
||
- Expenses ordered by `(date, id)`. `participants` is an array of user ids (`[]` = all members). All members see all expenses (shared trip data, like entries — there are no personal/hidden expenses).
|
||
- `summary.total` = sum of **effective totals** (same rule as costs: `own` counts `amount × participants`, else `amount`). `byCategory` groups the same; `byDay` is ordered by date and covers only dates that have expenses.
|
||
|
||
| Method & path | Body | Response |
|
||
|---|---|---|
|
||
| `POST /api/trips/:id/expenses` | `{date, description, amount, category?, paid_by?, split_mode?, participants?}` | `201 {expense}` — validates: valid date string; `description` non-empty ≤120 chars after trim; `amount` finite number ≥ 0; `category` in the enum (default `other`); `paid_by` null or a trip member's user id; `split_mode` ∈ `equal\|own\|payer` (`payer` requires `paid_by`); `participants` null/[] (= all members) or an array of trip-member user ids. `created_by` = the caller (server-set, not accepted in the body). Member of the trip required (else `404`). |
|
||
| `PATCH /api/expenses/:expenseId` | any subset of the POST fields | `200 {expense}` — same validation; `participants` replaces the whole set. Any trip member may edit any expense (`created_by` is informational and never changes). |
|
||
| `DELETE /api/expenses/:expenseId` | — | `204` (also deletes its expense_participants rows) |
|
||
| `GET /api/trips/:id/expenses/export.csv` | — | `200` CSV download, see below |
|
||
|
||
Expense routes resolve the trip via the expense, then require membership; non-existent expense or non-member → `404 {"error":"not found"}` (no leaking).
|
||
|
||
Server layout: routes in `src/server/routes/expenses.js`; the CSV is built by a pure `buildExpenseCsv({trip, members, rows})` in `src/server/util/expenseCsv.js` so it is unit-testable.
|
||
|
||
**CSV export** — one file covering the trip's complete money picture: every expense **and** every priced calendar entry, one row each, sorted by `(date, id)` with expenses and entries interleaved chronologically.
|
||
|
||
- Headers: `Content-Type: text/csv; charset=utf-8`, `Content-Disposition: attachment; filename="<trip-name-slug>-expenses.csv"` (slug = lowercase, non-alphanumeric runs → `-`, trimmed; fallback `trip-<id>`).
|
||
- Encoding: UTF-8 **with BOM** (so Excel opens it correctly), CRLF line endings, RFC 4180 quoting (quote fields containing `"`, `,`, CR or LF; double embedded quotes). No totals row — data rows only.
|
||
- **Formula-injection guard**: any field whose first character is `=`, `+`, `-`, `@`, tab or CR is prefixed with a single quote `'` before quoting, uniformly across all text columns (description, category, payer/participant/member names). Rationale: trips are multi-user, so another member's text lands in the caller's spreadsheet — without the guard a description like `=HYPERLINK(…)` would execute in Excel. Numeric columns (`amount`, shares) are server-formatted and never guarded.
|
||
- Columns: `date, source, category, description, amount, currency, paid_by, split, participants, share: <member display name>…` (one trailing column per current trip member, in member display order).
|
||
- `source` = `expense` or `entry`.
|
||
- `category` = the expense category, or the entry `type` for entry rows.
|
||
- `description` = expense description / entry title.
|
||
- `amount` = the **effective total** (per Cost semantics), 2 decimals.
|
||
- `paid_by` = display name, empty when unassigned. `split` = the split_mode. `participants` = `all` or semicolon-joined display names of the effective participants.
|
||
- `share: <name>` = that member's share of this row, 2 decimals (0.00 when not a participant) — the per-row breakdown that the Costs panel aggregates.
|
||
|
||
### 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",
|
||
"waypoints": [{"lat": 46.5, "lng": 10.45, "name": "Stelvio Pass"}]} ],
|
||
"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.
|
||
- Each ground leg carries `waypoints`: the via-points of the transport entry that bridges it, or `[]`. Bridging entry = the earliest (by `date`, `id`) `transport` entry with a non-empty `waypoints` array whose `date` falls within `[fromStop.date, toStop.date]` inclusive. Air legs always have `waypoints: []`. The map passes these to the directions proxy's `via` so the drawn road route detours through them; `km` in `/route` stays great-circle regardless (the routed distance is computed client-side).
|
||
- 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, "expense": 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.
|
||
- **Expenses are merged in**: each expense participates exactly like a priced entry (`amount` → price, `expense_participants` → participants) and lands in `byType` under the single key `expense`. `perUser`, `settlements`, `unassigned` and `totalCost` therefore cover entry costs and expenses together — one settle-up for the whole trip. The per-category expense breakdown is NOT here; it lives in `GET /api/trips/:id/expenses` `summary.byCategory`.
|
||
- 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.
|
||
|
||
### Directions proxy (OSRM)
|
||
|
||
`GET /api/directions?from=<lat>,<lng>&to=<lat>,<lng>[&via=<lat>,<lng>|<lat>,<lng>…]` → `200 {km, geometry}` (requires auth).
|
||
|
||
- Optional `via` = up to 8 `lat,lng` pairs separated by `|`, routed in order between `from` and `to` (scenic waypoints). Each pair validated like from/to → any malformed/out-of-range pair or more than 8 → `400`. The cache key includes the via-points.
|
||
- Proxies `${OSRM_URL}/route/v1/driving/{fromLng},{fromLat};{viaLng},{viaLat};…;{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.
|
||
- Expenses UI lives in `public/js/views/expenses.js` (+ `public/css/expenses.css` — styles.css is at its 500-line cap), rendered as a card in the trip detail side column directly below Costs (above Checklist). Self-fetching from `GET .../expenses` (never triggers a whole-trip refresh; after add/edit/delete it re-fetches itself AND tells the Costs panel to refresh). Shows: trip total + per-day grouped rows (day heading with day total; each row = category icon, description, payer, amount) by default; a **sort control** (Date ↑/↓, Amount ↑/↓, Category, Payer — non-date sorts flatten to a single list, pure client-side); a quick-add row (date defaulting to today clamped into the trip range, description, amount, category select, payer, split — same split modes/participants UI pattern as `costForm.js`); edit + delete per row; and an **Export CSV** button that simply navigates to `GET .../expenses/export.csv` (cookie auth makes a plain link work).
|
||
- Checklist UI lives in `public/js/views/checklist.js`, rendered as a card in the trip detail side column (below Costs). It shows a progress bar, items grouped by category with a checkbox / qty / 🔒-personal marker per row, inline add, drag-reorder (`dragdrop.js` `enableReorder`, desktop-only like the rest), an "Uncheck all" action, and a "💡 Suggestions" modal listing the advice with per-item checkboxes and "Add selected". Ticking a box PATCHes optimistically and re-syncs on failure.
|