Skip to content

Review slice 8/8 — Onboarding epic + hardening - #9

Closed
Banyel3 wants to merge 16 commits into
review/s7-observability-polishfrom
review/s8-onboarding-hardening
Closed

Review slice 8/8 — Onboarding epic + hardening#9
Banyel3 wants to merge 16 commits into
review/s7-observability-polishfrom
review/s8-onboarding-hardening

Conversation

@Banyel3

@Banyel3 Banyel3 commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Review slice 8/8 — Onboarding epic + hardening — part of the PR #1 split. Review-only; stacked on review/s7-observability-polish. Do not merge individually — PR #1 is the merge vehicle.

Self-serve onboarding (shop + rider): verification lifecycle, R2 uploads, self-serve endpoints, admin review UI, maps. Plus the review P2/P3 fixes and CI/perf work.

Commits

  • db104e8 fix(test): parity spec reads domain source as text (don't break the API build)
  • 033bc75 fix: clear the P3 review follow-ups (idempotency scope, PII, parity, coverage)
  • 045deb7 fix(security,money): close the two P2s from the review + spec the error filter
  • 8730818 feat(admin): shop overview map on /shops (Leaflet + OSM, key-free)
  • 8a215e7 feat(admin): inline location map on shop review (key-free OSM)
  • ebbc0ce fix(test): give the integration rider a VERIFIED profile (CI green)
  • ff28cc4 feat(admin): rider applications in the review queue (onboarding D UI)
  • 22ae534 feat(api): self-serve rider onboarding + verification gate (onboarding D backend)
  • 2d52741 docs(e2e): cost matrix notes DB on Neon now, moves to Railway on Hobby plan
  • 1f88413 feat(admin): shop verification review-queue page (onboarding C UI)
  • 2e7b8cd fix(admin): SSR QueryClientProvider regression (pages 500'd on the server)
  • ec775bb feat(api): self-serve shop onboarding + admin verify/reject (onboarding B2/C)
  • 555684c feat(api): R2 presigned uploads for onboarding proof (onboarding B1)
  • 7d168ab docs(e2e): onboarding & verification DOCX generator
  • 882cbf6 feat(api): shop verification lifecycle — status gate (onboarding A1)
  • 27edb58 perf(admin): persist cache to localStorage + skeleton loading states

Diffstat

 68 files changed, 3058 insertions(+), 63 deletions(-)

Banyel3 and others added 16 commits July 25, 2026 15:37
Addresses "cache isn't helping": React Query's cache was in-memory, so a full
page reload (F5 / typing the URL) wiped it and every reload re-fetched.

- staleTime + gcTime Infinity, refetchOnMount/onFocus off — a page fetches once,
  then serves from cache; the only refetch is a mutation's invalidateQueries
  (i.e. when the data actually changed). Dispatch board opts back into live
  polling (staleTime 0 + refetchInterval + refetchOnMount 'always').
- PersistQueryClientProvider + localStorage persister (SSR-guarded) so the cache
  survives reloads — first visit fetches, every visit after hydrates instantly.
  buster 'v1' to invalidate on a future shape change; 1-day maxAge.
- Skeleton shimmer loading states (TableSkeleton / CardsSkeleton) replace the
  bare "Loading…" lines on every page.

Type-check + next build clean; all pages still prerender.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Foundation for self-serve shop onboarding: a shop now has a verification status,
and customers only ever see verified ones.

- schema: ShopStatus enum (DRAFT → SUBMITTED → VERIFIED → REJECTED) + submittedAt,
  verifiedAt, rejectionReason, and R2 proof keys (permitKey, photoKeys). Migration
  backfills existing shops to VERIFIED, then flips the column default to DRAFT so
  future self-serve owners start unverified.
- gate: customer discovery + auto shop-match require `active AND status=VERIFIED`
  (shops.service.listActive + orders.repository.findActiveShopServices). An
  unverified shop never matches an order.
- admin-created shops are trusted → skip straight to VERIFIED (admin CRUD path).
  AdminShopView exposes status + verification timestamps (feeds the Phase C review
  queue). Seed shops default VERIFIED.
- tests: admin create is VERIFIED, listActive where-clause carries the status
  filter. 252 api tests; curl-verified (customer sees verified only, admin create
  → VERIFIED).

Rider self-serve (RiderProfile) lands with Phase D. Next: R2 uploads (B1) +
portal self-signup/onboarding wizard (B2/B3).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Native-Word doc (same theme as the money/cost/feature DOCX) mapping the self-serve
onboarding + verification flow for the team to finalize: three sign-up paths
(customer/rider/laundry), the laundry-owner step-by-step (portal signup → proof +
map location → admin review), the shop verification state table, admin review
queue, rider flow (next phase), and an open-decisions checklist. Output docx is a
generated artifact (git-ignored like the others); regenerate with
`node e2e/capture/onboarding-docx.mjs`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Direct-to-R2 upload brokering — the API never touches the bytes. Ships INERT:
no R2_* env → endpoints 503, same posture as Sentry.

- UploadsService: lazy S3 client against the R2 endpoint (region "auto").
  POST /uploads/presign → presigned PUT (5-min), key namespaced `uploads/<userId>/…`
  so a caller can only write under their own prefix; content-type allowlist
  (jpg/png/webp/pdf). GET /uploads/url → presigned GET, gated to the object owner
  or an ADMIN (proof review) — private bucket, no public links.
- deps: @aws-sdk/client-s3 + s3-request-presigner. Env: R2_ENDPOINT/ACCESS_KEY_ID/
  SECRET_ACCESS_KEY/BUCKET documented in both .env examples + DEPLOY table.
- tests: content-type validation, key namespacing, owner/admin view gate, inert
  503 when unconfigured; route-roles (uploads any-auth, ownership-scoped). 260 api
  tests; curl-verified (503 inert, 400 bad type).

Next: B2/B3 wire these keys into the portal onboarding wizard (needs R2 creds +
the TomTom map SDK).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ng B2/C)

The owner-driven onboarding flow + the admin review queue — backend for the
portal wizard and admin applications page.

- ShopOnboarding (self-serve, /shop/onboarding, any-authenticated, ownership-scoped):
  POST /start (idempotent — become SHOP_OWNER + create a DRAFT shop in one tx),
  GET (the caller's shop + status, null if none), PATCH (edit draft: name, address,
  map coords, R2 proof keys — locked once SUBMITTED/VERIFIED), POST /submit
  (DRAFT/REJECTED → SUBMITTED; enforces the required set: name, address, pinned
  location, permit). Owner view hides margin fields.
- Admin review (AdminShops): GET /admin/shops/applications (SUBMITTED, FIFO),
  POST :id/verify (→ VERIFIED + active + notify owner SHOP_VERIFIED),
  POST :id/reject (→ REJECTED + reason + notify owner SHOP_REJECTED). Both tx-wrapped,
  only act on SUBMITTED (else 409). Wired NotificationsModule into ShopsModule.
- domain + api-client: OwnerShopView, onboarding + review + presign methods.
- tests: onboarding (start idempotency + role grant, edit lock, submit required-set),
  verify/reject (state guard + owner notify), route-roles. 271 api tests; full flow
  curl-verified (start→submit→queue→verify→notify, gated from customers pre-verify).

UIs next (need R2 creds + TomTom Web SDK): portal wizard (B3) + admin queue map (C).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rver)

