feat: full backend + apps build (Logistics v1.1, auth, onboarding, observability) - #1
Open
Banyel3 wants to merge 100 commits into
Open
feat: full backend + apps build (Logistics v1.1, auth, onboarding, observability)#1Banyel3 wants to merge 100 commits into
Banyel3 wants to merge 100 commits into
Conversation
Test-first express order lifecycle: book -> manual dispatch -> weigh -> status walk -> DELIVERED -> remittance. 61 tests green (unit + real-Postgres concurrency), build + type-check clean, HTTP smoke verified. - Pricing engine: pure, Prisma.Decimal end-to-end. Remittance by subtraction (remittance + commission == wash, property-tested). Config-driven fees. - Coverage gate: hardcoded Zamboanga polygon, point-in-polygon before create. - Orders domain (ADR-003 seam): OrdersRepository is the only Prisma toucher; OrdersService owns every tx and threads tx into the repo. - Capacity: pg_advisory_xact_lock(shopId, Manila-day) before count; count excludes CANCELLED. Proven: concurrent creates on a 1-slot shop -> one wins. - Status machine: hand-rolled legal-transition map, role-gated per edge, row locked FOR UPDATE. OrderEvent written same-tx (S1). - Remittance: RemittanceLine written same-tx as DELIVERED (S2), unique(orderId) idempotent. Proven: concurrent DELIVERED -> one line. - Order code: sequence created in migration (C1), nextval inside create tx. - RolesGuard + @roles + per-order ownership checks. Fix: start script pointed at dist/main.js; nest emits dist/src/main.js. ADR-003 acceptance evidence complete; flip Proposed->Accepted pending CEO. Rates provisional/refundable (CEO D1). AUTH_DEV_BYPASS still on until mobile mints Firebase tokens. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ed packages Backend endpoints and shared TS packages the customer app needs, all test-first. Depends on nothing native — Lane B (Expo + Firebase) builds on top. - GET /shops (CUSTOMER-gated): active shops + active services, single-query include (no N+1). Response is shaped — commissionPct/expressSlotsPerDay never leave the server (no margin leak to clients). - POST /orders/preview: read-only price estimate reusing the pricing engine, so the preview total always matches what create will charge for the same inputs. - packages/domain: OrderStatus/ServiceType mirrored from Prisma (dependency-free for Metro), humanized status map + timeline, wire types. Prisma-parity test. - packages/api-client: hand-written typed client with an injected TokenProvider (zero Firebase dependency), 401 -> refresh -> retry, typed error mapping. Tests: api 65, domain 5, api-client 7. pnpm -r type-check clean. Reviews: eng + design plan reviews cleared (scope-reduced: map picker deferred, server price preview, weight-bucket UX, full state coverage). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Expo Router app (SDK 57) runnable in Expo Go today. Full express flow against the live API: shops list -> book (load buckets + GPS/text pickup + server price preview) -> confirm -> order tracking. Trimmed the template to Expo-Go-safe modules (dropped @expo/ui / expo-glass-effect dev-build natives). - Auth: dev-bypass stub (x-dev-uid=dev-customer) since Expo Go can't run native @react-native-firebase. Real phone OTP is a dev-build follow-up; ApiClient gained a devUid option for this. - Design (folded from the design review): load-size buckets not a kg field, 3-state coverage per screen (loading/empty/error), out-of-coverage message, price shown as estimate with a weigh-in caveat, status timeline with the current step as hero, 44px targets, brand palette. - pnpm monorepo Metro config (watch workspace root, hoisted resolution); api-client now declares its @wash-and-go/domain dependency. Verified: expo export bundles for iOS (1111 modules, no errors); app type-check clean; logic tests (peso formatter, load buckets) green. Monorepo: 82 tests pass, pnpm -r type-check clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…read Second Expo Go app: a rider signs in (dev-rider-1 stub), sees assigned jobs triaged (needs-action first), drives each status forward, and records cash — all against the live API. Backend (test-first): - Shaped GET /orders/:id (+list): per-actor relations (shop drop-off + customer contact) + availableActions computed from the state machine for the actor's role+ownership. Ownership logic refactored into one ownsTransition() shared by the enforce path and the compute path. Seed gains a dev-admin. - api-client: transition() + payCash(); the client only offers actions the shaped read says are available. Shared UI (DRY, 2nd-app trigger): - Extracted packages/ui — Screen/Card/PrimaryButton/States/Pill/StatusTimeline, theme, peso, plus SlideToConfirm (PanResponder, Expo Go safe). customer-mobile refactored to import it; still bundles. Rider design (from the design review): - Slide-to-confirm on Delivered + Record cash only (irreversible/money); plain big taps on mid-steps. Tap-to-call + tap-to-navigate. Triage: needs-action loudest. One-handed targets. Verified: api 70, api-client 10, domain 5, ui 3, customer 3, rider 7 — all green; pnpm -r type-check clean (9 projects); both apps expo-export bundle for iOS; live shaped read returns availableActions + shop + customer phone to the assigned rider. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Backend enablers for the web portals (test-first): - GET /riders (admin-only, direct-Prisma): active RIDER users, minimal projection. Verified: 200 for admin, 403 for shop-owner. - Seed: dev-shop-owner + a ShopMember on Tetuan so the laundry-portal has scoped data; dev-admin already added. - api-client: getRiders(), assignRider(), weigh() so the admin + shop portals drive dispatch and weigh-in without curl. Tests: api 71, api-client 13. pnpm -r type-check clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…url gap Two Next.js (App Router) ops apps over the shipped endpoints — the full BOOKED→DELIVERED loop is now demoable without curl: - admin-dashboard: orders board (status filter, color-coded), assign-rider picker on BOOKED rows (GET /riders). Port 3001, stub dev-admin. - laundry-portal: shop queue; weigh-in is preview→confirm (design D2: reuse POST /orders/preview to show old→new total before the bill changes), then drive PROCESSING/READY. Port 3002, stub dev-shop-owner (Tetuan member). - Both: TanStack Query (5s poll), api-client + domain reused, action buttons driven by the shaped read's availableActions (no client transition logic), status color from the domain tone. Shared: - peso moved to packages/domain (platform-free); packages/ui re-exports it so mobile is unchanged and the web portals get it without pulling React Native. - OrderView gains shopServiceId (already in the shaped payload) for weigh preview. Verified: domain 8, api 71, admin 2, laundry 2 (+ prior suites) green; pnpm -r type-check clean (9 projects); both portals `next build` succeed; customer app still bundles after the peso move. Notion: "[v1] Web Admin + Shop Portal" page created under the Engineering Hub. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Customer app now signs in with real Firebase auth; rider app + portals keep the
dev-bypass stub. Both coexist under one flag.
Backend:
- Auth resolution now prefers a real bearer token (verified via Admin SDK),
falling back to x-dev-uid only when no bearer is present — so a real-auth
client and stub clients work simultaneously with AUTH_DEV_BYPASS=1.
- FirebaseService initializes the Admin SDK whenever credentials exist (even
under dev bypass); only pure stub mode (bypass + no creds) skips it.
- Fix: api-client.postSession sends the ID token in the body ({ idToken }) to
match the public /auth/session contract (was header-only → 400).
Customer app (Firebase JS SDK, runs in Expo Go — no dev build):
- firebase init with AsyncStorage persistence (getReactNativePersistence
accessed dynamically; missing from firebase v12 types, present at runtime,
firebase-js-sdk#8332).
- Email/password login screen (sign in / create account), auth gate in the root
layout, FirebaseTokenProvider wired into the api-client seam.
- Public web config committed (public-safe by Firebase design; not a secret).
Verified end-to-end: Firebase signup -> POST /auth/session creates the Postgres
user (roles [CUSTOMER]) -> GET /shops with the real token = 200; x-dev-uid path
still 200. api 72, api-client 14 green; type-check clean; customer app bundles.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Convert the customer app from a bare stack to a persistent bottom tab bar: - (tabs) group with Home (shops + book), My Orders, and a new Profile page. - Profile: shows the signed-in email + Sign out (Firebase signOut; the auth gate then redirects to /login). Confirm before sign-out (window.confirm on web, Alert on native). This is the app's first sign-out affordance. - book + orders/[id] stay full-screen pushes (no tab bar on booking/tracking). - Ionicons tab icons (@expo/vector-icons, Expo Go safe); brand active / muted inactive, safe-area inset. Verified: type-check clean; expo export bundles (1211 modules, tab route tree). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…web) Storing the global fetch and calling it as this.fetchFn(...) threw "Illegal invocation" in the browser (fetch requires window as its `this`). Wrap it in an arrow that calls fetch as a free function — web-safe, and native RN unaffected. Surfaced running the customer app on Expo web. Also: ignore Expo's local .expo/ cache at the repo root. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port P1 of the v5 design: packages/ui theme remapped to the landing-page palette — navy #004375 (structure/primary), terracotta #d07a29 (action), slate #3d5975, warm neutrals. Existing brand*/text* keys kept as aliases so both apps re-skin without churn; added navy/terra/slate tokens + tints. PrimaryButton gains a `tone="terra"` variant for action/CTA buttons. Both mobile apps type-check + bundle clean on the new palette. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Delivery fee is no longer flat. delivery = base + max(0, roundTripKm − freeKm) × perKm, capped; roundTripKm = 2 × haversine(customer, shop) × roadFactor. Interim uses haversine × 1.3 as a driving-distance proxy — swap for a routing API when the maps vendor (D10) lands; the formula stays. - pricing/distance.ts: haversineKm + computeDeliveryFee (pure, 7 tests). - PricingConfig: delivery params (base 40 / freeKm 2 / perKm 8 / max 150 / roadFactor 1.3), env-overridable — later the admin dynamic-config store. - OrdersService.price() takes one-way km and computes the fee; preview + create + weigh all pass haversine(pickup, shop). Preview gains optional pickup coords (omitted → base fee). Tests updated for the new model (pickup == shop → 0km → base ₱40; golden total 150+40+7 = 197). API suite 79 green; pnpm -r type-check clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Backend for the shop-auto-match flow (customer no longer picks the laundry):
- POST /orders/quote { pickupLat, pickupLng, weightKg, shopServiceId? } resolves
the nearest active shop (haversine, within a max radius) — or the override —
and returns the shop + distanceKm + full price breakdown (distance delivery).
- OrdersRepository.findActiveShopServices; OrdersService.resolveNearest +
quoteOrder. Capacity stays a create-time gate; live-GPS/dynamic-radius later.
- GET /shops?lat&lng annotates distanceKm + sorts nearest-first (change-laundry
chooser).
- api-client: quoteOrder() + getShops(loc). domain: OrderQuote, QuoteOrderBody,
ShopView.distanceKm.
API 82 tests, api-client 14, domain 8 green; pnpm -r type-check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…, Change) Rework the customer app to the shop-auto-match model (customer no longer picks the laundry): - Home is now a dashboard, not a shop picker: greeting + terracotta "Book a wash" CTA + quick actions + live active-order card (with empty state). - Book: pick load + pickup only (no shop), then Continue → Checkout. - Checkout (new): POST /orders/quote resolves the nearest shop, shows it with a "Closest" badge + Change link + the distance-based price breakdown; Confirm creates the order with the resolved shopServiceId. - Change-laundry (new): GET /shops?lat&lng nearest-first chooser; picking one re-quotes checkout with that shop as an override. Disabled expo-router typedRoutes (regenerated types fought tsc/CI). Type-check + logic test green; app bundles (1213 modules). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Match the ported palette: jobs needing action (Pill + next-step hint) now use terracotta/terraDark instead of navy, so the rider's "do this next" cue pops against the navy chrome. Rider otherwise re-skins automatically via packages/ui. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Platform-level rules (service fee, distance-delivery params, match radius, plus placeholders for the coming weight-cutoff / price-floor / platform-fee) move from env/hardcoded into a DB-backed, admin-editable store — change a fee without a redeploy. Per-shop wash rates stay in the laundry portal. - Prisma: PlatformConfig (typed singleton, id=1) + ConfigAudit (append-only, one row per changed field: actor, old→new, timestamp). - PlatformConfigService: seed-on-first-read from env defaults (DB wins after), getValues() for the pricing/matching engines, update() that validates (finite ≥ 0), writes only changed columns, and audits each change in one tx. - ConfigController: GET/PUT /admin/config + GET /admin/config/audit (ADMIN only). - orders.service now sources delivery/serviceFee/maxResolveKm from the store (deletes the env-only PricingConfig + the hardcoded MAX_RESOLVE_KM). Order specs re-mocked; integration spec runs the real config service on Postgres. - domain + api-client: PlatformConfigView/Patch/ConfigAuditEntry + getConfig/updateConfig/getConfigAudit. - admin /config: grouped rules editor (Fees · Delivery · Matching), diff-only save, inline validation, recent-changes log. Placeholder group is flagged "not yet applied" so no one is misled that inert fields do anything. Tests: api 88 pass (new config service spec + integration), admin logic 6 pass; all type-checks clean. Config endpoints verified live (seed, patch-diff, audit, revert, negative-rejected). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rebuild the admin dashboard chrome around a proper app shell — no behavior change, same GET/PUT/audit flow. Makes the internal ops console look/feel production-grade instead of plain inline-styled pages. - App shell: dark-navy sticky sidebar (Dispatch · Business rules) with active state via usePathname; content area max-width'd. Collapses to a top bar under 820px. - Tokenized theme: chrome colors move to CSS custom properties with a light + dark palette (prefers-color-scheme). lib/theme.ts chrome colors reference the vars; brand + semantic stay literal (used in badge alpha math). - Config editor: page-head with eyebrow/title/sub, carded rule groups, .field- input controls, tabular-nums on the audit diff column, and a floating "Rules updated" save toast. Placeholder group still flagged "not yet applied". - Dispatch page: same shell treatment — page-head, carded table, tnum total. Type-check + logic tests green; next build clean (both routes prerender). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… throttler Tier A of the backend finish pass. No behavior change to the money path; closes the prod-readiness gaps an audit surfaced. - [BLOCKER] AUTH_DEV_BYPASS fail-safe: the bypass is now allowed ONLY when NODE_ENV is explicitly 'development'/'test'. Previously it only blocked when NODE_ENV==='production', so an unset NODE_ENV (the real deploy state) left x-dev-uid impersonation live. auth-config.ts + spec (undefined/staging now refuse boot). - Env schema (Joi) at ConfigModule.forRoot: NODE_ENV required (default development), DATABASE_URL required, AUTH_DEV_BYPASS default 0 — typo'd/missing vars fail fast. NODE_ENV added to .env / .env.example. - Global exception filter: Prisma P2002→409 / P2025→404 / P2003→409, sanitized 5xx (no stack leak), correlation id from the Fastify request id. - Global ThrottlerGuard (60 req/min default; in-memory store, Redis-swappable). - Global RolesGuard as a second APP_GUARD so a controller that forgets @UseGuards(RolesGuard) still gets role-gated (no-ops without @roles). - main.ts: @fastify/helmet, ValidationPipe forbidNonWhitelisted, CORS allow-list from CORS_ORIGINS (was origin:true+credentials), Swagger gated to non-prod, enableShutdownHooks() for clean SIGTERM drain. - Health: split liveness (/health) from readiness (/health/ready runs SELECT 1). - prisma:migrate:deploy script for controlled prod migrations. Verified live: boots clean, /health/ready reports db:up, helmet headers present, unknown DTO field → 400, swagger 200 in dev. 91 tests pass (+3 auth-config). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-pickup Tier B of the backend finish pass. Test-first; fixes two latent correctness holes an audit surfaced, and covers the untested money paths. Fixes: - Delivery-fee misconfig guard: assertConfigInvariants rejects a merged config where the cap < base (would cap every fee below base) or roadFactor < 1 (would under-count distance). Checked on the MERGED patch-over-current, not per-field. - weigh() null-pickup: when pickup coords are absent, charge base delivery (km=0) instead of computing distance to (0,0) mid-Atlantic, which capped the fee at max and inflated the total. Latent today (express always has coords); guards a future no-coords order. Tests added (+32, suite 91 → 123): - manila-time: day-bucketing boundary, month/year rollover, the 23:30-UTC → next-Manila-day case, 24h/contains-now invariants (was fully untested — keys the capacity advisory lock). - payCash: idempotency, rider-ownership forbid, admin-any, 404 (was zero tests). - listOrders scoping: CUSTOMER/RIDER/SHOP/ADMIN + the no-role __none__ sentinel (cross-tenant data-leak surface, was zero tests). - gate branches: assignRider-from-non-BOOKED, DELIVERED-with-null-shopId, preview-inactive-shop, quote-inactive-override, resolveNearest-beyond-radius. - route-roles: reflect @roles off every controller method and assert the exact role matrix, so a mis-scoped endpoint fails CI. - platform-config: cap<base and roadFactor<1 rejection + merged-invariant. Split test scripts: test:unit (no Docker) / test:int (integration only); `test` still runs both. Full suite 123 pass, type-check clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First Tier-C feature module (PLAN §3.2, Phase 4). Extends the RemittanceLine already written per DELIVERED order into batched, trackable shop payouts. Backend (ADR-003 repo → service → controller, TDD): - Schema: RemittanceBatch (shop, period, totalPhp, lineCount, status, reference, paidAt) + RemittanceStatus enum + RemittanceLine.batchId. Migration applied. - RemittanceService.closeBatch(shop, period): sums the unbatched lines in [start,end), creates one batch, links the lines — all in a tx. closeAllShops() closes every shop with lines. markPaid(id, ref) records the external transfer, idempotent (already-PAID is a no-op). Empty period → null (no empty batches). - Repo assignLinesToBatch guards on batchId IS NULL so a concurrent close can't steal already-claimed lines. - Admin API: POST /admin/remittance/close, GET /admin/remittance/batches, POST /admin/remittance/batches/:id/mark-paid — ADMIN only (added to the route-role matrix spec). - Payout transfer stays external at launch; weekly BullMQ automation + realtime emit deferred (noted). Shared + UI: - domain RemittanceBatchView/CloseRemittanceBody; api-client get/close/markPaid. - admin /remittance page: owed-summary stats, status filter, "close last week" action, per-batch mark-paid with transfer-ref input. New "Payouts" nav item. Tests: service spec (sum/link, empty, period guard, per-shop, mark-paid idempotency) + integration spec (real Postgres: ₱145.50 summed, lines linked, no double-batch, idempotent pay) + admin logic spec. Suite 123 → 134; admin 6 → 9; type-checks + next build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Second Tier-C module (PLAN §2 users/addresses). Self-contained, no external deps. Backend (ADR-003 repo → service → controller, TDD): - Address model (userId, label?, line, lat?, lng?, isDefault) + User.addresses. Migration applied. Order still snapshots its own pickup at create, so deleting an address never mutates a past order. - AddressService CRUD, every op user-scoped: a missing OR not-owned id is a 404 (no existence leak). Invariant: at most one default per user, cleared in the same tx as the write that sets it. - /me/addresses GET/POST/PATCH/DELETE — CUSTOMER only (added to the route-role matrix spec). Shared + customer app: - domain AddressView/CreateAddressBody/UpdateAddressBody; api-client get/create/update/deleteAddress. - Book screen: loads the address book, shows saved pickups as tappable cards that prefill address + coords (Default badge), plus a "Save this pickup for next time" toggle that best-effort-saves a new address on Continue. Tests: service spec (create default-clearing, ownership 404s, promote-to-default, plain-edit no-clear, delete) — suite 134 → 143. Verified live: full CRUD, the one-default invariant (2×isDefault → 1 default), and the role gate (RIDER/ADMIN → 403, CUSTOMER → 200). Type-checks clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…iates) Completes the money model locked in the plan review: the platform intermediates. A rider collects the full customerTotal COD at delivery, so that cash belongs to the platform; this tracks what each rider still owes = collected − deposited. The shipped remittance module (platform → shop payout) stays correct; this is the missing rider side. - RiderCashDeposit model (riderId, amountPhp, reference, note, recordedByUid) + User.cashDeposits. Migration applied. - RiderCashService: balance(rider) = SUM(customerTotalPhp of the rider's paid-cash orders) − SUM(deposits); summary() lists all riders who took COD, sorted by outstanding; recordDeposit() validates positive amount + rider role. - Money-domain repo (ADR-003): aggregate sums, per-rider groupBy for the summary. - Admin API: GET /admin/riders/cash (summary), GET /admin/riders/:id/cash (balance + deposits), POST /admin/riders/:id/cash/deposit — ADMIN only (added to the route-role matrix spec). Tests: service spec (outstanding math incl. zero + over-deposit-negative, summary join+sort, deposit validation) — suite 137 → 146 unit. Verified live: summary 200, deposit 201, balance computes. Type-check clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds an `e2e/` workspace with Playwright browser smokes that prove the web surfaces actually work (buttons + flows end-to-end), not just that logic units pass. First smoke = the laundry-portal weigh flow, the launch-critical path. Harness: - e2e/ workspace: @playwright/test, config auto-starts the portal (:3002), reuses a running API (:4000) + Docker Postgres. - lib/seed.ts seeds through the API (x-dev-uid stubs): creates an order and drives it BOOKED→ASSIGNED→PICKED_UP→AT_SHOP (admin drives all edges), returns the code; cancelOrder tidies after. - tests/laundry-portal-weigh.spec.ts: scoped to the seeded order's card (queue may hold others), enters weight → Preview price → Confirm weigh-in → asserts "Weighed 7kg" → Mark Washing → asserts status advanced. Bug the smoke caught + fixed: - POST /orders/preview was @roles('CUSTOMER') only, so the portal (SHOP_OWNER) got 403 and the weigh price-preview never rendered — the portal's preview had never worked. Widened to CUSTOMER + SHOP_OWNER + SHOP_STAFF + ADMIN (read-only price calc, a shop seeing its own order's economics is fine). Route-role matrix spec updated. - Added data-testid=`order-${code}` to the portal OrderCard so smokes can scope to a specific order. Smoke green (863ms); API 146 unit pass; portal type-check clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Builds the maps foundation (Phase C1). One interface, swappable adapters, so geocoding + driving distance can leave the haversine proxy for a real vendor without touching callers. Starting with TomTom. - packages/maps: MapsProvider interface + GeoPoint/GeocodeResult/RouteResult types (the D10 boundary; consumed type-only so no runtime coupling). - apps/api/src/maps: TomTomProvider (Search API fuzzy geocode + Routing API driving distance; reverse-geocode via the reverseGeocode endpoint that degrades to null when that product is off). HaversineProvider keyless fallback (distance only). MapsModule picks the adapter from MAPS_PROVIDER + key presence; global, so zones/pricing can inject it. Google adapter slots in here later. - Env: MAPS_PROVIDER / TOMTOM_API_KEY / GOOGLE_MAPS_API_KEY in .env(.example) + Joi schema (keys optional; fallback covers no-key). - scripts/maps-spike.ts: the D10 spike runner (geocode N addresses + a route). - Fixed .gitignore: /node_modules → node_modules/ (nested app node_modules weren't ignored). TDD: tomtom.provider.spec (geocode map, no-result null, 403 degrade, route meters→km, route error throws, reverse-geocode) — suite 146 → 154 unit, type-check clean. Live spike (real key) validated: geocode 6/6 Zamboanga addresses (barangay-level accurate, POI-level rough — flag for the Google A/B), route Tetuan→KCC 3.14km, reverse-geocode degrades cleanly (product not enabled). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Second half of Phase C1. Formalizes the hardcoded orders/coverage.ts polygon
into admin-managed DB zones; a pickup is covered when it falls inside any active
zone.
- Zone model (name, active, polygon JSON of {lat,lng} vertices) + migration.
- geo.ts: ray-cast pointInPolygon (moved from coverage.ts) + the pilot Zamboanga
ring as the seedless fallback.
- ZonesService: isCovered(point) — any active zone contains it, else falls back
to the pilot ring (empty table stays covered, no seed needed, launch-safe);
resolve(point) → containing zone or null; create() validates the ring (≥3
vertices, finite lat/lng); setActive().
- Admin API: GET/POST /admin/zones, PATCH /admin/zones/:id, GET
/admin/zones/resolve?lat&lng — ADMIN only (in the route-role matrix).
- orders.service now gates coverage via ZonesService.isCovered (async), not the
hardcoded polygon. coverage.ts + its spec deleted; order specs use a real
ZonesService (empty zones → pilot ring) so coverage behavior is unmocked.
TDD: geo.spec + zones.service.spec (fallback, in/out zone, resolve, validation)
— suite 154 → 168 (incl integration on real Postgres). Type-check clean. Live:
create zone 201, resolve central-ZC → zone, Manila → null.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Puts the validated TomTom Search API to work: customers can type a pickup and get it pinned, no GPS needed. Completes Phase C1's geocoding half. - GET /geocode?q= (CUSTOMER + ADMIN) backed by the active MapsProvider; returns the best hit or null (app falls back to a manual pin), 400 on <2-char query. Token moved to maps.constants.ts to break the controller↔module circular import (caught by boot: undefined-dependency DI error). - domain GeocodeHit + api-client.geocode(query). - Customer Book screen: "🔎 Find this address" geocodes the typed address → sets coords + normalizes the label. Complements GPS, saved addresses, manual. Tests: geocode.controller.spec (delegate/trim, <2-char 400, null passthrough) + route-role matrix. e2e geocode-api smoke (real TomTom through our stack: Zamboanga address → in-range coords, 400 on short query, rider 403). API 166 unit, all type-checks clean, 4/4 e2e green (geocode + portal weigh). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surfaces the rider-cash backend: ops sees what each rider owes and records deposits. Plus a real bug the browser smoke caught. - Admin /rider-cash page (new "Rider cash" nav): outstanding-per-rider table (collected − deposited, rider names joined from /riders), total-owed stat, and an inline record-deposit form. lib/rider-cash helpers (totalOutstanding, owing) + spec. - domain RiderCashBalance/DepositView/Detail + RecordDepositBody; api-client getRiderCashSummary / getRiderCashDetail / recordRiderDeposit. Bug the smoke caught + fixed: - The api-client set content-type: application/json on EVERY request, so a no-body POST (pay-cash) sent an empty json body → Fastify 500 "Body cannot be empty". pay-cash from the apps would have 500'd. Now content-type is only set when there's a body. Same fix in the e2e seed helper. Client spec updated (GET no content-type; body-POST has it). - e2e: admin-dashboard added to the Playwright webServer set; seedRiderCollected Cash (create → assign → pay-cash); admin-rider-cash browser smoke drives the real deposit flow (row shows outstanding → record ₱50 → outstanding drops 50). Tests: admin logic 9 → 11, api-client 14, all type-checks + next build clean. 5/5 e2e green (geocode, portal weigh, rider-cash). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Admin zone management (v1) so ops can define coverage without code. Plus a real
CORS bug the browser smoke caught.
- Admin /zones page (new "Zones" nav): create a zone by entering boundary points
(lat,lng per line) with a live SVG shape preview, list zones with an SVG
thumbnail + point count, activate/deactivate toggle. lib/zones (parseVertices,
polygonSvgPoints) + spec. A full map-tile draw editor is the follow-up; this
form-based v1 is fully testable now.
- DELETE /admin/zones/:id (ops can remove a bad zone; also smoke cleanup).
- domain ZoneView/ZoneVertex/CreateZoneBody; api-client getZones/createZone/
setZoneActive.
Bug the smoke caught + fixed:
- Fastify's default CORS preflight omits PATCH/DELETE, so every browser PATCH
(zone toggle, address edit) and DELETE (address/zone delete) was CORS-blocked
("Method PATCH is not allowed"). main.ts enableCors now lists methods
explicitly (GET/HEAD/POST/PUT/PATCH/DELETE/OPTIONS). POST was unaffected, which
is why earlier mutations passed.
Tests: admin logic 11 → 17, api 166 unit; e2e admin-zones smoke (create from
points → appears Active → toggle Inactive). Type-checks + next build clean, 6/6
e2e green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Investigated the 3 browser-smoke-caught bug classes (role-gating, empty-body, CORS) with parallel auditors. Verdict: all 4 original bugs are correctly fixed and NO live siblings remain. Applied defense-in-depth so each class can't recur. - Empty-body (P1): the 500 fires in Fastify's json parser BEFORE Nest, so the client fix was discipline-only. Override the json content-type parser to treat an empty body as undefined (create with bodyParser:false so ours is the only one). Closes the class server-side — any client (curl, third-party) sending content-type:json with no body now works; malformed json still 400s. - CORS (P2): pin allowedHeaders (authorization, content-type, x-dev-uid, idempotency-key) explicitly — it worked only via @fastify/cors reflection, so a future header lockdown could silently re-break PATCH/DELETE. Guard the origin:'*' + credentials foot-gun (never echo * with credentials). - Role-gating (P3): widen the 2 latent narrow gates (same shape as the preview bug, no caller yet): GET /shops (CUSTOMER-only → any-authenticated catalog), GET /geocode (CUSTOMER+ADMIN → any-authenticated utility). Non-sensitive reads; route-role matrix + e2e updated. - DI (P4): no code change needed — token already isolated in maps.constants.ts; convention documented there. Regression: e2e empty-body.spec (explicit content-type:json + empty body on pay-cash → not 500) guards the SERVER fix independent of the client. Verified live: empty-body 404 not 500, malformed 400, shops/geocode any-auth 200, preflight allows all methods+headers. 166 unit, 7/7 e2e green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Unlocks browser-smoking the customer app (Expo web + real Firebase auth), the piece the harness was missing. Now all four web surfaces are smoked. - Customer app added to the Playwright webServer set (Expo web on :3000, long first-bundle timeout). - lib/customer.customerLogin: signs in with the seeded Firebase email/password account (works headless — email/password needs no reCAPTCHA) and lands on the dashboard. - customer-book.spec: login → dashboard → Book → pick a load (Medium) → type a pickup → "Find this address" (TomTom geocode) → Continue → Checkout resolves the nearest shop, shows the Closest badge + a peso total. The whole book→quote path, end to end in a real browser. - ui Card now forwards testID → data-testid on web (helps all smokes); book buckets tagged bucket-S/M/L. 8/8 e2e green across customer / portal / admin / API. Type-checks clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The last web surface still on the old plain-blue styling. Brings it to the same tokenized quality as the admin dashboard — no behavior change (weigh smoke still green). - Tokenized globals.css: light + dark palette via CSS custom properties, plus the shared card / page-head / field-input / toast / tnum classes. - Top-bar shell (single-view console, so a header not a sidebar): dark-navy brand bar + role chip, centered max-width content. - theme.ts chrome colors → CSS vars (brand + semantic stay literal for alpha). - page.tsx: page-head, carded orders, tabular-nums money, tokenized weigh form + status buttons. All smoke-critical text + data-testid preserved (Preview price, Confirm weigh-in, Mark <status>, Weighed Nkg, order-<code>). Type-check + logic tests + next build clean; portal weigh e2e green on the redesign. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
From the /review of the separated slice PRs. (The NODE_ENV, remittance-double-pay, and idempotency-scope findings the reviewers raised were on frozen historical slices — already fixed at HEAD.) P2: - assignRider (manual admin dispatch) now gates on a VERIFIED rider profile, not just the RIDER role — parity with auto-dispatch, so a draft/rejected rider who hasn't proven license/ID is never handed platform cash. (repo.isRiderVerified) - zones.isCovered fails CLOSED when zones exist but all are deactivated (was falling back to the full pilot ring, silently reopening coverage). Distinguishes empty-table (bootstrap → pilot ring) from all-off (admin closed → no coverage). P3: - orders ?status query validated via ParseEnumPipe (garbage → 400, not Prisma 500). - onboarding proof keys (shop permit/photos, rider license/id) must live under the caller's uploads/<uid>/ prefix — a crafted key can no longer point the admin's presigned-GET at another user's private object. (shared assertOwnedKey) - rider backfill migration: existing RIDER users get a VERIFIED profile (idempotent) so the new dispatch gate doesn't strand pre-existing riders. - shop-facing remittance strips paidByUid (an admin's Firebase UID). - admin-shops addService/addMember map the concurrent-dup P2002 to 409, not 500. - /geocode/search throttled 20/min (billable TomTom call, was global-only). - platform-config envNum accepts a deliberate 0 (presence check, not !== 0). Noted, not fixed (dev-only / negligible): auth bearer-before-bypass dev 500, capacity-race returns 409, pagination cursor positional side-channel. 290 api unit tests. Integration + backfill migration validated by CI (local Docker unavailable this session). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
railway-app
Bot
temporarily deployed
to
appealing-perfection / production
July 25, 2026 13:28
Inactive
Non-scroll screens (login) were a plain View with no keyboard dismissal — tapping outside an input left the keyboard stuck. Wrap non-scroll bodies in TouchableWithoutFeedback(Keyboard.dismiss) (buttons still handle their own touch), and add keyboardDismissMode=on-drag to scroll screens. Fixes it everywhere the shared Screen is used. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
railway-app
Bot
temporarily deployed
to
appealing-perfection / production
July 31, 2026 06:43
Inactive
Replaces the ad-hoc inline red error `<Text>` scattered across the apps with one shared toast: green = success, red = error, slide-in from the top, tap or auto-dismiss. - packages/ui: ToastProvider + useToast() (toast.success/error/info), animated, safe-area aware. Wired into both apps' root _layout. - customer app: login, checkout, book (+ the geocode "could not search" error), change-laundry, addresses, profile, notifications, order detail (cancel/rate) — every transient error → toast.error; success paths (address saved/removed, booking cancelled, rating thanks, address pinned, all-caught-up) → toast.success. - rider app: login, job detail (status transition + cash) → toasts. - Full-screen load-failure ErrorState (with retry) intentionally kept — those aren't transient alerts. Sign-out confirm Alert kept (it's a confirmation). - type-clean: ui + both apps. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
railway-app
Bot
temporarily deployed
to
appealing-perfection / production
July 31, 2026 06:54
Inactive
- auth: _layout now calls postSession whenever Firebase restores a session, not only on the login-form submit. An account whose first postSession failed (e.g. wrong API URL during setup) was left with a Firebase account but no Postgres row → "User not found" 401 on every screen. Idempotent upsert fixes it on the next reload. - empty states: notifications now uses the friendly EmptyState (🔔) and the saved-addresses empty gets a centered 📍 block, matching the orders "No orders yet" screen — so a brand-new account reads as "nothing here yet", not an error. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
railway-app
Bot
temporarily deployed
to
appealing-perfection / production
July 31, 2026 06:59
Inactive
Tapping "Use my current location" with location denied showed a passive error toast. Now it prompts: if iOS can still ask, a simple heads-up; if it was permanently denied, an "Open Settings" action (the only way back). Treats a missing permission as "turn it on", not "you did something wrong". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…k title
- the "Book a wash" empty-state action went to home (router.replace('/')); now it
pushes /book directly, same as home's Book-a-wash CTA.
- header back button was showing the group route name "(tabs)" as its title;
set headerBackButtonDisplayMode 'minimal' (chevron only) across the stack.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GET /geocode/reverse?lat&lng -> { label } via the TomTom provider's reverseGeocode
(already existed, just needed a route). Any-authenticated, throttled 30/min
(billed TomTom call), validates coords -> 400. api-client.reverseGeocode(). Backs
the "drop a pin -> show its address" map picker. 26 geocode tests; curl-verified.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t address
Typing an address for a GPS pickup service was unreliable and produced two
conflicting buttons ("Use my location" vs "Find this address" set different
points). Replaced with one map picker:
- MapPicker (react-native-webview + Leaflet + TomTom raster tiles, EXPO_PUBLIC_
TOMTOM_MAP_KEY): Grab-style centre pin, opens on device GPS (falls back to
Zamboanga), pan to place the pin, debounced reverse-geocode (/geocode/reverse)
labels it, "Use this location" returns { lat, lng, address }. Reuses the TomTom
key — no Google Maps key, cross-platform, matches the admin map's tile approach.
- book.tsx: dropped the address TextInput + the two location buttons for a single
"Set pickup on map"; shows the pinned address in a card.
- addresses.tsx: the add form now pins the location on the map (and finally SAVES
lat/lng — the old text-only form saved addresses with no coordinates, useless for
a pickup service).
NOTE: react-native-webview is a native module → run `npx expo run:ios --device`
once to pick it up (JS reload won't include it).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…se, fix flicker Investigation (/plan-eng-review): the two mobile apps differ by role, so parity means sharing the UX *primitives*, not the screens. The map was customer-only and duplicated; rider navigated to a fuzzy text address instead of the exact point the customer pinned. packages/ui — one map stack, two interactions: - leaflet-map.ts: shared Leaflet-in-WebView html + pluggable tile presets (mapbox @2x, maptiler @2x, tomtom, osm) resolved from env via resolveTiles(). Tiles are the only HD lever; provider swaps with one env var, no code change. - MapPicker: refactored to take an injected `tiles` + `reverseGeocode` (api- agnostic). FIX: memoise the WebView `source` — an inline source object was a new ref every render, so RN WebView reloaded the map, which re-posted its centre, which re-rendered... the endless "Locating… <-> address" flicker. - MapView (new): read-only pin + coord Navigate, for the rider. - 11 unit tests for the tile resolver + html builder. rider-mobile: - orders/[id]: embeds MapView on the pickup, and Navigate now uses the exact pickupLat/Lng (falls back to text only for older orders with no pin). A text address can resolve blocks away — the whole point of the map picker was the coords, and the rider was throwing them away. - added react-native-webview + expo-location. customer-mobile: - components/MapPicker is now a thin wrapper injecting api.reverseGeocode + the env tile provider into the shared UI picker. api — reverseGeocode gains a keyless OSM Nominatim fallback: the TomTom dev key has Maps (tiles) scope but not Search, so reverse always returned null and the picker showed raw lat/lng. Nominatim is keyless AND permits storing the result (Mapbox geocoding forbids storage — we persist the label), so it's the correct provider for our use. TomTom stays primary when its Search scope is enabled. Tile provider (opt-in, no card needed for the free HD path): EXPO_PUBLIC_MAP_TILE_PROVIDER=maptiler|mapbox|tomtom|osm EXPO_PUBLIC_MAPTILER_KEY / EXPO_PUBLIC_MAPBOX_TOKEN / EXPO_PUBLIC_TOMTOM_MAP_KEY NOTE: react-native-webview + expo-location are native — rebuild both apps (`npx expo run:ios --device`) to pick them up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Flicker: the memoised source wasn't the whole story — Leaflet fires `moveend` repeatedly with the SAME centre during the modal slide-in and tile settle, and each one flipped the label to "Locating…" and re-fired the reverse-geocode. Dedupe on the rounded centre (5dp ~1m): identical centres are ignored, so no setState, no re-render, no flicker. Reset the dedupe key when the sheet reopens. HD: added keyless CARTO Voyager tiles (free, no signup) that serve @2x retina via Leaflet's {r} token — noticeably sharper than OSM/TomTom basic. resolveTiles now defaults to CARTO and only uses TomTom when explicitly asked (provider=tomtom), so a stray EXPO_PUBLIC_TOMTOM_MAP_KEY no longer pins the map to low-res. Paid @2x keys (MapTiler/Mapbox) still win when set. 12 ui tests (was 11), type-clean. NOTE: JS-only (no native change) — just reload Metro to pick this up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…slop jobs /design-review found the rider app read "empty" next to the customer app. Root causes: no bottom navigation (customer has Home/Orders/Profile tabs; rider was a flat stack), and a duplicated "My jobs" title (native header + an in-content H1) that ate the top third of the screen. - (tabs)/_layout.tsx: bottom tab bar mirroring the customer app — Jobs (briefcase), Cash (wallet), Profile (person). Job detail stays a stacked screen pushed from Jobs, so it's out of the bar. - Moved index.tsx + cash.tsx into (tabs)/. Cash was a tiny top-right text link (sub-44px target, easy to miss) — now a real tab. - (tabs)/profile.tsx (new): the rider had NO sign-out anywhere and no identity screen. Profile shows the signed-in rider, a verification badge (VERIFIED / pending / rejected from /rider/onboarding), and Sign out. - Jobs screen: dropped the redundant in-content "My jobs" title (the tab header already says it); leads with the live count instead. Kills the empty gap. Verified live in the iOS simulator (dev-rider-1): tab bar renders, jobs screen no longer double-titled. Type-clean, 7 rider tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A DELIVERED or CANCELLED job is read-only, but the detail screen still showed Call, Navigate, and the pickup map — a rider could ring a customer or route to a cancelled pickup. Gate those affordances on !isTerminal(status); keep the addresses visible for reference. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Riders had no on-app explanation of the COD money flow. Added a "How payments work" card: you hold the customer's cash for the platform, deposit it back (GCash / ops) which lowers what you owe, delivery earnings settle weekly, and new jobs pause if the owed balance climbs too high (the debt cap, enforced next). No self-deposit endpoint — deposits stay ops-recorded until the cap lands. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…much Prevents a rider accumulating unbounded platform cash (COD they collected but haven't deposited) and absconding. New platform-config field riderCodCapPhp (default ₱1,500, env RIDER_COD_CAP_PHP): once a rider's outstanding COD (collected − deposited) reaches the cap, they stop receiving new jobs until they deposit. - platform-config: riderCodCapPhp added to CONFIG_FIELDS, defaults, and the grouped values; migration adds the column (default 1500). - orders.repository: riderOutstandingCod(rider) for the manual gate; pickAutoDispatchRider now filters over-cap riders (batched two-groupBy, no per-rider round-trip) so auto-dispatch skips them too. - orders.service.assignRider: rejects an over-cap rider (parity with the auto-dispatch filter) — BadRequest naming the limit. - rider-cash: /me/cash now returns capPhp; the Cash tab shows "new jobs pause once you owe ₱X" and flips to "over the limit — deposit to resume" when hit. Note: ₱100 (as floated) would block a rider after one order — a single wash's COD exceeds it. ₱1,500 (~4-7 orders) bounds abscond loss while allowing a normal batch. The ~₱100 "carry allowance" (auto-net small debt vs weekly earnings) is a separate remittance-netting feature, deferred. New tests: over-cap assign rejected, just-under-cap allowed, balance carries the cap, config default. 136 api unit tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The full backend + app build for Wash & Go.
maincurrently only has the landing page; this branch carries everything else — the API, all four apps' features, auth, observability, and deploy tooling — across ~20 shipped checkpoints.Merging triggers full CI (DB-free unit tests on API/admin/portal/rider + API Docker image build) and deploys the apps.
Highlights
Logistics v1.1 (3 services / 2 tiers)
Money + correctness hardening
Onboarding + admin
Auth
x-dev-uid fallback preserved for local/e2e (prod build requires real login)
Customer/rider app wins
in-app notifications, re-order ("book again"), rider job polling, rider cash view
Observability (Tier 1)
CI/CD + deploy
Testing
users, shops, zones, rider cash)
Notes
JSON is the only secret and is env-only.
creation + role grants, optional Redis/Sentry DSN in prod env.
🤖 Generated with Claude Code