Guidance for building and maintaining this project. Read this before writing code, and keep it current as decisions change.
A ferry / sea-travel ticketing system for the Philippines. Passengers browse voyages, reserve seats online, and pay over-the-counter (OTC) at a terminal or partner agent. Staff confirm payment, issue e-tickets, and manage voyages and Coast Guard manifests.
Scale target: ~20,000 passengers/day sustained, with large seasonal peaks (Holy Week, Christmas, Undas/All Saints') that can reach ~10× normal load. Design every read path for caching and every write path for correctness under concurrency.
Guiding principles — in priority order when they conflict:
- Correctness — never oversell a voyage; never lose a confirmed booking.
- Security & privacy — passenger PII is regulated (RA 10173).
- Accessibility — WCAG 2.1 AA is a requirement, not a nice-to-have.
- Performance — fast on low-end Android phones over spotty mobile data.
- Offline resilience — boarding passes must work without signal at the port.
| Concern | Choice | Notes |
|---|---|---|
| Framework | Next.js 15 (App Router, RSC) | Server Components by default; "use client" only when needed. |
| UI runtime | React 19 | |
| Language | TypeScript (strict: true) |
No any; no non-null ! to silence the compiler. |
| Database / Auth / Storage | Supabase (Postgres + Auth + RLS + Storage + Realtime) | RLS on for every table. |
| DB access | @supabase/ssr |
Cookie-based SSR auth; separate server/browser/middleware clients. |
| Connection pooling | Supavisor (transaction mode) | Required for serverless; never hold long-lived connections. |
| Server cache / data | TanStack Query v5 | Client caching, mutations, optimistic updates. |
| Data grids | TanStack Table v8 | Admin: manifests, voyages, bookings. Server-side pagination/sort/filter. |
| Styling | Tailwind CSS v4 | |
| Components | shadcn/ui (Radix primitives) | Accessible by default; prefer over hand-rolled widgets. |
| Validation | Zod | One schema per boundary, shared between client and server. |
| Forms | React Hook Form + Zod resolver | |
| PWA | Serwist | Maintained successor to next-pwa. Service worker + offline e-tickets. |
| Dates/TZ | Luxon (or date-fns-tz) |
All voyage times in Asia/Manila. Store UTC, render Manila. |
| Errors / analytics | Sentry + Vercel Analytics + Speed Insights | |
| Rate limiting | Upstash Redis (optional) | Reservation + auth endpoints. |
| Hosting | Vercel | Edge CDN; security headers via next.config + middleware. |
| Package manager | pnpm | |
| Quality gates | ESLint, Prettier, Husky, lint-staged, Conventional Commits | |
| Testing | Vitest + Testing Library, Playwright, RLS policy tests |
When adding a dependency, prefer the options above. If you must introduce something new, note why here.
app/
(public)/ # Passenger booking flow (search → select → reserve → ticket)
(admin)/ # Staff: voyages, inventory, bookings, manifests, OTC confirmation
api/ # Route handlers (webhooks, QR validation, cron triggers)
layout.tsx # Root layout; injects CSP nonce
manifest.ts # PWA web app manifest
components/
ui/ # shadcn/ui primitives
<feature>/ # Feature components
lib/
supabase/ # server.ts, client.ts, middleware.ts (three clients — do not mix)
validation/ # Zod schemas shared client/server
query/ # TanStack Query client, query keys, hooks
utils/ # money, datetime (Asia/Manila), qr
db/
migrations/ # SQL migrations (source of truth; checked into git)
policies/ # RLS policies
functions/ # SQL functions (atomic inventory ops, hold expiry)
tests/
unit/ e2e/ rls/
middleware.ts # CSP nonce + auth session refresh + security headers
next.config.ts # Static security headers, image config
Core entities (all tables: id uuid pk, created_at, updated_at, RLS enabled):
ports— name, UN/LOCODE or local code, city/province, coordinates.vessels— name, type (fastcraft|roro|ferry), operator, accommodation config, total capacity.routes—origin_port_id,destination_port_id, distance, typical duration.voyages— scheduled sailings:route_id,vessel_id,departure_at(UTC),arrival_at,status(scheduled|boarding|departed|cancelled).accommodation_classes—economy|tourist|cabin|business(per vessel).voyage_inventory—(voyage_id, class_id)→capacity,remaining. The single source of truth for availability. All holds/releases mutateremainingatomically.bookings—reference(human-readable),status(pending→confirmed→cancelled|expired), contact info,total_centavos,hold_expires_at.booking_passengers— manifest PII: full name, age, sex, passenger type (adult|child|infant|senior|pwd|student), fare, discount applied.discounts— Senior Citizen 20% (RA 9994) and PWD 20% (RA 10754), both VAT-exempt; student/other fares as configured.payments—method = 'otc', OR (Official Receipt) number,cashier_id/terminal,paid_at, amount. Modeled as a separate table so an online gateway can be added later without schema churn.tickets— one per passenger: signed QR token, boarding status (issued|boarded|void).staff/ roles — via Supabase Auth + aroleclaim (agent,cashier,ops,admin).
Money: store as integer centavos (*_centavos bigint). Never use floats for money.
This is the heart of the system. The flow:
- Reserve (online): passenger picks a voyage, class, and passengers. Create a
bookinginpendingwithhold_expires_at = now() + interval '2 hours'(tunable) and atomically hold inventory. - Pay (OTC): passenger pays at a terminal/agent using the booking
reference. - Confirm: cashier records the OR number → booking →
confirmed→ e-tickets issued (QR generated, available offline via PWA). - Expire: a scheduled job releases inventory for any
pendingbooking pasthold_expires_atand sets it toexpired.
All inventory changes go through an atomic, conditional update inside a Postgres
function (SECURITY DEFINER), never via read-then-write in app code:
-- Hold: succeeds only if enough remains. Returns rows affected.
UPDATE voyage_inventory
SET remaining = remaining - $qty
WHERE voyage_id = $voyage_id
AND class_id = $class_id
AND remaining >= $qty;
-- 0 rows affected => sold out; reject the reservation.- Wrap hold + booking insert in a single transaction.
- Releases (
remaining = remaining + qty) happen on cancel/expire. - Reservation writes must be idempotent (dedupe by client-supplied request key) so a retried request during a peak never double-holds.
Use pg_cron (or a scheduled Supabase Edge Function) to sweep expired holds every
minute. Inventory release must be idempotent (only release once per booking).
- RLS is ON for every table. No table ships without policies. Default deny; grant the minimum. Test policies (see §14).
- Three clients, never mixed:
lib/supabase/server.ts(RSC/route handlers, reads cookies),lib/supabase/client.ts(browser),lib/supabase/middleware.ts(session refresh). service_rolekey never reaches the client and is never imported into anything underapp/(public). Use it only in trusted server contexts (cron, admin actions) and prefer RLS-respecting flows where possible.- Migrations are the source of truth — every schema/policy/function change is a checked-in
SQL migration in
db/. No manual changes in the Supabase dashboard. - Pooling: connect through Supavisor (transaction mode) for serverless; do not hold connections across requests.
- Indexes: index every foreign key and every column used in
WHERE/ORDER BYon hot paths (e.g.voyages(departure_at),voyage_inventory(voyage_id, class_id),bookings(reference),bookings(status, hold_expires_at)).
- Reads: prefer React Server Components for initial data (schedules, voyage details) — cheaper, no client waterfall, better TTFB.
- Client interactivity & mutations: TanStack Query v5. Centralize query keys in
lib/query/keys.ts. Use optimistic updates for seat selection, with rollback on the atomic-hold failure (sold out). - Admin grids: TanStack Table v8 with server-side pagination, sorting, and filtering — manifests and booking lists can be large; never fetch all rows to the client.
- Tables are TanStack, always. Every route that renders a tabular list of records MUST
use the shared
components/ui/data-table.tsx(DataTable, TanStack Table v8) — search, sortable headers, and 10/20/50/100 pagination come built in. Do not hand-roll a<table>for a data list; add per-view filters through thetoolbarslot. (Small fixed key/value or summary tables that aren't record lists are exempt.) - Set sensible
staleTimefor schedule data (it changes infrequently); invalidate precisely after mutations rather than refetching everything.
Set static headers in next.config.ts headers() and the CSP nonce in middleware.ts.
| Header | Value (starting point) |
|---|---|
Strict-Transport-Security |
max-age=63072000; includeSubDomains; preload |
Content-Security-Policy |
strict, nonce-based (see below) — no unsafe-inline |
X-Content-Type-Options |
nosniff |
X-Frame-Options |
DENY (and frame-ancestors 'none' in CSP) |
Permissions-Policy |
lock down unused features, e.g. camera=(), microphone=(), geolocation=(), interest-cohort=() |
X-DNS-Prefetch-Control |
on |
CSP (nonce-based, not unsafe-inline): generate a per-request nonce in middleware.ts,
pass it to next/script and inline styles, and build a directive like:
default-src 'self';
script-src 'self' 'nonce-{NONCE}' 'strict-dynamic';
style-src 'self' 'nonce-{NONCE}';
img-src 'self' data: https://*.supabase.co;
connect-src 'self' https://*.supabase.co wss://*.supabase.co;
font-src 'self';
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none';
Referrer-Policy exception: intentionally out of scope per project requirement. Note that securityheaders.com counts Referrer-Policy among its six graded headers — if a scan shows Grade A is blocked solely by its absence, add
Referrer-Policy: strict-origin-when-cross-originand revisit.
- AuthN: Supabase Auth. AuthZ: RLS + role claims — never trust the client for authorization.
- Validate every boundary with Zod (route handlers, server actions, form input). Treat all input as hostile.
- Rate-limit reservation, login, and QR-validation endpoints (Upstash Redis).
- Secrets: in Vercel env vars only; never commit.
NEXT_PUBLIC_*is public — only the anon key and URL belong there.service_roleand other secrets are server-only. - PII (Data Privacy Act RA 10173 / NPC): manifest data is personal information. Minimize collection, gate access behind RLS + staff roles, log access, and define retention. No PII in logs, URLs, or analytics.
- Cache aggressively on reads: ISR /
revalidatefor schedule and voyage pages; they change rarely and are read constantly. Cache at the edge (Vercel CDN). - Keep writes atomic and short (see §5) so the DB stays healthy during peaks.
- DB: index hot paths (§6); use Supavisor pooling; avoid N+1 (select joined data in one query / RPC).
- Idempotent reservations so retries during peaks don't double-book.
- Images:
next/image; serve modern formats; size for mobile. - Bundle: prefer Server Components; lazy-load heavy client components; keep the booking path's client JS small.
- Performance budgets (mobile, mid-range Android): LCP < 2.5s, INP < 200ms, CLS < 0.1. Track via Speed Insights; treat regressions as bugs.
- Semantic HTML first; ARIA only to fill gaps. Use Radix/shadcn primitives for dialogs, menus, tabs, etc. (focus trapping and keyboard handling come built in).
- Full keyboard operability; visible focus states; logical focus order; manage focus on route/dialog changes.
- Color contrast ≥ 4.5:1 for text; never use color as the only signal.
- Forms: every input has a
<label>; errors announced inline and programmatically associated; don't block paste. - Respect
prefers-reduced-motion. - CI gate: automated
axe(jest-axe / Playwright-axe) + Lighthouse a11y; plus manual keyboard + screen-reader spot checks for the booking flow.
- Serwist for the service worker. Provide
app/manifest.ts, maskable icons, theme color, and an offline fallback. - Installable and passes Lighthouse PWA checks.
- Caching strategy: static assets cache-first; schedule data network-first with cache fallback; e-tickets (QR) cached for offline access — a passenger at the port with no signal must still show a valid boarding pass.
- Don't cache authenticated/PII responses in the SW beyond what's needed for offline tickets, and scope/encrypt appropriately.
- Languages: English + Filipino, with Cebuano for Visayas/Mindanao routes. Keep all user-facing strings in message catalogs — no hard-coded copy.
- Timezone: store timestamps in UTC, render in Asia/Manila (Luxon). Never do date math in local browser time for voyage schedules.
- Currency formatting: PHP (
₱), derived from integer centavos.
- TypeScript
strict; noany; model domain types explicitly (lean on Zod-inferred types). - Title Case all UI labels. Every static UI string that is a label — button text, page
headings, nav items, table column headers, form field labels, sidecar/dialog titles,
tab/menu labels — uses Title Case (capitalize the first letter of every word):
"Add User", not "Add user"; "Save Changes", not "Save changes". Write the literal in Title
Case at the source — do not rely on CSS
text-transform, which mangles acronyms ("RORO") and user data. Exempt (leave as written): sentence-case help/description text, error and toast messages, dynamic values from the database (names, addresses), and placeholder examples. - Money = integer centavos, always. Format only at the display edge.
- Clear server/client boundary: secrets and
service_rolenever cross into client bundles; keep"use client"as low in the tree as possible. - Errors: fail loud server-side (log + Sentry), fail gracefully client-side (user-friendly messages, never leak internals).
- Conventional Commits; small, focused PRs; lint/typecheck/test must pass pre-commit (Husky + lint-staged).
- Match existing patterns in the file/feature you're editing.
- Unit (Vitest + Testing Library): money/discount math (senior/PWD), datetime/TZ, validation schemas, components.
- RLS / policy tests (
tests/rls): for each table, assert that each role can do exactly what it should and nothing more. Critical security gate. - Concurrency test: simulate parallel holds on the last seats and assert no oversell.
- E2E (Playwright): full booking → OTC confirm → e-ticket flow; hold-expiry release; offline e-ticket display.
- A11y: automated axe + Lighthouse in CI (§10).
Scaffolding doesn't exist yet — create these scripts in
package.jsonwhen initializing.
pnpm dev # local dev server
pnpm build # production build (must pass before merge)
pnpm start # run production build
pnpm lint # ESLint
pnpm typecheck # tsc --noEmit
pnpm test # Vitest unit + RLS tests
pnpm test:e2e # Playwright
# Supabase (via supabase CLI)
supabase migration new <name>
supabase db push # apply migrations
supabase gen types typescript --local > lib/supabase/types.ts- MARINA / Philippine Coast Guard / PPA (Philippine Ports Authority) manifests — accurate per-voyage passenger manifest (name, age, sex, type) exportable for every sailing.
- RA 10173 (Data Privacy Act / NPC) — PII minimization, access control (RLS + roles), retention policy, breach readiness; no PII in logs/URLs/analytics.
- RA 9994 (Senior Citizens) — 20% discount + VAT exemption.
- RA 10754 (PWD) — 20% discount + VAT exemption.
- Discounts require ID validation at OTC payment; record the basis on the booking.
- Seat-level assignment vs. class-capacity only (depends on vessel type — RORO/economy is often capacity-only; cabins are assigned).
- Hold duration default (assumed 2h) — confirm with operations.
- Whether to add an online payment gateway later (the
paymentstable is modeled to allow it).