Files
baya-monorepo/archive/docs/flows/public-front-door.md
T
2026-08-02 20:01:31 +03:30

142 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Flow — public front door
> Last verified: 2026-08-02 against commit `c841bde`
**Actor(s):** guest (unauthenticated) · **Status:** partial — what is built works; tier (c) guest browse is not built
**Client:** real (rewrite, gating and `?next=` all verified against a production build) · **Server:** real
**Business source:** no dedicated business area — the depth decision lives in
[product/notes/open-questions.md § "Decided — public guest-browse depth"](../../product/notes/open-questions.md);
the nearest requirement is "a customer can register and browse with only a verified phone" in
[product/business/01-actors-and-onboarding.md](../../product/business/01-actors-and-onboarding.md)
**Integration:** [docs/integration/api-contract.md § The anonymous surface](../integration/api-contract.md) ·
[domains/search.md](../integration/domains/search.md)
## What it does
A visitor with no session lands on `/` and is meant to see a marketing landing — what Balinyaar is, the
service categories, how escrow works, why nurses are trusted — plus a nurse-recruitment CTA and the legal
pages. Everything else redirects to login, carrying the attempted URL so a deep link survives the round
trip. Guest **browse** (searching nurses, opening a public profile without logging in) was deliberately
**not** built: tier (c) is still deferred.
## Screens
| Step | Route | Component / notes |
| --- | --- | --- |
| 1 | `/fa` (guest) | Middleware **rewrites** — never redirects — to `/{locale}/welcome`, so the URL and SEO canonical stay `/` ([`client/middleware.ts:35-37`](../../client/middleware.ts)) |
| 1 | `/fa` (signed in) | Falls through to the customer Home; a **nurse/admin** hitting `/` is a `RoleGuard` mismatch and is bounced to their own app |
| 2 | `/fa/welcome` | `(public-routes)/welcome/WelcomeScreen.tsx` — RSC with **zero query hooks**; hero, 5 static category tiles (linking to login, not to search), 3-step "how it works" with the verbatim `EscrowNotice`, 4-row trust explainer, nurse CTA (`/login?role=nurse`), footer. A signed-in hit here 307s to `/` (`middleware.ts:40-42`) |
| 3 | `/fa/terms`, `/fa/privacy` | Static RSC legal copy, 8 / 7 sections, both fronted by a «پیش‌نویس» draft banner — **not legally reviewed**. Linked from the welcome footer and the login consent line |
| 4 | `/fa/login` | Phone-OTP entry — see the auth flow, not this file |
| 5 | any unmatched `/fa/*` | `[...rest]/page.tsx` calls `notFound()` → branded `[locale]/not-found.tsx` («صفحه پیدا نشد») |
| — | render throw | `[locale]/error.tsx` («مشکلی پیش آمد») inside the locale providers; `app/global-error.tsx` above it |
| — | `/robots.txt`, `/sitemap.xml` | `app/robots.ts` disallows 13 private roots per locale; `app/sitemap.ts` emits only `''`, `/login`, `/terms`, `/privacy` × 2 locales |
`PUBLIC_PATHS = ['/login','/terms','/privacy','/welcome']`
([`src/constants/routes.ts:210`](../../client/src/constants/routes.ts)), matched with `startsWith`.
**`ROUTES.HOME` ('/') must never be added** — `startsWith('/')` would un-gate every route. That trap is why
the guest root is an exact-match rewrite instead of a `PUBLIC_PATHS` entry.
## API
The front door itself calls **nothing** — welcome, terms and privacy are static Server Components. The 20
anonymous server operations exist for other flows (and for the unbuilt tier c). Shapes:
[api-contract.md](../integration/api-contract.md).
| Call | Endpoint | Notes |
| --- | --- | --- |
| OTP pair | `POST /auth/request_otp`, `POST /auth/verify_otp` | the only anonymous ops the front door actually reaches, via `/login` |
| Reference reads | `GET /catalog/categories`, `/catalog/option_groups`, `/geo/{provinces,cities,districts,tree}` | probed **without a token → 200**. The welcome grid does **not** use them (static i18n labels) |
| Public nurse reads | `GET /nurses/{id}/profile`, `/trust_badge`, `/reviews`, `/review_tags`, `/nurse_variants/get/{id}` | anonymous **today**, but no guest UI consumes them |
| Search | `GET /search/nurses` | anonymous; **requires** `cityId` and `serviceCategoryId` > 0 — bare call returns `400`, so there is no open browse |
| Ping | `GET /ping/get_status`, `/get_status_rate_limited` | anonymous liveness |
| Webhooks | `POST /webhooks/payments/{p}`, `/webhooks_bnpl/{p}`, `/webhooks/payouts/{p}` | provider callbacks, not a UI surface |
| Dev | `GET /dev/last_otp/{phone}` | Development-only, anonymous — see [testing-setup.md](testing-setup.md) |
## Rules that must hold
| Rule | Source |
| --- | --- |
| `/` **forks by auth with a rewrite, never a redirect** — the guest URL and canonical stay `/` | [`middleware.ts:31-37`](../../client/middleware.ts), welcome `generateMetadata` sets `alternates.canonical = '/{locale}'` |
| `'/'` is never in `PUBLIC_PATHS` (the `startsWith` trap) | [`routes.ts:205-210`](../../client/src/constants/routes.ts), client/CLAUDE.md hard rule 16 |
| The middleware auth check is **UX only**`isTokenAlive` cannot read a JWE claim, so any well-formed 5-part token counts as alive. Never a security boundary | client/CLAUDE.md hard rule 17 |
| `?next=` is validated same-origin **and** role-permitting on the way out (`resolvePostLoginDestination`); `/partner*` is never a valid target | [`services/auth/routing.ts`](../../client/src/services/auth/routing.ts) |
| The escrow line on the landing is **product-mandated verbatim fa copy**, never paraphrased | `EscrowNotice.tsx:8-9` |
| Guest browse depth = tiers (a)+(b) only; tier (c) is deferred pending REQ-066/067 + a privacy sign-off | [open-questions.md §3.1](../../product/notes/open-questions.md) |
| Welcome must never wait on an API call — RSC, zero query hooks in the whole tree | `WelcomeScreen.tsx:22-30` |
**Tier (c) is still deferred — confirmed.** REQ-066/067 remain open, but *narrower than filed*: both
endpoints are **already anonymous** on the server (probed below). What is missing is (a) the rate limit —
`SearchController`/`NursesController` carry no `[EnableRateLimiting]`, so guest traffic falls to the 100/min
global per-IP limiter — and (b) the privacy review of the profile payload. See
[domains/search.md:80-81](../integration/domains/search.md). No guest search/profile route exists in the
client.
## How to test
1. Log in as nobody — this flow is the logged-out surface. Boot per [testing-setup.md](testing-setup.md);
use a **private window** (or clear `access_token`/`refresh_token`).
2. **Prove the anonymous surface, no token.** All seven returned `200`:
```bash
for u in nurses/1/profile nurses/1/trust_badge "nurses/1/reviews?page=1&pageSize=2" \
catalog/categories geo/provinces ping/get_status nurses/3/profile; do
curl -s --noproxy '*' -o /dev/null -w "$u %{http_code}\n" "http://localhost:5002/api/v1/$u"; done
```
**Expect:** seven `200`s. `nurses/1/trust_badge` → `isVerified: true`, `credentialTypes:
["criminal_record","moh_competency_license"]`; `nurses/1/reviews` → `averageRating: 5.00,
publishedCount: 1`. A bare `GET /search/nurses` → **`400`** («City Id must be greater than 0») — that is
correct, not a defect.
3. Open `http://localhost:3000/fa/welcome`. **Expect:** the marketing landing — brand lockup + tagline,
«شروع کنید» CTA, 5 category tiles, «چطور کار می‌کند» with the escrow callout, the trust card, the nurse
CTA, and terms/privacy footer links. Tab title is plain «بالین‌یار» (welcome sets no `title`).
4. Open `/fa`. **Expect:** the *same* body as step 3 with the URL still `/fa` — the rewrite, not a redirect.
5. Open `/fa/terms` and `/fa/privacy`. **Expect:** 8 and 7 numbered sections, each under the blue
«این متن پیش‌نویس است…» draft banner.
6. Open `/fa/zzz-nope` **logged out**. **Expect:** `307 → /fa/login?next=%2Fzzz-nope` — the auth gate runs
before routing, so a guest never reaches the branded «صفحه پیدا نشد» card. Log in first to see it.
7. Open `/fa/bookings` logged out. **Expect:** `307 → /fa/login?next=%2Fbookings`.
8. `curl /robots.txt` and `/sitemap.xml`. **Expect:** `200`; robots disallows `/*/bookings`, `/*/nurse`,
`/*/admin`, … ; the sitemap lists exactly 4 URLs × 2 hreflang alternates.
> **Run these against a production build, not `next dev`.** `cd client && npm run build && PORT=3001 npm run start`.
> Under `next dev` bare `/` returns `404` (a Turbopack root-path quirk) and stale `.next/` prerenders can be
> served ahead of the middleware, which makes every gate above look broken when it is not.
**Verified 2026-08-02 against `npm run build` (exit 0) on `:3001`:** `/` → `307 → /fa` · guest `/fa` → `200`
and byte-identical to `/fa/welcome` bar the URL (213 894 vs 213 906 chars) · `/fa/{bookings,profile,wallet,
admin,nurse,select-role,zzz-nope}` → `307 → /fa/login?next=…` each · `/bookings` → `307 → /fa/bookings` ·
`/robots.txt` and `/sitemap.xml` → `200`.
## Known gaps
- **`next dev` cannot be used to check any of this — and it looks broken when it is not.** On the dev
server every gate above appeared to fail: `/fa` served the customer Home's metadata
(`<title>اپلیکیشن خانواده | بالین‌یار</title>`) instead of the welcome page, `/bookings` returned `200`
rather than `307`, `/fa/{profile,wallet,admin,nurse,select-role}` all returned `200`, and no
`Link: …hreflang` header was emitted even though `middleware.ts:62-65` always copies one on. Every
response carried `x-nextjs-cache: HIT` / `x-nextjs-prerender: 1` — stale prerenders being served ahead of
the middleware. A fresh `npm run build` + `npm run start` on `:3001` reproduced **none** of it; all gates
behave exactly as coded. The gap is in the dev-server experience, not the product: anyone auditing guest
routing against `next dev` will file bugs that do not exist.
- `GET /api/v1/nurses/{id}/profile` returns `avatarUrl` as a **`file:///C:/Users/<user>/AppData/Local/Temp/
balinyaar-object-storage/avatars/nurse/1/…png`** — a local filesystem path, to an **anonymous** caller.
A browser cannot load it, and it discloses the host path. Deployed it becomes `file:///app/data/…`,
equally unusable. Blocks REQ-067 and breaks any avatar on a public profile.
- `GET /api/v1/nurses/3/profile` returns `200` **anonymously for the unverified nurse** (مریم احمدی,
`in_review`, `isBookable: false`) — the persona that "must never appear in search". Search correctly
hides her; the by-id profile read does not. Enumerable by incrementing `nurseId`.
- No anonymous rate limit on the public reads: `SearchController` and `NursesController` carry no
`[EnableRateLimiting]`, so guest traffic falls to the shared 100/min global per-IP limiter (REQ-066).
- `/terms` and `/privacy` ship **placeholder legal copy** behind a draft banner — flagged for human/legal
review since ui-phase-3 and still unreviewed.
- Tier (c) unbuilt: there is no guest search-results screen, no public nurse profile route, and no
guest→login handoff at the «درخواست رزرو» tap. The welcome category tiles link to `/login`, not `/search`.
- `welcome/opengraph-image.tsx` is **Latin-only** — `ImageResponse`'s fallback font has no Persian glyphs,
so the fa OG card cannot show the Persian tagline until a Mikhak font buffer is embedded.
- `/fa/welcome` has **no in-app link** — `ROUTES.WELCOME` appears only in `middleware.ts`. It is reachable
only as the rewrite body of `/` or by typing the URL, so the rewrite failing hides the page entirely.
- `NEXT_PUBLIC_SITE_URL` is unset in `.env.development`, so dev `robots.txt`/`sitemap.xml`/OG tags fall back
to `http://localhost:3000`. Correct in `.env.production`; only a dev-preview caveat.
- `GET /api/v1/dev/last_otp/{phone}` is anonymous and Development-only, and the deployment runs as
Development — so it is live on `api.balinyaar.ir`, returning any registered phone's login code.