Part 1Stack and repo layout
| Layer | Technology | Notes |
|---|---|---|
| Runtime | Node ≥ 24, TypeScript 5.9, npm workspaces | No Python, no global installs |
| Server | Hono 4 · zod 4 · postgres (postgres.js) | The BFF is the only Odoo caller |
| Client | React 19 · Vite 8 · Tailwind CSS 4 | Hand-rolled router, no library |
| Data | Supabase Postgres (local Docker) · RAM store | RLS on, no policy |
| Odoo contract | OpenAPI pinned · openapi-typescript · openapi-fetch · Prism mock | npm run spec:check -w contracts flags a spec change |
| Tests | Vitest 5 (projects server + app) · Testing Library · jsdom | Real fixtures, never invented Odoo responses |
tn-casa-quotation-estimator/ ├── contracts/ @casa/contracts — Odoo spec pinned + zod + generated types │ ├── odoo/estimate-api.v1.json pinned OpenAPI (SOURCE.md records when) │ ├── odoo/examples/ captured Odoo responses = fixtures │ └── src/trip.zod.ts TripShape: the only Trip parser ├── bff/ @casa/bff — one Vercel project │ ├── api/index.ts Vercel Function entry (hono/vercel) │ ├── dev-server-entry.ts same app inside Vite (dev:app) │ ├── dev.ts API alone on :8787 (curl, AI team) │ ├── src/app.ts createApp(): middleware + routes │ ├── src/routes/ estimates · share · submit · auth · session · settings · boats │ ├── src/auth/ cookies · owner · credential · key-crypto · rate-limit · fixture/odoo auth │ ├── src/odoo/ estimate gateway · call (timeout, retry) · breaker · cache · fixture │ ├── src/store/ DraftStore: memory.ts | pg.ts (same contract test) │ ├── src/trip/ fill · validate · derive · key · to-odoo │ ├── src/model/ sane (post-check) · redact (by role) │ ├── app/src/ React 19 SPA: pages · components · lib · i18n │ ├── vercel.json · vite.config.ts · vitest.config.ts ├── ai/ @casa/ai — extractor, parked (P5) ├── supabase/migrations/ 4 SQL migrations, RLS on, no policy ├── scripts/ suite.mjs · suites.json · test-record.mjs · qa-accounts.mjs └── docs/ flows/ · ledgers/ · product/ · superpowers/ (spec, plans) · integration/
Part 2Runtime topology
One project, two halves
bff/vercel.json:buildCommand: npm run build:app,outputDirectory: app/dist, functionapi/index.tswithmaxDuration: 15.- Rewrites:
/api/(.*)→/api/index; every other path exceptapi/andassets/→/index.htmlfor the SPA router. - Three entry points build the same app:
api/index.ts(Vercel),dev-server-entry.ts(via@hono/vite-dev-server),dev.ts(API only, port 8787). All three runloadEnv → selectGateway → selectStore → selectAuth → createApp; settings are read once at cold start. npm run dev:app -w bff: Vite hands/api/*to Hono in the same process; router paths are listed inexcludeso a refresh never 404s.
Part 3Life of a priced request
POST / PATCH /api/estimates
| # | Function | What it does | File |
|---|---|---|---|
| 0 | requestId · resolveOwner | Global middleware: attach a requestId, read ubg_auth then ubg_sid; read-only, never issues a cookie | app.ts · auth/owner.ts |
| 1 | ensureOwner | Only write routes issue ubg_sid when missing | auth/owner.ts |
| 2 | credentialFor | guest → ODOO_KEY_GUEST; signed in → decrypt the session key | auth/credential.ts |
| 3 | TripSchema.parse · fillTrip | zod drops unknown keys; six required groups → 422 {fields} | trip/fill.ts |
| 4 | validateTrip | Seven app rules → 422 {code, fields, issues}; room-empty only warns | trip/validate.ts |
| 5 | deriveTrip | Forces guestType from the session role (staff keep the payload); computes bookedDaysAhead in Manila time | trip/derive.ts |
| 6 | gateway.compute | api-key header, 8 s timeout, one retry on 502/503 only, breaker opens for 30 s after 5 failures in 60 s | odoo/estimate.ts · call.ts · breaker.ts |
| 7 | checkComputeSane | Post-check for unexpected zero dive / transport / room revenue → issues | model/sane.ts |
| 8 | redactForRole | By Odoo's response.role, not the session role; strips cost for guests | model/redact.ts |
| 9 | store | newScenario / saveWorkingTrip store the derived trip + redacted model (keyed by tripKey) | store/pg.ts · memory.ts |
Odoo errors go through app.onError: breaker open → 503, timeout → 504, Odoo 5xx → 502, Odoo 4xx → the same code. Each request writes exactly one log line: requestId, route (no real ids), role, status, odooMs; never the trip, sid, cookie or token.
Part 4API reference
Every route lives under /api/* in one Hono app. Not-yours and not-found both return 404 {error:"not found"}, never 403. The error string is Vietnamese for humans; code branches on the HTTP status, code, reason and fields.
| Route | Auth | Body / query | 2xx | Own errors | Odoo | Purpose |
|---|---|---|---|---|---|---|
| GET /api/health | none | — | {ok, mode} | — | 0 | Liveness, never touches Odoo |
| GET /api/warmup | none | — | {warmed, odooMs} | always 200 | 1 · cache | Wakes Odoo when a page opens |
| GET /api/me | optional cookie | — | {role, session, name?, login?} | — | 0 | Who am I |
| POST /api/session/guest | issues ubg_sid | — | {role, session:true} | — | 0 | Get a guest cookie, keep an existing one |
| GET /api/settings | public | — | {display_currency, share_token_ttl_days} | — | 0 | Public settings |
| GET /api/settings/all | staff | — | {settings} | 404 | 0 | All settings |
| PUT /api/settings/:key | staff | {value} | {settings} | 404 · 422 | 0 | Change one setting, reloaded at once |
| GET /api/rates | any | — | RatesResponse | Odoo errors | 1 · cache 60 s | Rate card as returned |
| GET /api/rooms | any | ?check_in&check_out | RoomsResponse | 422 | 1 | Rooms and availability |
| GET /api/boats | any | ?check_in&check_out | {boats} | 422 | 1 · cache 60 s | Boats (fixture: empty) |
| POST /api/auth/register | public · 5/min/IP | {name, email, password, role, agency?, attachments?} | {login, role, verified} | 422 · 429 | 1 | Register through Odoo |
| POST /api/auth/login | public · 5/min/IP | {email, password} | {role, name, login} + ubg_auth cookie | 401 · 422 · 429 · 502 | 1 | Sign in, merge guest drafts |
| POST /api/auth/logout | session | — | {ok:true} | — | 1 | Revoke the key, clear the cookie |
| POST /api/auth/forgot | public · 5/min/IP | {email} | {ok, message}, always the same | 422 · 429 | 1 | Forgot password |
| POST /api/estimates | issues ubg_sid if missing | {trip, ui?} | 201 {id, seq:0, role, pendingVerification, model, retailModel, issues, computedAt, sample} | 422 · 502 · 503 · 504 | 1 | Create a draft, first price |
| GET /api/estimates | cookie | ?scope=all (staff) | {items} | no cookie → {items:[]} | 0 | My trips / every trip |
| GET /api/estimates/:id | owner or staff | — | {id, status, label, workingTrip, workingUi, working, latest} | 404 | 0 | Reopen a draft |
| PATCH /api/estimates/:id | owner | {trip, ui?, save} | {…, saved} | 404 · 422 | 1 | Update price (save:true) |
| POST /api/estimates/:id/commit | owner | — | {id, seq, snapshotId, role, pendingVerification, computedAt} | 404 | 0 | 1 | Save trip: revision + snapshot |
| GET /api/estimates/:id/revisions | owner | — | {items:[{seq, createdAt, hasSnapshot}]} | 404 | 0 | List versions |
| GET /api/estimates/:id/revisions/:seq | owner | — | {seq, trip, snapshot} | 404 | 0 | One version |
| POST /api/estimates/:id/share | owner | — | {url, expiresAt} | 404 · 409 | 0 | Get final quote: issue a link |
| GET /api/share/:token | public; account links need a session | — | {scenarioId? , seq, trip, model, retailModel, computedAt, sample, currency, label} | 401 · 404 · 410 | 0 | The quote page |
| POST /api/estimates/:id/submit | owner with ubg_auth | {seq, contact:{name, email, phone?}} | {state, folioId, orderIds, sample, seq} | 401 · 404 · 409 · 422 · 502 · 503 | 1 · no retry | Send the reservation once |
| GET /api/estimates/:id/submission | owner with session | — | {state, folioId, …} without contact | 401 · 404 | 0 | The "Reservation sent" banner |
The four 422 shapes on POST / PATCH
| Cause | Body |
|---|---|
| Body is not JSON | {error} |
| zod shape error (unknown enum, over a limit, null for an enum) | {error, fields:["guests[0].courses", …]} |
| Missing a price-relevant field | {error, fields:["diveFrom", "guests[1].roomId", …]} |
| App rule at error level | {error, code, fields, issues:[{code, fields, level}]} |
Submit responses
| Situation | HTTP | reason | submission row |
|---|---|---|---|
| No ubg_auth session | 401 | login | none, no cookie issued |
| Not saved / seq mismatch / live row exists | 409 | no-snapshot · stale · already | no new row |
| Gate closed (live, flag off) | 503 | closed | none |
| Odoo accepts | 200 | — | confirmed |
| Odoo error / success:false | 502 | rejected | failed |
| Breaker open, nothing sent | 503 | busy | failed |
| Timeout / network error | 502 | unknown | unknown |
The full contract for the AI channel (Trip, rules, EstimateModel, sample curl): docs/integration/schema.md.
Part 5Data model
Draft store: seven tables
| Table | Holds | Index | Migration |
|---|---|---|---|
| scenario | One trip. owner_role (guest|agent|instructor|staff), owner_ref (sid or odoo:login), source, status (draft|submitted|expired), label, working_trip, working_ui, working_model + retail + key + role + sample + computed_at, expires_at | scenario_owner_idx (owner_role, owner_ref, updated_at desc) | 0922 · 0923 · 0924 |
| revision | One Save: scenario_id, seq, trip, author (human|ai) | unique (scenario_id, seq) | 0922 |
| snapshot | Frozen model of a revision: model (redacted), retail_model, role, sample, rates_version, computed_at, odoo_ms | unique (revision_id) | 0922 · 0923 |
| share_token | Quote link: token_hash (sha256), scenario_id, requires_login, expires_at, revoked_at | share_token_scenario_idx | 0922 · 0923 |
| app_setting | key / value jsonb; seed: draft_ttl_days 90, raw_text_ttl_days 30, share_token_ttl_days null, display_currency PHP | pk key | 0922 |
| user_session | Signed-in session: id (16 bytes), role, odoo_login, display_name, odoo_key_enc (AES-256-GCM), key_expires_at, last_seen_at, revoked_at | user_session_login_idx | 0923 |
| submission | One send: scenario_id, revision_seq, snapshot_id, state, contact (PII), folio_id, order_ids, error, sample, submitted_by_* | submission_one_live (partial unique) · submission_scenario_idx | 0925 |
- RLS is on for all seven tables with no policy: anon/authenticated read 0 rows through PostgREST; only the BFF connects, over
DATABASE_URL(service role). submission_one_live: unique onscenario_idwherestate in (pending, confirmed, unknown); the pg store catches 23505 on that exact index name to answeralready.DraftStore(bff/src/store/types.ts) has two implementations under one test suite;latestSnapshotis the only owner-agnostic read (for public links) and is only called aftergetScenarioin private routes.- Apply new migrations without data loss:
npx -y supabase@latest migration up;db resetdrops and re-applies everything.
Part 6Auth and security
From cookie to price card
| Topic | How |
|---|---|
| Cookies | ubg_sid (guest) and ubg_auth (session) as <id>.<hmac>, HMAC-SHA256 with SESSION_SECRET, compared with timingSafeEqual; HttpOnly, SameSite=Lax, Path=/, Max-Age 30 days, Secure in production. A bad signature counts as no cookie, not a 401. |
| Odoo keys | The key Odoo issues at login is stored in user_session.odoo_key_enc, AES-256-GCM with an HKDF-SHA256 key derived from SESSION_SECRET and a fresh nonce each time. Decrypted only in request memory; never in logs, responses or snapshots. Rotating SESSION_SECRET signs everyone out. |
| Passwords | Passed to Odoo once, never stored or logged. Wrong password and unknown email share one message; forgot always answers the same. |
| Rate limit | register / login / forgot: 5 per minute per IP, a fixed in-memory window (each Vercel instance counts separately: a rough brake only). |
| Redaction | redactForRole uses Odoo's response.role and runs before the snapshot is stored; the quote page redacts again by the viewer's role. pendingVerification = session role ≠ guest && response.role = guest. |
| 404, never 403 | Someone else's draft and a missing id return the same 404 {error:"not found"}: a 403 would confirm the id exists. Staff routes return 404 to non-staff; a non-staff ?scope=all is ignored. |
| Share tokens | 32 random bytes base64url (43 chars); the DB keeps only sha256; TOKEN_SHAPE rejects before hashing; expired returns 410; an account link returns 401 reason:"login" to anonymous viewers. |
| Submit gate | POST /api/estimates/:id/submit calls Odoo only with ODOO_SUBMIT_ENABLED=1, or when both env and gateway are fixture. Off → 503 closed, nothing written. Every live call creates a real folio. |
| Logging | One line per request, maskPII; never the trip, sid, cookie, token, contact or setting values. |
Part 7Client architecture
- Router:
App.tsxreadswindow.location.pathnameand picks a page; no library./·/quotes·/quote/:token·/trip/:id[/estimates|agent|ops]·/trip/:id/reserve·/trip/:id/booking·/signin·/register·/forgot·/ops·/settings. - TripWorkspace: after Plan my trip every screen of the trip sits in one frame with
TripTabs;lib/tabs.tsis a puretabsFor(role), an unknown role falls back to the narrowest set. Tabs share oneuseTrip; switching tabs makes no Odoo call. - useMe:
/api/meonce per page load, a module-level promise cache;resetMeCache()on sign in / sign out. - api.ts: one function per route; every priced call yields
success | invalid | unavailable; an abort is not an error; JS never reads cookies.
Price card state machine (useTrip, F05 D5)
Tabs by role (lib/tabs.ts)
- i18n:
i18n/en.ts(source) andi18n/zh.ts;t(key, vars)through a context; theno-hardcoded-copytest blocks hard-coded strings in components. - Theme: Tailwind 4 tokens in the
@themeblock ofindex.css(navy#04080F, accent#4DC2E8, Playfair Display + DM Sans); the day theme overrides via[data-theme="day"]on<html>, stored in localStorage; print is always black on white. - validateTrip on the client mirrors the server rules, kept in step by a parity test; the client never does maths on money.
Part 8Environment variables
Read by loadEnv() (bff/src/env.ts, zod) from process.env. Real values live only in bff/.env.local (git-ignored) and in Vercel env; this page lists names only.
| Name | Shape | Purpose | When |
|---|---|---|---|
| FIXTURE_MODE | 0 | 1 | 1 = use captured Odoo responses, no network | all |
| SESSION_SECRET | ≥ 32 chars | Signs cookies and derives the key that encrypts Odoo keys; required even in fixture | required |
| DATABASE_URL | url | Draft store Postgres; absent → RAM store + one warning | optional |
| ODOO_BASE_URL | url | The estimate-api base URL | required when FIXTURE_MODE=0 |
| ODOO_KEY_GUEST | string | Guest-role service key for anonymous visitors | real mode |
| ODOO_KEY_AGENT · ODOO_KEY_STAFF | string | Legacy per-role keys, optional; signed-in users use their own key | optional |
| ODOO_API_KEY_HEADER | api-key | Odoo auth header name | default |
| ODOO_TIMEOUT_COMPUTE_MS | 8000 | Time budget for compute, retries included | default |
| ODOO_TIMEOUT_SUBMIT_MS | 20000 | Timeout for booking/submit | default |
| ODOO_SUBMIT_ENABLED | 0 | 1 | Safety gate for live submit; only with the lead's and the Odoo side's consent | default 0 |
| NODE_ENV | production | Turns on the Secure cookie flag | Vercel |
Part 9Testing
- Vitest 5, two projects in
bff/vitest.config.ts:server(node,test/**/*.test.ts) andapp(jsdom + React,app/test/**). - Phase suites:
scripts/suites.jsonmaps globs forp0,p1,p2,p3,p4; runnpm run suite -- p1orall. The runner expands globs itself (Vitest positional args are substring filters, not globs). - test:record:
npm run test:record -- --suite all --label "…"runs typecheck + suite, writes one row todocs/ledgers/test-log.mdand a detail file indocs/ledgers/test-runs/; exits 1 when red but still records. - Real fixtures: tests that pin Odoo behaviour use files in
contracts/odoo/examples/; a stale fixture is recaptured, never hand-edited. - Store contract test: one suite for memory and Postgres; the Postgres half is
skipIf(!DATABASE_URL). The p1/p3 Postgres tests truncate a shared DB and collide in parallel (B-026): use--no-file-parallelism. - Run one file:
npm exec -w bff -- vitest run <path>(not vitest's-w, which means watch). - Latest all run (25 Sep 2026): 801 pass, 0 fail, 39 skipped.
Part 10Deployment
| Vercel setting | Value |
|---|---|
| Root Directory | bff |
| Include source files outside of the Root Directory | ON (bff imports @casa/contracts and fixture JSON from the root) |
| Preview + Development | FIXTURE_MODE=1, SESSION_SECRET |
| Production | Empty until the Odoo side issues keys; SESSION_SECRET required |
| Check after deploy | curl https://<preview>/api/health → {"ok":true,"mode":"fixture"} |
Pending
- Linking the Vercel project (lead). Never
vercel --prodwithout the lead. - Supabase cloud: the free tier is full, so it runs in local Docker; without
DATABASE_URLon a preview every draft dies when the lambda recycles. - Real keys for real mode, a staff account, permission to submit on staging.
- Settings reload only in the instance that served the PUT; others keep the old values until a cold start (B-027).
Part 11Key decisions
Taken from docs/ledgers/lessons.md (40 D-0xx decisions, 25 L-0xx bugs). Each has a "price if wrong" in the ledger.
| # | Decision |
|---|---|
| D-004 | Real fixtures captured from staging, never invented mocks: invented mocks hid the silent L-001 failure. |
| D-007 | Auth is entirely Odoo's; the app has no user table. |
| D-008 | Anonymous guests share one guest service key; each agent and staff member has their own key. |
| D-010 | Policies that change are shared settings staff edit without a deploy. |
| D-011 | Guest quotes are public; agent quotes need sign-in. |
| D-015 | Share tokens are stored as sha256 only; links point at the scenario, not a revision. |
| D-016 | Without DATABASE_URL the RAM store runs, with a warning only. |
| D-018 | Odoo keys are stored server-side with AES-256-GCM, no JWT of our own. |
| D-019 | Redact by Odoo's response.role, not the session role. |
| D-022 | The last model is stored next to the working trip; Save reuses it when tripKey matches, 0 Odoo calls. |
| D-028 | Flow A dropped live pricing: Odoo only gets compute on Plan my trip and Update price. |
| D-031 | Staff read every scenario through dedicated functions; a non-staff scope=all is ignored, not a 403. |
| D-033 | The guestType sent to Odoo is forced by the server from the session role; bookedDaysAhead is computed server-side. |
| D-034 | Validation rules are written once on the server; the client mirrors them, held by a parity test. |
| D-036 | The submission row is written as pending before the submit call and is the idempotency lock itself. |
| D-037 | Submit errors are classified by whether the request left the BFF: Odoo answered → failed, timeout → unknown, breaker → failed. |
| D-040 | Send and Get final quote open only when the latest saved version is the trip on screen (savedIsCurrent). |
Part 12Working conventions
- TDD: red test → green code → refactor, test committed with the code. Before "done":
npm run typecheck+ a green suite. A test that passes against unchanged code is worthless. - Four ledgers: Flows (
docs/flows/Fxx, before coding), Logbook (when a task finishes), Lessons (on every bug or trade-off), Test-log (automatic). A task without a Logbook row is not done. - Diagrams before tasks: a plan that touches data flow has a Diagrams section before its task list. Changing an existing flow amends its figure with
NEW/CHANGED; it is never redrawn from scratch. - Commits: Conventional Commits (
feat|fix|hotfix|docs|chore|refactor|test), format checked by a hook (npm run hooks). Never push tomain: branch, PR, one human approval, squash. - Red lines: never call
booking/submiton staging without an order; never touchpricelists,rates/manifest,PATCH rates; never commit a secret; always send the full Trip. - Casa policy is never decided in code: log a Q-0xx, make it configuration, mark
// Q-0xx pending. - Language: UI copy is English first through the dictionary with 中文 available; internal docs are Vietnamese.