The localStorage-persistence change (27edb58) returned children WITHOUT any
QueryClientProvider on the SSR/prerender branch (persister is client-only), so
every page using useQuery threw 'No QueryClient set' and 500'd server-side. Wrap
the SSR branch in a plain QueryClientProvider; persistence still layers on in the
browser. All admin pages serve 200 again.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The admin side of self-serve onboarding — clear the queue of submitted shops.

- Applications page: lists SUBMITTED shops with submitted-time, an external map
  link to eyeball the pinned location, a "view permit" link (presigned R2 GET,
  degrades when R2 unset / no proof), and Approve / Reject-with-reason actions.
  Nav entry + hover-prefetch; live-ish (staleTime 15s + refetchOnMount).
- AdminShopView now carries permitKey + photoKeys so the reviewer can open proof.
- seed: one SUBMITTED application ("Ayala Suds (pending)", owner dev-pending-owner)
  so the queue has content in dev/e2e.
- browser smoke: renders the pending shop + approve/reject controls (non-mutating,
  idempotent). admin-applications green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…y plan

Postgres is temporarily on Neon's free tier (Singapore) while the Railway trial
is spent; once the Railway Hobby plan is provisioned the DB moves onto Railway
alongside the API (Docker deploy, colocated) — a DATABASE_URL change only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…g D backend)

The second onboarding pipeline — mirror of shops. Riders sign up, submit
documents, and are only dispatchable once an admin verifies them.

- schema: RiderProfile (1:1 User) + RiderStatus DRAFT→SUBMITTED→VERIFIED→REJECTED
  + doc keys (licenseKey, idKey) + vehicle. Migration adds the table + FK.
- dispatch GATE: manual assign (riders.service.listRiders) + auto-dispatch
  (orders.repository.pickAutoDispatchRider) now require riderProfile status
  VERIFIED — an unverified rider never gets a job. Seed gives dev riders VERIFIED
  profiles so dispatch keeps working.
- self-serve /rider/onboarding (any-auth, ownership-scoped): start (become RIDER +
  DRAFT profile, idempotent), GET mine, PATCH (docs + vehicle), submit (required
  set → SUBMITTED).
- admin /admin/riders: applications (SUBMITTED, FIFO), :id/verify (→ VERIFIED +
  notify RIDER_VERIFIED), :id/reject (→ REJECTED + reason + notify). NotificationsModule
  wired into RidersModule. Distinct paths from the rider-cash routes.
