cleanup phases 3
This commit is contained in:
@@ -1,5 +1,12 @@
|
|||||||
# Balinyaar — Manual Testing Plan
|
# Balinyaar — Manual Testing Plan
|
||||||
|
|
||||||
|
> **SUPERSEDED (2026-08-02) by [`docs/flows/`](../../docs/flows/index.md).** This file was written on
|
||||||
|
> 2026-07-26 and parts of it no longer work: it tells you to set crypto keys with `dotnet user-secrets`
|
||||||
|
> (removed in `5885280`), calls the API `https://localhost:5002` (it is plain HTTP), and promises the OTP
|
||||||
|
> code appears in the server console (it never does). The verified replacements are
|
||||||
|
> [`docs/flows/testing-setup.md`](../../docs/flows/testing-setup.md) for bring-up and
|
||||||
|
> [`docs/flows/index.md`](../../docs/flows/index.md) for the per-flow walkthroughs. Kept as a record.
|
||||||
|
|
||||||
The flow-by-flow walkthrough for testing the whole product against the **seeded lifecycle demo world**.
|
The flow-by-flow walkthrough for testing the whole product against the **seeded lifecycle demo world**.
|
||||||
Written after the UI/backend/refinement phase chains completed (2026-07-26). Companion docs:
|
Written after the UI/backend/refinement phase chains completed (2026-07-26). Companion docs:
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# Run the whole app locally — Balinyaar bring-up runbook
|
# Run the whole app locally — Balinyaar bring-up runbook
|
||||||
|
|
||||||
|
> **SUPERSEDED (2026-08-02) by [`docs/flows/testing-setup.md`](../../../docs/flows/testing-setup.md)**,
|
||||||
|
> which was executed rather than transcribed. This file is right about `user-secrets` but wrong about six
|
||||||
|
> other things — the API scheme (`http`, not `https`), the dev cert step, `.env.development`'s value, the
|
||||||
|
> committed `Seams:Sms:Provider` (it is `telegram`, so `request_otp` 500s on a fresh clone), the console
|
||||||
|
> OTP line (the code is never printed), and "only `auth` is real" (15 of 22 client domains are real).
|
||||||
|
> Kept as a record.
|
||||||
|
|
||||||
The one copy-pasteable procedure for standing up **both** projects on one machine and watching a real,
|
The one copy-pasteable procedure for standing up **both** projects on one machine and watching a real,
|
||||||
authenticated request cross the wire. Established by **Refinement Phase 0**
|
authenticated request cross the wire. Established by **Refinement Phase 0**
|
||||||
([refinement-phase-0-bring-up.md](refinement-phase-0-bring-up.md)).
|
([refinement-phase-0-bring-up.md](refinement-phase-0-bring-up.md)).
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
# Mock & integration registry
|
# Mock & integration registry
|
||||||
|
|
||||||
|
> **The frontend half of this file is stale (checked 2026-08-02).** Its "Config flag … default `true`"
|
||||||
|
> column was never updated after the refinement-phase-4 de-mock, so **8 rows name the wrong default** and
|
||||||
|
> three are wrong about behaviour, not just configuration. 17 disagreements with the code are listed, with
|
||||||
|
> file:line evidence, in [`docs/flows/index.md`](../../../docs/flows/index.md#corrections-to-mocks-registrymd).
|
||||||
|
> **Read the code-derived map there, not the flag column here.** The backend seam rows (the top of this
|
||||||
|
> file) were not re-audited; the current server seam picture is in
|
||||||
|
> [`docs/flows/index.md`](../../../docs/flows/index.md#mock-vs-real-map--server-seams). Kept as a record.
|
||||||
|
|
||||||
The master list of every external dependency that is **mocked behind a DI seam** in this build, and the
|
The master list of every external dependency that is **mocked behind a DI seam** in this build, and the
|
||||||
exact steps to make each one real. Backend lane owns this file; every phase that introduces or touches a
|
exact steps to make each one real. Backend lane owns this file; every phase that introduces or touches a
|
||||||
seam updates its row. This is the checklist the team works through to go from "MVP with mocks" to
|
seam updates its row. This is the checklist the team works through to go from "MVP with mocks" to
|
||||||
|
|||||||
+4
-2
@@ -29,7 +29,7 @@ The entry point. Start here, follow one link, stop reading.
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| [rules/](rules/index.md) | What must never be broken — the tiered rule set behind the `CLAUDE.md` files | **written** · phase 1 |
|
| [rules/](rules/index.md) | What must never be broken — the tiered rule set behind the `CLAUDE.md` files | **written** · phase 1 |
|
||||||
| [integration/](integration/index.md) | The client↔server seam in one place: contract, config, topology, OpenAPI | **written** · phase 2 |
|
| [integration/](integration/index.md) | The client↔server seam in one place: contract, config, topology, OpenAPI | **written** · phase 2 |
|
||||||
| [flows/](flows/index.md) | What is implemented, and how to test it — one file per user journey | not yet written · phase 3 |
|
| [flows/](flows/index.md) | What is implemented, and how to test it — one file per user journey | **written** · phase 3 |
|
||||||
| [status/](status/index.md) | Where the project actually is: implemented, backlog, decisions | not yet written · phase 4 |
|
| [status/](status/index.md) | Where the project actually is: implemented, backlog, decisions | not yet written · phase 4 |
|
||||||
| [roadmap/](roadmap/index.md) | Where it goes next, and what gates a launch | not yet written · phase 5 |
|
| [roadmap/](roadmap/index.md) | Where it goes next, and what gates a launch | not yet written · phase 5 |
|
||||||
| [_plan/](_plan/README.md) | The chain that builds this tree. **Temporary** — moves to `archive/` when done | in progress |
|
| [_plan/](_plan/README.md) | The chain that builds this tree. **Temporary** — moves to `archive/` when done | in progress |
|
||||||
@@ -49,7 +49,9 @@ Two documents stay outside this tree on purpose:
|
|||||||
a claim, not a fact.
|
a claim, not a fact.
|
||||||
2. **Verify, don't copy.** A load-bearing claim is checked against code or run. If it cannot be checked,
|
2. **Verify, don't copy.** A load-bearing claim is checked against code or run. If it cannot be checked,
|
||||||
it is written with an explicit `UNVERIFIED:` prefix.
|
it is written with an explicit `UNVERIFIED:` prefix.
|
||||||
3. **Write short.** A reference doc over ~400 lines should be split.
|
3. **Write short.** A reference doc over ~400 lines should be split. One deliberate exception:
|
||||||
|
[flows/testing-setup.md](flows/testing-setup.md) is the single page you hand a new tester, and splitting
|
||||||
|
it would defeat that.
|
||||||
4. **English throughout**, including in files that describe Persian UI copy.
|
4. **English throughout**, including in files that describe Persian UI copy.
|
||||||
|
|
||||||
The full convention is in [docs/rules/documentation.md](rules/documentation.md), to be enforced by a
|
The full convention is in [docs/rules/documentation.md](rules/documentation.md), to be enforced by a
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ we work. `archive/` = how we got here. A file belongs in exactly one.
|
|||||||
| 0 | [Inventory & scaffold](phase-0-inventory-and-scaffold.md) | — | 1 session | `docs/` skeleton + a disposition for all 260 files + fresh OpenAPI |
|
| 0 | [Inventory & scaffold](phase-0-inventory-and-scaffold.md) | — | 1 session | `docs/` skeleton + a disposition for all 260 files + fresh OpenAPI |
|
||||||
| 1 | [Rules consolidation](phase-1-rules-consolidation.md) | 0 | 1–2 sessions | `docs/rules/` + slim CLAUDE.md files |
|
| 1 | [Rules consolidation](phase-1-rules-consolidation.md) | 0 | 1–2 sessions | `docs/rules/` + slim CLAUDE.md files |
|
||||||
| 2 | [Integration & dependency](phase-2-integration.md) | 0 | 1 session | `docs/integration/` |
|
| 2 | [Integration & dependency](phase-2-integration.md) | 0 | 1 session | `docs/integration/` |
|
||||||
| 3 | [Flow atlas (verified)](phase-3-flow-atlas.md) | 0, 2 | 2–4 sessions | `docs/flows/` — the biggest phase |
|
| 3 | [Flow atlas (verified)](phase-3-flow-atlas.md) | 0, 2 | **done** 2026-08-02 | `docs/flows/` — 25 files, 23 flows, 283 gaps for phase 4 |
|
||||||
| 4 | [Backlog reconciliation](phase-4-backlog.md) | 0, 3 | 1–2 sessions | `docs/status/` |
|
| 4 | [Backlog reconciliation](phase-4-backlog.md) | 0, 3 | 1–2 sessions | `docs/status/` |
|
||||||
| 5 | [Roadmap & tech requirements](phase-5-roadmap.md) | 4 | 1 session | `docs/roadmap/` |
|
| 5 | [Roadmap & tech requirements](phase-5-roadmap.md) | 4 | 1 session | `docs/roadmap/` |
|
||||||
| 6 | [Archive & prune](phase-6-archive.md) | 1–5 | 1 session | `archive/`, `dev/` gone, links fixed |
|
| 6 | [Archive & prune](phase-6-archive.md) | 1–5 | 1 session | `archive/`, `dev/` gone, links fixed |
|
||||||
|
|||||||
@@ -184,13 +184,19 @@ on a mocked service, which is exactly the trap the hardening audit found.
|
|||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
- [ ] `testing-setup.md` was executed, not transcribed — the agent booted the API and the client.
|
- [x] `testing-setup.md` was executed, not transcribed — the API and the client were booted, all 8 demo
|
||||||
- [ ] Every flow file has a status backed by a code trace, or an explicit `UNVERIFIED` mark.
|
accounts logged in over the real phone-OTP round-trip, and a production client build was run to settle
|
||||||
- [ ] The mock-vs-real map was derived from code and disagreements with `mocks-registry.md` are logged.
|
the root-path question. 10 stale instructions in the predecessor docs were found and corrected.
|
||||||
- [ ] No flow file restates an API shape that `docs/integration/` already owns.
|
- [x] Every flow file has a status backed by a code trace. The 7 money/auth flows were additionally walked
|
||||||
- [ ] The index accounts for all 22 client service domains and all 14 business areas — anything with
|
against the running API by an adversarial second agent. **0 flows are `UNVERIFIED`.**
|
||||||
no flow is either intentional or a Phase 4 backlog item.
|
- [x] The mock-vs-real map was derived from code (22 domains, 153 seam operations; 26 server seams) and
|
||||||
- [ ] Every flow file carries a `Last verified:` line.
|
**17 disagreements with `mocks-registry.md` are logged** in `docs/flows/index.md`. That file now
|
||||||
|
carries a banner pointing at the correction.
|
||||||
|
- [x] No flow file restates an API shape. 126 links into `docs/integration/` instead; the only JSON block
|
||||||
|
anywhere is one `request_otp` response in `testing-setup.md`, used as a bring-up example.
|
||||||
|
- [x] The index accounts for all 22 client service domains, all 14 business areas and all 83 routes. Area
|
||||||
|
13 (Tax, Invoicing & Legal) is flagged as the weakest coverage and is a Phase 4 item.
|
||||||
|
- [x] Every flow file carries a `Last verified:` line. 477 relative links checked, 0 broken.
|
||||||
|
|
||||||
## Definition of done
|
## Definition of done
|
||||||
|
|
||||||
@@ -199,5 +205,68 @@ product without asking you a single question — and without hitting an instruct
|
|||||||
|
|
||||||
## Handoff
|
## Handoff
|
||||||
|
|
||||||
_(filled in per slice — which flows are done, which are `UNVERIFIED` and why, and the running list of
|
**Done 2026-08-02 against `c841bde`, in one session rather than the planned 2–4 slices.**
|
||||||
gaps found, which is Phase 4's primary input)_
|
|
||||||
|
### What was produced
|
||||||
|
|
||||||
|
`docs/flows/` — 25 files, ~3 500 lines: `index.md`, `testing-setup.md`, and **all 23 flow files**. The
|
||||||
|
candidate list needed no adjustment; every one turned out to be a real, distinct journey.
|
||||||
|
|
||||||
|
**Status spread: 1 `built` · 15 `partial` · 7 `mocked` · 0 `not started` · 0 `UNVERIFIED`.**
|
||||||
|
Only `auth-login-otp` is end-to-end real and observed working.
|
||||||
|
|
||||||
|
### How it was verified
|
||||||
|
|
||||||
|
The stack was booted and stayed up throughout. Every flow got the cheap check (route → hook → seam →
|
||||||
|
endpoint → controller → handler, cited `file:line`). The 7 money/auth flows got the expensive check *twice*:
|
||||||
|
once by the author, then by an independent adversarial agent told to refute the status claim. That second
|
||||||
|
pass was worth its cost — it caught a fabricated test count, five wrong `file:line` refs, two factually
|
||||||
|
wrong gaps, a business-rule citation that asserted the **opposite** of the claim it was supporting, a
|
||||||
|
fabricated source for a money constant, a self-contradicting headline, and a test step that was false
|
||||||
|
against the running world. All were corrected in place.
|
||||||
|
|
||||||
|
Verification also **mutated the shared demo DB**: booking requests 21–24 were created and driven through
|
||||||
|
accept / reject / cancel / convert to prove those paths work. They carry fresh dates (2026-08-06…08-12) and
|
||||||
|
are the only non-aged requests in the world.
|
||||||
|
|
||||||
|
### The six findings that matter most
|
||||||
|
|
||||||
|
1. **The admin backoffice cannot reach its own server.** All 16 admin GET operations `403` for both seeded
|
||||||
|
admin accounts. `DynamicPermissionService.CanAccess` grants on the literal role `"admin"` or a
|
||||||
|
per-controller `DynamicPermission` claim; the demo admins hold `super_admin`/`finance`, and **no code
|
||||||
|
anywhere writes that claim**. `USE_ADMIN_MOCK = true` hides it completely.
|
||||||
|
2. **34 of 153 client seam operations target routes that do not exist server-side** — they would `404` the
|
||||||
|
day a flag is flipped. 12 more fabricate or client-derive a value. The flag is not the honesty signal.
|
||||||
|
3. **No card payment can complete from a browser.** `MockPaymentProvider` redirects to a non-existent host.
|
||||||
|
The server money path is correct and idempotent; only the last hop is missing.
|
||||||
|
4. **VAT is computed two different ways on two surfaces** — carved out of the commission at checkout,
|
||||||
|
added to it on the invoice — differing by 341 IRR on one seeded booking. No `product/` file resolves it.
|
||||||
|
5. **Booking deadline timestamps ship without a timezone** (`DateTime`, not `DateTimeOffset`), so every
|
||||||
|
countdown the client renders is wrong by the UTC offset — 3.5 h in Tehran.
|
||||||
|
6. **The real BNPL rail is dead.** `SeedPaymentGatewaysAsync` seeds only a `Standard` gateway, never a
|
||||||
|
`Bnpl` one, so `checkout_bnpl/initiate` returns `400 "No active BNPL gateway is configured."` for every
|
||||||
|
request. The mocked UI hides it entirely.
|
||||||
|
|
||||||
|
Plus: the demo world is dated 2026-07-26 and has aged out (no `pending`/`accepted` request survived the
|
||||||
|
60-second expiry job); `/healthz/ready` is a genuine Windows-only code defect; and a re-login silently
|
||||||
|
invalidates every token previously issued to that account, so two people cannot share a demo phone.
|
||||||
|
|
||||||
|
### Input to Phase 4
|
||||||
|
|
||||||
|
**283 gaps** are recorded across the 23 files, each a plain bullet naming what breaks, for whom, and where
|
||||||
|
in the code. That list plus the 17 `mocks-registry.md` corrections is Phase 4's primary input. No `BL-###`
|
||||||
|
ids were invented — Phase 4 assigns them.
|
||||||
|
|
||||||
|
### What was left undone, and why
|
||||||
|
|
||||||
|
- **The local-Docker database path is documented but `UNVERIFIED`** — Docker is not installed on the
|
||||||
|
verification machine. The remote path was fully exercised instead.
|
||||||
|
- **The reset/reseed procedure was not executed.** It requires dropping a database shared with the
|
||||||
|
`balinyaar.ir` demo deployment; that is the owner's call, not a doc-writing side effect.
|
||||||
|
- **No screen was driven in a browser.** Everything client-side is a code trace plus HTTP-level probing of
|
||||||
|
the dev and production servers. Where that limits a claim, the file says so.
|
||||||
|
- **Backend seam rows in `mocks-registry.md` were not re-audited** row-by-row; the current server picture is
|
||||||
|
the seam table in `docs/flows/index.md` instead.
|
||||||
|
- `dev/post-phase/manual-testing-plan.md`, `dev/post-phase/refinement/RUNBOOK.md` and
|
||||||
|
`dev/shared-working-context/reports/mocks-registry.md` now carry supersession banners pointing at
|
||||||
|
`docs/flows/`. They were otherwise left intact, because `dev/` is history.
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
# Flow — account-and-settings
|
||||||
|
|
||||||
|
> Last verified: 2026-08-02 against commit `c841bde`
|
||||||
|
|
||||||
|
**Actor(s):** every signed-in actor (customer · nurse · admin · partner) · **Status:** partial
|
||||||
|
**Client:** partial · **Server:** real
|
||||||
|
**Business source:** [product/business/01-actors-and-onboarding.md](../../product/business/01-actors-and-onboarding.md)
|
||||||
|
(§(a) as-built auth/session rules; the account-hub UI itself is a UI-phase-9 decision with no product-doc source
|
||||||
|
— see the coverage note in the business-area map)
|
||||||
|
**Integration:** [docs/integration/domains/profiles.md](../integration/domains/profiles.md) ·
|
||||||
|
[docs/integration/domains/auth.md](../integration/domains/auth.md)
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
Each of the four shells ends in a settings hub: who you are signed in as, the handful of preferences the app
|
||||||
|
keeps (appearance, language, and — for a customer — name and emergency contact), the links out to
|
||||||
|
notifications and support, and the way out of the app. Since UI phase 9 the customer's `/fa/profile` is an
|
||||||
|
**account hub**, not a profile form: each section opens its own bottom sheet. Sign-out is the one action here
|
||||||
|
that reaches the server, and it revokes the session rather than just clearing cookies.
|
||||||
|
|
||||||
|
## Screens
|
||||||
|
|
||||||
|
| Step | Route | Component / notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Customer hub | `/fa/profile` | [`(customer)/profile/page.tsx`](../../client/src/app/%5Blocale%5D/%28private-routes%29/%28customer%29/profile/page.tsx) — `ProfileSummary` + rows; three `FormDialogShell` sheets (اطلاعات شخصی / زبان / مخاطب اضطراری) over **one** `react-hook-form`, because the wire upsert has no PATCH semantics (`:76-89`) |
|
||||||
|
| Nurse hub | `/fa/nurse/more` | [`NurseMoreScreen.tsx`](../../client/src/app/%5Blocale%5D/%28private-routes%29/nurse/more/NurseMoreScreen.tsx) — `ProfileSummary` (+ `TrustBadge`), support/notification hub rows, `SettingsPanel`, `SignOutRow` |
|
||||||
|
| Admin hub | `/fa/admin/system` | [`admin/system/page.tsx`](../../client/src/app/%5Blocale%5D/%28private-routes%29/admin/system/page.tsx) — the 6 system consoles (capability-filtered) **plus** identity/appearance/sign-out. Always shown: it is the only way out |
|
||||||
|
| Partner hub | `/fa/partner/more` | [`partner/more/page.tsx`](../../client/src/app/%5Blocale%5D/%28private-routes%29/partner/more/page.tsx) — center name + MoR chip, `SettingsPanel`, `SignOutRow` |
|
||||||
|
| Appearance + language | (in every hub) | [`SettingsPanel.tsx`](../../client/src/components/settings/SettingsPanel.tsx) → [`ThemeModeSetting`](../../client/src/components/settings/ThemeModeSetting.tsx) (روشن/تیره/سیستم) + [`LocaleSwitcher`](../../client/src/components/common/LocaleSwitcher/LocaleSwitcher.tsx). **The customer hub does not use `SettingsPanel`** — it inlines `ThemeModeSetting` (`page.tsx:155`) and puts `LocaleSwitcher` inside the زبان sheet (`:207`) |
|
||||||
|
| Actor switch | customer + nurse hubs | [`ActorSwitcher`](../../client/src/layout/components/ActorSwitcher.tsx) — renders **nothing** unless the session holds both `customer` and `nurse` (`:27`). No seeded demo account is dual-role |
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| Call | Endpoint | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| identity for every hub | `GET /api/v1/me` | `MeController.cs:23`. The customer's first/last name is sourced from here by design |
|
||||||
|
| customer profile read | `GET /api/v1/customer_profiles/me` | `404 → null` (an empty form), not an error — `profiles/apis/clientApi.ts:19-26` |
|
||||||
|
| customer save (all 3 sheets) | `POST /api/v1/customer_profiles/upsert` | one call per sheet, always sending the **whole** profile — `clientApi.ts:60-75`. Names are written to the `users` row, not the profile (`UpsertCustomerProfileCommand.Handler.cs:45-57`) |
|
||||||
|
| nurse identity | `GET /api/v1/nurse_profiles/me` | supplies `avatarUrl` + verification state for the header |
|
||||||
|
| partner identity | `useMyPartnerCenter()` | **mocked** — `USE_PARTNER_MOCK = true`, and the mock resolves `MOCK_MY_CENTER_ID = 1` for any caller |
|
||||||
|
| sign out | `POST /api/v1/auth/logout` | `AuthController.cs:48` → `LogoutCommand.Handler.cs` |
|
||||||
|
|
||||||
|
Request/response shapes: [profiles.md](../integration/domains/profiles.md), [auth.md](../integration/domains/auth.md).
|
||||||
|
|
||||||
|
### Does logout actually revoke server-side? **Yes — and it revokes everywhere.**
|
||||||
|
|
||||||
|
Traced: `SignOutRow.tsx:14` / `profile/page.tsx:269` → `useLogout()` → `authApi.logout({})` →
|
||||||
|
`authClientApi.logout` (`auth/apis/clientApi.ts:47-52`) → `POST /api/v1/auth/logout` →
|
||||||
|
[`LogoutCommand.Handler.cs:25-43`](../../server/src/Core/Baya.Application/Features/Identity/Commands/Logout/LogoutCommand.Handler.cs).
|
||||||
|
Both callers send an **empty body**, and the handler treats a missing `refreshToken` as
|
||||||
|
`Everywhere`: `RevokeAllActiveForUserAsync(...)` for every active session, then
|
||||||
|
`UpdateSecurityStampAsync`, which makes every outstanding JWE access token fail the bearer handler's
|
||||||
|
stamp check. Cookies are cleared in `onSettled` **regardless of the call's outcome**
|
||||||
|
(`useLogout.ts` — `clearAuthTokens()`), so an offline sign-out still ends the local session.
|
||||||
|
|
||||||
|
Proven server-side by the integration test `RefreshAndLogoutTests.cs:41-53`, which posts `{}` — the exact
|
||||||
|
client body — and asserts the follow-up `/me` returns `401`. **Not probed live on purpose:** calling
|
||||||
|
`/auth/logout` would revoke the shared pre-minted demo token for that account and break other testers.
|
||||||
|
|
||||||
|
## Rules that must hold
|
||||||
|
|
||||||
|
| Rule | Value / source |
|
||||||
|
| --- | --- |
|
||||||
|
| Sign-out kills **all** sessions, not just this device | `LogoutCommand.cs:12` — no `refreshToken` ⇒ `Everywhere`. Session TTL itself is CONFIG `auth_session_ttl_days` = 30 |
|
||||||
|
| Self-selectable roles are `customer` and `nurse` only | `RoleNames.SelfAssignable`; an admin sub-role self-assign is a **403**. The hub has no role-change affordance at all |
|
||||||
|
| Phones are masked on `/me`-style payloads | INV-21 — live `/me` returns `0912*****10`; `ProfileSummary` renders it as a `dir="ltr"` island |
|
||||||
|
| PII is encrypted at rest | INV-21 — the emergency contact name/phone are field-encrypted; only the masked/decrypted read comes back |
|
||||||
|
| Colors come from tokens, never a literal | [docs/rules/client/theme.md](../rules/client/theme.md); the no-flash boot is **CSS-only** (client hard rule 9) |
|
||||||
|
| `prefers-reduced-motion` has exactly one gate | `src/app/globals.css` (client hard rule 10) |
|
||||||
|
|
||||||
|
**Theme mechanics (no JS boot script).** `ColorSchemeCookieSync` (`theme/ThemeProvider.tsx:20-30`) writes the
|
||||||
|
*resolved* scheme to a `color-scheme` cookie; the root layout reads it (`getThemeMode`,
|
||||||
|
`lib/cookies/server.ts:42-50`) and stamps `data-mui-color-scheme` on `<html>` server-side
|
||||||
|
(`[locale]/layout.tsx:112,125`). With no cookie the attribute is omitted and `tokens.css`'s
|
||||||
|
`@media (prefers-color-scheme: dark)` fallback paints instead. `ThemeModeSetting` is the **only** subtree
|
||||||
|
subscribed to `useColorScheme()`.
|
||||||
|
|
||||||
|
## How to test
|
||||||
|
|
||||||
|
1. Log in as **09120000010** (سارا محمدی, customer) — see [testing-setup.md](testing-setup.md).
|
||||||
|
2. Open `/fa/profile`. **Expect:** initials avatar, «سارا محمدی», masked `0912*****10`, a «تماس اضطراری»
|
||||||
|
card with a **green** check icon reading «بهرام محمدی · 09121110010», and rows for
|
||||||
|
اطلاعات شخصی / نشانیها / زبان / نمایش / اعلانها / پشتیبانی, then a red «خروج از حساب» row.
|
||||||
|
3. Tap the **نمایش** segments روشن → تیره. **Expect:** the whole app repaints immediately, no reload; reload
|
||||||
|
the page and the choice survives (the `color-scheme` cookie), with no flash of the wrong scheme.
|
||||||
|
4. Tap **زبان** → the globe button. **Expect:** the URL becomes `/en/profile` — the *same* page, not home.
|
||||||
|
The «زبان برنامه» select below it saves `preferredLanguage` to the server but **changes nothing visible**
|
||||||
|
(see gaps).
|
||||||
|
5. Tap **اطلاعات شخصی**, change the family name, save. **Expect:** a «ذخیره شد» toast and the header name
|
||||||
|
updates (the mutation invalidates `/me`).
|
||||||
|
6. Tap **خروج از حساب** → confirm «خروج از حساب؟». **Expect:** you land on `/fa/login`. Re-using that
|
||||||
|
account's old bearer token against `GET /api/v1/me` now returns `401`.
|
||||||
|
7. Log in as **09120000001** (زهرا عزیزی, nurse) and open `/fa/nurse/more`. **Expect:** name + a green
|
||||||
|
✓ تاییدشده `TrustBadge`, the نمایش/زبان panel, and a «خروج» button that signs out on **one tap, with no
|
||||||
|
confirmation** (unlike the customer hub).
|
||||||
|
8. `/fa/admin/system` as **09120000020**: the identity card renders, but the role label under the name is the
|
||||||
|
raw key `admin.role_super_admin` (see gaps). The six console rows render; every one of them 403s — that is
|
||||||
|
the [admin RBAC gap](testing-setup.md#-the-seeded-admins-cannot-reach-any-admin-endpoint), not this flow.
|
||||||
|
9. `/fa/partner/more` — **type the URL**; nothing links to `/fa/partner`, and `09120000030` has no partner
|
||||||
|
role (`/me` → `["customer"]`). Whatever center name you see is mock data.
|
||||||
|
|
||||||
|
**Seeded-world caveat:** every seeded customer already has an emergency contact, so the 400 in the first gap
|
||||||
|
below **will not reproduce on a demo account**. To see it, sign in with a fresh phone, complete onboarding,
|
||||||
|
then edit only the name.
|
||||||
|
|
||||||
|
## Known gaps
|
||||||
|
|
||||||
|
- **A customer with no emergency contact cannot save their name or language.** `save()`
|
||||||
|
(`(customer)/profile/page.tsx:97-116`) always posts the whole profile, and
|
||||||
|
`UpsertCustomerProfileCommand.Validator.cs:9-13` requires `DefaultEmergencyContactName` non-empty and
|
||||||
|
`DefaultEmergencyContactPhone` to be a valid Iranian mobile. Verified live: `POST customer_profiles/upsert`
|
||||||
|
with empty emergency fields → **400** (`'Default Emergency Contact Name' must not be empty.`). Only
|
||||||
|
`saveEmergency` pre-validates (`:120-123`); `savePersonal`/`saveLanguage` do not.
|
||||||
|
- **That 400 is completely silent.** `useUpsertCustomerProfile` has no `onError`, the call site passes only
|
||||||
|
`onSuccess`, and `clientFetch` deliberately does not toast non-401/403/5xx 4xx
|
||||||
|
(`lib/api/client.ts:11`). The sheet just stays open — a client hard-rule-22 violation.
|
||||||
|
- **`preferredLanguage` is stored and never consumed.** It round-trips server-side
|
||||||
|
(`CustomerProfileRepository.cs:25`), but the only client reads are the form's own default and its save
|
||||||
|
(`profile/page.tsx:85,105`). The UI locale comes from the URL via next-intl; nothing reads the stored
|
||||||
|
preference at login or on the server. The زبان sheet therefore shows **two** language controls that do
|
||||||
|
different things.
|
||||||
|
- **Nurse avatars never load.** `LocalDiskObjectStorage.GetUrl` returns a `file://` URI (`:51`); live
|
||||||
|
`GET /nurse_profiles/me` for nurse 1 returns
|
||||||
|
`avatarUrl: "file:///C:/Users/.../avatars/nurse/1/....png"`, which a browser will not fetch from an
|
||||||
|
`http://` page. `ProfileSummary` degrades to the name's first letter. Affects the deployed `local`
|
||||||
|
provider too — only `Seams:ObjectStorage:Provider = s3` would emit a usable URL.
|
||||||
|
- **The admin hub renders a raw translation key as the role label.** `admin/system/page.tsx:79` calls
|
||||||
|
``ta(`role_${primaryRoleCode}`)``; `admin.role_super_admin` / `role_finance` / `role_support` /
|
||||||
|
`role_moderation` / `role_admin` exist in **neither** `messages/fa.json` nor `en.json`. No `onError`/
|
||||||
|
`getMessageFallback` is configured in `i18n/request.ts`, so next-intl renders the key path.
|
||||||
|
`npm run check` misses it — `check-copy.mjs` lints orthography and en/fa symmetry, not key existence.
|
||||||
|
- **The partner settings hub shows fabricated identity.** `USE_PARTNER_MOCK = true` and the mock resolves
|
||||||
|
`MOCK_MY_CENTER_ID = 1` for **any** caller, so the center name and the merchant-of-record chip are the same
|
||||||
|
for everyone. There is no real tenancy on this screen.
|
||||||
|
- **Sign-out confirmation is inconsistent.** The customer hub gates it behind a `ConfirmDialog`
|
||||||
|
(`profile/page.tsx:259-271`); nurse, admin and partner use `SignOutRow`, which fires on the first tap. One
|
||||||
|
mis-tap on `/fa/nurse/more` ends the session — and, per the handler, every other session too.
|
||||||
|
- **A customer cannot set an avatar.** `POST /api/v1/customer_profiles/avatar` is live but the client's
|
||||||
|
`uploadAvatar` targets the nurse route only (`profiles/apis/clientApi.ts:100-109`); the customer hub passes
|
||||||
|
`initialsFallback` and no `avatarUrl`. Already recorded in
|
||||||
|
[profiles.md](../integration/domains/profiles.md).
|
||||||
|
- **No notification-preference surface exists.** The «اعلانها» row deep-links to the notification *centre*;
|
||||||
|
there is no per-channel opt-in/out anywhere in the app.
|
||||||
|
- **The nurse hub's support badge can never show a number.** `ticketsApi.getUnreadTotal` on the real path is
|
||||||
|
literally `async () => null` (`tickets/apis/clientApi.ts:223`, REQ-059), so `useSupportUnreadTotal()`
|
||||||
|
always returns `null` and `NurseMoreScreen.tsx:40` renders no badge.
|
||||||
|
- **The customer hub navigates with a hand-built locale prefix.** `profile/page.tsx:66,129` imports
|
||||||
|
`useRouter` from `next/navigation` and does ``router.push(`/${locale}${path}`)`` — client hard rule 13
|
||||||
|
requires `@/i18n/navigation`. Works today; breaks silently if `localePrefix` ever changes.
|
||||||
|
- **UNVERIFIED (no browser in this environment): a system-mode user may see one scheme flip after
|
||||||
|
hydration.** `getThemeMode` collapses the cookie to a concrete `defaultMode` of `dark`/`light`
|
||||||
|
(`lib/cookies/server.ts:47-48`), while `ThemeModeSetting` reads MUI's `mode`, which can still be
|
||||||
|
`'system'`. If the OS scheme changed since the last visit, the SSR paint and the post-hydration resolution
|
||||||
|
disagree. Code-traced only; not observed rendering.
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# Flow — addresses and map
|
||||||
|
|
||||||
|
> Last verified: 2026-08-02 against commit `c841bde`
|
||||||
|
|
||||||
|
**Actor(s):** customer · **Status:** partial
|
||||||
|
**Client:** real · **Server:** real
|
||||||
|
**Business source:** [product/business/01-actors-and-onboarding.md](../../product/business/01-actors-and-onboarding.md)
|
||||||
|
(`customer_addresses`) · pin purpose: [06-evv-and-service-delivery.md](../../product/business/06-evv-and-service-delivery.md)
|
||||||
|
**Integration:** [addresses.md](../integration/domains/addresses.md) · [geography.md](../integration/domains/geography.md)
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
The family's address book: where the nurse is asked to come. Each address is a province → city →
|
||||||
|
(optional) district choice plus a free-text street line and a map pin; exactly one address is primary,
|
||||||
|
and the primary one is preselected on the C4 booking-request form. The pin is what the nurse's EVV
|
||||||
|
check-in is later measured against, so it is the reason the map exists at all.
|
||||||
|
|
||||||
|
## Screens
|
||||||
|
|
||||||
|
| Step | Route | Component / notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | `/fa/addresses` | [`(customer)/addresses/page.tsx`](../../client/src/app/%5Blocale%5D/(private-routes)/(customer)/addresses/page.tsx) — `'use client'`, no `generateMetadata`. List / skeleton / `ErrorState` / `EmptyState`, add-edit `FormDialogShell`, delete `ConfirmDialog` |
|
||||||
|
| 1a | — | [`AddressCard.tsx`](../../client/src/components/geography/AddressCard.tsx) — title, «شهر · منطقه», street line, «آدرس اصلی» badge, «پین ثبت شده» / «پین ندارد» |
|
||||||
|
| 2 | dialog | [`AddressForm.tsx`](../../client/src/components/geography/AddressForm.tsx) — react-hook-form. Required: title, city, **pin**, street line. District optional |
|
||||||
|
| 2a | dialog | [`CascadingRegionSelect.tsx`](../../client/src/components/geography/CascadingRegionSelect.tsx) — three `TextField select`s; the empty district option is the explicit «کل شهر» choice (`:152`) |
|
||||||
|
| 2b | dialog | [`AddressMapPicker.tsx`](../../client/src/components/geography/AddressMapPicker.tsx) — **branches on `NESHAN_WEB_KEY` at `:43`**. Key set → `NeshanMap` (Leaflet + Neshan tiles, search box, locate-me, reverse-geocoded preview). Key unset → `GridFallbackMap` (`:78`) |
|
||||||
|
| 3 | `/fa/bookings/request` | C4 reads the book (`page.tsx:116`) and preselects the primary (`:183-184`). **Read-only** — no inline create; the empty state CTA routes to `/fa/addresses` (`:383`) |
|
||||||
|
| 3a | `/fa/profile` | account-hub row «مدیریت آدرسها» → `/fa/addresses` |
|
||||||
|
|
||||||
|
### What a tester actually sees on the map
|
||||||
|
|
||||||
|
`client/.env.development` does **not** define `NEXT_PUBLIC_NESHAN_KEY` (the variable appears only in
|
||||||
|
`.env.production`, commented out, and in `.env.sample`). `config.ts:25` therefore resolves
|
||||||
|
`NESHAN_WEB_KEY = undefined`, so `AddressMapPicker` renders the **grid stand-in**: a 220 px
|
||||||
|
`--bal-primary-soft` panel with a 28 px CSS grid, no tiles, no search box and no locate-me button. Tapping
|
||||||
|
or dragging places a teardrop pin and prints «عرض» / «طول» to 5 decimals underneath. The canvas spans
|
||||||
|
±0.06° (~±6 km) around the chosen city's centroid (`SPAN`, `:65`). It is a real coordinate emitter, not a
|
||||||
|
decoration — the value it produces is what `create`/`update` send.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
Shapes belong to [addresses.md](../integration/domains/addresses.md) and
|
||||||
|
[geography.md](../integration/domains/geography.md) — not repeated here.
|
||||||
|
|
||||||
|
| Call | Endpoint | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `useAddresses` | `GET /api/v1/customer_addresses/list` | [`clientApi.ts:46`](../../client/src/services/addresses/apis/clientApi.ts) → `ListMyAddressesQuery`. **Probed** with `T_09120000010`: `total: 1`, `provinceId: 1`, `districtId: 1003`, pin `35.7595 / 51.41`, `postalCode`/`recipientName`/`recipientPhone` all populated |
|
||||||
|
| `useCreateAddress` | `POST /api/v1/customer_addresses/create` | [`CreateAddressCommand.Handler.cs:50-62`](../../server/src/Core/Baya.Application/Features/Addresses/Commands/CreateAddress/CreateAddressCommand.Handler.cs) — a sent pin wins (`GeocodeSources.UserPin`); only a *missing* pin calls `IGeocoder` |
|
||||||
|
| `useUpdateAddress` | `POST /api/v1/customer_addresses/update/{id}` | `UpdateAddressCommand.Handler.cs:62-74` — pin wins; re-geocodes only when city/district/line changed |
|
||||||
|
| `useSetPrimaryAddress` | `POST /api/v1/customer_addresses/set_primary/{id}` | demote + promote in one transaction; the client invalidates the whole list key |
|
||||||
|
| `useDeleteAddress` | `DELETE /api/v1/customer_addresses/delete/{id}` | soft delete behind the global query filter |
|
||||||
|
| `useProvinces` / `useCities` / `useDistricts` | `GET /api/v1/geo/{provinces,cities,districts}` | anonymous, `staleTime: Infinity`. **Probed**: 31 provinces; `province_id=1` → 1 city (101 تهران); `city_id=101` → 22 districts |
|
||||||
|
| — | `GET /api/v1/geo/tree` | live (**probed: 200**) but **unwired** — the client fetches the three levels separately |
|
||||||
|
| map search / reverse | `https://api.neshan.org/v1/search`, `/v5/reverse` | [`geography/neshan.ts`](../../client/src/services/geography/neshan.ts) — a deliberate **direct third-party `fetch`**, bypassing `clientFetch` so our bearer is never sent to Neshan (`:8-9`). Both return `null` immediately while the key is unset (`:29`) |
|
||||||
|
|
||||||
|
Server seam: `IGeocoder` = `MockGeocoder` (no `Seams:Geocoding:Provider` is set anywhere) — deterministic
|
||||||
|
jitter near the city centroid; an address line containing `NO_GEO` resolves to null coordinates.
|
||||||
|
|
||||||
|
## Rules that must hold
|
||||||
|
|
||||||
|
| Rule | Source |
|
||||||
|
| --- | --- |
|
||||||
|
| **`districtId = null` on an address is optional metadata** — it widens nothing. The same null on a *nurse service area* means whole-city (INV-3). Never coerce it to `0`. | [geography.md](../integration/domains/geography.md) · [nurse-service-areas.md](nurse-service-areas.md) |
|
||||||
|
| **The client-picked pin is authoritative** — the server does not re-geocode over it (REQ-008, delivered). | [addresses.md](../integration/domains/addresses.md) |
|
||||||
|
| **`latitude`/`longitude` are nullable** — null means "saved without a map pin", a display state, never an error. | [addresses.md](../integration/domains/addresses.md) |
|
||||||
|
| **`address_snapshot_json` freezes the address onto the booking** — editing or deleting an address never rewrites history. | [product/business/05](../../product/business/05-booking-and-scheduling.md) §Snapshots |
|
||||||
|
| **A booking request sees a city/district-coarse mask, not the line** — two-stage disclosure (INV-6). | [booking-request.md](booking-request.md) |
|
||||||
|
| **The pin feeds the EVV check-in distance test at `evv_location_tolerance_meters` = 200 m, advisory only** — a mismatch raises a support alert, never blocks or cancels. | [product/business/06](../../product/business/06-evv-and-service-delivery.md) |
|
||||||
|
| **`addressLine`/`postalCode`/`recipientName`/`recipientPhone` are encrypted at rest** and decrypted only for the owner (INV-21). | [product/business/01](../../product/business/01-actors-and-onboarding.md) |
|
||||||
|
| **Exactly one primary**; the first address saved is forced primary (`Handler.cs:42-43`). | server handler |
|
||||||
|
|
||||||
|
## How to test
|
||||||
|
|
||||||
|
1. Log in as `09120000010` (سارا محمدی, customer) — see [testing-setup.md](testing-setup.md).
|
||||||
|
2. Go to `/fa/profile` → «مدیریت آدرسها», or straight to `/fa/addresses`.
|
||||||
|
**Expect:** exactly **one** card — «خانه», «تهران · منطقه ۳», the ملاصدرا street line, the «آدرس اصلی»
|
||||||
|
badge and «پین ثبت شده».
|
||||||
|
3. Tap «افزودن آدرس». Pick استان تهران → شهر تهران. **Expect:** the city select holds exactly one option;
|
||||||
|
the district select then offers «کل شهر» plus 22 «منطقه ۱…۲۲» rows.
|
||||||
|
4. Submit with the pin untouched. **Expect:** the form blocks with «روی نقشه یک پین بگذارید» — the pin is a
|
||||||
|
required field (`AddressForm.tsx:131`).
|
||||||
|
5. Tap anywhere on the grid panel, then Save. **Expect:** a «آدرس ذخیره شد» toast, the list refetches, and the
|
||||||
|
new card shows «پین ثبت شده». Confirm server-side:
|
||||||
|
`curl -s --noproxy '*' "http://localhost:5002/api/v1/customer_addresses/list?page=1&pageSize=10" -H "Authorization: Bearer $T_09120000010"`
|
||||||
|
→ `total: 2` and non-null `latitude`/`longitude` on the new row.
|
||||||
|
6. Toggle «انتخاب بهعنوان آدرس اصلی» on the new card. **Expect:** «آدرس اصلی بهروزرسانی شد» and the badge
|
||||||
|
moves — never two badges.
|
||||||
|
7. Delete the address you just created. **Expect:** «آدرس حذف شد» and `total` back to 1.
|
||||||
|
8. Pick استان اصفهان instead in step 3. **Expect (and this is the defect):** the map panel re-centres on the
|
||||||
|
**Iran centroid** (32.4279, 53.688 — empty desert), not on Isfahan, because real Isfahan is city id `103`
|
||||||
|
while `CITY_CENTROIDS` only knows `301` (see gaps).
|
||||||
|
|
||||||
|
**Do not edit the seeded address id 1** — the edit dialog silently wipes its postal code and recipient
|
||||||
|
fields (see gaps). Create a throwaway address and edit that instead. The world is shared and 7 days stale,
|
||||||
|
but nothing in this flow depends on the aged booking data.
|
||||||
|
|
||||||
|
## Known gaps
|
||||||
|
|
||||||
|
- `NEXT_PUBLIC_NESHAN_KEY` is unset in `client/.env.development` and commented out in `.env.production`, so **`NeshanMap` never renders anywhere today** — every environment gets the keyless grid stand-in. The real map path (Leaflet, tiles, address search, locate-me, reverse-geocode preview) is entirely unexercised.
|
||||||
|
- `NESHAN_TILE_URL_TEMPLATE` (`geography/constants.ts:51`) and every response field name in `geography/neshan.ts` (`items[].location.x/y`, `formatted_address`) are **UNVERIFIED against the live Neshan API** — both files say so in their own headers. Nobody has ever run this code against `platform.neshan.org`. Setting a key may yield a blank tile layer and a silently empty search box.
|
||||||
|
- `CITY_CENTROIDS` (`geography/constants.ts:27-36`) is keyed on the **mock** seed's city ids (201 Mashhad, 301 Isfahan, 401 Shiraz, …). Live probe: real cities are `101`+`provinceId`-adjacent — Tehran `101`, Karaj `102`, Isfahan `103`. Only Tehran matches. For **every other city** `cityCentroid()` falls back to the Iran centroid, so the picker opens ~400 km from the customer and the ±6 km canvas can never reach their street.
|
||||||
|
- **Editing an address through the UI destroys data.** `AddressForm.submit` (`:91-102`) never collects `postalCode`, `recipientName` or `recipientPhone`; `clientApi.toBody` (`:21-34`) coerces the missing values to `null`; `UpdateAddressCommand.Handler.cs:57-59` assigns them unconditionally. Editing seeded address 1 nulls `1991834511` / «بهرام محمدی» / `09121110010`. Silent, no warning, not recoverable from the UI.
|
||||||
|
- Those same three fields are **never displayed anywhere** either — `AddressCard` shows title/region/line only, so a customer cannot see or set a recipient name for a visit that is not for themselves.
|
||||||
|
- The pin is a **required** form field, so the client can never take the create/update path that has no pin — `IGeocoder` is unreachable from the web app, and the `latitude == null` state (and `AddressCard`'s «پین ندارد» badge) is unreachable except via a direct API call or seeded data.
|
||||||
|
- Geography is seeded **one city per province** (31 provinces, 31 cities; probed `province_id` 2/3/5 → 1 city each) and districts exist **only for Tehran** (`city_id=103` → 0). Outside Tehran the cascade is a two-step formality and «کل شهر» is the only district choice.
|
||||||
|
- `GET /api/v1/geo/tree` is live and returns 200 but no client consumes it; the cascade makes three round trips instead of one.
|
||||||
|
- `AddressMapPicker`'s pin geometry is applied via inline `style` specifically to dodge the RTL stylis plugin — correct, but **fragile and untested for RTL drift**; `AddressMapPicker.test.tsx` only asserts the keyless fallback path.
|
||||||
|
- No `product/` doc describes address entry, the map-pin picker, or the Neshan integration. `business/01` merely lists the `customer_addresses` table; geography is documented **supply-side only** (`business/04` service areas). This flow's UI rules have no business source to check against.
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
# Flow — admin-backoffice
|
||||||
|
|
||||||
|
> Last verified: 2026-08-02 against commit `c841bde`
|
||||||
|
|
||||||
|
**Actor(s):** admin (`super_admin` · `admin` · `support` · `finance` · `moderation`) · **Status:** mocked
|
||||||
|
**Client:** partial (19 of 21 consoles on mocked seams, 2 on real) · **Server:** partial (real handlers, **403 for every seeded admin**)
|
||||||
|
**Business source:** [product/business/14-notifications-and-admin.md](../../product/business/14-notifications-and-admin.md)
|
||||||
|
**Integration:** [docs/integration/domains/admin.md](../integration/domains/admin.md)
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
The single operator console behind Balinyaar: approve nurses, moderate reviews, run the weekly payout batch,
|
||||||
|
issue refunds, triage support alerts and tickets, edit the platform's runtime rates, and read the append-only
|
||||||
|
audit trail. It is the only surface where a human authorises money movement — a scheduled job may *generate* a
|
||||||
|
draft payout batch, but `process` is always an explicit admin action.
|
||||||
|
|
||||||
|
## ⚠ Read this first — the console cannot reach the server
|
||||||
|
|
||||||
|
**Every `[Authorize(ConstantPolicies.DynamicPermission)]` endpoint returns `403` for both seeded admin
|
||||||
|
accounts** (`09120000020` super_admin, `09120000021` finance). 23 controllers carry that policy. The chain:
|
||||||
|
|
||||||
|
| Link | file:line | What it does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| the gate | [`DynamicPermissionService.cs:9`](../../server/src/Infrastructure/Baya.Infrastructure.Identity/Identity/PermissionManager/DynamicPermissionService.cs) | `if (user.IsInRole("admin")) return true;` — the **literal** string, not "any admin sub-role" |
|
||||||
|
| the fallback | same file `:15-19` | else requires a `DynamicPermission` claim whose value is exactly `"{area}:{controller}:"` |
|
||||||
|
| the vocabulary | [`RoleNames.cs:13-17`](../../server/src/Core/Baya.Domain/Entities/User/RoleNames.cs) | `Admin` · `Support` · `Finance` · `Moderation` · `SuperAdmin` are five **sibling** roles — `super_admin` is not a superset of `admin` |
|
||||||
|
| the seeder | [`DemoWorldDefinitions.cs:144-145`](../../server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Seeding/DemoWorldDefinitions.cs) | grants `RoleNames.SuperAdmin` / `RoleNames.Finance` — **never** `RoleNames.Admin` |
|
||||||
|
| the missing half | [`SeedDataBase.cs:50-71`](../../server/src/Infrastructure/Baya.Infrastructure.Identity/Identity/SeedDatabaseService/SeedDataBase.cs) | the only code path that calls `AddToRoleAsync(user, "admin")`, and it returns early unless `Seed:AdminUsername` **and** `Seed:AdminPassword` are configured. Neither is in `appsettings.Development.json` |
|
||||||
|
|
||||||
|
No code anywhere writes a `DynamicPermission` claim, so branch 2 can never fire either. **No seeded account
|
||||||
|
satisfies either branch.**
|
||||||
|
|
||||||
|
**Why this is invisible in the UI:** `USE_ADMIN_MOCK = true`
|
||||||
|
([`admin/constants.ts:9`](../../client/src/services/admin/constants.ts)), plus `verification`, `payouts`,
|
||||||
|
`refunds` and `partnerCenter` are all mock-primary — so 19 of the 21 consoles render a complete, filterable,
|
||||||
|
mutable world out of in-browser fixtures and never call the API at all. The console *looks* built.
|
||||||
|
|
||||||
|
Live-probed 2026-08-02 (`super_admin` token unless noted):
|
||||||
|
|
||||||
|
| Probe | Result |
|
||||||
|
| --- | --- |
|
||||||
|
| `GET /api/v1/me` | `200` · `{"id":6,"roles":["super_admin"]}` — the token is fine |
|
||||||
|
| `GET /api/v1/tickets?page=1` (user scope, same token) | `200`, 9 tickets — non-admin endpoints work normally |
|
||||||
|
| `GET /api/v1/admin/tickets?page=1` | **`403`** `{"isSuccess":false,"statusCode":403,"message":"Authorization Error"}` |
|
||||||
|
| `GET /api/v1/admin/reviews/moderation_queue?page=1` | **`403`** |
|
||||||
|
| `POST /api/v1/platform_config/update_platform_config` (valid body) | **`403`** — writes are blocked at authz, *before* validation. Nothing mutates |
|
||||||
|
| `GET /api/v1/admin_payouts/batches` (**finance** token) | **`403`** |
|
||||||
|
|
||||||
|
## Screens
|
||||||
|
|
||||||
|
21 routes, all inside the 480 px phone frame. `RoleGuard expected=admin` admits any of the five codes;
|
||||||
|
`useAdminCapabilities()` ([`hooks/capabilities.ts:43`](../../client/src/hooks/capabilities.ts)) then hides
|
||||||
|
group tabs and rows per code — **a UI hint only**, the consoles stay URL-reachable.
|
||||||
|
|
||||||
|
| Group | Route | Client seam | Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| — | `/fa/admin` | none | Overview hub; `NavHubList` filtered by capabilities |
|
||||||
|
| اعتماد | `/fa/admin/trust` | none | Group root |
|
||||||
|
| | `/fa/admin/verification` · `/…/[nurseId]` | `verification` **MOCK** | Queue folded per-nurse; case view with `DocumentViewer` re-signing its own URL |
|
||||||
|
| | `/fa/admin/reviews` | `reviews` **REAL** | 4 moderation tabs → **403s live** |
|
||||||
|
| مالی | `/fa/admin/finance` | none | Group root |
|
||||||
|
| | `/fa/admin/payouts` · `/…/[batchId]` | `payouts` **MOCK** | Dry-run preview → idempotency-keyed run; `gross − clawback = net` |
|
||||||
|
| پشتیبانی | `/fa/admin/support` | none | Group root |
|
||||||
|
| | `/fa/admin/tickets` · `/…/[id]` | `tickets` **REAL** (+ `refunds` MOCK via `RefundPanel`) | Internal-notes composer → **403s live** |
|
||||||
|
| | `/fa/admin/alerts` | `admin` **MOCK** | `support_alerts` triage; assign-to-self, resolve with note |
|
||||||
|
| سیستم | `/fa/admin/system` | `auth` | Always shown — carries sign-out |
|
||||||
|
| | `/fa/admin/config` | `admin` **MOCK** | Typed editor per `data_type`; rate keys validated to `[0, 1)` |
|
||||||
|
| | `/fa/admin/holidays` | `admin` **MOCK** | `iranian_holidays` + the `is_bank_closed` flag |
|
||||||
|
| | `/fa/admin/audit` | `admin` **MOCK** | Read-only; rows expand to the `changed_fields` diff |
|
||||||
|
| | `/fa/admin/partners` · `/…/[id]` | `admin` + `partnerCenter` **MOCK** | IBAN write-then-masked, never displayed |
|
||||||
|
| | `/fa/admin/users` | `admin` **MOCK** | Backed by `admin_users/search` — **route does not exist** |
|
||||||
|
| | `/fa/admin/roles` | `admin` **MOCK** | RBAC grid; in-page banner says it is deferred |
|
||||||
|
| | `/fa/admin/notifications` | none | `PlaceholderScreen` stub; **true orphan**, nothing links to it |
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
Shapes belong to the integration tree — do not restate them here.
|
||||||
|
|
||||||
|
| Console | Endpoints | Contract |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| config · holidays · audit · alerts | `platform_config/*`, `holidays/*`, `audit/get_audit_trail`, `support_alerts/*` | [admin.md](../integration/domains/admin.md) |
|
||||||
|
| verification queue + case | `admin_verifications/*` | [verification.md](../integration/domains/verification.md) |
|
||||||
|
| payout batches | `admin_payouts/*` | [payouts.md](../integration/domains/payouts.md) |
|
||||||
|
| refunds (in the ticket thread) | `admin_refunds/*` | [refunds.md](../integration/domains/refunds.md) |
|
||||||
|
| review moderation | `admin/reviews/moderation_queue` | [reviews.md](../integration/domains/reviews.md) |
|
||||||
|
| ticket queue + admin thread | `admin/tickets`, `admin/tickets/{id}` | [tickets.md](../integration/domains/tickets.md) |
|
||||||
|
| partner centers | `admin/partner-centers*` | [partner-center.md](../integration/domains/partner-center.md) |
|
||||||
|
| **roles · user directory** | `admin_roles/*`, `admin_users/*` | **phantom — not on the wire** (REQ-031 deferred; REQ-061 never filed) |
|
||||||
|
|
||||||
|
Client chain, verified link by link for the config console: `admin/config/page.tsx` → `usePlatformConfigs()`
|
||||||
|
([`services/admin/hooks/usePlatformConfigs.ts`](../../client/src/services/admin/hooks/usePlatformConfigs.ts))
|
||||||
|
→ `adminApi` ([`apis/index.ts:10`](../../client/src/services/admin/apis/index.ts), the one-line selector,
|
||||||
|
currently resolving to `adminMockApi`) → `adminClientApi.listPlatformConfigs`
|
||||||
|
([`apis/clientApi.ts:75`](../../client/src/services/admin/apis/clientApi.ts)) → `clientFetch` →
|
||||||
|
`GET /api/v1/platform_config/get_platform_configs` → `PlatformConfigController` → `ListPlatformConfigs`
|
||||||
|
handler. Every link exists. Only the selector and the authz gate stand in the way.
|
||||||
|
|
||||||
|
## Rules that must hold
|
||||||
|
|
||||||
|
| Rule | Value | Source of truth | Product |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Money movement stays human-approved | the scheduler opens a **draft** batch; `process` is an explicit admin action | `WeeklyPayoutGenerationJob` + `ExecutePayoutBatch` | [10-payouts.md](../../product/business/10-payouts.md) |
|
||||||
|
| Config is rows read at compute time, and a rate change is **never retroactive** | rates snapshotted onto the row (`Bookings.PlatformFeeRate`, `Invoices.VatRate`) | CONFIG via `IPlatformConfig` | [14-…-admin.md](../../product/business/14-notifications-and-admin.md) |
|
||||||
|
| Commission / VAT | `platform_fee_rate = 0.15`, `vat_rate = 0.10`, VAT on the **commission line only** | CONFIG keys (0.15 is a seeded default, not a product mandate) | [13-tax-invoicing-and-legal.md](../../product/business/13-tax-invoicing-and-legal.md) |
|
||||||
|
| Rate keys are in `[0, 1)` | console validates before writing | `RATE_CONFIG_KEYS`, `admin/constants.ts:27` | — |
|
||||||
|
| Holidays shift the payout date | server resolves the next business day; **the client never computes a shift** | `IHolidayCalendar` ROWS | [10-payouts.md](../../product/business/10-payouts.md) |
|
||||||
|
| Audit trail is append-only | no edit/delete affordance; retention `730` d general / `2555` d financial | CONFIG `audit_retention_*_days` | — |
|
||||||
|
| `is_internal` never leaves the query layer | admin thread is the *only* surface that carries internal notes | `TicketRepository` projections | [12-messaging-and-emergencies.md](../../product/business/12-messaging-and-emergencies.md) |
|
||||||
|
| Refunds are admin-only and ticket-anchored | no customer self-service; every refund splits across both fee legs | `AdminRefundsController`, `CK_Refunds_LegSplit` | [07-cancellation-and-refunds.md](../../product/business/07-cancellation-and-refunds.md) |
|
||||||
|
| Low-rating alert threshold | `≤ 2` | CONFIG `min_rating_for_support_alert` | [11-reviews-trust-and-safety.md](../../product/business/11-reviews-trust-and-safety.md) |
|
||||||
|
| Publishing a review recomputes the nurse aggregate **server-side** | on every status transition | review moderation handlers | [11-…-safety.md](../../product/business/11-reviews-trust-and-safety.md) |
|
||||||
|
| Suspending a nurse flips `is_searchable = 0` on every one of her rows | rows are kept, never deleted | `SearchIndexMaintainer.cs:177,248` | [11-…-safety.md](../../product/business/11-reviews-trust-and-safety.md) |
|
||||||
|
|
||||||
|
## How to test
|
||||||
|
|
||||||
|
1. Log in as `09120000020` (نگار مدیری, super_admin) — see [testing-setup.md](testing-setup.md).
|
||||||
|
**Expect:** `RoleGuard` admits you and the app lands on `/fa/admin` with all four group tabs visible.
|
||||||
|
2. Walk `/fa/admin/config` → `/fa/admin/holidays` → `/fa/admin/audit` → `/fa/admin/alerts`.
|
||||||
|
**Expect:** each renders a populated, filterable console; edits appear to save.
|
||||||
|
**This proves nothing about the server** — these are `adminMockApi` fixtures held in module state, and they
|
||||||
|
reset on every page reload or HMR. Open devtools Network: **there is no request**.
|
||||||
|
3. Open `/fa/admin/tickets` and `/fa/admin/reviews` — the two consoles on real seams.
|
||||||
|
**Expect:** an error state, not a queue. `clientFetch` toasts the 403 itself.
|
||||||
|
4. Log in as `09120000021` (کامران مالی, finance) and open `/fa/admin`.
|
||||||
|
**Expect:** «اعتماد» and «پشتیبانی» tabs are gone; only «مالی» and «سیستم» remain. `/fa/admin/payouts`
|
||||||
|
renders a mock batch list. `/fa/admin/audit` is hidden (audit is `admin`/`super_admin` only) but still
|
||||||
|
URL-reachable.
|
||||||
|
5. Confirm the gap directly, without the browser:
|
||||||
|
`curl -s --noproxy '*' "http://localhost:5002/api/v1/platform_config/get_platform_configs?page=1" -H "Authorization: Bearer $T_09120000020"`
|
||||||
|
**Expect:** `403` `{"isSuccess":false,"statusCode":403,"message":"Authorization Error"}`, while
|
||||||
|
`GET /api/v1/me` with the same token returns `200`.
|
||||||
|
|
||||||
|
**Break-glass (UNVERIFIED — not executed for this stamp):** add `"Seed": { "AdminUsername": "…",
|
||||||
|
"AdminPassword": "…" }` to `appsettings.Development.json` **before boot**. `SeedDataBase.SeedBootstrapAdminAsync`
|
||||||
|
then mints a user in the literal `admin` role, which satisfies branch 1. That account is
|
||||||
|
**username/password, not phone-OTP**, so it cannot log in through the web UI — drive the API directly. It was
|
||||||
|
not tested here because it needs a server restart shared with other agents.
|
||||||
|
|
||||||
|
**Seeded-world caveat:** the demo world is 7 days stale (see
|
||||||
|
[testing-setup.md](testing-setup.md#-it-has-aged-out--and-this-is-not-cosmetic)). Even with the RBAC gap
|
||||||
|
fixed, the verification queue has no `in_review` step awaiting a decision beyond nurse 3's two blocking steps,
|
||||||
|
and every dispute window has closed — so a live payout preview would sweep bookings you did not stage.
|
||||||
|
|
||||||
|
## Known gaps
|
||||||
|
|
||||||
|
- **`DynamicPermissionService.CanAccess` grants only on the literal role `"admin"`.** The four sibling admin
|
||||||
|
roles — `super_admin`, `support`, `finance`, `moderation` — get 403 on all 23 `DynamicPermission`
|
||||||
|
controllers. `DynamicPermissionService.cs:9`. A `super_admin` has *less* access than an `admin`.
|
||||||
|
- **No seeded account holds the literal `admin` role.** `DemoWorldDefinitions.cs:144-145` grants
|
||||||
|
`super_admin`/`finance`; `SeedDataBase.cs:55-56` returns early because `Seed:AdminUsername`/`AdminPassword`
|
||||||
|
are unset. The entire backoffice is untestable end-to-end out of the box.
|
||||||
|
- **No code path ever writes a `DynamicPermission` claim**, so the per-controller fallback branch
|
||||||
|
(`DynamicPermissionService.cs:15-19`) is dead — the claim key `"{area}:{controller}:"` has no producer.
|
||||||
|
- **The 403 is invisible in the UI.** `USE_ADMIN_MOCK = true` plus mocked `verification`/`payouts`/`refunds`/
|
||||||
|
`partnerCenter` means 19 of 21 consoles never call the API. A reviewer clicking through the console concludes
|
||||||
|
it works.
|
||||||
|
- **`/fa/admin/tickets` and `/fa/admin/reviews` are broken for the operator right now** — real client seams
|
||||||
|
onto 403ing controllers. The only two consoles where the defect surfaces.
|
||||||
|
- **`admin/apis/clientApi.ts` `pageQuery()` sends `page_size`; the controllers declare `PageSize`.** Model
|
||||||
|
binding is case-insensitive, not separator-insensitive, so every admin list silently falls back to the
|
||||||
|
server default page size the moment `USE_ADMIN_MOCK` flips.
|
||||||
|
- **`/fa/admin/roles` has no server** — `admin_roles/list_roles|grant_role|revoke_role` are phantom (REQ-031
|
||||||
|
deferred). Admin roles are seeded, never managed.
|
||||||
|
- **`/fa/admin/users` has no server** — `admin_users/search|lookup` are phantom, and **REQ-061 was never filed
|
||||||
|
in the ledger** despite ten client files citing it. `AuditLogRow` shows `#id` instead of a name.
|
||||||
|
- **`/fa/admin/notifications` is a `PlaceholderScreen` stub and a true orphan** — no link reaches it, and
|
||||||
|
`AdminLayout` renders no `NotificationBell`.
|
||||||
|
- **`POST /api/v1/holidays/delete_holiday` is unwired** — the console offers no delete affordance.
|
||||||
|
- **`admin_cancellation_policies/list|upsert` are unwired** — no screen edits the cancellation tiers, so the
|
||||||
|
seeded `standard_24h`/`standard_inside_24h` rows are effectively read-only in production.
|
||||||
|
- **`admin_search/rebuild_index` and `admin_booking_requests/expire` have no UI** — ops one-shots reachable
|
||||||
|
only by curl (and both 403 for a seeded admin).
|
||||||
|
- **`useAdminCapabilities` hides tabs but does not block routes.** Every console stays URL-reachable for any
|
||||||
|
admin code; the enforcement is expected to be server-side, which is currently a blanket 403.
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# Flow — auth-login-otp
|
||||||
|
|
||||||
|
> Last verified: 2026-08-02 against commit `c841bde`
|
||||||
|
|
||||||
|
**Actor(s):** everyone (guest → customer / nurse / admin / partner owner) · **Status:** built
|
||||||
|
**Client:** real · **Server:** real
|
||||||
|
**Business source:** [product/business/01-actors-and-onboarding.md](../../product/business/01-actors-and-onboarding.md)
|
||||||
|
**Integration:** [docs/integration/domains/auth.md](../integration/domains/auth.md) ·
|
||||||
|
[api-contract.md](../integration/api-contract.md)
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
Phone number plus a six-digit SMS code is the only way into Balinyaar — there are no passwords. A verified
|
||||||
|
phone mints a rotating refresh-token session; `/me` then says who you are, and the client sends you to the
|
||||||
|
family app, the nurse app, the admin console, or a first-use role picker. A deep link you were bounced off
|
||||||
|
survives the round trip.
|
||||||
|
|
||||||
|
**This is the one flow verified end to end live.** Every status code below was observed on
|
||||||
|
`http://localhost:5002` at the stamp date, not inferred.
|
||||||
|
|
||||||
|
## Screens
|
||||||
|
|
||||||
|
| Step | Route | Component / notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Bounce | any private route | [`middleware.ts:44-55`](../../client/middleware.ts) → `/{locale}/login?next=<path>` |
|
||||||
|
| Guest front door | `/fa` (exact) | `middleware.ts:35-37` — **rewrite** to `/fa/welcome`, never a redirect; URL stays `/` |
|
||||||
|
| A1 / B1 phone | `/fa/login` | `login/page.tsx` → `LoginScreen` → [`LoginFlow`](../../client/src/components/auth/LoginFlow.tsx) → `PhoneStep`. `?role=nurse` seeds the intent; the switch link toggles copy only — **one login tree, not two** |
|
||||||
|
| A2 / B2 code | `/fa/login` | [`OtpStep.tsx`](../../client/src/components/auth/OtpStep.tsx) — 6 boxes, auto-verify on the last digit, resend countdown, `useWebOtp` autofill (REQ-039: never fires, template not conformant) |
|
||||||
|
| Routing splash | `/fa/login` | [`RoleRouter.tsx`](../../client/src/components/auth/RoleRouter.tsx) — renders `AuthSplash` while `/me` is in flight so the wrong shell never flashes |
|
||||||
|
| First-use role pick | `/fa/select-role` | [`SelectRole.tsx`](../../client/src/components/auth/SelectRole.tsx). Deliberately outside every actor group and carries **no `RoleGuard`** — this page is what resolves the role |
|
||||||
|
| Sign out | `/fa/profile`, `/fa/nurse/more`, `/fa/admin/system`, `/fa/partner/more` | `SignOutRow` → `useLogout` |
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
Shapes live in [domains/auth.md](../integration/domains/auth.md) — not restated here. Client seam
|
||||||
|
[`services/auth/apis/index.ts:10`](../../client/src/services/auth/apis/index.ts), `USE_AUTH_MOCK = false`
|
||||||
|
([constants.ts:6](../../client/src/services/auth/constants.ts)) — **real**.
|
||||||
|
|
||||||
|
| Call | Endpoint | Observed | Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `useRequestOtp` | `POST /api/v1/auth/request_otp` | **200** | anon · `otp` policy. Returns `codeLength: 6`, `expiresInSeconds: 60`, `resendAvailableInSeconds: 120` |
|
||||||
|
| — (dev only) | `GET /api/v1/dev/last_otp/{phone}` | **200** | the only way to read a code — see [testing-setup.md](testing-setup.md#getting-an-otp) |
|
||||||
|
| `useVerifyOtp` | `POST /api/v1/auth/verify_otp` | **200** | anon · `otp` policy. Returns `accessToken` (5-part JWE), `refreshToken`, `accessExpiresAt`, `refreshExpiresAt`, `isNewUser`, `roles` |
|
||||||
|
| `useMe` | `GET /api/v1/me` | **200** | phone comes back **masked** — first four + last two, so `09120000010` → `0912*****10` (`IdentityDefaults.MaskPhone`); carries `roles`, `hasNurseProfile`, `nurseVerificationStatus` |
|
||||||
|
| `useSelectRole` | `POST /api/v1/me/select_role` | **200** (already held → idempotent) · **403** (`admin`) | 403 message: `"Only the customer or nurse role can be self-selected."` Returns **`Me`, not tokens** — see the rotation note below |
|
||||||
|
| `useRefresh` / fetch layer | `POST /api/v1/auth/refresh` | **200** rotated · **401** on replay | `auth` policy 10/min; called by both `services/auth` **and** [`lib/api/refresh.ts`](../../client/src/lib/api/refresh.ts) |
|
||||||
|
| `useLogout` | `POST /api/v1/auth/logout` | wired (not probed) | empty envelope — the client awaits it and never `unwrap()`s |
|
||||||
|
|
||||||
|
Chain confirmed link by link: hook → [`apis/clientApi.ts:24-62`](../../client/src/services/auth/apis/clientApi.ts)
|
||||||
|
→ [`lib/api/client.ts:34`](../../client/src/lib/api/client.ts) → `AuthController` / `MeController` →
|
||||||
|
`Baya.Application/Features/Identity/**`.
|
||||||
|
|
||||||
|
> **`select_role` does not rotate anything server-side.** `SelectRoleCommandHandler` grants the role and
|
||||||
|
> returns `MeResult` — no token, no security-stamp bump (verified: the 200 body carries no `accessToken`).
|
||||||
|
> The rotation is a *client-side follow-up*: [`useSelectRole.ts`](../../client/src/services/auth/hooks/useSelectRole.ts)
|
||||||
|
> fires a second `POST /auth/refresh` on success, because role claims live inside the JWE. So the one
|
||||||
|
> user action is **two** calls, and a failed rotation is swallowed by design.
|
||||||
|
|
||||||
|
## Rules that must hold
|
||||||
|
|
||||||
|
| Rule | Value | Source |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| OTP length / validity | 6 digits · **60 s** | `IdentityDefaults.cs:26,30` |
|
||||||
|
| Resend window | 120 s per phone | `platform_configs.auth_otp_resend_seconds` |
|
||||||
|
| Wrong attempts before lockout | **5**, then envelope `code: "otp_locked"` + `retryAfterSeconds` | a **config row**, not a constant: `platform_configs.auth_otp_max_attempts` (seeded `5` — `PlatformConfigConfig.cs:44`), read at compute time by `VerifyOtpCommand.Handler.cs:36-44` |
|
||||||
|
| Rate limit — **`request_otp` AND `verify_otp` share one policy** | 5 / 60 s **per IP** | `RateLimitingServiceExtension.cs:52`; `AuthController.cs:26,32` |
|
||||||
|
| Refresh rotation | every refresh revokes the presented session and mints a new pair | `RefreshTokenCommand.Handler.cs:61` |
|
||||||
|
| Reuse = theft | replaying a revoked token revokes **every** active session for that user and returns 401 | `RefreshTokenCommand.Handler.cs:35-47`; [business §as-built](../../product/business/01-actors-and-onboarding.md) |
|
||||||
|
| Self-assignable roles | `customer`, `nurse` only; any admin sub-role → **403** | `RoleNames.SelfAssignable`; `SelectRoleCommand.Handler.cs:23-24` |
|
||||||
|
| No account enumeration | wrong phone, wrong code and expired code all collapse to one message | `VerifyOtpCommand.Handler.cs:24,53-59` |
|
||||||
|
| `/me` is the only identity source | the JWE is opaque; never decode a claim client-side | [`token.ts:44-58`](../../client/src/lib/auth/token.ts) |
|
||||||
|
| `?next=` is not an open redirect | same-origin relative **and** role-owned, else fall through | [`routing.ts:49-82`](../../client/src/services/auth/routing.ts); `routing.test.ts` has **17** tests, **7** of them on this guard |
|
||||||
|
| Phone lookup is by HMAC hash | `users.PhoneHash` from `Seams:FieldEncryption:HashKey` — immutable | [testing-setup.md](testing-setup.md#the-four-crypto-values-and-why-they-are-load-bearing) |
|
||||||
|
|
||||||
|
## How to test
|
||||||
|
|
||||||
|
1. Log in as **`09120000010`** (سارا محمدی, customer) — see [testing-setup.md](testing-setup.md).
|
||||||
|
Boot the API with `Seams__Sms__Provider=mock` or step 3 will 500.
|
||||||
|
2. Open `http://localhost:3000/fa/nurse/visits` while signed out.
|
||||||
|
**Expect:** a 307 to `/fa/login?next=/nurse/visits`.
|
||||||
|
3. Enter the phone, submit. **Expect:** the six-box code screen with «۰۲:۰۰» counting down.
|
||||||
|
4. `curl --noproxy '*' http://localhost:5002/api/v1/dev/last_otp/09120000010` → paste the code.
|
||||||
|
**Expect:** auto-verify on the sixth digit, the branded splash, then `/fa` (the family home) — **not**
|
||||||
|
`/fa/nurse/visits`, because a customer does not own that path. This is the `?next=` guard working.
|
||||||
|
5. Repeat step 2–4 as **`09120000001`** (زهرا عزیزی, nurse) with `?role=nurse`. **Expect:** you land on
|
||||||
|
`/fa/nurse/visits` — the same deep link now survives.
|
||||||
|
6. Enter a wrong code five times. **Expect:** «کد وارد شده صحیح نیست» each time, then the lockout copy
|
||||||
|
(`otp_locked`) with resend still enabled — resend is the way out (`OtpStep.tsx:94-95`).
|
||||||
|
7. Role picker: no seeded account is role-less, so `/fa/select-role` cannot be reached naturally. Visit it
|
||||||
|
directly while signed in. **Expect:** two cards, «خانواده» pre-selected; «ادامه» calls `select_role`,
|
||||||
|
*then* a second `/auth/refresh` from the client to pick up the new claim, and routes. Selecting a role
|
||||||
|
you already hold is a safe no-op (verified live: `{"role":"customer"}` on `09120000010` → **200**).
|
||||||
|
8. Admins: `09120000020` logs in fine and `/me` returns 200, but **every admin endpoint then 403s** — the
|
||||||
|
console renders on `USE_ADMIN_MOCK` data. See [testing-setup.md](testing-setup.md#-the-seeded-admins-cannot-reach-any-admin-endpoint).
|
||||||
|
9. `09120000030` (partner owner) has `/me` roles `["customer"]` only — you land on the family home and must
|
||||||
|
navigate to `/fa/partner` by hand.
|
||||||
|
|
||||||
|
**Live rotation/reuse walk (executed):** `refresh(RT1)` → **200**, new token differs. Replay `RT1` →
|
||||||
|
**401** `"Refresh token is no longer valid. Sign in again."` Then `refresh(RT2)` — the *legitimate* token
|
||||||
|
issued moments earlier — also → **401**. The whole session family dies, exactly as the business doc
|
||||||
|
specifies. The access token minted before all of that still returned **200** from `/me` afterwards.
|
||||||
|
|
||||||
|
## Known gaps
|
||||||
|
|
||||||
|
- `POST /auth/request_otp` **500s on a fresh clone**: the committed `Seams:Sms:Provider` is `telegram` with
|
||||||
|
nothing on `:5010`. Login is unreachable in the browser until you boot with `Seams__Sms__Provider=mock`.
|
||||||
|
- **Reuse detection cannot tell theft from two tabs.** The single-flight guard in
|
||||||
|
`lib/api/refresh.ts:17` is module-level, so it is per-tab. Two tabs 401ing at once both POST the same
|
||||||
|
cookie value; the second is classified as reuse and **every session dies** — a silent full logout with no
|
||||||
|
explanation. Verified live: the freshly-rotated `RT2` was already dead.
|
||||||
|
- **`useSelectRole`'s rotation bypasses the single-flight guard.** It calls `authApi.refresh` directly,
|
||||||
|
not `attemptTokenRefresh`, so its `/auth/refresh` is not coalesced with a concurrent 401-retry from
|
||||||
|
`clientFetch`. Two refreshes racing on the same cookie value is exactly the reuse pattern above — the
|
||||||
|
loser kills every session. Narrow window (first-run role pick only), but it is the same defect twice.
|
||||||
|
- **Session revocation does not kill access tokens.** `RefreshTokenCommandHandler` revokes sessions but
|
||||||
|
never rotates the security stamp (contrast `LogoutCommand.Handler.cs:42`, which does). Verified: after
|
||||||
|
reuse-detection revoked all sessions, `GET /me` with the pre-existing access token still returned **200**.
|
||||||
|
"Logged out everywhere" is up to 60 minutes late (`IdentitySettings:ExpirationMinutes: "60"` in
|
||||||
|
`appsettings.json` / `appsettings.Development.json` — a config value, not a code constant).
|
||||||
|
- **The access cookie expires 45 minutes before the token does.** `AUTH_ACCESS_COOKIE_OPTIONS.maxAge = 900`
|
||||||
|
(`lib/cookies/constants.ts:24`) vs a 60-minute server token. After 15 idle minutes any full page load hits
|
||||||
|
the middleware with no cookie and is bounced to `/login`, even though the 7-day refresh cookie could have
|
||||||
|
recovered the session. The API path self-heals via `clientFetch`'s 401 retry; the navigation gate does not.
|
||||||
|
- **A 429 on `verify_otp` is shown to the user as "wrong code."** `OtpStep.tsx:134-137` renders
|
||||||
|
`otp_invalid` for *any* verify error, and a rate-limited `verify_otp` returns an **empty body** so there is
|
||||||
|
no `code` to branch on. `PhoneStep.tsx:50` handles 429 correctly — `OtpStep` does not.
|
||||||
|
- **The client ignores the server's OTP metadata.** `request_otp` returns `codeLength` and
|
||||||
|
`expiresInSeconds` (REQ-002, delivered) but `RequestOtpResult` (`services/auth/types.ts:37-40`) declares
|
||||||
|
neither, and `OTP_CODE_LENGTH = 6` is hardcoded. The 60-second code expiry is never shown at all — the
|
||||||
|
only timer on screen is the 120-second resend cooldown, so an expired code just looks wrong.
|
||||||
|
- Stale comments in `services/auth/constants.ts:15-28` claim the live server exposes no code length and no
|
||||||
|
machine-readable failure code. It exposes both (`codeLength`, `otp_locked`, `otp_invalid`).
|
||||||
|
- `GET /api/v1/dev/last_otp/{phone}` is **live on `api.balinyaar.ir`** — anyone who knows a registered phone
|
||||||
|
can read its login code. Recorded in [DEPLOY.md](../../DEPLOY.md); the fix is the environment switch.
|
||||||
|
- **A signed-in user visiting `/fa/login` sees the phone form again.** `/login` is in `PUBLIC_PATHS`, so the
|
||||||
|
middleware skips the gate and never redirects an authenticated visitor away (contrast `/welcome`, which
|
||||||
|
does redirect — `middleware.ts:40-42`).
|
||||||
|
- **A `?next=` pointing at `/partner/...` never survives login.** `appRoleForPath` returns `null` for the
|
||||||
|
partner tree (`routing.ts:60`), so the deep link is always discarded. Related: REQ-038 — `/me` carries no
|
||||||
|
signal that the caller administers a partner center.
|
||||||
|
- **`useLogout` always signs out every device.** It posts `{}`, and an absent `refreshToken` means
|
||||||
|
"everywhere" (`LogoutCommand.Handler.cs:25-28`). Signing out on a phone kills the desktop session too;
|
||||||
|
no UI offers the choice.
|
||||||
|
- `resolveRoleDestination` checks admin before customer/nurse (`routing.ts:34`), so a user who holds both an
|
||||||
|
admin sub-role and `customer` can never reach the family app from login.
|
||||||
|
- REQ-039 open: the OTP SMS template is not WebOTP-conformant, so `useWebOtp` ships but never fires.
|
||||||
|
- No seeded account is role-less, so the `/fa/select-role` screen has **no natural path to it** in the demo
|
||||||
|
world — it can only be tested by navigating directly.
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# Flow — BNPL installments
|
||||||
|
|
||||||
|
> Last verified: 2026-08-02 against commit `c841bde`
|
||||||
|
|
||||||
|
**Actor(s):** customer · **Status:** mocked
|
||||||
|
**Client:** mock · **Server:** partial
|
||||||
|
**Business source:** [product/business/09-installments-bnpl.md](../../product/business/09-installments-bnpl.md) · [product/payments/bnpl-landscape.md](../../product/payments/bnpl-landscape.md)
|
||||||
|
**Integration:** [docs/integration/domains/bnpl.md](../integration/domains/bnpl.md)
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
The second checkout rail off C6: instead of paying by card, the family finances the booking through a BNPL
|
||||||
|
provider (SnappPay, Digipay, …). The provider pays Balinyaar **one full lump, net of its own commission**,
|
||||||
|
and carries the customer's installments itself — Balinyaar finances nothing and tracks no repayments. From
|
||||||
|
the booking's point of view a settled BNPL order is identical to a card payment that landed net-of-fee.
|
||||||
|
|
||||||
|
## Screens
|
||||||
|
|
||||||
|
| Step | Route | Component / notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| CTA | `/fa/bookings/checkout` | «پرداخت اقساطی» button, [`checkout/page.tsx:198-204`](../../client/src/app/%5Blocale%5D/(private-routes)/(customer)/bookings/checkout/page.tsx) — gated **only** on `BNPL_ENABLED` (`payment/constants.ts:23`), never on the mock flags |
|
||||||
|
| D1 provider | `/fa/bookings/checkout/bnpl` | `MethodStep` — one stateful wizard, `?request_id=`; provider list from `useBnplOptions`. **Unreachable today** — the status gate at [`page.tsx:88`](../../client/src/app/%5Blocale%5D/(private-routes)/(customer)/bookings/checkout/bnpl/page.tsx) bails before it, see gap 3 |
|
||||||
|
| D2 plan | same route | `PlanStep` — per-plan monthly / down-payment / total |
|
||||||
|
| D3 eligibility | same route | `EligibilityStep` — national id + mobile + consent, then the provider's verdict |
|
||||||
|
| D4 schedule | same route | `ScheduleStep` — repayment table + contract, then `issueBnplToken` → handoff |
|
||||||
|
| handoff | `/fa/bookings/checkout/bnpl/gateway` | **Dev harness only** (`notFound()` outside `NODE_ENV=development`); only the mock points here |
|
||||||
|
| return | `/fa/bookings/checkout/bnpl/return` | `useAcceptBnplSchedule` fires once per mount, then a bounded settle poll → confirmation / retry / card fall-back / window-lapse |
|
||||||
|
| receipt | `/fa/bookings/checkout/confirmation?method=bnpl` | The **reused card confirmation**, relabelled «پرداختشده با اقساط» |
|
||||||
|
| D5 wallet | `/fa/wallet` → «اقساط» tab | `WalletInstallments.tsx` — **provider-reported** status, never ledger-derived |
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
Shapes and enums live in [bnpl.md](../integration/domains/bnpl.md) — not restated here.
|
||||||
|
|
||||||
|
| Call | Endpoint | Verified |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| D3 eligibility | `POST /api/v1/checkout_bnpl/eligibility` | live; **409** `"This request is not awaiting payment."` on request 19 |
|
||||||
|
| D4 initiate | `POST /api/v1/checkout_bnpl/initiate` · `Idempotency-Key` | live but **400** `"No active BNPL gateway is configured."` — see gap 1 |
|
||||||
|
| order by own id | `GET /api/v1/checkout_bnpl/{id}` | **200** for order 1 as `09120000011`; **404** as `09120000010` (tenancy) |
|
||||||
|
| order by request | `GET /api/v1/checkout_bnpl/by_request/{id}` | **200** for request 11 — the client comment calling it a phantom is **stale** |
|
||||||
|
| D1/D2 options | `GET /api/v1/checkout_bnpl/options/{id}` | **404** — phantom (REQ-022) |
|
||||||
|
| D4 schedule | `GET /api/v1/checkout_bnpl/schedule/{id}` | **404** — phantom (REQ-022) |
|
||||||
|
| D5 wallet list | `GET /api/v1/checkout_bnpl/wallet_installments` | **404** — phantom (REQ-024) |
|
||||||
|
| provider callback | `POST /api/v1/webhooks_bnpl/{provider}` | server-only; nothing fires it in dev |
|
||||||
|
| admin verify/settle/revert/get | `/api/v1/admin_bnpl/{id}*` | **403** for seeded `super_admin` — `GET /admin_bnpl/1` probed |
|
||||||
|
|
||||||
|
Chain traced for the two live customer calls: `bnpl/page.tsx:47` → `useBnplOptions` → `services/bnpl/apis/index.ts`
|
||||||
|
(ternary on `USE_BNPL_MOCK`) → `apis/clientApi.ts:53/76` → `clientFetch` → `CheckoutBnplController.cs:34/39` →
|
||||||
|
`CheckBnplEligibilityQuery.Handler.cs` / `InitiateBnplOrderCommand.Handler.cs`. Every link exists; the seam
|
||||||
|
selector picks the **mock**, so the real chain is never exercised from the UI.
|
||||||
|
|
||||||
|
## Rules that must hold
|
||||||
|
|
||||||
|
| Rule | Value | Source |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| BNPL is **full-upfront** — the provider bears 100% of default risk, Balinyaar tracks no installments | GT-3 | [overview/platform-summary.md](../../product/overview/platform-summary.md) |
|
||||||
|
| The card payment is recorded **net of the provider fee**: `settled + bnplCommission == orderAmount`, rejected otherwise | `SettleBnplOrderCommand.Handler.cs:72-74` + DB `CK_BnplTransactions_SettleSplit` | [business/09](../../product/business/09-installments-bnpl.md) §(b) |
|
||||||
|
| The commission is **read from the actual settlement, never from config** | `bnpl_provider_commission_rate` (`0.07`) is a baseline `platform_config` row (`PlatformConfigConfig.cs:40`, shipped in `InitialMarketplaceBaseline`), and the **only** reader in the repo is the demo seeder — no money path consults it | [payments/bnpl-landscape.md](../../product/payments/bnpl-landscape.md) §5 |
|
||||||
|
| The nurse payout is **invariant to payment method** — from the booking split, never from `settled_amount` | INV-13; `SettleBnplOrder…Handler.cs:91-92` passes `conversion.PayoutIrr` | [payments/cancellation-and-payout.md](../../product/payments/cancellation-and-payout.md) §7 |
|
||||||
|
| BNPL commission is a **platform expense** (`bnpl_fee_expense` leg), never the nurse's | `LedgerPosting.BnplSettle` | [payments/escrow-ledger.md](../../product/payments/escrow-ledger.md) |
|
||||||
|
| `BnplStatus` is **forward-only**: `eligible → token_issued → verified → settled → reverted` | `BnplTransitions.cs`; only cohesive domain methods mutate `status` | server hard rule 13 |
|
||||||
|
| Webhook idempotency **before** money moves — dedup on `(provider_code, external_event_id)` first | `HandleBnplCallbackCommand.Handler.cs:37-42,79-88` | INV-10 |
|
||||||
|
| Money flows only customer ↔ provider ↔ Balinyaar; the provider owns the unwind | INV-12 | [business/09](../../product/business/09-installments-bnpl.md) §(a) |
|
||||||
|
| `installment_count` (`4`) is **informational** and never drives money | | [business/09](../../product/business/09-installments-bnpl.md) §(a) |
|
||||||
|
| MoR is config (`bnpl_merchant_of_record` = `platform`) | read in both handlers | [business/13](../../product/business/13-tax-invoicing-and-legal.md) §(a) |
|
||||||
|
| A booking with an active refund is **held out of every payout batch** | INV-16 | [business/10](../../product/business/10-payouts.md) §(d1) |
|
||||||
|
|
||||||
|
## How to test
|
||||||
|
|
||||||
|
Log in as **09120000011** (customer Sara, the BNPL customer) — see [testing-setup.md](testing-setup.md).
|
||||||
|
|
||||||
|
**A. Inspect the seeded BNPL order (this is the only part that works today).**
|
||||||
|
|
||||||
|
1. `GET /api/v1/checkout_bnpl/by_request/11` with Sara's token.
|
||||||
|
**Expect:** `200`, order `id: 1`, `bookingId: 6`, `providerCode: "snapppay"`, `status: "reverted"`,
|
||||||
|
`orderAmountIrr "3200000"`, `settledAmountIrr "2976000"`, `bnplCommissionIrr "224000"`.
|
||||||
|
**The PASS is the arithmetic:** `2976000 + 224000 == 3200000` — the net-of-fee invariant, live.
|
||||||
|
`224000 / 3200000 = 0.07` because the seeder used `bnpl_provider_commission_rate`.
|
||||||
|
2. Same row shows the revert leg: `revertedAmountIrr "1600000"` (50% tier), `revertTransactionId
|
||||||
|
"demo-revert-txn-1"`, `refundChannel "bnpl_revert"`, `expectedCustomerRefundEta "2026-08-09"`, and
|
||||||
|
`providerCommissionReversedAmount: null`.
|
||||||
|
3. `GET /api/v1/checkout_bnpl/1` as **09120000010** → **404**, not 403. That is the tenancy rule (hard rule 20).
|
||||||
|
4. Cross-check the payout hold: nurse 1 (09120000001) has an outstanding clawback and booking 6 is excluded
|
||||||
|
from every batch — see [nurse-earnings-and-payouts.md](nurse-earnings-and-payouts.md).
|
||||||
|
|
||||||
|
**B. The wizard (D1→D5) — a dead end at every id.** Open `/fa/bookings/checkout/bnpl?request_id=1`.
|
||||||
|
**Expect: not the four steps** — you get the "pay with card" fallback card. `request_id=1` and `=2` are the
|
||||||
|
only rows the BNPL mock's request store holds, both seeded `pending_nurse_response` and swept to
|
||||||
|
`expired_no_response` minutes later, and `page.tsx:88` bails for anything ≠ `accepted_awaiting_payment`. Any
|
||||||
|
other id throws `404` inside the mock. **D1–D5 cannot be walked today by any route** — so there is no
|
||||||
|
reproducible test of the BNPL UI, mock or real. Reaching it the intended way (the C6 CTA) passes a **real**
|
||||||
|
request id and 404s inside the mock — see gap 3.
|
||||||
|
|
||||||
|
**C. Prove the rail is dead server-side.** `POST /api/v1/checkout_bnpl/initiate` with any request id.
|
||||||
|
**Expect:** `400 "No active BNPL gateway is configured."` No workaround exists from the API — a
|
||||||
|
`payment_gateways` row with `Type = Bnpl, IsActive = 1` must be inserted first.
|
||||||
|
|
||||||
|
**The seeded world can no longer support a fresh BNPL checkout either.** Probed live: `booking_requests/list`
|
||||||
|
returns 13 rows for `09120000010` and 5 for `09120000011`, and **none** is `accepted_awaiting_payment` (a mix
|
||||||
|
of `converted`, `payment_deadline_expired`, `expired_no_response`, `cancelled_by_customer`,
|
||||||
|
`rejected_by_nurse`). So even with a gateway row every eligibility call returns `409` — request 11 answers
|
||||||
|
`"This booking has already been paid."`, request 19 `"This request is not awaiting payment."` Testing D1–D4
|
||||||
|
end-to-end requires re-seeding, fixing gap 1, **and** giving the mock wizard a payable request.
|
||||||
|
|
||||||
|
## Known gaps
|
||||||
|
|
||||||
|
- No `payment_gateways` row of type `bnpl` is ever created — `SeedPaymentGatewaysAsync` (`ServiceCollectionExtensions.cs:140-151`) seeds only `Standard`, so `InitiateBnplOrderCommand.Handler.cs:42-44` and `CheckBnplEligibilityQuery.Handler.cs:45-47` always fail with `400 "No active BNPL gateway is configured."` The whole rail is unreachable on the live dev server. Verified live.
|
||||||
|
- `USE_BNPL_MOCK = true` (`bnpl/constants.ts:18`) — every D1–D5 screen renders fixture data; no customer-visible BNPL number on screen comes from the server.
|
||||||
|
- **The wizard is a dead end for every id — H-07's cross-domain mock edge, but the symptom is deadness, not fabrication.** `checkout/page.tsx:198` renders the CTA on `BNPL_ENABLED` alone and pushes a **real** booking-request id into the mock wizard. `bnpl/apis/mockApi.ts:5-8` hard-imports `bookingRequestsMockApi` and `mockInsertConvertedBooking` (consumed at `:262, :289, :358, :378, :405, :424, :463, :484`) — bypassing the seam, so the flip `USE_BOOKING_REQUESTS_MOCK = false` never applies. That store seeds ids **1 and 2 only** (`nextId = 1`), both `pending_nurse_response`, and its `sweep()` ages them to `expired_no_response`; nothing in a real-primary UI ever accepts them. So a real id 404s **and** ids 1/2 hit the `page.tsx:88` guard — D1–D5 never render. The `6001+` client-only booking H-07 warned about is currently **unreachable**, because the status gate fires before any settle; the guard is the only thing preventing it.
|
||||||
|
- Three client ops have no server route and 404 live: `getBnplOptions` (`clientApi.ts:53`), `getBnplSchedule` (`:69`), `getWalletInstallments` (`:107`) — REQ-022/024. D1/D2's plan list, D4's schedule and the wallet «اقساط» tab therefore have no real source at all.
|
||||||
|
- `bnpl/apis/clientApi.ts:101-103` and its header block `:38-41` both claim `GET checkout_bnpl/by_request/{id}` is a proposed slug that 404s. **It exists** (`CheckoutBnplController.ByRequest`, in the swagger) **and returns 200** — stale comments blocking a partial flip.
|
||||||
|
- `acceptBnplSchedule` (`clientApi.ts:93`) derives `requestStatus` client-side as `order.status === 'settled' ? 'converted' : 'accepted_awaiting_payment'`. A `failed`/`reverted`/`cancelled` order is mislabelled awaiting-payment, so the return page's `windowExpired` branch (`bnpl/return/page.tsx:77`) can never fire on the real path.
|
||||||
|
- All four `AdminBnplController` endpoints return **403** for the seeded `super_admin` (`09120000020`) and `finance` (`09120000021`) accounts — probed live on `GET /admin_bnpl/1` and `POST /admin_bnpl/1/verify` (RBAC gap) — and no admin console consumes them — `admin_bnpl/{id}/revert` is unreachable from any UI; the reversal is driven from [cancellation-and-refunds.md](cancellation-and-refunds.md) instead.
|
||||||
|
- `providerCommissionReversedAmount` is `null` on the seeded reverted order — the reconciliation figure most providers never send. Nothing in the UI or admin surfaces the resulting commission shortfall.
|
||||||
|
- `DemoLifecycleSeeder.Money.cs:42` writes `EligibilityStatus = "approved"`, which is **not** in the closed `BnplEligibilityStatus` set (`eligible`/`not_eligible`/`ceiling_exceeded`) nor in the client union `bnpl/types.ts:28`.
|
||||||
|
- The D3 KYC inputs are half-wired: the client sends `{ nationalId, mobile, consent }` (`clientApi.ts:62`) but `CheckBnplEligibilityQuery.Handler.cs:49-52` uses only the mobile. Legal consent is collected and discarded (REQ-023).
|
||||||
|
- Nothing fires `POST /webhooks_bnpl/{provider}` in dev, so even a successfully initiated real order would never reach `settled` — settlement is webhook-driven by design (`HandleBnplCallbackCommand.Handler.cs`).
|
||||||
|
- `/fa/bookings/checkout/bnpl/gateway` is an orphan on the real path — referenced only by `mockApi.ts:342` and `notFound()`-gated outside `NODE_ENV=development`.
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# Flow — Booking lifecycle & EVV
|
||||||
|
|
||||||
|
> Last verified: 2026-08-02 against commit `c841bde`
|
||||||
|
|
||||||
|
**Actor(s):** nurse (check-in/out, care instructions) · customer (watch the engagement) · **Status:** partial
|
||||||
|
**Client:** real · **Server:** real
|
||||||
|
**Business source:** [product/business/06-evv-and-service-delivery.md](../../product/business/06-evv-and-service-delivery.md) ·
|
||||||
|
[product/business/05-booking-and-scheduling.md](../../product/business/05-booking-and-scheduling.md)
|
||||||
|
**Integration:** [docs/integration/domains/bookings.md](../integration/domains/bookings.md)
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
Once a booking request is paid, a `bookings` row exists with per-visit sessions. The assigned nurse works the
|
||||||
|
day: opens the visit, clocks in, reads the gated care instructions, clocks out. The customer watches the same
|
||||||
|
booking from the other side — a server-truth timeline, the money split, and the address. Check-out is the
|
||||||
|
event that makes a session payout-eligible; nothing else does.
|
||||||
|
|
||||||
|
## Screens
|
||||||
|
|
||||||
|
| Step | Route | Component / notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Nurse day hero | `/fa/nurse` | [`NurseDashboardScreen.tsx:81-133`](../../client/src/app/%5Blocale%5D/(private-routes)/nurse/NurseDashboardScreen.tsx) `NextVisitCard` picks the first `scheduled`/`in_progress` row, CTA → `/nurse/visits` |
|
||||||
|
| Nurse «ویزیت امروز» | `/fa/nurse/visits` | [`visits/page.tsx`](../../client/src/app/%5Blocale%5D/(private-routes)/nurse/visits/page.tsx) — `SessionCard` per row, one `useEvvController`, 60 s poll, `EvvStatusBanner` (rendered inside `SessionCard`) once checked in |
|
||||||
|
| Nurse booking detail | `/fa/nurse/visits/[id]` | `BookingDetailView viewerRole="nurse"` + `NurseVisitNotesPanel` + `BookingSupportEntry`. EVV controls + `CareInstructionsCard` live here. **The notes panel is not this flow's data** — see gap 12 |
|
||||||
|
| Customer «رزروها» | `/fa/bookings` | `BookingsScreen` — 3 tabs; **فعال** = `pending_payment\|confirmed\|in_progress`, **گذشته** = `completed\|disputed\|closed\|cancelled` |
|
||||||
|
| Customer booking detail | `/fa/bookings/[id]` | The **same** `BookingDetailView`, `viewerRole="customer"` — no EVV controls, care query never fires |
|
||||||
|
|
||||||
|
`BookingDetailView` is one component in two shells (both rows appear in this atlas's route coverage,
|
||||||
|
[index.md](index.md)); the viewer role drives the UI gate only — the server gates independently.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| Call | Endpoint | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| list bookings | `GET bookings/list?role=` | `clientApi.ts:36-43` → `ListBookings`. Thin DTO — no `patientId` |
|
||||||
|
| booking detail | `GET bookings/get/{id}` | `:33` → `GetBookingDetail`; nurse view nulls `addressSnapshotJson` (`BookingMapper.cs:28`) |
|
||||||
|
| today feed | `GET booking_sessions/today` | `:45-53` → `ListSessionsForNurseQuery` → `BookingRepository.cs:185-214`. **See gap 1** |
|
||||||
|
| session EVV | `GET booking_sessions/evv/{id}` | `:55` → `GetVisitVerification`; raw GPS gated to owning nurse + admin |
|
||||||
|
| care instructions | `GET bookings/care_instructions/{id}` | `:58` → `GetCareInstructionsQuery.Handler.cs:35-38` — the stage-2 gate |
|
||||||
|
| check in | `POST booking_sessions/check_in/{id}` | `:61-67` → `CheckInVisitCommand.Handler.cs`. Body is coordinates only; the server timestamps |
|
||||||
|
| check out | `POST booking_sessions/check_out/{id}` | `:69-75` → `CheckOutVisitCommand.Handler.cs`. `sensitive` 20/min |
|
||||||
|
|
||||||
|
Shapes live in [bookings.md](../integration/domains/bookings.md) — not restated here. Client seam
|
||||||
|
`USE_BOOKINGS_MOCK = false` ([`constants.ts:14`](../../client/src/services/bookings/constants.ts)); all 7 seam
|
||||||
|
ops map published routes.
|
||||||
|
|
||||||
|
**The independent EVV-GPS seam.** `NEXT_PUBLIC_EVV_MOCK_GPS` selects `ILocationProvider`
|
||||||
|
([`evv/locationProvider.ts:76`](../../client/src/services/bookings/evv/locationProvider.ts)). It resolves as
|
||||||
|
`?? (USE_BOOKINGS_MOCK ? 'in_range' : 'off')` — and `USE_BOOKINGS_MOCK` is now `false`, so **the default is
|
||||||
|
`off` = real browser GPS.** Flipping the bookings flag does not disable this seam and vice-versa.
|
||||||
|
|
||||||
|
## Rules that must hold
|
||||||
|
|
||||||
|
| Rule | Value / shape | Source |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Forward-only lifecycle | `pending_payment → confirmed → in_progress → completed → closed`, plus `disputed`/`cancelled`; illegal edge ⇒ clean `409` | INV-1, `BookingTransitions`. Live: `check_in` on a completed session → `409 "This session cannot be checked in."` |
|
||||||
|
| EVV is **advisory** | `checkInAddressMatch` tri-state `true`/`false`/**`null` = no reading, not a failure**. A mismatch raises `SupportAlertType.EvvLocationMismatch` + notifies the family; it never blocks and never cancels | INV-14, `CheckInVisitCommand.Handler.cs:64-110`; [business/06](../../product/business/06-evv-and-service-delivery.md) |
|
||||||
|
| Location tolerance | `evv_location_tolerance_meters` = **200 m** (CONFIG), Haversine against the frozen address snapshot | `…Handler.cs:71` |
|
||||||
|
| Check-**out** starts the payout clock | `session.SetPayoutEligible(now + dispute_window_hours)` — **the only** thing that makes a session payout-eligible; never the `completed` status alone | INV-2, `CheckOutVisitCommand.Handler.cs:59-62` |
|
||||||
|
| Dispute window | `dispute_window_hours` = **72 h** (CONFIG), frozen per session *and* onto `Bookings.DisputeWindowEndsAt` | [business/10](../../product/business/10-payouts.md) |
|
||||||
|
| Two-stage clinical gate | Care instructions decrypt **only** post-confirmation, **only** for the assigned nurse or an admin. Never in a list, never logged. Any other caller gets `404`, not `403` | INV-6 / INV-7, `GetCareInstructionsQuery.Handler.cs:35-38` |
|
||||||
|
| Three-amount split | `gross = balinyaarCommission + nursePayout`, DB CHECK; `platformFeeRate` **snapshotted** at conversion | INV-4 / INV-8 |
|
||||||
|
| Per-session accrual | equal integer shares, remainder on the last session — Σ = `nursePayoutAmount` | INV-19, `BookingAmounts.SplitPayout:35-42` |
|
||||||
|
| No-show | `no_show_threshold_minutes` = **60** (CONFIG); the hourly `no_show_sweep` flags an un-checked-in session `missed` | [business/06](../../product/business/06-evv-and-service-delivery.md) |
|
||||||
|
| Emergency playbook | The nurse's escalation contact reaches them **only** inside the gated `CareInstructionsCard` (`emergencyContactName`/`Phone`); `LogEmergencyTicket` never dials and never exposes a number | INV-20, [business/12](../../product/business/12-messaging-and-emergencies.md) |
|
||||||
|
|
||||||
|
Live proof of the money + clock rules on booking 2: `4500000 = 675000 + 3825000`, `platformFeeRate 0.15`,
|
||||||
|
`visitPayoutAmount 765000 × 5`, and session 4's `payoutEligibleAt` is exactly `checkOutAt + 72 h`.
|
||||||
|
|
||||||
|
## How to test
|
||||||
|
|
||||||
|
1. Log in as **09120000001** (nurse زهرا عزیزی) — see [testing-setup.md](testing-setup.md).
|
||||||
|
**Before starting `npm run dev`, set `NEXT_PUBLIC_EVV_MOCK_GPS=in_range`** in `client/.env.development`, or
|
||||||
|
every check-in from your laptop is a mismatch (see gap 6).
|
||||||
|
2. Open `/fa/nurse/visits`.
|
||||||
|
**Expect:** a session list — **but not today's.** The seeded world has no session dated 2026-08-02, yet the
|
||||||
|
screen renders 11 rows from 2026-07-15 to 2026-08-12 (gap 1). Only `sessionId 14` (booking 10,
|
||||||
|
2026-08-12) is `scheduled`/`pending`; every other seeded row is `completed`, `missed` or `cancelled`.
|
||||||
|
3. Tap **مشاهدهٔ رزرو** on booking 2.
|
||||||
|
**Expect:** `BookingDetailView` in the nurse shell — status «در حال انجام», the care-instructions card
|
||||||
|
populated (`فشار خون بالا، دیابت نوع دو تحت کنترل.` / `متفورمین 500 (صبح و شب)…` / `پنی سیلین.`), 5 session
|
||||||
|
rows (3 `completed`, 2 `missed`), and the **quiet `address_pending_note` copy «آدرس پس از تأیید رزرو در
|
||||||
|
دسترس قرار میگیرد.» instead of the address** — REQ-051, not a crash.
|
||||||
|
4. **Walk a real check-in/out.** The seeded world can no longer support this: booking 2's sessions are all
|
||||||
|
terminal and booking 10's only session is dated 2026-08-12. Workaround — either (a) as **09120000010**
|
||||||
|
create a request for **today** → accept it as the nurse → convert it with the Development-only capture
|
||||||
|
simulator `POST /api/v1/bookings/convert` **inside the 30-minute payment window** (paying through the
|
||||||
|
browser cannot finish — `MockPaymentProvider` redirects to the non-existent `mock-psp.local`, see
|
||||||
|
[checkout-and-payment.md](checkout-and-payment.md)); or (b) probe the guards, which is what proves the
|
||||||
|
wiring:
|
||||||
|
`POST /api/v1/booking_sessions/check_in/4` → **`409` "This session cannot be checked in."**;
|
||||||
|
`POST /api/v1/booking_sessions/check_out/5` → **`400` "Check-out must follow an open check-in."**
|
||||||
|
5. Read the out-of-range EVV as **09120000002** (nurse علی کریمی):
|
||||||
|
`GET /api/v1/booking_sessions/evv/9`.
|
||||||
|
**Expect:** `checkInAddressMatch: false`, `checkInDistanceMeters: 1220.00` — this is the seeded
|
||||||
|
`evv_location_mismatch` case. Its admin queue is unreachable (gap 5).
|
||||||
|
6. Confirm the clinical gate from both sides:
|
||||||
|
`GET /api/v1/bookings/care_instructions/2` as the nurse → **`200`** with decrypted Persian text;
|
||||||
|
the same call as **09120000010** (the owning customer) → **`404` "Care instructions not found."**
|
||||||
|
7. Log in as **09120000010** and open `/fa/bookings` → tab **فعال**.
|
||||||
|
**Expect:** bookings 1, 2 and 10 (`confirmed`, `in_progress`, `confirmed`). Open booking 2 →
|
||||||
|
the address **is** present in the customer view, and no EVV control is rendered.
|
||||||
|
|
||||||
|
## Known gaps
|
||||||
|
|
||||||
|
- `booking_sessions/today` applies **no date filter when `date` is omitted** (`BookingRepository.cs:191-192`), and both callers — `visits/page.tsx:26` and `NurseDashboardScreen.tsx:85` — call `useTodaySessions()` with no argument. The «ویزیت امروز» screen therefore lists the nurse's whole session history, oldest first (live: 11 rows, 2026-07-15 → 2026-08-12). `useTodaySessions.ts:10` asserts "`date` omitted = the server's today"; that is false. The feed is also a fixed `pageSize: 20` with no "load more" (`useTodaySessions.ts:16`), so a nurse past 20 lifetime sessions can never reach the recent ones. The dashboard hero compounds it: `NextVisitCard` labels the 2026-08-12 session "next visit" ten days early.
|
||||||
|
- A booking whose remaining sessions are swept to `missed` **never completes**. `DetectNoShowSessionsCommand.Handler` transitions sessions only; the `allSettled → Completed` re-evaluation exists solely in `CheckOutVisitCommand.Handler.cs:64-70`. Live: booking 2 has all 5 sessions terminal (3 `completed`, 2 `missed`), is still `in_progress`, and `disputeWindowEndsAt` is `null` — so its 3 checked-out sessions can never enter a payout batch (`PayoutRepository.cs:31` requires `b.Status == BookingStatus.Completed`). Nurse money is stranded.
|
||||||
|
- `completed → closed` is unreachable. No job closes a booking and the only writer, `POST bookings/transition/{id}`, is admin-only, has no UI, and 403s for the seeded admins. Live: bookings 3, 4 and 8 have dispute windows that closed on 2026-07-18/19/28 and are still `completed`.
|
||||||
|
- No client path produces `disputed`. The status renders in `BOOKING_STATUS_KIND` but nothing raises a dispute — the customer's only booking write is cancel.
|
||||||
|
- The `evv_location_mismatch` alert queue is doubly unreachable: `GET admin_evv/list` has no client screen, and it 403s for `09120000020`/`09120000021` anyway. The seeded 1220 m mismatch on session 9 can only be seen through the nurse's own EVV read.
|
||||||
|
- `NEXT_PUBLIC_EVV_MOCK_GPS` now defaults to **`off`** (`constants.ts:60-61`) because it is derived from `USE_BOOKINGS_MOCK`, which was flipped to `false`. A tester on a laptop away from the seeded Tehran address therefore fires a real mismatch — and a `SupportAlert` — on every check-in. The constant's own doc-comment still describes the old `in_range` default.
|
||||||
|
- REQ-051 — the nurse never sees the address on a confirmed booking. `BookingMapper.ToDetailDto` nulls `addressSnapshotJson` for the nurse, so the nurse must navigate by patient name alone. Ironically the server *does* read that snapshot to compute the EVV match.
|
||||||
|
- REQ-052 — `BookingSessionListItemDto` (`BookingDtos.cs:71-80`) carries no service/variant field. The client models it as optional `variantLabel?` and it is always `undefined` on the real path, so every today-feed row says patient name + visit index only.
|
||||||
|
- REQ-057 — `BookingListItemDto` has no `patientId`, so a booking row cannot deep-link to the care record.
|
||||||
|
- `POST bookings/submit_care_instructions/{id}` is unwired — there is no customer form anywhere in `client/src`. Care instructions exist only because the seeder wrote them; on a booking a tester creates, the nurse's card will be empty.
|
||||||
|
- `POST booking_sessions/cancel/{id}` is unwired — a nurse cannot cancel a single visit from the UI.
|
||||||
|
- **The nurse booking detail is half real.** `Client: real` above is a verdict on the `bookings` seam only. The same screen mounts `NurseVisitNotesPanel`, whose task checklist, continuity history and note-save all come from `services/patientRecords` — `USE_PATIENT_RECORDS_MOCK = true`, module state, lost on navigation. The EVV/care/timeline half is server truth; the notes half is fabricated. → [patient-care-records.md](patient-care-records.md)
|
||||||
|
- `client/src/services/bookings/apis/serverApi.ts` has no importer anywhere in `client/src` — dead code, and its doc-block still claims the domain is mock-primary (as does `constants.ts:1-13` above the `false`).
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# Flow — booking-request
|
||||||
|
|
||||||
|
> Last verified: 2026-08-02 against commit `c841bde`
|
||||||
|
|
||||||
|
**Actor(s):** customer (C4/C5) + nurse (inbox) · **Status:** partial
|
||||||
|
**Client:** real · **Server:** real
|
||||||
|
**Business source:** [product/business/05-booking-and-scheduling.md](../../product/business/05-booking-and-scheduling.md)
|
||||||
|
**Integration:** [docs/integration/domains/booking-requests.md](../integration/domains/booking-requests.md)
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
Stage one of booking, and the only stage with **no money in it**. A family picks a nurse from search, names
|
||||||
|
the patient, the service variant, the address, a date/time window and the caregiver gender they need, and
|
||||||
|
sends a request. The nurse has a frozen response window to accept or decline; an accept opens a 30-minute
|
||||||
|
payment window and hands the customer to [checkout](checkout-and-payment.md). A `booking_requests` row
|
||||||
|
becomes a `bookings` row only on payment capture.
|
||||||
|
|
||||||
|
## Screens
|
||||||
|
|
||||||
|
| Step | Route | Component / notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| C4 — request form | `/fa/bookings/request?nurse_id=&variant_id=&required_gender=` | `client/src/app/[locale]/(private-routes)/(customer)/bookings/request/page.tsx` (727 ln). Waits for patients + addresses + nurse profile before mounting so `defaultValues` are the only values. `JalaliDateIntentPicker` + three preset time windows (`TIME_WINDOWS`, :55) or custom. Blocks a same-gender mismatch before the round-trip: `genderMismatch` at :216, submit guard :248, inline alert :544 |
|
||||||
|
| C5 — awaiting response | `/fa/bookings/request/[id]` | `…/bookings/request/[id]/page.tsx` (368 ln). Polls every 15 s while non-terminal, stops on terminal (`useBookingRequest`, hook :20-23). `StepperHeader` 3-step tracker, `BookingRequestSummaryCard`, `CountdownTimer`. Each of the five terminal statuses gets its own recovery card |
|
||||||
|
| customer inbox | `/fa/bookings` | `…/(customer)/bookings/BookingsScreen.tsx:53` mounts `useCustomerRequests()` and deep-links rows back to C5. There is **no dedicated customer requests page** — this list is the only entry point back into C5 |
|
||||||
|
| nurse inbox | `/fa/nurse/requests` | `…/nurse/requests/page.tsx` (198 ln). Three tabs «در انتظار» / «پاسخداده» / «منقضی»; urgency-tinted countdown pill (teal >2 h, amber <2 h, terracotta <30 min, :17-18); shows `customerNotes` only — never an address |
|
||||||
|
| nurse detail | `/fa/nurse/requests/[id]` | `…/nurse/requests/[id]/page.tsx` (343 ln). Accept (confirm dialog) / reject (reason dialog, ≤500 chars). A stale action's `409` becomes the «action_stale» toast + refetch (:82-89) |
|
||||||
|
|
||||||
|
Client service seam is **real**: [`USE_BOOKING_REQUESTS_MOCK = false`](../../client/src/services/bookingRequests/constants.ts) (`constants.ts:14`).
|
||||||
|
Its doc-comment above that line still argues for the mock and is stale.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
The six client-facing ops are wired and were traced client → server; `checkout_summary` sits on this
|
||||||
|
controller but is consumed by the checkout flow, and `expire` has no client caller at all. Shapes live in
|
||||||
|
[docs/integration/domains/booking-requests.md](../integration/domains/booking-requests.md).
|
||||||
|
|
||||||
|
| Call | Endpoint | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| C4 submit | `POST /booking_requests/create` | `clientApi.ts:27` → `BookingRequestsController.cs:34` → `CreateBookingRequestCommand.Handler.cs` |
|
||||||
|
| C5 / nurse detail | `GET /booking_requests/get/{id}` | `clientApi.ts:35` → controller `:59`. Role-scoped masking in the mapper, not the client |
|
||||||
|
| both inboxes | `GET /booking_requests/list?role=&status=&page=&pageSize=` | `clientApi.ts:37` → controller `:54`. Role resolution `ListBookingRequestsQuery.Handler.cs:45-58` |
|
||||||
|
| nurse accept | `POST /booking_requests/accept/{id}` | `clientApi.ts:48` → controller `:44` |
|
||||||
|
| nurse reject | `POST /booking_requests/reject/{id}` | `clientApi.ts:51` → controller `:49`; id comes from the **route**, reason from the body |
|
||||||
|
| customer cancel | `POST /booking_requests/cancel/{id}` | `clientApi.ts:59` → controller `:39` |
|
||||||
|
| checkout money | `GET /booking_requests/checkout_summary/{id}` | on this controller, consumed by [checkout-and-payment](checkout-and-payment.md) |
|
||||||
|
| expiry sweep | `POST /admin_booking_requests/expire` | `AdminBookingRequestsController.cs:22` is `[Authorize(DynamicPermission)]` — **403 for both demo admins** (re-checked live: …020 and …030 both `403 "Authorization Error"`). The in-process `booking_request_expiry` job (`BookingRequestExpiryJob.cs`, 1-min interval) runs it unattended anyway |
|
||||||
|
|
||||||
|
## Rules that must hold
|
||||||
|
|
||||||
|
| Rule | Where enforced | Live evidence |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Response deadline frozen from config, never recomputed ([05-booking §a.2](../../product/business/05-booking-and-scheduling.md)) | `CreateBookingRequestCommand.Handler.cs:72,86` — `nurse_response_deadline_hours` | created `2026-08-02T12:56:14` → deadline `2026-08-03T12:56:14` = **24 h** |
|
||||||
|
| 30-minute payment window on accept ([05-booking §a.3](../../product/business/05-booking-and-scheduling.md)) | `AcceptBookingRequestCommand.Handler.cs:49-50` — `booking_payment_deadline_minutes` | accepted `12:56:42` → `paymentDeadlineAt 13:26:42` = **30 min** |
|
||||||
|
| Forward-only status; a sideways move is a clean `409`, never a 500 | `AcceptBookingRequestCommand.Handler.cs:38-39`; `BookingRequestTransitions` behind `BookingRequest.cs:66` | second accept on req 21 → `409 "This request can no longer be accepted."`; second reject on req 23 → `409 "This request can no longer be rejected."` |
|
||||||
|
| Stage-1 disclosure: nurse gets `customerNotes` + coarse city/district only | `BookingRequestMapper.cs:35-38` (`includeFullAddress`) | nurse view of req 21: `addressLine`/`postalCode`/`recipientName`/`recipientPhone` all `null`; `cityNameFa`/`districtNameFa` present. Customer's own view: full address |
|
||||||
|
| Tenancy — patient + address ∈ caller, variant ∈ nurse; a mismatch is `404` not a leak | `CreateBookingRequestCommand.Handler.cs:37-47`; `GetTrackedForNurseAsync` | nurse 2 reading nurse 1's request 21 → **404** |
|
||||||
|
| Same-gender care is decisive, never defaulted | client `page.tsx:216,248`, server `CreateBookingRequestCommand.Handler.cs:61-64` (`CaregiverGender.Matches`) | — |
|
||||||
|
| Nurse must be verified **and** accepting bookings | `CreateBookingRequestCommand.Handler.cs:56-57` | — |
|
||||||
|
| `customerNotes` ≤ 1000, `nurseRejectionReason` ≤ 500 | `CreateBookingRequestCommand.Validator.cs:25`, `RejectBookingRequestCommand.Validator.cs:10-12`, mirrored client-side in `constants.ts:31,34` | — |
|
||||||
|
| Countdown renders a **server-frozen** deadline, never a locally computed one | `CountdownTimer.tsx:84-85` | true, but see the timezone gap below |
|
||||||
|
|
||||||
|
## How to test
|
||||||
|
|
||||||
|
Log in as **09120000010** (سارا محمدی, customer) — see [testing-setup.md](testing-setup.md) for boot, the
|
||||||
|
`Seams__Sms__Provider=mock` override, and `dev/last_otp`.
|
||||||
|
|
||||||
|
**The seeded world cannot exercise accept/reject.** It was seeded 2026-07-26; every *seeded* request has aged
|
||||||
|
into a terminal state. The seeded baseline is 11 rows for nurse 1 (`converted` 6, `payment_deadline_expired` 3,
|
||||||
|
`rejected_by_nurse` 1, `expired_no_response` 1) and 9 for customer …010 — **zero `pending_nurse_response`,
|
||||||
|
zero `accepted_awaiting_payment`**. So `/fa/nurse/requests` opens on an empty «در انتظار» tab and the
|
||||||
|
accept/reject buttons are unreachable on seeded data. (The live inbox now reads 14 / 12 because the probe rows
|
||||||
|
below — 21 accepted, 22 cancelled, 23 rejected — were left in place.) Create your own:
|
||||||
|
|
||||||
|
1. As …010 go to `/fa/search`, open نرس **زهرا عزیزی** (nurse 1, female, verified), press «درخواست رزرو».
|
||||||
|
2. On C4 pick patient حسن محمدی, service «مراقبت ساعتی سالمند» (variant 2, 250 000 IRR/hr), address «خانه»,
|
||||||
|
a **future** date, a time window, gender «زن». Submit.
|
||||||
|
**Expect:** redirect to `/fa/bookings/request/<newId>`, step 2 of 3 active, status «در انتظار تایید
|
||||||
|
پرستار», a countdown against a deadline ~24 h out.
|
||||||
|
3. In another browser profile log in as **09120000001** (nurse 1) and open `/fa/nurse/requests`.
|
||||||
|
**Expect:** the new request in «در انتظار» with the service headline, the patient name, the gender chip,
|
||||||
|
your note — **and no address**. Open it: the location line reads «تهران · منطقه ۳» only.
|
||||||
|
4. Press «پذیرش».
|
||||||
|
**Expect:** `200`, status flips to `accepted_awaiting_payment`, the row leaves «در انتظار», and the
|
||||||
|
customer's C5 (polling at 15 s) shows the «پذیرفته شد» badge, a 30-minute countdown and «ادامه پرداخت».
|
||||||
|
5. Press «پذیرش» again (or reload and retry): **expect a `409`** and the «action_stale» toast.
|
||||||
|
6. For the reject leg, create a second request and press «رد درخواست» with a reason (≤500 chars).
|
||||||
|
**Expect:** `200`, status `rejected_by_nurse`, the reason echoed back on the DTO, and C5 swapping to the
|
||||||
|
rejection recovery card. A repeat reject is `409 "This request can no longer be rejected."`
|
||||||
|
|
||||||
|
Verified by curl on 2026-08-02: request 21 created → nurse-1 pending inbox `total 1` → accepted
|
||||||
|
(`paymentDeadlineAt` +30 min) → second accept `409`; request 22 created then cancelled by the customer
|
||||||
|
(`cancelled_by_customer`, `200`); request 23 created → **rejected** by nurse 1 (`200`,
|
||||||
|
`rejected_by_nurse`, `nurseRejectionReason` echoed) → second reject `409`. Tenancy re-checked live: nurse 2
|
||||||
|
reading request 21 → `404`. Every leg of this flow is now live-walked, not code-traced.
|
||||||
|
|
||||||
|
Re-walked independently the same day on a **fresh** row: request **24** created by …010 against nurse 1
|
||||||
|
(`200`, `pending_nurse_response`, `nurseResponseDeadlineAt` = `createdAt + 24 h` exactly) → accepted by
|
||||||
|
nurse 1 (`200`, `accepted_awaiting_payment`, `paymentDeadlineAt` = accept instant + **30 min** exactly,
|
||||||
|
`addressLine`/`recipientPhone` `null` in the nurse's own accept response) → second accept `409` → accept by
|
||||||
|
nurse 2 `404`. Same results, so the four load-bearing behaviours above are reproducible, not one-off.
|
||||||
|
|
||||||
|
curl gotcha: sending non-ASCII `customerNotes` as a `-d` argument from Git Bash produced a spurious `400`.
|
||||||
|
Put the JSON in a UTF-8 file and use `--data-binary @file`; it then returns `200` with the note intact.
|
||||||
|
This is a shell encoding artifact, **not** a server defect.
|
||||||
|
|
||||||
|
## Known gaps
|
||||||
|
|
||||||
|
- **Deadline timestamps lose their timezone, so every countdown on this flow is wrong off-UTC.**
|
||||||
|
`BookingRequestDto.cs:39-40` and `BookingRequestListItemDto.cs:19-20` type the two deadlines as `DateTime`
|
||||||
|
(Kind unspecified after the EF round-trip), so the wire carries `"2026-08-03T12:56:14.1701328"` with no
|
||||||
|
`Z`. `CountdownTimer.tsx:84` calls `Date.parse` on it, which per spec reads an offset-less date-**time**
|
||||||
|
as *local*. In Tehran (UTC+3:30) the customer's 30-minute payment window renders as ~4 hours and the
|
||||||
|
request expires while the timer still shows time left. `createdAt` is a `DateTimeOffset` and *is* correct,
|
||||||
|
so `windowStart` and `deadlineIso` on the same progress ring disagree by the UTC offset.
|
||||||
|
- **Nothing seeded is actionable.** No `pending`/`accepted` request survives, so the nurse inbox, the accept
|
||||||
|
path, the reject path and the payment-window handoff are all unwalkable without creating a fresh request
|
||||||
|
(see above). Cause: `DemoLifecycleSeeder` anchors to first-run epoch, not wall clock.
|
||||||
|
- **The `expire` admin endpoint is unreachable for the demo admins** — `AdminBookingRequestsController.cs:22`
|
||||||
|
uses `DynamicPermission`, which `super_admin`/`finance` do not satisfy. Only the background
|
||||||
|
`booking_request_expiry` job can move a stale request; a tester cannot force expiry on demand.
|
||||||
|
- **The «پاسخداده» tab is unpaged.** The API filters one status at a time and there is no `status`-group
|
||||||
|
filter, so `nurse/requests/page.tsx:37-39` fires three separate page-1 queries and concatenates them
|
||||||
|
(`Pager` is hidden for that tab). A nurse with >20 accepted/converted/rejected requests silently loses
|
||||||
|
rows. This is the surviving half of REQ-050.
|
||||||
|
- **No structured rejection-reason code (REQ-044).** The wire carries free-text `nurseRejectionReason`, so
|
||||||
|
C5 runs a keyword heuristic (`RETRY_BLOCK_KEYWORDS`, `bookings/request/[id]/page.tsx:23-28` — seven
|
||||||
|
strings: `gender`/`coverage`/`area`/«جنسیت»/«پوشش»/«منطقه»/«محدوده») to decide whether to offer «درخواست
|
||||||
|
دوباره از همین پرستار». Any other wording defeats it — the live probe's `"schedule conflict"` passed through.
|
||||||
|
- **`services/bookingRequests/types.ts` is behind the wire.** `variantLabel` is typed `string | null`
|
||||||
|
optional on the list item (`types.ts:137`) and documented as "client-augmented", but the server always
|
||||||
|
serves it — confirmed live. `nurse/requests/page.tsx:135` therefore keeps a `hasPricedService` fallback
|
||||||
|
branch that is dead on the real path.
|
||||||
|
- **The list row still lacks `variantPrice`/`variantPriceUnit`**, so the nurse inbox card cannot show the
|
||||||
|
money she is being asked to commit to without opening the detail.
|
||||||
|
- **`patientAge` is served but never modelled client-side.** `BookingRequestListItemDto.cs:25` returns it and
|
||||||
|
the wire carries it (live: `"patientAge": 78` on every nurse-inbox row), but `BookingRequestListItem`
|
||||||
|
(`client/src/services/bookingRequests/types.ts:118-140`) declares no such field — `patientAge` appears
|
||||||
|
nowhere in `client/src` except a comment in `apis/clientApi.ts:23`. REQ-014's triage age therefore never
|
||||||
|
reaches the inbox card, and widening the client type is a pure-client fix.
|
||||||
|
- **`useCustomerRequests` does not poll** (no `refetchInterval`, `useCustomerRequests.ts:21-27`) while the
|
||||||
|
detail hook does, so the pending-request countdowns on `/fa/bookings` go stale until a manual refresh.
|
||||||
|
- **`useBookingRequest` has no `useIsAuthenticated` gate** (unlike both list hooks), so a hard reload of C5
|
||||||
|
before hydration can fire an unauthenticated `GET` and flash the error card.
|
||||||
|
- **`nurseAvatarUrl` is served as a `file:///C:/Users/…` local disk path** (verified live on requests 13, 21,
|
||||||
|
22). C5 does not pass it to `BookingRequestSummaryCard`, so this flow is unaffected — but
|
||||||
|
[checkout](checkout-and-payment.md) renders it and will show a broken avatar.
|
||||||
|
- The nurse sees the patient's **display name** pre-accept (`patientName`, verified live). That is wider than
|
||||||
|
the "notes + coarse address only" wording in the integration doc; confirm it is the intended stage-1
|
||||||
|
boundary rather than a leak.
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# Flow — cancellation-and-refunds
|
||||||
|
|
||||||
|
> Last verified: 2026-08-02 against commit `c841bde`
|
||||||
|
|
||||||
|
**Actor(s):** customer (cancel + watch the refund) · admin (dispute refund, settlement) · **Status:** mocked
|
||||||
|
**Client:** mock · **Server:** partial
|
||||||
|
**Business source:** [product/business/07-cancellation-and-refunds.md](../../product/business/07-cancellation-and-refunds.md) · [product/payments/cancellation-and-payout.md](../../product/payments/cancellation-and-payout.md)
|
||||||
|
**Integration:** [docs/integration/domains/refunds.md](../integration/domains/refunds.md)
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
A customer who can no longer take a booked visit cancels it, is told **before** confirming exactly how much
|
||||||
|
comes back and how much is kept, and then watches the money return on a status screen. An admin can also
|
||||||
|
issue a refund off a dispute ticket. The platform never edits a payment — it posts a **reversal**, and if the
|
||||||
|
nurse was already paid it raises a **clawback** the payout engine nets off her next batch.
|
||||||
|
|
||||||
|
**The trap this file exists to expose:** the server half is real, live and correct — every customer **read**
|
||||||
|
(preview · by-booking · status) was probed today and returns the full fee-split. The one **write**
|
||||||
|
(`POST bookings/{id}/cancel`) is trace-only, never fired (step 6). The **client seam is still mocked**
|
||||||
|
(`USE_REFUNDS_MOCK = true`, [refunds/constants.ts:18](../../client/src/services/refunds/constants.ts)), and
|
||||||
|
its real half is stale enough that flipping the flag today would render *10000%* refund. Details below.
|
||||||
|
|
||||||
|
## Screens
|
||||||
|
|
||||||
|
| Step | Route | Component / notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Entry | `/fa/bookings/[id]` | «لغو رزرو» CTA — [bookings/[id]/page.tsx:96](../../client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/page.tsx) → `bookingCancelPath` ([routes.ts:159](../../client/src/constants/routes.ts)) |
|
||||||
|
| 1 — disclose | `/fa/bookings/[id]/cancel` | Two-step. Step 0 shows off-ramps (reschedule/support → a ticket, real reschedule is deferred), then `CancellationPolicyDisclosure`, reason select, and an explicit acknowledgement checkbox that gates «ادامه» ([cancel/page.tsx:146-217](../../client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/cancel/page.tsx)) |
|
||||||
|
| 2 — confirm | same route, `step=1` | Restates refund vs fee in Toman, then `useCancelBooking` → routes to refund status ([:124-136](../../client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/cancel/page.tsx)) |
|
||||||
|
| 3 — track | `/fa/bookings/[id]/refund_status` | `RefundStatusCard` off `useRefundStatus(bookingId)` ([refund_status/page.tsx:28](../../client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/refund_status/page.tsx)); the poll gate lives in the hook — it runs only while non-terminal, at `REFUND_STATUS_POLL_INTERVAL_MS = 5s` ([constants.ts:38](../../client/src/services/refunds/constants.ts)) |
|
||||||
|
| Wallet tab | `/fa/wallet` → «استردادها» | `WalletRefunds.tsx:19` calls `useMyRefunds()` — **no such route exists** (REQ-048); empty on the real path |
|
||||||
|
| Admin | `/fa/admin/tickets/[id]` | `RefundPanel` ([admin/tickets/[id]/page.tsx:207](../../client/src/app/[locale]/(private-routes)/admin/tickets/[id]/page.tsx)) — preview → initiate → approve/reject. **The only admin refund surface.** |
|
||||||
|
| Admin | `/fa/admin/finance` | Group hub only — links to payouts. **No refunds console, no clawback screen, no settlement-confirm screen.** |
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| Call | Endpoint | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Pre-cancel preview | `GET bookings/{id}/cancellation_policy` | [BookingsController.cs:74-77](../../server/src/API/Baya.Web.Api/Controllers/V1/BookingsController.cs) → `GetCancellationPolicyPreviewQuery` — **live, probed** |
|
||||||
|
| Cancel + refund | `POST bookings/{id}/cancel` | [BookingsController.cs:67-71](../../server/src/API/Baya.Web.Api/Controllers/V1/BookingsController.cs) → `CancelBookingAndRefundCommand` (cancel → freeze snapshot → `CreateRefundCommand` → return status) |
|
||||||
|
| Refund by booking | `GET refunds/by_booking/{bookingId}` | [RefundsController.cs:29](../../server/src/API/Baya.Web.Api/Controllers/V1/RefundsController.cs) — **live, probed** |
|
||||||
|
| Refund by id | `GET refunds/{id}/status` | [RefundsController.cs:23](../../server/src/API/Baya.Web.Api/Controllers/V1/RefundsController.cs) |
|
||||||
|
| Admin create+execute | `POST admin_refunds` | [AdminRefundsController.cs:33](../../server/src/API/Baya.Web.Api/Controllers/V1/AdminRefundsController.cs) — `DynamicPermission` → **403 for seeded admins** |
|
||||||
|
| Admin settle / fail | `POST admin_refunds/{id}/confirm_settlement` · `/mark_failed` | same controller, same 403 |
|
||||||
|
| Clawback write-off | `POST admin_clawbacks/{id}/write_off` | no client screen at all |
|
||||||
|
| Tier admin | `GET admin_cancellation_policies/list` · `POST /upsert` | the runtime-editable tier surface — exists in swagger, **403 for seeded admins**, no client screen |
|
||||||
|
| Phantom (client-only) | `admin_refunds/preview` · `/{id}/approve` · `/{id}/reject` · `refunds/my` | REQ-035 / REQ-048 — absent from swagger, 404 on flip |
|
||||||
|
|
||||||
|
Shapes: [docs/integration/domains/refunds.md](../integration/domains/refunds.md).
|
||||||
|
|
||||||
|
## Rules that must hold
|
||||||
|
|
||||||
|
| Rule | Value / source |
|
||||||
|
| --- | --- |
|
||||||
|
| **Cancellation tiers are DB rows, seeded by `HasData`** | [CancellationPolicyConfig.cs:30-58](../../server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/BookingConfig/CancellationPolicyConfig.cs) — `standard_24h` (customer, ≥24h, **100%**) · `standard_inside_24h` (customer, open lower bound <24h, **50%**) · `nurse_no_show` (nurse, **100%**, `FeeRate=0`) · `admin_cancellation` (admin, **100%**). Editable at runtime, never retroactive |
|
||||||
|
| The applied rate is **snapshotted onto the booking**, then onto the refund | `CancellationHelper.ResolvePolicy` → `Bookings.CancellationPolicyCode` / `CancellationRefundPercentage` → `Refund.RefundPercentageApplied` ([CreateRefundCommand.Handler.cs:102-103](../../server/src/Core/Baya.Application/Features/Refunds/Commands/CreateRefund/CreateRefundCommand.Handler.cs)) |
|
||||||
|
| **A refund is a reversal leg, never a mutation** | `LedgerPosting.RefundReversalPrePayout` — `DEBIT platform_revenue` + `DEBIT nurse_payable` / `CREDIT refund_payable` ([LedgerPosting.cs:95-114](../../server/src/Core/Baya.Domain/Entities/Payments/LedgerPosting.cs)) |
|
||||||
|
| **Pre-payout vs post-payout clawback fork** | `INursePayoutStatus.IsNursePaidForBookingAsync` picks `RefundReversalPrePayout` vs `ClawbackReversalPostPayout` (debits `nurse_clawback_receivable`) and writes a `nurse_clawbacks` row ([Handler.cs:124-143](../../server/src/Core/Baya.Application/Features/Refunds/Commands/CreateRefund/CreateRefundCommand.Handler.cs)) |
|
||||||
|
| **Fee-leg decomposition is served, never split client-side** | `ResolveDecomposition` pro-rates each frozen leg ([Handler.cs:172-192](../../server/src/Core/Baya.Application/Features/Refunds/Commands/CreateRefund/CreateRefundCommand.Handler.cs)); the preview does the same at [GetCancellationPolicyPreviewQuery.Handler.cs:59-64](../../server/src/Core/Baya.Application/Features/Refunds/Queries/GetCancellationPolicyPreview/GetCancellationPolicyPreviewQuery.Handler.cs) |
|
||||||
|
| **VAT is on commission only** | `Invoice.VatIrr = round(PlatformCommissionIrr × VatRate)`, rate `0.10` ([Invoice.cs:38-41](../../server/src/Core/Baya.Domain/Entities/Invoices/Invoice.cs)). There is **no VAT ledger account** — VAT is an invoice line, so the reversal touches it only implicitly |
|
||||||
|
| `Σ refunded ≤ captured` | summed under `lock(booking:{id}:refund)`; a breach is a clean 409 ([Handler.cs:45,78-81](../../server/src/Core/Baya.Application/Features/Refunds/Commands/CreateRefund/CreateRefundCommand.Handler.cs)) |
|
||||||
|
| Crash-window: persist the refund **before** the channel call | [Handler.cs:106-114](../../server/src/Core/Baya.Application/Features/Refunds/Commands/CreateRefund/CreateRefundCommand.Handler.cs) |
|
||||||
|
| Only **un-started** sessions are refundable | preview marks `refundable = Status == Scheduled` ([Query.Handler.cs:44](../../server/src/Core/Baya.Application/Features/Refunds/Queries/GetCancellationPolicyPreview/GetCancellationPolicyPreviewQuery.Handler.cs)); MVP always cancels all of them |
|
||||||
|
| Channel decides mechanics | `manual` if a bank ref is supplied, else `bnpl_revert` for a BNPL gateway, else `psp_card` ([Handler.cs:194-199](../../server/src/Core/Baya.Application/Features/Refunds/Commands/CreateRefund/CreateRefundCommand.Handler.cs)); BNPL ETA = `bnpl_refund_eta_business_days` = **10** business days |
|
||||||
|
| Every admin refund hangs off a ticket | auto-opens a `refund` ticket when none is passed ([Handler.cs:54-62](../../server/src/Core/Baya.Application/Features/Refunds/Commands/CreateRefund/CreateRefundCommand.Handler.cs)) |
|
||||||
|
|
||||||
|
## How to test
|
||||||
|
|
||||||
|
1. Log in as **09120000010** (customer Sara) — see [testing-setup.md](testing-setup.md).
|
||||||
|
2. Open `/fa/bookings/7/refund_status`. **Expect (UI, mocked):** the mock's own fixture, *not* booking 7's
|
||||||
|
real refund. **Expect (API truth, probed today):**
|
||||||
|
`GET /api/v1/refunds/by_booking/7` → `id 1 · status succeeded · psp_card · amount 2000000 ·
|
||||||
|
platformFeeRefundedIrr 300000 · nursePayoutRefundedIrr 1700000 · refundPercentageApplied 100.00 ·
|
||||||
|
cancellationPolicyCode standard_24h`. The masked reference renders as `••••••••••ef-1`.
|
||||||
|
3. Post-payout clawback case: `GET /api/v1/refunds/by_booking/8` → `id 3 · succeeded · amount 250000 ·
|
||||||
|
payout leg 212500`. That 212500 **is** nurse 1's outstanding clawback — cross-checked live:
|
||||||
|
`GET /api/v1/nurse_payouts/earnings_balance` (nurse 09120000001) → `clawbackOutstandingIrr "212500" ·
|
||||||
|
paidTotalIrr 3187500 · netPayableBalanceIrr 8500000`. `refundPercentageApplied` and
|
||||||
|
`cancellationPolicyCode` are **null** here because it was an admin refund, not a policy cancellation.
|
||||||
|
4. Pre-cancel preview on a **stale** booking: `GET /api/v1/bookings/7/cancellation_policy` →
|
||||||
|
`cancellable:false · standard_inside_24h · refundPercentageApplied 50.00 · all amounts "0" ·
|
||||||
|
sessions[0].reasonCode "cancelled"`. **PASS** = the screen shows the «قابل لغو نیست» card.
|
||||||
|
5. Pre-cancel preview on a **live** booking: booking **10** (confirmed, 2026-08-12) →
|
||||||
|
`cancellable:true · standard_24h · refundPercentageApplied 100.00 · feePercentage 0.00 ·
|
||||||
|
refundAmountIrr 250000 · platformFeeRefundedIrr 37500 · nursePayoutRefundedIrr 212500 ·
|
||||||
|
leadTimeLabel at_least_24h`. That 37500/212500 split is exactly the 15% commission rule.
|
||||||
|
**This is the one genuinely cancellable booking in the world today** — the 2026-07-26 seed is 7 days
|
||||||
|
stale and every other customer booking is `cancelled` or `completed`.
|
||||||
|
6. **UNVERIFIED: the actual `POST bookings/10/cancel` was not fired** — it is irreversibly destructive to the
|
||||||
|
only cancellable booking left and other agents share this world. Trace only.
|
||||||
|
7. Admin: log in as **09120000021** (finance). `/fa/admin/finance` shows only a payouts tile. Open
|
||||||
|
`/fa/admin/tickets/{id}` on a booking-linked ticket to reach `RefundPanel` — it renders off the **mock**.
|
||||||
|
`GET /api/v1/admin_refunds` returns **403** with the finance token *and* with the `super_admin` token
|
||||||
|
(09120000020); so does `GET /api/v1/admin_cancellation_policies/list`. All probed.
|
||||||
|
|
||||||
|
## Known gaps
|
||||||
|
|
||||||
|
- `USE_REFUNDS_MOCK = true` ([refunds/constants.ts:18](../../client/src/services/refunds/constants.ts)) — every cancel screen, refund status and admin refund panel shows fabricated data while a working server sits behind it.
|
||||||
|
- **H-06 CONFIRMED, still unticked** ([hardening/issues.md:64](../../dev/post-phase/hardening/issues.md)). [refunds/apis/mockApi.ts:3](../../client/src/services/refunds/apis/mockApi.ts) imports `mockGetBookingForRefund`/`mockMarkBookingCancelled` from the **bookings** mock (used at `:88`, `:312`), but `USE_BOOKINGS_MOCK = false`. The bookings mock store only ever holds its own seeds (5001–5005) plus ids it converts at runtime — a real booking id such as 7 or 10 never enters it, so `mockGetBookingForRefund` 404s inside the policy preview. This is a live cross-domain `mockApi` import and the flow is broken end-to-end today.
|
||||||
|
- `CHANNEL_BY_BOOKING` ([mockApi.ts:37](../../client/src/services/refunds/apis/mockApi.ts)) pins `bnpl_revert` to fixture id 5002, so every real booking silently demos as `psp_card`.
|
||||||
|
- **Percent-scale defect (flip-blocker).** The wire sends `refundPercentageApplied: 100.00` / `feePercentage: 50.00` (0–100, probed), but `CancellationPolicyDisclosure.tsx:19-37` runs `toPercent(x) = round(x * 100)` on the documented 0–1 fraction → renders **10000%** refund the moment the flag flips.
|
||||||
|
- **The client throws away six fields the server now serves.** `RefundStatusWire` ([clientApi.ts:25-33](../../client/src/services/refunds/apis/clientApi.ts)) does not declare `platformFeeRefundedIrr`, `nursePayoutRefundedIrr`, `refundPercentageApplied`, `cancellationPolicyCode`, `createdAt`, `completedAt`, and `toSummary` (`:46-51`) hardcodes all six to `null` — so the fee-split transparency section can never render. All six were returned live today. REQ-021 is delivered; the client does not know.
|
||||||
|
- **Enum drift, three fields.** Client `CancellationPolicyCode = free_24h | partial_under_24h | customer_no_show` ([types.ts:76](../../client/src/services/refunds/types.ts)) vs the seeded rows `standard_24h | standard_inside_24h | nurse_no_show | admin_cancellation`; `CancellationLeadTime = gt_24h | lt_24h | started` vs the wire's `at_least_24h | less_than_24h`; `appliesTo` is typed `CancellationScope` (`whole_booking | remaining_sessions`) but the wire sends the **actor** `"customer"`. Every one of these drives an i18n key lookup → missing-key on flip.
|
||||||
|
- `preview.refundableSessionIds` ([cancel/page.tsx:129](../../client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/cancel/page.tsx)) does not exist on `CancellationPolicyPreviewDto` — the wire serves `sessions[]` only. On the real path it is `undefined`; harmless today because the server defaults to all un-started sessions, but the type lies.
|
||||||
|
- **The `clientApi.ts` doc-block is stale and misleading** (`:55-67`): it states REQ-019/020/021 are *contract gaps*. All three routes exist and returned 200 today. Only `getMyRefunds`, `getRefundPreview`, `approveRefund`, `rejectRefund` remain phantom.
|
||||||
|
- **Every admin refund endpoint is 403** for the seeded `super_admin`/`finance` accounts (`DynamicPermission` grants on the literal role `admin`). Confirmed live for `GET /api/v1/admin_refunds`. The whole admin half is unreachable even after the flag flips.
|
||||||
|
- **REQ-035 not delivered:** `POST admin_refunds` creates *and executes* in one call. There is no preview, approve or reject route, so `RefundPanel`'s three-step console has no real backend.
|
||||||
|
- **REQ-048 not delivered:** no `GET refunds/my`, so the wallet «استردادها» tab renders empty on the real path.
|
||||||
|
- **No admin console for settlement.** `confirm_settlement`, `mark_failed` and `admin_clawbacks/{id}/write_off` have server handlers but **zero client screens** — a `manual`-channel refund can be created and then never confirmed through the UI.
|
||||||
|
- `/fa/admin/finance` is a hub with a single payouts tile — no refunds, no clawbacks, no invoice issue.
|
||||||
|
- `expectedCustomerRefundEta` is hardcoded `null` in the preview ([Query.Handler.cs:83-84](../../server/src/Core/Baya.Application/Features/Refunds/Queries/GetCancellationPolicyPreview/GetCancellationPolicyPreviewQuery.Handler.cs)), so the BNPL 10-business-day window is invisible *before* confirming — exactly where the honesty matters most.
|
||||||
|
- **`customer_no_show` has no seeded row.** The product doc's "up to 100% charge" tier does not exist in `CancellationPolicies`; a customer no-show falls through to `standard_inside_24h` (50% back).
|
||||||
|
- **No credit note / invoice reversal on refund.** `Invoice` carries no `RefundId` and `CreateRefundCommandHandler` issues none, so a refunded booking's VAT-bearing commission invoice stays as issued.
|
||||||
|
- The client's in-memory mocks (22 `services/*/apis/mockApi.ts` modules) are module-scoped and reset on reload/HMR — a mocked cancel demo does not survive the navigation to `refund_status`.
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# Flow — care-circle-patients
|
||||||
|
|
||||||
|
> Last verified: 2026-08-02 against commit `c841bde`
|
||||||
|
|
||||||
|
**Actor(s):** customer · **Status:** partial
|
||||||
|
**Client:** partial · **Server:** real
|
||||||
|
**Business source:** [product/business/01-actors-and-onboarding.md](../../product/business/01-actors-and-onboarding.md) ·
|
||||||
|
data model: [product/data-model/10-reviews-and-records.md](../../product/data-model/10-reviews-and-records.md)
|
||||||
|
**Integration:** [docs/integration/domains/patients.md](../integration/domains/patients.md) ·
|
||||||
|
[patient-records.md](../integration/domains/patient-records.md)
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
A customer keeps a list of the people they arrange care for — «حلقهٔ مراقبت», renamed from «بیماران» in
|
||||||
|
ui-phase-9. Each one is a first-class row, not the account holder: the payer is usually an adult child and
|
||||||
|
the care recipient an elderly parent or a newborn. Tapping a person opens their care record — the family's
|
||||||
|
own medication/routine/task plan, plus the read-only history of visit notes nurses wrote for them.
|
||||||
|
|
||||||
|
> **The seam splits mid-flow.** The **list** screen is real end to end and was probed live. The **record**
|
||||||
|
> screen is real UI on a mock service (`USE_PATIENT_RECORDS_MOCK = true`,
|
||||||
|
> [`patientRecords/constants.ts:13`](../../client/src/services/patientRecords/constants.ts)) — even though
|
||||||
|
> all five server endpoints exist and return `200`. Nothing you see on `/record` comes from the database.
|
||||||
|
|
||||||
|
## Screens
|
||||||
|
|
||||||
|
| Step | Route | Component / notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| E1 — the care circle | `/fa/patients` | [`patients/page.tsx`](../../client/src/app/%5Blocale%5D/(private-routes)/(customer)/patients/page.tsx) · `PatientCard` (uses `InitialsAvatar`) · add/edit in `FormDialogShell` + `PatientForm` · archive behind `ConfirmDialog` |
|
||||||
|
| — home teaser | `/fa` | `HomeScreen.tsx:87` calls the same `usePatients()` |
|
||||||
|
| E2 — care record | `/fa/patients/[id]/record` | [`record/page.tsx`](../../client/src/app/%5Blocale%5D/(private-routes)/(customer)/patients/%5Bid%5D/record/page.tsx) · 4 tabs داروها / روتین / سوابق / وظایف · `PatientHeader`, `VisitNoteCard`, per-item bottom-sheet edit · `EmptyState` access-denied card gated **before** any clinical fetch |
|
||||||
|
| — nurse counterpart | `/fa/nurse/visits/[id]` | `NurseVisitNotesPanel.tsx:38-44` — same domain, append-only |
|
||||||
|
|
||||||
|
Age is derived client-side by [`patients/age.ts`](../../client/src/services/patients/age.ts); the wire
|
||||||
|
carries `birthDate`.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| Call | Endpoint | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `usePatients` → `patientsClientApi.list` | `GET /api/v1/patients/list` | **real** · `clientApi.ts:46` → `PatientsController.List` |
|
||||||
|
| `usePatient` | `GET /patients/get/{id}` | real |
|
||||||
|
| `useCreatePatient` / `useUpdatePatient` | `POST /patients/create` · `/update/{id}` | real · `relation`+`conditions` round-trip (REQ-005) |
|
||||||
|
| `useArchivePatient` | `POST /patients/archive/{id}` | real · **archive, never delete** |
|
||||||
|
| `useRecordAccess` | `GET /patients/{id}/record_access` | server real, **client mocked** |
|
||||||
|
| `usePatientCareRecord` | `GET /patients/{id}/care_record` | server real, **client mocked** |
|
||||||
|
| `useUpdateCareRecord` | `PUT /patients/{id}/care_record` | server real, **client mocked** — the only `PUT` in the API |
|
||||||
|
| `usePatientHistory` | `GET /patients/{id}/care_records` | server real, **client mocked** |
|
||||||
|
| `useCreateVisitNote` (nurse) | `POST /patients/{id}/care_records` | server real, **client mocked** |
|
||||||
|
|
||||||
|
Handlers: `PatientsController.cs` (Features/Identity/{Commands,Queries}) and
|
||||||
|
`PatientCareRecordsController.cs` → `Features/PatientCareRecords/**`. Request/response shapes live in the
|
||||||
|
integration docs linked above — not here.
|
||||||
|
|
||||||
|
## Rules that must hold
|
||||||
|
|
||||||
|
| Rule | Source |
|
||||||
|
| --- | --- |
|
||||||
|
| A patient is a **first-class row distinct from the customer**; `self` still creates its own row. Customer→patient is 1:N | [business/01](../../product/business/01-actors-and-onboarding.md) |
|
||||||
|
| **`gender` is load-bearing** — it drives same-gender caregiver matching. Never defaulted or inferred | [integration/patients.md](../integration/domains/patients.md) |
|
||||||
|
| **Archive, never delete.** A patient referenced by a booking must stay resolvable | [integration/patients.md](../integration/domains/patients.md) |
|
||||||
|
| **The plan is family-owned; visit records are nurse-owned and append-only.** No edit, no delete endpoint exists and none should | [integration/patient-records.md](../integration/domains/patient-records.md) |
|
||||||
|
| **Two-stage clinical disclosure** — clinical bodies encrypted at rest, decrypted only after the access check; nurse read is scoped by an active booking | INV-6, `server/CLAUDE.md` rule 18 |
|
||||||
|
| **Tenancy mismatch is a 404, never a 403** | [api-contract.md](../integration/api-contract.md#status-codes) |
|
||||||
|
| `initialMedicalNotes` is encrypted PII and is **not** the care record | [integration/patients.md](../integration/domains/patients.md) |
|
||||||
|
|
||||||
|
## How to test
|
||||||
|
|
||||||
|
1. Log in as `09120000010` (سارا محمدی, customer) — see [testing-setup.md](testing-setup.md).
|
||||||
|
2. Open `/fa/patients`.
|
||||||
|
**Expect:** two cards — «فاطمه محمدی» and «حسن محمدی». Header reads «حلقهٔ مراقبت». Verified live:
|
||||||
|
`GET /patients/list?page=1&pageSize=10` → `total: 2`.
|
||||||
|
**Note:** both seeded rows carry `relation: null` and `conditions: []`, so the relation chip and the
|
||||||
|
condition chips render nothing. That is seed data, not a UI bug.
|
||||||
|
3. Tap «افزودن» and save a new person.
|
||||||
|
**Expect:** the card appears immediately (the list is invalidated on mutation) and a green «ذخیره شد».
|
||||||
|
A new `GET /patients/list` now returns `total: 3` — this writes to the **shared remote DB**.
|
||||||
|
4. Tap a card's archive action and confirm.
|
||||||
|
**Expect:** the card disappears, toast «بایگانی شد». The row is still resolvable server-side
|
||||||
|
(`isActive: false`), so any booking that references it keeps working.
|
||||||
|
5. Tap a card body to open `/fa/patients/1/record`.
|
||||||
|
**Expect:** the ownership banner «این پرونده متعلق به خانواده است…», then four tabs.
|
||||||
|
**This screen is mock-served.** What you see under داروها / روتین / وظایف is
|
||||||
|
`patientRecords/apis/mockApi.ts` fixtures, and سوابق shows mock notes — **not** the two real seeded
|
||||||
|
care records. Every edit you make is lost on reload (the mock store is module-level).
|
||||||
|
6. To see the real data the screen is hiding, call it directly:
|
||||||
|
`curl --noproxy '*' "http://localhost:5002/api/v1/patients/1/care_records?page=1&pageSize=20" -H "Authorization: Bearer $T_09120000010"`.
|
||||||
|
**Expect:** `total: 2`, two Persian notes by «زهرا عزیزی» dated 2026-07-16 and 2026-07-25 — and
|
||||||
|
`taskResults` entries whose `label` is `null` (see gaps).
|
||||||
|
7. Access control, verified live and **only** observable via curl today:
|
||||||
|
|
||||||
|
| Caller | `record_access` for patient 1 | Result |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `09120000010` (owner) | `canView: true, canEdit: true, canAppendNote: false` | ✅ |
|
||||||
|
| `09120000001` (nurse 1, has bookings for patient 1) | `canView: true, canEdit: false, canAppendNote: true` | ✅ |
|
||||||
|
| `09120000011` (other customer) | `canView: false, deniedReason: "not_authorized"` | ✅ denies; `GET care_record` for the same caller returns `403` |
|
||||||
|
|
||||||
|
In the browser the mock's denial path is only reachable via
|
||||||
|
`MOCK_FOREIGN_PATIENT_ID = 8888` → `/fa/patients/8888/record`.
|
||||||
|
|
||||||
|
The seeded world still supports every step of this flow — it does not depend on booking-request freshness.
|
||||||
|
|
||||||
|
## Known gaps
|
||||||
|
|
||||||
|
- `USE_PATIENT_RECORDS_MOCK = true` (`client/src/services/patientRecords/constants.ts:13`) while **all five** server endpoints are live and return `200`. The customer's care record and the nurse's visit-note panel both render fixtures instead of the database. The single highest-value flag flip in this flow.
|
||||||
|
- Flipping that flag today **breaks the record screen**: `getFamilyRecord` (`patientRecords/apis/clientApi.ts:68-69`) is a bare `unwrap()` with no mapper, and the wire shape does not match. Server `MedicationDto(long Id, string Name, string? Dosage, string Frequency, string? TimingNote)` vs client `Medication { id: string, doseAmount, doseUnit, frequencyCode, frequencyText, timeOfDay: TimeOfDayCode[], timingNote }` — dose/frequency/time-of-day all render blank.
|
||||||
|
- Same flip **crashes the روتین tab**: server `RoutineItemDto.TimeOfDay` is a single `string?`; the client types it `TimeOfDayCode[]` and calls `.map()` on it (`record/page.tsx:616`).
|
||||||
|
- Care-plan `id` type mismatch (hardening H-17, confirmed): server `long`, client `string`. `updateFamilyRecord` `PUT`s the whole record including client temp ids like `new-1754…` (`record/page.tsx:277`), so the write is unsafe against the real endpoint.
|
||||||
|
- **Server defect — `taskResults` labels are lost.** `GetPatientHistoryQuery.Handler.cs:78` deserializes `TaskResultsJson` with default (case-**sensitive**) `JsonSerializer` options, but `DemoLifecycleSeeder.Social.cs:324` seeds camelCase `{"label":…,"done":…}`. Live result for patient 1: three entries of `{"label": null, "done": false}`. Any externally-written JSON in that column silently degrades to blank labels.
|
||||||
|
- **Client discards a field the server does serve.** `toVisitNote` (`patientRecords/apis/clientApi.ts:37`) hardcodes `taskResults: []` although `CareRecordDto.taskResults` is on the wire. The «X از Y» task-summary chip on `VisitNoteCard` can never render on the real path.
|
||||||
|
- `deniedReason` enum mismatch: the server returns `"not_authorized"` (`PatientAccess.cs:20`), while `patientRecords/types.ts:117` and `docs/integration/domains/patient-records.md` both declare `no_access | not_found`. Latent today (the UI renders a generic card), a bug the moment anyone branches on it.
|
||||||
|
- **Stale code comments assert the opposite of reality.** `patientRecords/types.ts:11-13`, `constants.ts:1-12` and `apis/clientApi.ts:62-64` all say the family record and access check have "**no backend**" / are REQ-027 gaps. All three routes exist, are `[Authorize]`d, and were probed `200`. The integration doc is right and the code comments are wrong.
|
||||||
|
- `patients/constants.ts:1-7` still explains the domain "demos behind the mock" because REQ-005 is missing — REQ-005 is delivered and the flag is already `false`.
|
||||||
|
- **Editing a patient destroys their birth date.** `PatientForm.tsx:94` always submits `ageToBirthDate(age)` = `YYYY-01-01`. Saving seeded patient 1 (`birthDate: 1948-03-15`) with no changes rewrites it to `1948-01-01`. Age display survives; the date does not.
|
||||||
|
- Seeded care plans are **empty** — `GET /patients/1/care_record` returns `{ medications: [], routine: [], tasks: [] }`, so three of the four record tabs would be blank the moment the flag flips. `DemoLifecycleSeeder` never seeds a `PatientCarePlan`.
|
||||||
|
- Seeded patients have `relation: null` and `conditions: []`, so the relation/condition chips the UI is built around never appear on demo data.
|
||||||
|
- REQ-057 open: `PatientDto` has no `lastVisitAt`/`visitCount` and `BookingListItemDto` has no `patientId`, so no booking card can show a care teaser.
|
||||||
|
- **No `product/business/` file covers patient care records at all** — no documented rule for the append-only constraint, the encryption, or the clinical-access gate. Only `product/data-model/10-reviews-and-records.md` mentions the table. Largest product-doc hole touching this flow.
|
||||||
|
- Archive is optimistic with no undo; a cross-tenant/stale id 404s and the card silently reappears with a generic «در دسترس نیست» toast (`patients/page.tsx:65-70`).
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# Flow — Checkout & payment
|
||||||
|
|
||||||
|
> Last verified: 2026-08-02 against commit `c841bde`
|
||||||
|
|
||||||
|
**Actor(s):** customer · **Status:** partial
|
||||||
|
**Client:** partial · **Server:** partial
|
||||||
|
**Business source:** [product/business/08-payments-and-escrow.md](../../product/business/08-payments-and-escrow.md) ·
|
||||||
|
[product/payments/escrow-ledger.md](../../product/payments/escrow-ledger.md) ·
|
||||||
|
[product/business/13-tax-invoicing-and-legal.md](../../product/business/13-tax-invoicing-and-legal.md)
|
||||||
|
**Integration:** [docs/integration/domains/payment.md](../integration/domains/payment.md) ·
|
||||||
|
[docs/integration/api-contract.md](../integration/api-contract.md)
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
The customer's nurse accepted, a 30-minute payment window is running, and the family pays the whole service
|
||||||
|
price on a card. Balinyaar never creates the booking on "the user tapped pay" — the booking, the balanced
|
||||||
|
escrow ledger group and the commission invoice all come into existence inside the PSP's webhook, after the
|
||||||
|
server re-verifies the capture with the acquirer. Everything before that is a redirect.
|
||||||
|
|
||||||
|
## Screens
|
||||||
|
|
||||||
|
| Step | Route | Component / notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| C6 summary & pay | `/fa/bookings/checkout?request_id=` | [`checkout/page.tsx`](../../client/src/app/%5Blocale%5D/(private-routes)/(customer)/bookings/checkout/page.tsx) — nurse identity card, served `PriceBreakdown`, `CountdownTimer` on the frozen `paymentDeadlineAt`, `EscrowExplainer`, `StickyActionBar`. Non-payable statuses render a `PaymentStateCard` instead of a CTA (`:92-124`) |
|
||||||
|
| Return from gateway | `/fa/bookings/checkout/return` | `useConfirmGatewayReturn` fires once per mount (`confirmFiredRef`), then the `StatusTimeline` pending-callback state backed by the backoff poll; terminal → `invalidateAfterPaymentSuccess` → confirmation |
|
||||||
|
| Receipt | `/fa/bookings/checkout/confirmation` | Paid total, copyable LTR کد پیگیری, Shamsi paid-at, method, booking deep-link, «دانلود فاکتور». Shared with the BNPL branch via `?method=bnpl` |
|
||||||
|
| Money hub | `/fa/wallet` | 4 tabs; «پرداختها»/«رسیدها» merge card + BNPL rows in `useWalletHistoryRows.ts` |
|
||||||
|
| Invoice | `/fa/bookings/[id]/invoice` | A4-printable commission invoice, VAT-on-commission line, مودیان status chip |
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| Call | Endpoint | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Checkout summary | `GET booking_requests/checkout_summary/{id}` | **Live — probed `200`.** `clientApi.ts:35-42` |
|
||||||
|
| Initiate | `POST bookings/{bookingRequestId}/payments` | `clientApi.ts:44-50`; **one of only two endpoints that read `Idempotency-Key`** (`PaymentsController.cs:34`; the other is `CheckoutBnplController.cs:41`) |
|
||||||
|
| Outcome poll | `GET booking_requests/get/{id}` | No transaction read exists; `clientApi.ts:56-80` maps request status → `succeeded`/`failed`/`pending` |
|
||||||
|
| Invoice | `GET invoices/{bookingId}` | `clientApi.ts:82-83` |
|
||||||
|
| PSP callback | `POST webhooks/payments/{provider}` | anonymous, server-only |
|
||||||
|
| Payment history | `GET bookings/payment_history` | **Phantom — probed `404`** (REQ-047) |
|
||||||
|
|
||||||
|
Shapes live in [payment.md](../integration/domains/payment.md); the request DTO in
|
||||||
|
[booking-requests.md](../integration/domains/booking-requests.md).
|
||||||
|
|
||||||
|
## Rules that must hold
|
||||||
|
|
||||||
|
| Rule | Value | Where |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Three-amount split | `gross = balinyaar_commission + nurse_payout`, all ≥ 0 | DB CHECK `CK_Bookings_AmountSplit` (`BookingConfig.cs:13-17`) — [business/08](../../product/business/08-payments-and-escrow.md) |
|
||||||
|
| Commission rate | `0.15` — a **seeded `platform_configs` default**, not a product mandate | key `platform_fee_rate`; read at conversion (`BookingConversion.cs:45`) |
|
||||||
|
| Rate is snapshotted | frozen onto `Bookings.PlatformFeeRate` at conversion, never re-read | `BookingFactory.Create`; a later rate change is never retroactive |
|
||||||
|
| VAT | `0.10`, **on Balinyaar's commission line only** — never the gross, never the payout | key `vat_rate`; [business/13](../../product/business/13-tax-invoicing-and-legal.md), [platform-summary GT-2](../../product/overview/platform-summary.md) |
|
||||||
|
| Payment window | `30` min, server-frozen onto `BookingRequests.PaymentDeadlineAt` | key `booking_payment_deadline_minutes`; [business/05](../../product/business/05-booking-and-scheduling.md) |
|
||||||
|
| Money on the wire | IRR integer — **digit string outbound, `int64` inbound**. Never `Number()`; parse with the BigInt helpers | `GetCheckoutSummaryQuery.Handler.cs:72` (`Str()`); client rule 18 |
|
||||||
|
| Ledger balances | `CardCapture` = DEBIT `escrow_held` gross / CREDIT `platform_revenue` commission + `nurse_payable` payout, one group | `LedgerPosting.cs:26-36` **throws** rather than persist an unbalanced group |
|
||||||
|
| Ledger is append-only | corrections are new balancing groups, never edits | `LedgerEntryConfig.cs`; [escrow-ledger.md](../../product/payments/escrow-ledger.md) |
|
||||||
|
| Escrow release | only after a **confirmed check-out** and a closed 72 h dispute window — never on `completed` alone | `Bookings/Commands/CheckOutVisit/CheckOutVisitCommand.Handler.cs` (the booking-lifecycle flow file is not written yet — see [flows index](index.md)) |
|
||||||
|
| The platform holds no cash | «escrow» is a ledger state over funds custodied at the licensed provider (GT-1) | [platform-summary](../../product/overview/platform-summary.md) |
|
||||||
|
| Webhook idempotency | upsert `payment_webhook_events` on `(provider, external_event_id)` **first**, then re-verify with the acquirer, then confirm | `HandlePaymentWebhookCommand.Handler.cs:31-36, 76-85, 88` |
|
||||||
|
| One succeeded payment | filtered `UNIQUE(booking_id) WHERE status='succeeded'`; a unique-violation on confirm is idempotent **success** | `ConfirmPaymentAndPostLedgerCommand.Handler.cs:69-75` |
|
||||||
|
| `409` on initiate is benign | "already paid / not awaiting payment / window lapsed" — converge, never toast | handler `:35-43`; client `checkout/page.tsx:145-149` |
|
||||||
|
|
||||||
|
### ⚠ The same commission is taxed two different ways
|
||||||
|
|
||||||
|
Probed live on **booking 8 / request 13**, gross `250000`, commission `37500`:
|
||||||
|
|
||||||
|
| Surface | VAT | Method |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `checkout_summary/13` | **`3409`** | `commissionNet = round(commission / (1 + vatRate))`, VAT carved **out** (`GetCheckoutSummaryQuery.Handler.cs:44-46`) |
|
||||||
|
| `invoices/8` | **`3750`** | `vat = round(commission × vatRate)`, VAT **additive** (`IssueInvoiceCommand.Handler.cs:37-38`) |
|
||||||
|
|
||||||
|
Both reconcile internally (checkout: `212500 + 34091 + 3409 = 250000`; invoice: `37500 + 3750 = 41250`) but
|
||||||
|
they disagree by 341 IRR on one booking. No product file resolves it. Never state "VAT is additive/inclusive"
|
||||||
|
without naming the surface.
|
||||||
|
|
||||||
|
## How to test
|
||||||
|
|
||||||
|
Log in as **`09120000010`** (سارا محمدی, customer) — see [testing-setup.md](testing-setup.md) for boot, the
|
||||||
|
OTP and the demo accounts.
|
||||||
|
|
||||||
|
> **The pristine seed has nothing to pay for.** Every seeded request has aged past its window: ids 6,7,8,9,12,13
|
||||||
|
> are `converted`, 2 is `payment_deadline_expired`, 1 is `expired_no_response`, 3 is `rejected_by_nurse`. The
|
||||||
|
> seed contains **no** `accepted_awaiting_payment` request — you must make one (step 4) to reach a payable C6.
|
||||||
|
> **The live DB is not pristine:** this session's `booking_requests/list` for `…010` also returned **21**
|
||||||
|
> (`accepted_awaiting_payment`, live deadline) and **22** (`cancelled_by_customer`), both left by earlier
|
||||||
|
> walk-throughs. Re-list before assuming an id's state.
|
||||||
|
|
||||||
|
**Walk the read-only half (works today):**
|
||||||
|
|
||||||
|
1. Open `/fa/bookings/checkout?request_id=13`. **Expect:** the already-paid convergence card («این رزرو
|
||||||
|
پرداخت شده است»), not a pay button — the summary returns `requestStatus: "converted"`.
|
||||||
|
2. Open `/fa/bookings/8/invoice`. **Expect:** `INV-0000000008`, VAT row labelled `۱۰٪`, مودیان chip
|
||||||
|
«در انتظار» (`moadianStatus: "pending"` — the mock `IMoadianClient` returns `registered` **only** when
|
||||||
|
`Seams:Moadian:ForceRegistered` is on, and it is off by default), no PDF button (`pdfUrl: null`).
|
||||||
|
3. Open `/fa/wallet` → «پرداختها». **Expect:** the empty state, *not* an error — `bookings/payment_history`
|
||||||
|
404s and the BNPL half is mock-empty (`useWalletHistoryRows.ts`).
|
||||||
|
|
||||||
|
**Create something payable (the workaround):**
|
||||||
|
|
||||||
|
4. As `09120000010`: search → C4 → submit a booking request. As **`09120000001`** (زهرا عزیزی): accept it from
|
||||||
|
the nurse inbox. **Expect:** status `accepted_awaiting_payment` and a 30-minute countdown. (If a leftover
|
||||||
|
payable request is still inside its window — id `21` at this stamp — reuse it and skip to step 5.)
|
||||||
|
5. Return to `/fa/bookings/checkout?request_id=<new>`. **Expect:** the money breakdown, the live countdown,
|
||||||
|
and an enabled «پرداخت».
|
||||||
|
6. **Do not tap «پرداخت» in the browser** — see the first gap. Instead capture from the CLI:
|
||||||
|
`POST /api/v1/bookings/convert {"bookingRequestId": <new>}` with the customer's bearer.
|
||||||
|
**Expect:** `200` with the booking detail, request → `converted`, and two notifications of *different*
|
||||||
|
types (`booking_confirmed` to the customer, `booking_confirmed_nurse` to the nurse).
|
||||||
|
**But:** this path posts **no ledger group, no `payment_transactions` row, no invoice and no coordination
|
||||||
|
ticket** (`ConvertRequestToBookingCommand.Handler.cs` — it stops at `CommitAsync` + notify), so
|
||||||
|
`GET /invoices/{newBookingId}` will `404`. Only the webhook path (`ConfirmPaymentAndPostLedger…:58-98`)
|
||||||
|
produces the full money record.
|
||||||
|
|
||||||
|
**Live probe results at this stamp** (customer `…010`, every curl `--noproxy '*'`):
|
||||||
|
|
||||||
|
- `checkout_summary/13` → `200`; `vatIrr "3409"`, `grossPriceIrr "250000"`, `balinyaarCommissionIrr "37500"`.
|
||||||
|
- `checkout_summary/21` (a genuinely payable request) → `200`, `requestStatus "accepted_awaiting_payment"`.
|
||||||
|
- **`POST bookings/21/payments` + `Idempotency-Key` → `200`, `redirectUrl
|
||||||
|
"https://mock-psp.local/pay/mock-ref-21-<key>"`.** The dead end below is probed on a payable request, not
|
||||||
|
inferred from the 409 path.
|
||||||
|
- `POST bookings/13/payments` + `Idempotency-Key` → `409 "This booking has already been paid."`
|
||||||
|
- `bookings/payment_history` → `404` · `booking_requests/get/13` → `200` with `bookingId: 8` ·
|
||||||
|
`invoices/8` → `200`; `vatIrr "3750"`, `totalIrr "41250"`, `moadianStatus "pending"`, `pdfUrl null`.
|
||||||
|
|
||||||
|
## Known gaps
|
||||||
|
|
||||||
|
- **Tapping «پرداخت» dead-ends the browser.** `MockPaymentProvider.cs:20` returns
|
||||||
|
`https://mock-psp.local/pay/{ref}` — a non-existent host — and `checkout/page.tsx:137-140` does
|
||||||
|
`window.location.assign` on any absolute URL. The customer lands on a DNS error and never returns.
|
||||||
|
**Probed:** `POST bookings/21/payments` really does return that URL with a `200`. The local card-gateway
|
||||||
|
harness page (`checkout/gateway/page.tsx`) was deleted in `64f6aa4`, so nothing catches the hop.
|
||||||
|
- **Nothing fires the PSP webhook locally**, so no *new* card payment reaches `ConfirmPaymentAndPostLedger` in
|
||||||
|
dev: the `payment_transactions` row stays `pending`, no ledger group is posted, no invoice is issued.
|
||||||
|
`bookings/convert` substitutes for the *booking*, not for the money. The succeeded transactions and invoices
|
||||||
|
you can read today (booking 8) are **seeded**, not reproducible from the UI — so the flow's core invariant is
|
||||||
|
observable only as data, never as behaviour.
|
||||||
|
- **A verified nurse is shown as unverified on the payment screen.** `CheckoutSummaryDto` (client
|
||||||
|
`types.ts:46-47`) declares `nurseAvatarUrl` and `nurseVerified`; the server DTO constructs neither
|
||||||
|
(`GetCheckoutSummaryQuery.Handler.cs:48-67`, absent from the probed payload), so `nurseVerified` is
|
||||||
|
`undefined` → `<TrustBadge state="unverified">` at `checkout/page.tsx:326`. REQ-046.
|
||||||
|
- **`sessionCount` is served but nullable, and the client types it as non-null.** The server *does* emit the
|
||||||
|
field (`"sessionCount": null` on requests 13 and 21 — `ctx.SessionCount` is `int?`, defaulted to `1` only
|
||||||
|
for the internal `gross` maths); the client declares `sessionCount: number` (`types.ts:52`) and feeds it
|
||||||
|
straight into the ICU label `row_service_cost_with_count` at `checkout/page.tsx:265`. UNVERIFIED how
|
||||||
|
next-intl renders a null `count` — not reproduced in a browser this session.
|
||||||
|
- **The confirmation screen cannot deep-link to the booking.** `booking_requests/get/{id}` **does** serve
|
||||||
|
`bookingId` (probed `8`), but `clientApi.ts:58` types the response as `Omit<…,'bookingId'>` and `:73`
|
||||||
|
hardcodes `bookingId: null`. Hardening issue H-10. The receipt also always hides کد پیگیری and paid-at
|
||||||
|
(`:76-77`).
|
||||||
|
- **The client's payment constants and comments are stale and mislead the reader:** `clientApi.ts:36-37` says
|
||||||
|
`checkout_summary` "404s until the backend delivers it" (it returns `200`); `constants.ts:5-16` still says
|
||||||
|
"Mock is primary this phase" and "the contract serves no checkout summary" while `USE_PAYMENT_MOCK = false`;
|
||||||
|
and `constants.ts:53` hardcodes `MOCK_PLATFORM_FEE_RATE = 0.12` against the server's seeded `0.15`. Only the
|
||||||
|
last is money-shaped, and it is unreachable while the mock is off.
|
||||||
|
- **The invoice screen derives a money row.** `invoice/page.tsx:135` computes
|
||||||
|
`serviceIrr = gross − commission − vat` = `208750` for booking 8, but the real nurse payout is `212500` —
|
||||||
|
wrong by exactly the VAT, because VAT is additive and not part of gross. It also labels `invoice.grossIrr`
|
||||||
|
as the invoice total while the served `totalIrr` (`41250`, the commission invoice) is never shown. Violates
|
||||||
|
client rule 18 ("the client displays money; it never computes it").
|
||||||
|
- **The wallet «پرداختها» tab is permanently empty** for a card-paying customer — `bookings/payment_history`
|
||||||
|
is a live `404` on every visit (REQ-047).
|
||||||
|
- **`InvoiceDto` serves no payment method, transaction reference or seller fiscal identity** (REQ-049), so
|
||||||
|
those rows never render on the real path.
|
||||||
|
- **The escrow ledger has no read surface.** `ledger_entries` is exposed only through
|
||||||
|
`GetNursePayableBalance`; a customer, an admin and a tester have no way to see the balanced capture group
|
||||||
|
the flow's core invariant depends on.
|
||||||
|
- **`GET invoices/{bookingId}` is `[Authorize]` only** (`InvoicesController.cs:17`); tenancy is enforced inside
|
||||||
|
`GetInvoiceQuery`. Not re-probed cross-tenant this session — UNVERIFIED that a foreign customer gets `404`.
|
||||||
+202
-23
@@ -1,33 +1,212 @@
|
|||||||
# Flows — what is implemented, and how to test it
|
# Flows — what is implemented, and how to test it
|
||||||
|
|
||||||
> **Populated by phase 3 — not yet written.** It is the biggest phase in the chain (2–4 sessions).
|
One file per **user-meaningful journey**. Each answers three questions and no others: what it does, what is
|
||||||
> Until then the closest things are [manual-testing-plan.md](../../dev/post-phase/manual-testing-plan.md)
|
real versus mocked, and the exact steps to walk it against a running stack.
|
||||||
> and [RUNBOOK.md](../../dev/post-phase/refinement/RUNBOOK.md) — both partly stale, see below.
|
|
||||||
|
|
||||||
One file per **user-meaningful journey**, not per screen. Each answers three questions and no others:
|
**Start here → [testing-setup.md](testing-setup.md)** for bring-up, the demo accounts and the OTP. Then pick
|
||||||
what it does, what is real versus mocked, and the exact steps to walk it against a running stack.
|
a row below.
|
||||||
|
|
||||||
This page becomes the **status table** — every flow, one row, with its `Last verified` date. It is the
|
> Last verified: 2026-08-02 against commit `c841bde`, against a **booted stack** — API on
|
||||||
page you check before believing anything else in `docs/`.
|
> `http://localhost:5002` against the shared remote SQL Server, client on `http://localhost:3000` (and a
|
||||||
|
> production build on `:3001`), all 8 demo accounts logged in over the real phone-OTP round-trip.
|
||||||
|
|
||||||
## Planned contents
|
---
|
||||||
|
|
||||||
| File | Covers |
|
## Status table
|
||||||
| --- | --- |
|
|
||||||
| `testing-setup.md` | Bring-up, demo accounts, the seeded world, OTP retrieval, reset, troubleshooting |
|
|
||||||
| `<flow>.md` × ~23 | One per journey — the candidate list is in [phase 3](../_plan/phase-3-flow-atlas.md) |
|
|
||||||
|
|
||||||
## Before writing testing-setup.md
|
`Client` / `Server` are independent on purpose. **A real UI on a mocked service is the trap this atlas
|
||||||
|
exists to expose** — six flows below are exactly that.
|
||||||
|
|
||||||
Four contradictions land squarely on the bring-up story, and phase 3 has to settle them rather than
|
| Flow | Actor | Status | Client | Server | Gaps | File |
|
||||||
copy either source. All are logged in [_plan/open-contradictions.md](../_plan/open-contradictions.md):
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| auth-login-otp | all | **built** | real | real | 15 | [→](auth-login-otp.md) |
|
||||||
|
| public-front-door | guest | partial | real | real | 10 | [→](public-front-door.md) |
|
||||||
|
| onboarding-customer | customer | partial | real | real | 9 | [→](onboarding-customer.md) |
|
||||||
|
| care-circle-patients | customer | partial | partial | real | 15 | [→](care-circle-patients.md) |
|
||||||
|
| addresses-and-map | customer | partial | real | real | 10 | [→](addresses-and-map.md) |
|
||||||
|
| onboarding-nurse | nurse | partial | partial | real | 11 | [→](onboarding-nurse.md) |
|
||||||
|
| nurse-service-areas | nurse | partial | real | real | 8 | [→](nurse-service-areas.md) |
|
||||||
|
| nurse-verification | nurse + admin | **mocked** | mock | partial | 12 | [→](nurse-verification.md) |
|
||||||
|
| nurse-catalog-and-pricing | nurse | partial | real | real | 9 | [→](nurse-catalog-and-pricing.md) |
|
||||||
|
| search-and-discovery | customer | partial | partial | real | 14 | [→](search-and-discovery.md) |
|
||||||
|
| booking-request | customer + nurse | partial | real | real | 12 | [→](booking-request.md) |
|
||||||
|
| checkout-and-payment | customer | partial | partial | partial | 11 | [→](checkout-and-payment.md) |
|
||||||
|
| bnpl-installments | customer | **mocked** | mock | partial | 12 | [→](bnpl-installments.md) |
|
||||||
|
| booking-lifecycle-evv | nurse + customer | partial | real | real | 13 | [→](booking-lifecycle-evv.md) |
|
||||||
|
| cancellation-and-refunds | customer + admin | **mocked** | mock | partial | 17 | [→](cancellation-and-refunds.md) |
|
||||||
|
| reviews | customer + admin | partial | real | real | 11 | [→](reviews.md) |
|
||||||
|
| patient-care-records | nurse + customer | **mocked** | mock | real | 12 | [→](patient-care-records.md) |
|
||||||
|
| nurse-earnings-and-payouts | nurse + admin | **mocked** | mock | partial | 18 | [→](nurse-earnings-and-payouts.md) |
|
||||||
|
| messaging-tickets | all | partial | partial | real | 14 | [→](messaging-tickets.md) |
|
||||||
|
| notifications | all | partial | real | real | 13 | [→](notifications.md) |
|
||||||
|
| admin-backoffice | admin | **mocked** | partial | partial | 13 | [→](admin-backoffice.md) |
|
||||||
|
| partner-center | partner | **mocked** | mock | partial | 12 | [→](partner-center.md) |
|
||||||
|
| account-and-settings | all | partial | partial | real | 12 | [→](account-and-settings.md) |
|
||||||
|
|
||||||
- **C-1** — `manual-testing-plan.md` still tells you to set crypto keys via `dotnet user-secrets`, which
|
**1 built · 15 partial · 7 mocked · 0 not started · 0 UNVERIFIED.** 283 gaps recorded — Phase 4's input.
|
||||||
was removed in `5885280`. **`RUNBOOK.md` is already correct on this**; only the one file is stale.
|
|
||||||
- **C-3** — the API listens on `http://localhost:5002`, not `https://`, in 18 places' worth of prose.
|
|
||||||
- **C-4** — the `dotnet dev-certs https --trust` step therefore has nothing to trust.
|
|
||||||
- **C-5** — `RUNBOOK.md` starts a **local** SQL Server in Docker; the committed dev config points at a
|
|
||||||
**remote** one. These are not the same world: the remote DB is already seeded, a fresh local one is
|
|
||||||
not, and its data is encrypted under keys that must match exactly.
|
|
||||||
|
|
||||||
Pick one bring-up path, verify it end to end, and say plainly what the other one was.
|
`built` = end-to-end real and observed working · `partial` = real end to end with named gaps · `mocked` =
|
||||||
|
the UI is real, the data is fake · `not started` = no implementation. Every row is backed by a code trace,
|
||||||
|
and every money flow plus auth was additionally walked against the running API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The six things that surprise everyone
|
||||||
|
|
||||||
|
1. **No seeded admin can reach any admin endpoint.** All 16 admin GET operations return `403` for both
|
||||||
|
`09120000020` (super_admin) and `09120000021` (finance). `DynamicPermissionService.CanAccess` grants on
|
||||||
|
the **literal** role `"admin"` or a per-controller `DynamicPermission` claim; the demo admins hold
|
||||||
|
`super_admin`/`finance`, and **no code anywhere writes that claim**. The client hides it completely
|
||||||
|
because `USE_ADMIN_MOCK = true`. → [admin-backoffice.md](admin-backoffice.md)
|
||||||
|
2. **A `false` mock flag does not mean the domain is honest.** `payment` is flag-real with only 2 of 6
|
||||||
|
operations working; `search` hardcodes `isVerified: true` and `nurseGender: 'female'`;
|
||||||
|
`tickets.getUnreadTotal` is literally `async () => null`.
|
||||||
|
3. **A `true` mock flag does not mean the real half is missing.** `verification` has 10 of 14 operations
|
||||||
|
live and probed, `admin` 9 of 14, `payouts` 5 of 10 — all suppressed by one shared flag. The nurse's
|
||||||
|
verification status answers correctly to `curl` right now; nothing in the browser calls it.
|
||||||
|
4. **The seeded demo world is dated 2026-07-26 and has aged out.** No `pending` or `accepted` booking
|
||||||
|
request survived the 60-second expiry job, so the nurse inbox and checkout have nothing seeded to act
|
||||||
|
on. → [testing-setup.md](testing-setup.md#the-seeded-world-and-how-stale-it-is)
|
||||||
|
5. **No card payment can complete from a browser.** `MockPaymentProvider` redirects to
|
||||||
|
`https://mock-psp.local/pay/…`, a host that does not exist. The server money path is correct and
|
||||||
|
idempotent; only the last hop is unreachable. → [checkout-and-payment.md](checkout-and-payment.md)
|
||||||
|
6. **The real BNPL rail is dead on the live stack.** `SeedPaymentGatewaysAsync` seeds only a `Standard`
|
||||||
|
gateway, never a `Bnpl` one, so `POST /checkout_bnpl/initiate` returns `400 "No active BNPL gateway is
|
||||||
|
configured."` for every request — verified live. The UI never notices because it runs on a mock.
|
||||||
|
→ [bnpl-installments.md](bnpl-installments.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mock-vs-real map — client
|
||||||
|
|
||||||
|
Derived from code across all 22 `client/src/services/` domains, **153 seam operations**. `Real-half verdict`
|
||||||
|
is about the `clientApi.ts` implementation, independent of the flag: `COMPLETE` = every op maps a published
|
||||||
|
route with no fabrication · `PARTIAL` = ≥1 op fabricates or derives a value · `GAPPED` = ≥1 op targets a
|
||||||
|
route that does not exist server-side.
|
||||||
|
|
||||||
|
| Domain | Flag | Ops | Real-half verdict | live / proposed / fabricated |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `addresses` | real | 5 | COMPLETE | 5 / 0 / 0 |
|
||||||
|
| `admin` | **mock** | 14 | GAPPED | 9 / 5 / 0 |
|
||||||
|
| `auth` | real | 6 | COMPLETE | 6 / 0 / 0 |
|
||||||
|
| `bnpl` | **mock** | 7 | GAPPED | 2 / 4 / 1 |
|
||||||
|
| `bookingRequests` | real | 6 | COMPLETE | 6 / 0 / 0 |
|
||||||
|
| `bookings` | real | 7 | COMPLETE | 7 / 0 / 0 |
|
||||||
|
| `catalog` | real | 7 | COMPLETE | 7 / 0 / 0 |
|
||||||
|
| `geography` | real | 3 | COMPLETE | 3 / 0 / 0 |
|
||||||
|
| `notifications` | real | 4 | COMPLETE | 4 / 0 / 0 |
|
||||||
|
| `nurse` (bank) | real | 4 | COMPLETE | 4 / 0 / 0 |
|
||||||
|
| `partnerCenter` | **mock** | 13 | GAPPED | 6 / 7 / 0 |
|
||||||
|
| `patientRecords` | **mock** | 5 | GAPPED | 2 / 3 / 0 |
|
||||||
|
| `patients` | real | 5 | COMPLETE | 5 / 0 / 0 |
|
||||||
|
| `payment` | real | 6 | **GAPPED + PARTIAL — worst real domain** | 2 / 2 / 2 |
|
||||||
|
| `payouts` | **mock** | 10 | GAPPED + PARTIAL | 5 / 4 / 1 |
|
||||||
|
| `profiles` | real | 6 | COMPLETE | 6 / 0 / 0 |
|
||||||
|
| `refunds` | **mock** | 9 | GAPPED | 2 / 7 / 0 |
|
||||||
|
| `reviews` | real | 6 | COMPLETE | 6 / 0 / 0 |
|
||||||
|
| `search` | real | 2 | PARTIAL | 2 / 0 / 3 fields |
|
||||||
|
| `serviceAreas` | real | 3 | COMPLETE | 3 / 0 / 0 |
|
||||||
|
| `tickets` | real | 11 | PARTIAL + GAPPED (gated) | 7 / 3 / 1 |
|
||||||
|
| `verification` | **mock** | 14 | GAPPED + PARTIAL | 10 / 3 / 1 |
|
||||||
|
|
||||||
|
Totals: **15 real / 7 mock** · ~107 ops map published routes · **34 would 404 if the flag were flipped
|
||||||
|
today** · 12 fabricate or client-derive a value.
|
||||||
|
|
||||||
|
Three further facts a reader needs:
|
||||||
|
|
||||||
|
- **20 of 22 mocks hold mutable module state** — a mocked demo that crosses a full page navigation restarts
|
||||||
|
from its seed.
|
||||||
|
- **Two cross-domain mock edges break live paths**: the `refunds` and `bnpl` mocks read the `bookings` mock
|
||||||
|
store, which is seeded only with ids `5001-5005`, and `findBooking` throws `404` for anything else. Real
|
||||||
|
booking ids therefore `404` inside those wizards.
|
||||||
|
- **One production seam breach**:
|
||||||
|
[`nurse/verification/page.tsx:12`](../../client/src/app/[locale]/(private-routes)/nurse/verification/page.tsx)
|
||||||
|
imports `__mockApproveAll`/`__mockRejectStep` from `apis/mockApi` unconditionally. The render is gated;
|
||||||
|
the module edge is not, so the mock ships in every build.
|
||||||
|
|
||||||
|
## Mock-vs-real map — server seams
|
||||||
|
|
||||||
|
**Exactly one `Seams:*:Provider` is set anywhere in the repo — `Seams:Sms:Provider = telegram`.** Every
|
||||||
|
other rail runs on its mock, in development *and* in the deployed stack: `docker-compose.yml` overrides only
|
||||||
|
the relay's `BaseUrl` and the object-storage `RootPath`, never a selector.
|
||||||
|
|
||||||
|
| Rail | Mock | Real adapter available | Selected today |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| SMS / OTP | `LoggingSmsSender` | `KavenegarSmsSender` · `TelegramSmsSender` | **telegram** |
|
||||||
|
| Card PSP | `MockPaymentProvider` | `ZarinPalPaymentProvider` | mock |
|
||||||
|
| Settlement split (تسهیم) | `MockSettlementSplitProvider` | `ProviderSettlementSplitProvider` | mock |
|
||||||
|
| BNPL | `MockBnplProvider` | `SnappPayBnplProvider` · `DigipayBnplProvider` | mock |
|
||||||
|
| Object storage | `LocalDiskObjectStorage` | `S3ObjectStorage` | mock |
|
||||||
|
| Geocoder | `MockGeocoder` | `NeshanGeocoder` | mock |
|
||||||
|
| Bank transfer (payouts) | `MockBankTransferProvider` | none | mock — **moves no money** |
|
||||||
|
| e-invoicing (مودیان) | `MockMoadianClient` | `MoadianClient` | mock |
|
||||||
|
| Shahkar · e-KYC · IBAN ownership | mocks | Finnotech adapters | mock |
|
||||||
|
| Credential (MoH/INO) · eNamad · review moderation | mocks | **none — deliberate** | mock, always |
|
||||||
|
| Search | *(no mock)* | `SqlNurseSearch` | real |
|
||||||
|
|
||||||
|
Two rails **throw at startup** instead of falling back to the mock: `Seams:Sms:Provider = smsir|ghasedak`,
|
||||||
|
and `Search:Backend` set to anything but `sql`. And the PSP selector is not a token match —
|
||||||
|
`ServiceCollectionExtension.cs:174` treats *any* non-`mock` string as ZarinPal, so a typo silently selects a
|
||||||
|
real gateway.
|
||||||
|
|
||||||
|
## Corrections to `mocks-registry.md`
|
||||||
|
|
||||||
|
[`dev/shared-working-context/reports/mocks-registry.md`](../../dev/shared-working-context/reports/mocks-registry.md)
|
||||||
|
was cross-checked row by row against the code: **the whole frontend section — 26 rows plus its prose header
|
||||||
|
— and 17 disagreements were found.** The registry now carries a banner pointing here.
|
||||||
|
|
||||||
|
The systemic cause: its **"Config flag … default `true`"** column was never updated after the
|
||||||
|
refinement-phase-4 de-mock, although the same file's prose header and Status column were.
|
||||||
|
|
||||||
|
| Kind | Count | Detail |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Stale flag default | 8 | `patients`, `profiles`, `nurse` (bank), `geography`, `addresses`, `serviceAreas`, `catalog`, `reviews`, `notifications` rows all still say "default `true`"; the code says `false` |
|
||||||
|
| Actively wrong about behaviour | 3 | `profilesClientApi.uploadAvatar` "throws 501" — it does a real multipart upload · `AddressMapPicker` "not a real map, no Neshan tiles, no network" — it renders real Leaflet Neshan tiles when the key is set · the `payment` row records the flip as clean and never records the 4 broken ops it produced |
|
||||||
|
| Wrong in both directions at once | 1 | One row's flag column says all-mock and its own Status column says all-real; reality is 2 real / 3 mock |
|
||||||
|
| Missing entirely | 3 | `SearchApi`, `BookingRequestsApi` (the exact store behind the BNPL breakage), and `geography/neshan.ts`'s direct third-party `fetch` calls |
|
||||||
|
| Internal contradiction | 1 | The registry says the REQ-027 endpoints exist; `patientRecords/apis/clientApi.ts` says none exist. Needs a server-side ruling |
|
||||||
|
| Verified and **holding** | 15+ | Including all 7 remaining flag values, the EVV GPS default, and the deleted card mock-gateway harness |
|
||||||
|
|
||||||
|
Backend seam rows were **not** re-audited row-by-row; the current server picture is the seam table above.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Coverage accounting
|
||||||
|
|
||||||
|
**All 14 business areas are covered.** Each maps to a primary flow; none is orphaned.
|
||||||
|
|
||||||
|
| Area | Primary flow | Area | Primary flow |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 01 Actors & Onboarding | `auth-login-otp`, `onboarding-*` | 08 Payments & Escrow | `checkout-and-payment` |
|
||||||
|
| 02 Nurse Verification | `nurse-verification` | 09 Installments / BNPL | `bnpl-installments` |
|
||||||
|
| 03 Catalog & Pricing | `nurse-catalog-and-pricing` | 10 Payouts | `nurse-earnings-and-payouts` |
|
||||||
|
| 04 Search & Matching | `search-and-discovery` | 11 Reviews, Trust & Safety | `reviews` |
|
||||||
|
| 05 Booking & Scheduling | `booking-request`, `booking-lifecycle-evv` | 12 Messaging & Emergencies | `messaging-tickets` |
|
||||||
|
| 06 EVV / Service Delivery | `booking-lifecycle-evv` | 13 Tax, Invoicing & Legal | `partner-center` ⚠ **weakest** |
|
||||||
|
| 07 Cancellation & Refunds | `cancellation-and-refunds` | 14 Notifications & Admin | `notifications`, `admin-backoffice` |
|
||||||
|
|
||||||
|
**Area 13 is the thin one.** Invoicing and VAT are split across `checkout-and-payment` (the per-booking
|
||||||
|
invoice) and `admin-backoffice` (the مودیان reconciliation); no flow owns the legal/tax story end to end.
|
||||||
|
Phase 4 backlog item.
|
||||||
|
|
||||||
|
**All 22 client service domains are covered** by at least one flow (see the map above). **All 83 client
|
||||||
|
routes** are reachable from a flow file's Screens table; the 9 routes with no service domain are static
|
||||||
|
pages, and the orphans (nothing links to them) are recorded in the flow that owns their area.
|
||||||
|
|
||||||
|
**One flow has no product source:** `account-and-settings` is a UI-phase-9 decision with no
|
||||||
|
`product/business/` file behind it. `public-front-door`'s depth decision lives in
|
||||||
|
[`product/notes/open-questions.md`](../../product/notes/open-questions.md) rather than a business area.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conventions these files follow
|
||||||
|
|
||||||
|
1. Every file carries `> Last verified: <date> against <commit>`. A file without one is a claim, not a fact.
|
||||||
|
2. Every status is backed by a code trace (route → hook → seam → endpoint → controller → handler) with
|
||||||
|
`file:line` evidence. The money flows and auth were additionally walked against the running API.
|
||||||
|
3. What could not be checked is written with an explicit `UNVERIFIED:` prefix and a reason.
|
||||||
|
4. **API shapes are not restated here** — [docs/integration/](../integration/index.md) owns them, and every
|
||||||
|
flow links to its domain file. **Business rules are not restated** — [product/](../../product/index.md)
|
||||||
|
owns them; a flow states the number and links to its source.
|
||||||
|
5. Each file stays under 200 lines. `testing-setup.md` is a deliberate exception: it is the single page you
|
||||||
|
hand someone, and splitting it would defeat that.
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# Flow — messaging & support tickets
|
||||||
|
|
||||||
|
> Last verified: 2026-08-02 against commit `c841bde`
|
||||||
|
|
||||||
|
**Actor(s):** customer · nurse · admin/staff · **Status:** partial
|
||||||
|
**Client:** partial (real seam, two fabricated summary fields) · **Server:** real
|
||||||
|
**Business source:** [product/business/12-messaging-and-emergencies.md](../../product/business/12-messaging-and-emergencies.md)
|
||||||
|
**Integration:** [docs/integration/domains/tickets.md](../integration/domains/tickets.md)
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
Tickets are the **only** sanctioned post-booking channel — there is no live chat and no direct
|
||||||
|
nurse↔customer messaging, because every conversation must be admin-readable. A booking-coordination
|
||||||
|
thread is auto-created when a booking is paid for; users also open support threads themselves, and
|
||||||
|
refunds hang off a ticket. Staff read the same threads plus **internal notes** users can never see.
|
||||||
|
|
||||||
|
## Screens
|
||||||
|
|
||||||
|
| Step | Route | Component / notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Customer inbox | `/fa/support/tickets` | [`TicketInboxScreen`](../../client/src/components/messaging/TicketInboxScreen.tsx) `role="customer"`; its header CTA (`:63-70`) opens `ContactSupportDialog` with `defaultCategory="support"` |
|
||||||
|
| Customer thread | `/fa/support/tickets/[id]` | [`TicketThreadScreen`](../../client/src/components/messaging/TicketThreadScreen.tsx) — reference code + category + status header, bubble stream, sticky composer. **No internal-note affordance anywhere** |
|
||||||
|
| Nurse inbox / thread | `/fa/nurse/support/tickets`, `/…/[id]` | The **same two components**, `role="nurse"`, in the nurse shell |
|
||||||
|
| Open from a booking | `/fa/bookings/[id]`, `/fa/nurse/visits/[id]` | [`BookingSupportEntry`](../../client/src/components/messaging/BookingSupportEntry.tsx) — also renders the nurse's emergency banner (`tel:` + playbook) |
|
||||||
|
| Open from cancel | `/fa/bookings/[id]/cancel` | `ContactSupportDialog` (`cancel/page.tsx:219`) |
|
||||||
|
| Admin hub | `/fa/admin/support` | Group root, no data reads |
|
||||||
|
| Admin queue | `/fa/admin/tickets` | `useAdminTickets` + `useAdminListState` (draft-vs-applied filters, URL-synced) |
|
||||||
|
| Admin thread | `/fa/admin/tickets/[id]` | Full conversation **including internal notes**; composer toggles `isInternal` and goes amber; hosts `RefundPanel`; close/reopen/assign hidden behind `TICKET_LIFECYCLE_ENABLED` |
|
||||||
|
|
||||||
|
Thread polling: `TICKET_THREAD_REFETCH_INTERVAL = 15 s` while the screen is mounted
|
||||||
|
([`constants.ts:34`](../../client/src/services/tickets/constants.ts)); no background refetch.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
Shapes belong to [docs/integration/domains/tickets.md](../integration/domains/tickets.md) — not repeated here.
|
||||||
|
|
||||||
|
| Call | Endpoint | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Inbox | `GET /api/v1/tickets` | ✅ live. `clientApi.ts:181`. Participant-scoped at the query layer (`TicketRepository.ListMyTicketsAsync:114-118`) |
|
||||||
|
| Thread (user) | `GET /api/v1/tickets/{id}` | ✅ live. **Stamps `last_read_at`** (`GetTicketThreadQuery.Handler.cs:48-56`) — opening the thread is what clears the badge |
|
||||||
|
| Open | `POST /api/v1/tickets` | ✅ live. Only caller is `ContactSupportDialog` via `useOpenTicket` |
|
||||||
|
| Post message | `POST /api/v1/tickets/{id}/messages` | ✅ live, both user and staff composers (`clientApi.ts:212,253`) |
|
||||||
|
| Admin queue | `GET /api/v1/admin/tickets` | ⛔ **403 for every seeded admin** — see gaps |
|
||||||
|
| Admin thread | `GET /api/v1/admin/tickets/{id}` | ⛔ **403** (probed live) |
|
||||||
|
| Close / reopen | `POST /tickets/{id}/close`, `/reopen` | Server-live, **client-unreachable** (`TICKET_LIFECYCLE_ENABLED = false`, `constants.ts:52`) |
|
||||||
|
| Assign | `POST /tickets/{id}/assign` | ❌ **phantom** — no server route (REQ-063) |
|
||||||
|
| Emergency | `POST /api/v1/tickets/emergency` | ❌ **unwired** — zero client callers; `EmergencyBanner` only dials `tel:` |
|
||||||
|
| Participants | `POST`/`DELETE /tickets/{id}/participants…` | ❌ unwired — no UI |
|
||||||
|
| Unread total | *(none)* | `getUnreadTotal` is literally `async () => null` (`clientApi.ts:223`, REQ-059) |
|
||||||
|
|
||||||
|
Coordination tickets are auto-created server-side, not by the client — `ConfirmPaymentAndPostLedger…Handler.cs:94`
|
||||||
|
and `SettleBnplOrderCommand.Handler.cs:128` both send `AutoCreateCoordinationTicketCommand`.
|
||||||
|
|
||||||
|
## Rules that must hold
|
||||||
|
|
||||||
|
| Rule | Where it lives | Verified |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **INV-5 — `is_internal` is a query-layer boundary.** A non-staff read never *receives* an internal row | `TicketRepository.GetMessagesAsync:99-101` (`if (!includeInternal) query.Where(m => !m.IsInternal)`); `includeInternal = request.AsAdmin && isStaff` (`GetTicketThreadQuery.Handler.cs:41`) | ✅ live — see below |
|
||||||
|
| …and a non-staff caller can never **set** one | `PostMessageCommand.Handler.cs` → `403 "Only staff can post an internal note."` | ✅ live `403` |
|
||||||
|
| …and `isInternal` is **absent from every user-facing client type** | `TicketMessage`/`TicketDetail` (`types.ts:82-106`) carry no such field; only `AdminTicketMessage:164` and `PostAdminMessageRequest:205` do | ✅ read |
|
||||||
|
| …UI filtering is defence-in-depth only, never the gate | `clientApi.ts:89` re-drops any flagged row and the comment names it a backend defect to file | ✅ read |
|
||||||
|
| Ticket-only, admin-readable communication; no live chat; the emergency playbook **never dials and never exposes a number** | [business/12 §(a)](../../product/business/12-messaging-and-emergencies.md) (INV-20) | partial (see gaps) |
|
||||||
|
| Ticket **bodies are encrypted at rest** (refinement-phase-9) | `TicketMessageConfig.cs:19-21`; `TicketMessageEncryptionTests.cs` | ✅ Persian bodies round-tripped correctly on every probe |
|
||||||
|
| `referenceCode` is the stable, unique human id (`TKT-…`) | `TicketReferenceCode.cs`; UNIQUE-indexed | ✅ |
|
||||||
|
| Body ≤ **4000** chars; subject ≤ **300** | `OpenTicketCommand.Validator.cs:15-16`, `PostMessageCommand.Validator.cs:10` | ✅ read |
|
||||||
|
| `clientMessageId` is optimistic-send idempotency — a retry returns the **original** message | `PostMessageCommand.Handler.cs`; UNIQUE `(TicketId, ClientMessageId)` | ✅ live |
|
||||||
|
|
||||||
|
**Live proof of the boundary (2026-08-02).** Seeded ticket `TKT-DEMOSUP1` (id 12) holds four messages,
|
||||||
|
one of them an admin internal note («یادداشت داخلی: …», `DemoLifecycleSeeder.Social.cs:197-203`).
|
||||||
|
`GET /tickets/12` as `09120000010` returned message ids **7, 9, 10** — id **8 is missing from the
|
||||||
|
sequence**, and that gap is the internal note. No `isInternal: true` row reached the wire.
|
||||||
|
|
||||||
|
**The one cross-side enum mismatch** the integration doc records: **`TicketAuthorRole`**. The server's
|
||||||
|
`TicketParticipantRole` (`TicketCodes.cs`) defines `customer` · `nurse` · `admin`; the client
|
||||||
|
(`types.ts:37`) adds a fourth member **`system`** that the server never emits. Widening on the reading
|
||||||
|
side is safe, but a reader of the client types would wrongly conclude `system` is a wire value.
|
||||||
|
|
||||||
|
## How to test
|
||||||
|
|
||||||
|
1. Log in as `09120000010` (سارا محمدی, customer) — see [testing-setup.md](testing-setup.md).
|
||||||
|
2. Go to `/fa/support/tickets` (linked from the `/fa/profile` account hub).
|
||||||
|
**Expect:** **9 tickets** — `TKT-DEMOSUP1` (support, open, 1 unread), `TKT-DEMORF01`/`TKT-DEMORF03`
|
||||||
|
(refund), and six `coordination` rows for bookings 1, 2, 3, 4, 7, 8.
|
||||||
|
3. Open `TKT-DEMOSUP1`.
|
||||||
|
**Expect:** exactly **three** bubbles (customer → support → customer, ending «ممنون، حل شد. 🌸»).
|
||||||
|
**The internal note must not appear.** Its unread badge clears on return to the inbox (the GET stamped
|
||||||
|
`last_read_at`). Persian text renders correctly = decryption works.
|
||||||
|
4. Type a reply and send. **Expect:** the bubble appears instantly as `sending`, then settles to `sent`.
|
||||||
|
Send the sentinel `/fail` only under the mock — on the real path, kill the API to see the bubble go
|
||||||
|
`failed` **with your text preserved** and a retry-in-place control (`usePostMessage.ts:60-69`).
|
||||||
|
5. Log in as `09120000001` (زهرا عزیزی, nurse) → `/fa/nurse/support/tickets` (linked from `/fa/nurse/more`).
|
||||||
|
**Expect:** **7 tickets** — the same six coordination threads plus `TKT-DEMOEMG1` (emergency).
|
||||||
|
Same components, nurse chrome.
|
||||||
|
6. Open any `coordination` ticket. **Expect:** an **empty** thread — `lastMessageAt` is `null` on all six;
|
||||||
|
the auto-created ticket carries no opening message.
|
||||||
|
7. Admin half: `/fa/admin/tickets` as `09120000020`. **Expect: it fails.** Every `/api/v1/admin/tickets*`
|
||||||
|
call returns `403` for the seeded admins. Unlike the other admin consoles this domain is **not**
|
||||||
|
mock-covered (`USE_TICKETS_MOCK = false`), so the queue renders its error state, not fake data.
|
||||||
|
**Workaround:** none in the browser. Read a thread out of band as staff —
|
||||||
|
`GET /api/v1/tickets/12` with the super_admin token returns **`200`** (staff bypass the participation
|
||||||
|
check, `GetTicketThreadQuery.Handler.cs:36-38`), still with internal notes stripped.
|
||||||
|
|
||||||
|
The seeded world supports this flow fully on the user side; unlike the booking flows, **nothing here has
|
||||||
|
aged out** (tickets carry no deadline).
|
||||||
|
|
||||||
|
## Known gaps
|
||||||
|
|
||||||
|
- `GET /api/v1/admin/tickets` and `/admin/tickets/{id}` return **403 for `09120000020` and `09120000021`** (`DynamicPermission` grants only on the literal role `admin`). The whole staff console — queue, internal notes, `RefundPanel` — is untestable end to end.
|
||||||
|
- The admin ticket console is the **only admin console with no mock fallback** (`USE_TICKETS_MOCK = false`), so the 403 surfaces as a broken screen rather than being hidden.
|
||||||
|
- Staff authorization is inconsistent by layer: `StaffRoles.All` **includes** `super_admin`/`finance`, so the handlers would happily serve them — the controller attribute is what blocks. Two different definitions of "staff" in one request path.
|
||||||
|
- `getUnreadTotal` returns a hardcoded `null` (`tickets/apis/clientApi.ts:223`) — the chrome support badge can never light up (REQ-059).
|
||||||
|
- `mapSummary` hardcodes `lastMessagePreview: null` and `lastAuthorRole: null` (`clientApi.ts:77-78`) — the inbox card shows no message preview.
|
||||||
|
- `POST /api/v1/tickets/emergency` has **zero client callers**. The nurse's emergency banner dials `tel:` but the "then opens a ticket" half of the business/12 playbook is manual.
|
||||||
|
- `POST`/`DELETE /tickets/{id}/participants…` are unwired — no UI can attach a third party to a thread.
|
||||||
|
- `close` / `reopen` are live server-side but unreachable: `TICKET_LIFECYCLE_ENABLED = false` (`constants.ts:52`). `tickets/constants.ts` still justifies the gate with "the backend has no close/reopen/assign routes yet" — only `assign` is actually missing.
|
||||||
|
- `POST /tickets/{id}/assign` is a **phantom** — `clientApi.ts:273` targets a route that does not exist (REQ-063).
|
||||||
|
- Client `PostMessageResult` (`types.ts:141-145`) omits `clientMessageId`, which the server **does** echo (probed). The doc block at `types.ts:130-133` claims "the server has no field for it today… the real client does not send it" — `clientApi.ts:218` sends it. Stale comment inside the shipping domain.
|
||||||
|
- Client `TicketAuthorRole` declares `system`; the server's `TicketParticipantRole` does not. One side must move.
|
||||||
|
- Auto-created coordination tickets are opened with **no first message**, so both actors see an empty thread with nothing explaining what it is for.
|
||||||
|
- `TicketDetail.messages` is unpaginated by contract — a long thread has no incremental read.
|
||||||
|
- Attachments are designed but gated off (`TICKETS_ATTACHMENTS_ENABLED = false`, REQ-060).
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# Flow — notifications
|
||||||
|
|
||||||
|
> Last verified: 2026-08-02 against commit `c841bde`
|
||||||
|
|
||||||
|
**Actor(s):** customer · nurse (admin/partner have no bell) · **Status:** partial
|
||||||
|
**Client:** real · **Server:** real
|
||||||
|
**Business source:** [product/business/14-notifications-and-admin.md](../../product/business/14-notifications-and-admin.md)
|
||||||
|
**Integration:** [docs/integration/domains/notifications.md](../integration/domains/notifications.md)
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
Every state change another domain makes — a request arrives, a payment captures, a refund settles, a ticket
|
||||||
|
gets a reply — leaves a record the user can find later. A bell in the header carries the unread count; tapping
|
||||||
|
it opens a day-grouped feed; tapping a row marks it read and, **when the type is one the client recognises**,
|
||||||
|
jumps to the thing it is about. In-app only: there is no push, SMS or email channel, and nothing is dispatched
|
||||||
|
client-side — every notification is server-raised.
|
||||||
|
|
||||||
|
## Screens
|
||||||
|
|
||||||
|
| Step | Route | Component / notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Bell (customer) | any `(customer)` route | [`NotificationBell role="customer"`](../../client/src/components/notifications/NotificationBell.tsx) mounted in [`CustomerLayout.tsx:34`](../../client/src/layout/CustomerLayout.tsx) — badge from the polled count; **always navigates**, never opens a popover |
|
||||||
|
| Bell (nurse) | any `/nurse/*` route | same, [`NurseLayout.tsx:74`](../../client/src/layout/NurseLayout.tsx) |
|
||||||
|
| Feed (customer) | `/fa/notifications` | [`NotificationCenter role="customer"`](../../client/src/components/notifications/NotificationCenter.tsx) — day groups «امروز» / «دیروز» / «این هفته» then Shamsi dates, «نمایش بیشتر» grows page 1, «علامتگذاری همه…» bulk-clears |
|
||||||
|
| Feed (nurse) | `/fa/nurse/notifications` | same component, `role="nurse"` |
|
||||||
|
| Feed (admin) | `/fa/admin/notifications` | **`PlaceholderScreen` stub** — icon + generic body, no data ([`page.tsx`](../../client/src/app/[locale]/(private-routes)/admin/notifications/page.tsx)). Unreachable: `AdminLayout` renders no bell and nothing links to it |
|
||||||
|
| Row | — | [`NotificationRow.tsx`](../../client/src/components/notifications/NotificationRow.tsx) — navigable rows are a `ButtonBase` with a chevron; `kind: 'none'` rows render as a static surface (no ripple, no chevron) but still mark read |
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| Call | Endpoint | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| list | `GET /notifications/get_notifications` | unread-first then newest-first ([`NotificationService.cs:24`](../../server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Notifications/NotificationService.cs)); client grows `pageSize` instead of paging ([`useNotifications.ts`](../../client/src/services/notifications/hooks/useNotifications.ts)) |
|
||||||
|
| badge | `GET /notifications/get_unread_count` | own cheap query; polled every 60 s, stale 45 s ([`constants.ts:25-27`](../../client/src/services/notifications/constants.ts)), gated on auth |
|
||||||
|
| mark one | `POST /notifications/mark_notification_read` | optimistic — flips the row and decrements the badge, rolls back on error ([`useMarkNotificationRead.ts`](../../client/src/services/notifications/hooks/useMarkNotificationRead.ts)) |
|
||||||
|
| mark all | `POST /notifications/mark_all_read` | optimistic, zeroes the badge; bulk `ExecuteUpdate` server-side |
|
||||||
|
|
||||||
|
Chain traced end to end: [`NotificationCenter.tsx`](../../client/src/components/notifications/NotificationCenter.tsx)
|
||||||
|
→ hooks → [`apis/index.ts`](../../client/src/services/notifications/apis/index.ts) (`USE_NOTIFICATIONS_MOCK = false`)
|
||||||
|
→ [`clientApi.ts:45-73`](../../client/src/services/notifications/apis/clientApi.ts)
|
||||||
|
→ [`NotificationsController.cs`](../../server/src/API/Baya.Web.Api/Controllers/V1/NotificationsController.cs)
|
||||||
|
→ `ListMyNotifications` / `GetUnreadCount` / `MarkNotificationRead` / `MarkAllRead` handlers. No phantom, no gap.
|
||||||
|
Shapes live in the [integration doc](../integration/domains/notifications.md).
|
||||||
|
|
||||||
|
## The type vocabulary — probed live
|
||||||
|
|
||||||
|
The server emits **14** type codes. `parse.ts` recognises **3 of them**; the other 11 fall to `{ kind: 'none' }`
|
||||||
|
and render as untappable rows.
|
||||||
|
|
||||||
|
| Server `type` | Raised by | To | `dataJson` | Client verdict |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `booking_confirmed` | `ConfirmPaymentAndPostLedger.Handler.cs:109`, `SettleBnplOrder.Handler.cs:142` | customer | `booking_id` | ✅ → `/bookings/{id}` |
|
||||||
|
| `refund_completed` | `ConfirmRefundSettlement.Handler.cs:70` | customer | `booking_id`, `refund_id` | ✅ → `/bookings/{id}/refund` |
|
||||||
|
| `ticket_message` | `PostMessage.Handler.cs:73` | ticket participants | `ticketId` | ✅ → the thread |
|
||||||
|
| `booking_confirmed_nurse` | `ConfirmPaymentAndPostLedger.Handler.cs:117` | **nurse** | `booking_id` | ❌ unknown type — the nurse's own "you have a paid booking" is dead |
|
||||||
|
| `booking_request_received` | `CreateBookingRequest.Handler.cs:97` | nurse | `booking_request_id` | ❌ unknown type **and** unknown key |
|
||||||
|
| `booking_request_accepted` | `AcceptBookingRequest.Handler.cs:57` | customer | `booking_request_id`, `payment_deadline_at` | ❌ — the pay-now prompt does not link to checkout |
|
||||||
|
| `booking_request_rejected` | `RejectBookingRequest.Handler.cs:45` | customer | `booking_request_id` | ❌ |
|
||||||
|
| `booking_request_expired_no_response` | `ExpireBookingRequests.Handler.cs:26` | customer | `booking_request_id` | ❌ |
|
||||||
|
| `booking_request_payment_window_expired` | `ExpireBookingRequests.Handler.cs:35` | customer | `booking_request_id` | ❌ |
|
||||||
|
| `evv_location_mismatch` | `CheckInVisit.Handler.cs:105` | customer | `booking_id`, `session_id` | ❌ type unknown (the id would have resolved) |
|
||||||
|
| `evv_no_show` | `DetectNoShowSessions.Handler.cs:67` | customer | `booking_id`, `session_id` | ❌ same |
|
||||||
|
| `refund_issued` | `CreateRefund.Handler.cs:256` | customer | `booking_id`, `refund_id` | ❌ client knows `refund_processed`, not `refund_issued` |
|
||||||
|
| `review_moderated` | `ModerateReview.Handler.cs:54` | customer | `reviewId` | ❌ client knows `review_published`, and keys off `nurse_profile_id` |
|
||||||
|
| `verification_expiry_prompt` | `ScanExpiringCredentials.Handler.cs:81` | nurse | *(none)* | ❌ — no payload at all, so nothing to link to |
|
||||||
|
|
||||||
|
Ten codes go the other way: `parse.ts:21-43` handles `booking_reminder`, `session_reminder`,
|
||||||
|
`payment_captured`, `booking_cancelled`, `refund_processed`, `payout_paid`, `payout_failed`, `ticket_opened`,
|
||||||
|
`review_published`, `ticket_closed` — **no server code emits any of them.** Consequently the `payout` and
|
||||||
|
`nurse_profile` branches of [`deepLink.ts`](../../client/src/services/notifications/deepLink.ts) are
|
||||||
|
unreachable, and a nurse never gets a payout notification at all.
|
||||||
|
|
||||||
|
## Rules that must hold
|
||||||
|
|
||||||
|
| Rule | Value | Source |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| In-app only; no push at launch | `NotificationChannel.Sms`/`Push` exist in the enum, nothing implements them | [business/14 §MVP](../../product/business/14-notifications-and-admin.md) · `INotificationDispatcher.cs` |
|
||||||
|
| Read notifications hard-deleted after **90 days**; unread never deleted | `RetentionDays = 90`, swept every 24 h — a **hardcoded constant, not a `platform_configs` row** | `NotificationRetentionJob.cs`; business/14 §(a) |
|
||||||
|
| A notification never exists for a rolled-back transaction | the dispatcher self-commits its own row and is called **after** the handler's `CommitAsync` | `InAppNotificationDispatcher.cs`; e.g. `ExpireBookingRequests.Handler.cs:76-85` |
|
||||||
|
| Every read is scoped to the caller | `userId` from `ICurrentUser`; no by-id fetch exists | `NotificationService.cs:24,38` |
|
||||||
|
| Unread count is its own query, never derived from a page | badge must be right without a list fetch | integration doc |
|
||||||
|
| Adding a type is safe; changing a type's `dataJson` is not | the client keys deep-links off the payload and degrades to non-tappable | integration doc |
|
||||||
|
|
||||||
|
## How to test
|
||||||
|
|
||||||
|
1. Log in as `09120000010` (سارا محمدی, customer) — see [testing-setup.md](testing-setup.md).
|
||||||
|
2. Look at the header bell. **Expect** a numeric badge. Live at this stamp: `GET /notifications/get_unread_count`
|
||||||
|
→ `{"count":10}` (the seeded 6 plus requests other testers raised today, so treat the number as ≥ 6, not
|
||||||
|
exactly 6).
|
||||||
|
3. Tap the bell → `/fa/notifications`. **Expect** `total: 12`, unread-first, grouped under «امروز» / «دیروز» /
|
||||||
|
«این هفته» / Shamsi day headers, each unread row with a dot and a tinted icon.
|
||||||
|
4. Find the top row — `booking_confirmed`, `{"booking_id":10}`. **Expect** a chevron; tapping it navigates to
|
||||||
|
`/fa/bookings/10` and the badge drops by one immediately (optimistic).
|
||||||
|
5. Find any `booking_request_accepted` / `evv_no_show` row. **Expect the opposite**: neutral grey icon, no
|
||||||
|
chevron, tap does nothing but mark read. That is the type-vocabulary gap, working as coded.
|
||||||
|
6. Tap «علامتگذاری همه بهعنوان خواندهشده». **Expect** the badge to hit 0 instantly and stay 0 after a
|
||||||
|
refresh. **This is destructive to the shared demo DB** — the unread count does not come back. Skip it
|
||||||
|
unless you own the world.
|
||||||
|
7. Nurse side: log in as `09120000001` and open `/fa/nurse/notifications`. **Expect** 9 rows, mostly
|
||||||
|
`booking_request_received` — **all of them untappable**, and the two `booking_confirmed_nurse` rows
|
||||||
|
untappable too.
|
||||||
|
8. Admin: `/fa/admin/notifications` renders a placeholder. There is no link to it and no bell in the admin
|
||||||
|
shell; reaching it means typing the URL.
|
||||||
|
|
||||||
|
Everything above except step 6 was executed live against `:5002` with pre-minted bearer tokens on 2026-08-02.
|
||||||
|
|
||||||
|
## Known gaps
|
||||||
|
|
||||||
|
- 11 of the 14 server notification types are unknown to `parse.ts`, so their rows are untappable; only
|
||||||
|
`booking_confirmed`, `refund_completed` and `ticket_message` deep-link.
|
||||||
|
- `booking_confirmed_nurse` carries a valid `booking_id` but no client branch — the nurse's most important
|
||||||
|
notification cannot be opened (`parse.ts:21-26`).
|
||||||
|
- All five `booking_request_*` types carry `booking_request_id`, a key `parse.ts` never reads and
|
||||||
|
`NotificationData` has no `kind` for — the whole request lifecycle is non-navigable, including the
|
||||||
|
"pay within the window" prompt.
|
||||||
|
- `refund_issued` vs the client's `refund_processed`, and `review_moderated` vs `review_published`: near-miss
|
||||||
|
names on both sides of the same contract.
|
||||||
|
- `verification_expiry_prompt` is dispatched with `DataJson = null` — even a matching client branch could not
|
||||||
|
route it (`ScanExpiringCredentials.Handler.cs:81`).
|
||||||
|
- The client handles 10 types no server code emits; the `payout` and `nurse_profile` deep-link classes in
|
||||||
|
`deepLink.ts` (and their icons/tints) are dead code. No payout notification is ever raised, so the nurse is
|
||||||
|
never told a payout paid or failed.
|
||||||
|
- **Every `title` and `body` is a server-side English literal** (`"Booking confirmed"`, `"The nurse declined
|
||||||
|
your request."`) rendered verbatim into the Persian RTL feed — `NotificationRow.tsx:62,67`, no i18n path.
|
||||||
|
Only the chrome (headers, buttons, empty state) is translated.
|
||||||
|
- `/fa/admin/notifications` is a `PlaceholderScreen`, is not linked from anywhere, and no admin/partner shell
|
||||||
|
mounts a bell — admins have no notification surface at all.
|
||||||
|
- `NotificationBellPopover.tsx` (4.4 KB, full mark-read/deep-link logic) is exported but mounted nowhere —
|
||||||
|
dead UI since the desktop branch was removed.
|
||||||
|
- The list is not real pagination: `useNotifications` grows `pageSize` on page 1, so "load more" refetches the
|
||||||
|
whole feed each time.
|
||||||
|
- Notification retention (90 d / 24 h) is a hardcoded constant, not a `platform_configs` row, contrary to the
|
||||||
|
repo's config-is-rows convention.
|
||||||
|
- The two notification `page.tsx` files are `'use client'` with no `generateMetadata`, departing from the
|
||||||
|
thin-RSC-page convention in [client/CLAUDE.md](../../client/CLAUDE.md).
|
||||||
|
- No unread-notification cap or archive: the feed grows until the 90-day sweep, and unread rows are never
|
||||||
|
swept.
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# Flow — nurse catalog & pricing
|
||||||
|
|
||||||
|
> Last verified: 2026-08-02 against commit `c841bde`
|
||||||
|
|
||||||
|
**Actor(s):** nurse (browse side: customer, anonymous) · **Status:** partial
|
||||||
|
**Client:** real · **Server:** real
|
||||||
|
**Business source:** [product/business/03-service-catalog-and-pricing.md](../../product/business/03-service-catalog-and-pricing.md)
|
||||||
|
**Integration:** [docs/integration/domains/catalog.md](../integration/domains/catalog.md)
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
A nurse turns the platform's service catalogue into her own price list. She picks a category, answers the
|
||||||
|
dimensions the catalogue defines for it, and puts a price on that exact configuration. The result — a
|
||||||
|
**variant** — is the atomic bookable unit: it is what a customer searches for, taps, and pays for. The
|
||||||
|
catalogue skeleton itself (categories → option groups → option values) is admin-owned reference data;
|
||||||
|
the nurse only composes on top of it.
|
||||||
|
|
||||||
|
## Screens
|
||||||
|
|
||||||
|
| Step | Route | Component / notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Offerings list | `/fa/nurse/services` | [`MyServicesList.tsx`](../../client/src/app/%5Blocale%5D/%28private-routes%29/nurse/services/MyServicesList.tsx) — `useMyVariants()`, per-row activate/deactivate via `useSetVariantActive`, `EmptyState` when none |
|
||||||
|
| Go-live gate | same page (top card) | [`PublishGate.tsx`](../../client/src/app/%5Blocale%5D/%28private-routes%29/nurse/services/PublishGate.tsx) — reads `useActivationChecklist`, fires the real `profiles` `set_accepting_bookings`; a listed variant is not a *visible* variant |
|
||||||
|
| Build / edit | same page, in-place | [`VariantBuilder.tsx`](../../client/src/app/%5Blocale%5D/%28private-routes%29/nurse/services/VariantBuilder.tsx) — 3-step stepper (category → options → price). No extra route; `page.tsx:18-28` swaps the body |
|
||||||
|
| Practice hub | `/fa/nurse/practice` | `NursePracticeScreen.tsx:25` reads `useMyVariants()` only for the "how many offerings" count |
|
||||||
|
| Public preview | `/fa/nurse/profile/preview` | `preview/page.tsx:47,68` — renders **only `isActive` variants** through `ServicePriceRow`, entirely from the nurse's own cache (never the search index) |
|
||||||
|
|
||||||
|
The builder's middle step is **derived, not fixed**: `VariantBuilder.tsx:140-144` drops the options step for a
|
||||||
|
category that provably has zero option groups, and blocks *Next* while a required group is unanswered
|
||||||
|
(`:134`), naming the missing dimension instead of erroring after the tap.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| Call | Endpoint | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Categories | `GET /api/v1/catalog/categories` | anonymous; `clientApi.ts:28-35` → `CatalogController.Categories` |
|
||||||
|
| Option groups | `GET /api/v1/catalog/option_groups?category_id=` | anonymous; `clientApi.ts:37-40` → `CatalogController.OptionGroups` (`category_id` is snake_case, the only one) |
|
||||||
|
| My offerings | `GET /api/v1/nurse_variants/list` | `[Authorize]`, self-scoped; `clientApi.ts:42-49` |
|
||||||
|
| One variant | `GET /api/v1/nurse_variants/get/{id}` | **`[AllowAnonymous]`** by design (`NurseVariantsController.cs:46`) — a public profile deep-links a variant |
|
||||||
|
| Create | `POST /api/v1/nurse_variants/create` | `CreateVariantCommand.Handler.cs` |
|
||||||
|
| Edit price | `POST /api/v1/nurse_variants/update/{id}` | option-set is immutable on update (`UpdateVariantCommand.Handler.cs:33-37`) |
|
||||||
|
| Retire / restore | `POST /api/v1/nurse_variants/set_active/{id}` | soft only — there is no delete |
|
||||||
|
|
||||||
|
Shapes live in [catalog.md](../integration/domains/catalog.md). The seven `admin_catalog/*` authoring routes
|
||||||
|
exist on the server and have **no client caller at all** (`grep -rn admin_catalog client/src` → 0 hits).
|
||||||
|
|
||||||
|
## Rules that must hold
|
||||||
|
|
||||||
|
| Rule | Where it is enforced |
|
||||||
|
| --- | --- |
|
||||||
|
| **The catalogue is EAV**: categories → option groups → option values. Option groups are **not** seeded with the migration — `CatalogSeed.cs:7` says so explicitly ("those are admin-authored data per category"). A group with `serviceCategoryId = null` is **cross-category**. | `CatalogSeed.cs`, `GetApplicableGroupsAsync` |
|
||||||
|
| **Duplicate guard = `option_set_hash`.** SHA-256 over the sorted `(groupId:valueId)` pairs → one comparable column, so "same set of choices" becomes expressible as `UNIQUE(NurseId, ServiceCategoryId, OptionSetHash)` filtered on `DeletedAt IS NULL`. Handler pre-checks and returns a clean `409`; the index is the race backstop. | `OptionSetHash.cs:16-25`, `NurseServiceVariantConfig.cs:28-31`, `CreateVariantCommand.Handler.cs:65-69` |
|
||||||
|
| **Every required dimension must be answered, exactly once**, and every value must belong to an applicable group. | `CreateVariantCommand.Handler.cs:40-62` |
|
||||||
|
| **`PriceUnit` is a closed set of 5** (`per_hour` `per_session` `per_half_day` `per_day` `per_24h`) and is a **label, never a multiplier** — the client must not derive a total from it. | `Domain/Entities/Catalog/PriceUnits.cs`; §2b of the business-rule map |
|
||||||
|
| **Money is IRR integer.** The wire carries a digit string; the DB column is `bigint`. The **only** Toman↔Rial boundary is the price field: `tomanToRial` on submit, `rialToToman` to pre-fill an edit. | `utils/money.ts:38` (`tomanToRial`, `×10` via `BigInt`), used at `VariantBuilder.tsx:113,183`; `NurseServiceVariantConfig.cs:13` |
|
||||||
|
| **The variant snapshot freezes a variant onto a booking** so a later edit or deactivation never mutates a past booking, dispute or invoice. | `IVariantSnapshotSerializer`, `BookingFactory.cs:53` → `Bookings.VariantSnapshotJson` (`Booking.cs:41`) |
|
||||||
|
| **Search visibility is a separate gate.** Every create/update/set_active reindexes in the same unit of work; a row is searchable only when `is_verified AND is_accepting_bookings AND status != suspended AND variant.is_active` (INV-17). Deactivated rows stay with `is_searchable = 0`, never deleted. | `CreateVariantCommand.Handler.cs:99`, `UpdateVariant…:40`, `SetVariantActive…:34` |
|
||||||
|
| **Tenancy is 404, not 403** — another nurse's variant id resolves to "not found". | `UpdateVariantCommand.Handler.cs:28-31` |
|
||||||
|
|
||||||
|
## How to test
|
||||||
|
|
||||||
|
1. Log in as `09120000001` (nurse زهرا عزیزی, verified) — see [testing-setup.md](testing-setup.md).
|
||||||
|
2. Open `/fa/nurse/practice`. **Expect:** the services row shows a non-zero offerings count.
|
||||||
|
3. Open `/fa/nurse/services`. **Expect:** a `PublishGate` card at the top and **5** offering cards.
|
||||||
|
The task brief and the seeder say *3* — that is stale: live `nurse_variants/list` returned
|
||||||
|
`total: 5` (ids 1, 2, 3 seeded + 7, 8 created by earlier manual testing on the real path). Their
|
||||||
|
existence is itself proof the create path works end to end.
|
||||||
|
4. Tap *add*, choose «مراقبت از سالمند», then *Next*. **Expect:** a middle step titled with the
|
||||||
|
required dimension «نوع شیفت» offering روزانه / شبانه / شبانه روزی. It is required, so *Next* stays
|
||||||
|
disabled until one is picked.
|
||||||
|
5. Pick **شبانه**, price `2000000` Toman, unit «نیمروز», duration `1`, submit. **Expect:** an inline
|
||||||
|
duplicate warning (not a toast) offering «ویرایش همان مورد» — this collides with variant `8`
|
||||||
|
(`cat 1 / Night / 20000000 IRR / per_half_day`). Server returns exactly
|
||||||
|
`409 · "You already offer this exact configuration in this category."`;
|
||||||
|
`VariantBuilder.tsx:258-260` maps it to the inline state.
|
||||||
|
6. Go back, pick **روزانه** instead, price `300000` Toman, unit «ساعتی». **Expect:** success; the list
|
||||||
|
now shows the new card, display name auto-built as «مراقبت از سالمند · روزانه»
|
||||||
|
(`CreateVariantCommand.Handler.cs:131-134`).
|
||||||
|
7. Deactivate that card. **Expect:** it stays in the list marked inactive, and disappears from
|
||||||
|
`/fa/nurse/profile/preview` (which filters `isActive`, `preview/page.tsx:68`).
|
||||||
|
|
||||||
|
Live probes run for this stamp. Anonymous: `GET /catalog/categories` → **5** active categories
|
||||||
|
(ids 1–5, `sortOrder = id`); `GET /catalog/option_groups?category_id=1` → **1** group (`id 1`,
|
||||||
|
`serviceCategoryId: null`, `isRequired: true`, 3 values); `GET /nurse_variants/get/{id}` → `200`
|
||||||
|
without a token. Nurse-token: nurse 1 `total: 5`, nurse 2 `total: 2`.
|
||||||
|
|
||||||
|
All three write-path guards were exercised live against the running API and **CONFIRMED**:
|
||||||
|
|
||||||
|
| Probe | Result |
|
||||||
|
| --- | --- |
|
||||||
|
| `create` with nurse 1's existing `cat 1 / valueId 2` set | `409 · "You already offer this exact configuration in this category."` — and no row was added (`total` stayed 5) |
|
||||||
|
| `create` on `cat 3` with `options: []` | `400 · {"Options":["Required dimension(s) not answered: نوع شیفت."]}` — the cross-category group is enforced on a category that never declared it |
|
||||||
|
| `create` with a **customer** token (09120000010) | `403 · "Only a nurse can create a variant."` — the role check is in the handler, not just the attribute |
|
||||||
|
|
||||||
|
The 409 fires from the handler pre-check (`:68-69`), ahead of the index; the filtered UNIQUE index is
|
||||||
|
the race backstop, not the message source.
|
||||||
|
|
||||||
|
## Known gaps
|
||||||
|
|
||||||
|
- **No catalogue-authoring UI exists.** All seven `admin_catalog/*` routes (create/update category,
|
||||||
|
option group, option value) have zero client callers. An admin cannot add a dimension without SQL.
|
||||||
|
- **Even with a UI, no seeded admin could use it.** `admin_catalog` is `[Authorize(DynamicPermission)]`,
|
||||||
|
which `super_admin`/`finance` fail — see [testing-setup.md](testing-setup.md#-the-seeded-admins-cannot-reach-any-admin-endpoint).
|
||||||
|
- **A production database has zero option groups.** `CatalogSeed.cs` seeds categories only. The single
|
||||||
|
«نوع شیفت» group exists because `DemoWorldSeeder.EnsureShiftTypeGroupAsync` runs **in Development only**
|
||||||
|
(`DemoWorldDefinitions.cs:36-40` says so). Deployed, every builder collapses to two steps and every
|
||||||
|
variant in a category is a duplicate of every other — one price per nurse per category, forever.
|
||||||
|
- **ZWNJ is stripped from every stored Persian string.** `StringExtensions.FixPersianChars` line 90
|
||||||
|
(`.Replace("", " ")`), applied to every string property of every entity at
|
||||||
|
`ApplicationDbContext.cs:79`. Live proof: the seeder writes «شبانهروزی» and the API returns
|
||||||
|
«شبانه روزی» (`ش…ه ر…` — a literal U+0020). This contradicts the repo naming rule
|
||||||
|
("with a ZWNJ, always") and silently mangles nurse-typed display names. The API test at
|
||||||
|
`NurseVariantsApiTests.cs:50-51` documents the behaviour rather than fixing it.
|
||||||
|
- **A price edit is not shielded from an in-flight request.** `UpdateVariantCommand.Handler.cs:33` writes
|
||||||
|
the new price unconditionally, and `BookingRequestRepository.GetConversionSourceAsync:212-229` reads
|
||||||
|
`r.Variant.Price` **live** at conversion. The snapshot freezes at booking creation (post-payment), not
|
||||||
|
at request creation — so a nurse editing price inside the 30-min payment window changes what the
|
||||||
|
customer pays. Code-traced, not exercised live.
|
||||||
|
- **`IVariantSnapshotSerializer`'s doc-comment names the wrong table** — it says the JSON is frozen onto
|
||||||
|
a `booking_requests` row; the only field is `Bookings.VariantSnapshotJson` (`Booking.cs:41`).
|
||||||
|
- **Category `iconKey` and both description fields are `null` for all 5 categories** (live probe), so the
|
||||||
|
Home A5 grid and the builder's category tiles fall back to a generic icon and show no explainer copy.
|
||||||
|
- **The «همراهی و مراقبت روزمره» (Companionship) category is data-only** — `CatalogSeed.cs:11` notes it
|
||||||
|
"ships only as a seeded category, not a pricing path", but the builder offers it like any other.
|
||||||
|
- **`sessionCount` is free-typed and unvalidated against `priceUnit`** — nothing stops
|
||||||
|
`per_24h` + `sessionCount: 5`; seeded variant `7` is `per_session` + `5`, variant `8` is
|
||||||
|
`per_half_day` + `1`, both legal.
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# Flow — Nurse earnings and payouts
|
||||||
|
|
||||||
|
> Last verified: 2026-08-02 against commit `c841bde`
|
||||||
|
|
||||||
|
**Actor(s):** nurse (read-only) · admin/finance (runs the batch) · the weekly scheduler · **Status:** mocked
|
||||||
|
**Client:** mock · **Server:** partial (all 4 nurse reads verified live; every admin op 403s)
|
||||||
|
**Business source:** [product/business/10-payouts.md](../../product/business/10-payouts.md)
|
||||||
|
**Integration:** [docs/integration/domains/payouts.md](../integration/domains/payouts.md)
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
A nurse wants one answer: *how much am I owed, and when does it land?* Money accrues per completed booking,
|
||||||
|
becomes eligible once the 72-hour dispute window closes, and is swept weekly into a payout batch against the
|
||||||
|
nurse's verified primary IBAN. Admin/finance opens the batch and — as the one irreversible, human-approved
|
||||||
|
step on the platform — submits it to the bank rail.
|
||||||
|
|
||||||
|
**This is the sharpest "real server, mocked client" case in the product.** All four nurse endpoints are live
|
||||||
|
and return real data (probed below); `USE_PAYOUTS_MOCK = true` (`client/src/services/payouts/constants.ts:13`)
|
||||||
|
means all six routed screens plus the nurse-dashboard widget render module-level fixtures instead. This confirms
|
||||||
|
hardening issue [H-09](../../dev/post-phase/hardening/issues.md). **"Live" is not "correct":** two of the four
|
||||||
|
reads are substantively wrong even at 200 — a booking with an unpaid payout is reported `paid`, and the headline
|
||||||
|
net does not reconcile with the four buckets (see gaps). Flipping the flag exposes both.
|
||||||
|
|
||||||
|
## Screens
|
||||||
|
|
||||||
|
| Step | Route | Component / notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | `/fa/nurse/finance` | Group root. The single headline number: signed net payable, never clamped (`NurseFinanceScreen.tsx:66-68,76` — `parseIrr` → `BigInt`, then `isOwed ? -net : net` for magnitude + an `error` tone. **BigInt negation, not `Math.abs`** — IRR never touches a float) |
|
||||||
|
| 2 | `/fa/nurse/earnings` | `EarningsBalanceHeader` four buckets + weekly/dispute explainer + state-segmented list (`all`/`pending`/`eligible`/`paid`/`clawback_applied`), `PAYOUTS_PAGE_SIZE = 10` |
|
||||||
|
| 3 | `/fa/nurse/earnings/payouts` | `PayoutHistoryRow` list, newest first. No retry control — deliberate; retry is an admin action |
|
||||||
|
| 4 | `/fa/nurse/earnings/payouts/[id]` | Reconciliation detail: `gross − clawback = net`, amount transferred, masked IBAN, transfer reference, `failureReasonLabelKey()`, and the bookings covered |
|
||||||
|
| 5 | `/fa/admin/payouts` | Batch list + a Jalali period picker → preview dialog → confirm "run". **The confirm calls `POST admin_payouts/batches` (generate), not process** |
|
||||||
|
| 6 | `/fa/admin/payouts/[batchId]` | Paginated payout rows, retry a `failed` payout, record a transfer reference |
|
||||||
|
| — | (none) | The nurse dashboard `/fa/nurse` also reads the balance (`NurseDashboardScreen.tsx:220`) |
|
||||||
|
|
||||||
|
Admin screens are additionally hidden behind `canPayout` (`super_admin`/`admin`/`finance`) — a UI hint only.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
Shapes, enums and verdicts live in [payouts.md](../integration/domains/payouts.md) — not restated here.
|
||||||
|
|
||||||
|
| Call | Endpoint | Live probe (2026-08-02, tokens from `tokens.env`) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| balance | `GET /api/v1/nurse_payouts/earnings_balance` | **200** nurse 1 → `0 / 0 / 3187500 / 212500`, net **`8500000`**; nurse 3 → all zeros |
|
||||||
|
| earnings list | `GET /api/v1/nurse_payouts/earnings` | **200** nurse 1 → 3 items (bookings 3, 4, 8), states `paid`, `paid`, `clawback_applied` |
|
||||||
|
| history | `GET /api/v1/nurse_payouts/history` | **200** nurse 1 → 2 payouts; **`failureReason` IS on the wire** (null here) |
|
||||||
|
| payout detail | `GET /api/v1/nurse_payouts/{id}` | **200** payout 3 → `pending`, net `1487500`, batch 3 `draft`, `initiatedByAdminId: null` |
|
||||||
|
| tenancy | same, nurse 2 (`09120000002`) reading payout 1 | **404** "Payout not found." — correct (a 403 would confirm the row). A **non-nurse** caller instead gets **403** "Only a nurse can read their payout." (`GetNursePayoutDetailQuery.Handler.cs:22,26,30`) |
|
||||||
|
| eligible / batches / process / retry / mark_failed | `admin_payouts/*` | **403** "Authorization Error" for **both** `09120000020` (super_admin) and `09120000021` (finance) — see the RBAC gap below |
|
||||||
|
| ledger balance | `GET /api/v1/nurses/{id}/payable_balance` | **200** → `8500000`. Not consumed by the client; tenancy is enforced in the handler (`GetNursePayableBalanceQuery.Handler.cs:22-29`) |
|
||||||
|
| webhook | `POST /api/v1/webhooks/payouts/{provider}` | server-only reconciliation callback; not client-reachable |
|
||||||
|
|
||||||
|
Client chain, verified link by link: page → `services/payouts` barrel (`index.ts`) → `hooks/useNurseEarningsBalance.ts:12`
|
||||||
|
→ `apis/index.ts:10` (the one seam, `USE_PAYOUTS_MOCK ? mock : client`) → `apis/clientApi.ts:163` → `clientFetch`
|
||||||
|
→ `NursePayoutsController.cs:33` → `GetNurseEarningsBalanceQueryHandler`. Every link exists. **The seam selector
|
||||||
|
picks the mock**, so the real link is never traversed at runtime.
|
||||||
|
|
||||||
|
## Rules that must hold
|
||||||
|
|
||||||
|
| Rule | Value | Source |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Payout cadence | weekly, config `nurse_payout_interval_days = 7` | [business/10](../../product/business/10-payouts.md) §(d1) |
|
||||||
|
| Eligibility gate | `completed` **and** `dispute_window_ends_at < now` (72 h) **and** no active refund on the booking | [business/10](../../product/business/10-payouts.md) §(a) + §(d1) |
|
||||||
|
| One payout per booking | `UNIQUE(booking_id)` on `nurse_payout_booking_links` — the DB is the authority, not an `if` | [business/10](../../product/business/10-payouts.md) §(a), §(d) |
|
||||||
|
| **Two different "net"s — do not conflate** | a *payout's* `net_amount = gross − clawback_applied` is clamped **≥ 0**; the *nurse's* `netPayableBalanceIrr` is the ledger sum and is **SIGNED, never clamped** — showing 0 for a debt lies | clamped: §(d1) · signed: [business/10](../../product/business/10-payouts.md) §(a) ("derived from the ledger — it may go negative") |
|
||||||
|
| Clawback netting | **whole clawbacks only**, oldest-first, capped at the batch gross; a clawback bigger than the batch stays fully `pending` | [business/10](../../product/business/10-payouts.md) §(d1) |
|
||||||
|
| IBAN gate | verified **primary** IBAN gates *payment*, not accrual; a nurse without one is skipped with a recorded reason (`EligibleNurseEarningsDto.hasVerifiedPrimaryIban`, `GeneratePayoutBatchResult.skipped`) | [business/02](../../product/business/02-nurse-verification.md) step 6 |
|
||||||
|
| PAYA vs SATNA | net ≥ `1,000,000,000` IRR ⇒ SATNA, config `payout_satna_threshold_irr` | the **seed** (`PlatformConfigConfig.cs:53`); [business/10](../../product/business/10-payouts.md) §(d1) names the config but carries no number |
|
||||||
|
| Holiday shifting | period end + processing date shift server-side via `IHolidayCalendar`; **the client never computes one** | [business/10](../../product/business/10-payouts.md) §(a) |
|
||||||
|
| **Generation is automatic, processing is not** | `WeeklyPayoutGenerationJob` opens a `draft`; the irreversible `process` is always an explicit admin action | [server/CLAUDE.md](../../server/CLAUDE.md) hard rule 12 |
|
||||||
|
|
||||||
|
The scheduler half is **verified working in the live DB**: batch 3 was created `2026-07-29T02:58:32Z` with
|
||||||
|
`initiatedByAdminId: null` and status `draft` — a system-initiated batch, exactly as `WeeklyPayoutGenerationJob.cs:48`
|
||||||
|
sends `SystemInitiated = true` and `AdminPayoutsController.cs:47` forces it to `false` for API callers.
|
||||||
|
**Note the business doc is stale here:** §(d1) still says "the weekly cron trigger is DEFERRED — batches are
|
||||||
|
admin-triggered". Refinement phase 7 shipped it; the running server is the authority, not that line.
|
||||||
|
|
||||||
|
## How to test
|
||||||
|
|
||||||
|
1. Log in as **09120000001** (nurse 1, the seeded nurse with real payout rows) — see [testing-setup.md](testing-setup.md).
|
||||||
|
2. Open `/fa/nurse/finance`. **Expect (today):** the mock fixture balance, not `8,500,000` IRR. The masked IBAN
|
||||||
|
on the following screens reads `IR••••••••••••••••••4821` (`payouts/apis/mockApi.ts:51`) — the live one ends `9012`.
|
||||||
|
Seeing `4821` is the fastest way to prove you are looking at fixtures.
|
||||||
|
3. Open `/fa/nurse/earnings`, cycle the five tabs. **Expect (today):** four fixture rows for bookings 5001–5004.
|
||||||
|
Tapping "view booking" on one 404s — those ids do not exist in the DB.
|
||||||
|
4. Open `/fa/nurse/earnings/payouts` → a row → the detail. **Expect (today):** fixture reconciliation, including a
|
||||||
|
`failed` payout with a reason the real path would have discarded (see gaps).
|
||||||
|
5. **To see the truth instead**, curl the four nurse endpoints with nurse 1's bearer (§API above). **Expect:**
|
||||||
|
balance `0 / 0 / 3187500 / 212500`, net `8500000`; payout 3 `pending` in `draft` batch 3.
|
||||||
|
6. Admin half, as **09120000021** (finance): `/fa/admin/payouts`. **Expect:** the mock batch list renders fine, but
|
||||||
|
the same call against the API is **403**. Flipping the flag turns this console into a permission wall.
|
||||||
|
7. Set `MOCK_SCENARIO = 'clawback_heavy'` (`payouts/constants.ts:23`) and reload to exercise the negative-balance
|
||||||
|
("owed back") treatment. **Expect:** a red/`error`-toned magnitude on `/fa/nurse/finance`, never `0`.
|
||||||
|
|
||||||
|
**Seeded-world caveat:** the world is 7 days stale, so nothing is `pending` or `eligible` — nurse 1's buckets are
|
||||||
|
`0 / 0`. There is no way to observe a fresh accrual without re-seeding. Batch 3 stays `draft` forever because the
|
||||||
|
process step both 403s and has no UI.
|
||||||
|
|
||||||
|
## Known gaps
|
||||||
|
|
||||||
|
- `USE_PAYOUTS_MOCK = true` (`payouts/constants.ts:13`) suppresses four working nurse endpoints — H-09 confirmed, six screens + the dashboard widget show fixtures.
|
||||||
|
- The mock's booking ids `5001–5004` (`payouts/apis/mockApi.ts:54`) deep-link into the now-real bookings screens; "view booking" 404s.
|
||||||
|
- `toHistoryItem` hardcodes `failureReason: null` (`payouts/apis/clientApi.ts:60`) although the live wire carries it — on flip, a failed payout loses its reason in the nurse's history.
|
||||||
|
- `previewPayoutBatch` (`payouts/apis/clientApi.ts:205-227`) sums net amounts client-side and fabricates `processingDate = periodEnd`, `holidayShifted: false`, `skipped: []` — the client computing money and a payout date, against client hard rule 18.
|
||||||
|
- **No `process` operation exists anywhere in `PayoutsApi`.** The irreversible step has no UI on either path; `/fa/admin/payouts`'s confirm button calls `POST admin_payouts/batches` (generate).
|
||||||
|
- The mock's `runPayoutBatch` returns batch `status: 'processing'` with payouts `submitted` (`payouts/apis/mockApi.ts:637,653`) while the real generate returns `draft` — the demo shows money moving that the real path never moves, collapsing generate and process into one click.
|
||||||
|
- Every `admin_payouts/*` op returns **403** for the seeded `super_admin`/`finance` accounts (`DynamicPermissionService.CanAccess` grants on the literal role `admin`) — no one can process a batch today.
|
||||||
|
- Draft batch 3 (system-generated, 1 payout, **1,487,500 IRR** to nurse 1) is therefore unpayable, indefinitely.
|
||||||
|
- `DeriveEarningsState` (`PayoutRepository.cs:275-284`) returns `paid` for any un-clawed-back booking that merely **has a payout link**, regardless of that payout's status (the checks are `clawback → payout → dispute window`, and the payout branch never reads `Status`) — booking 3 renders as «پرداختشده» while `paidAt: null`, `transferReference: null` and payout 3 is `pending`. The nurse is told they were paid when no money moved.
|
||||||
|
- Consequence of the above: booking 3's `1,700,000` IRR falls into **no** bucket — not `pending`, not `eligible` (`GetNurseEarningsBalanceQuery.Handler.cs:33-39`), and not `paidTotal`, which sums only `Status == Paid` payouts (`PayoutRepository.cs:286-289`).
|
||||||
|
- The four buckets and the headline `netPayableBalanceIrr` come from different sources (booking projection vs. the `nurse_payable` ledger sum) and do **not** reconcile: net `8,500,000` against buckets of `0` pending / `0` eligible / `3,187,500` paid / `212,500` clawback outstanding. Which is authoritative is undocumented, and the unreconciled number is the one on `/fa/nurse/finance` and the nurse dashboard.
|
||||||
|
- `recordTransferReference` targets `admin_payouts/{id}/transfer_reference` (`payouts/apis/clientApi.ts:266`), which does not exist — the batch-detail reconcile field 404s on flip (REQ-036).
|
||||||
|
- The client sends `Idempotency-Key` on generate/retry (`payouts/apis/clientApi.ts:233,259`); `AdminPayoutsController` never reads it — decorative, and misleading to a reader.
|
||||||
|
- `POST admin_payouts/{id}/mark_failed` exists server-side but has no client op — a reconciled bank rejection cannot be recorded from the console.
|
||||||
|
- `holidayShifted` is always `false` (`payouts/apis/clientApi.ts:123`) because `PayoutBatchDto` carries no flag — the batch detail cannot say a date was shifted off a bank-closed day.
|
||||||
|
- No payout forecast (REQ-053) — the nurse dashboard's "next batch" line renders nothing on the real path.
|
||||||
|
- `failureReasons.ts:7` maps exactly one code (`invalid_sheba`); every other bank-rail reason falls back to the generic label.
|
||||||
|
- Stale doc-blocks assert the opposite of reality: `payouts/constants.ts:5-11` and `payouts/apis/clientApi.ts:149-161` both claim `earnings_balance`, `earnings` and `{id}` do not exist server-side. All three returned 200.
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# Flow — nurse-service-areas
|
||||||
|
|
||||||
|
> Last verified: 2026-08-02 against commit `c841bde`
|
||||||
|
|
||||||
|
**Actor(s):** nurse · **Status:** partial
|
||||||
|
**Client:** real · **Server:** real
|
||||||
|
**Business source:** [product/business/04-search-and-matching.md](../../product/business/04-search-and-matching.md) §(a)
|
||||||
|
**Integration:** [docs/integration/domains/service-areas.md](../integration/domains/service-areas.md) ·
|
||||||
|
[geography.md](../integration/domains/geography.md)
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
A nurse declares where she will travel: one or more cities, each either **whole-city** or a named district.
|
||||||
|
Coverage is the geographic half of being findable — with zero areas the nurse has zero rows in
|
||||||
|
`nurse_search_index` and no family can reach her, however verified and priced she is. It is add/remove only;
|
||||||
|
there is no edit.
|
||||||
|
|
||||||
|
## Screens
|
||||||
|
|
||||||
|
| Step | Route | Component / notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | `/fa/nurse/practice` | «حرفهٔ من» hub. The «مناطق تحت پوشش» row carries a live count off `areas.total` — [`NursePracticeScreen.tsx:49`](../../client/src/app/%5Blocale%5D/(private-routes)/nurse/practice/NursePracticeScreen.tsx) |
|
||||||
|
| 2 | `/fa/nurse/coverage` | The whole editor in one `'use client'` `page.tsx` (no thin-RSC split) — [`coverage/page.tsx`](../../client/src/app/%5Blocale%5D/(private-routes)/nurse/coverage/page.tsx) |
|
||||||
|
| 2a | ″ | Existing areas as MUI `Chip`s. A whole-city row renders «تهران · کل شهر»; a district row «تهران · منطقه ۳» (`chipLabel`, `page.tsx:42-47`) |
|
||||||
|
| 2b | ″ | Zero areas ⇒ an amber warning card, **not** an empty state: «تا زمانی که حداقل یک منطقهٔ تحت پوشش اضافه نکنید، در جستجو نمایش داده نمیشوید.» |
|
||||||
|
| 2c | ″ | Add form = [`CascadingRegionSelect`](../../client/src/components/geography/CascadingRegionSelect.tsx) (province → city → district). **City is the only required field.** |
|
||||||
|
| 2d | ″ | Remove = chip `onDelete` → MUI confirm `Dialog` («حذف منطقهٔ تحت پوشش؟») |
|
||||||
|
| — | `/fa/nurse` · `/fa/nurse/profile/preview` | Same `useServiceAreas` query, read-only: the activation checklist's "≥1 coverage area" row ([`useActivationChecklist.ts:46,63`](../../client/src/components/ActivationChecklist/useActivationChecklist.ts)) and the pre-publish dossier |
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| Call | Endpoint | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| list | `GET /api/v1/nurse_service_areas/list?page&pageSize` | `pageSize` 100 by default; whole-city rows sorted first (`NurseServiceAreaRepository.cs:34`) |
|
||||||
|
| add | `POST /api/v1/nurse_service_areas/add` | duplicate ⇒ `409`; also fans the area out into the search index in the same transaction |
|
||||||
|
| remove | `DELETE /api/v1/nurse_service_areas/remove/{id}` | soft-delete + drop this nurse×area's index rows, same transaction |
|
||||||
|
| geo | `GET /api/v1/geo/{provinces,cities,districts}` | **anonymous**; drives the three cascading selects |
|
||||||
|
|
||||||
|
Shapes: [service-areas.md](../integration/domains/service-areas.md) and
|
||||||
|
[geography.md](../integration/domains/geography.md).
|
||||||
|
|
||||||
|
**Chain, verified link by link:** `coverage/page.tsx:9` → `services/serviceAreas/hooks/*` →
|
||||||
|
[`apis/index.ts:10`](../../client/src/services/serviceAreas/apis/index.ts) (`USE_SERVICE_AREAS_MOCK = false`,
|
||||||
|
[`constants.ts:8`](../../client/src/services/serviceAreas/constants.ts)) →
|
||||||
|
[`apis/clientApi.ts:6`](../../client/src/services/serviceAreas/apis/clientApi.ts) → `clientFetch` →
|
||||||
|
[`NurseServiceAreasController.cs:23-36`](../../server/src/API/Baya.Web.Api/Controllers/V1/NurseServiceAreasController.cs)
|
||||||
|
→ `Features/ServiceAreas/{Commands,Queries}` → `NurseServiceAreaRepository`. Every link exists.
|
||||||
|
|
||||||
|
## Rules that must hold
|
||||||
|
|
||||||
|
| Rule | Where it is enforced |
|
||||||
|
| --- | --- |
|
||||||
|
| **`districtId = null` means WHOLE CITY** — an affirmative coverage claim, not missing data. [product/business/04](../../product/business/04-search-and-matching.md) §(a): *"a city-level row (no district) means the whole city"*. INV-3. | Client: the district select's empty «کل شهر» option **is** the choice (`CascadingRegionSelect.tsx:141`); `page.tsx:63` submits `region.districtId` verbatim. Server: `NurseServiceArea.DistrictId` nullable, `isWholeCity = DistrictId is null` (`AddNurseServiceArea…Handler.cs:72`). |
|
||||||
|
| **…in both directions.** A district search matches that district's rows **plus** every whole-city row; a city-only search matches all of them. | [`SqlNurseSearch.cs:30-31`](../../server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Search/SqlNurseSearch.cs): `r.DistrictId == districtId \|\| r.DistrictId == null`. The projection keeps the null verbatim — [`NurseSearchIndex.cs:44-47`](../../server/src/Core/Baya.Domain/Entities/Search/NurseSearchIndex.cs), written by `SearchIndexMaintainer.NewRow` (`:222`). **Live-verified below.** |
|
||||||
|
| **One control owns the whole-city choice.** The ui-phase-8 scope toggle was deliberately deleted; an empty district select is a complete, valid submission and never an error. | `coverage/page.tsx:14-24` (the comment records why), `:55-69` — only `cityId == null` is validated |
|
||||||
|
| **A duplicate `(nurseId, cityId, districtId)` is `409`**, treating `null` as a real value. | Server pre-check `DuplicateExistsAsync` → `ConflictResult` (`AddNurseServiceArea…Handler.cs:43-47`); DB backstop is a **filtered unique-index pair** (`NurseServiceAreaConfig.cs:21-29`) because SQL Server treats NULLs as distinct. Client fast-path `areaExists` (`types.ts:46-52`) plus a 409 fallback (`page.tsx:80`). |
|
||||||
|
| **Coverage edits hit the search index in the same transaction** — no lag, no reconciliation job. | `FanOutServiceAreaAsync` / `RemoveServiceAreaRowsAsync` called before `CommitAsync` (`AddNurseServiceArea…Handler.cs:61-62`, `RemoveNurseServiceArea…Handler.cs:39-40`) |
|
||||||
|
| **Coverage is not part of `is_searchable`.** The gate is `is_verified AND is_accepting_bookings AND status != suspended AND variant.is_active` (INV-17). Coverage decides whether a row **exists at all** — the effect is the same (invisible), the mechanism is not. | `SearchIndexMaintainer.cs:177,248-249`. Note [service-areas.md](../integration/domains/service-areas.md) calls coverage "one of its four conditions" — imprecise. |
|
||||||
|
| **404-not-403 on a foreign area id** (INV-7). | `GetOwnedAsync(id, nurseId)` → `NotFoundResult` (`RemoveNurseServiceArea…Handler.cs:31-33`) |
|
||||||
|
| Coverage is independent of `isAcceptingBookings`; a nurse can pause bookings and keep coverage. | separate fields; both feed `NurseBookable` |
|
||||||
|
|
||||||
|
## How to test
|
||||||
|
|
||||||
|
Log in as **`09120000001`** (زهرا عزیزی, verified nurse) — see [testing-setup.md](testing-setup.md).
|
||||||
|
|
||||||
|
1. Go to `/fa/nurse/practice` → tap «مناطق تحت پوشش». **Expect:** the row's count badge matches the number of
|
||||||
|
chips on the next screen.
|
||||||
|
2. On `/fa/nurse/coverage`, read the chips. **Expect** (live, 2026-08-02): `تهران · کل شهر`, `اهواز · کل شهر`,
|
||||||
|
`تهران · منطقه ۱`, `تهران · منطقه ۳`, `تهران · منطقه ۶` — **5 areas, whole-city rows first**.
|
||||||
|
⚠ The seed defines only 3 (whole-city Tehran + districts 1 and 3). Ahvaz (`id 8`) is pre-existing drift on
|
||||||
|
the shared remote DB and district 6 (`id 9`) was created by **this** verification and could not be removed
|
||||||
|
(the bearer token expired mid-probe). Treat "3 seeded areas" as no longer true.
|
||||||
|
3. Add a duplicate: province تهران → city تهران → district left at «کل شهر» → «افزودن منطقه».
|
||||||
|
**Expect:** the inline red «این منطقه از قبل تحت پوشش شماست.», no request fired (client fast path).
|
||||||
|
The server agrees — probed directly: `POST add {cityId:101,districtId:null}` → **`409 "You already cover
|
||||||
|
this whole city."`**
|
||||||
|
4. Add a genuinely new district in Tehran. **Expect:** `200`, a success snackbar «منطقهٔ تحت پوشش اضافه شد»,
|
||||||
|
the form resets, a new chip appears. Probed: `{cityId:101,districtId:1006}` → `200`, area `id 9`.
|
||||||
|
5. Verify the both-directions rule as a guest (no token needed):
|
||||||
|
`GET /api/v1/search/nurses?service_category_id=1&city_id=101&district_id=1006`.
|
||||||
|
**Expect:** nurse 1's rows even though — before step 4 — she had **no** district-6 area. Measured:
|
||||||
|
`total 3`, every row `districtId: null` (the whole-city rows). After step 4 it became `total 6` —
|
||||||
|
3 whole-city rows **plus** 3 district-6 rows for the same three variants (see gap 1).
|
||||||
|
With no `district_id` at all: `total 9` (3 variants × 3 Tehran areas), matching the boot log's 27 rows / 3 nurses.
|
||||||
|
6. Remove a chip → confirm in the dialog. **Expect:** «منطقهٔ تحت پوشش حذف شد» and the nurse stops appearing
|
||||||
|
in a search for that district. **UNVERIFIED live** — the token expired before the `DELETE` landed
|
||||||
|
(`401 Token is Not Valid`). Code-traced only: soft-delete + `RemoveServiceAreaRowsAsync`.
|
||||||
|
|
||||||
|
## Known gaps
|
||||||
|
|
||||||
|
- **Whole-city + a specific district in the same city are both accepted, and search then returns the nurse twice.** `DuplicateExistsAsync` (`NurseServiceAreaRepository.cs:22-25`) compares `DistrictId` for exact equality, so `(101, null)` and `(101, 1006)` are distinct rows; `SqlNurseSearch.cs:30-31` then ORs them and does no `Distinct` on `(nurseId, variantId)`. Live-measured: district-6 search went `3 → 6` results, the same three variants listed twice. The customer sees duplicate cards.
|
||||||
|
- **Duplicate React keys on the search results list.** `search/results/page.tsx:151` keys rows `${nurseId}-${variantId}`, which is not unique once the row above happens. React logs a duplicate-key warning and both cards render.
|
||||||
|
- **The coverage screen has no error state.** `page.tsx:31` destructures only `{ data, isLoading }`; `isError` is dropped. A failed `list` renders the amber «هنوز منطقهای ثبت نشده» card, telling a nurse with real coverage that she is invisible in search. This violates the phase-1 "error is never empty" convention that `useActivationChecklist` follows (`:51,55`).
|
||||||
|
- **[docs/integration/domains/service-areas.md](../integration/domains/service-areas.md) is wrong on the conflict rule.** It states that adding `(city, null)` when district rows exist "or the reverse" is a conflict. It is not — probed `200`. Only an exact `(nurse, city, district)` repeat conflicts.
|
||||||
|
- **No edit and no deactivate.** `NurseServiceAreaDto.isActive` is returned and typed client-side but nothing reads or toggles it — there is no endpoint. Changing a district means remove + add, which silently drops the nurse from search between the two calls.
|
||||||
|
- **No warning that removing the last area de-lists the nurse.** The confirm dialog says «دیگر برای ویزیت در این منطقه انتخاب نمیشوید» regardless of whether it is the nurse's last area, which is a much bigger consequence.
|
||||||
|
- **`remove` is unverified end-to-end** — see step 6.
|
||||||
|
- **The shared demo DB has drifted from `DemoWorldDefinitions`.** Nurse 1 now has 5 service areas, not the 3 the seeder defines, and the seeders are idempotent so they will never correct it.
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# Flow — nurse-verification
|
||||||
|
|
||||||
|
> Last verified: 2026-08-02 against commit `c841bde`
|
||||||
|
|
||||||
|
**Actor(s):** nurse · admin · (public, read-only trust badge) · **Status:** mocked
|
||||||
|
**Client:** mock · **Server:** partial (nurse half real and live; admin half real code, unreachable — 403)
|
||||||
|
**Business source:** [product/business/02-nurse-verification.md](../../product/business/02-nurse-verification.md)
|
||||||
|
**Integration:** [docs/integration/domains/verification.md](../integration/domains/verification.md)
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
A nurse proves who she is and what she is licensed to do — identity, phone binding, professional
|
||||||
|
credentials, bank ownership — and an admin reviews the manual parts. Until the pipeline says `approved`
|
||||||
|
the nurse is not bookable and does not appear in search. This is the flow the whole marketplace's trust
|
||||||
|
claim rests on.
|
||||||
|
|
||||||
|
**This is the atlas's clearest "real UI on a mocked service in front of a live server" trap.** Every nurse
|
||||||
|
screen renders in-browser fixtures from
|
||||||
|
[`verification/apis/mockApi.ts`](../../client/src/services/verification/apis/mockApi.ts) because
|
||||||
|
`USE_VERIFICATION_MOCK = true` ([`constants.ts:9`](../../client/src/services/verification/constants.ts)) —
|
||||||
|
while the server's nurse half answers correctly, right now, to a `curl`. Nothing in the browser ever calls
|
||||||
|
it. The one flag also holds the working nurse half hostage to the broken admin half.
|
||||||
|
|
||||||
|
## Screens
|
||||||
|
|
||||||
|
| Step | Route | Component / notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| B3 status hub | `/fa/nurse/verification` | [`page.tsx`](../../client/src/app/[locale]/(private-routes)/nurse/verification/page.tsx) — the canonical view of the **one cached status query** (`useVerificationStatus`). `not_started` CTA → grouped checklist → terminal approved panel. Renders mock-only approve/reject buttons (`:84-95`) |
|
||||||
|
| checklist | (same) | `VerificationChecklist.tsx` + [`verificationSteps.ts`](../../client/src/app/[locale]/(private-routes)/nurse/verification/verificationSteps.ts) — data-driven; groups server steps into هویت / مدارک حرفهای / بانک. Prepends a **synthetic `mobile_verified` step** (`MOBILE_STEP`, id `0`, always `passed`) that is not a server step — it is satisfied at phone-OTP login |
|
||||||
|
| B4 identity | `/fa/nurse/verification/identity` | national id (10-digit + checksum) + ID-card image + liveness selfie → `identity_kyc` run, chained `shahkar_match`. The two images are **local captures**, never uploaded |
|
||||||
|
| B5 credentials | `/fa/nurse/verification/credentials` | one `DocumentUpload` per manual step **in the status** (`MANUAL_CREDENTIAL_CODES`, `page.tsx:32`) + INO number, specialties, credential dates |
|
||||||
|
| B6 under review | `/fa/nurse/verification/review` | a second focused view of the **same cached query** — never a second fetch |
|
||||||
|
| admin queue | `/fa/admin/verification` | `useVerificationQueue`; per-step wire rows folded to one row per nurse |
|
||||||
|
| admin case | `/fa/admin/verification/[nurseId]` | per-step pass/reject + `DocumentViewer`; prev/next off the queue's cached page order |
|
||||||
|
| admin group root | `/fa/admin/trust` | nav only, no data |
|
||||||
|
| public | `/fa/search/nurse/:nurseId` | `TrustBadge` + `VerificationPanel` read `getTrustBadge` — see [search-and-discovery.md](search-and-discovery.md) |
|
||||||
|
|
||||||
|
`DocumentUpload` resolves its resting state as `state === 'success' || (state === 'idle' && existingDoc != null)`
|
||||||
|
([`DocumentUpload.tsx:132`](../../client/src/components/DocumentUpload/DocumentUpload.tsx)) — a just-finished
|
||||||
|
upload wins over stale server metadata, so a re-upload never snaps back to the old file name.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
Shapes, enums and the two vocabularies live in
|
||||||
|
[docs/integration/domains/verification.md](../integration/domains/verification.md). All 20 paths below were
|
||||||
|
confirmed present in the live OpenAPI doc (178 paths).
|
||||||
|
|
||||||
|
| Call | Endpoint | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| status (B3/B5/B6) | `GET /api/v1/nurse_verification` | **live, probed 200.** `clientApi.ts:157` → `NurseVerificationController.cs:32` |
|
||||||
|
| start | `POST …/submit` | wired |
|
||||||
|
| presign / confirm | `POST …/steps/{stepId}/upload_url` · `POST …/steps/{stepId}/documents` | wired; XHR PUT direct to storage + SHA-256 integrity hash (`clientApi.ts:180-208`) |
|
||||||
|
| automated runs | `POST …/steps/{identity_kyc\|shahkar_match\|bank_account_verification}/run` | wired; each is a separately switchable seam |
|
||||||
|
| credential details | `POST …/credential_details` | **endpoint EXISTS** (`NurseVerificationController.cs:49-52`) but `clientApi.ts:212` is an empty no-op — see gaps |
|
||||||
|
| public badge | `GET /api/v1/nurses/{nurseId}/trust_badge` | **live, anonymous, probed 200** |
|
||||||
|
| admin queue | `GET /api/v1/admin_verifications` | **403 for every seeded admin** |
|
||||||
|
| admin case | `GET /api/v1/admin_verifications/{nurseVerificationId}` | 403 |
|
||||||
|
| admin decide | `POST /api/v1/admin_verifications/steps/{stepId}/decide` | 403. **Per-step only** — approval emerges when the last required step passes |
|
||||||
|
| suspend · scan_expiring | `POST …/{id}/suspend` · `POST …/scan_expiring` | exist server-side, **no client caller** |
|
||||||
|
| step-type catalog | `GET/POST /api/v1/admin_verification_step_types`, `DELETE …/{id}` | exist, **no client caller**; probed **403** |
|
||||||
|
| approve / reject / document URL | `POST …/{id}/approve`, `…/reject`, `GET …/documents/{id}/url` | **proposed, do not exist** (REQ-034) — the admin case page wires CTAs to all three |
|
||||||
|
|
||||||
|
## Rules that must hold
|
||||||
|
|
||||||
|
| Rule | Source |
|
||||||
|
| --- | --- |
|
||||||
|
| `status` is the source of truth; `nurse_profiles.is_verified` is **derived** and flips only inside the transaction that confirms every required step passed (INV-18) | [business/02](../../product/business/02-nurse-verification.md); `AdminReviewStepCommand.Handler.cs:74-78` |
|
||||||
|
| The step catalog is **data, not code** — `verification_step_types` rows. 6 seeded, all `is_required`. Adding a regulatory step is one INSERT | [business/02](../../product/business/02-nurse-verification.md) §(a); `VerificationStepTypeSeed.cs` |
|
||||||
|
| The server computes `isBookable` and names `blockingSteps`; the client must **never** derive bookability from the step array | [integration/verification.md](../integration/domains/verification.md) |
|
||||||
|
| Search visibility is one gate: `is_verified AND is_accepting_bookings AND status != suspended AND variant.is_active` (INV-17) | `SearchIndexMaintainer.cs:177,248` |
|
||||||
|
| `expired` is a normal step state, not an error — the credential-expiry scan runs every **24 h** (`verification_expiry_scan_cadence_hours`) and re-gates bookability | [business/02](../../product/business/02-nurse-verification.md) |
|
||||||
|
| The trust badge exposes credential **types** only; `credential_number` is encrypted and never serialized | [business/02](../../product/business/02-nurse-verification.md) §(c-bis) |
|
||||||
|
| INO membership locks once submitted (it feeds the public badge) | [integration/verification.md](../integration/domains/verification.md) |
|
||||||
|
|
||||||
|
## How to test
|
||||||
|
|
||||||
|
1. Log in as **09120000003** (مریم احمدی — the deliberately unverified nurse) — see
|
||||||
|
[testing-setup.md](testing-setup.md). Boot the API with `Seams__Sms__Provider=mock` or `request_otp` 500s.
|
||||||
|
2. Open `/fa/nurse/verification`. **Expect:** the B3 grouped checklist renders and a «شبیهسازی بررسی ادمین»
|
||||||
|
block with approve/reject buttons appears at the bottom. That block is the mock tell — it only renders
|
||||||
|
when `USE_VERIFICATION_MOCK` is true.
|
||||||
|
3. Walk B4 → B5, then press the mock «تأیید» button. **Expect:** the hub flips to the green approved panel
|
||||||
|
with a «انتشار خدمات» CTA. **This changed nothing on the server** — reload the page and the mock store
|
||||||
|
resets to its seed.
|
||||||
|
4. Now read the server truth for the same nurse:
|
||||||
|
`curl --noproxy '*' http://localhost:5002/api/v1/nurse_verification -H "Authorization: Bearer $T_09120000003"`.
|
||||||
|
**Expect (probed 2026-08-02, HTTP 200):** `status: "in_review"`, `isBookable: false`,
|
||||||
|
`blockingSteps: ["moh_competency_license","criminal_record"]`, and 4 steps — `identity_kyc` passed,
|
||||||
|
`shahkar_match` passed, `moh_competency_license` in_review, `criminal_record` pending. Compare it with
|
||||||
|
what step 3 showed you; they are unrelated.
|
||||||
|
5. Public badge, no auth: `curl --noproxy '*' http://localhost:5002/api/v1/nurses/1/trust_badge`.
|
||||||
|
**Expect:** `{"nurseId":1,"isVerified":true,"approvedAt":"2026-07-26T10:52:18…","credentialTypes":["criminal_record","moh_competency_license"]}`.
|
||||||
|
Nurse 3 returns `isVerified:false`, `credentialTypes:[]`.
|
||||||
|
6. Admin half: open `/fa/admin/verification` as **09120000020**. **Expect:** a populated queue — all of it
|
||||||
|
in-browser fixtures. The live endpoint is **403**:
|
||||||
|
`curl --noproxy '*' "http://localhost:5002/api/v1/admin_verifications?page=1&page_size=5" -H "Authorization: Bearer $T_09120000020"`
|
||||||
|
→ `403` (probed). Same for `/api/v1/admin_verification_step_types`.
|
||||||
|
|
||||||
|
**Seeded-world limits.** No admin can approve anything on the real path (the RBAC gap). Nurse 1 is already
|
||||||
|
`approved` but returns `steps: []`, so there is no approved-with-checklist case to look at. Nurse 3 carries
|
||||||
|
only 4 of the 6 seeded step types — `ino_membership` and `bank_account_verification` have no step rows at
|
||||||
|
all, so the bank branch of the journey cannot be walked against real data. Workaround for the whole flow:
|
||||||
|
none today; the honest demo is the mock, and the honest server check is `curl`.
|
||||||
|
|
||||||
|
## Known gaps
|
||||||
|
|
||||||
|
- `USE_VERIFICATION_MOCK = true` ([`verification/constants.ts:9`](../../client/src/services/verification/constants.ts)) suppresses a working real nurse half — 7 nurse-side ops map live, probed-200 routes. One flag covers nurse + public badge + 6 admin ops, so the nurse half cannot be flipped independently.
|
||||||
|
- `submitCredentialDetails` is an empty no-op (`verification/apis/clientApi.ts:212`) even though `POST /api/v1/nurse_verification/credential_details` exists and `SubmitCredentialDetailsCommand` matches `CredentialDetailsInput` field-for-field. **Flipping the flag today would silently drop every nurse's INO number and specialties.** The client comment, `useSubmitCredentials.ts` and [integration/verification.md](../integration/domains/verification.md) all still say "no nurse-facing endpoint" — stale.
|
||||||
|
- Every `admin_verifications` and `admin_verification_step_types` route returns **403** for `super_admin`/`finance` (`DynamicPermissionService.CanAccess`). The admin review half of this flow is untestable end to end; the mock hides it completely.
|
||||||
|
- [`nurse/verification/page.tsx:12`](../../client/src/app/[locale]/(private-routes)/nurse/verification/page.tsx) imports `__mockApproveAll`/`__mockRejectStep` from `apis/mockApi` **unconditionally**. The render is gated but the module edge is not — the mock is bundled into the nurse verification route in every build. This is the one production seam breach in the client.
|
||||||
|
- With the flag flipped there is **no** way for a nurse to observe the approved flip in a demo: the mock admin controls disappear and the real admin queue 403s.
|
||||||
|
- `foldQueueRows` (`clientApi.ts:74-96`) folds a per-step wire page to per-nurse items and leaves `stepsPassed: 0`, `stepsTotal: 0`, `hasExpiringCredential: false`, `counts` undefined; `total` stays the per-step count, so the queue's pager is nominal. A nurse's steps can straddle a page boundary (REQ-034, REQ-062).
|
||||||
|
- `approveVerification`, `rejectVerification` and `getDocumentSignedUrl` target routes that do not exist (REQ-034). The admin case page wires visible approve/reject CTAs to two of them — they would 404 on the real path.
|
||||||
|
- `AdminVerificationsController` `suspend` and `scan_expiring`, and all three `admin_verification_step_types` routes, have **no client caller** — no suspension UI and no step-catalog editor, so the "data-driven catalog" rule has no admin surface.
|
||||||
|
- The wire serves `isRequired` per step (confirmed live) but `VerificationStep` (`verification/types.ts:62-71`) omits it, and `progressCounts` assumes every step is required. Adding one optional step type would make the "X از Y" meter wrong.
|
||||||
|
- No `submittedAt` on `VerificationStatusDto` (REQ-055) → B6 omits the submitted-at line. `TrustBadgeDto` carries no per-step detail (REQ-043) → the public `VerificationPanel` is a summary only. Both confirmed against the live responses.
|
||||||
|
- The admin case route folder is `[nurseId]` but the value it passes is a **`nurseVerificationId`** (`[nurseId]/page.tsx:74`). Typing `/fa/admin/verification/3` opens verification-case 3, not nurse 3.
|
||||||
|
- Seeded data: nurse 1 (`approved`) returns `steps: []`, and nurse 3 has no `ino_membership` or `bank_account_verification` step rows — two of the six catalog steps are unexercisable in the demo world.
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# Flow — onboarding-customer
|
||||||
|
|
||||||
|
> Last verified: 2026-08-02 against commit `c841bde`
|
||||||
|
|
||||||
|
**Actor(s):** customer (a family member / payer) · **Status:** partial
|
||||||
|
**Client:** real · **Server:** real
|
||||||
|
**Business source:** [product/business/01-actors-and-onboarding.md](../../product/business/01-actors-and-onboarding.md)
|
||||||
|
**Integration:** [profiles.md](../integration/domains/profiles.md) · [auth.md](../integration/domains/auth.md) · [patients.md](../integration/domains/patients.md)
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
A phone that has just verified an OTP holds a session with **no public role**. This flow is everything
|
||||||
|
between that moment and a usable family app: pick "I need care" vs "I am a nurse", register the first
|
||||||
|
person you arrange care for, and — later, prompted by a nudge on Home — fill in your own payer details
|
||||||
|
and the emergency contact a booking falls back on.
|
||||||
|
|
||||||
|
The customer's own identity KYC is deliberately **not** part of it: a customer registers and browses on a
|
||||||
|
verified phone alone ([business/01 §(a)](../../product/business/01-actors-and-onboarding.md)).
|
||||||
|
|
||||||
|
## Screens
|
||||||
|
|
||||||
|
| Step | Route | Component / notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 0 — route | *(none)* | [`RoleRouter`](../../client/src/components/auth/RoleRouter.tsx) runs after `verify_otp`. `resolveRoleDestination` ([`routing.ts:33`](../../client/src/services/auth/routing.ts)) sends `roles == []` to `/select-role`, carrying `?role=nurse` when that was the login intent |
|
||||||
|
| 1 — role | `/fa/select-role` | [`SelectRole.tsx`](../../client/src/components/auth/SelectRole.tsx). Two radio cards, «خانواده» / «پرستار»; admin is never offered. `FocusedLayout`, **no `RoleGuard`** — resolving the role is the page's job |
|
||||||
|
| 2 — gate | `/fa` | [`HomeScreen.tsx:90-94`](../../client/src/app/[locale]/(private-routes)/(customer)/HomeScreen.tsx) — `total === 0` ⇒ `router.replace('/onboarding')`. Waits for a *settled* list so a post-create refetch can't bounce back |
|
||||||
|
| 3 — welcome | `/fa/onboarding` | [`OnboardingScreen.tsx`](../../client/src/app/[locale]/(private-routes)/(customer-focused)/onboarding/OnboardingScreen.tsx) phase `welcome` — brand moment, one CTA. Not a stepper step |
|
||||||
|
| 4 — relation | `/fa/onboarding` | phase `relation` — «مراقبت برای چه کسی است؟» `RelationSelect` over `parent`/`spouse`/`child`/`self`, one glyph each |
|
||||||
|
| 5 — first patient | `/fa/onboarding` | phase `patient` — `PatientForm` with `relation` pre-set and hidden. On success → `router.replace('/')` |
|
||||||
|
| 6 — payer details | `/fa/profile` | Customer **account hub**. Reached from Home's `nudge_profile` card ([`HomeScreen.tsx:155-163`](../../client/src/app/[locale]/(private-routes)/(customer)/HomeScreen.tsx)), shown while `me.hasCustomerProfile === false`. Three `FormDialogShell` sheets over **one** `react-hook-form` — personal (نام/نام خانوادگی), language, emergency contact |
|
||||||
|
|
||||||
|
`(customer-focused)` is a chrome-free route group: same URL space, `FocusedLayout` strips the bottom nav so
|
||||||
|
the user cannot tab away mid-setup. It still carries `RoleGuard expected=customer`; `/select-role` does not.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| Call | Endpoint | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| commit role | `POST /api/v1/me/select_role` | [`clientApi.ts:56`](../../client/src/services/auth/apis/clientApi.ts) → [`MeController.cs:30`](../../server/src/API/Baya.Web.Api/Controllers/V1/MeController.cs) → `SelectRoleCommandHandler`. **Live: 200** re-selecting `customer`; **403** for `super_admin` |
|
||||||
|
| token rotation | `POST /api/v1/auth/refresh` | [`useSelectRole.ts:26-33`](../../client/src/services/auth/hooks/useSelectRole.ts) rotates immediately after — role claims live **inside** the JWE, so without it the next gated call carries the stale claim. A failed rotation is swallowed by design |
|
||||||
|
| identity | `GET /api/v1/me` | the only identity source. Live for `…010`: `roles:["customer"]`, `hasCustomerProfile:true`, masked `0912*****10` |
|
||||||
|
| first patient | `POST /api/v1/patients/create` | [`useCreatePatient.ts`](../../client/src/services/patients/hooks/useCreatePatient.ts) splices the row into every cached list before invalidating, so there is no transient "0 patients" window |
|
||||||
|
| read profile | `GET /api/v1/customer_profiles/me` | **404 ⇒ `null`, not an error** ([`clientApi.ts:19-26`](../../client/src/services/profiles/apis/clientApi.ts)) — a first-run customer sees an empty form. Live: 404 for a nurse token, 200 for `…010` |
|
||||||
|
| write profile | `POST /api/v1/customer_profiles/upsert` | **403 for a non-customer** (verified live). Creates on first call, updates after |
|
||||||
|
|
||||||
|
Shapes belong to [docs/integration/domains/](../integration/domains/profiles.md); do not restate them here.
|
||||||
|
`USE_AUTH_MOCK`, `USE_PROFILES_MOCK` and `USE_PATIENTS_MOCK` are all `false` — every call above is real HTTP.
|
||||||
|
|
||||||
|
## Rules that must hold
|
||||||
|
|
||||||
|
| Rule | Value | Source |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Self-selectable roles | `customer`, `nurse` **only**; any admin sub-role ⇒ **403** | [business/01 §(a)](../../product/business/01-actors-and-onboarding.md); `RoleNames.SelfAssignable`, `SelectRoleCommand.Handler.cs:23-24` |
|
||||||
|
| A user may hold both public roles | grants are audited via `granted_by`/`granted_at`; a revoked grant is **re-activated**, never duplicated | `SelectRoleCommand.Handler.cs:44-50` |
|
||||||
|
| Customer KYC is deferred | a customer registers and browses on a verified phone alone; `national_id` is nurse-only | [business/01 §(a)/(c)](../../product/business/01-actors-and-onboarding.md) |
|
||||||
|
| Patient ≠ customer | the `self` relation still creates a **distinct** patient row; the customer is never collapsed into the patient | [business/01 §(a)](../../product/business/01-actors-and-onboarding.md) |
|
||||||
|
| Relation enum | `parent` \| `spouse` \| `child` \| `self` (REQ-005, delivered) | [business/01 §(a)](../../product/business/01-actors-and-onboarding.md) |
|
||||||
|
| PII at rest | phone, name and the emergency contact are encrypted; `/me` masks the phone to `0912*****10` | INV-21; `IFieldEncryptor`, `IdentityDefaults.MaskPhone` |
|
||||||
|
| Tenancy | the profile is resolved from `ICurrentUser`, never the body | `UpsertCustomerProfileCommand.Handler.cs:17-21` |
|
||||||
|
|
||||||
|
No money, no config rate, no deadline is involved in this flow.
|
||||||
|
|
||||||
|
## How to test
|
||||||
|
|
||||||
|
Log in as `09120000010` (سارا محمدی, customer) — see [testing-setup.md](testing-setup.md) for boot, the
|
||||||
|
OTP and the account list.
|
||||||
|
|
||||||
|
**All 8 demo accounts are already onboarded**, so steps 1–5 cannot be walked with a seeded account. Two
|
||||||
|
options:
|
||||||
|
|
||||||
|
**A — inspect the finished state (any demo customer).**
|
||||||
|
1. Log in as `09120000010`, land on `/fa`.
|
||||||
|
**Expect:** Home renders (2 patients seeded), **not** a redirect to `/fa/onboarding`.
|
||||||
|
2. Open `/fa/profile`.
|
||||||
|
**Expect:** header «سارا محمدی» + `0912*****10`; the emergency card shows «بهرام محمدی» / `09121110010`.
|
||||||
|
No `nudge_profile` card on Home, because `hasCustomerProfile` is `true`.
|
||||||
|
3. Type `/fa/select-role` directly.
|
||||||
|
**Expect:** the picker renders (it has no `RoleGuard`). Choosing «پرستار» **adds** the nurse role to
|
||||||
|
this account — a real, permanent write to the shared demo DB. Do not do it casually.
|
||||||
|
|
||||||
|
**B — see the genuine first-run state (a fresh phone).**
|
||||||
|
1. `POST /api/v1/auth/request_otp` with an unused Iranian mobile, e.g. `09129990001`. The handler creates
|
||||||
|
an **inactive shell `users` row** with no role and no PII beyond the encrypted phone
|
||||||
|
([`RequestOtpCommand.Handler.cs:39-54`](../../server/src/Core/Baya.Application/Features/Identity/Commands/RequestOtp/RequestOtpCommand.Handler.cs)) —
|
||||||
|
creation happens **before** the SMS send, so a `500` from the telegram relay still leaves the account.
|
||||||
|
2. `GET /api/v1/dev/last_otp/09129990001`, then log in at `/fa/login`.
|
||||||
|
**Expect:** `/me` returns `roles: []` → `RoleRouter` replaces to `/fa/select-role`.
|
||||||
|
3. Pick «خانواده» → **expect** a 200 from `select_role`, a silent token rotation, then `/fa`, which
|
||||||
|
immediately replaces to `/fa/onboarding` (zero patients).
|
||||||
|
4. Welcome → relation → patient. On save **expect** the «ذخیره شد» toast and a landing on `/fa`, now with
|
||||||
|
the `nudge_profile` card visible because `hasCustomerProfile` is still `false`.
|
||||||
|
5. Tap the nudge → `/fa/profile` → «اطلاعات شخصی» and the emergency sheet. On save **expect**
|
||||||
|
`GET /me` to flip `hasCustomerProfile` to `true` and the nudge to disappear.
|
||||||
|
|
||||||
|
> This creates a permanent shell account on the **shared** remote demo DB. Prefer a local instance
|
||||||
|
> ([testing-setup.md](testing-setup.md#the-local-alternative--unverified)) if that matters.
|
||||||
|
>
|
||||||
|
> **`curl` on Git Bash mangles Persian in `-d`.** A round-trip upsert sent inline wrote `?????` into the
|
||||||
|
> DB during this verification (restored). Put the JSON in a UTF-8 file and use `--data-binary @file`.
|
||||||
|
|
||||||
|
## Known gaps
|
||||||
|
|
||||||
|
- Onboarding never creates a `customer_profile` — it creates a patient and stops. `hasCustomerProfile`
|
||||||
|
stays `false` and the **only** path to the payer details is Home's dismissible-free `nudge_profile` card
|
||||||
|
(`HomeScreen.tsx:155-163`). Nothing blocks a customer from booking without an emergency contact.
|
||||||
|
- `POST /api/v1/customer_profiles/avatar` is live in swagger and implemented
|
||||||
|
(`CustomerProfilesController.cs:37`) but **no client code calls it** — `useUploadAvatar` is imported only
|
||||||
|
by `nurse/profile/page.tsx:21`. A customer can never set a photo; `ProfileSummary` always shows initials.
|
||||||
|
- The customer's own `gender` is **unsettable through the app**. `auth/types.ts:20` says "null until the
|
||||||
|
profile flow (b3) sets it", but `UpsertCustomerProfileCommand` carries no gender field (confirmed against
|
||||||
|
the live swagger) and no client call sends one. A fresh account's `/me.gender` stays `null` forever.
|
||||||
|
- `preferredLanguage` is **write-only**. It round-trips (verified live) but nothing on either side reads it
|
||||||
|
— the UI locale comes from the `/fa|/en` URL prefix and `LocaleSwitcher`. The language sheet at
|
||||||
|
`profile/page.tsx:91-93` therefore persists a preference the app ignores.
|
||||||
|
- The upsert has **no PATCH semantics** (`profile/page.tsx:76-79`): every sheet save rewrites the whole
|
||||||
|
profile *and* `users.Name`/`FamilyName`. Saving only the emergency contact re-sends the name; a stale form
|
||||||
|
can silently overwrite a name changed on another device.
|
||||||
|
- Saving any sheet writes `preferredLanguage: values.language`, whose default is `'fa'` when the served
|
||||||
|
value is `null` (`profile/page.tsx:85`). A customer who opens the language sheet and saves silently
|
||||||
|
commits `'fa'` they never chose.
|
||||||
|
- `/fa/select-role` is reachable by any authenticated user at any time and has no `RoleGuard` by design, so
|
||||||
|
an onboarded customer can permanently add themselves the `nurse` role by typing the URL. Intentional
|
||||||
|
(`role_add_later_note`), but there is no confirmation step for an irreversible grant.
|
||||||
|
- The seeded demo world contains **no un-onboarded account**, so this flow's first-run half is untestable
|
||||||
|
without minting a shell user against the shared DB. See [testing-setup.md](testing-setup.md).
|
||||||
|
- `useSelectRole` swallows a failed post-select token rotation
|
||||||
|
(`useSelectRole.ts:30-32`). The role is persisted server-side, but the client keeps a JWE without the new
|
||||||
|
claim until the fetch layer's silent refresh happens to fire — a role-gated call in between 403s.
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# Flow — onboarding-nurse
|
||||||
|
|
||||||
|
> Last verified: 2026-08-02 against commit `c841bde`
|
||||||
|
|
||||||
|
**Actor(s):** nurse · **Status:** partial
|
||||||
|
**Client:** partial · **Server:** real
|
||||||
|
**Business source:** [product/business/01-actors-and-onboarding.md](../../product/business/01-actors-and-onboarding.md)
|
||||||
|
**Integration:** [nurse.md](../integration/domains/nurse.md) · [profiles.md](../integration/domains/profiles.md)
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
A phone-verified account becomes a listable nurse: pick the nurse role, write the profile families will
|
||||||
|
read, register the IBAN that earnings land in, and watch one checklist say what is still missing before
|
||||||
|
the listing goes live. Verification itself is a separate journey — [nurse-verification.md](nurse-verification.md);
|
||||||
|
this flow is everything around it.
|
||||||
|
|
||||||
|
## Screens
|
||||||
|
|
||||||
|
| Step | Route | Component / notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 0 | `/fa/select-role` | [`SelectRole`](../../client/src/components/auth/SelectRole.tsx) — only `customer`/`nurse` offered. Reached programmatically (`resolveRoleDestination` on an empty `roles`), never linked |
|
||||||
|
| 1 | `/fa/nurse` | thin RSC → `NurseDashboardScreen` → [`DashboardActivationSlot`](../../client/src/app/[locale]/(private-routes)/nurse/DashboardActivationSlot.tsx) → the shared `ActivationChecklist` |
|
||||||
|
| 2 | `/fa/nurse/profile` | client `page.tsx`; react-hook-form, three `FormSection`s (معرفی / تجربه و تحصیلات / تخصصها) + avatar upload + a `beforeunload` guard on a staged-but-unsaved avatar |
|
||||||
|
| 3 | `/fa/nurse/profile/preview` | «نمایهٔ عمومی من» — the C3 dossier composed from the nurse's **own** cached queries, so it renders pre-publish with no search-index dependency |
|
||||||
|
| 4 | `/fa/nurse/bank` | an accounts *section*: submit شبا + holder, watch pending → verified/mismatch, «افزودن حساب دیگر», make-primary |
|
||||||
|
| — | `/fa/nurse/practice`, `/fa/nurse/services` | the checklist is also mounted above the offerings list (`MyServicesList.tsx:92`) next to `PublishGate` |
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| Call | Endpoint | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| read profile | `GET /api/v1/nurse_profiles/me` | 404 → `null` (empty form) via `orNull` (`profiles/apis/clientApi.ts:19-26`) |
|
||||||
|
| save profile | `POST /api/v1/nurse_profiles/upsert` | body carries 5 fields only — see the `avatarUrl` gap |
|
||||||
|
| avatar | `POST /api/v1/nurse_profiles/avatar` | `multipart/form-data`; `clientFetch` must not set `Content-Type` |
|
||||||
|
| go live | `POST /api/v1/nurse_profiles/set_accepting_bookings` | the real switch behind `PublishGate`; reindexes in-transaction |
|
||||||
|
| bank list / add / primary | `GET nurse_bank_accounts/list`, `POST .../add`, `POST .../set_primary/{id}` | `add` is `sensitive` 20/min |
|
||||||
|
| role | `POST /api/v1/me/select_role` | `RoleNames.SelfAssignable = [customer, nurse]` (`RoleNames.cs:19`) |
|
||||||
|
|
||||||
|
Shapes live in [profiles.md](../integration/domains/profiles.md) and [nurse.md](../integration/domains/nurse.md).
|
||||||
|
`POST nurse_bank_accounts/verify_ownership/{id}` is wired server-side and implemented at
|
||||||
|
`nurse/apis/clientApi.ts:30` but **no hook or component ever calls it**.
|
||||||
|
|
||||||
|
## Rules that must hold
|
||||||
|
|
||||||
|
| Rule | Where enforced |
|
||||||
|
| --- | --- |
|
||||||
|
| **`is_verified` is never client-settable.** `UpsertNurseProfileCommand` has no such field; the handler's `Apply()` writes only bio/years/education×2/specializations. Only the verification finalize transaction flips it (INV-18) | `UpsertNurseProfileCommand.cs`, `.Handler.cs` |
|
||||||
|
| **`iban_hash` is UNIQUE platform-wide** — deterministic hash checked in the handler, `UNIQUE(iban_hash)` as the DB backstop | `AddNurseBankAccountCommand.Handler.cs:36-39` |
|
||||||
|
| **Write-then-masked IBAN.** Every read model returns `ibanMasked` (last-4); the full IBAN is never re-served | [nurse.md](../integration/domains/nurse.md) |
|
||||||
|
| **A verified *primary* IBAN with `matchedNationalId == true` is the first-payout gate** — not a search gate. No account ⇒ the nurse accrues a balance and is skipped with a recorded reason | [payouts.md](../integration/domains/payouts.md); `product/business/10` §(b) |
|
||||||
|
| **`is_searchable = is_verified AND is_accepting_bookings AND status != suspended AND variant.is_active`** (INV-17). Bio and avatar are **not** in it | `SearchIndexMaintainer.cs:248-249` |
|
||||||
|
| A profile row does **not** exist until the first `upsert`; `select_role` only grants the role | `SelectRoleCommand.Handler.cs` |
|
||||||
|
| Bank `add` requires a nurse profile to already exist ("Create your profile first.") — profile precedes bank | `AddNurseBankAccountCommand.Handler.cs:32-34` |
|
||||||
|
|
||||||
|
## How to test
|
||||||
|
|
||||||
|
Log in as **09120000001** (زهرا عزیزی, verified nurse) — see [testing-setup.md](testing-setup.md).
|
||||||
|
|
||||||
|
1. Open `/fa/nurse`. **Expect:** the «راهاندازی حساب» card. **Actual today:** it renders *incomplete* with
|
||||||
|
«احراز هویت و مدارک» un-passed, because the verification row reads a client mock (see gaps). It never
|
||||||
|
collapses to the green «فعال در جستجو» state for any account.
|
||||||
|
2. Open `/fa/nurse/profile`. **Expect:** bio «پرستار سالمند با هشت سال سابقه مراقبت در منزل.», years `8`,
|
||||||
|
level «کارشناسی», field «پرستاری». A warning card «پروفایل شما هنوز فعال نیست» is shown — also mock-driven,
|
||||||
|
and wrong for this account (`GET nurse_profiles/me` → `isVerified: true`).
|
||||||
|
3. Edit the bio and save. **Expect:** a success toast and the value persisted on reload. Verified live:
|
||||||
|
`POST nurse_profiles/upsert` → `200`.
|
||||||
|
4. Same call with `"isVerified": false, "isAcceptingBookings": false` injected into the body.
|
||||||
|
**Expect (verified live):** `200` with `isVerified: true`, `isAcceptingBookings: true` — the guard holds.
|
||||||
|
5. Open `/fa/nurse/bank`. **Expect:** one card, «تأییدشده», «بانک ملت», `••••9012`, primary.
|
||||||
|
6. Submit `IR820170000000123456789012` (nurse 1's own seeded IBAN) via «افزودن حساب دیگر».
|
||||||
|
**Expect (verified live):** `400` `{"Iban":["This IBAN is already registered."]}` and no new row. The UI
|
||||||
|
shows only the generic «ثبت این حساب ممکن نشد…» toast.
|
||||||
|
7. Open `/fa/nurse/profile/preview`. **Expect:** the C3 dossier rendered from own data. The avatar renders as
|
||||||
|
a fallback icon, not the photo — see the `file://` gap.
|
||||||
|
|
||||||
|
**Seeded-world limits.** Nothing exercises the *first-run* path: all three demo nurses already have a profile,
|
||||||
|
and nurses 1–2 already have a verified primary IBAN. To see the empty profile → 404 → blank form, the
|
||||||
|
"no nurse profile yet" bank rejection, or `select-role`, you need a **new** account, which the phone-OTP
|
||||||
|
login cannot mint for an unseeded phone. `09120000003` (unverified, `in_review`) is the closest stand-in for
|
||||||
|
a mid-onboarding nurse; its token in `tokens.env` returned `401` at this stamp, so its state was **not**
|
||||||
|
re-probed live.
|
||||||
|
|
||||||
|
## Known gaps
|
||||||
|
|
||||||
|
- **The activation checklist mixes real and mocked truth.** `useActivationChecklist.ts:43-47` folds four real
|
||||||
|
domains (`profiles`, `catalog`, `serviceAreas`, `nurse`) with one mocked one (`verification`,
|
||||||
|
`USE_VERIFICATION_MOCK = true` at `verification/constants.ts:9`). The «احراز هویت و مدارک» row is browser
|
||||||
|
state, not server state.
|
||||||
|
- **Every nurse looks unverified on every page load.** The verification mock's `steps` starts `[]`
|
||||||
|
(`verification/apis/mockApi.ts:40`) and `getStatus` returns that aggregate, so `isApproved()` is `false`
|
||||||
|
for the seeded, server-verified nurse 1 — and the module state resets on every reload/HMR. Consequences:
|
||||||
|
the checklist never reaches «فعال در جستجو»; `PublishGate` renders the *blocked* branch and hides the real
|
||||||
|
`set_accepting_bookings` CTA; `/fa/nurse/profile` shows a false «پروفایل شما هنوز فعال نیست» banner.
|
||||||
|
- **`isSearchVisible` is not the server's gate, despite the component saying it is.**
|
||||||
|
`useActivationChecklist.ts:35-41` claims the four rows "are exactly the server's `is_searchable` gate". They
|
||||||
|
are not: the `profile` row (`bio.trim() !== '' && avatarUrl != null`, `:31-33`) is client-invented and the
|
||||||
|
server never reads it, while `is_accepting_bookings` — which *is* in the gate — is excluded from `searchRows`
|
||||||
|
and tracked separately. A nurse can pass all four rows and still be invisible.
|
||||||
|
- **The avatar URL is unrenderable on the default seam.** `LocalDiskObjectStorage.GetUrl` returns
|
||||||
|
`new Uri(localPath).AbsoluteUri` (`LocalDiskObjectStorage.cs:51`), so `nurse_profiles/me` serves
|
||||||
|
`file:///C:/Users/.../avatars/nurse/1/….png` (verified live). A browser cannot load `file://` from an
|
||||||
|
`http://` page — the `<Avatar>` on `/fa/nurse/profile` and the preview both fall back to the placeholder
|
||||||
|
icon, while the checklist's `profile` row counts the non-null URL as passed.
|
||||||
|
- **`avatarUrl` on the upsert input is dead on the real path.** `UpsertNurseProfileInput.avatarUrl`
|
||||||
|
(`profiles/types.ts:40`) is populated by the form (`nurse/profile/page.tsx:154`) but
|
||||||
|
`profilesClientApi.upsertNurseProfile` (`profiles/apis/clientApi.ts:85-95`) never sends it. Harmless today
|
||||||
|
(the multipart route already persisted it) but it is a mock-era field the real client silently drops.
|
||||||
|
- **Duplicate IBAN is `400`, not `409`.** [nurse.md](../integration/domains/nurse.md) says "the second `add`
|
||||||
|
returns a `409`"; the handler returns `FailureResult` → `400` with a field error (verified live). The
|
||||||
|
integration doc is wrong.
|
||||||
|
- **The duplicate-IBAN message never reaches the nurse.** `nurse/bank/page.tsx:51` toasts the generic
|
||||||
|
«ثبت این حساب ممکن نشد. شبا را بررسی کرده و دوباره تلاش کنید.» and discards the server's
|
||||||
|
`{"Iban":["This IBAN is already registered."]}`, so a nurse re-entering their own IBAN is told to check it.
|
||||||
|
- **The ownership inquiry ignores the national id.** `MockBankAccountOwnershipVerifier` decides from the IBAN
|
||||||
|
alone (`:21-26`) and matches everything except `Seams:BankOwnership:MismatchIban`. A nurse who has not done
|
||||||
|
identity KYC has `users.national_id = NULL` (`NurseIdentityContext.cs:9-10`) and still gets
|
||||||
|
`matchedNationalId = true` — the payout gate opens on a claim nothing checked.
|
||||||
|
- **`verifyOwnership` is a dead seam op on the client.** Declared (`nurse/types.ts:41`), implemented
|
||||||
|
(`nurse/apis/clientApi.ts:30`), server route live — but no hook exists, so a `mismatch` account has no
|
||||||
|
re-inquiry affordance; the UI only offers "re-enter the IBAN".
|
||||||
|
- **`/fa/select-role` cannot be reached with any seeded account** — all 8 demo users already hold a role, so
|
||||||
|
the role picker is untestable end-to-end today.
|
||||||
|
- **The bank page polls forever on a stuck inquiry.** `useNurseBankAccounts` refetches every 2 s while any
|
||||||
|
account is `pending` (`nurse/constants.ts:14`) with no ceiling and no timeout copy.
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# Flow — partner-center
|
||||||
|
|
||||||
|
> Last verified: 2026-08-02 against commit `c841bde`
|
||||||
|
|
||||||
|
**Actor(s):** partner-center owner / center admin («مدیر مرکز») · **Status:** mocked
|
||||||
|
**Client:** mock · **Server:** partial
|
||||||
|
**Business source:** [product/business/13-tax-invoicing-and-legal.md](../../product/business/13-tax-invoicing-and-legal.md)
|
||||||
|
**Integration:** [docs/integration/domains/partner-center.md](../integration/domains/partner-center.md)
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
A licensed nursing center («مرکز») sponsors nurses onto Balinyaar and — when it is the **merchant of
|
||||||
|
record** — is the legal seller: it issues the customer invoice and receives settlement, with Balinyaar's cut
|
||||||
|
booked as a commission against it. The portal is the center owner's own read-only window: who they sponsor,
|
||||||
|
which bookings they legally cover, and what they are owed. It is a **separate authz scope from `/admin`**.
|
||||||
|
|
||||||
|
**The core finding:** there is **no partner-facing controller**. The server ships one admin controller
|
||||||
|
(`admin/partner-centers`), one center-scoped aggregate (`centers/{id}/dashboard`), and an internal MoR
|
||||||
|
resolver. The portal's five `/centers/me*` reads do not exist, so all six screens run on client fixtures.
|
||||||
|
|
||||||
|
## Screens
|
||||||
|
|
||||||
|
All six sit inside `PartnerLayout` (5-tab `MobileShell`, no bell). Every page's access gate is
|
||||||
|
`useMyPartnerCenter()` — which today resolves a **mock** center for any caller.
|
||||||
|
|
||||||
|
| Step | Route | Component / notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | `/fa/partner` | [`PartnerHomeScreen.tsx`](../../client/src/app/%5Blocale%5D/(private-routes)/partner/PartnerHomeScreen.tsx) — MoR chip, onboarding banner (`draft`/`pending`/`suspended`), license block (permit · technical-director licence · Enamad · legal type), masked settlement IBAN (MoR only, `:101-103`) |
|
||||||
|
| 2 | `/fa/partner/nurses` | Sponsored roster — name + verification `StatusChip`, unpaginated (bounded set) |
|
||||||
|
| 3 | `/fa/partner/bookings` | Read-only sponsored-bookings list; URL-synced status filter over 7 wire codes; translated labels only |
|
||||||
|
| 4 | `/fa/partner/bookings/[id]` | Scoped detail (REQ-064) — patient display name, date, `StatusTimeline`. **No clinical content, no address, no money** |
|
||||||
|
| 5 | `/fa/partner/settlement` | MoR drives the whole view: non-MoR → `settlement_not_mor` state; MoR → per-booking commission invoices (`PartnerSettlementRow`), masked IBAN, PDF link, client-side CSV of the loaded page |
|
||||||
|
| 6 | `/fa/partner/more` | Center identity + MoR chip, appearance/language, sign-out |
|
||||||
|
|
||||||
|
`layout.tsx:15` wraps the shell in a `RoleGuard` **with no `expected` role** — partner is deliberately not an
|
||||||
|
`AppRole`; the guard only hardens `/me` hydration.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
Chain traced: page → `useMyPartnerCenter` ([hooks/useMyPartnerCenter.ts:14](../../client/src/services/partnerCenter/hooks/useMyPartnerCenter.ts))
|
||||||
|
→ `partnerCenterApi` selector (`apis/index.ts`) → **`USE_PARTNER_MOCK = true`**
|
||||||
|
([constants.ts:10](../../client/src/services/partnerCenter/constants.ts)) → `mockApi.ts:276`
|
||||||
|
`centerById(MOCK_MY_CENTER_ID = 1)`. The real branch exists but points at routes that 404. Shapes live in
|
||||||
|
[partner-center.md](../integration/domains/partner-center.md) — not restated here.
|
||||||
|
|
||||||
|
| Call | Endpoint | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Portal: my center / nurses / bookings / booking detail / settlement | `GET /api/v1/centers/me{,/nurses,/bookings,/bookings/{id},/settlement}` | **Phantom — 5 routes.** `clientApi.ts:105-123`. Probed live: `GET /centers/me` as owner `09120000030` → **404** (no such route in swagger; only `/centers/{id}/dashboard` exists) |
|
||||||
|
| The one real portal read | `GET /api/v1/centers/{id}/dashboard` | **Wired, and it works.** Probed as `09120000030` → **200**: `«مرکز پرستاری آرامش»`, `isMerchantOfRecord true`, `settlementIbanMasked "••••7777"`, `sponsoredNurseCount 1`, `sponsoredBookingCount 2`, `invoiceCount 2`, one inline nurse (`nurseProfileId 2`). **No client code calls it** |
|
||||||
|
| Tenancy | same | Probed as customer `09120000010` → **403 "This dashboard belongs to another center."** ([GetCenterDashboardQuery.Handler.cs:26-28](../../server/src/Core/Baya.Application/Features/PartnerCenters/Queries/GetCenterDashboard/GetCenterDashboardQuery.Handler.cs)) — keyed on `partner_centers.admin_user_id`, **not** a role |
|
||||||
|
| MoR resolver (HTTP) | `GET /api/v1/internal/bookings/{bookingId}/center` | Exists ([InternalCentersController.cs:23](../../server/src/API/Baya.Web.Api/Controllers/V1/InternalCentersController.cs)) but is `DynamicPermission`. Probed as `super_admin` → **403 "Authorization Error"** — unreachable over HTTP in the demo |
|
||||||
|
| MoR resolver (in-process) | — | **This is the path that actually runs.** [`IssueInvoiceCommand.Handler.cs:54`](../../server/src/Core/Baya.Application/Features/Invoices/Commands/IssueInvoice/IssueInvoiceCommand.Handler.cs) calls `ResolveCenterForBookingAsync`, then sets `IssuingEntityType` (`:63`) and `PartnerCenterId` (`:64`) |
|
||||||
|
| Admin CRUD / verify / sponsor / set-active | `GET·POST·PATCH /api/v1/admin/partner-centers…` | 7 wired routes, all `DynamicPermission` + sensitive → **403 for every seeded admin** (see [testing-setup.md](testing-setup.md)). Client half is behind the same mock flag |
|
||||||
|
| Admin roster read | `GET /api/v1/admin/partner-centers/{id}/nurses` | **Phantom** (`clientApi.ts:101`) — the server folds nurses into the dashboard aggregate instead |
|
||||||
|
|
||||||
|
**The resolver, traced:** `booking → NurseId → NurseProfile.PartnerCenterId → PartnerCenter.IsMerchantOfRecord`
|
||||||
|
([PartnerCenterRepository.cs:77-106](../../server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/PartnerCenterRepository.cs)).
|
||||||
|
A non-MoR sponsor does **not** change the issuer — it falls through to `platform`.
|
||||||
|
|
||||||
|
**Proof it works on live data:** `GET /api/v1/invoices/6` (booking 6, nurse علی کریمی, the sponsored nurse) as
|
||||||
|
customer `09120000011` → `"issuingEntityType": "partner_center"`, gross `3,200,000`, commission `480,000`,
|
||||||
|
VAT `48,000`, total `528,000`. The same call for booking 1 (unsponsored nurse) → `"platform"`. The MoR chain
|
||||||
|
is real end-to-end **on the server**; only the portal that should surface it is fake.
|
||||||
|
|
||||||
|
## Rules that must hold
|
||||||
|
|
||||||
|
| Rule | Value / invariant | Source |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Merchant of record | `isMerchantOfRecord = true` → the **center** is the taxable seller and invoice issuer; `false` → sponsor only, platform issues | [business/13 §Decisions](../../product/business/13-tax-invoicing-and-legal.md) |
|
||||||
|
| Why the vehicle exists | Home nursing is a licensed activity (MoH پروانه تأسیس + مسئول فنی). Partnering with a licensed center is what makes online payment and BNPL legal pre-permit | [business/13](../../product/business/13-tax-invoicing-and-legal.md) |
|
||||||
|
| VAT | 10% on the **commission only**, additive (`480,000 × 0.10 = 48,000`, confirmed on invoice 6) — never on the nurse's fee | [business/13](../../product/business/13-tax-invoicing-and-legal.md) |
|
||||||
|
| Commission rate | Per-center override of the platform default; seeded center = `0.05`. Snapshotted at compute time | [integration/partner-center.md](../integration/domains/partner-center.md) |
|
||||||
|
| Settlement IBAN | Write-then-masked — the full value never comes back on any read (`Mask.IbanTail`, repository `:142`) | [integration/partner-center.md](../integration/domains/partner-center.md) |
|
||||||
|
| Technical director | `technicalDirectorNurseUserId` links to a real verified nurse, not free text — regulation requires a named مدیر فنی | [integration/partner-center.md](../integration/domains/partner-center.md) |
|
||||||
|
| `set-active` | Suspend/activate, **not** delete — sponsored nurses and past bookings stay resolvable | [integration/partner-center.md](../integration/domains/partner-center.md) |
|
||||||
|
| Portal scope | Center admin sees **no clinical content, no address, no customer money** — only patient display name, date, status | `partner/bookings/[id]/page.tsx` |
|
||||||
|
| `onboardingState` | Client-derived from `isActive` + `verifiedAt` (`deriveCenterState`) — the wire never carries the string | [integration/partner-center.md](../integration/domains/partner-center.md) |
|
||||||
|
|
||||||
|
## How to test
|
||||||
|
|
||||||
|
1. Log in as **09120000030** (بهنام رستگار, the partner-center owner) — see [testing-setup.md](testing-setup.md).
|
||||||
|
2. **Expect:** you land on the **customer** home. `/me` returns `roles: ["customer"]` only — there is no
|
||||||
|
partner signal (REQ-038), and `services/auth/routing.ts:60` returns `null` for `/partner`, so even
|
||||||
|
`?next=/fa/partner` is discarded. Nothing anywhere links to the portal.
|
||||||
|
3. Type `/fa/partner` in the address bar (locale prefix required).
|
||||||
|
4. **Expect (this is the tell):** the header reads **«مرکز پرستاری آسانگستر»**, permit `MOH-12345`, Enamad
|
||||||
|
`EN-999`, IBAN `••••0001` — the **mock fixture** (`mockApi.ts:46-59`), *not* the seeded
|
||||||
|
«مرکز پرستاری آرامش» / `MOH-C-1001` / `ENAMAD-DEMO-771` / `••••7777`. PASS for the flow = you see the mock
|
||||||
|
center. Seeing the seeded one would mean the flag flipped.
|
||||||
|
5. `/fa/partner/nurses` → three fixture nurses (زهرا موسوی، مریم رضایی، سارا کاظمی). The seeded reality is
|
||||||
|
**one** sponsored nurse, علی کریمی (09120000002).
|
||||||
|
6. `/fa/partner/bookings` → three rows with ids `5001·5002·5003`; open one. **Expect:** a detail screen that
|
||||||
|
resolves — those ids exist in no database. The seeded sponsored bookings are **6 and 5**, both `completed`.
|
||||||
|
7. `/fa/partner/settlement` → two commission invoices with a fake مودیان reference and a stub PDF. The real
|
||||||
|
center's invoices (`invoiceCount 2`) are unreachable from here.
|
||||||
|
8. **Server-side truth check** (no UI path exists): with the owner's token,
|
||||||
|
`GET /api/v1/centers/1/dashboard` → 200 with the seeded center. With a customer's token → 403.
|
||||||
|
9. **MoR check:** `GET /api/v1/invoices/6` as customer **09120000011** → `issuingEntityType: "partner_center"`;
|
||||||
|
`GET /api/v1/invoices/1` as **09120000010** → `"platform"`. This is the only place the MoR rule is
|
||||||
|
observable today.
|
||||||
|
|
||||||
|
*Seeded-world caveat:* the owner must be told center id `1` out of band — nothing in the API tells a caller
|
||||||
|
which center they administer. Any admin-side action (create / verify / suspend / sponsor) is **403** for both
|
||||||
|
demo admin accounts, so the center cannot be administered at all in this build.
|
||||||
|
|
||||||
|
## Known gaps
|
||||||
|
|
||||||
|
- `USE_PARTNER_MOCK = true` (`partnerCenter/constants.ts:10`) — all 6 portal screens render fixtures; the demo shows a center, nurses, bookings and invoices that exist in no database.
|
||||||
|
- 5 portal routes are phantom (`GET /centers/me`, `/me/nurses`, `/me/bookings`, `/me/bookings/{id}`, `/me/settlement`, `clientApi.ts:105-123`) — probed 404. REQ-032 / REQ-033 / REQ-064.
|
||||||
|
- `MOCK_MY_CENTER_ID = 1` (`constants.ts:13`, used at `mockApi.ts:276`) resolves "my center" for **any** signed-in caller — the portal has zero tenancy today, and `layout.tsx:15` passes no `expected` role.
|
||||||
|
- No partner signal in `/me` (REQ-038): `/fa/partner` is an orphan route reachable only by typing the URL, and `services/auth/routing.ts:60` makes it an invalid `?next=` target.
|
||||||
|
- Shape mismatch: the server serves **one aggregate** (`centers/{id}/dashboard`, nurses inline and capped by `DashboardNurseCap`); the portal needs `/me` + paginated splits. It cannot page an inline array.
|
||||||
|
- `GET /centers/{id}/dashboard` requires a center id the portal cannot discover, and **no client code calls it** — the one working portal endpoint is unused.
|
||||||
|
- The HTTP MoR resolver (`internal/bookings/{id}/center`) is `DynamicPermission` and 403s for both seeded admins — reachable only in-process from `IssueInvoice`.
|
||||||
|
- All 7 `admin/partner-centers` routes 403 for the seeded `super_admin`/`finance` accounts, and the admin console (`/fa/admin/partners`) is behind the same mock flag — there is **no** working path to create, verify, suspend or sponsor.
|
||||||
|
- A center owner cannot read the invoices it issues: `GetInvoiceQuery.Handler.cs:25-27` authorizes the `admin` role or the booking's customer only. The settlement view has no legal read even per booking.
|
||||||
|
- Two MoR resolvers can disagree: BNPL reads the `bnpl_merchant_of_record` config (`platform`) while invoicing uses `ResolveCenterForBookingAsync` — a MoR center's BNPL order and its invoice would name different sellers.
|
||||||
|
- Seeded invoices carry `moadianStatus: "pending"`, `moadianReferenceNumber: null`, `pdfUrl: null` — the settlement view's PDF/CSV columns have nothing real to render.
|
||||||
|
- Center self-onboarding is deferred (`product/business/01-actors-and-onboarding.md:24`); the write-then-masked IBAN flow has never been exercised on a real route.
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# Flow — patient-care-records
|
||||||
|
|
||||||
|
> Last verified: 2026-08-02 against commit `c841bde`
|
||||||
|
|
||||||
|
**Actor(s):** customer (owns the care plan) · nurse (appends visit notes) · admin (read-only) · **Status:** mocked
|
||||||
|
**Client:** mock · **Server:** real
|
||||||
|
**Business source:** none — **no `product/business/` area covers clinical records.** The only product source is
|
||||||
|
[product/data-model/10-reviews-and-records.md](../../product/data-model/10-reviews-and-records.md). This is the
|
||||||
|
atlas's largest product-doc hole.
|
||||||
|
**Integration:** [docs/integration/domains/patient-records.md](../integration/domains/patient-records.md)
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
A family keeps a **care plan** for a patient — medications, daily routine, a task checklist — that outlives any
|
||||||
|
one booking. A nurse on a confirmed booking reads that plan, ticks the checklist during a visit, and **appends**
|
||||||
|
one visit note. Nobody edits or deletes a note; the next nurse reads the whole patient history for continuity.
|
||||||
|
|
||||||
|
> ⚠ **Two endpoints one character apart.** `care_record` (singular) is the **family-owned plan**
|
||||||
|
> (`GET`/`PUT`). `care_records` (plural) is the **append-only visit history** (`GET`/`POST`). Different
|
||||||
|
> resources, different owners, different write verbs. Misreading the `s` silently targets the wrong resource.
|
||||||
|
|
||||||
|
## Screens
|
||||||
|
|
||||||
|
| Step | Route | Component / notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Customer opens the care circle | `/fa/patients` | E1 list → row links to the record |
|
||||||
|
| **E2 care record** | `/fa/patients/[id]/record` | [`record/page.tsx`](../../client/src/app/%5Blocale%5D/%28private-routes%29/%28customer%29/patients/%5Bid%5D/record/page.tsx) — 4 tabs داروها / روتین / سوابق / وظایف; per-item bottom-sheet editing; `canView === false` → non-leaking access-denied card **before** any clinical fetch |
|
||||||
|
| Nurse opens today's visit | `/fa/nurse/visits/[id]` | [`page.tsx:24`](../../client/src/app/%5Blocale%5D/%28private-routes%29/nurse/visits/%5Bid%5D/page.tsx) mounts the panel under the EVV surface |
|
||||||
|
| **E3 nurse visit note** | same route | [`NurseVisitNotesPanel.tsx`](../../client/src/app/%5Blocale%5D/%28private-routes%29/nurse/visits/%5Bid%5D/NurseVisitNotesPanel.tsx) — checklist + free-text composer + read-only history. Panel renders only when the booking is confirmed-or-beyond (`:36`); composer hides unless `canAppendNote` (`:72`) |
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
All five ops go hook → [`services/patientRecords/apis/index.ts`](../../client/src/services/patientRecords/apis/index.ts)
|
||||||
|
→ **`mockApi`**, because `USE_PATIENT_RECORDS_MOCK = true`
|
||||||
|
([`constants.ts:13`](../../client/src/services/patientRecords/constants.ts)). The table is the **real half**
|
||||||
|
([`clientApi.ts`](../../client/src/services/patientRecords/apis/clientApi.ts)) that is compiled but not selected.
|
||||||
|
Shapes: [docs/integration/domains/patient-records.md](../integration/domains/patient-records.md).
|
||||||
|
|
||||||
|
| Call | Endpoint | Live probe (2026-08-02) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `getRecordAccess` | `GET /patients/{id}/record_access` | **200** — owner `{canView:t, canEdit:t, canAppendNote:f}`; nurse 1 `{t, f, t}`; patient 9999 `{f,f,f, deniedReason:"not_found"}` |
|
||||||
|
| `getFamilyRecord` | `GET /patients/{id}/care_record` | **200** — but patient 1 returns `{medications:[],routine:[],tasks:[]}` (nothing seeded) |
|
||||||
|
| `updateFamilyRecord` | `PUT /patients/{id}/care_record` | not probed (write). Owner-only guard at [`UpsertCarePlanCommand.Handler.cs:24`](../../server/src/Core/Baya.Application/Features/PatientCareRecords/Commands/UpsertCarePlan/UpsertCarePlanCommand.Handler.cs) |
|
||||||
|
| `getPatientHistory` | `GET /patients/{id}/care_records?page&pageSize` | **200** — 2 notes for patient 1, newest-first, Persian bodies decrypted |
|
||||||
|
| `createVisitNote` | `POST /patients/{id}/care_records` | not probed (write). Nurse + qualifying-booking guard at [`WritePatientCareRecordCommand.Handler.cs:39-43`](../../server/src/Core/Baya.Application/Features/PatientCareRecords/Commands/WritePatientCareRecord/WritePatientCareRecordCommand.Handler.cs) |
|
||||||
|
|
||||||
|
Server: one controller,
|
||||||
|
[`PatientCareRecordsController.cs`](../../server/src/API/Baya.Web.Api/Controllers/V1/PatientCareRecordsController.cs),
|
||||||
|
five actions, all `[Authorize]`, all five handlers present.
|
||||||
|
|
||||||
|
**REQ-027 is delivered.** Every client comment claiming "no wire endpoint exists"
|
||||||
|
([`clientApi.ts:61-62`, `:67`, `:71`, `:87`](../../client/src/services/patientRecords/apis/clientApi.ts)),
|
||||||
|
plus [`constants.ts:6-8`](../../client/src/services/patientRecords/constants.ts) and
|
||||||
|
[`types.ts:11-13`](../../client/src/services/patientRecords/types.ts), is **stale** — contradicted by a live 200.
|
||||||
|
|
||||||
|
## Rules that must hold
|
||||||
|
|
||||||
|
| Rule | Where enforced | Verified |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Append-only.** No edit, no delete of a visit note — there is no such endpoint and none may be added | controller has only `POST` + `GET` on `care_records` | ✅ read |
|
||||||
|
| **Encrypted at rest.** Bodies leave the repo as ciphertext (`CareRecordCipherRow.BodyEncrypted`) and are decrypted only after the access check | [`GetPatientHistoryQuery.Handler.cs:56-65`](../../server/src/Core/Baya.Application/Features/PatientCareRecords/Queries/GetPatientHistory/GetPatientHistoryQuery.Handler.cs) | ✅ read |
|
||||||
|
| **Patient-scoped, not booking-scoped** — a new nurse reads the whole history | `GetPatientHistoryAsync(patientId, …)` | ✅ live (2 notes span bookings 3 and 4) |
|
||||||
|
| **One access resolver** — edit = owning customer, append = nurse with a confirmed booking, view = either or admin | [`PatientAccess.cs:24-50`](../../server/src/Core/Baya.Application/Features/PatientCareRecords/PatientAccess.cs) | ✅ live |
|
||||||
|
| `record_access` **never 403s** — always 200 with non-leaking flags (deliberate INV-7 exception) | same | ✅ live (patient 9999 → 200) |
|
||||||
|
| **Tenancy mismatch is a 404, never a 403** ([server/CLAUDE.md hard rule 20](../../server/CLAUDE.md)) | — | ❌ **VIOLATED — see gaps** |
|
||||||
|
| Nurse view never wires plan editing | [`NurseVisitNotesPanel.tsx`](../../client/src/app/%5Blocale%5D/%28private-routes%29/nurse/visits/%5Bid%5D/NurseVisitNotesPanel.tsx) imports no `useUpdateCareRecord` | ✅ read |
|
||||||
|
| Client hard rule 19 — never leak clinical data; gate access **before** any clinical fetch | `useRecordAccess` gates `enabled:` on the other two queries (`:42-43`) | ✅ read |
|
||||||
|
|
||||||
|
Note counts are not load-bearing; there are no money or config numbers in this flow.
|
||||||
|
|
||||||
|
## How to test
|
||||||
|
|
||||||
|
1. Log in as `09120000010` (سارا محمدی, customer) — see [testing-setup.md](testing-setup.md).
|
||||||
|
2. Go to `/fa/patients`, open a patient, tap پروندهٔ مراقبت (`/fa/patients/1/record`).
|
||||||
|
**Expect (today, mocked):** all four tabs populated — «متفورمین ۵۰۰», a routine list, a task list, and a
|
||||||
|
month-grouped سوابق timeline. **None of it is on the server**; it is
|
||||||
|
[`mockApi.ts:47`](../../client/src/services/patientRecords/apis/mockApi.ts) `defaultFamilyRecord`, and it
|
||||||
|
**resets on every page reload**.
|
||||||
|
3. Navigate to `/fa/patients/8888/record`.
|
||||||
|
**Expect:** the access-denied card. `8888` is `MOCK_FOREIGN_PATIENT_ID` (`constants.ts:36`) — a mock-only
|
||||||
|
sentinel with no real-path equivalent.
|
||||||
|
4. To see the **truth**, bypass the client:
|
||||||
|
`curl -s --noproxy '*' "http://localhost:5002/api/v1/patients/1/care_records?page=1&pageSize=10" -H "Authorization: Bearer $TOKEN"`.
|
||||||
|
**Expect:** `200`, `total: 2`, two Persian bodies by «زهرا عزیزی» — and `taskResults` entries with
|
||||||
|
**`label: null, done: false`** (a seed defect, see gaps).
|
||||||
|
`GET /patients/1/care_record` → `200` with **three empty arrays** — the seeded world has no care plan at all.
|
||||||
|
5. Log in as `09120000001` (زهرا عزیزی, nurse), open `/fa/nurse/visits/3`, scroll past EVV.
|
||||||
|
**Expect:** the checklist + composer + continuity history — again all mock. `GET /patients/1/record_access`
|
||||||
|
with the nurse token returns `canAppendNote: true`, so the real path would also permit the append.
|
||||||
|
|
||||||
|
**Seeded-world limits:** no care plan row exists for any patient, so the real path's medications/routine/tasks
|
||||||
|
tabs and the nurse's checklist would all render empty. Only patients 1 and (nurse 2 / customer 011's infant)
|
||||||
|
have visit notes. Creating a plan requires a real `PUT /patients/1/care_record`.
|
||||||
|
|
||||||
|
## Known gaps
|
||||||
|
|
||||||
|
- `USE_PATIENT_RECORDS_MOCK = true` (`patientRecords/constants.ts:13`) while **all five server endpoints are live** — the entire clinical surface both actors see is in-browser fiction that resets on reload.
|
||||||
|
- **Care-plan shape mismatch blocks the flip.** Server `MedicationDto(long Id, string Name, string? Dosage, string Frequency, string? TimingNote)` ([`CarePlanDtos.cs:11`](../../server/src/Core/Baya.Application/Models/Patients/CarePlanDtos.cs)) vs client `Medication { id: string; doseAmount; doseUnit; frequencyCode; frequencyText; timeOfDay: TimeOfDayCode[] }` ([`types.ts:56-66`](../../client/src/services/patientRecords/types.ts)). Only `name`/`timingNote` survive. The ui-phase-9 structured addendum was never delivered server-side.
|
||||||
|
- **H-17 ID-TYPE MISMATCH — CONFIRMED, not fixed.** Client `Medication.id` / `RoutineItem.id` / `CareTask.id` are `string` (`types.ts:57,70,78`, mock-seeded `'m1'`/`'r1'`/`'t1'`); the wire is `long Id`. `updateFamilyRecord` `PUT`s the record whole, ids included, so a flip sends `"m1"` where the server binds `long` — the write is unsafe, not just lossy.
|
||||||
|
- **`RoutineItem.timeOfDay` is an array client-side, a single `string?` server-side** (`RoutineItemDto`, `CarePlanDtos.cs:13`) — multi-slot routine items cannot round-trip.
|
||||||
|
- **`deniedReason` vocabulary disagrees.** Server emits `"not_authorized"` (`PatientAccess.cs:20`); the client union is `'no_access' | 'not_found'` (`types.ts:117`) and [the integration doc](../integration/domains/patient-records.md) documents `no_access`. A real denial would land on an unmapped code.
|
||||||
|
- **Structured `taskResults` are discarded in both directions on the real path.** The server accepts them on write (`WriteCareRecordBody.TaskResults`, controller `:63`) and returns them on read (`CareRecordDto.TaskResults`), but `clientApi.ts:37` hardcodes `taskResults: []` and `clientApi.ts:100-103` posts only `{bookingId, body}`, folding the checklist into free text via `composeVisitNoteBody`. The `:36`/`:45` comments saying the wire has no structured field are wrong.
|
||||||
|
- **Seeded visit notes return `label: null, done: false`.** `DemoLifecycleSeeder.Social.cs:323,330,337` writes camelCase JSON (`[{"label":…,"done":true}]`) while `GetPatientHistoryQuery.Handler.cs:78` deserializes `List<TaskResultDto>` with default (PascalCase) options. Live-confirmed: 3 result rows, all null/false. API-written notes round-trip fine; only the demo data is broken.
|
||||||
|
- **Tenancy leak: a foreign patient returns 403, not 404.** Live-confirmed — customer `09120000011` on `GET /patients/1/care_records` got `403 "You do not have clinical access to this patient's care records."`, confirming patient 1 exists. `GetPatientHistoryQuery.Handler.cs:56-58`, `GetCarePlanQuery.Handler.cs:23-24`, `UpsertCarePlanCommand.Handler.cs:24-25` all return `ForbiddenResult`. Violates server/CLAUDE.md hard rule 20 and INV-7.
|
||||||
|
- **No care plan is seeded for any patient** — `GET /patients/1/care_record` returns three empty arrays, so on the real path the nurse's task checklist would always be empty and the E2 plan tabs blank.
|
||||||
|
- **No `product/business/` file covers clinical records** — no owning business area for encryption, the append-only rule, or the clinical access gate. Everything above is derived from code, not from a product decision.
|
||||||
|
- **`MOCK_FOREIGN_PATIENT_ID = 8888`** (`constants.ts:36`) is the only way to demo access-denied; the real path has no such sentinel, so that screen state is untested against the server.
|
||||||
|
- Four stale doc-blocks assert "no wire endpoint exists" / "REQ-027 gap": `clientApi.ts:61-62`, `constants.ts:6-11`, `types.ts:11-16`, `apis/index.ts:7-8`. All four are wrong; the integration domain file is right. (`docs/status/mocks-registry.md`, which recorded the correct verdict, no longer exists — [docs/status/](../status/index.md) is now only an index.)
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# Flow — reviews
|
||||||
|
|
||||||
|
> Last verified: 2026-08-02 against commit `c841bde`
|
||||||
|
|
||||||
|
**Actor(s):** customer (author) · admin (moderator) · public/anonymous (reader) · **Status:** partial
|
||||||
|
**Client:** real · **Server:** real (moderation half unreachable — RBAC)
|
||||||
|
**Business source:** [product/business/11-reviews-trust-and-safety.md](../../product/business/11-reviews-trust-and-safety.md)
|
||||||
|
**Integration:** [docs/integration/domains/reviews.md](../integration/domains/reviews.md)
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
After a visit is finished, the family rates the nurse 1–5, optionally writes a note and picks trait tags. The
|
||||||
|
review is **not public on submit** — it is pre-screened, parked in `pending_moderation`, and only an admin
|
||||||
|
decision makes it visible. The nurse's public star average is derived from published reviews only, and is
|
||||||
|
**recomputed from source** on every moderation transition so hiding a bad review actually lowers the average.
|
||||||
|
|
||||||
|
## Screens
|
||||||
|
|
||||||
|
| Step | Route | Component / notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | `/fa/bookings` | Completed-and-unreviewed booking shows a compact star-strip CTA — [`BookingsScreen.tsx:16,233`](../../client/src/app/%5Blocale%5D/%28private-routes%29/%28customer%29/bookings/BookingsScreen.tsx) gates it on `useReviewEligibility` |
|
||||||
|
| 2 | `/fa/bookings/[id]` | CTA «ثبت نظر» / passive «در حال بررسی» — [`[id]/page.tsx:52`](../../client/src/app/%5Blocale%5D/%28private-routes%29/%28customer%29/bookings/%5Bid%5D/page.tsx), `useMyReviewForBooking` fires only when `completed`/`closed` |
|
||||||
|
| 3 | `/fa/bookings/[id]/review` | The form: `RatingInput` + body (2000 chars) + `ReviewTagSelector`; moderation notice shown **before** submit ([`review/page.tsx:169-174`](../../client/src/app/%5Blocale%5D/%28private-routes%29/%28customer%29/bookings/%5Bid%5D/review/page.tsx)). Already-reviewed → a read-only state card, never a second form (`:93`) |
|
||||||
|
| 4 | `/fa/search/nurse/[nurseId]` | «نظرات» tab — aggregate + infinite published list ([`page.tsx:288`](../../client/src/app/%5Blocale%5D/%28private-routes%29/%28customer%29/search/nurse/%5BnurseId%5D/page.tsx)) |
|
||||||
|
| 5 | `/fa/admin/reviews` | Moderation queue + publish/hide/reject/unpublish dialog ([`admin/reviews/page.tsx:61-62`](../../client/src/app/%5Blocale%5D/%28private-routes%29/admin/reviews/page.tsx)) — **renders an error state today, see Known gaps** |
|
||||||
|
| — | *(none)* | **The nurse has no screen to read their own reviews.** No route consumes `useNurseReviews` outside step 4 |
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
Client seam: [`services/reviews/apis/index.ts:11`](../../client/src/services/reviews/apis/index.ts) selects
|
||||||
|
`reviewsClientApi` because `USE_REVIEWS_MOCK = false`
|
||||||
|
([`constants.ts:14`](../../client/src/services/reviews/constants.ts)). Shapes:
|
||||||
|
[docs/integration/domains/reviews.md](../integration/domains/reviews.md).
|
||||||
|
|
||||||
|
| Call | Endpoint | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `getReviewEligibility` [`clientApi.ts:67`](../../client/src/services/reviews/apis/clientApi.ts) | `GET /bookings/{id}/review_eligibility` | `[Authorize]` [`BookingReviewsController.cs:33`](../../server/src/API/Baya.Web.Api/Controllers/V1/BookingReviewsController.cs) → [`GetReviewEligibilityQuery.Handler.cs:24-33`](../../server/src/Core/Baya.Application/Features/Reviews/Queries/GetReviewEligibility/GetReviewEligibilityQuery.Handler.cs). **Probed 200** |
|
||||||
|
| `getMyReviewForBooking` `clientApi.ts:78` | `GET /bookings/{id}/my_review` | `BookingReviewsController.cs:39`. **Probed 200** |
|
||||||
|
| `createReview` `clientApi.ts:91` | `POST /bookings/{id}/review` | `BookingReviewsController.cs:26` → [`SubmitReviewCommand.Handler.cs`](../../server/src/Core/Baya.Application/Features/Reviews/Commands/SubmitReview/SubmitReviewCommand.Handler.cs). Not probed (would mutate the demo world) |
|
||||||
|
| `getNurseReviews` `clientApi.ts:54` | `GET /nurses/{id}/reviews` | **anonymous** — [`NursesController.cs:21,38`](../../server/src/API/Baya.Web.Api/Controllers/V1/NursesController.cs). **Probed 200** |
|
||||||
|
| `listModerationQueue` `clientApi.ts:99` | `GET /admin/reviews/moderation_queue` | [`AdminReviewsController.cs:20`](../../server/src/API/Baya.Web.Api/Controllers/V1/AdminReviewsController.cs) `DynamicPermission`. **Probed HTTP 403** |
|
||||||
|
| `moderateReview` `clientApi.ts:113` | `PATCH /reviews/{id}/status` | [`ReviewsController.cs:32-33`](../../server/src/API/Baya.Web.Api/Controllers/V1/ReviewsController.cs) `DynamicPermission`. **Probed HTTP 403** (authz fires before the handler) |
|
||||||
|
| *(no client method)* | `GET /nurses/{id}/review_tags` | anonymous, `NursesController.cs:44`. **Probed 200** — returns the code/labelFa/labelEn/count/percentage rollup. **Unwired** |
|
||||||
|
| *(no client method)* | `POST /reviews/{id}/tags` | `ReviewsController.cs:27`, plain `[Authorize]`. Probed with a customer token: **404 "Review not found"** — authz passes, no UI calls it |
|
||||||
|
|
||||||
|
## Rules that must hold
|
||||||
|
|
||||||
|
| Rule | Value | Source of truth |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Rating range | `1`–`5` | DB CHECK `CK_Reviews_Rating` (**HARDCODED**) · [business/11](../../product/business/11-reviews-trust-and-safety.md) |
|
||||||
|
| One review per booking | `UNIQUE(booking_id)` on `reviews.Reviews` | `ReviewConfig.cs` (**HARDCODED DB**); handler pre-checks and returns `409` (`SubmitReviewCommand.Handler.cs:57`) |
|
||||||
|
| Reviewable only when finished | booking status `completed` or `closed` | `SubmitReviewCommand.Handler.cs:54`; eligibility is **server-decided**, the client never infers it |
|
||||||
|
| Low-rating alert threshold | `≤ 2` → `SupportAlertType.LowRating` | CONFIG key `min_rating_for_support_alert`; `SubmitReviewCommand.Handler.cs:112-119` |
|
||||||
|
| Not public on submit | clean text → `pending_moderation` (a human-review *flag*) unless `Seams:ReviewModeration:AutoApproveClean` | [`MockReviewModerationService.cs:28`](../../server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockReviewModerationService.cs); the key is **absent from `appsettings*.json`** so the default `false` holds |
|
||||||
|
| Only `published` is public and counted | hidden/rejected/pending excluded from both the list and the aggregate | `ReviewModerationStatus.cs:12-22`; **verified live** — nurse 2 holds a seeded hidden 1★ and returns `averageRating 0.00 / publishedCount 0` |
|
||||||
|
| **Aggregate recomputed from source, never incremented** | `COUNT`/`SUM(rating)` over the nurse's published reviews *excluding* the transitioning row, then the row's **new** status folded in; average rounded to 2 dp away-from-zero | [`RecomputeNurseRating.cs:33-43`](../../server/src/Core/Baya.Application/Features/Reviews/RecomputeNurseRating.cs) — **verified in the handler, no `+= 1` path exists.** Called by `ModerateReviewCommand.Handler.cs:45` on **every** transition and by an auto-published/auto-hidden submit (`SubmitReviewCommand.Handler.cs:101`) |
|
||||||
|
| Recompute is atomic with the transition | status change + aggregate + b7 search-index reindex staged on one unit of work, one `CommitAsync` | `ModerateReviewCommand.Handler.cs:42-47`; `RecomputeNurseRating.cs:46` |
|
||||||
|
| Public row carries no author | probed payload is `{id, rating, body, tagCodes, createdAt}` — **no author field at all**, stronger than the masking REQ-026 describes | live probe of `GET /nurses/1/reviews` |
|
||||||
|
| Tag codes are codes | labels are i18n keys; never render a raw code | `review/page.tsx:123,202` fall back to the code only when the key is missing |
|
||||||
|
|
||||||
|
## How to test
|
||||||
|
|
||||||
|
1. Log in as **09120000010** (customer Mohammadi) — see [testing-setup.md](testing-setup.md).
|
||||||
|
2. Open `/fa/bookings`. **Expect:** bookings 1, 2, 3, 4, 7, 8, 10 listed; only **booking 8** shows the
|
||||||
|
star-strip «ثبت نظر» CTA (probed: `booking 8 → canReview: true`; bookings 3 and 4 →
|
||||||
|
`canReview: false, reason: "already_reviewed"`).
|
||||||
|
3. Open `/fa/bookings/4`. **Expect:** the passive CTA reads «مشاهدهٔ نظر شما», and `/fa/bookings/4/review`
|
||||||
|
renders the read-only card — 5 stars, the Persian body, chips «وقتشناس» + «حرفهای», status chip
|
||||||
|
«منتشرشده» (`published`). No form.
|
||||||
|
4. Open `/fa/bookings/8/review`. **Expect:** the form, with the blue moderation notice above it. Submit 4
|
||||||
|
stars + any body. **Expect:** the page flips to the state card with status `pending_moderation`
|
||||||
|
(«در انتظار بررسی»). *This is the only genuinely writable review path left in the seeded world.*
|
||||||
|
5. Open `/fa/search/nurse/1` → «نظرات» tab. **Expect:** average **۵٫۰** and **۱** review — the single
|
||||||
|
published 5★. The review you just wrote in step 4 must **not** appear.
|
||||||
|
6. Anonymous check (no login): `curl -s --noproxy '*' http://localhost:5002/api/v1/nurses/2/reviews`.
|
||||||
|
**Expect:** `averageRating 0.00, publishedCount 0` — nurse 2 carries a seeded **hidden** 1★ and it is
|
||||||
|
correctly excluded from both the list and the aggregate. This is the from-source recompute proof.
|
||||||
|
7. Log in as **09120000020** (super_admin) and open `/fa/admin/reviews`. **Expect (today):** an error state,
|
||||||
|
not a queue — the endpoint returns **403**. See Known gaps.
|
||||||
|
|
||||||
|
**Seeded-world caveat:** three reviews exist — booking 4 `published` 5★ (nurse 1), booking 3
|
||||||
|
`pending_moderation` 4★ (nurse 1), one `hidden` 1★ (nurse 2)
|
||||||
|
([`DemoLifecycleSeeder.Social.cs:29-85`](../../server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Seeding/DemoLifecycleSeeder.Social.cs)).
|
||||||
|
Because moderation 403s, **no review submitted during testing can ever become public** — the publish half of
|
||||||
|
the loop is not demonstrable end to end.
|
||||||
|
|
||||||
|
## Known gaps
|
||||||
|
|
||||||
|
- `GET /admin/reviews/moderation_queue` returns **403** for the seeded `super_admin` (09120000020) and
|
||||||
|
`finance` (09120000021) — `DynamicPermission` grants on the literal role `admin`; no seeded account holds
|
||||||
|
it. `/fa/admin/reviews` is therefore a dead screen. (`AdminReviewsController.cs:20`)
|
||||||
|
- `PATCH /reviews/{reviewId}/status` returns **403** for the same reason (`ReviewsController.cs:33`, probed
|
||||||
|
against a non-existent id so authz is provably the blocker). **No review can leave `pending_moderation`**,
|
||||||
|
so the submit→publish loop is untestable and every new review is permanently invisible.
|
||||||
|
- With `AutoApproveClean` absent from config and moderation 403, `published` is only reachable via the
|
||||||
|
seeder. A demo customer's review never reaches the nurse's profile.
|
||||||
|
- `ReviewModerationStatus.Rejected` is unreachable end to end: the submit path maps a banned-word
|
||||||
|
`ModerationDecision.Reject` to **`Hidden`**, not `Rejected` (`SubmitReviewCommand.Handler.cs:77`), and the
|
||||||
|
only other producer is the 403'd admin PATCH. The client still maps a `rejected` chip
|
||||||
|
(`review/page.tsx:48`).
|
||||||
|
- `GET /nurses/{id}/review_tags` is **unwired** — the "% of reviewers said X" rollup is served by the API
|
||||||
|
(probed) but no client method exists in `clientApi.ts`; the profile builds chips from per-review `tagCodes`
|
||||||
|
instead.
|
||||||
|
- `POST /reviews/{reviewId}/tags` is **unwired** — no screen lets an author amend tags after submit.
|
||||||
|
- The client hardcodes the five tag codes in
|
||||||
|
[`services/reviews/types.ts:36`](../../client/src/services/reviews/types.ts) instead of reading the server's
|
||||||
|
`review_tags` master. A newly seeded tag is invisible to the UI, and an unseeded code makes submit fail with
|
||||||
|
`Unknown review tag(s)` (`SubmitReviewCommand.Handler.cs:67`).
|
||||||
|
- **The nurse cannot see their own reviews.** No nurse route consumes any reviews hook — a nurse learns of a
|
||||||
|
1★ only through the notification the moderation handler dispatches (`ModerateReviewCommand.Handler.cs:54`),
|
||||||
|
which itself cannot fire while moderation 403s.
|
||||||
|
- Eligibility distinguishes `not_found` from `not_owner` with a `200`
|
||||||
|
(`GetReviewEligibilityQuery.Handler.cs:24-27`), letting any authenticated caller probe whether an arbitrary
|
||||||
|
booking id exists. Contradicts the repo's 404-not-403 tenancy invariant (INV-7).
|
||||||
|
- The low-rating support alert is raised on submit (rating ≤ 2) but has **no reachable triage surface** —
|
||||||
|
admin support-alert reads are behind the same 403.
|
||||||
|
- Stale doc comments assert the opposite of the shipped config: `constants.ts:5-12` says "**Mock is primary
|
||||||
|
this phase**" and `clientApi.ts:50` says "`USE_REVIEWS_MOCK = true`", while the flag is `false`.
|
||||||
|
`services/reviews/apis/mockApi.ts` (14.6 KB) is now dead behind that flag.
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# Flow — search and discovery
|
||||||
|
|
||||||
|
> Last verified: 2026-08-02 against commit `c841bde`
|
||||||
|
|
||||||
|
**Actor(s):** customer (guest is aspirational — see gaps) · **Status:** partial
|
||||||
|
**Client:** partial · **Server:** real
|
||||||
|
**Business source:** [product/business/04-search-and-matching.md](../../product/business/04-search-and-matching.md)
|
||||||
|
**Integration:** [docs/integration/domains/search.md](../integration/domains/search.md)
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
A family picks a care category, a city (district optional), an optional same-gender preference, a date
|
||||||
|
intent and a Toman price range, then browses rating-sorted nurses and opens one nurse's trust dossier
|
||||||
|
before requesting a booking. Only verified, currently-accepting nurses are ever shown. Discovery is
|
||||||
|
**filter-only** — there is no free-text search.
|
||||||
|
|
||||||
|
## Screens
|
||||||
|
|
||||||
|
| Step | Route | Component / notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| C1 «یافتن پرستار» | `/fa/search` | [`SearchScreen.tsx`](../../client/src/app/%5Blocale%5D/(private-routes)/(customer)/search/SearchScreen.tsx) + [`useSearchFilters.ts`](../../client/src/app/%5Blocale%5D/(private-routes)/(customer)/search/useSearchFilters.ts). `CategoryTile` grid (catalog) · `CascadingRegionSelect` (district blank = whole city) · `GenderToggle allowAny` · `JalaliDateIntentPicker` · Toman price fields (400 ms debounce, `tomanToRial` at the field boundary) · `StickyActionBar` showing the **live result count**. |
|
||||||
|
| C2 «نتایج جستجو» | `/fa/search/results` | [`results/page.tsx`](../../client/src/app/%5Blocale%5D/(private-routes)/(customer)/search/results/page.tsx). Filter set lives in the URL (deep-linkable cache key); tappable recap chips carry the *whole* query string back to C1. `NurseResultCard` grid, load-more grows `pageSize`, `keepPreviousData` so the list never flashes empty. |
|
||||||
|
| C3 trust dossier | `/fa/search/nurse/[nurseId]` | [`nurse/[nurseId]/page.tsx`](../../client/src/app/%5Blocale%5D/(private-routes)/(customer)/search/nurse/%5BnurseId%5D/page.tsx). Identity header · tappable `TrustBadge` → `VerificationPanel` dialog · attribute chips · tabs «خدمات» / «نظرات» (published reviews only) · sticky «درخواست رزرو» → `/fa/bookings/request`. |
|
||||||
|
|
||||||
|
`sort_static` renders «مرتبشده بر اساس امتیاز» as a caption, not a dropdown — rating is the only sort.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| Call | Endpoint | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| C1 live count + C2 list | `GET /api/v1/search/nurses` | anonymous · **snake_case params, `page`/`page_size`** |
|
||||||
|
| C3 profile | `GET /api/v1/nurses/{id}/profile` | anonymous · one request builds the whole screen |
|
||||||
|
| C3 reviews tab | `GET /api/v1/nurses/{id}/reviews` | anonymous — [reviews.md](../integration/domains/reviews.md) |
|
||||||
|
| C2/C3 trust badge | `GET /api/v1/nurses/{id}/trust_badge` | anonymous, **exists, and is not reached** — see gaps |
|
||||||
|
|
||||||
|
Shapes live in [search.md](../integration/domains/search.md); do not duplicate them here.
|
||||||
|
|
||||||
|
Chain traced: `useNurseSearch` ([`hooks/useNurseSearch.ts:17`](../../client/src/services/search/hooks/useNurseSearch.ts)) →
|
||||||
|
`searchApi` ([`apis/index.ts:10`](../../client/src/services/search/apis/index.ts), `USE_SEARCH_MOCK = false` at
|
||||||
|
[`constants.ts:9`](../../client/src/services/search/constants.ts)) →
|
||||||
|
[`clientApi.ts:73-114`](../../client/src/services/search/apis/clientApi.ts) →
|
||||||
|
[`SearchController.cs:26`](../../server/src/API/Baya.Web.Api/Controllers/V1/SearchController.cs) →
|
||||||
|
`SearchNursesQuery` → [`SqlNurseSearch.cs:20`](../../server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Search/SqlNurseSearch.cs).
|
||||||
|
Every link exists. C3: `useNurseProfile` → `clientApi.ts:116` → [`NursesController.cs:34`](../../server/src/API/Baya.Web.Api/Controllers/V1/NursesController.cs) → `NurseProfileRepository.GetPublicProfileAsync:66`.
|
||||||
|
|
||||||
|
## Rules that must hold
|
||||||
|
|
||||||
|
| Rule | Where it is enforced |
|
||||||
|
| --- | --- |
|
||||||
|
| **`is_searchable` is one gate over four conditions** — verified **and** accepting bookings **and** not suspended **and** the variant is active. Deactivated rows are kept with `is_searchable = 0`, never deleted (INV-17). | [`SearchIndexMaintainer.cs:177,248`](../../server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Search/SearchIndexMaintainer.cs) · read gate at `SqlNurseSearch.cs:24` |
|
||||||
|
| **`districtId = NULL` means whole city, on both sides** (INV-3). A district search matches that district's rows **plus** the NULL rows; a city-only search matches everything in the city. | `SqlNurseSearch.cs:30-31`; supply side in [geography](../integration/domains/geography.md) |
|
||||||
|
| The index is maintained **inline in each source write's transaction**, not by a job — a publish is visible in search immediately. | `ISearchIndexMaintainer` |
|
||||||
|
| Price is IRR Rials, integer. Toman exists only in the C1 input fields. | `NurseSearchIndex.Price` (`long`); `useSearchFilters.ts:16-24` |
|
||||||
|
| `nurse_gender` accepts only `male`/`female`; **omit it for "any"** — there is no `any` value. | [`SearchNursesQuery.Validator.cs:15-18`](../../server/src/Core/Baya.Application/Features/Search/Queries/SearchNurses/SearchNursesQuery.Validator.cs) |
|
||||||
|
| Date is **intent only** at MVP — carried into the booking request, never used to filter results. | `SearchScreen.tsx:29-31` |
|
||||||
|
|
||||||
|
## How to test
|
||||||
|
|
||||||
|
Log in as `09120000010` (سارا محمدی, customer) — see [testing-setup.md](testing-setup.md). No token is
|
||||||
|
needed for the API calls themselves; the *screens* are behind the customer role guard.
|
||||||
|
|
||||||
|
1. Open `http://localhost:3000/fa/search`. Pick «مراقبت از سالمند» (category 1) and city تهران.
|
||||||
|
**Expect:** the sticky CTA reads «مشاهدهٔ ۹ پرستار».
|
||||||
|
2. Tap it. **Expect:** `/fa/search/results?...` with the header «۹ پرستار» — but only **one** distinct
|
||||||
|
nurse, زهرا عزیزی, repeated nine times (see the first gap). Her avatar is a broken image.
|
||||||
|
3. Tap the ✓ تاییدشده chip on any card. **Expect:** a panel listing «پروانه صلاحیت» *and* «نظام پرستاری».
|
||||||
|
That is the **mock** — the real badge for nurse 1 is `criminal_record` + `moh_competency_license`.
|
||||||
|
4. Open a card. **Expect:** C3 with 5 services, rating ۵٫۰, one published review, and no distance chip.
|
||||||
|
5. Verify the whole-city invariant without the browser:
|
||||||
|
```bash
|
||||||
|
curl -s --noproxy '*' "http://localhost:5002/api/v1/search/nurses?service_category_id=1&city_id=101&district_id=1006&page=1&page_size=50"
|
||||||
|
```
|
||||||
|
**Expect:** `total: 3` — nurse 1 names districts 1001/1003 only, so these are her three whole-city
|
||||||
|
(`districtId: null`) rows surfacing in a district she never enumerated. PASS is `districtId: null` on
|
||||||
|
every returned row.
|
||||||
|
6. Negative control — the unverified nurse must never appear:
|
||||||
|
`?service_category_id=3&city_id=101` → **`total: 0`** (nurse 3, مریم احمدی, is the only category-3
|
||||||
|
supplier and is `in_review`).
|
||||||
|
|
||||||
|
The seeded world supports every step above; unlike the booking flows it is **not** damaged by the
|
||||||
|
2026-07-26 staleness, because the index is derived, not time-anchored.
|
||||||
|
|
||||||
|
## Known gaps
|
||||||
|
|
||||||
|
- **The result list is index rows, not nurses.** The index is one row per (variant × area), and neither
|
||||||
|
`SqlNurseSearch` nor the client de-duplicates. Live: `category=1&city=101` → `total: 9` for **one**
|
||||||
|
nurse (3 variants × 3 areas). The copy says «۹ پرستار» ("9 nurses", `messages/fa.json` `search.results_count`
|
||||||
|
/ `cta_view_results`), so C1's headline count and C2's header both lie to the customer.
|
||||||
|
- **Duplicate React keys in C2.** `results/page.tsx:151` keys on `` `${nurseId}-${variantId}` ``, which is
|
||||||
|
not unique once the same variant appears under several areas — the nine live rows collapse to three
|
||||||
|
distinct keys.
|
||||||
|
- **C2 and C3's trust dossier is mocked.** `TrustBadge`/`VerificationPanel` call `useNurseTrustBadge` →
|
||||||
|
`verificationApi`, and `USE_VERIFICATION_MOCK = true` (`verification/constants.ts:9`). The mock returns
|
||||||
|
the same fabricated `['moh_competency_license','ino_membership']` for **every** nurse id. The real
|
||||||
|
anonymous `GET /nurses/{id}/trust_badge` works (probed: nurse 1 → `criminal_record`,
|
||||||
|
`moh_competency_license`, `approvedAt` 2026-07-26) and is simply not reached.
|
||||||
|
- **C3 asserts "verified" for an unverified nurse.** `nurse/[nurseId]/page.tsx:173` renders
|
||||||
|
`<TrustBadge state="verified">` unconditionally and never reads `profile.isVerified`. `GET /nurses/3/profile`
|
||||||
|
returns `200` anonymously with `isVerified: false`, full name, bio and price list — so a deep link to
|
||||||
|
`/fa/search/nurse/3` shows «✓ تاییدشده» for a nurse who is `in_review`. Related to the still-open REQ-067
|
||||||
|
privacy review; the badge half is a defect regardless.
|
||||||
|
- **`isVerified: true` is fabricated client-side** at `search/apis/clientApi.ts:101`. Justified by the index
|
||||||
|
invariant, but it is a trust signal the client asserts rather than one the server served.
|
||||||
|
- **Avatars are unusable URLs.** `LocalDiskObjectStorage` yields `file:///C:/Users/.../avatars/nurse/1/….png`
|
||||||
|
(probed on `/nurses/1/profile`), which no browser will load. Every seeded nurse photo renders as the
|
||||||
|
initials fallback in dev, and the same code path signs the C2 card image.
|
||||||
|
- **`distanceKm` is always `null`** — `SqlNurseSearch.cs:62-67` hardcodes it because the covering index
|
||||||
|
carries no coordinate. The distance chip on `NurseResultCard.tsx:46` can never render.
|
||||||
|
- **`topReviewTag` is never served** (REQ-040), so `clientApi.ts:111` always maps `null` and the card's tag
|
||||||
|
chip (`NurseResultCard.tsx:128`) is dead code today.
|
||||||
|
- **`nurseGender: 'female'` is hardcoded** on the C3 profile mapping (`clientApi.ts:154`) because
|
||||||
|
`NursePublicProfileDto` carries no gender (REQ-042). The page deliberately omits the chip, so the
|
||||||
|
fabricated value is unused — but it is a live lie in the typed model.
|
||||||
|
- **No free-text search** (REQ-041). A customer who knows a nurse's name cannot find her.
|
||||||
|
- **Guests cannot reach any of it.** All three routes sit in `(private-routes)/(customer)` behind
|
||||||
|
`RoleGuard expected="customer"`, and `PUBLIC_PATHS` does not include `/search`, so the middleware
|
||||||
|
redirects to `/fa/login`. The endpoints are anonymous; the screens are not (REQ-066).
|
||||||
|
- **`search/nurses` carries no `[EnableRateLimiting]`** — the one deliberately pre-auth read falls back to
|
||||||
|
the 100/min global per-IP limiter (REQ-066, narrower than filed).
|
||||||
|
- **Pagination footgun.** This is the only endpoint declaring `page_size` (snake). Binding is
|
||||||
|
case-insensitive but not separator-insensitive, so `pageSize=2` binds nothing and silently yields
|
||||||
|
`Pagination.DefaultPageSize` — which is **50**, not the 20 the client's `SEARCH_PAGE_SIZE` assumes.
|
||||||
|
Probed: `?…&pageSize=2` → `pageSize: 50`. `searchClientApi` sends `page_size` correctly; anything else
|
||||||
|
hitting this route will not.
|
||||||
|
- **`attributeChips` is empty for every seeded nurse** (probed on nurses 1 and 3), and
|
||||||
|
`latestReview.authorMasked` is `null`, which the client maps to `''` — the C3 snippet renders a bare
|
||||||
|
«` · <date>`».
|
||||||
@@ -0,0 +1,519 @@
|
|||||||
|
# Testing setup — boot the stack, get a code, log in
|
||||||
|
|
||||||
|
Everything you need to run Balinyaar locally and walk any flow in [index.md](index.md). **Executed, not
|
||||||
|
transcribed:** every command, URL, status code and console line below was run against this repo on the date
|
||||||
|
in the stamp. Where a predecessor doc says something different, this file says so and says which is right.
|
||||||
|
|
||||||
|
> Last verified: 2026-08-02 against commit `c841bde`, on Windows 11 / .NET SDK 10.0.300-preview / Node 24.11.1.
|
||||||
|
|
||||||
|
**Predecessors, and their standing:** [RUNBOOK.md](../../dev/post-phase/refinement/RUNBOOK.md) and
|
||||||
|
[manual-testing-plan.md](../../dev/post-phase/manual-testing-plan.md) are **superseded by this file**. Both
|
||||||
|
contain instructions that no longer work — see [What the old docs get wrong](#what-the-old-docs-get-wrong).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The five-minute path
|
||||||
|
|
||||||
|
Three terminals. The third one is not optional — see [Getting an OTP](#getting-an-otp).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1 — API. Plain HTTP on :5002. Needs the SMS provider overridden, or login 500s.
|
||||||
|
cd server
|
||||||
|
Seams__Sms__Provider=mock dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj
|
||||||
|
# PowerShell: $env:Seams__Sms__Provider="mock"; dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj
|
||||||
|
|
||||||
|
# 2 — client
|
||||||
|
cd client && npm install && npm run dev # http://localhost:3000/fa
|
||||||
|
|
||||||
|
# 3 — read the OTP (the console does NOT print the code)
|
||||||
|
curl http://localhost:5002/api/v1/dev/last_otp/09120000010
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open **`http://localhost:3000/fa/login`**, enter `09120000010`, and paste the code from terminal 3.
|
||||||
|
|
||||||
|
No database setup step. The committed dev config already points at a seeded remote SQL Server.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
| | Verified working | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| .NET SDK | `10.0.300-preview.0.26177.108` | `NETSDK1057` (preview SDK) is an expected warning |
|
||||||
|
| Node | `24.11.1` / npm `11.6.2` | README says 18+; 24 is fine |
|
||||||
|
| SQL Server | none locally | the dev config uses a **remote** instance — nothing to install |
|
||||||
|
| Docker | **not required** | only for the optional local-DB path, which is *unverified* here |
|
||||||
|
|
||||||
|
`dotnet build Baya.sln` completes with **0 errors, 95 warnings** on a clean clone. The warnings are
|
||||||
|
pre-existing (`NU1903` vulnerability advisories on `Microsoft.OpenApi` / `SQLitePCLRaw`, `NU1510`,
|
||||||
|
`NETSDK1057`). They are not yours; don't "fix" them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration — where it lives
|
||||||
|
|
||||||
|
**`dotnet user-secrets` is not used and is not read.** The `<UserSecretsId>` was removed from
|
||||||
|
`Baya.Web.Api.csproj` in `5885280`. Any instruction to `dotnet user-secrets set …` is dead — the command
|
||||||
|
will error with "could not find UserSecretsId", and even a leftover `secrets.json` on your machine is inert.
|
||||||
|
|
||||||
|
| What | Where | Read when |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Server config + keys | [`server/src/API/Baya.Web.Api/appsettings.Development.json`](../../server/src/API/Baya.Web.Api/appsettings.Development.json) | process start |
|
||||||
|
| Server base file | `appsettings.json` — placeholders only, guarded by `StartupSecretsGuard` | process start |
|
||||||
|
| Client dev config | [`client/.env.development`](../../client/.env.development) | `next dev` |
|
||||||
|
| Client prod config | `client/.env.production` | **`next build`** — inlined into the bundle |
|
||||||
|
| Deployment overrides | [`docker-compose.yml`](../../docker-compose.yml) (`Seams__…` env vars) | container start |
|
||||||
|
|
||||||
|
Any server key can be overridden by an environment variable using `__` for `:` —
|
||||||
|
`Seams__Sms__Provider`, `ConnectionStrings__SqlServer`. That is how the five-minute path avoids editing a
|
||||||
|
committed file. Full matrix: [docs/integration/config-matrix.md](../integration/config-matrix.md).
|
||||||
|
|
||||||
|
### The four crypto values, and why they are load-bearing
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"IdentitySettings": { "SecretKey": …, "Encryptkey": … } // signs + encrypts the JWE access token
|
||||||
|
"Seams": { "FieldEncryption": { "Key": …, "HashKey": … } } // decrypts PII; derives users.PhoneHash
|
||||||
|
```
|
||||||
|
|
||||||
|
`Seams:FieldEncryption:Key` and `:HashKey` **must match whatever the target database was encrypted under.**
|
||||||
|
Every phone, address, IBAN and clinical note in `Baya` was written with the committed values. Boot against
|
||||||
|
that database with different ones and you get *silent* failure first — every phone lookup misses, so every
|
||||||
|
login says "no such account" — then `Padding is invalid and cannot be removed` on the first PII read. The
|
||||||
|
appsettings file carries a `"//"` comment saying exactly this. Do not rotate them.
|
||||||
|
|
||||||
|
`IdentitySettings` may be changed freely; it only invalidates tokens already issued.
|
||||||
|
|
||||||
|
> **The repo contains live credentials on purpose** — a pre-launch trade for a demo deployment. Rotating them
|
||||||
|
> is a "Going to Production" step in [DEPLOY.md](../../DEPLOY.md), not a local setup step.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Which database
|
||||||
|
|
||||||
|
**The committed dev config points at a remote SQL Server: `87.107.152.16,1433` → `Baya` (+ `Baya_Logs`).**
|
||||||
|
It is the same instance the deployed demo uses, it is **already migrated and already seeded**, and it is
|
||||||
|
shared — your writes are visible to everyone else pointed at it.
|
||||||
|
|
||||||
|
Verified reachable (`Test-NetConnection … -Port 1433` → `TcpTestSucceeded: True`) and the API's
|
||||||
|
`sql-app` health check reports `Healthy`.
|
||||||
|
|
||||||
|
### The local alternative — **UNVERIFIED**
|
||||||
|
|
||||||
|
[`server/docker-compose.yml`](../../server/docker-compose.yml) provisions SQL Server 2022 on
|
||||||
|
`localhost:1433` with the dev-only SA password `Balinyaar_Dev1433`. Point the API at it with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export ConnectionStrings__SqlServer="Server=localhost,1433;Database=Baya;User Id=sa;Password=Balinyaar_Dev1433;TrustServerCertificate=True;Encrypt=False;"
|
||||||
|
```
|
||||||
|
|
||||||
|
The API self-migrates and self-seeds on boot, so an empty instance is enough. **This path was not executed
|
||||||
|
for this stamp** — Docker is not installed on the verification machine. Treat the remote path as the tested
|
||||||
|
one and this as documented-but-unproven.
|
||||||
|
|
||||||
|
Two things that are true either way: the local DB starts empty, so you get a *freshly dated* demo world
|
||||||
|
(see [staleness](#the-seeded-world-and-how-stale-it-is)); and it is encrypted under whatever
|
||||||
|
`Seams:FieldEncryption` values you boot with, so it is not interchangeable with the remote one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Boot
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
Seams__Sms__Provider=mock dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
**`http://localhost:5002` — plain HTTP.** `launchSettings.json` binds no TLS. There is no
|
||||||
|
`https://localhost:5002`, and `dotnet dev-certs https --trust` has nothing to trust; skip it. Swagger is at
|
||||||
|
`http://localhost:5002/swagger`, the OpenAPI doc at `/swagger/v1/swagger.json` (178 paths).
|
||||||
|
|
||||||
|
A healthy boot logs, in order:
|
||||||
|
|
||||||
|
```
|
||||||
|
[INF] Demo world already seeded — no-op. Search index re-derived: 3 nurses, 27 rows.
|
||||||
|
[INF] Demo lifecycle already seeded — no-op. Search index re-derived: 3 nurses, 27 rows.
|
||||||
|
[INF] Recurring job scheduler starting with 7 job(s): booking_request_expiry, notification_retention,
|
||||||
|
verification_expiry_scan, no_show_sweep, weekly_payout_generation, moadian_reconciliation,
|
||||||
|
audit_log_retention
|
||||||
|
[INF] مودیان reconciliation scanned 8 invoice(s); 0 reached registered
|
||||||
|
```
|
||||||
|
|
||||||
|
Noise you can ignore: ~11 `WRN Entity 'X' has a global query filter …` lines, and two
|
||||||
|
`WRN HTTP/2 is not enabled for 127.0.0.1:5002` lines (expected — HTTP/2 needs TLS; HTTP/1.1 is used).
|
||||||
|
|
||||||
|
### Health
|
||||||
|
|
||||||
|
| Endpoint | Expected | Actual |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GET /healthz/live` | `200 Healthy` | ✅ `200` |
|
||||||
|
| `GET /healthz/ready` | `200 Healthy` | ❌ **`503 Unhealthy` on Windows** |
|
||||||
|
|
||||||
|
`/healthz/ready` fails on the `object-storage` probe, not the database:
|
||||||
|
|
||||||
|
```
|
||||||
|
IOException: The process cannot access the file
|
||||||
|
'C:\Users\<you>\AppData\Local\Temp\balinyaar-object-storage\healthz\object-storage-probe'
|
||||||
|
because it is being used by another process.
|
||||||
|
at LocalDiskObjectStorage.DeleteAsync … at ObjectStorageWriteHealthCheck…
|
||||||
|
```
|
||||||
|
|
||||||
|
`sql-app` reports `Healthy` in the same response. It is a real code defect, not configuration:
|
||||||
|
`ObjectStorageWriteHealthCheck.cs:29` opens the probe blob with `await using var` and line 33 deletes it
|
||||||
|
**while the `FileStream` handle is still open**. POSIX `unlink` permits that, so the Linux container passes;
|
||||||
|
Windows `File.Delete` throws. **Do not read a red `/healthz/ready` as "the app is broken"** — check the
|
||||||
|
`entries` object. Phase 4 backlog material.
|
||||||
|
|
||||||
|
`Seams:ObjectStorage:RootPath` is `""` in dev, which resolves to `%TEMP%\balinyaar-object-storage`
|
||||||
|
(`LocalDiskObjectStorage.cs:18-20`). Deployed, `docker-compose.yml` sets `/app/data/object-storage`.
|
||||||
|
|
||||||
|
### Client
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client && npm install && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
`✓ Ready in 6.8s` on `http://localhost:3000`. **Routes are locale-prefixed**: `/fa` and `/en` return `200`,
|
||||||
|
bare `/` returns **`404` under `next dev`**. That is a Turbopack dev-server quirk on the root path, not a
|
||||||
|
routing bug — **confirmed by building for production:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client && npm run build && PORT=3001 npm run start
|
||||||
|
```
|
||||||
|
|
||||||
|
`next build` exits 0. Against `:3001`, `/` → `307 → /fa`; guest `/fa` returns `200` and renders the welcome
|
||||||
|
landing itself (byte-for-byte the same page as `/fa/welcome`, differing only in the URL — which is the
|
||||||
|
middleware **rewrite**, not a redirect); `/fa/search` → `307 → /fa/login?next=%2Fsearch`. **Never judge
|
||||||
|
root-path or guest routing from `next dev`.**
|
||||||
|
|
||||||
|
`client/.env.development` already carries `NEXT_PUBLIC_API_URL = http://localhost:5002`, correctly on HTTP.
|
||||||
|
**Do not copy `.env.sample`** — it still says `https://localhost:5002` and will break every call with an
|
||||||
|
opaque network error.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Getting an OTP
|
||||||
|
|
||||||
|
This is where a fresh clone fails, so read the whole section.
|
||||||
|
|
||||||
|
### The problem
|
||||||
|
|
||||||
|
`appsettings.Development.json` ships with:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"Seams": { "Sms": { "Provider": "telegram", "Telegram": { "BaseUrl": "http://127.0.0.1:5010", … } } }
|
||||||
|
```
|
||||||
|
|
||||||
|
With nothing listening on `:5010`, `POST /api/v1/auth/request_otp` returns **`500 Server Error`**:
|
||||||
|
|
||||||
|
```
|
||||||
|
[WRN] Telegram OTP relay delivery failed for phone ending 0010 — http 502
|
||||||
|
[ERR] Telegram OTP relay delivery failed (http 502).
|
||||||
|
at TelegramSmsSender.PostAsync(…) TelegramSmsSender.cs:line 73
|
||||||
|
```
|
||||||
|
|
||||||
|
This is deliberate — the relay fails **loudly** rather than pretending an undelivered code was sent. In the
|
||||||
|
browser it surfaces as an error toast on the login screen and you never reach the code step.
|
||||||
|
|
||||||
|
The code is still generated and persisted before the send, so `dev/last_otp` works even while `request_otp`
|
||||||
|
500s. That is a usable-but-ugly fallback, not a fix.
|
||||||
|
|
||||||
|
### The fix — one of three
|
||||||
|
|
||||||
|
| Option | Command | Result |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **A (recommended)** | boot with `Seams__Sms__Provider=mock` | `request_otp` → `200`, no relay needed |
|
||||||
|
| B | set `"Provider": "mock"` in `appsettings.Development.json` | same, but shows up in `git status` |
|
||||||
|
| C | run the Telegram relay | code arrives in Telegram — see below |
|
||||||
|
|
||||||
|
Option A verified:
|
||||||
|
|
||||||
|
```json
|
||||||
|
POST /api/v1/auth/request_otp {"phone":"09120000002"} → HTTP 200
|
||||||
|
{"data":{"otpSent":true,"resendAvailableInSeconds":120,"codeLength":6,"expiresInSeconds":60},"isSuccess":true}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reading the code
|
||||||
|
|
||||||
|
**`GET /api/v1/dev/last_otp/{phone}` is the only way to read the code.** It is anonymous and
|
||||||
|
Development-only (`404` in any other environment).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:5002/api/v1/dev/last_otp/09120000010
|
||||||
|
# {"data":{"phone":"09120000010","code":"724740"},"isSuccess":true,"statusCode":200,…}
|
||||||
|
```
|
||||||
|
|
||||||
|
The capture bridge is registered only for a capture-safe sender — `mock`, `telegram`, or unset
|
||||||
|
(`Program.cs:94-100`). Select the real `kavenegar` gateway and the endpoint stops returning codes, by design.
|
||||||
|
|
||||||
|
> **The server console does NOT print the OTP.** With `Provider=mock` the line is
|
||||||
|
> `[INF] MOCK SMS — OTP issued to phone ending in 0002` — the phone suffix only, **no code**. RUNBOOK.md and
|
||||||
|
> manual-testing-plan.md both promise `MOCK SMS — OTP code 123456 for phone ending in 0001`. That string does
|
||||||
|
> not exist in the codebase. Verified: zero 6-digit sequences appear anywhere in a full boot+login log.
|
||||||
|
|
||||||
|
### Option C — the Telegram relay
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd telegram-otp-bot && npm start # zero dependencies, no npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
Two things must line up, and on the verification machine **neither did**:
|
||||||
|
|
||||||
|
1. `telegram-otp-bot/.env` `API_KEY` **must equal** `Seams:Sms:Telegram:ApiKey` in
|
||||||
|
`appsettings.Development.json`. On the verification machine it did not: the local `.env` still holds
|
||||||
|
`ab8984974bc1fe5ce514d0fd74f71c8738b3aed92a7e4d86` — byte-for-byte the key published in `.env.example`.
|
||||||
|
`TelegramSmsSender.cs:35` hardcodes that exact string as a rejected placeholder, so the relay would
|
||||||
|
reject the API's calls even with everything running. Copy the appsettings value into `.env`.
|
||||||
|
2. `api.telegram.org` is filtered in Iran, so `TELEGRAM_PROXY_URL` must point at a working proxy.
|
||||||
|
|
||||||
|
It **broadcasts** every code to every configured chat id, so it is a shared inbox for a trusted group, not
|
||||||
|
an SMS gateway.
|
||||||
|
|
||||||
|
### Limits that will bite you
|
||||||
|
|
||||||
|
| Limit | Value | Source |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| OTP length | 6 digits | `IdentityDefaults.OtpCodeLength` |
|
||||||
|
| OTP validity | **60 s** | `IdentityDefaults.OtpExpirySeconds` |
|
||||||
|
| Resend window | **120 s** per phone | `platform_configs.auth_otp_resend_seconds` |
|
||||||
|
| Wrong attempts | **5**, then `code: "otp_locked"` | `Lockout.MaxFailedAccessAttempts` |
|
||||||
|
| **Endpoint rate limit** | **5 requests / 60 s per IP** | `otp` policy, `RateLimitingServiceExtension.cs` |
|
||||||
|
|
||||||
|
**The `otp` rate-limit policy covers `request_otp` *and* `verify_otp`.** Five combined calls inside one
|
||||||
|
minute and both endpoints return `429` — `verify_otp` with an **empty body**, which looks like a crash. A
|
||||||
|
scripted login is 2 calls, so **you get two logins per minute, total.** See
|
||||||
|
[Scripting logins](#scripting-logins).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Demo accounts
|
||||||
|
|
||||||
|
Read out of [`DemoWorldDefinitions.cs`](../../server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Seeding/DemoWorldDefinitions.cs)
|
||||||
|
and [`DemoLifecycleDefinitions.cs`](../../server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Seeding/DemoLifecycleDefinitions.cs)
|
||||||
|
— the seeders are the authority, not any doc — and each one confirmed by a live `GET /api/v1/me`.
|
||||||
|
|
||||||
|
| Phone | `/me` roles | Who | Demonstrates |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `09120000001` | `nurse` | زهرا عزیزی (f) | **verified** · 3 priced variants · whole-city Tehran + districts 1, 3 · 2 credentials · bank account |
|
||||||
|
| `09120000002` | `nurse` | علی کریمی (m) | **verified** · 2 variants · districts 3, 6, 12 · sponsored by the partner center |
|
||||||
|
| `09120000003` | `nurse` | مریم احمدی (f) | **unverified** (`status: in_review`, `isBookable: false`, blocking `moh_competency_license` + `criminal_record`) — must never appear in search |
|
||||||
|
| `09120000010` | `customer` | سارا محمدی (f) | 2 patients · 1 Tehran address · owns 6 of the 8 seeded bookings |
|
||||||
|
| `09120000011` | `customer` | رضا حسینی (m) | 1 infant patient · 1 address · owns the BNPL + payout-eligible bookings |
|
||||||
|
| `09120000020` | `super_admin` | نگار مدیری (f) | the full backoffice — **but see the RBAC gap below** |
|
||||||
|
| `09120000021` | `finance` | کامران مالی (m) | scoped backoffice; the client's `useAdminCapabilities()` shows only money consoles |
|
||||||
|
| `09120000030` | `customer` | بهنام رستگار (m) | owns مرکز پرستاری آرامش (merchant-of-record). **No partner role** — `/me` returns `["customer"]`; navigate to `/fa/partner` manually |
|
||||||
|
|
||||||
|
Admin sub-roles are **server-granted** — `POST /me/select_role` only accepts `customer` and `nurse`
|
||||||
|
(`RoleNames.SelfAssignable`).
|
||||||
|
|
||||||
|
### ⚠ The seeded admins cannot reach any admin endpoint
|
||||||
|
|
||||||
|
**Every `[Authorize(DynamicPermission)]` route returns `403` for `09120000020` and `09120000021`.** Verified
|
||||||
|
against all 16 admin GET operations in the live swagger — `platform_config`, `audit`, `holidays`,
|
||||||
|
`admin_verifications`, `admin_payouts`, `admin_refunds`, `admin_evv`, `admin_bnpl`, `admin/tickets`,
|
||||||
|
`admin/reviews`, `admin_cancellation_policies`, `admin/partner-centers`. All `403`. The same token gets
|
||||||
|
`200` from `/me`, and nurse/customer endpoints work normally, so this is not a token problem.
|
||||||
|
|
||||||
|
Root cause, in `DynamicPermissionService.CanAccess`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
if (user.IsInRole("admin")) return true;
|
||||||
|
var key = $"{area}:{controller}:";
|
||||||
|
return user.FindAll(ConstantPolicies.DynamicPermission).Any(c => c.Value.Equals(key, …));
|
||||||
|
```
|
||||||
|
|
||||||
|
It grants on the **literal** role `"admin"` or on a per-controller `DynamicPermission` claim. The demo
|
||||||
|
admins hold `super_admin` / `finance` (`RoleNames.cs:13-17`), and refinement-phase-5 stopped auto-seeding the
|
||||||
|
`admin`/`qw123321` account — so **no seeded account satisfies either branch.**
|
||||||
|
|
||||||
|
Consequence for testing: **the entire admin backoffice is untestable end-to-end on the real path.** The
|
||||||
|
client hides this because `USE_ADMIN_MOCK = true`, so the console renders fully on in-browser fake data.
|
||||||
|
See [admin-backoffice.md](admin-backoffice.md). Workaround: add both `"Seed": { "AdminUsername",
|
||||||
|
"AdminPassword" }` keys to `appsettings.Development.json` before boot to mint a literal-`admin` account, and
|
||||||
|
call the API directly (the web login is phone-OTP only).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The seeded world, and how stale it is
|
||||||
|
|
||||||
|
`DemoWorldSeeder` builds the personas; `DemoLifecycleSeeder` layers 8 bookings plus the money, reviews,
|
||||||
|
tickets, notifications and records behind them. Both are **idempotent and Development-only**, and log
|
||||||
|
`already seeded — no-op` on every subsequent boot.
|
||||||
|
|
||||||
|
Live counts on the shared remote DB at this stamp:
|
||||||
|
|
||||||
|
| Thing | Count | Read with |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Bookings | 8 (6 for `…010`, 2 for `…011`) | `GET /bookings/list?role=customer` |
|
||||||
|
| Booking requests | 15 (11 nurse 1, 4 nurse 2) | `GET /booking_requests/list` |
|
||||||
|
| Search rows | 27 across 3 nurses; `category=1&city=101` → **9**, `category=3` → **0** | `GET /search/nurses` (anonymous) |
|
||||||
|
| Patients / addresses | 2 / 1 for `…010` | `GET /patients/list`, `GET /customer_addresses/list` |
|
||||||
|
| Notifications | 8, 6 unread | `GET /notifications/get_notifications` |
|
||||||
|
| Tickets | 9 | `GET /tickets` |
|
||||||
|
| Reviews | nurse 1 → `averageRating: 5`, `publishedCount: 1` | `GET /nurses/1/reviews` (anonymous) |
|
||||||
|
| Refunds | booking 7 → `succeeded`, `psp_card`, `2000000` | `GET /refunds/by_booking/7` |
|
||||||
|
| Payouts | nurse 1: paid `3187500`, clawback outstanding `212500`; nurse 2: eligible `2720000` | `GET /nurse_payouts/earnings_balance` |
|
||||||
|
| Care records | 2 for patient 1 | `GET /patients/1/care_records` |
|
||||||
|
|
||||||
|
### ⚠ It has aged out — and this is not cosmetic
|
||||||
|
|
||||||
|
`DemoLifecycleDefinitions` expresses every timestamp as an **offset from seed time**, and the seeder anchors
|
||||||
|
to the *first* run's epoch, never to wall-clock now. The seeder's own comment is explicit:
|
||||||
|
|
||||||
|
> *"A wipe + reseed is what moves the demo world forward in time."*
|
||||||
|
|
||||||
|
**This world was seeded on 2026-07-26. It is 7 days old.** What that has already broken:
|
||||||
|
|
||||||
|
| Scenario as designed | State today | Effect |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| B1 `upcoming` — scheduled +3 d | scheduled 2026-07-29, **in the past** | nothing is actually "upcoming" |
|
||||||
|
| B3 `completed_in_window` — dispute window open | window closed 2026-07-28 | the dispute/review-moderation path can't be walked |
|
||||||
|
| B2 `in_progress` — session 3 checked in *today* | scheduled 2026-07-26 | mid-engagement is 7 days stale |
|
||||||
|
| One `pending` request awaiting the nurse | `expired_no_response` (the `booking_request_expiry` job ran) | — |
|
||||||
|
| Two `accepted` requests awaiting payment | `payment_deadline_expired` | — |
|
||||||
|
|
||||||
|
**There is no longer a single `pending` or `accepted` booking request in the world.** Confirmed across both
|
||||||
|
nurses: statuses are only `converted`, `rejected_by_nurse`, `cancelled_by_customer`, `expired_no_response`,
|
||||||
|
`payment_deadline_expired`.
|
||||||
|
|
||||||
|
So **[booking-request](booking-request.md) and [checkout-and-payment](checkout-and-payment.md) have nothing
|
||||||
|
seeded to act on.** Create a fresh request yourself (customer → search → C4/C5) — that path works and is the
|
||||||
|
intended way to exercise both — or reseed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The scheduler is running while you test
|
||||||
|
|
||||||
|
`RecurringJobSchedulerHostedService` starts with the API and gives each of the 7 jobs its own loop. **Every
|
||||||
|
job fires once immediately at boot**, then on its own cadence, re-reading its interval from
|
||||||
|
`platform_configs` each tick. It does **not** run under the `Testing` environment.
|
||||||
|
|
||||||
|
Three of them will change state under you:
|
||||||
|
|
||||||
|
| Job | Cadence | What you will notice |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `booking_request_expiry` | **every 60 s** (hardcoded) | A request you leave un-actioned flips to `expired_no_response`, then `payment_deadline_expired`. **This is what aged the seeded world out**, and it will do the same to yours — accept and pay promptly |
|
||||||
|
| `no_show_sweep` | hourly | A session whose start time passed with no EVV check-in gets flagged missed |
|
||||||
|
| `weekly_payout_generation` | at boot, then 7 d | Creates a **draft** payout batch you did not ask for. Generation only — it never moves money; `process` stays an explicit admin action |
|
||||||
|
|
||||||
|
The other four (`notification_retention`, `verification_expiry_scan`, `moadian_reconciliation`,
|
||||||
|
`audit_log_retention`) are no-ops on a freshly seeded database. `moadian_reconciliation` logs
|
||||||
|
`scanned 8 invoice(s); 0 reached registered` because `IMoadianClient` is on its mock.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reset
|
||||||
|
|
||||||
|
There is **no in-app reseed**. Both seeders guard on natural keys (a persona's phone, a ticket's reference
|
||||||
|
code, the partner permit number, a request's customer/nurse/variant/date tuple), so re-running never
|
||||||
|
duplicates and never refreshes. Moving the world forward in time means dropping the data first.
|
||||||
|
|
||||||
|
| Target | Procedure | Verified |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Local Docker DB | `cd server && docker compose down -v && docker compose up -d`, then `dotnet run` | ✗ no Docker here |
|
||||||
|
| Remote shared DB | `DROP DATABASE Baya` (and `Baya_Logs`), then boot — `MigrateAsync` + both seeders run | ✗ **not attempted** |
|
||||||
|
|
||||||
|
**Do not drop the shared remote database casually.** It backs the `balinyaar.ir` demo deployment and is used
|
||||||
|
by other people. If you need a clean, freshly-dated world, use a local instance.
|
||||||
|
|
||||||
|
Schema-only, without booting the app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
dotnet ef database update --project src/Infrastructure/Baya.Infrastructure.Persistence \
|
||||||
|
--startup-project src/API/Baya.Web.Api
|
||||||
|
```
|
||||||
|
|
||||||
|
Reference and demo seeds still run on the next app boot.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scripting logins
|
||||||
|
|
||||||
|
Two API calls per login, both on the `otp` policy, 5 per 60 s per IP. **Space logins ≥ 40 s apart** or you
|
||||||
|
will 429. This script minted tokens for all 8 demo accounts:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
login() {
|
||||||
|
P="$1"
|
||||||
|
curl -s -X POST http://localhost:5002/api/v1/auth/request_otp \
|
||||||
|
-H "Content-Type: application/json" -d "{\"phone\":\"$P\"}" > /dev/null
|
||||||
|
C=$(curl -s "http://localhost:5002/api/v1/dev/last_otp/$P" \
|
||||||
|
| node -pe "JSON.parse(require('fs').readFileSync(0,'utf8')).data.code")
|
||||||
|
curl -s -X POST http://localhost:5002/api/v1/auth/verify_otp \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"phone\":\"$P\",\"code\":\"$C\",\"deviceInfo\":\"cli\"}" \
|
||||||
|
| node -pe "JSON.parse(require('fs').readFileSync(0,'utf8')).data.accessToken"
|
||||||
|
}
|
||||||
|
for P in 09120000001 09120000002 09120000003 09120000010 \
|
||||||
|
09120000011 09120000020 09120000021 09120000030; do
|
||||||
|
echo "T_$P=$(login $P)"; sleep 40
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
Then `curl -H "Authorization: Bearer $T_09120000010" http://localhost:5002/api/v1/me`.
|
||||||
|
|
||||||
|
Then `curl -H "Authorization: Bearer $T_09120000010" http://localhost:5002/api/v1/me`.
|
||||||
|
|
||||||
|
Four things to get right:
|
||||||
|
|
||||||
|
- **The field is `phone`, not `phoneNumber`.** `phoneNumber` returns `400` with
|
||||||
|
`"The Phone field is required."`
|
||||||
|
- **`Authorization: Bearer <token>`, never a cookie.** The client stores the JWE in a cookie it reads
|
||||||
|
itself and sends as a header; the server's CORS policy does not allow credentials.
|
||||||
|
- Access tokens last **60 minutes** (`IdentitySettings.ExpirationMinutes`).
|
||||||
|
- **One live token per account. Logging in again kills the previous one.** Verified: after re-running the
|
||||||
|
script, the earlier tokens for the re-minted phones all returned `401` while untouched accounts kept
|
||||||
|
working. `AppUserManagerImplementation.VerifyUserCode` calls `UpdateSecurityStampAsync` on every
|
||||||
|
successful verification, and the bearer handler's `OnTokenValidated` runs
|
||||||
|
`ValidateSecurityStampAsync` — so a new login invalidates every token previously issued to that user.
|
||||||
|
**Two people cannot share a demo account**, and a second browser profile will silently log the first out.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What the old docs get wrong
|
||||||
|
|
||||||
|
Each row was checked against running code. **This file is right; the predecessor is stale.**
|
||||||
|
|
||||||
|
| # | Claim | Where | Reality |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | Set crypto keys with `dotnet user-secrets` | manual-testing-plan | Removed in `5885280`; the store is not read. Edit `appsettings.Development.json` or use `__` env vars |
|
||||||
|
| 2 | API on `https://localhost:5002` | RUNBOOK ×6, manual-testing-plan | **`http://`**. No TLS binding exists |
|
||||||
|
| 3 | Run `dotnet dev-certs https --trust` | RUNBOOK setup step 1 | Nothing to trust. Skip |
|
||||||
|
| 4 | `client/.env.development` has `https://localhost:5002` | RUNBOOK | The file says `http://`. But **`.env.sample` still says `https://`** — a live trap |
|
||||||
|
| 5 | `Seams:Sms:Provider` "committed default is `mock`" | RUNBOOK, Telegram step 3 | It is **`telegram`**, so `request_otp` **500s** on a fresh clone |
|
||||||
|
| 6 | Console prints `MOCK SMS — OTP code 123456 …` | RUNBOOK, manual-testing-plan | It prints `MOCK SMS — OTP issued to phone ending in 0002` — **no code**. Use `dev/last_otp` |
|
||||||
|
| 7 | "Only `auth` is real; 21 of 22 domains are mocked" | RUNBOOK "Good to know" | Stale by refinement-phase-4. **15 of 22 are real**, 7 mocked — see [index.md](index.md) |
|
||||||
|
| 8 | `docker compose down -v` resets the world | RUNBOOK | Only for the local-DB path. The committed config uses a **remote** DB where it does nothing |
|
||||||
|
| 9 | Demo world shows "upcoming" / open dispute windows | manual-testing-plan | Seeded 2026-07-26 and **aged out**; no `pending`/`accepted` requests remain |
|
||||||
|
| 10 | `09120000020` gives "full backoffice" | RUNBOOK | `403` on **every** admin endpoint — see [the RBAC gap](#-the-seeded-admins-cannot-reach-any-admin-endpoint) |
|
||||||
|
|
||||||
|
Contradictions C-1, C-3, C-4 and C-5 from
|
||||||
|
[_plan/open-contradictions.md](../_plan/open-contradictions.md) are settled by rows 1–4 and 8.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Cause | Fix |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `request_otp` → `500`, log shows `Telegram OTP relay delivery failed (http 502)` | `Provider=telegram`, relay not running | Boot with `Seams__Sms__Provider=mock` |
|
||||||
|
| `request_otp` → `429`; `verify_otp` → `429` **with an empty body** | `otp` policy, 5/60 s per IP | Wait 60 s. Space scripted logins ≥ 40 s |
|
||||||
|
| `400` `"The Phone field is required."` | Sent `phoneNumber` | The field is `phone` |
|
||||||
|
| `403` on every `/api/v1/admin*` route | `DynamicPermission` doesn't recognise `super_admin` | No clean workaround — see the RBAC gap |
|
||||||
|
| `/healthz/ready` → `503` | object-storage probe file lock (Windows) | Cosmetic. Check `entries.sql-app` instead |
|
||||||
|
| `dev/last_otp` → `404 "No OTP has been issued for this phone yet."` | No `request_otp` for that phone yet, or not Development | Call `request_otp` first (a `500` from it still stores the code) |
|
||||||
|
| Login "succeeds" but the account is unknown / `Padding is invalid` | `Seams:FieldEncryption` doesn't match the DB | Restore the committed values |
|
||||||
|
| `Refusing to start: required secret configuration is missing…` | `ConnectionStrings` blank or still `SET_VIA_USER_SECRETS_OR_ENV` | Use `appsettings.Development.json` or `ConnectionStrings__SqlServer` |
|
||||||
|
| Every `curl` returns `502` | A machine-wide `HTTP_PROXY`/`HTTPS_PROXY` intercepting localhost | `curl --noproxy '*'`, or clear `NO_PROXY` |
|
||||||
|
| Client `404` on `http://localhost:3000/` | Locale prefixes; `next dev` root-path quirk | Use `/fa`. Verify root behaviour with a prod build |
|
||||||
|
| Browser: blocked by CORS | Origin not in `Cors:AllowedOrigins` | `http://localhost:3000` is listed by default |
|
||||||
|
| `dotnet user-secrets`: "could not find UserSecretsId" | Expected | Edit appsettings instead |
|
||||||
|
| A `500` returns a raw stack trace instead of the `ApiResult` envelope | `ExceptionHandler` returns `false` in Development on purpose | Expected locally. Don't document the error shape from a dev response |
|
||||||
|
| A booking request flipped status while you were reading it | `booking_request_expiry` runs **every 60 s** | Expected. Act on requests promptly |
|
||||||
|
| A token that worked a minute ago now `401`s | Someone (or another tab) logged into the same demo account — the security stamp rotated | Use one account per tester, or re-login |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Where to go next
|
||||||
|
|
||||||
|
[index.md](index.md) — the flow atlas: what is built, what is mocked, and how to walk each journey.
|
||||||
Reference in New Issue
Block a user