- domain + api-client: RiderProfileView, RiderApplicationView + methods. route-roles.
- tests: onboarding (start/idempotency/edit-lock/submit), admin verify/reject
  (state guard + notify), dispatch-filter assertion. 284 api tests; full flow
  curl-verified — picker 2→3 across verify, gate blocks SUBMITTED both ways.

Rider APP UI (photo capture + upload) needs R2 + camera; the backend + admin
review work today.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Applications page now reviews riders alongside shops: a "Riders" section
lists submitted rider profiles with vehicle + license/ID proof links and
Approve / Reject-with-reason. Verifying makes the rider dispatchable.

- reuses the ProofLink (presigned R2 GET, labelled license/ID; degrades when R2
  unset). Sections split into "Laundries" + "Riders" with counts.
- seed: one pending rider ("Pending Rider", dev-pending-rider, SUBMITTED) so the
  queue has content in dev/e2e.
- browser smoke: rider card renders with approve/reject (non-mutating). Both
  admin-applications tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The onboarding-D dispatch gate (riderProfile status VERIFIED) excluded the
integration test's rider — it had no profile — so auto-dispatch found no
candidate and the order stayed BOOKED. Passed locally only because seeded
verified riders masked it; CI runs against a fresh (unseeded) DB and failed.

- create the test rider with a VERIFIED riderProfile so it's dispatchable.
- teardown deletes riderProfile before the user (FK RESTRICT).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reviewers can now see the pinned storefront location without leaving the page —
the whole point of verification. Key-free OpenStreetMap embed (no TomTom key,
no dep); swap the iframe src for a TomTom tile URL once that SDK lands. External
"open larger" link kept.

Smoke asserts the map iframe renders; both admin-applications tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The long-standing "map on /shops" — all shops as color-coded pins (green=live,
amber=awaiting review, grey=draft/rejected/inactive) over a real map, with a
hover tooltip. react-leaflet + CircleMarkers (no icon assets) on free OpenStreetMap
tiles — no TomTom key. Loaded client-only via next/dynamic (Leaflet needs window).
To move to TomTom later, swap the TileLayer url; nothing else changes.

Build clean; admin-shops smoke asserts the Leaflet map renders.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…or filter

Addresses the three findings the /review flagged on this branch.

- AUTH fail-safe hole (P2, security): NODE_ENV is now REQUIRED in the env schema
  (was `.default('development')`). @nestjs/config wrote that default back into
  process.env, so an unset NODE_ENV read as 'development' and let AUTH_DEV_BYPASS=1
  slip past assertAuthConfigSafe → x-dev-uid could impersonate ADMIN on any
  non-Docker deploy. Required = such a deploy refuses to boot outright. Dev (.env)
  + prod (Docker ENV) both set it; jest sets test.
- Remittance double-pay (P2, money): closeBatchTx now checks the count from
  assignLinesToBatch (which only claims still-unbatched lines). On a concurrent
  close the loser claims fewer than it created → count mismatch → throw to roll
  back, so no phantom batch (totalPhp set, lines missing) can be marked paid twice.
- Exception filter (P2, coverage): new all-exceptions.filter.spec — Prisma-code→
  HTTP mapping + the security guarantee that a 5xx never leaks the underlying
  error/stack to the client.
- tests: remittance concurrency guard + the filter spec. 290 api tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…coverage)

The remaining low-risk /review items — the branch now has zero open findings.

- idempotency actor-scoping (privacy): POST /orders + rider-cash deposits only
  return a reused-key row when it belongs to the same actor (customerId / riderId
  match). A guessed/collided key can no longer disclose another user's order or
  deposit — it falls through to the unique-constraint 409 instead.
- Sentry PII: the exception filter reports the request PATH only (strips the query
  string), so a home address in /geocode?q=… or ?lat&lng never reaches Sentry.
- cross-package parity (new parity.spec): domain ORDER_STATUSES must equal the
  Prisma-generated enum (real schema-drift guard, not a hardcoded copy), and the
  API + domain load catalogs must agree on keys + estimate kg.
- coverage: admin-shops list/get/update success paths now tested.
- 298 api tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…PI build)

The parity spec value-imported @wash-and-go/domain, which jest tolerated but
`nest build` rejected (TS2307) — the API can only TYPE-import the workspace
packages (they ship as unbuilt TS source). Read the domain files as text and
regex-extract the values instead, so the schema-drift + load parity checks run
without a runtime module import. API build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 25, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
wash-and-go-admin Ready Ready Preview, Comment Jul 25, 2026 1:10pm
wash-and-go-landing Ready Ready Preview, Comment Jul 25, 2026 1:10pm

@Banyel3

Banyel3 commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

Closing — review-only slice for reading the PR #1 diff in chunks. All findings addressed; PR #1 remains the (not-yet) merge vehicle.

@Banyel3 Banyel3 closed this Jul 26, 2026
@Banyel3
Banyel3 deleted the review/s8-onboarding-hardening branch July 26, 2026 03:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant