create mvp path
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
# Balinyaar docs
|
||||
|
||||
The entry point. Start here, follow one link, stop reading.
|
||||
|
||||
> **Built by the clarify chain**, phases 0–7, 2026-07-29 → 2026-08-02. The chain's own plan — inventory,
|
||||
> contradiction log, phase files, and their progress table — is now history, kept at
|
||||
> [`archive/clarify-chain/`](../archive/clarify-chain/README.md). Phase 7 (skills & guardrails) is done:
|
||||
> three playbooks live in [`.claude/skills/`](../.claude/skills/); the anti-drift convention below is
|
||||
> enforced by review rather than a git hook (an MVP-stage call — see
|
||||
> [decisions.md](status/decisions.md)).
|
||||
|
||||
---
|
||||
|
||||
## The rule that keeps three trees apart
|
||||
|
||||
| Tree | Answers | Example |
|
||||
| --- | --- | --- |
|
||||
| [`product/`](../product/index.md) | **What the business is** | escrow holds funds until check-out is confirmed |
|
||||
| **`docs/`** (here) | **What we built, and how we work** | the escrow ledger is implemented; here is how to test it |
|
||||
| `archive/` | **How we got here** | the phase-10 prompt that built the ledger, and its report |
|
||||
|
||||
**A file belongs in exactly one.** If you are about to write a business rule into `docs/`, it belongs in
|
||||
`product/`. If you are about to obey something in `archive/`, stop — it is a record, not an instruction.
|
||||
|
||||
`archive/` holds the executed build-chain prompts, reports, and the pre-cleanup `docs/_plan/`. Start at
|
||||
[`archive/README.md`](../archive/README.md).
|
||||
|
||||
---
|
||||
|
||||
## Map
|
||||
|
||||
| Section | What it holds | Status |
|
||||
| --- | --- | --- |
|
||||
| [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 |
|
||||
| [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 | **written** · phase 4 |
|
||||
| [roadmap/](roadmap/index.md) | Where it goes next, and what gates a launch | **written** · phase 5 |
|
||||
|
||||
## Still elsewhere
|
||||
|
||||
Two documents stay outside this tree on purpose:
|
||||
|
||||
- [`DEPLOY.md`](../DEPLOY.md) — the deploy *procedure*, at the repo root where an operator will look for
|
||||
it. The runtime *topology* it implies moves to `docs/integration/topology.md`.
|
||||
- [`product/`](../product/index.md) — untouched by this chain. `docs/status/implemented.md` overlays
|
||||
build state onto its 14 business areas rather than restating them.
|
||||
|
||||
## Conventions
|
||||
|
||||
1. **Every status or flow doc carries `> Last verified: <date> against <commit>`.** A doc without one is
|
||||
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,
|
||||
it is written with an explicit `UNVERIFIED:` prefix.
|
||||
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.
|
||||
|
||||
The full convention is in [docs/rules/documentation.md](rules/documentation.md) — enforced by review,
|
||||
not tooling (an MVP-stage call; see [git-and-gates.md](rules/shared/git-and-gates.md)).
|
||||
@@ -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](../../archive/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`.
|
||||
@@ -0,0 +1,212 @@
|
||||
# Flows — what is implemented, and how to test it
|
||||
|
||||
One file per **user-meaningful journey**. Each answers three questions and no others: what it does, what is
|
||||
real versus mocked, and the exact steps to walk it against a running stack.
|
||||
|
||||
**Start here → [testing-setup.md](testing-setup.md)** for bring-up, the demo accounts and the OTP. Then pick
|
||||
a row below.
|
||||
|
||||
> Last verified: 2026-08-02 against commit `c841bde`, against a **booted stack** — API on
|
||||
> `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.
|
||||
|
||||
---
|
||||
|
||||
## Status table
|
||||
|
||||
`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.
|
||||
|
||||
| Flow | Actor | Status | Client | Server | Gaps | File |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| 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) |
|
||||
|
||||
**1 built · 15 partial · 7 mocked · 0 not started · 0 UNVERIFIED.** 283 gaps recorded — Phase 4's input.
|
||||
|
||||
`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`
|
||||
|
||||
[`archive/build-chain/working-context/reports/mocks-registry.md`](../../archive/build-chain/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](../../archive/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](../../archive/post-phase/refinement/RUNBOOK.md) and
|
||||
[manual-testing-plan.md](../../archive/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
|
||||
[clarify-chain/open-contradictions.md](../../archive/clarify-chain/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.
|
||||
@@ -0,0 +1,284 @@
|
||||
# The API contract
|
||||
|
||||
Everything that holds for **every** Balinyaar endpoint. The per-domain files in
|
||||
[`domains/`](domains/index.md) assume all of this and never restate it.
|
||||
|
||||
> Last verified: 2026-07-30 against commit `d3ec723`. Wire facts were derived mechanically from
|
||||
> [`openapi/swagger.v1.json`](openapi/swagger.v1.json) (2026-07-29) and read out of the code named
|
||||
> beside each claim. **When the JSON and this file disagree, the JSON wins and this file is wrong.**
|
||||
|
||||
---
|
||||
|
||||
## Base, versioning, routing
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Client base URL | `NEXT_PUBLIC_API_URL` → [`client/src/config.ts`](../../client/src/config.ts) `API_URL` (required; boot fails without it) |
|
||||
| Local | `http://localhost:5002` — **plain HTTP**, per `launchSettings.json`. There is no local TLS and no certificate to trust |
|
||||
| Deployed | `https://api.balinyaar.ir` → Caddy → `balinyaar-api:8080` |
|
||||
| Route template | `api/v{version:apiVersion}/[controller]/[action]`, `DefaultApiVersion = 1.0` |
|
||||
| Segment casing | snake_case, via `SnakeCaseParameterTransformer` (`RouteTokenTransformerConvention`) |
|
||||
| Swagger UI | `/swagger` · ReDoc `/api-docs/{documentName}` · documents `v1` and `v1.1` |
|
||||
|
||||
Route strings are **never hardcoded** server-side — the `[controller]`/`[action]` tokens also derive the
|
||||
dynamic-permission key, so renaming a handler renames its URL *and* its permission
|
||||
([server/CLAUDE.md](../../server/CLAUDE.md) hard rule 4).
|
||||
|
||||
`v1.1` is registered (`AddSwagger("v1","v1.1")`) but **empty**: all 55 controllers are `[ApiVersion("1")]`
|
||||
and `ApiVersionDocumentProcessor` drops every path whose URL lacks the document's version segment.
|
||||
|
||||
## The envelope
|
||||
|
||||
`Baya.Application/Models/ApiResult/ApiResult.cs`, applied by `ApiResultFilterAttribute` +
|
||||
`base.OperationResult(result)`.
|
||||
|
||||
| Field | Type | Notes |
|
||||
| --- | --- | --- |
|
||||
| `isSuccess` | boolean | |
|
||||
| `statusCode` | **integer** | `ApiResultStatusCode`: `200 400 401 403 404 406 409 422 424 500`. Not a string |
|
||||
| `message` | string \| null | User-safe. Defaults to the status's display name (`"Success"`, `"Bad Request Error"`, …) |
|
||||
| `requestId` | string \| null | `Activity.Current.TraceId` as hex — the W3C trace id, empty string if no activity |
|
||||
| `code` | string \| null | Optional stable machine-readable error code. **Omitted from the wire when null** |
|
||||
| `data` | `T` \| null | The payload. Present only on `ApiResult<T>` |
|
||||
|
||||
Failure responses use the same shape with `data` null (or the validation dictionary, below).
|
||||
|
||||
**Client-side drift:** `ApiEnvelope<T>` in
|
||||
[`client/src/lib/api/types.ts`](../../client/src/lib/api/types.ts) declares `isSuccess`, `statusCode`,
|
||||
`message`, `requestId`, `data` — but **not `code`**, even though `clientFetch` reads `body.code` at
|
||||
runtime and threads it into `ApiError`. Harmless today, incomplete as a type.
|
||||
|
||||
## Casing
|
||||
|
||||
**JSON bodies are camelCase. URL segments are snake_case.** REQ-001 settled this; verified here
|
||||
mechanically — across all 339 component schemas there are **427 distinct property names, 0 containing an
|
||||
underscore and 0 in PascalCase**.
|
||||
|
||||
Query parameters are the exception with no single rule: most are camelCase, and `GET /search/nurses`
|
||||
takes snake_case (`service_category_id`, `city_id`, `district_id`, `nurse_gender`, `min_price`,
|
||||
`max_price`, `page_size`) — the only endpoint that does. See [domains/search.md](domains/search.md).
|
||||
|
||||
## Status codes
|
||||
|
||||
| Code | Meaning | Server source |
|
||||
| --- | --- | --- |
|
||||
| `200` | Success, payload in `data` | |
|
||||
| `400` | Validation / business-rule failure | `data` is `{ "<field>": ["<message>", …] }` (`ApiResultOfDictionaryOfStringAndListOfString`), from `ModelStateValidationAttribute` + FluentValidation |
|
||||
| `401` | Unauthenticated — missing, expired or unreadable token | |
|
||||
| `403` | Authenticated but lacks the policy/permission | |
|
||||
| `404` | Not found — **and a tenancy mismatch.** Deliberate: a 403 would confirm the row exists ([server/CLAUDE.md](../../server/CLAUDE.md) rule 20) | |
|
||||
| `406` | Not acceptable | |
|
||||
| `409` | Conflict — forward-only state-machine violation, duplicate, or converged idempotent replay | `OperationResult.ConflictResult` |
|
||||
| `422` | Entity process error | |
|
||||
| `424` | Failed dependency (an external rail refused) | |
|
||||
| `429` | Rate limited | The rate limiter, below |
|
||||
| `5xx` | Unexpected. Generic message; detail only in logs — **except** in Development, where the developer exception page returns a stack trace | `ExceptionHandler` |
|
||||
|
||||
Handlers **never throw for an expected failure** — they return `OperationResult.FailureResult` /
|
||||
`NotFoundResult` / `ConflictResult`, which the filter maps to the codes above.
|
||||
|
||||
### What the client does with each
|
||||
|
||||
[`client/src/lib/api/client.ts`](../../client/src/lib/api/client.ts):
|
||||
|
||||
| | Behaviour |
|
||||
| --- | --- |
|
||||
| `401` | One silent refresh + retry (single-flight). On failure: clear both auth cookies, toast "session expired", `window.location.replace('/{locale}/login')`, **return `undefined` rather than throw** |
|
||||
| `403` | Toast + throw `ApiError(403, message, code)` |
|
||||
| `5xx` | Toast + throw `ApiError` |
|
||||
| other `4xx` | **Throw without toasting** — the calling hook owns the user-facing message |
|
||||
| network failure | Toast + `throw new ApiError(0, 'Network error')` |
|
||||
| `204` | Returns `undefined` |
|
||||
|
||||
`serverFetch` ([`server.ts`](../../client/src/lib/api/server.ts)) never toasts and never redirects — every
|
||||
non-OK response throws `ApiError` and the caller decides between `notFound()`, `redirect()` and an error
|
||||
boundary.
|
||||
|
||||
## Auth
|
||||
|
||||
### Transport is a header, storage is a cookie
|
||||
|
||||
The JWE access token lives in a **client-readable cookie** (`access_token`) that the client reads itself
|
||||
and re-sends as `Authorization: Bearer <token>`. **No cookie crosses the wire as an auth credential.**
|
||||
Consequences:
|
||||
|
||||
- The server's CORS policy sets **no** `AllowCredentials()` — see [config-matrix.md](config-matrix.md#cors).
|
||||
- Neither fetch layer sets `credentials: 'include'`.
|
||||
- A cross-site cookie policy (`SameSite`) is irrelevant to API auth; the cookies are same-origin storage.
|
||||
|
||||
| Cookie | Options | Written by |
|
||||
| --- | --- | --- |
|
||||
| `access_token` | `path=/`, `maxAge=900` (15 min), `sameSite=lax`, `secure` | `persistAuthTokens` |
|
||||
| `refresh_token` | `path=/`, `maxAge=604800` (7 days), `sameSite=lax`, `secure` | `persistAuthTokens` |
|
||||
|
||||
> **Asymmetry worth knowing:** the cookie's `maxAge` is 15 minutes but `IdentitySettings:ExpirationMinutes`
|
||||
> is **60**. The token stays valid for an hour; the client simply stops having it after 15 minutes, so the
|
||||
> next call goes out unauthenticated, gets a 401, and refreshes. It works, but the refresh cadence is set
|
||||
> by the cookie, not the token.
|
||||
|
||||
### The token is opaque
|
||||
|
||||
It is a **JWE** — signed *and* AES-128-encrypted. The client cannot read a claim out of it and must not
|
||||
try. Identity, roles and profile-completeness come from `GET /api/v1/me`; a user holding more than one
|
||||
role commits to one with `POST /api/v1/me/select_role`. See [domains/auth.md](domains/auth.md) and
|
||||
[docs/rules/client/auth.md](../rules/client/auth.md).
|
||||
|
||||
### Refresh, rotation, reuse detection
|
||||
|
||||
`POST /api/v1/auth/refresh` with `{ refreshToken }` returns a **new pair**; the old refresh token is
|
||||
retired. Presenting a retired token is treated as theft and kills the session. The client coalesces
|
||||
concurrent 401s into **one** refresh via a module-level in-flight promise
|
||||
([`refresh.ts`](../../client/src/lib/api/refresh.ts)) and retries the original request exactly once.
|
||||
`/auth/refresh`, `/auth/request_otp` and `/auth/verify_otp` are excluded from the retry — a 401 there is
|
||||
terminal.
|
||||
|
||||
### Authorization model
|
||||
|
||||
Three levels, declared per controller:
|
||||
|
||||
| Attribute | Used by | Meaning |
|
||||
| --- | --- | --- |
|
||||
| *(none)* | 7 controllers | Anonymous — there is **no** `FallbackPolicy`, so an omitted attribute *is* the decision |
|
||||
| `[Authorize]` | user-facing controllers | Any authenticated caller; tenancy is then resolved from `ICurrentUser` |
|
||||
| `[Authorize(ConstantPolicies.DynamicPermission)]` | every `Admin*` controller + `Holidays`, `PlatformConfig`, `SupportAlerts`, `Audit`, `InternalCenters` | Dynamic permission keyed off the controller/action route |
|
||||
|
||||
### The anonymous surface
|
||||
|
||||
20 of 186 operations declare no security. This is the complete list:
|
||||
|
||||
```
|
||||
POST /api/v1/auth/request_otp POST /api/v1/auth/verify_otp
|
||||
GET /api/v1/catalog/categories GET /api/v1/catalog/option_groups
|
||||
GET /api/v1/geo/provinces GET /api/v1/geo/cities
|
||||
GET /api/v1/geo/districts GET /api/v1/geo/tree
|
||||
GET /api/v1/nurses/{nurseId}/profile GET /api/v1/nurses/{nurseId}/trust_badge
|
||||
GET /api/v1/nurses/{id}/reviews GET /api/v1/nurses/{id}/review_tags
|
||||
GET /api/v1/nurse_variants/get/{id} GET /api/v1/search/nurses
|
||||
GET /api/v1/ping/get_status GET /api/v1/ping/get_status_rate_limited
|
||||
POST /api/v1/webhooks/payments/{provider} POST /api/v1/webhooks_bnpl/{provider}
|
||||
POST /api/v1/webhooks/payouts/{provider} GET /api/v1/dev/last_otp/{phone}
|
||||
```
|
||||
|
||||
Two things follow that the REQ ledger has not caught up with:
|
||||
|
||||
- **REQ-066/067** ask for anonymous nurse search + profile reads for guest browse and are filed *open*.
|
||||
Those endpoints are **already anonymous**. What is genuinely missing is the rate limit the REQ asks for
|
||||
(`SearchController` and `NursesController` carry no `[EnableRateLimiting]`, so they fall to the 100/min
|
||||
per-IP global limiter) and the privacy review of the profile payload.
|
||||
- `GET /api/v1/dev/last_otp/{phone}` returns any registered phone's login code. It is Development-only
|
||||
code, and the deployment runs as Development — **so it is live on `api.balinyaar.ir`**. Recorded in
|
||||
[DEPLOY.md](../../DEPLOY.md) as the deployment's largest exposure.
|
||||
|
||||
## Localisation
|
||||
|
||||
The client sends `Accept-Language` (`fa` default) on **every** call, taken from the URL's locale segment.
|
||||
`serverFetch` reads the locale from its own `x-app-locale` request header (`HEADER_NAMES.LOCALE`, set by
|
||||
the Next.js middleware) and forwards it as `Accept-Language`. Reference data carrying `nameFa`/`nameEn`
|
||||
returns both and the client picks.
|
||||
|
||||
## Pagination
|
||||
|
||||
Every unbounded list is paginated. Payload: `{ items, total, page, pageSize }` — `total`, `page` and
|
||||
`pageSize` are `integer/int32`, `items` is nullable.
|
||||
|
||||
29 operations take paging params, in three declared spellings:
|
||||
|
||||
| Declared | Count | Endpoints |
|
||||
| --- | --- | --- |
|
||||
| `Page`, `PageSize` | 25 | the default — every `*/list`, `admin_*` worklist, `tickets`, `notifications`, … |
|
||||
| `page`, `pageSize` | 3 | `admin_payouts/batches/{id}` · `nurses/{id}/reviews` · `patients/{id}/care_records` |
|
||||
| `page`, **`page_size`** | 1 | `search/nurses` |
|
||||
|
||||
The first two are interchangeable — ASP.NET model binding is case-insensitive, which is what REQ-010
|
||||
recorded. **`page_size` is not**: it is a different name, and sending `pageSize` to `search/nurses` binds
|
||||
nothing and silently yields the default page size. The client's search client already sends `page_size`
|
||||
correctly.
|
||||
|
||||
## Idempotency
|
||||
|
||||
`Idempotency-Key`, a request header. **Read on exactly two endpoints**, both via
|
||||
`Request.Headers["Idempotency-Key"].FirstOrDefault()`:
|
||||
|
||||
| Endpoint | Controller | Semantics |
|
||||
| --- | --- | --- |
|
||||
| `POST /api/v1/bookings/{bookingRequestId}/payments` | `PaymentsController` | One key per payment **attempt**, reused across retries of that attempt. A new attempt takes a new key. `409` means "already in progress / already captured" — a benign convergence, not an error |
|
||||
| `POST /api/v1/checkout_bnpl/initiate` | `CheckoutBnplController` | Same, per BNPL attempt |
|
||||
|
||||
Because it is read from the header rather than bound as a parameter, **it appears nowhere in swagger.**
|
||||
It *is* in the CORS allow-list, so the pre-flight passes.
|
||||
|
||||
Money-path writes are idempotent by construction regardless of the header: the webhook event is upserted
|
||||
first and a duplicate no-ops, `bookings.booking_request_id` is `UNIQUE` so a replayed conversion cannot
|
||||
create a second booking, and a unique-violation on confirm is treated as idempotent success. **The DB
|
||||
constraint is the backstop, not the handler's `if`.**
|
||||
|
||||
Webhooks do **not** use the header — they dedupe on the provider's `external_event_id`.
|
||||
|
||||
## Money on the wire
|
||||
|
||||
| Direction | Type | Count | Rule |
|
||||
| --- | --- | --- | --- |
|
||||
| Outbound — DTOs, results | **digit string** (`"23300000"`) | 68 / 68 | Parse with the `@/utils` BigInt helpers. **Never `Number()`** |
|
||||
| Inbound — `*Command` / `*Request` bodies | **`integer/int64`** | 3 / 3 | `UpsertCancellationPolicyCommand.feeAmountIrr`, `CreateRefundCommand.platformFeeRefundedIrr`, `CreateRefundCommand.nursePayoutRefundedIrr` |
|
||||
|
||||
Invariants the client must not recompute: `grossPriceIrr = balinyaarCommissionIrr + nursePayoutAmount`;
|
||||
VAT applies to Balinyaar's commission only, never the nurse payout; a rate change is never retroactive
|
||||
(the rate is snapshotted onto the row at compute time). Toman↔IRR conversion happens only inside a
|
||||
provider adapter. Payout dates are resolved server-side against the holiday calendar — the client never
|
||||
computes one.
|
||||
|
||||
## Dates, ids, PII
|
||||
|
||||
- Timestamps are **UTC ISO-8601** strings. Shamsi rendering is a client concern.
|
||||
- `dayOfWeek` for availability uses the **Shamsi week** (0 = Saturday … 6 = Friday), not ISO Monday-start.
|
||||
- Entity ids are integers. Human-facing references (`referenceCode` on tickets, `invoiceNumber`) are
|
||||
opaque strings.
|
||||
- Encrypted-at-rest fields (phone, national id, IBAN, addresses, clinical notes) are returned only to
|
||||
authorised callers and often masked. Each domain file states masked vs. full.
|
||||
- **Two-stage clinical disclosure:** a booking *request* exposes only unencrypted `customerNotes` and a
|
||||
city/district-coarse address; encrypted care instructions become readable only after confirmation, only
|
||||
to the assigned nurse and admin, and are never projected into a list.
|
||||
- `gender` is load-bearing — it drives same-gender caregiver matching. Never defaulted, never dropped.
|
||||
|
||||
## Enums
|
||||
|
||||
**Swagger carries no string enums.** Of 339 schemas exactly one has an `enum`, and it is the integer
|
||||
`ApiResultStatusCode`. Every status/type/code field serialises as a bare `string`, so the JSON cannot
|
||||
validate a vocabulary and **the domain files here are the only written record.**
|
||||
|
||||
Each vocabulary in [`domains/`](domains/index.md) was cross-checked against the server's `Baya.Domain`
|
||||
code sets and the client's string-literal unions. Both sides agree on all 18 shared vocabularies at this
|
||||
stamp, with one exception ([domains/tickets.md](domains/tickets.md): the client's `TicketAuthorRole`
|
||||
carries a `system` member the server's `TicketCodes` does not define).
|
||||
|
||||
## Rate limits
|
||||
|
||||
`RateLimitingServiceExtension`. Over-limit → **429**. Partitioned on the client IP as resolved by the
|
||||
forwarded-headers middleware, so behind Caddy each real client gets its own bucket.
|
||||
|
||||
| Policy | Limit | Applied to |
|
||||
| --- | --- | --- |
|
||||
| *(global, implicit)* | 100 / min per IP | every endpoint that opts into nothing else |
|
||||
| `otp` | 5 / min | `auth/request_otp`, `auth/verify_otp` |
|
||||
| `auth` | 10 / min | `auth/refresh` |
|
||||
| `sensitive` | 20 / min | every `Admin*` controller, `checkout_bnpl`, `bookings` money actions, `booking_sessions` check-out, `nurse_bank_accounts` writes, `payments` |
|
||||
| `webhook` | 120 / min | the three webhook controllers, partitioned **per provider × IP** so one PSP's burst cannot starve another |
|
||||
| `global` (named) | 5 / 10 s | `ping/get_status_rate_limited` only — a demonstration endpoint |
|
||||
|
||||
`app.UseCors()` runs **after** `UseRouting()` and **before** the rate limiter and authentication, so a
|
||||
pre-flight `OPTIONS` is answered rather than rejected as 429 or 401.
|
||||
|
||||
## Platform endpoints (outside every domain)
|
||||
|
||||
Not part of any `services/` domain, and intentionally so:
|
||||
|
||||
| Endpoint | Purpose |
|
||||
| --- | --- |
|
||||
| `GET /api/v1/ping/get_status` | Liveness smoke test (anonymous) |
|
||||
| `GET /api/v1/ping/get_status_rate_limited` | Demonstrates a 429 (anonymous, 5/10 s) |
|
||||
| `GET /healthz/live` | Process only — never touches a dependency |
|
||||
| `GET /healthz/ready` | + app DB, log DB (deployed only), object-storage write probe |
|
||||
| `GET /HealthCheck` | The aggregate, kept for existing probes |
|
||||
| `GET /metrics` | Prometheus scrape (OpenTelemetry is the only metrics source) |
|
||||
|
||||
The health and metrics endpoints are **not** under `/api/v1` and carry no envelope.
|
||||
@@ -0,0 +1,262 @@
|
||||
# Config matrix
|
||||
|
||||
Every configuration key on both sides of the seam, plus docker and the OTP relay, with where it is set and
|
||||
who reads it.
|
||||
|
||||
> Last verified: 2026-08-02 against commit `51e86a1`. Built by **mechanically enumerating** every leaf key
|
||||
> in both `appsettings*.json`, every `environment:` entry in `docker-compose.yml`, every assignment in the
|
||||
> three `client/.env*` files and `telegram-otp-bot/.env.example`, and every `process.env.*` read under
|
||||
> `client/src/` — then diffing the sets. The gaps that diff found are in
|
||||
> [§ What the diff found](#what-the-diff-found).
|
||||
|
||||
---
|
||||
|
||||
## Configuration lives in files. `dotnet user-secrets` is not used.
|
||||
|
||||
The `<UserSecretsId>` was **removed** from `Baya.Web.Api.csproj`, so that store is **not even read**. A
|
||||
stale `secrets.json` on a developer machine is inert and can be deleted. Any instruction anywhere in this
|
||||
repo to set a Balinyaar value with `dotnet user-secrets` is stale — including the placeholder string
|
||||
`SET_VIA_USER_SECRETS_OR_ENV`, whose *name* is a historical artifact (see
|
||||
[§ The placeholder's name](#the-placeholders-name)).
|
||||
|
||||
**Every value is a file in git**, which means **the repository contains live credentials** — a deliberate
|
||||
pre-launch trade for a demo deployment. Before onboarding real users they must be rotated and the secret
|
||||
half moved out of git: [DEPLOY.md § Going to Production](../../DEPLOY.md) and, when Phase 5 writes it,
|
||||
`docs/roadmap/pre-launch.md`.
|
||||
|
||||
> ⚠️ **`Seams:FieldEncryption:Key` and `:HashKey` are load-bearing and must never change.** Every encrypted
|
||||
> column in the database — phones, addresses, IBANs, clinical notes — was written with those exact values,
|
||||
> and `users.PhoneHash`, which **every login** looks up, is derived from `HashKey`. Rotating either makes
|
||||
> the existing data unreadable and locks every account out. The JWE keys
|
||||
> (`IdentitySettings:SecretKey`/`Encryptkey`) are safe to rotate — that only signs everyone out.
|
||||
|
||||
### There is no `appsettings.Production.json`
|
||||
|
||||
Only `appsettings.json` (placeholders) and `appsettings.Development.json` (real values) exist. The
|
||||
deployment runs `ASPNETCORE_ENVIRONMENT=Development`, so **`appsettings.Development.json` *is* the
|
||||
production config.** Creating an `appsettings.Production.json` today would change nothing until the
|
||||
environment name changes too.
|
||||
|
||||
---
|
||||
|
||||
## Server — `appsettings.json` / `appsettings.Development.json`
|
||||
|
||||
`appsettings.json` holds a rejected placeholder for every secret; `appsettings.Development.json` holds the
|
||||
real value. Environment-variable overrides use the double-underscore form
|
||||
(`Seams__Sms__Telegram__BaseUrl`).
|
||||
|
||||
| Key | Set in | Read by | Required | Default / committed value |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `ConnectionStrings:SqlServer` | both | `AddPersistenceServices`, `StartupSecretsGuard`, readiness probe | **yes, always** | placeholder / `87.107.152.16,1433;Database=Baya` |
|
||||
| `ConnectionStrings:logDb` | both | Serilog sink, `StartupSecretsGuard`, readiness probe (**deployed only**) | **yes, always** | placeholder / `…;Database=Baya_Logs` |
|
||||
| `IdentitySettings:SecretKey` | both | JWE signing | yes **when deployed** | placeholder / `dev-only-…-not-for-production` |
|
||||
| `IdentitySettings:Encryptkey` | both | JWE AES-128 encryption | yes **when deployed** | placeholder / `dev-only-16bytes` |
|
||||
| `IdentitySettings:Issuer` | both | token validation | — | `Balinyaar` |
|
||||
| `IdentitySettings:Audience` | both | token validation | — | `BalinyaarClient` |
|
||||
| `IdentitySettings:NotBeforeMinutes` | both | token validation | — | `0` |
|
||||
| `IdentitySettings:ExpirationMinutes` | both | access-token lifetime | — | `60` — **but the client's cookie expires at 15 min**, see [api-contract.md](api-contract.md#auth) |
|
||||
| **`Seams:FieldEncryption:Key`** | both | `IFieldEncryptor` (process-wide singleton) | yes **when deployed** | placeholder / `local-dev-field-encryption-key-not-for-production` · **IMMUTABLE** |
|
||||
| **`Seams:FieldEncryption:HashKey`** | both | deterministic lookup hashes incl. `users.PhoneHash` | yes **when deployed** | placeholder / `local-dev-field-hash-key-not-for-production` · **IMMUTABLE** |
|
||||
| `Seams:ObjectStorage:RootPath` | both + compose | local-disk blob root | when provider = `local` | `""` / compose sets `/app/data/object-storage` |
|
||||
| `Seams:Sms:Provider` | Dev only | SMS seam selector **and** the OTP-capture gate in `Program.cs` | — | `telegram` (default in code: `mock`) |
|
||||
| `Seams:Sms:Telegram:BaseUrl` | Dev + **compose** | `TelegramSmsSender` | when provider = `telegram` | `http://127.0.0.1:5010` / compose: `http://balinyaar-otp-relay:5010` |
|
||||
| `Seams:Sms:Telegram:ApiKey` | Dev only | relay `X-Api-Key` — **must equal the relay's `API_KEY`** | when provider = `telegram` | `6a8dfaee…` (rotated away from the published example) |
|
||||
| `Seams:Sms:Telegram:TimeoutSeconds` | Dev only | HTTP timeout | — | `10` |
|
||||
| `Seams:Geocoding:ReturnNullCoordinates` | both | mock geocoder | — | `false` |
|
||||
| `Seams:Geocoding:LowConfidenceMarker` | both | mock geocoder | — | `NO_GEO` |
|
||||
| `Seams:Geocoding:ResolvedConfidence` | both | mock geocoder | — | `0.9` |
|
||||
| `Cors:AllowedOrigins` | both | `AddCorsPolicies` | — | `[]` → falls back to `http://localhost:3000` / the three real origins |
|
||||
| `ForwardedHeaders:KnownProxies` | both | `AddForwardedHeadersConfiguration` | — | `[]` |
|
||||
| `ForwardedHeaders:KnownNetworks` | both | ditto — **required for the rate limiter to see the real client IP behind Caddy** | deployed | `[]` / the three docker bridge ranges |
|
||||
| `AllowedHosts` | both | host filtering | — | `*` |
|
||||
| `Kestrel:EndpointDefaults:Protocols` | both | Kestrel — `Http1AndHttp2` is what lets gRPC share the port | — | `Http1AndHttp2` |
|
||||
|
||||
### Keys the code reads that no file sets
|
||||
|
||||
Every one has a working default, so nothing is broken — but none is discoverable from the config files.
|
||||
|
||||
| Key | Read by | Behaviour when unset | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `OpenTelemetry:Otlp:Endpoint` | `SetupOpenTelemetry` | **OTLP export is not wired at all.** Prometheus `/metrics` still works | Opt-in by design, so no exporter spams an absent collector |
|
||||
| `Search:Backend` | `AddPersistenceServices` | `SqlNurseSearch` | Any value other than `sql`/empty **throws at startup** — Elasticsearch is deferred and fails loudly |
|
||||
| `Seed:AdminUsername` / `:AdminPassword` / `:AdminEmail` | `SeedDataBase` | no break-glass admin is seeded | The hardcoded `admin`/`qw123321` was removed in refinement-phase-5 |
|
||||
| `Seams:<rail>:Provider` (×11) | `AddCrossCuttingSeams` | **`mock`** | The seam selectors, below |
|
||||
|
||||
### The seam selectors
|
||||
|
||||
`SeamOptions` binds the whole `Seams` section. **Every rail defaults to its mock**, so an unconfigured
|
||||
environment behaves exactly as before and a **partial rollout is the normal case** — real SMS and a real
|
||||
geocoder while payments stay mocked is three config keys.
|
||||
|
||||
| Selector | `mock` (default) → | Real values |
|
||||
| --- | --- | --- |
|
||||
| `Seams:Sms:Provider` | log the OTP | `kavenegar` `smsir` `ghasedak` · `telegram` *(Development relay, not a gateway)* |
|
||||
| `Seams:ObjectStorage:Provider` | `local` disk | `s3` (MinIO / ArvanCloud, path-style) |
|
||||
| `Seams:Geocoding:Provider` | deterministic point | `neshan` |
|
||||
| `Seams:Shahkar:Provider` | designated test values | `finnotech` |
|
||||
| `Seams:IdentityKyc:Provider` | designated test values | `finnotech` |
|
||||
| `Seams:BankOwnership:Provider` | designated test values | `finnotech` |
|
||||
| `Seams:Payments:Provider` | deterministic capture | `zarinpal` `sadad` `vandar` `jibit` |
|
||||
| `Seams:Bnpl:Provider` | one mock provider | `real` → `IBnplProviderResolver` per `provider_code` |
|
||||
| `Seams:BankTransfer:Provider` | settles every payout | `jibit` `vandar` `sadad` |
|
||||
| `Seams:Moadian:Provider` | stays `pending` | `moadian` |
|
||||
| `Seams:Currency` *(no selector)* | `TomanToIrrMultiplier = 10` | a redenomination is a config change |
|
||||
|
||||
`Seams:Finnotech:{BaseUrl,ClientId,AccessToken}` are shared by the three trust rails — they authenticate
|
||||
against one tenant, so the connection facts live once.
|
||||
|
||||
Each mock also carries **test knobs** whose only purpose is to make a failure path reachable:
|
||||
`Shahkar:SharedSimPhone` `09120000000` · `Shahkar:MismatchNationalId` `1111111111` ·
|
||||
`IdentityKyc:FailNationalId` `0000000000` · `BankOwnership:MismatchIban` `IR0000…0000` ·
|
||||
`Bnpl:NotEligibleMobile` `09120000099` · `BankTransfer:FailIban` · `BankTransfer:ForceFailure` ·
|
||||
`PaymentCapture:ForceFailure` · `Moadian:ForceRegistered` · `ReviewModeration:AutoApproveClean` ·
|
||||
`LicenseVerification:AutoApprove` · `Payments:InvalidSignatureMarker` `INVALID_SIGNATURE`.
|
||||
|
||||
---
|
||||
|
||||
## Client — `client/.env.*`
|
||||
|
||||
**Every `NEXT_PUBLIC_*` value is inlined into the browser bundle at build time.** It is public by
|
||||
definition, and changing one requires **rebuilding the image**, not restarting the container. This is why
|
||||
`docker-compose.yml` deliberately sets no `environment:` for the `web` service — anything there would be
|
||||
silently ignored.
|
||||
|
||||
| Key | `.env.development` | `.env.production` | Read by | Required |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `NEXT_PUBLIC_API_URL` | `http://localhost:5002` | `https://api.balinyaar.ir` | `config.ts` → `API_URL` | **yes** — `envRequired`, boot fails without it |
|
||||
| `NEXT_PUBLIC_ENV` | `development` | `production` | `getCurrentEnvironment()` → `IS_PRODUCTION` | — |
|
||||
| `NEXT_PUBLIC_DEBUG` | `true` | `false` | `IS_DEBUG` — `true` **prints the resolved config, including the API URL, to the browser console** | — |
|
||||
| `NEXT_PUBLIC_PUBLIC_URL` | `http://localhost:3000` | `https://balinyaar.ir` | `PUBLIC_URL` (optional) | — |
|
||||
| `NEXT_PUBLIC_SITE_URL` | *(unset)* | `https://balinyaar.ir` | `SITE_URL` — **metadata only** (OG tags, `metadataBase`, `robots.ts`, `sitemap.ts`), never API calls | — · falls back to `http://localhost:3000` |
|
||||
| `NEXT_PUBLIC_NESHAN_KEY` | *(unset)* | *(commented out)* | `NESHAN_WEB_KEY` — the Neshan **web** key | — · unset ⇒ `AddressMapPicker` uses its bounded-canvas grid, so dev/CI/jsdom work without it |
|
||||
| `NEXT_PUBLIC_EVV_MOCK_GPS` | **not in any file** | **not in any file** | `bookings/constants.ts` | — · `in_range` when the bookings mock is on, else `off`. Values: `off` `in_range` `out_of_range` `denied` |
|
||||
| `NEXT_PUBLIC_VERSION` | **not in any file** | **not in any file** | `getCurrentVersion()`, after `npm_package_version` | — · falls back to `'unknown'` |
|
||||
|
||||
> **Two Neshan keys exist and they are different products.** `NEXT_PUBLIC_NESHAN_KEY` is a client-embeddable
|
||||
> **web** key; `Seams:Geocoding:ApiKey` is the **server** geocoding key. Never share one value between them.
|
||||
|
||||
`client/.env.sample` is the copy-me template for a fresh clone. It is **not** loaded by Next.js.
|
||||
|
||||
---
|
||||
|
||||
## Docker — `docker-compose.yml`
|
||||
|
||||
Three containers, **no published ports** — everything is reached through the existing Caddy on the external
|
||||
`caddy_net`. Full graph in [topology.md](topology.md).
|
||||
|
||||
| Service | Variable | Value | Why it is here and not in a file |
|
||||
| --- | --- | --- | --- |
|
||||
| `api` | `ASPNETCORE_ENVIRONMENT` | `Development` | **Deliberate**, so the demo + lifecycle seeders populate the shared DB. Consequences in [DEPLOY.md](../../DEPLOY.md) |
|
||||
| `api` | `Seams__Sms__Telegram__BaseUrl` | `http://balinyaar-otp-relay:5010` | Container DNS instead of loopback |
|
||||
| `api` | `Seams__ObjectStorage__RootPath` | `/app/data/object-storage` | Must land on the named volume |
|
||||
| `web` | *(none)* | — | Every `NEXT_PUBLIC_*` is baked at build time; a variable here would be ignored |
|
||||
| `otp-relay` | `TELEGRAM_BOT_TOKEN` | `8968527151:AAF…` | Live credential in git |
|
||||
| `otp-relay` | `TELEGRAM_CHAT_IDS` | `1277103616,110209855` | **Every id receives every login code, for every phone number** |
|
||||
| `otp-relay` | `API_KEY` | `6a8dfaee…` | **Must equal `Seams:Sms:Telegram:ApiKey`** |
|
||||
| `otp-relay` | `TELEGRAM_PROXY_URL` | `http://hysteria-client:8081` | `api.telegram.org` is filtered in Iran; a wrong value fails at boot, not per-OTP |
|
||||
| `otp-relay` | `REDACT_CODE_IN_LOGS` | `"true"` | Keeps codes out of `docker logs` so a host-log reader cannot harvest them |
|
||||
|
||||
Volumes: `api-object-storage` (uploaded verification documents — **losing it breaks the admin queue**) and
|
||||
`api-logs` (Serilog file sink).
|
||||
|
||||
## Caddy — `deploy/Caddyfile`
|
||||
|
||||
Not loaded by anything in this repo; it is a copy of the block `DEPLOY.md` tells you to paste into the
|
||||
Caddy container that owns `caddy_net`.
|
||||
|
||||
| Hostname | Upstream | Notes |
|
||||
| --- | --- | --- |
|
||||
| `balinyaar.ir`, `www.balinyaar.ir` | `balinyaar-web:3000` | |
|
||||
| `api.balinyaar.ir` | `balinyaar-api:8080` | **8080 in-container, not 5002** — 5002 is the local `launchSettings.json` port |
|
||||
|
||||
Caddy is the only TLS terminator and renews both certificates itself. It sets `X-Forwarded-For` and
|
||||
`X-Forwarded-Proto` by default, which is why no header directives are needed — but the API must trust the
|
||||
hop via `ForwardedHeaders:KnownNetworks`, or the rate limiter partitions every request onto Caddy's IP.
|
||||
|
||||
## CORS
|
||||
|
||||
`CorsServiceExtension`, policy `BalinyaarWebClient`.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Origins | `Cors:AllowedOrigins`; falls back to `http://localhost:3000` when unset or empty |
|
||||
| Headers | `Authorization` · `Content-Type` · `Accept-Language` · `Idempotency-Key` — explicit, **not** `AllowAnyHeader`, so the surface is auditable |
|
||||
| Methods | any |
|
||||
| **Credentials** | **not allowed.** The client authenticates with a bearer header, not a cookie, so `AllowCredentials()` is unnecessary |
|
||||
| Pipeline position | after `UseRouting()`, **before** the rate limiter and authentication, so a pre-flight `OPTIONS` is answered rather than rejected as 429/401 |
|
||||
|
||||
Adding a browser origin means editing `Cors:AllowedOrigins` **and** rebuilding the client if its
|
||||
`NEXT_PUBLIC_API_URL` changes.
|
||||
|
||||
## Telegram OTP relay — `telegram-otp-bot`
|
||||
|
||||
A standalone zero-dependency Node service. **It is not an SMS gateway**: there is no per-user routing — it
|
||||
*broadcasts* every code to a fixed list of chat ids. Workable for a trusted demo group, disqualifying the
|
||||
moment anyone outside it can request a code. Switching `Seams:Sms:Provider` to `kavenegar` at that point
|
||||
changes nothing else.
|
||||
|
||||
| Variable | Required | Default | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `TELEGRAM_BOT_TOKEN` | **yes** | — | From @BotFather |
|
||||
| `TELEGRAM_CHAT_IDS` | **yes** | — | Comma-separated. **Each recipient must have messaged the bot first** — Telegram forbids a bot opening a conversation |
|
||||
| `API_KEY` | **yes** | — | Min 16 chars; the process refuses to start without it. Must equal `Seams:Sms:Telegram:ApiKey`. **The value in `.env.example` is published, and `TelegramSmsSender` deliberately refuses to authenticate with it** |
|
||||
| `PORT` | — | `5010` | |
|
||||
| `HOST` | — | `127.0.0.1` | |
|
||||
| `REDACT_CODE_IN_LOGS` | — | `false` | `true` in the deployment |
|
||||
| `TELEGRAM_PROXY_URL` | — | unset ⇒ direct | HTTP CONNECT or SOCKS5. `HTTPS_PROXY`/`ALL_PROXY` are honoured as a fallback |
|
||||
|
||||
`telegram-otp-bot/.env` is for **local `npm start` only** — nothing in it is read inside the container.
|
||||
|
||||
## The database is not containerised
|
||||
|
||||
It is a remote SQL Server at `87.107.152.16:1433`, already provisioned and already seeded. Nothing in
|
||||
`docker-compose.yml` creates it; the API only needs network reach. Two databases: `Baya` (app) and
|
||||
`Baya_Logs` (Serilog sink).
|
||||
|
||||
## Health, metrics, and the secrets guard
|
||||
|
||||
| Endpoint | Checks |
|
||||
| --- | --- |
|
||||
| `/healthz/live` | process only — deliberately dependency-free, so a dependency outage never restarts a healthy instance |
|
||||
| `/healthz/ready` | app DB · log DB (**deployed only** — its connection string is a placeholder in Development) · an object-storage **write** round-trip |
|
||||
| `/HealthCheck` | the aggregate, retained for existing probes |
|
||||
| `/metrics` | Prometheus scrape. OpenTelemetry is the only metrics source; the duplicate prometheus-net stack was removed |
|
||||
|
||||
`StartupSecretsGuard` runs before any service reads configuration and **refuses to boot** on a missing or
|
||||
placeholder value. It requires both connection strings in every environment, and the four crypto keys only
|
||||
when **not** Development. It is skipped entirely in the `Testing` environment. Placeholder markers:
|
||||
`SET_VIA_USER_SECRETS_OR_ENV`, `not-for-production`, `change-me`,
|
||||
`ShouldBe-LongerThan-16Char-SecretKey`, `16CharEncryptKey`.
|
||||
|
||||
---
|
||||
|
||||
## What the diff found
|
||||
|
||||
Enumerating both sets and subtracting them surfaced five things. None is a broken deployment; all five are
|
||||
places where the config is not discoverable from the config files.
|
||||
|
||||
1. **`NEXT_PUBLIC_EVV_MOCK_GPS` and `NEXT_PUBLIC_VERSION` are read by client code and declared in no `.env`
|
||||
file.** Both have working defaults. Adding them commented-out to `.env.sample` would make them findable.
|
||||
2. **`OpenTelemetry:Otlp:Endpoint`, `Search:Backend` and `Seed:Admin*` are read by server code and set
|
||||
nowhere.** All three are intentionally opt-in, but a reader of `appsettings.json` cannot learn they
|
||||
exist. `Search:Backend` is the sharpest: a wrong value **throws at startup**.
|
||||
3. **`client/.env.sample` still says `NEXT_PUBLIC_API_URL = https://localhost:5002`** — the `https` half of
|
||||
contradiction **C-3**, in the one file a fresh clone is meant to copy. `.env.development` has the
|
||||
correct `http://`.
|
||||
4. **`Seams:Sms:Telegram:ApiKey` and the relay's `API_KEY` are the same secret in two files** with no
|
||||
mechanism keeping them equal. They currently match. A mismatch fails every OTP send at runtime, not at
|
||||
boot.
|
||||
5. **There is no `appsettings.Production.json`,** and `DEPLOY.md` step 2 of "Going to Production" is
|
||||
therefore a *create*, not an *edit*.
|
||||
|
||||
### The placeholder's name
|
||||
|
||||
`SET_VIA_USER_SECRETS_OR_ENV` names a store that no longer exists (contradiction **C-2**). The *behaviour*
|
||||
is correct — it is a sentinel that `StartupSecretsGuard` rejects — but the name instructs a reader to use a
|
||||
removed mechanism.
|
||||
|
||||
It was **not renamed in this phase**, because the string is load-bearing in several live files:
|
||||
`appsettings.json` (×6), `StartupSecretsGuard.cs`, `Baya.Test.Api/StartupSecretsGuardTests.cs` (×2), and
|
||||
`docs/rules/server/structure.md`. Renaming it is a server-code + test change requiring `dotnet build` and
|
||||
`dotnet test` to prove the gate still fires — out of scope for a documentation phase. **This section is the
|
||||
authoritative statement of the mechanism**; the rename is filed for Phase 4 with that worklist.
|
||||
@@ -0,0 +1,45 @@
|
||||
# addresses — customer addresses
|
||||
|
||||
> Client seam `client/src/services/addresses/` · `USE_ADDRESSES_MOCK = false` (**real**) · 5 server ops
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
|
||||
|
||||
The customer's saved addresses. One address is primary; the rest are ordered by recency. The address a
|
||||
booking uses is **snapshotted** onto the booking, so editing an address later never rewrites history.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Auth | Verdict |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/api/v1/customer_addresses/list` | `[Authorize]` | wired · paginated (`Page`/`PageSize`) |
|
||||
| POST | `/api/v1/customer_addresses/create` | `[Authorize]` | wired |
|
||||
| POST | `/api/v1/customer_addresses/update/{id}` | `[Authorize]` | wired |
|
||||
| POST | `/api/v1/customer_addresses/set_primary/{id}` | `[Authorize]` | wired |
|
||||
| DELETE | `/api/v1/customer_addresses/delete/{id}` | `[Authorize]` | wired · **soft delete** |
|
||||
|
||||
No phantoms. The domain maps 1:1.
|
||||
|
||||
## Shape rules the JSON does not express
|
||||
|
||||
- **`provinceId` is on `CustomerAddressDto`** (REQ-009, delivered). The client needs it to preselect the
|
||||
province in the city cascade without a reverse lookup.
|
||||
- **The client-picked map pin is accepted on create and update** (REQ-008, delivered) — the server does
|
||||
**not** re-geocode over a pin the user placed. When no pin is given, `IGeocoder` resolves one.
|
||||
- **`latitude`/`longitude` are nullable.** Null means the geocoder could not resolve the address and the
|
||||
user placed no pin; the UI shows "saved without a map pin" rather than an error. `IGeocoder`'s mock
|
||||
forces this path for any address whose text contains `NO_GEO`
|
||||
(`Seams:Geocoding:LowConfidenceMarker`).
|
||||
- **The full address line is encrypted at rest** and returned decrypted only to its owner. A *booking
|
||||
request* sees a city/district-coarse mask instead — see [booking-requests.md](booking-requests.md).
|
||||
- **`set_primary` touches two rows** (demote the old, promote the new) in one transaction. The client
|
||||
invalidates the whole list key rather than patching one item.
|
||||
- Delete is a soft delete behind the entity's global query filter; a booking that snapshotted the address
|
||||
is unaffected.
|
||||
|
||||
## Enums
|
||||
|
||||
None of its own. `provinceId` / `cityId` / `districtId` are geography ids — see
|
||||
[geography.md](geography.md), where **`districtId = null` means whole-city**.
|
||||
|
||||
## Open REQs
|
||||
|
||||
None. REQ-008 and REQ-009 were both delivered in refinement-phase-3 and are folded into the rules above.
|
||||
@@ -0,0 +1,99 @@
|
||||
# admin — platform config, holidays, audit, support alerts
|
||||
|
||||
> Client seam `client/src/services/admin/` · `USE_ADMIN_MOCK = true` (**mock is primary**) · 14 server ops
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
|
||||
|
||||
The ops console's cross-cutting reads and writes. Domain-specific admin surfaces live with their domain —
|
||||
verification admin in [verification.md](verification.md), refunds in [refunds.md](refunds.md), payouts in
|
||||
[payouts.md](payouts.md), catalog in [catalog.md](catalog.md), geo in [geography.md](geography.md),
|
||||
partner centers in [partner-center.md](partner-center.md), the ticket queue in [tickets.md](tickets.md).
|
||||
|
||||
Every endpoint here is `[Authorize(ConstantPolicies.DynamicPermission)]` + `sensitive` rate limit
|
||||
(20/min), **except** the three `platform_config`/`holidays`/`audit`/`support_alerts` controllers, which
|
||||
carry the dynamic-permission policy without the sensitive limit — they fall to the 100/min global limiter.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Verdict |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/platform_config/get_platform_configs` | wired · paginated |
|
||||
| POST | `/api/v1/platform_config/update_platform_config` | wired |
|
||||
| GET | `/api/v1/platform_config/get_config_change_history` | wired · paginated |
|
||||
| GET | `/api/v1/holidays/get_holidays` | wired · paginated |
|
||||
| POST | `/api/v1/holidays/upsert_holiday` | wired |
|
||||
| POST | `/api/v1/holidays/delete_holiday` | **unwired** — no real client caller; the console offers no delete |
|
||||
| GET | `/api/v1/audit/get_audit_trail` | wired · paginated |
|
||||
| GET | `/api/v1/support_alerts/get_support_alerts` | wired · paginated |
|
||||
| POST | `/api/v1/support_alerts/assign_support_alert` | wired |
|
||||
| POST | `/api/v1/support_alerts/resolve_support_alert` | wired |
|
||||
| GET | `/api/v1/admin_cancellation_policies/list` | **unwired** — the tier table is read through [refunds.md](refunds.md)'s policy preview instead |
|
||||
| POST | `/api/v1/admin_cancellation_policies/upsert` | **unwired** — no console screen edits tiers |
|
||||
| POST | `/api/v1/admin_search/rebuild_index` | **unwired** — an ops one-shot, no UI |
|
||||
| POST | `/api/v1/admin_booking_requests/expire` | **unwired** — an ops one-shot; the scheduler does this unattended |
|
||||
|
||||
### Phantom — 5
|
||||
|
||||
The client's real `clientApi.ts` calls five routes the server does not expose. Both groups are
|
||||
**deliberately written real-shaped** so flipping the seam is one line once they ship.
|
||||
|
||||
| Client call | REQ | Note |
|
||||
| --- | --- | --- |
|
||||
| `GET /api/v1/admin_roles/list_roles` | REQ-031 | **Deferred** in refinement-phase-3. The admin sub-role vocabulary and phone-OTP admins are seeded, not managed |
|
||||
| `POST /api/v1/admin_roles/grant_role` | REQ-031 | |
|
||||
| `POST /api/v1/admin_roles/revoke_role` | REQ-031 | |
|
||||
| `GET /api/v1/admin_users/search` | **REQ-061 — never filed** | Backs `UserPicker`/`NursePicker` |
|
||||
| `POST /api/v1/admin_users/lookup` | **REQ-061 — never filed** | Batch id→label resolve for `AuditLogRow` |
|
||||
|
||||
> **REQ-061 does not exist in the ledger.** `ui-phase-11-report.md` records "REQ-061…064 appended", but
|
||||
> only 062/063/064 were. Ten live client files cite REQ-061 for the admin user directory. Phase 4 must
|
||||
> file it rather than assume it is tracked.
|
||||
|
||||
## Two live drifts
|
||||
|
||||
**1. The client sends `page_size`; these endpoints declare `PageSize`.** `admin/apis/clientApi.ts`'s
|
||||
`pageQuery()` builds `page` + `page_size` "per b1 api-conventions". Model binding is case-*insensitive*,
|
||||
not separator-insensitive, so `page_size` does **not** bind to `PageSize` — every admin list would
|
||||
silently fall back to the server's default page size. Invisible today because the mock is primary; it
|
||||
becomes a real defect the moment `USE_ADMIN_MOCK` flips. See
|
||||
[../api-contract.md](../api-contract.md#pagination).
|
||||
|
||||
**2. `updatedAt`/`updatedBy` and the audit filters *are* on the wire.** REQ-029 (config audit fields) and
|
||||
REQ-030 (`actorId`/`action`/`from`/`to` filters on `audit/get_audit_trail`) were both **delivered** in
|
||||
refinement-phase-3. `admin/constants.ts` still gives them as reasons the mock is primary. The only
|
||||
remaining reason is REQ-031 + REQ-061.
|
||||
|
||||
## Shape rules the JSON does not express
|
||||
|
||||
- **Platform config is rows read at compute time**, never hardcoded, and **a rate change is never
|
||||
retroactive** — the effective rate is snapshotted onto the row when the amount is computed.
|
||||
- `platform_fee_rate` and `vat_rate` are **rates in `[0, 1)`** — the console validates the closed-open
|
||||
interval before writing (`RATE_CONFIG_KEYS` in `admin/constants.ts`). The canonical values are
|
||||
`0.15` fee / `0.10` VAT (refinement-phase-3).
|
||||
- The audit trail's `changedFieldsJson` is a **string containing JSON**, not an object:
|
||||
`{"Field": {"old": …, "new": …}}`. The client parses it defensively and yields `null` on malformed input.
|
||||
- `POST update_platform_config` and the holiday/alert writes go through self-committing facades that call
|
||||
`SaveChanges` on the shared scoped context — they run **after** the handler's own `CommitAsync`.
|
||||
- Holidays drive **payout date shifting**: the server resolves a bank-closure-safe payout date from this
|
||||
calendar and the client never computes one.
|
||||
|
||||
## Enums
|
||||
|
||||
| Vocabulary | Values |
|
||||
| --- | --- |
|
||||
| `ConfigDataType` | `string` `int` `decimal` `bool` `json` |
|
||||
| `AuditAction` | `created` `updated` `deleted` |
|
||||
| `HolidayType` | `official` `religious` `national` |
|
||||
| `SupportAlertType` | `low_rating` `evv_no_show` `evv_location_mismatch` `verification_expired` `shared_sim` `payment_anomaly` `fraud_signal` `nurse_clawback` `emergency` |
|
||||
| `SupportAlertSeverity` | `low` `medium` `high` |
|
||||
| `SupportAlertStatus` | `open` `assigned` `resolved` |
|
||||
| `AdminRole` *(phantom surface)* | `super_admin` `admin` `support` `finance` `moderation` |
|
||||
| `DirectoryUserRole` *(phantom surface)* | `customer` `nurse` `admin` `partner` |
|
||||
|
||||
The last two describe the REQ-031/REQ-061 shapes and are **not on the wire**.
|
||||
|
||||
## Open REQs
|
||||
|
||||
| REQ | Status | Effect |
|
||||
| --- | --- | --- |
|
||||
| REQ-031 | deferred | No RBAC console. Admin roles are seeded |
|
||||
| **REQ-061** | **never filed** | No admin user directory. `AuditLogRow` shows `#id` instead of a name |
|
||||
@@ -0,0 +1,79 @@
|
||||
# auth — phone OTP, sessions, `/me`, role selection
|
||||
|
||||
> Client seam `client/src/services/auth/` · `USE_AUTH_MOCK = false` (**real**) · 7 server ops
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
|
||||
|
||||
The only way into the platform. Phone + OTP, no passwords. The transport rules — bearer header, cookie
|
||||
storage, silent refresh, rotation, reuse detection — are in
|
||||
[../api-contract.md](../api-contract.md#auth); this file is the endpoints and the payload semantics.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Auth | Rate limit | Verdict |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| POST | `/api/v1/auth/request_otp` | **anonymous** | `otp` 5/min | wired |
|
||||
| POST | `/api/v1/auth/verify_otp` | **anonymous** | `otp` 5/min | wired |
|
||||
| POST | `/api/v1/auth/refresh` | **anonymous** | `auth` 10/min | wired · called by both `services/auth` **and** the fetch layer |
|
||||
| POST | `/api/v1/auth/logout` | `[Authorize]` | — | wired |
|
||||
| GET | `/api/v1/me` | `[Authorize]` | — | wired |
|
||||
| POST | `/api/v1/me/select_role` | `[Authorize]` | — | wired |
|
||||
| GET | `/api/v1/dev/last_otp/{phone}` | **anonymous** | — | **Development only** — see below |
|
||||
|
||||
No phantoms.
|
||||
|
||||
`AUTH_API_BASE` is `/api/v1`; the routes above are exactly what the client sends.
|
||||
|
||||
## `dev/last_otp` — live on the deployment
|
||||
|
||||
`DevController` is registered unconditionally, and the OTP-capture bridge behind it is wired only when
|
||||
`IsDevelopment()` **and** the SMS provider is capture-safe (`mock` or `telegram`). The
|
||||
`balinyaar.ir` deployment runs `ASPNETCORE_ENVIRONMENT=Development` with `Seams:Sms:Provider = telegram`,
|
||||
so **both conditions hold and the endpoint is reachable on `api.balinyaar.ir`.** Anyone who knows a
|
||||
registered phone number can read its login code. Recorded in [DEPLOY.md](../../../DEPLOY.md) as the
|
||||
deployment's largest exposure; the fix is the environment switch, not a code change.
|
||||
|
||||
Selecting a real gateway (`kavenegar`, …) disables the bridge — the OTP must never be logged or captured
|
||||
once real SMS ships.
|
||||
|
||||
## Shape rules the JSON does not express
|
||||
|
||||
- **`RequestOtpResult` carries the code length and expiry** (REQ-002, delivered) so the client sizes the
|
||||
input and runs the countdown from server truth rather than a hardcoded constant.
|
||||
- **`verify_otp` failures carry a machine-readable `code`** on the envelope (REQ-003, delivered) —
|
||||
e.g. `otp_locked` — so the client branches on the state instead of matching a message string. This is
|
||||
the `code` field described in [../api-contract.md](../api-contract.md#the-envelope).
|
||||
- **`refresh` returns a new pair and retires the old refresh token.** Presenting a retired token is
|
||||
treated as theft: the session is killed, not merely refused. A 401 from `/auth/refresh` is therefore
|
||||
terminal and the client must not retry it.
|
||||
- **`/me` is the only source of identity.** The JWE is opaque; the client reads role, gender and
|
||||
profile-completeness from `/me`, never from a decoded claim.
|
||||
- **Multi-role users**: `/me` reports the roles the caller holds. `POST /me/select_role` commits to one.
|
||||
REQ-004 was **resolved as a client concern** — the client owns the disambiguation and the "resolved vs.
|
||||
pending" role hydration; no backend change was needed. See
|
||||
[docs/rules/client/auth.md](../../rules/client/auth.md).
|
||||
- **`logout` returns an empty envelope** — no `data`. The client awaits the revocation and must not
|
||||
`unwrap()` it.
|
||||
- **Phone numbers are encrypted at rest.** Login looks the user up by a deterministic HMAC hash
|
||||
(`users.PhoneHash`, derived from `Seams:FieldEncryption:HashKey`), never by comparing the encrypted
|
||||
column. This is why that key is immutable — see [../config-matrix.md](../config-matrix.md).
|
||||
|
||||
## Enums
|
||||
|
||||
| Vocabulary | Values |
|
||||
| --- | --- |
|
||||
| `PublicRole` | `customer` `nurse` |
|
||||
| `AdminRole` | `admin` `support` `finance` `moderation` `super_admin` |
|
||||
| `Gender` | `male` `female` — **load-bearing**, drives same-gender caregiver matching |
|
||||
| `NurseVerificationStatus` *(as surfaced on `/me`)* | `not_started` `in_progress` `pending_review` `verified` `rejected` |
|
||||
|
||||
> The `/me` verification summary uses a **different vocabulary** from the verification domain's own
|
||||
> aggregate status (`not_started` `pending` `in_review` `approved` `rejected` `suspended`). They are two
|
||||
> read models over the same source of truth, not a drift — but do not treat the strings as
|
||||
> interchangeable. See [verification.md](verification.md).
|
||||
|
||||
## Open REQs
|
||||
|
||||
| REQ | Status | Effect |
|
||||
| --- | --- | --- |
|
||||
| REQ-038 | open | `/me` carries no signal that the caller administers a partner center, so partner auto-routing cannot be driven off it. See [partner-center.md](partner-center.md) |
|
||||
| REQ-039 | open | The OTP SMS template is not WebOTP-conformant, so the browser's one-tap autofill never fires. The client's WebOTP hook ships anyway and degrades silently |
|
||||
@@ -0,0 +1,92 @@
|
||||
# bnpl — provider-financed installments
|
||||
|
||||
> Client seam `client/src/services/bnpl/` · `USE_BNPL_MOCK = true` (**mock is primary**) · 9 server ops
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
|
||||
|
||||
The second checkout rail. **Balinyaar does not finance anything** — a provider (SnappPay, Digipay, …) pays
|
||||
the platform net of its commission and carries the customer's installments itself. Card checkout is
|
||||
[payment.md](payment.md).
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Auth | Verdict |
|
||||
| --- | --- | --- | --- |
|
||||
| POST | `/api/v1/checkout_bnpl/eligibility` | `[Authorize]` · `sensitive` | wired |
|
||||
| POST | `/api/v1/checkout_bnpl/initiate` | `[Authorize]` · `sensitive` | wired · **`Idempotency-Key`** |
|
||||
| GET | `/api/v1/checkout_bnpl/{id}` | `[Authorize]` · `sensitive` | wired |
|
||||
| GET | `/api/v1/checkout_bnpl/by_request/{bookingRequestId}` | `[Authorize]` · `sensitive` | wired |
|
||||
| POST | `/api/v1/webhooks_bnpl/{provider}` | **anonymous** · `webhook` 120/min | server-only — the provider calls it |
|
||||
| GET | `/api/v1/admin_bnpl/{id}` | admin · `sensitive` | **unwired** — no console screen |
|
||||
| POST | `/api/v1/admin_bnpl/{id}/verify` | admin · `sensitive` | **unwired** |
|
||||
| POST | `/api/v1/admin_bnpl/{id}/settle` | admin · `sensitive` | **unwired** |
|
||||
| POST | `/api/v1/admin_bnpl/{id}/revert` | admin · `sensitive` | **unwired** — the reversal is driven from [refunds.md](refunds.md) instead |
|
||||
|
||||
### Phantom — 3
|
||||
|
||||
All three are REQ-022's deferred half. Written real-shaped so the swap is one line.
|
||||
|
||||
| Client call | REQ | Note |
|
||||
| --- | --- | --- |
|
||||
| `GET /api/v1/checkout_bnpl/options/{bookingRequestId}` | REQ-022 | The D1/D2 provider + plan list, per-plan monthly / down-payment / total |
|
||||
| `GET /api/v1/checkout_bnpl/schedule/{id}` | REQ-022 | The D4 repayment schedule |
|
||||
| `GET /api/v1/checkout_bnpl/wallet_installments` | REQ-024 | The D5 wallet installment list |
|
||||
|
||||
REQ-022 was **partially** delivered: `balinyaar` was added to the `provider_code` enum; `options` and
|
||||
`schedule` were deferred. `BnplEligibilityDto` carries a single `planSummary` + `installmentCount`, not a
|
||||
list of plans — which is exactly why the client needs `options`.
|
||||
|
||||
## The money shape
|
||||
|
||||
Two facts that make BNPL different from card, and both are easy to get wrong:
|
||||
|
||||
1. **The card payment is recorded net of the provider's fee.** The provider deducts its commission before
|
||||
remitting, so the platform receives `orderAmount − bnplCommission`. `settledAmountIrr` and
|
||||
`bnplCommissionIrr` are both on `BnplOrderStatusDto`, and the handler reads the **actual deducted
|
||||
amount from the settlement response** — never a rate from config. `Seams:Bnpl:CommissionRate` tunes the
|
||||
*mock* only.
|
||||
2. **Settlement is not necessarily instant.** `settledAt` is nullable, modelling the deferred / T+1–3 /
|
||||
weekly reality. A null `settledAt` on a `settled` order is normal, not an inconsistency.
|
||||
|
||||
`BnplStatus` is **forward-only**. A reversal is `reverted`, with `revertTransactionId`,
|
||||
`revertedAmountIrr`, `revertedAt` and — when the provider returns it — `providerCommissionReversedAmount`,
|
||||
which the reconciliation needs and which most providers do not send. See [refunds.md](refunds.md) for the
|
||||
`bnpl_revert` refund channel.
|
||||
|
||||
## Shape rules the JSON does not express
|
||||
|
||||
- **Eligibility accepts the credit-check inputs** `{ nationalId, mobile, consent }` (REQ-023, delivered).
|
||||
Consent is **required** when the KYC inquiry runs — it is a legal precondition, not a checkbox.
|
||||
- **`eligibilityStatus` distinguishes three outcomes**, and the third is not a failure:
|
||||
`not_eligible` (provider declined) vs `ceiling_exceeded` (order above `creditCeilingIrr` — offer card
|
||||
instead) vs `eligible`. The UI must fall back to card, not show an error, on either negative.
|
||||
- **`bookingId` is on the settled order** (REQ-024, confirmed) so the wallet can link an installment plan
|
||||
to its booking.
|
||||
- **D5 installment status is provider-reported, not ledger-derived.** The platform does not track the
|
||||
customer's repayment; whatever the provider says is the truth. Never compute an installment state from
|
||||
Balinyaar's own ledger.
|
||||
- **`currency` is on the wire and matters.** `Seams:Bnpl:WireCurrency` is `IRR` by default; SnappPay and
|
||||
Digipay speak Rial. Conversion happens **only** inside the adapter via `ICurrencyNormalizer`.
|
||||
- Provider credentials proper live in the encrypted `payment_gateways.config_json`; only non-secret
|
||||
connection facts (base URL, sandbox flag, merchant handle) come from `Seams:Bnpl:Providers`.
|
||||
- `Seams:Bnpl:NotEligibleMobile` (`09120000099`) is the designated test mobile that returns
|
||||
`not_eligible`, so the fall-back-to-card path is testable.
|
||||
|
||||
## Enums
|
||||
|
||||
| Vocabulary | Values |
|
||||
| --- | --- |
|
||||
| `BnplStatus` | `eligible` `token_issued` `verified` `settled` `reverted` `cancelled` `failed` |
|
||||
| `BnplEligibilityStatus` | `eligible` `not_eligible` `ceiling_exceeded` |
|
||||
| `ProviderCode` | `snapppay` `digipay` `tara` `torobpay` `balinyaar` |
|
||||
| `BnplInstallmentStatus` *(D5, provider-reported)* | `paid` `due_soon` `upcoming` `overdue` |
|
||||
| `BnplHandoffOutcome` *(client, from the return URL)* | `success` `failure` |
|
||||
|
||||
The first three are verified identical to `Entities/Bnpl/BnplStatus.cs`,
|
||||
`BnplEligibilityStatus.cs` and `BnplProviderCodes.cs`. Note `snapppay` has **three** `p`s.
|
||||
|
||||
## Open REQs
|
||||
|
||||
| REQ | Status | Effect |
|
||||
| --- | --- | --- |
|
||||
| REQ-022 | partially delivered | `options` and `schedule` deferred → 3 phantom routes. D1/D2/D4 are mock-only |
|
||||
| REQ-024 | partially delivered | `bookingId` confirmed present; the wallet installment list is deferred |
|
||||
@@ -0,0 +1,104 @@
|
||||
# booking-requests — the money-free pre-payment request
|
||||
|
||||
> Client seam `client/src/services/bookingRequests/` · `USE_BOOKING_REQUESTS_MOCK = false` (**real**) · 7 server ops
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
|
||||
|
||||
Stage one of the booking flow: the customer asks, the nurse answers, and **no money exists yet**. A
|
||||
`booking_requests` row becomes a `bookings` row only on payment capture — see
|
||||
[bookings.md](bookings.md) and [payment.md](payment.md).
|
||||
|
||||
> `Features/Booking` (singular, this domain) and `Features/Bookings` (plural, the post-payment engine) are
|
||||
> **different server areas, not a rename.**
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Caller | Verdict |
|
||||
| --- | --- | --- | --- |
|
||||
| POST | `/api/v1/booking_requests/create` | customer | wired |
|
||||
| GET | `/api/v1/booking_requests/list` | both | wired · paginated · role-scoped |
|
||||
| GET | `/api/v1/booking_requests/get/{id}` | both | wired |
|
||||
| POST | `/api/v1/booking_requests/accept/{id}` | nurse | wired |
|
||||
| POST | `/api/v1/booking_requests/reject/{id}` | nurse | wired |
|
||||
| POST | `/api/v1/booking_requests/cancel/{id}` | customer | wired |
|
||||
| GET | `/api/v1/booking_requests/checkout_summary/{id}` | customer | wired — but read by the **[payment](payment.md)** domain, not this one |
|
||||
|
||||
All `[Authorize]`. No phantoms.
|
||||
|
||||
`checkout_summary` is the C6 money read (gross / commission / VAT breakdown, REQ-016 delivered). It lives
|
||||
on this controller because the request is what gets paid for, and is documented in
|
||||
[payment.md](payment.md) where it is consumed.
|
||||
|
||||
## The lifecycle
|
||||
|
||||
```
|
||||
pending_nurse_response ──accept──▸ accepted_awaiting_payment ──capture──▸ converted
|
||||
│ │
|
||||
├──reject──▸ rejected_by_nurse └──window lapses──▸ payment_deadline_expired
|
||||
├──deadline──▸ expired_no_response
|
||||
└──customer──▸ cancelled_by_customer
|
||||
```
|
||||
|
||||
**Forward-only.** A backward or sideways transition is a clean `409`, never a 500. Two deadlines are
|
||||
server-owned and config-driven (`booking_request_response_deadline_minutes`,
|
||||
`payment_window_minutes` in [admin.md](admin.md)):
|
||||
|
||||
- the nurse's response window → `expired_no_response`
|
||||
- the customer's payment window after acceptance → `payment_deadline_expired`
|
||||
|
||||
`POST /api/v1/admin_booking_requests/expire` is the ops one-shot for both; the in-process scheduler runs
|
||||
it unattended. See [admin.md](admin.md).
|
||||
|
||||
## Shape rules the JSON does not express
|
||||
|
||||
- **Stage-one disclosure is deliberately partial.** Before payment the nurse sees only unencrypted
|
||||
`customerNotes` and a **city/district-coarse masked address**. Encrypted care instructions and the full
|
||||
address are unreadable until the booking is confirmed. This is a hard server rule, not a UI choice.
|
||||
- **The countdown is server-frozen.** `CountdownTimer` renders a deadline the server sent; the client
|
||||
never computes an expiry from a local clock.
|
||||
- **`variantPrice` is on `BookingRequestDto`** (REQ-013, delivered) so the customer sees the price they are
|
||||
committing to without a second variant fetch. It is a **digit string**.
|
||||
- **The inbox list item carries `variantLabel` + `patientAge`** (REQ-014, delivered).
|
||||
|
||||
### The two list shapes, exactly
|
||||
|
||||
Confirmed field-by-field against the live swagger, because three in-repo comments disagree about this:
|
||||
|
||||
| | `BookingRequestListItemDto` (list) | `BookingRequestDto` (detail) |
|
||||
| --- | --- | --- |
|
||||
| `variantLabel` | **yes** | yes |
|
||||
| `patientAge` | **yes** | — (`patientName` instead) |
|
||||
| `variantPrice` · `variantPriceUnit` | **no** | yes |
|
||||
| address / notes | `customerNotes` only | full masked address block |
|
||||
| `nurseRejectionReason` | — | yes, **free text** |
|
||||
|
||||
Two consequences:
|
||||
|
||||
- **`client/src/services/bookingRequests/types.ts` is behind the wire.** It marks `variantLabel` as
|
||||
"client-augmented … `undefined` on the real path", but the server serves it. Widening the client type is
|
||||
safe and would let the real inbox card render its decision-first headline today.
|
||||
- **REQ-050 is partly stale.** It states the list DTO carries neither field, "confirmed against
|
||||
`services/bookingRequests/types.ts`" — i.e. confirmed against the client type, not the wire. What the
|
||||
wire genuinely lacks is `variantPrice`/`variantPriceUnit` on the *list* row and the `status=answered`
|
||||
group filter.
|
||||
- **`requiredCaregiverGender` is never defaulted or dropped.** `any` is an explicit choice, distinct from
|
||||
absent.
|
||||
- The address the request references is snapshotted at create time; later edits to the saved address do
|
||||
not rewrite it.
|
||||
|
||||
## Enums
|
||||
|
||||
| Vocabulary | Values |
|
||||
| --- | --- |
|
||||
| `BookingRequestStatus` | `pending_nurse_response` `accepted_awaiting_payment` `converted` `rejected_by_nurse` `expired_no_response` `payment_deadline_expired` `cancelled_by_customer` |
|
||||
| `RequiredCaregiverGender` | `male` `female` `any` |
|
||||
| `RequestRole` *(client-side list filter)* | `customer` `nurse` |
|
||||
|
||||
Verified identical to `Baya.Domain/Entities/Booking/BookingRequestStatus.cs`. REQ-015 confirmed these
|
||||
serialise as the exact snake_case codes.
|
||||
|
||||
## Open REQs
|
||||
|
||||
| REQ | Status | Effect |
|
||||
| --- | --- | --- |
|
||||
| REQ-044 | open | No structured `nurseRejectionReasonCode` — the wire carries free-text `nurseRejectionReason`. The client runs a keyword heuristic over it, documented in-code as a known approximation |
|
||||
| REQ-050 | open, **narrower than filed** | The list row already has `variantLabel`; it lacks `variantPrice`/`variantPriceUnit`. The `status=answered` group filter is genuinely absent, so the «پاسخداده» tab fires three page-1 queries and concatenates — an unpaged workaround a nurse with many answered requests will hit |
|
||||
@@ -0,0 +1,97 @@
|
||||
# bookings — the post-payment engine, sessions and EVV
|
||||
|
||||
> Client seam `client/src/services/bookings/` · `USE_BOOKINGS_MOCK = false` (**real**) · 16 server ops
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
|
||||
|
||||
Stage two: a paid booking, its per-visit sessions, and electronic visit verification. A `bookings` row is
|
||||
created **only on payment capture**, from an accepted [booking request](booking-requests.md).
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Caller | Verdict |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/api/v1/bookings/list` | both | wired · paginated · `role=customer\|nurse\|all` |
|
||||
| GET | `/api/v1/bookings/get/{id}` | both | wired |
|
||||
| GET | `/api/v1/bookings/care_instructions/{id}` | nurse, admin | wired · **stage-2 disclosure gate** |
|
||||
| POST | `/api/v1/bookings/submit_care_instructions/{id}` | customer | **unwired** — the client has no care-instructions form |
|
||||
| POST | `/api/v1/bookings/convert` | — | **unwired** — Development-only capture simulator; the PSP webhook supersedes it |
|
||||
| POST | `/api/v1/bookings/transition/{id}` | admin | **unwired** — a raw state-machine escape hatch, no UI |
|
||||
| POST | `/api/v1/bookings/cancel/{id}` | — | **unwired, superseded** by `{id}/cancel` |
|
||||
| POST | `/api/v1/bookings/{id}/cancel` | customer | wired — by **[refunds](refunds.md)** (cancel *and* refund, REQ-019) |
|
||||
| GET | `/api/v1/bookings/{id}/cancellation_policy` | customer | wired — by **[refunds](refunds.md)** (REQ-020) |
|
||||
| GET | `/api/v1/booking_sessions/today` | nurse | wired · paginated |
|
||||
| GET | `/api/v1/booking_sessions/evv/{id}` | nurse | wired |
|
||||
| POST | `/api/v1/booking_sessions/check_in/{id}` | nurse | wired |
|
||||
| POST | `/api/v1/booking_sessions/check_out/{id}` | nurse | wired · `sensitive` 20/min — **this is what releases the payout clock** |
|
||||
| POST | `/api/v1/booking_sessions/cancel/{id}` | nurse | **unwired** — no per-session cancel in the UI |
|
||||
| GET | `/api/v1/admin_evv/list` | admin | **unwired** — no console screen; the mock demonstrates it |
|
||||
| POST | `/api/v1/admin_evv/detect_no_shows` | admin | **unwired** — an ops one-shot; the scheduler runs it |
|
||||
|
||||
All `[Authorize]`; the two `admin_evv` routes are `DynamicPermission` + `sensitive`. No phantoms.
|
||||
|
||||
> **Two cancel routes exist on the same controller.** `POST bookings/cancel/{id}` (action-style, b9) and
|
||||
> `POST bookings/{id}/cancel` (REST-style, b11 cancel-and-refund). Only the second is wired. They are
|
||||
> not aliases — the second also drives the refund. Treat the first as legacy.
|
||||
|
||||
## The lifecycle
|
||||
|
||||
```
|
||||
pending_payment ──capture──▸ confirmed ──first check-in──▸ in_progress
|
||||
│ │
|
||||
│ all sessions out
|
||||
▼ ▼
|
||||
cancelled ◂──cancel── completed ──dispute window──▸ closed
|
||||
└──dispute──▸ disputed
|
||||
```
|
||||
|
||||
Forward-only, through the transition table. `status` has a private setter; only cohesive domain methods
|
||||
mutate it and the handler pre-checks, returning a clean `409`.
|
||||
|
||||
Sessions run their own machine: `scheduled → in_progress → completed`, or `missed` / `cancelled`.
|
||||
|
||||
## Shape rules the JSON does not express
|
||||
|
||||
- **Two-stage clinical disclosure is enforced server-side.** `care_instructions/{id}` decrypts and returns
|
||||
the care plan **only** post-confirmation and **only** to the assigned nurse or an admin. It is never
|
||||
projected into a list and never logged. The client's UI gate mirrors this; it does not create it.
|
||||
- **EVV is advisory, and `checkInAddressMatch` is a tri-state**: `true` (inside tolerance), `false`
|
||||
(outside), `null` (**no reading** — permission denied or unavailable). Null is not a failure. A
|
||||
mismatch does not block check-in; it raises a support alert
|
||||
(`evv_location_mismatch`, see [admin.md](admin.md)). Tolerance is
|
||||
`evv_location_tolerance_meters` config. REQ-015 confirmed the tri-state.
|
||||
- **`BookingDetailDto.variantSnapshotJson` and `addressSnapshotJson` are strings containing JSON**, not
|
||||
typed objects — the point of a snapshot is that later edits to the variant or address cannot rewrite
|
||||
history. The client parses defensively across multiple key spellings (REQ-045 open).
|
||||
- **The three-amount split is guaranteed by a DB CHECK**:
|
||||
`grossPriceIrr = balinyaarCommissionIrr + nursePayoutAmount`. `platformFeeRate` is **snapshotted onto
|
||||
the row** at compute time, so a later rate change is not retroactive. Never recompute any of these.
|
||||
- **`payoutEligibleAt` per session** is the payout clock, started by check-out and resolved against the
|
||||
holiday calendar server-side. See [payouts.md](payouts.md).
|
||||
- **`disputeWindowEndsAt`** gates `completed → closed`; `dispute_window_hours` is config.
|
||||
- `BookingListItemDto` is deliberately thin: `id, status, counterpartyName, scheduledDate, sessionCount,
|
||||
amountIrr, disputeWindowEndsAt, createdAt`. **No `patientId`** — which is what blocks REQ-057's care
|
||||
teaser.
|
||||
- `NEXT_PUBLIC_EVV_MOCK_GPS` overrides the GPS reading for local testing (`off` = real capture). See
|
||||
[../config-matrix.md](../config-matrix.md).
|
||||
|
||||
## Enums
|
||||
|
||||
| Vocabulary | Values |
|
||||
| --- | --- |
|
||||
| `BookingStatus` | `pending_payment` `confirmed` `in_progress` `completed` `disputed` `closed` `cancelled` |
|
||||
| `BookingSessionStatus` | `scheduled` `in_progress` `completed` `missed` `cancelled` |
|
||||
| `VisitVerificationStatus` (`evvStatus`) | `pending` `checked_in` `completed` |
|
||||
| `BookingListRole` *(query param)* | `customer` `nurse` `all` |
|
||||
| `EvvGpsMode` *(client test knob)* | `off` `in_range` `out_of_range` `denied` |
|
||||
|
||||
Verified identical to `Baya.Domain/Entities/Booking/*.cs`.
|
||||
|
||||
## Open REQs
|
||||
|
||||
| REQ | Status | Effect |
|
||||
| --- | --- | --- |
|
||||
| REQ-045 | open | `variantSnapshot`/`addressSnapshot` are untyped JSON strings. The client keeps a defensive multi-key parse; no user-facing defect |
|
||||
| REQ-051 | open | The nurse view of a confirmed+ booking still masks the address. The nurse sees a quiet fallback note, not a crash |
|
||||
| REQ-052 | open | The today feed carries no service label — it renders patient name + visit index only |
|
||||
| REQ-054 | deferred | No web push for new requests; a 15 s poll remains the only signal |
|
||||
| REQ-057 | open | `BookingListItemDto` has no `patientId` and the patient read has no `lastVisitAt`, so the card renders no care teaser. See [patients.md](patients.md) |
|
||||
@@ -0,0 +1,63 @@
|
||||
# catalog — service categories, option groups, nurse pricing variants
|
||||
|
||||
> Client seam `client/src/services/catalog/` · `USE_CATALOG_MOCK = false` (**real**) · 14 server ops
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
|
||||
|
||||
An EAV catalogue: admin defines categories and their option groups; each nurse composes **variants** —
|
||||
a category + a chosen set of option values + a price. A variant is what a customer actually books.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Auth | Verdict |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/api/v1/catalog/categories` | **anonymous** | wired · paginated |
|
||||
| GET | `/api/v1/catalog/option_groups` | **anonymous** | wired |
|
||||
| GET | `/api/v1/nurse_variants/list` | `[Authorize]` | wired · paginated |
|
||||
| GET | `/api/v1/nurse_variants/get/{id}` | **anonymous** | wired |
|
||||
| POST | `/api/v1/nurse_variants/create` | `[Authorize]` | wired |
|
||||
| POST | `/api/v1/nurse_variants/update/{id}` | `[Authorize]` | wired |
|
||||
| POST | `/api/v1/nurse_variants/set_active/{id}` | `[Authorize]` | wired |
|
||||
| POST | `/api/v1/admin_catalog/create_category` | admin | **unwired** — no console screen |
|
||||
| POST | `/api/v1/admin_catalog/update_category/{id}` | admin | **unwired** |
|
||||
| POST | `/api/v1/admin_catalog/set_category_active/{id}` | admin | **unwired** |
|
||||
| POST | `/api/v1/admin_catalog/create_option_group` | admin | **unwired** |
|
||||
| POST | `/api/v1/admin_catalog/update_option_group/{id}` | admin | **unwired** |
|
||||
| POST | `/api/v1/admin_catalog/create_option_value` | admin | **unwired** |
|
||||
| POST | `/api/v1/admin_catalog/update_option_value/{id}` | admin | **unwired** |
|
||||
|
||||
No phantoms. The seven `admin_catalog` routes are `DynamicPermission` + `sensitive`; the catalogue is
|
||||
seeded and managed out of band today, so the console has no editor. That is a UI gap, not a contract gap.
|
||||
|
||||
> Mutations are **action-style, not REST**: `POST admin_catalog/create_category`, never
|
||||
> `POST admin/catalog/categories`. The old contract doc calls this out explicitly and it still holds.
|
||||
|
||||
## Shape rules the JSON does not express
|
||||
|
||||
- **`GET nurse_variants/get/{id}` is anonymous** while `list` requires auth. That asymmetry is deliberate —
|
||||
a public nurse profile links to a specific variant.
|
||||
- **A duplicate variant is rejected by `option_set_hash`.** The server hashes the chosen option-value set
|
||||
per (nurse, category) and enforces uniqueness, so a nurse cannot list the same configuration twice at two
|
||||
prices. The client surfaces the resulting `409`, it does not pre-check.
|
||||
- **The variant snapshot is serialised at booking time** (`IVariantSnapshotSerializer`) onto the booking
|
||||
row — see [bookings.md](bookings.md). Editing or deactivating a variant never changes a past booking.
|
||||
- **`set_active` is the only way to retire a variant.** There is no delete; a variant referenced by
|
||||
bookings must remain resolvable.
|
||||
- **Prices are IRR digit strings** outbound. The nurse enters Toman in the UI and the client converts at
|
||||
the input boundary — the wire is always IRR.
|
||||
- Reference names come as **both** `nameFa` and `nameEn`; the client picks by locale. `OptionGroupDto`
|
||||
carries its `values` inline, so the variant builder needs one round trip, not one per group.
|
||||
- `isRequired` + `sortOrder` on an option group drive the builder's validation and layout — the client
|
||||
does not hardcode either.
|
||||
|
||||
## Enums
|
||||
|
||||
| Vocabulary | Values |
|
||||
| --- | --- |
|
||||
| `PriceUnit` | `per_hour` `per_session` `per_half_day` `per_day` `per_24h` |
|
||||
|
||||
`PriceUnit` is a label vocabulary, never a multiplier — the client must not derive a total from it. Display
|
||||
strings are i18n keys, never the code.
|
||||
|
||||
## Open REQs
|
||||
|
||||
None. The variant builder (b7) and the Home category grid (A5) both read the contract as served.
|
||||
@@ -0,0 +1,64 @@
|
||||
# geography — provinces, cities, districts
|
||||
|
||||
> Client seam `client/src/services/geography/` · `USE_GEOGRAPHY_MOCK = false` (**real**) · 13 server ops
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
|
||||
|
||||
The reference hierarchy every address, service area and search filter is keyed on. Also the home of the
|
||||
single most load-bearing null in the schema.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Auth | Verdict |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/api/v1/geo/provinces` | **anonymous** | wired |
|
||||
| GET | `/api/v1/geo/cities` | **anonymous** | wired |
|
||||
| GET | `/api/v1/geo/districts` | **anonymous** | wired |
|
||||
| GET | `/api/v1/geo/tree` | **anonymous** | **unwired** — the client fetches the three levels separately and caches each |
|
||||
| POST | `/api/v1/admin_geo/create_province` | admin | **unwired** — no console screen |
|
||||
| POST | `/api/v1/admin_geo/update_province/{id}` | admin | **unwired** |
|
||||
| POST | `/api/v1/admin_geo/set_province_active/{id}` | admin | **unwired** |
|
||||
| POST | `/api/v1/admin_geo/create_city` | admin | **unwired** |
|
||||
| POST | `/api/v1/admin_geo/update_city/{id}` | admin | **unwired** |
|
||||
| POST | `/api/v1/admin_geo/set_city_active/{id}` | admin | **unwired** |
|
||||
| POST | `/api/v1/admin_geo/create_district` | admin | **unwired** |
|
||||
| POST | `/api/v1/admin_geo/update_district/{id}` | admin | **unwired** |
|
||||
| POST | `/api/v1/admin_geo/set_district_active/{id}` | admin | **unwired** |
|
||||
|
||||
No phantoms. The nine `admin_geo` routes are `DynamicPermission` + `sensitive`; geography is seeded, so
|
||||
there is no console editor. Mutations are **action-style** (`admin_geo/create_city`, never
|
||||
`admin_geo/cities`).
|
||||
|
||||
## `districtId = null` means whole-city
|
||||
|
||||
This is the one rule to get right, and it reads in **both** directions:
|
||||
|
||||
- **On a nurse service area** (see [service-areas.md](service-areas.md)), `districtId = null` means the
|
||||
nurse covers the **entire city**, not "no district".
|
||||
- **On a search query**, a customer in district *D* must match both a nurse whose area names *D* and a
|
||||
nurse whose area is whole-city. See [search.md](search.md).
|
||||
- **On an address** it is genuinely optional metadata — a missing district does not widen anything.
|
||||
|
||||
Never coerce the null to 0 or to a sentinel id, and never write a query that drops whole-city rows.
|
||||
|
||||
## Shape rules the JSON does not express
|
||||
|
||||
- Every level returns **both** `nameFa` and `nameEn`; the client picks by locale.
|
||||
- `isActive` filters the pickers. An inactive city must stay **resolvable** — an existing address or
|
||||
booking references it — so it is filtered from selection, never deleted.
|
||||
- **Two Neshan keys exist and they are different products.** The server's geocoder key is
|
||||
`Seams:Geocoding:ApiKey` (server-side address→point); the client's map key is
|
||||
`NEXT_PUBLIC_NESHAN_KEY` (a *web* key for the embeddable map/search). Never share one value between
|
||||
them. With the client key unset, `AddressMapPicker` falls back to a bounded-canvas grid, which is why
|
||||
dev, CI and jsdom all work without it. See [../config-matrix.md](../config-matrix.md).
|
||||
- Geocoding is a seam: `Seams:Geocoding:Provider` = `mock` (default) or `neshan`. The mock resolves a
|
||||
deterministic point near the city centroid; an address containing `NO_GEO` resolves to null coordinates
|
||||
so the "saved without a map pin" state is testable per-request.
|
||||
|
||||
## Enums
|
||||
|
||||
None. All three levels are integer ids with localised names.
|
||||
|
||||
## Open REQs
|
||||
|
||||
None. REQ-008 (accept the client-picked pin) and REQ-009 (`provinceId` on the address DTO) were delivered
|
||||
and are documented in [addresses.md](addresses.md).
|
||||
@@ -0,0 +1,121 @@
|
||||
# Domain contracts
|
||||
|
||||
One file per client `services/` domain — **22 files, 22 domains, one-to-one.** Each names every server
|
||||
endpoint that belongs to it, verdicted against the live swagger and the real client code.
|
||||
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` and
|
||||
> [`../openapi/swagger.v1.json`](../openapi/swagger.v1.json) (2026-07-29).
|
||||
|
||||
Read [`../api-contract.md`](../api-contract.md) first — the envelope, casing, pagination, errors, auth,
|
||||
idempotency and money rules hold everywhere and are not restated per domain.
|
||||
|
||||
---
|
||||
|
||||
## How to read a domain file
|
||||
|
||||
Every endpoint carries one verdict:
|
||||
|
||||
| Verdict | Means |
|
||||
| --- | --- |
|
||||
| **wired** | In swagger **and** called by the domain's real `apis/clientApi.ts` |
|
||||
| **unwired** | In swagger, no real client caller. Server-only, admin-only, or superseded — the reason is given |
|
||||
| **phantom** | The client calls it; **the server has no such route.** It 404s. Always carries its REQ |
|
||||
|
||||
`phantom` rows are the frontend's proposed routes, filed as REQs and mocked behind the domain seam
|
||||
meanwhile. They are not bugs in the client — they are the contract's open edge — but on a domain whose
|
||||
mock is **off** they are live 404s, and that is called out where it happens.
|
||||
|
||||
## The census
|
||||
|
||||
186 operations, each in exactly one file below. 184 belong to a domain; 2 (`ping`) are platform
|
||||
endpoints and live in [`../api-contract.md`](../api-contract.md#platform-endpoints-outside-every-domain).
|
||||
|
||||
| Domain file | `client/src/services/` | Seam | Server ops | Phantom |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| [addresses.md](addresses.md) | `addresses` | real | 5 | — |
|
||||
| [admin.md](admin.md) | `admin` | **mock** | 14 | 5 |
|
||||
| [auth.md](auth.md) | `auth` | real | 7 | — |
|
||||
| [bnpl.md](bnpl.md) | `bnpl` | **mock** | 9 | 3 |
|
||||
| [booking-requests.md](booking-requests.md) | `bookingRequests` | real | 7 | — |
|
||||
| [bookings.md](bookings.md) | `bookings` | real | 16 | — |
|
||||
| [catalog.md](catalog.md) | `catalog` | real | 14 | — |
|
||||
| [geography.md](geography.md) | `geography` | real | 13 | — |
|
||||
| [notifications.md](notifications.md) | `notifications` | real | 4 | — |
|
||||
| [nurse.md](nurse.md) | `nurse` | real | 4 | — |
|
||||
| [partner-center.md](partner-center.md) | `partnerCenter` | **mock** | 9 | 6 |
|
||||
| [patient-records.md](patient-records.md) | `patientRecords` | **mock** | 5 | — |
|
||||
| [patients.md](patients.md) | `patients` | real | 5 | — |
|
||||
| [payment.md](payment.md) | `payment` | real | 3 | 1 |
|
||||
| [payouts.md](payouts.md) | `payouts` | **mock** | 13 | 1 |
|
||||
| [profiles.md](profiles.md) | `profiles` | real | 7 | — |
|
||||
| [refunds.md](refunds.md) | `refunds` | **mock** | 8 | 4 |
|
||||
| [reviews.md](reviews.md) | `reviews` | real | 8 | — |
|
||||
| [search.md](search.md) | `search` | real | 2 | — |
|
||||
| [service-areas.md](service-areas.md) | `serviceAreas` | real | 3 | — |
|
||||
| [tickets.md](tickets.md) | `tickets` | real | 11 | 1 |
|
||||
| [verification.md](verification.md) | `verification` | **mock** | 17 | 3 |
|
||||
| | | | **184** | **24** |
|
||||
|
||||
"Seam" is the domain's `constants.ts` flag (`USE_<DOMAIN>_MOCK`): **15 real, 7 mock.** A mocked domain
|
||||
still has a complete real client — flipping the flag is one line in `apis/index.ts`.
|
||||
|
||||
**Two phantoms sit on a domain whose mock is off**, so they are reachable and they 404:
|
||||
`GET /api/v1/bookings/payment_history` ([payment.md](payment.md), REQ-047) and
|
||||
`POST /api/v1/tickets/{id}/assign` ([tickets.md](tickets.md), REQ-063). Both are guarded in the client —
|
||||
the first renders an empty state, the second is behind a default-off capability flag.
|
||||
|
||||
## Route-shape exceptions
|
||||
|
||||
The routing convention is snake_case segments generated from `[controller]`/`[action]` tokens. **Four
|
||||
controllers hardcode a route string instead**, and three of those introduce hyphens:
|
||||
|
||||
| Route | Controller | Note |
|
||||
| --- | --- | --- |
|
||||
| `api/v1/admin/partner-centers` | `AdminPartnerCentersController` | hyphens **and** a nested `admin/` segment; children add `/set-active`, `/sponsor-nurse` |
|
||||
| `api/v1/admin/tickets` | `AdminTicketsController` | nested `admin/` segment |
|
||||
| `api/v1/admin/reviews/moderation_queue` | `AdminReviewsController` | nested `admin/`, then snake_case |
|
||||
| `api/v1/internal/bookings/{bookingId}/center` | `InternalCentersController` | an `internal/` namespace |
|
||||
|
||||
Every other admin controller uses a flat `admin_*` prefix (`admin_geo`, `admin_catalog`, `admin_refunds`,
|
||||
…). The split is historical, not meaningful. Since the route also derives the dynamic-permission key,
|
||||
normalising it is a breaking change to permissions as well as URLs — it is recorded here, not fixed.
|
||||
|
||||
## Enum vocabularies
|
||||
|
||||
Swagger declares **no** string enums (see
|
||||
[`../api-contract.md`](../api-contract.md#enums)), so each domain file carries its own vocabulary. Every
|
||||
one was cross-checked against the server's `Baya.Domain` code set **and** the client's string-literal
|
||||
union. All match except one, noted in [tickets.md](tickets.md).
|
||||
|
||||
| Vocabulary | Domain file | Server source |
|
||||
| --- | --- | --- |
|
||||
| `BookingRequestStatus` · `RequiredCaregiverGender` | [booking-requests.md](booking-requests.md) | `Entities/Booking/BookingRequestStatus.cs` |
|
||||
| `BookingStatus` · `BookingSessionStatus` · `VisitVerificationStatus` | [bookings.md](bookings.md) | `Entities/Booking/*.cs` |
|
||||
| `PriceUnit` | [catalog.md](catalog.md) | catalog config rows |
|
||||
| `BnplStatus` · `BnplEligibilityStatus` · `ProviderCode` | [bnpl.md](bnpl.md) | `Entities/Bnpl/*.cs` |
|
||||
| `PaymentTransactionStatus` · `MoadianStatus` | [payment.md](payment.md) | `Entities/Payments/`, `Entities/Invoices/` |
|
||||
| `RefundStatus` · `RefundChannel` · `ClawbackStatus` · cancellation codes | [refunds.md](refunds.md) | `Entities/Refunds/*.cs` |
|
||||
| `PayoutStatus` · `PayoutBatchStatus` · `EarningsState` | [payouts.md](payouts.md) | `Entities/Payouts/*.cs` |
|
||||
| `VerificationStatus` · `VerificationStepStatus` · `StepTypeCode` | [verification.md](verification.md) | `Entities/Verification/*.cs` |
|
||||
| `ModerationStatus` · `ModerationAction` | [reviews.md](reviews.md) | `Entities/Reviews/ReviewModerationStatus.cs` |
|
||||
| `TicketStatus` · `TicketCategory` · `TicketAuthorRole` | [tickets.md](tickets.md) | `Entities/Messaging/TicketCodes.cs` |
|
||||
| `CenterOnboardingState` | [partner-center.md](partner-center.md) | `Entities/PartnerCenters/` |
|
||||
| `BankAccountStatus` | [nurse.md](nurse.md) | bank-account entity |
|
||||
| roles · `Gender` | [auth.md](auth.md) | identity seed |
|
||||
| config/audit/holiday/alert codes | [admin.md](admin.md) | `Entities/Configuration/`, `Audit/`, `Holidays/`, `SupportAlerts/` |
|
||||
|
||||
## What replaced what
|
||||
|
||||
These files supersede [`archive/build-chain/contracts/domains/`](../../../archive/build-chain/contracts/domains/) — 17 hand-written files
|
||||
frozen 2026-07-13, plus the two `conventions/` files. Route-level content there held up well: an audit of
|
||||
every route those files name found **zero** that the live swagger lacks. What did not hold up:
|
||||
|
||||
| Was | Now |
|
||||
| --- | --- |
|
||||
| `conventions/api-conventions.md`: body casing is "typically `snake_case` … derive from swagger" | **camelCase**, proven mechanically. [`../api-contract.md`](../api-contract.md#casing) |
|
||||
| `conventions/api-conventions.md`: server default `https://localhost:5002` | `http://localhost:5002` — plain HTTP (contradiction **C-3**) |
|
||||
| The envelope has 5 fields | It has **6** — `code` was added for machine-readable errors (REQ-003) |
|
||||
| Enum vocabularies spread across 17 files and the REQ ledger | One vocabulary block per domain file, cross-checked both ways |
|
||||
| `messaging.md` (851 B, headerless) silently amending `messaging-notifications-admin.md` | Merged: [tickets.md](tickets.md) + [notifications.md](notifications.md) + [admin.md](admin.md) (contradiction **C-8**) |
|
||||
| 17 files whose names matched *backend phases* | 22 files whose names match the **client's domains**, which is how the seam is actually consumed |
|
||||
| The REQ ledger as the change log you had to read to know the current shape | Each domain file states the current shape and lists only its **open** REQs |
|
||||
@@ -0,0 +1,48 @@
|
||||
# notifications — the in-app feed and unread badge
|
||||
|
||||
> Client seam `client/src/services/notifications/` · `USE_NOTIFICATIONS_MOCK = false` (**real**) · 4 server ops
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
|
||||
|
||||
Server-raised, user-scoped notifications. In-app only — there is no push channel and no email channel.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Verdict |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/notifications/get_notifications` | wired · paginated |
|
||||
| GET | `/api/v1/notifications/get_unread_count` | wired — polled for the bell badge |
|
||||
| POST | `/api/v1/notifications/mark_notification_read` | wired |
|
||||
| POST | `/api/v1/notifications/mark_all_read` | wired |
|
||||
|
||||
All `[Authorize]`. No phantoms. The domain maps 1:1.
|
||||
|
||||
## Shape rules the JSON does not express
|
||||
|
||||
- **`dataJson` is a string containing JSON**, not an object. It is the deep-link payload: the client parses
|
||||
it (`parse.ts`) and resolves a route from it (`deepLink.ts`), both with unit tests, and **falls back to
|
||||
an inert notification rather than throwing** on anything unrecognised. A notification whose payload
|
||||
cannot be parsed still renders — it just is not tappable.
|
||||
- **Unread count is a separate read, not derived from the list.** The badge must be correct without
|
||||
fetching a page, so `get_unread_count` is its own cheap query.
|
||||
- **`mark_all_read` is a bulk write**; the client invalidates both the list and the count keys, and does not
|
||||
patch items locally.
|
||||
- The feed is **day-grouped in the UI** with Shamsi headers — a client-side transform over UTC
|
||||
`createdAt`. The server sends no grouping.
|
||||
- Notifications are raised through a self-committing facade (`DispatchAsync`) that runs **after** the
|
||||
originating handler's `CommitAsync` — so a notification never exists for a transaction that rolled back.
|
||||
|
||||
## Enums
|
||||
|
||||
`type` is a **bare string** on the wire and the vocabulary is open-ended by design — new server-side
|
||||
notification types must not break an older client. The client models the *payload* as a discriminated
|
||||
union (`NotificationData`, keyed on a `kind` inside `dataJson`) and treats an unknown `type` as
|
||||
non-actionable rather than an error.
|
||||
|
||||
Consequence for the server: **adding a notification type is safe; changing an existing type's `dataJson`
|
||||
shape is not.** The deep-link parser keys off the payload, not the type string.
|
||||
|
||||
## Open REQs
|
||||
|
||||
| REQ | Status | Effect |
|
||||
| --- | --- | --- |
|
||||
| REQ-054 | deferred, non-blocking | No web push for new booking requests. The nurse dashboard's 15 s poll remains the only signal. Nothing was built for it |
|
||||
@@ -0,0 +1,53 @@
|
||||
# nurse — nurse bank accounts
|
||||
|
||||
> Client seam `client/src/services/nurse/` · `USE_NURSE_BANK_MOCK = false` (**real**) · 4 server ops
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
|
||||
|
||||
The nurse's payout destination. Narrow domain, high stakes: a payout cannot be paid to an unverified IBAN.
|
||||
|
||||
> The domain is named `nurse`, not `nurse-bank-accounts`, because that is the client folder name. The
|
||||
> nurse's *profile* lives in [profiles.md](profiles.md); coverage in
|
||||
> [service-areas.md](service-areas.md); verification in [verification.md](verification.md).
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Rate limit | Verdict |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/api/v1/nurse_bank_accounts/list` | — | wired |
|
||||
| POST | `/api/v1/nurse_bank_accounts/add` | `sensitive` 20/min | wired |
|
||||
| POST | `/api/v1/nurse_bank_accounts/set_primary/{id}` | — | wired |
|
||||
| POST | `/api/v1/nurse_bank_accounts/verify_ownership/{id}` | `sensitive` 20/min | wired |
|
||||
|
||||
All `[Authorize]`. No phantoms. The domain maps 1:1.
|
||||
|
||||
## Shape rules the JSON does not express
|
||||
|
||||
- **`iban_hash` is UNIQUE across the platform.** The same IBAN cannot be registered by two nurses; the
|
||||
second `add` returns a `409`. The uniqueness is enforced on a deterministic hash, not the encrypted
|
||||
column.
|
||||
- **The IBAN is encrypted at rest and returned masked** — `maskedIban` on every read model, including the
|
||||
admin-side `PayoutDto` and the nurse's own `NursePayoutHistoryDto`. **The full IBAN is never returned
|
||||
after the write that created it.** Write-then-masked is the pattern.
|
||||
- **`verify_ownership` is استعلام شبا** — a Shahkar-class inquiry that confirms the account holder's
|
||||
national id matches the nurse's. It is a seam: `Seams:BankOwnership:Provider` = `mock` (default) or
|
||||
`finnotech`. The mock returns a match for every IBAN **except** `Seams:BankOwnership:MismatchIban`
|
||||
(`IR000000000000000000000000`), which exists so the payout-gating path is testable.
|
||||
- **Ownership verification gates payouts, and the gate lives in the payout engine, not here.**
|
||||
`EligibleNurseEarningsDto.hasVerifiedPrimaryIban` is the flag the admin console reads before generating
|
||||
a batch — see [payouts.md](payouts.md). A nurse with earnings and no verified primary IBAN accrues a
|
||||
balance and is simply not paid.
|
||||
- Client-side IBAN handling (`iban.ts`) does checksum validation and formatting only. It is a UX
|
||||
affordance; the server re-validates.
|
||||
|
||||
## Enums
|
||||
|
||||
| Vocabulary | Values |
|
||||
| --- | --- |
|
||||
| `BankAccountStatus` | `pending` `verified` `mismatch` |
|
||||
|
||||
`mismatch` is a terminal, actionable state — the holder's national id did not match — and is distinct from
|
||||
`pending`, which only means the inquiry has not run.
|
||||
|
||||
## Open REQs
|
||||
|
||||
None.
|
||||
@@ -0,0 +1,99 @@
|
||||
# partner-center — nursing companies
|
||||
|
||||
> Client seam `client/src/services/partnerCenter/` · `USE_PARTNER_MOCK = true` (**mock is primary**) · 9 server ops
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
|
||||
|
||||
Nursing companies («مراکز») that sponsor nurses onto the platform. The domain with the **widest gap between
|
||||
what the client wants and what the server serves** — 6 of its client calls are phantom.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Verdict |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/admin/partner-centers` | wired · paginated |
|
||||
| POST | `/api/v1/admin/partner-centers` | wired |
|
||||
| GET | `/api/v1/admin/partner-centers/{id}` | wired |
|
||||
| PATCH | `/api/v1/admin/partner-centers/{id}` | wired |
|
||||
| POST | `/api/v1/admin/partner-centers/{id}/set-active` | wired — REQ-032's delivered half |
|
||||
| POST | `/api/v1/admin/partner-centers/{id}/sponsor-nurse` | wired |
|
||||
| POST | `/api/v1/admin/partner-centers/{id}/verify` | wired |
|
||||
| GET | `/api/v1/centers/{id}/dashboard` | **unwired** — the client wants `centers/me/*` splits instead |
|
||||
| GET | `/api/v1/internal/bookings/{bookingId}/center` | **unwired** — the MoR resolver, server-internal |
|
||||
|
||||
The seven `admin/partner-centers` routes are `DynamicPermission` + `sensitive`; `centers` is `[Authorize]`;
|
||||
`internal/bookings` is `DynamicPermission`.
|
||||
|
||||
This domain owns **three of the four route-shape exceptions** in the API: hyphens (`partner-centers`,
|
||||
`set-active`, `sponsor-nurse`), a nested `admin/` segment, and an `internal/` namespace. It also has one of
|
||||
only two `PATCH` verbs. See [index.md](index.md#route-shape-exceptions).
|
||||
|
||||
### Phantom — 6
|
||||
|
||||
| Client call | REQ | Note |
|
||||
| --- | --- | --- |
|
||||
| `GET /api/v1/centers/me` | REQ-032 | The portal's own-center read |
|
||||
| `GET /api/v1/centers/me/nurses` | REQ-032 | Split read — the server serves one aggregate instead |
|
||||
| `GET /api/v1/centers/me/bookings` | REQ-032 | Split read |
|
||||
| `GET /api/v1/centers/me/bookings/{id}` | REQ-064 | Partner-scoped booking detail |
|
||||
| `GET /api/v1/centers/me/settlement` | REQ-033 | Per-booking commission invoices |
|
||||
| `GET /api/v1/admin/partner-centers/{id}/nurses` | REQ-032 | The admin-side sponsored-nurse list |
|
||||
|
||||
**The shape mismatch is the point.** The server serves **one aggregate**, `GET centers/{id}/dashboard` →
|
||||
`CenterDashboardDto` with `sponsoredNurses` inline. The portal wants **`/me` plus paginated splits** — it
|
||||
cannot page an inline array, and it does not know its own center id without REQ-038. Until REQ-032 lands,
|
||||
the portal is mock-only.
|
||||
|
||||
## Merchant of record
|
||||
|
||||
The one business rule that changes where money goes:
|
||||
|
||||
- **`isMerchantOfRecord = true`** → the *center* is the seller. It invoices the customer, holds the
|
||||
commercial relationship, and Balinyaar's cut is a commission **against the center**.
|
||||
- **`isMerchantOfRecord = false`** → the nurse is the seller and the center is a sponsor only.
|
||||
|
||||
`GET internal/bookings/{bookingId}/center` is the **MoR resolver** the invoice pipeline calls to decide
|
||||
which entity issues the invoice — which is why `InvoiceDto.issuingEntityType` exists. See
|
||||
[payment.md](payment.md). `commissionRate` on the center is a **per-center override** of the platform
|
||||
default, snapshotted at compute time like every other rate.
|
||||
|
||||
## Shape rules the JSON does not express
|
||||
|
||||
- **The settlement IBAN is write-then-masked.** `settlementIbanMasked` is the only form on every read model;
|
||||
the full value never comes back after the write that set it. Same pattern as
|
||||
[nurse.md](nurse.md).
|
||||
- **`verify` is a licence check behind a seam.** `ILicenseVerificationService` checks the eNamad code and the
|
||||
MoH establishment permit; by default it returns `NeedsManualReview`, so `verify` records a **human admin
|
||||
decision**. `Seams:LicenseVerification:AutoApprove` makes the mock return `Valid` to test the
|
||||
auto-approve path. A real eNamad/MoH registry adapter ignores the knob.
|
||||
- **`technicalDirectorNurseUserId` links to a real verified nurse**, not a free-text name — Iranian
|
||||
regulation requires a named technical director («مدیر فنی») with a valid licence.
|
||||
- **`set-active` is suspend/activate, not delete.** A suspended center's sponsored nurses and past bookings
|
||||
stay resolvable.
|
||||
- `sponsoredNurseCount` is denormalised onto both the list item and the detail so the queue needs no
|
||||
per-row count.
|
||||
- `legalEntityType` and `mohEstablishmentPermitNo` are the regulatory identity; `enamadCode` is the
|
||||
e-commerce trust seal. All three are distinct and none substitutes for another.
|
||||
|
||||
## Enums
|
||||
|
||||
| Vocabulary | Values |
|
||||
| --- | --- |
|
||||
| `CenterOnboardingState` | `draft` `pending_verification` `verified` `suspended` |
|
||||
| `MoadianStatus` *(on the center's invoices)* | `pending` `submitted` `registered` `failed` |
|
||||
|
||||
`MoadianStatus` is shared with [payment.md](payment.md) — it is the سامانه مودیان submission state, and it
|
||||
is the same vocabulary on both sides.
|
||||
|
||||
> `CenterOnboardingState` is the client's model of the center's position in onboarding. On the wire the
|
||||
> server carries the **facts** it is derived from — `isActive` and `verifiedAt` on both
|
||||
> `PartnerCenterListItemDto` and `PartnerCenterDetailDto` — not the state string itself. Derive, do not
|
||||
> expect a field.
|
||||
|
||||
## Open REQs
|
||||
|
||||
| REQ | Status | Effect |
|
||||
| --- | --- | --- |
|
||||
| REQ-032 | partially delivered | `set-active` landed. The `/me` split reads and the IBAN write-then-masked flow are deferred → 5 phantom routes. **The main reason this seam is mocked** |
|
||||
| REQ-033 | partially delivered | `totalIrr` is on `InvoiceDto`; the per-booking commission invoice list is deferred → 1 phantom |
|
||||
| REQ-064 | open | No partner-scoped booking detail → 1 phantom |
|
||||
| REQ-038 | open | `/me` carries no signal that the caller administers a center, so the portal cannot auto-route or discover its own center id. See [auth.md](auth.md) |
|
||||
@@ -0,0 +1,83 @@
|
||||
# patient-records — the care plan and visit records
|
||||
|
||||
> Client seam `client/src/services/patientRecords/` · `USE_PATIENT_RECORDS_MOCK = true` (**mock is primary**) · 5 server ops
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
|
||||
|
||||
The clinical content: a family-owned **care plan** (medications, routine, tasks) and the **append-only**
|
||||
visit records nurses write against it. The patient rows themselves are [patients.md](patients.md).
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Verdict |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/patients/{patientId}/care_record` | wired — the care plan |
|
||||
| PUT | `/api/v1/patients/{patientId}/care_record` | wired — **the only `PUT` in the API** |
|
||||
| GET | `/api/v1/patients/{patientId}/care_records` | wired · paginated (**`page`/`pageSize`**) — visit history |
|
||||
| POST | `/api/v1/patients/{patientId}/care_records` | wired — a nurse writes one visit record |
|
||||
| GET | `/api/v1/patients/{patientId}/record_access` | wired — **ask before you read** |
|
||||
|
||||
All `[Authorize]`. **No phantoms — every route the client calls exists.** The seam is mocked for UI
|
||||
completeness, not because the contract is missing.
|
||||
|
||||
> Singular vs. plural is load-bearing here: `care_record` (no `s`) is the **plan**; `care_records` is the
|
||||
> **visit history**. They are different resources on adjacent paths.
|
||||
|
||||
## Two different ownership models on one path
|
||||
|
||||
| | `care_record` (plan) | `care_records` (visits) |
|
||||
| --- | --- | --- |
|
||||
| Owner | the **family** (the customer) | the **nurse** who performed the visit |
|
||||
| Write | `PUT` — upsert, replaces | `POST` — **append only** |
|
||||
| Edit after the fact | yes, it is a living plan | **no** |
|
||||
| Delete | no | **no** |
|
||||
|
||||
**A nurse can never edit or delete a visit record.** It is a clinical record: append-only is the whole
|
||||
point, and there is no endpoint that would allow otherwise. Do not add one.
|
||||
|
||||
## `record_access` — check before you read
|
||||
|
||||
`GET record_access` answers "may this caller read this patient's records, and if not, why". The client
|
||||
calls it **first** and renders the denial state rather than firing a read and interpreting an error.
|
||||
|
||||
Two reasons come back, and they are deliberately hard to tell apart from outside:
|
||||
|
||||
| `RecordAccessDeniedReason` | Means |
|
||||
| --- | --- |
|
||||
| `no_access` | The patient exists; you are not authorised |
|
||||
| `not_found` | No such patient — **or** a tenancy mismatch |
|
||||
|
||||
That second row is the platform's tenancy rule: a row you do not own is a **404, never a 403**, because a
|
||||
403 confirms it exists. See [../api-contract.md](../api-contract.md#status-codes).
|
||||
|
||||
## Shape rules the JSON does not express
|
||||
|
||||
- **Every record body is encrypted at rest** and decrypted only for an authorised caller. Care content is
|
||||
**never** projected into a list, never logged, and never included in a search index.
|
||||
- **Nurse read access is scoped by an active booking**, not by having ever cared for the patient. The
|
||||
two-stage clinical disclosure rule applies: full care content is readable only post-confirmation, only by
|
||||
the assigned nurse and admin. See [bookings.md](bookings.md).
|
||||
- **`CarePlanDto` is `{ patientId, medications, routine, tasks }`** — three structured lists, not free text,
|
||||
so the client can render a schedule and a checklist rather than a blob.
|
||||
- **`CareRecordDto.taskResults` is structured** (REQ-027, delivered): each visit reports per-task outcomes
|
||||
against the plan's tasks, which is what lets the family see whether the routine was actually followed.
|
||||
- **`nurseName` is on the visit record** so the history is attributable without a per-row lookup.
|
||||
- Dose units, frequency presets and times-of-day are **codes**; the UI labels are i18n keys. Never render
|
||||
the code, and never parse a frequency into a schedule client-side.
|
||||
|
||||
## Enums
|
||||
|
||||
| Vocabulary | Values |
|
||||
| --- | --- |
|
||||
| `DoseUnit` | `tablet` `capsule` `drop` `cc` `unit` |
|
||||
| `FrequencyPreset` | `once_daily` `twice_daily` `three_times_daily` `every_8_hours` `as_needed` |
|
||||
| `TimeOfDayCode` | `morning` `noon` `evening` `night` |
|
||||
| `RecordAccessDeniedReason` | `no_access` `not_found` |
|
||||
| `CareRecordTab` *(client UI only)* | `medications` `routine` `history` `tasks` |
|
||||
|
||||
`as_needed` (PRN) has **no** time-of-day and must not be rendered on a schedule grid.
|
||||
|
||||
## Open REQs
|
||||
|
||||
| REQ | Status | Effect |
|
||||
| --- | --- | --- |
|
||||
| REQ-027 | delivered | The family-owned care record (medications/routine/tasks), `record_access`, and structured task results are all served |
|
||||
@@ -0,0 +1,52 @@
|
||||
# patients — the care circle
|
||||
|
||||
> Client seam `client/src/services/patients/` · `USE_PATIENTS_MOCK = false` (**real**) · 5 server ops
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
|
||||
|
||||
The people a customer books care *for* — «حلقهٔ مراقبت» in the UI. A patient is owned by the customer who
|
||||
created them, never by a nurse. Clinical content about a patient lives in
|
||||
[patient-records.md](patient-records.md).
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Verdict |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/patients/list` | wired · paginated (`Page`/`PageSize`) |
|
||||
| GET | `/api/v1/patients/get/{id}` | wired |
|
||||
| POST | `/api/v1/patients/create` | wired |
|
||||
| POST | `/api/v1/patients/update/{id}` | wired |
|
||||
| POST | `/api/v1/patients/archive/{id}` | wired — **archive, not delete** |
|
||||
|
||||
All `[Authorize]`. No phantoms. The domain maps 1:1.
|
||||
|
||||
## Shape rules the JSON does not express
|
||||
|
||||
- **`relation` and `conditions` are on `PatientDto`** (REQ-005, delivered). `relation` is the family
|
||||
relationship shown on the record sheet; `conditions` is the coarse condition list used for triage.
|
||||
- **`gender` is load-bearing.** It drives same-gender caregiver matching, which is a near-hard requirement
|
||||
in this market. Never defaulted, never dropped, never inferred from a name.
|
||||
- **`initialMedicalNotes` is encrypted at rest** and readable only by the owning customer (and, post-
|
||||
confirmation, the assigned nurse via the booking's care-instructions read — see
|
||||
[bookings.md](bookings.md)). It is not the care record.
|
||||
- **Archive, never delete.** A patient referenced by a booking must stay resolvable; `isActive = false`
|
||||
removes them from pickers. There is no delete endpoint and there should not be.
|
||||
- **`displayName` is server-composed** from first/last name. The client renders `displayName` and uses the
|
||||
parts only in the edit form — so a naming-convention change is a server change, not a client one.
|
||||
- `birthDate` is a date; the client derives the age band (`age.ts`) for display. The **server** stamps
|
||||
`patientAge` on the nurse-facing booking-request list row (see
|
||||
[booking-requests.md](booking-requests.md)) — the nurse never receives a birth date.
|
||||
|
||||
## Enums
|
||||
|
||||
| Vocabulary | Values |
|
||||
| --- | --- |
|
||||
| `Gender` | `male` `female` |
|
||||
|
||||
`bloodType` is a free string, not an enum.
|
||||
|
||||
## Open REQs
|
||||
|
||||
| REQ | Status | Effect |
|
||||
| --- | --- | --- |
|
||||
| REQ-057 | open | `PatientDto` has no `lastVisitAt` (or `visitCount`), and `BookingListItemDto` has no `patientId`, so the booking card can render no care teaser. Either field unblocks it |
|
||||
| REQ-058 | deferred, non-blocking | No patient photo upload. `PatientDto` has no `avatarUrl` and no UI reads one — `InitialsAvatar` ships either way |
|
||||
@@ -0,0 +1,95 @@
|
||||
# payment — card checkout, the PSP webhook, invoices
|
||||
|
||||
> Client seam `client/src/services/payment/` · `USE_PAYMENT_MOCK = false` (**real**) · 3 server ops
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
|
||||
|
||||
The card money path. Three endpoints on the wire; the domain reads three more that belong to neighbours.
|
||||
Installment checkout is [bnpl.md](bnpl.md); reversals are [refunds.md](refunds.md).
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Auth | Verdict |
|
||||
| --- | --- | --- | --- |
|
||||
| POST | `/api/v1/bookings/{bookingRequestId}/payments` | `[Authorize]` · `sensitive` 20/min | wired · **`Idempotency-Key`** |
|
||||
| GET | `/api/v1/invoices/{bookingId}` | `[Authorize]` | wired |
|
||||
| POST | `/api/v1/webhooks/payments/{provider}` | **anonymous** · `webhook` 120/min | server-only — the PSP calls it |
|
||||
|
||||
Also read by this domain, documented with their owners:
|
||||
`GET booking_requests/checkout_summary/{id}` and `GET booking_requests/get/{id}`
|
||||
([booking-requests.md](booking-requests.md)).
|
||||
|
||||
### Phantom — 1
|
||||
|
||||
| Client call | REQ | Live? |
|
||||
| --- | --- | --- |
|
||||
| `GET /api/v1/bookings/payment_history` | REQ-047 | **Yes — this domain's mock is off.** The wallet «پرداختها» tab calls it, gets a 404, and renders its empty state. Guarded, but a real 404 on every visit |
|
||||
|
||||
## The flow, and why it is shaped this way
|
||||
|
||||
```
|
||||
accepted request ──initiate──▸ PSP hosted page ──customer pays──▸ PSP webhook
|
||||
(money-free) (redirectUrl) │
|
||||
▼
|
||||
server re-verifies, then creates + confirms the booking
|
||||
```
|
||||
|
||||
Four rules that follow, and they are the whole design:
|
||||
|
||||
1. **Payment is initiated against the accepted *request*, not a booking.** The `bookings` row does not
|
||||
exist yet. `POST bookings/{bookingRequestId}/payments` takes a **request** id despite the `bookings/`
|
||||
prefix — the route is misleading and the parameter name is the truth.
|
||||
2. **There is no client verify endpoint.** The server re-verifies with the acquirer *inside* the webhook
|
||||
handler. A client-reported "success" is never trusted.
|
||||
3. **The client learns the outcome by polling.** `getPaymentOutcome` maps the request status
|
||||
(`converted` → succeeded) with backoff. A first-class transaction-status read is REQ-017's remaining
|
||||
half.
|
||||
4. **One `Idempotency-Key` per attempt**, reused across retries of that attempt; a new attempt takes a new
|
||||
key. A `409` on initiate means "already in progress / already captured" — a benign convergence, and the
|
||||
client must not surface it as an error.
|
||||
|
||||
**Webhook idempotency does not use the header.** The handler upserts the provider event first, keyed on
|
||||
`external_event_id`, and no-ops on a duplicate; `bookings.booking_request_id` is `UNIQUE` so a replay
|
||||
cannot create a second booking; a unique-violation on confirm is treated as idempotent success. The DB
|
||||
constraint is the backstop, not the handler's `if`.
|
||||
|
||||
`POST bookings/convert` ([bookings.md](bookings.md)) is the **Development-only** capture simulator that
|
||||
stands in for the webhook locally. It is fail-closed outside Development/Testing.
|
||||
|
||||
## Shape rules the JSON does not express
|
||||
|
||||
- **`CheckoutSummaryDto` serves the money breakdown so the client never derives it** (REQ-016, delivered):
|
||||
`serviceCostIrr`, `commissionIrr`, `vatIrr`, `vatRate`, `totalIrr` **and** the three-amount split
|
||||
`grossPriceIrr` / `balinyaarCommissionIrr` / `nursePayoutAmount`. All digit strings.
|
||||
- **VAT is on Balinyaar's commission only** — the platform's taxable supply — never on the nurse payout.
|
||||
- **`InitiatePaymentResult` is `{ transactionId, redirectUrl, gatewayReferenceCode }`.** The client hands
|
||||
off to `redirectUrl` and keeps `transactionId` to poll.
|
||||
- **`InvoiceDto` carries `totalIrr`** (REQ-033, partial) = platform commission + BNPL commission + VAT, and
|
||||
`moadianStatus`/`moadianReferenceNumber` for the سامانه مودیان e-invoicing submission.
|
||||
`issuingEntityType` distinguishes a platform-issued invoice from a partner-center one — see
|
||||
[partner-center.md](partner-center.md).
|
||||
- **The acquirer is a seam.** `Seams:Payments:Provider` = `mock` (default) / `zarinpal` / `sadad` /
|
||||
`vandar` / `jibit`; `IPaymentProvider`, `ISettlementSplitProvider` and `IWebhookVerifier` swap together.
|
||||
Webhook signature secrets are per-provider (`Seams:Payments:WebhookSigningSecrets`), read from the
|
||||
`X-Signature` header by default. A provider with no signature falls back to the mandatory server-side
|
||||
re-verify.
|
||||
|
||||
## Enums
|
||||
|
||||
| Vocabulary | Values |
|
||||
| --- | --- |
|
||||
| `PaymentTransactionStatus` | `pending` `succeeded` `failed` |
|
||||
| `MoadianStatus` | `pending` `submitted` `registered` `failed` |
|
||||
| `GatewayReturnOutcome` *(client-side, from the return URL)* | `success` `failure` |
|
||||
|
||||
Verified against `Entities/Payments/PaymentTransactionStatus.cs` and `Entities/Invoices/MoadianStatus.cs`.
|
||||
`GatewayReturnOutcome` is a client reading of the acquirer's redirect and is **advisory only** — the
|
||||
authoritative outcome is the polled request status.
|
||||
|
||||
## Open REQs
|
||||
|
||||
| REQ | Status | Effect |
|
||||
| --- | --- | --- |
|
||||
| REQ-017 | delivered (partial in practice) | `bookingId` is on the converted request; the first-class transaction-status read is not, so the client polls the request status instead |
|
||||
| REQ-046 | open | No nurse identity on the checkout summary and no client-readable payment reference. The real receipt hides the identity avatar/badge and the tracking line |
|
||||
| REQ-047 | open | No customer payment-transactions list. **Live 404** on `bookings/payment_history`; the wallet tab renders empty |
|
||||
| REQ-049 | open | `InvoiceDto` has no payment method, transaction reference, or seller fiscal identity. The invoice renders the money breakdown and مودیان status unconditionally |
|
||||
@@ -0,0 +1,102 @@
|
||||
# payouts — weekly nurse settlement
|
||||
|
||||
> Client seam `client/src/services/payouts/` · `USE_PAYOUTS_MOCK = true` (**mock is primary**) · 13 server ops
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
|
||||
|
||||
Money going out, weekly, in batches. The nurse's view is read-only; the admin's view is the one place in
|
||||
the platform where an irreversible transfer is triggered by hand.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Auth | Verdict |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/api/v1/nurse_payouts/earnings` | `[Authorize]` | wired · paginated — per-booking earnings |
|
||||
| GET | `/api/v1/nurse_payouts/earnings_balance` | `[Authorize]` | wired — the four-bucket balance |
|
||||
| GET | `/api/v1/nurse_payouts/history` | `[Authorize]` | wired · paginated |
|
||||
| GET | `/api/v1/nurse_payouts/{id}` | `[Authorize]` | wired — payout detail |
|
||||
| GET | `/api/v1/nurses/{nurseId}/payable_balance` | `[Authorize]` | **unwired** — the nurse reads `earnings_balance` instead |
|
||||
| GET | `/api/v1/admin_payouts/eligible` | admin · `sensitive` | wired · paginated |
|
||||
| GET | `/api/v1/admin_payouts/batches` | admin · `sensitive` | wired · paginated |
|
||||
| POST | `/api/v1/admin_payouts/batches` | admin · `sensitive` | **unwired** — generation is automatic (below) |
|
||||
| GET | `/api/v1/admin_payouts/batches/{id}` | admin · `sensitive` | wired · paginated (**`page`/`pageSize`**) |
|
||||
| POST | `/api/v1/admin_payouts/batches/{id}/process` | admin · `sensitive` | **unwired** — the irreversible step; no console button yet |
|
||||
| POST | `/api/v1/admin_payouts/{payoutId}/retry` | admin · `sensitive` | wired |
|
||||
| POST | `/api/v1/admin_payouts/{payoutId}/mark_failed` | admin · `sensitive` | **unwired** |
|
||||
| POST | `/api/v1/webhooks/payouts/{provider}` | **anonymous** · `webhook` | server-only — the transferor's reconciliation callback |
|
||||
|
||||
This domain absorbed the **entire** wire-level drift between the 2026-07-13 contract freeze and the
|
||||
2026-07-29 snapshot — one added endpoint and one changed schema, both here:
|
||||
|
||||
- **Added (C-6):** `POST /api/v1/webhooks/payouts/{provider}` — the transferor's reconciliation callback.
|
||||
- **Changed (C-7):** `GeneratePayoutBatchCommand` gained **`systemInitiated: boolean`**, alongside the
|
||||
existing `periodStart`/`periodEnd` dates. That is the refinement-phase-7 scheduler flag: it distinguishes
|
||||
a batch the recurring job generated from one an admin generated, which is what keeps the
|
||||
"generation is automatic, processing is not" rule auditable.
|
||||
|
||||
### Phantom — 1
|
||||
|
||||
| Client call | REQ | Note |
|
||||
| --- | --- | --- |
|
||||
| `POST /api/v1/admin_payouts/{id}/transfer_reference` | REQ-036 | Deferred. The reference is *readable* on `PayoutDto.transferReference`; what is missing is a route to record one manually |
|
||||
|
||||
## Generation is automatic; processing is not
|
||||
|
||||
This is a hard platform rule, not a convention:
|
||||
|
||||
- A **scheduled job may generate** a `draft` batch. `IRecurringJob` + `RecurringJobSchedulerHostedService`
|
||||
do this weekly, in-process (refinement-phase-7).
|
||||
- The **irreversible `process` step is always an explicit admin action.** No job, no schedule, no retry
|
||||
loop may trigger a real transfer.
|
||||
|
||||
Which is why `POST batches` is unwired (the job does it) and `POST batches/{id}/process` is unwired (the
|
||||
console has no button yet — that is the gap, and it is deliberate that nothing automated fills it).
|
||||
|
||||
## The money rules
|
||||
|
||||
- **`UNIQUE` on booking id: one payout per booking, ever.** The DB constraint is the authority; the handler
|
||||
does not rely on an `if`.
|
||||
- **The net balance is SIGNED and must never be clamped to zero.** A nurse with a clawback larger than
|
||||
their eligible earnings has a **negative** `netAmountIrr`. Rendering it as 0 tells them they have nothing
|
||||
owing when in fact they owe. The client displays the signed value.
|
||||
- **Clawback netting is whole-clawback greedy**, never partial: a clawback either fits in this batch or
|
||||
waits for the next. See [refunds.md](refunds.md).
|
||||
- **Payout dates are resolved against the bank-holiday calendar server-side.** The client never computes
|
||||
one — see [admin.md](admin.md). `nurse_payout_interval_days` and `payout_satna_threshold_irr` are config;
|
||||
the threshold selects PAYA vs SATNA.
|
||||
- **A verified primary IBAN gates payment, not accrual.** `EligibleNurseEarningsDto.hasVerifiedPrimaryIban`
|
||||
is what the console checks; a nurse without one accrues a balance and is not paid. See
|
||||
[nurse.md](nurse.md).
|
||||
- The IBAN is **always masked** on every read model, nurse-side and admin-side alike.
|
||||
|
||||
## A resolved drift
|
||||
|
||||
`payouts/apis/clientApi.ts` states that `NursePayoutHistoryDto` "carries **no** `failureReason` — that field
|
||||
lives on the admin-only `PayoutDto` … until REQ-025 adds it". **The live wire has it**: `failureReason` is
|
||||
on `NursePayoutHistoryDto` *and* `NursePayoutDetailDto` *and* `PayoutDto`. The comment predates the
|
||||
delivery; a `failed` payout can show its reason to the nurse today.
|
||||
|
||||
The client also sends `Idempotency-Key` on process/retry. **The server does not read it there** — only
|
||||
`PaymentsController` and `CheckoutBnplController` do. Harmless (those writes are idempotent by
|
||||
constraint), but the header is decorative on this domain. See
|
||||
[../api-contract.md](../api-contract.md#idempotency).
|
||||
|
||||
## Enums
|
||||
|
||||
| Vocabulary | Values |
|
||||
| --- | --- |
|
||||
| `PayoutStatus` | `pending` `submitted` `paid` `failed` |
|
||||
| `PayoutBatchStatus` | `draft` `processing` `partially_failed` `completed` `failed` |
|
||||
| `EarningsState` | `pending` `eligible` `paid` `clawback_applied` |
|
||||
|
||||
`PayoutStatus` and `PayoutBatchStatus` are verified identical to `Entities/Payouts/*.cs` and are
|
||||
forward-only through `PayoutStatusTransitions`. `partially_failed` is a real batch outcome — some
|
||||
destinations settled, some did not — and drives the single-payout `retry`; `Seams:BankTransfer:FailIban`
|
||||
exists to make it testable.
|
||||
|
||||
## Open REQs
|
||||
|
||||
| REQ | Status | Effect |
|
||||
| --- | --- | --- |
|
||||
| REQ-025 | delivered | The four-bucket balance, per-booking earnings list and payout detail are all served (including `failureReason`, above) |
|
||||
| REQ-036 | deferred | No single-payout preview, no `holidayShifted` flag, no record-transfer-reference route → 1 phantom |
|
||||
| REQ-053 | open | No payout forecast (next batch date + expected eligible amount). The nurse dashboard's forecast line renders nothing on the real path |
|
||||
@@ -0,0 +1,57 @@
|
||||
# profiles — customer and nurse profiles
|
||||
|
||||
> Client seam `client/src/services/profiles/` · `USE_PROFILES_MOCK = false` (**real**) · 7 server ops
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
|
||||
|
||||
One client domain over two server controllers, because the client's profile screens are one feature with
|
||||
two actor variants. Identity and roles come from [auth.md](auth.md)'s `/me`, not from here.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Verdict |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/customer_profiles/me` | wired |
|
||||
| POST | `/api/v1/customer_profiles/upsert` | wired |
|
||||
| POST | `/api/v1/customer_profiles/avatar` | **unwired** — the client's avatar upload calls the nurse route only |
|
||||
| GET | `/api/v1/nurse_profiles/me` | wired |
|
||||
| POST | `/api/v1/nurse_profiles/upsert` | wired |
|
||||
| POST | `/api/v1/nurse_profiles/avatar` | wired |
|
||||
| POST | `/api/v1/nurse_profiles/set_accepting_bookings` | wired |
|
||||
|
||||
All `[Authorize]`. No phantoms.
|
||||
|
||||
> **`customer_profiles/avatar` exists and is unused.** The nurse avatar upload is wired; the customer one
|
||||
> is not, so a customer cannot set a photo even though the endpoint is live. A one-line client gap, not a
|
||||
> contract gap.
|
||||
|
||||
## Shape rules the JSON does not express
|
||||
|
||||
- **Avatar upload is `multipart/form-data`.** This is the one place the client must **not** set
|
||||
`Content-Type` — `clientFetch` detects a `FormData` body and lets the browser write the multipart
|
||||
boundary. A manual JSON content-type there breaks the upload. See
|
||||
[../api-contract.md](../api-contract.md).
|
||||
- **`avatarUrl` is served by the object-storage seam.** `Seams:ObjectStorage:Provider` = `local` (default,
|
||||
writes under `RootPath`) or `s3`. In the deployment `RootPath` is a **named docker volume** — without it,
|
||||
uploads vanish on the next `up --build`. See [../topology.md](../topology.md).
|
||||
- **`isAcceptingBookings` is a real toggle with real consequences.** It is one of the four conditions the
|
||||
search index's `is_searchable` requires — flipping it off removes the nurse from discovery. See
|
||||
[search.md](search.md).
|
||||
- **`isVerified` on `NurseProfileDto` is derived, never writable.** It is written **only** by the
|
||||
verification finalize transaction when the aggregate reaches `approved`. Never set it from a profile
|
||||
write. See [verification.md](verification.md).
|
||||
- **`averageRating` / `totalReviews` / `totalCompletedBookings` are recomputed from source**, not
|
||||
incremented. A moderation change that hides a review recomputes the aggregate. See
|
||||
[reviews.md](reviews.md).
|
||||
- **`specializationsJson` is a string containing JSON**, like the other `*Json` fields on this wire.
|
||||
- `preferredLanguage` on `CustomerProfileDto` (REQ-007, delivered) alongside the name update.
|
||||
- `defaultEmergencyContactName`/`Phone` are the customer-level fallback used when a booking supplies none.
|
||||
|
||||
## Enums
|
||||
|
||||
None of its own. `educationLevel` and `educationField` are free strings. Gender lives on the user
|
||||
(see [auth.md](auth.md)), not on the profile.
|
||||
|
||||
## Open REQs
|
||||
|
||||
None. REQ-006 (avatar/object-storage upload route) and REQ-007 (name + preferred language) were both
|
||||
delivered in refinement-phase-3.
|
||||
@@ -0,0 +1,88 @@
|
||||
# refunds — cancellation, reversal, clawbacks, invoices
|
||||
|
||||
> Client seam `client/src/services/refunds/` · `USE_REFUNDS_MOCK = true` (**mock is primary**) · 8 server ops
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
|
||||
|
||||
Money going back. The customer half is thin and real; the admin half is largely deferred, which is why the
|
||||
seam is mocked.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Auth | Verdict |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/api/v1/refunds/by_booking/{bookingId}` | `[Authorize]` | wired — the customer's refund for a booking |
|
||||
| GET | `/api/v1/refunds/{id}/status` | `[Authorize]` | wired |
|
||||
| GET | `/api/v1/admin_refunds` | admin · `sensitive` | wired · paginated |
|
||||
| POST | `/api/v1/admin_refunds` | admin · `sensitive` | **unwired** — creates **and executes** in one call; the client wants preview → approve (REQ-035) |
|
||||
| POST | `/api/v1/admin_refunds/{id}/confirm_settlement` | admin · `sensitive` | **unwired** — the manual-channel settlement confirm |
|
||||
| POST | `/api/v1/admin_refunds/{id}/mark_failed` | admin · `sensitive` | **unwired** |
|
||||
| POST | `/api/v1/admin_clawbacks/{id}/write_off` | admin · `sensitive` | **unwired** — no console screen |
|
||||
| POST | `/api/v1/admin_invoices` | admin · `sensitive` | **unwired** — owner-issue an invoice (REQ-018's fallback path) |
|
||||
|
||||
Also wired by this domain, documented with their owner ([bookings.md](bookings.md)):
|
||||
`POST bookings/{id}/cancel` (cancel **and** refund, REQ-019) and
|
||||
`GET bookings/{id}/cancellation_policy` (the pre-cancel preview, REQ-020).
|
||||
|
||||
### Phantom — 4
|
||||
|
||||
| Client call | REQ | Note |
|
||||
| --- | --- | --- |
|
||||
| `POST /api/v1/admin_refunds/preview` | REQ-035 | Deferred. Today's single `POST admin_refunds` creates+executes with no preview step |
|
||||
| `POST /api/v1/admin_refunds/{id}/approve` | REQ-035 | Deferred |
|
||||
| `POST /api/v1/admin_refunds/{id}/reject` | REQ-035 | Deferred |
|
||||
| `GET /api/v1/refunds/my` | REQ-048 | The customer's "all my refunds" list. The wallet «استردادها» tab renders empty on the real path |
|
||||
|
||||
## The money rules
|
||||
|
||||
Four, and none of them are the client's to compute:
|
||||
|
||||
1. **A refund is a reversal leg, not a deletion.** `ledger_entries` is append-only; every posting group
|
||||
balances. Nothing is ever edited or removed.
|
||||
2. **Fee-leg decomposition is served, not derived.** `RefundStatusDto` carries
|
||||
`platformFeeRefundedIrr` and `nursePayoutRefundedIrr` separately (REQ-021, delivered) — a partial refund
|
||||
does not necessarily refund the commission and the payout in the same proportion. Never split a total.
|
||||
3. **Pre-payout and post-payout fork.** If the nurse has not been paid, the payout leg is simply reduced.
|
||||
If they have, the platform raises a **clawback**, which the payout engine nets against the nurse's next
|
||||
batch — whole-clawback greedy netting, never a partial. See [payouts.md](payouts.md).
|
||||
4. **VAT is on commission only**, so a refund's VAT leg follows the commission leg, never the payout.
|
||||
|
||||
## Shape rules the JSON does not express
|
||||
|
||||
- **`refundChannel` decides the mechanics and is not a display detail**: `psp_card` reverses through the
|
||||
acquirer, `bnpl_revert` calls the BNPL provider's revert (see [bnpl.md](bnpl.md)), `manual` is a bank
|
||||
transfer an admin confirms with `confirm_settlement`. Each has a different ETA, and
|
||||
`expectedCustomerRefundEta` is server-computed per channel — the client displays it verbatim.
|
||||
- **`CancellationPolicyPreviewDto` is the complete pre-cancel answer** (REQ-020, delivered):
|
||||
`cancellable`, `cancellationPolicyCode`, `refundPercentageApplied`, `feePercentage`, `refundAmountIrr`,
|
||||
`feeAmountIrr`, `refundableAmountIrr`, the two fee legs, `appliesTo`, `leadTimeLabel`, `refundChannel`,
|
||||
`expectedCustomerRefundEta`, and a **per-session** breakdown. The client shows it and asks for
|
||||
confirmation; it computes none of it.
|
||||
- **Cancellation tiers are config rows**, not code — `cancellation_tier1/2/3_refund_rate` in
|
||||
[admin.md](admin.md) — and the applied rate is **snapshotted onto the refund** at compute time, so a
|
||||
later tier change is not retroactive.
|
||||
- **`refundPercentage` on `CancellationPolicyDto` is a rate, `refundPercentageApplied` on the refund is the
|
||||
snapshot.** They can legitimately differ; that is the point.
|
||||
- The crash-window fix in refinement-phase-6 wired the previously unreachable BNPL and manual settlement
|
||||
paths — a refund created against those channels now actually clears.
|
||||
|
||||
## Enums
|
||||
|
||||
| Vocabulary | Values |
|
||||
| --- | --- |
|
||||
| `RefundStatus` | `requested` `approved` `processing` `succeeded` `failed` `rejected` |
|
||||
| `RefundChannel` | `psp_card` `bnpl_revert` `manual` |
|
||||
| `ClawbackStatus` | `pending` `recovered` `written_off` |
|
||||
| `CancellationPolicyCode` | `free_24h` `partial_under_24h` `customer_no_show` |
|
||||
| `CancellationLeadTime` | `gt_24h` `lt_24h` `started` |
|
||||
| `CancellationScope` | `whole_booking` `remaining_sessions` |
|
||||
| `CancelReasonCategory` *(client)* | `changed_mind` `schedule_conflict` `found_other_care` `other` |
|
||||
|
||||
The first three are verified identical to `Entities/Refunds/RefundStatus.cs` and `ClawbackStatus.cs`.
|
||||
|
||||
## Open REQs
|
||||
|
||||
| REQ | Status | Effect |
|
||||
| --- | --- | --- |
|
||||
| REQ-035 | deferred | No admin preview / approve / reject → 3 phantom routes. The one live route creates and executes together, which the console will not call |
|
||||
| REQ-048 | open | No customer "all my refunds" list → 1 phantom. The wallet tab renders empty on the real path |
|
||||
| REQ-033 | partially delivered | `totalIrr` is on `InvoiceDto`; the partner per-booking commission invoice list is deferred. See [partner-center.md](partner-center.md) |
|
||||
@@ -0,0 +1,85 @@
|
||||
# reviews — ratings, tags, moderation
|
||||
|
||||
> Client seam `client/src/services/reviews/` · `USE_REVIEWS_MOCK = false` (**real**) · 8 server ops
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
|
||||
|
||||
Customer reviews of a completed booking, pre-screened and then human-moderated before they are public.
|
||||
Clinical care records are a different domain — [patient-records.md](patient-records.md).
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Auth | Verdict |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/api/v1/bookings/{bookingId}/review_eligibility` | `[Authorize]` | wired |
|
||||
| GET | `/api/v1/bookings/{bookingId}/my_review` | `[Authorize]` | wired |
|
||||
| POST | `/api/v1/bookings/{bookingId}/review` | `[Authorize]` | wired |
|
||||
| GET | `/api/v1/nurses/{nurseProfileId}/reviews` | **anonymous** | wired · paginated (**`page`/`pageSize`**) |
|
||||
| GET | `/api/v1/nurses/{nurseProfileId}/review_tags` | **anonymous** | **unwired** — the client reads tags off the review rows |
|
||||
| PATCH | `/api/v1/reviews/{reviewId}/status` | `[Authorize]` | wired — the moderation decision |
|
||||
| POST | `/api/v1/reviews/{reviewId}/tags` | `[Authorize]` | **unwired** — no console screen edits tags |
|
||||
| GET | `/api/v1/admin/reviews/moderation_queue` | admin | wired · paginated |
|
||||
|
||||
No phantoms. Note `PATCH` — one of only two `PATCH` verbs in the whole API (the other is on
|
||||
[partner-center.md](partner-center.md)); everything else mutates with `POST`. Note also the hardcoded
|
||||
nested `admin/reviews/` route segment — see [index.md](index.md#route-shape-exceptions).
|
||||
|
||||
## Aggregates are recomputed, never incremented
|
||||
|
||||
`averageRating`, `totalReviews` and `totalCompletedBookings` on the nurse profile are **recomputed from
|
||||
source** whenever a review's moderation status changes. Hiding a published review must lower the average;
|
||||
an incrementing counter cannot do that correctly. Never `+= 1` a review aggregate. See
|
||||
[profiles.md](profiles.md).
|
||||
|
||||
## The moderation gate
|
||||
|
||||
```
|
||||
submit ──AI pre-screen──▸ pending_moderation ──admin──▸ published
|
||||
│ │
|
||||
banned word hit unpublish ──▸ hidden
|
||||
▼
|
||||
rejected
|
||||
```
|
||||
|
||||
- **A review is not public on submit.** `IReviewModerationService` pre-screens; by default clean text
|
||||
returns a human-review **flag**, keeping the gate on. `Seams:ReviewModeration:AutoApproveClean` makes
|
||||
clean text auto-publish; `BannedWords` (default `scam`, `fraud`, `کلاهبردار`) forces `reject`. Both are
|
||||
mock knobs — a real classifier ignores them.
|
||||
- **A low rating raises a support alert** (`low_rating`), linked as `lowRatingAlertId` on the queue item.
|
||||
See [admin.md](admin.md).
|
||||
- **`unpublish` is a distinct action from `hide`** in the client's `ModerationAction` union even though both
|
||||
land on `hidden` — the audit trail records which was chosen.
|
||||
|
||||
## Shape rules the JSON does not express
|
||||
|
||||
- **`ReviewEligibilityDto` is `{ canReview, reason }`** and the reason is a **code**, not a message:
|
||||
`not_completed` `already_reviewed` `not_owner` `not_found`. The client maps it to an i18n key. Eligibility
|
||||
is server-decided — the client must not infer it from booking status.
|
||||
- **The author is masked** (REQ-026, confirmed): a public review carries `authorMasked`, never a full name.
|
||||
This is a privacy decision, not a display choice.
|
||||
- **`tagCodes` is on `ModerationQueueItemDto`** (REQ-037, delivered) so the queue shows what the reviewer
|
||||
tagged without a second fetch.
|
||||
- **Review tags are codes; labels are i18n keys.** Never render a tag code, and never build a display
|
||||
string from one.
|
||||
- `moderationReason` is admin-facing free text and is **not** returned on the public review read.
|
||||
- The public reviews list is **anonymous and paginated with `page`/`pageSize`** (camelCase — unlike
|
||||
`search/nurses`, which uses `page_size`). See [../api-contract.md](../api-contract.md#pagination).
|
||||
|
||||
## Enums
|
||||
|
||||
| Vocabulary | Values |
|
||||
| --- | --- |
|
||||
| `ModerationStatus` | `pending_moderation` `published` `hidden` `rejected` |
|
||||
| `ModerationAction` | `publish` `hide` `reject` `unpublish` |
|
||||
| `ReviewIneligibilityReason` | `not_completed` `already_reviewed` `not_owner` `not_found` |
|
||||
|
||||
`ModerationStatus` and `ModerationAction` are both defined in
|
||||
`Entities/Reviews/ReviewModerationStatus.cs` and verified identical to the client's unions — the file
|
||||
carries the four states and the four actions together.
|
||||
|
||||
## Open REQs
|
||||
|
||||
| REQ | Status | Effect |
|
||||
| --- | --- | --- |
|
||||
| REQ-026 | delivered | Eligibility + my-review-for-booking reads, and masked-author confirmation |
|
||||
| REQ-037 | delivered | `tagCodes` on the moderation queue item |
|
||||
| REQ-040 | open | No `topReviewTag` on the search index row — the C2 card renders without the tag chip. Owned by [search.md](search.md) |
|
||||
@@ -0,0 +1,81 @@
|
||||
# search — nurse discovery
|
||||
|
||||
> Client seam `client/src/services/search/` · `USE_SEARCH_MOCK = false` (**real**) · 2 server ops
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
|
||||
|
||||
Two endpoints, both anonymous, both reading a **projected index** rather than joining live tables.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Auth | Verdict |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/api/v1/search/nurses` | **anonymous** | wired · paginated — **`page` + `page_size`** |
|
||||
| GET | `/api/v1/nurses/{nurseId}/profile` | **anonymous** | wired |
|
||||
|
||||
No phantoms. `POST /api/v1/admin_search/rebuild_index` is the index rebuild one-shot and lives in
|
||||
[admin.md](admin.md); `nurses/{id}/reviews` and `/review_tags` are in [reviews.md](reviews.md);
|
||||
`nurses/{id}/trust_badge` is in [verification.md](verification.md).
|
||||
|
||||
## The one endpoint with snake_case query params
|
||||
|
||||
`GET /api/v1/search/nurses` is the **only** endpoint whose query parameters are snake_case:
|
||||
|
||||
```
|
||||
service_category_id city_id district_id nurse_gender min_price max_price page page_size
|
||||
```
|
||||
|
||||
`service_category_id` and `city_id` are required. This matters because it is also the only endpoint where
|
||||
paging is declared as **`page_size`**, not `pageSize` — and model binding is case-insensitive, not
|
||||
separator-insensitive, so sending `pageSize` here binds nothing and silently yields the default page size.
|
||||
The client's `searchClientApi` already sends `page_size` correctly. See
|
||||
[../api-contract.md](../api-contract.md#pagination).
|
||||
|
||||
## `is_searchable` — the four conditions
|
||||
|
||||
A nurse variant appears in results only when **all four** hold. Anything that flips one of them must
|
||||
maintain the index in the same unit of work (`ISearchIndexMaintainer`):
|
||||
|
||||
1. the nurse's verification aggregate is `approved` — [verification.md](verification.md)
|
||||
2. `isAcceptingBookings` is true — [profiles.md](profiles.md)
|
||||
3. the variant is active — [catalog.md](catalog.md)
|
||||
4. the nurse has at least one service area covering the queried city — [service-areas.md](service-areas.md)
|
||||
|
||||
**`districtId = null` means whole-city on both sides of the match.** A customer filtering by district *D*
|
||||
must see a nurse whose area names *D* **and** a nurse whose area is whole-city. A query that drops the
|
||||
nulls silently hides every whole-city nurse. See [geography.md](geography.md).
|
||||
|
||||
## Shape rules the JSON does not express
|
||||
|
||||
- **The index row is denormalised** (REQ-012, delivered): `nurseName`, `avatarUrl` and `distanceKm` are on
|
||||
`NurseSearchResultDto`, so the result card needs no per-row fetch. `price` is a **digit string**.
|
||||
- **`distanceKm` is nullable** — null when either side has no resolved coordinates. The card omits the
|
||||
distance chip rather than showing 0.
|
||||
- **`GET nurses/{id}/profile` aggregates** identity, bio, specialties, the full services list and the
|
||||
latest review into `NursePublicProfileDto` (REQ-012). One request builds the whole profile screen.
|
||||
- **`attributeChips` is a server-composed display list**, not a code vocabulary — render it, do not map it.
|
||||
- **`inoMembership` on the public profile is a boolean summary** of the INO verification step, not the step
|
||||
itself. The per-step detail is the still-open REQ-043.
|
||||
- The index is maintained **inline inside each source write's transaction**, not by a background job — so a
|
||||
profile change is visible in search immediately, and a failed index write fails the source write.
|
||||
- `Search:Backend` selects the implementation: unset or `sql` → `SqlNurseSearch`. Any other value **throws
|
||||
at startup** — Elasticsearch is deferred, and the config fails loudly rather than silently degrading.
|
||||
|
||||
## Enums
|
||||
|
||||
| Vocabulary | Values |
|
||||
| --- | --- |
|
||||
| `NurseGender` *(filter + result field)* | `male` `female` |
|
||||
| `SearchSort` *(client)* | `rating` — the only sort implemented |
|
||||
|
||||
`nurse_gender` accepts `male`/`female`; **omit the param for "any"** — there is no `any` value on this
|
||||
filter, unlike `requiredCaregiverGender` on a booking request.
|
||||
|
||||
## Open REQs
|
||||
|
||||
| REQ | Status | Effect |
|
||||
| --- | --- | --- |
|
||||
| REQ-040 | open | No `topReviewTag` on the index row. The C2 card renders without the tag chip |
|
||||
| REQ-041 | open | No free-text `q` over nurse/variant/category names. Discovery is filter-only |
|
||||
| REQ-042 | open | `NursePublicProfileDto` has no `nurseGender` — the *index row* has it, the profile does not, so the C3 screen cannot show the gender chip |
|
||||
| REQ-066 | open, **narrower than filed** | `search/nurses` is **already anonymous**. What is missing is the rate limit — `SearchController` carries no `[EnableRateLimiting]`, so guest browse falls to the 100/min global per-IP limiter |
|
||||
| REQ-067 | open, **narrower than filed** | `nurses/{id}/profile` is **already anonymous**. What is missing is the privacy review of the payload for unauthenticated callers |
|
||||
@@ -0,0 +1,53 @@
|
||||
# service-areas — nurse coverage
|
||||
|
||||
> Client seam `client/src/services/serviceAreas/` · `USE_SERVICE_AREAS_MOCK = false` (**real**) · 3 server ops
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
|
||||
|
||||
Where a nurse will travel. Three endpoints, one rule that everything else depends on.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Verdict |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/nurse_service_areas/list` | wired · paginated |
|
||||
| POST | `/api/v1/nurse_service_areas/add` | wired |
|
||||
| DELETE | `/api/v1/nurse_service_areas/remove/{id}` | wired |
|
||||
|
||||
All `[Authorize]`. No phantoms. The domain maps 1:1. There is no update — coverage is add/remove.
|
||||
|
||||
## `districtId = null` means whole-city
|
||||
|
||||
A service area is `(cityId, districtId?)`. **`districtId = null` is the affirmative claim "I cover this
|
||||
entire city"** — not missing data, not "no district".
|
||||
|
||||
The consequences reach three other domains:
|
||||
|
||||
- **Search** must match a whole-city area against a district-filtered query, in both directions. A query
|
||||
that drops nulls silently hides every whole-city nurse. See [search.md](search.md).
|
||||
- **`is_searchable`** requires at least one area covering the queried city — coverage is one of its four
|
||||
conditions. See [search.md](search.md).
|
||||
- **Geography** owns the ids and the same null convention. See [geography.md](geography.md).
|
||||
|
||||
The client models this as a **single control**: the coverage editor offers "whole city" as a first-class
|
||||
choice alongside individual districts, so a nurse can never accidentally express it as an empty district
|
||||
list. Do not reintroduce a two-step "city, then optionally districts" flow — an empty selection is
|
||||
ambiguous in a way `null` is not.
|
||||
|
||||
## Shape rules the JSON does not express
|
||||
|
||||
- **A duplicate area is a `409`.** Adding `(city, null)` when district rows for that city already exist —
|
||||
or the reverse — is a conflict the server resolves; the client surfaces it rather than pre-checking.
|
||||
- **Removing the last area for a city removes the nurse from search in that city** in the same
|
||||
transaction, via `ISearchIndexMaintainer`. There is no lag and no reconciliation job.
|
||||
- Coverage is independent of `isAcceptingBookings`: a nurse can keep coverage while pausing bookings, and
|
||||
both are conditions of `is_searchable`.
|
||||
- `list` is paginated even though the practical row count is small — the platform paginates every
|
||||
unbounded list without exception.
|
||||
|
||||
## Enums
|
||||
|
||||
None. Both fields are geography ids; `districtId` is nullable and the null is meaningful.
|
||||
|
||||
## Open REQs
|
||||
|
||||
None. REQ-008/REQ-009 concern addresses, not coverage — see [addresses.md](addresses.md).
|
||||
@@ -0,0 +1,91 @@
|
||||
# tickets — coordination, support, emergency
|
||||
|
||||
> Client seam `client/src/services/tickets/` · `USE_TICKETS_MOCK = false` (**real**) · 11 server ops
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
|
||||
|
||||
Threaded messaging between customer, nurse and staff. Also the emergency channel. The in-app notification
|
||||
feed is a separate domain — [notifications.md](notifications.md).
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Verdict |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/tickets` | wired · paginated · `bookingId` filter |
|
||||
| POST | `/api/v1/tickets` | wired |
|
||||
| GET | `/api/v1/tickets/{id}` | wired — **stamps `last_read_at`** |
|
||||
| POST | `/api/v1/tickets/{id}/messages` | wired · optional `clientMessageId` |
|
||||
| POST | `/api/v1/tickets/{id}/close` | wired |
|
||||
| POST | `/api/v1/tickets/{id}/reopen` | wired |
|
||||
| POST | `/api/v1/tickets/emergency` | **unwired** — no emergency entry point in the UI yet |
|
||||
| POST | `/api/v1/tickets/{id}/participants` | **unwired** |
|
||||
| DELETE | `/api/v1/tickets/{id}/participants/{userId}` | **unwired** |
|
||||
| GET | `/api/v1/admin/tickets` | wired · paginated — the staff queue |
|
||||
| GET | `/api/v1/admin/tickets/{id}` | wired — the staff thread, **internal notes visible** |
|
||||
|
||||
All `[Authorize]`; the two `admin/tickets` routes are `DynamicPermission`. Note the hardcoded nested
|
||||
`admin/` route segment — see [index.md](index.md#route-shape-exceptions).
|
||||
|
||||
### Phantom — 1
|
||||
|
||||
| Client call | REQ | Live? |
|
||||
| --- | --- | --- |
|
||||
| `POST /api/v1/tickets/{id}/assign` | REQ-063 | **Yes — this domain's mock is off.** Gated behind `TICKET_LIFECYCLE_ENABLED`, default **off**, so nothing calls it today |
|
||||
|
||||
> **`tickets/constants.ts` is stale on this.** It says the gate is off because "the backend has no
|
||||
> close/reopen/assign routes yet (REQ-063)". `close` and `reopen` **exist and are wired.** Only `assign`
|
||||
> is missing. The gate could be turned on for close/reopen alone.
|
||||
|
||||
## `isInternal` is a query-layer boundary
|
||||
|
||||
The hardest rule in this domain, and the easiest to get wrong in a UI:
|
||||
|
||||
- `TicketMessageDto` carries `isInternal`, so the field **is** on the wire shape.
|
||||
- **The filtering is not.** A non-staff caller's thread query never returns an internal row, and a
|
||||
non-staff caller can never *set* one. Both are enforced at the query layer, in the handler — **never in
|
||||
the UI**.
|
||||
- Consequence for the client: it must not model internal notes as "rows to hide". They do not arrive. A
|
||||
client-side filter would be a second, weaker gate that hides a leak rather than preventing one.
|
||||
- Consequence for the server: any new ticket read must repeat the filter. There is no global interceptor
|
||||
doing it.
|
||||
|
||||
## Shape rules the JSON does not express
|
||||
|
||||
- **Unread is computed server-side against `last_read_at`**, which is stamped **when the participant
|
||||
fetches the user-facing thread** (`GET /tickets/{id}`) — not by a separate mark-read call. So opening a
|
||||
thread is what clears its badge. `unreadCount` counts non-internal messages from *others* after that
|
||||
stamp, and is **0 on the admin queue** by definition.
|
||||
- **`clientMessageId` is optimistic-send idempotency** (REQ-028): a retried send with the same key returns
|
||||
the original message and echoes the key back on `PostMessageResult`. This is what makes the client's
|
||||
retry-in-place send safe. It is **not** the `Idempotency-Key` header — it is a body field, and it is the
|
||||
only place in the API that works this way.
|
||||
- **The message author is a role label, not a name** — confirmed intentional, for privacy.
|
||||
`TicketMessageDto` carries `senderId` only; the client derives the author label from the participant
|
||||
role. No raw identity is exposed, and none should be added.
|
||||
- **`referenceCode` is the human-facing id** shown to users and quoted in support. Treat it as opaque.
|
||||
- `bookingId` and `refundId` on the summary link a ticket to what it is about; the `bookingId` query
|
||||
filter is how the client jumps from a booking to its coordination thread.
|
||||
- Ticket bodies are **encrypted at rest** (refinement-phase-9).
|
||||
|
||||
## Enums
|
||||
|
||||
| Vocabulary | Values | Note |
|
||||
| --- | --- | --- |
|
||||
| `TicketStatus` | `open` `closed` | matches `Entities/Messaging/TicketCodes.cs` |
|
||||
| `TicketCategory` | `coordination` `support` `refund` `emergency` | matches |
|
||||
| `TicketAuthorRole` | `customer` `nurse` `admin` **`system`** | **the one cross-side mismatch in the API** |
|
||||
| `MessageSendStatus` *(client-only)* | `sent` `sending` `failed` | optimistic-send UI state, never on the wire |
|
||||
|
||||
> **`system` is client-only.** `TicketCodes` defines `customer`, `nurse`, `admin`. The client's
|
||||
> `TicketAuthorRole` adds `system` for platform-generated messages. Widening a union on the *reading* side
|
||||
> is safe — the client can render a value the server never sends — but it means a reader of the client
|
||||
> types would wrongly conclude the server emits `system`. Either the server should define it or the client
|
||||
> should drop it; today it is a documented asymmetry.
|
||||
|
||||
## Open REQs
|
||||
|
||||
| REQ | Status | Effect |
|
||||
| --- | --- | --- |
|
||||
| REQ-028 | delivered | `unreadCount` + `lastMessageAt` on the summary, `bookingId` filter, `clientMessageId` dedupe, role-label authors — all present |
|
||||
| REQ-059 | open | No last-message preview and no author role on the summary, and no unread-*total* read. The real inbox shows subject + status + time with no preview |
|
||||
| REQ-060 | deferred | No message photo attachments. The affordance is designed and gated off |
|
||||
| REQ-063 | open, **narrower than filed** | `close` and `reopen` are delivered and wired. Only `assign` is missing → 1 phantom |
|
||||
@@ -0,0 +1,117 @@
|
||||
# verification — the nurse trust pipeline
|
||||
|
||||
> Client seam `client/src/services/verification/` · `USE_VERIFICATION_MOCK = true` (**mock is primary**) · 17 server ops
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
|
||||
|
||||
The largest domain, and the one the whole marketplace's trust claim rests on. A nurse is not bookable until
|
||||
this pipeline says so.
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Nurse-facing
|
||||
|
||||
| Method | Path | Verdict |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/nurse_verification` | wired — **the one cached status query** |
|
||||
| POST | `/api/v1/nurse_verification/submit` | wired |
|
||||
| POST | `/api/v1/nurse_verification/steps/{stepId}/upload_url` | wired — presigned PUT |
|
||||
| POST | `/api/v1/nurse_verification/steps/{stepId}/documents` | wired — confirm the upload |
|
||||
| POST | `/api/v1/nurse_verification/steps/identity_kyc/run` | wired |
|
||||
| POST | `/api/v1/nurse_verification/steps/shahkar_match/run` | wired |
|
||||
| POST | `/api/v1/nurse_verification/steps/bank_account_verification/run` | wired |
|
||||
| POST | `/api/v1/nurse_verification/credential_details` | **unwired** — the write half of REQ-011; the client has no read-back (REQ-056) |
|
||||
|
||||
### Public
|
||||
|
||||
| Method | Path | Auth | Verdict |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/api/v1/nurses/{nurseId}/trust_badge` | **anonymous** | wired |
|
||||
|
||||
### Admin — all `DynamicPermission` + `sensitive`
|
||||
|
||||
| Method | Path | Verdict |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/v1/admin_verifications` | wired · paginated |
|
||||
| GET | `/api/v1/admin_verifications/{nurseVerificationId}` | wired |
|
||||
| POST | `/api/v1/admin_verifications/steps/{stepId}/decide` | wired — **per-step** decision |
|
||||
| POST | `/api/v1/admin_verifications/{nurseVerificationId}/suspend` | **unwired** |
|
||||
| POST | `/api/v1/admin_verifications/scan_expiring` | **unwired** — an ops one-shot; the scheduler runs it |
|
||||
| GET | `/api/v1/admin_verification_step_types` | **unwired** — the step catalogue is data-driven; no editor |
|
||||
| POST | `/api/v1/admin_verification_step_types` | **unwired** |
|
||||
| DELETE | `/api/v1/admin_verification_step_types/{id}` | **unwired** |
|
||||
|
||||
### Phantom — 3
|
||||
|
||||
| Client call | REQ | Note |
|
||||
| --- | --- | --- |
|
||||
| `GET /api/v1/admin_verifications/documents/{id}/url` | REQ-034 | Deferred. No on-demand signed document URL, so the queue cannot open an uploaded document |
|
||||
| `POST /api/v1/admin_verifications/{id}/approve` | REQ-034 | Deferred. Today approval is **per-step** only — the aggregate flips when the last required step passes |
|
||||
| `POST /api/v1/admin_verifications/{id}/reject` | REQ-034 | Deferred |
|
||||
|
||||
## The two rules that must not be broken
|
||||
|
||||
1. **`status` is the source of truth; `nurse_profiles.is_verified` is derived.** The boolean is written
|
||||
**only** by the finalize transaction when the aggregate reaches `approved`, as one guarded
|
||||
cross-aggregate flip: load both tracked, mutate through one pure domain helper, commit once. Never set
|
||||
`is_verified` from a profile write, a controller, or out of band. See [profiles.md](profiles.md).
|
||||
2. **The step catalogue is data, not code.** `verification_step_types` rows define which steps exist,
|
||||
which are required, and which are automated. Adding a step is a data change. The client must not
|
||||
hardcode the step list — it renders whatever `VerificationStatusDto.steps` contains.
|
||||
|
||||
## Shape rules the JSON does not express
|
||||
|
||||
- **`VerificationStatusDto` is `{ status, isBookable, blockingSteps, steps }`** — the server computes
|
||||
`isBookable` and names the `blockingSteps`. The client does not derive bookability from the step array.
|
||||
This is why the client keeps **one cached status query** and every screen reads it.
|
||||
- **`expiresAt` on a step is real.** MoH competency licences and INO membership lapse; `scan_expiring`
|
||||
reverts a lapsed step to `expired`, which re-gates bookability. `expired` is therefore a normal state,
|
||||
not an error.
|
||||
- **Document upload is a two-step presign flow**: `upload_url` returns a presigned PUT, the client uploads
|
||||
**directly to storage**, then `documents` confirms. The document bytes never transit the API.
|
||||
`Seams:ObjectStorage:PresignExpirySeconds` (900) bounds the window.
|
||||
- **The three `run` steps are seams, each independently switchable** —
|
||||
`Seams:IdentityKyc:Provider`, `Seams:Shahkar:Provider`, `Seams:BankOwnership:Provider`, all `mock` by
|
||||
default, all `finnotech` for the real bridge (sharing `Seams:Finnotech` credentials). Designated test
|
||||
values make each failure path reachable: `SharedSimPhone` `09120000000`,
|
||||
`MismatchNationalId` `1111111111`, `FailNationalId` `0000000000`,
|
||||
`MismatchIban` `IR0000…0000`.
|
||||
- **`shahkar_match` failing as *shared SIM* is a distinct outcome** from a plain phone↔national-id
|
||||
mismatch, and the UI must say which — a shared family SIM is a common, innocent case.
|
||||
- **National id and licence numbers are encrypted at rest**; the admin queue sees them only where the
|
||||
decision requires it.
|
||||
- The nurse's INO membership field is **locked once submitted** (it feeds the public trust badge).
|
||||
|
||||
## Two vocabularies for one thing
|
||||
|
||||
`GET /me` reports a *nurse verification summary* using `not_started` `in_progress` `pending_review`
|
||||
`verified` `rejected`. This domain's aggregate uses `not_started` `pending` `in_review` `approved`
|
||||
`rejected` `suspended`. **They are two read models over the same source of truth, not a drift** — but they
|
||||
are not interchangeable strings. Map deliberately. See [auth.md](auth.md).
|
||||
|
||||
## Enums
|
||||
|
||||
| Vocabulary | Values |
|
||||
| --- | --- |
|
||||
| `VerificationStatus` (aggregate) | `not_started` `pending` `in_review` `approved` `rejected` `suspended` |
|
||||
| `VerificationStepStatus` | `not_started` `pending` `in_review` `passed` `failed` `expired` |
|
||||
| `StepTypeCode` | `identity_kyc` `shahkar_match` `moh_competency_license` `ino_membership` `criminal_record` `bank_account_verification` |
|
||||
| `CredentialType` | `moh_competency_license` `ino_membership` `criminal_record` |
|
||||
| `VerificationMethod` | `manual` `portal` `api` |
|
||||
| `BadgeState` *(client, from `TrustBadgeDto`)* | `verified` `unverified` `expired` |
|
||||
|
||||
The first two are C# enums serialised as snake_case codes (`Entities/Verification/VerificationStatus.cs`,
|
||||
`VerificationStepStatus.cs`); the rest are string constants in `VerificationStepTypeCodes.cs`. All verified
|
||||
identical to the client's unions.
|
||||
|
||||
`TrustBadgeDto` is `{ nurseId, isVerified, approvedAt, credentialTypes }` — a summary, with no per-step
|
||||
detail. That absence is REQ-043.
|
||||
|
||||
## Open REQs
|
||||
|
||||
| REQ | Status | Effect |
|
||||
| --- | --- | --- |
|
||||
| REQ-034 | deferred | No nurse-grouped queue, no on-demand document URL, no whole-verification approve/reject → 3 phantom routes. This is the main reason the seam is mocked |
|
||||
| REQ-043 | open | `TrustBadgeDto` has no per-step detail (step codes + decision dates), so the public verification panel shows a summary only |
|
||||
| REQ-055 | open | No `submittedAt` on `VerificationStatusDto` — the real B6 screen omits the timestamp line |
|
||||
| REQ-056 | open | No nurse-facing read-back of submitted credential details. `credential_details` writes; nothing reads. The real form degrades to blank |
|
||||
| REQ-062 | open | No name/phone search and no per-status counts on the admin queue |
|
||||
@@ -0,0 +1,152 @@
|
||||
# Integration — the client↔server seam
|
||||
|
||||
The two projects have **no shared build**. Everything that crosses between them is described here, as one
|
||||
thing. This page is the whole seam; the four files below are the detail.
|
||||
|
||||
> Last verified: 2026-07-30 against commit `d3ec723`, `swagger.v1.json` (2026-07-29, 178 paths / 186
|
||||
> operations) and the code in `client/src/lib/api/`, `client/src/services/*/`, `server/src/API/`.
|
||||
|
||||
| File | Covers |
|
||||
| --- | --- |
|
||||
| [api-contract.md](api-contract.md) | Envelope, casing, pagination, errors, idempotency, auth, money, rate limits |
|
||||
| [domains/](domains/index.md) | 22 files, one per client `services/` domain — every endpoint, verdicted against the live swagger |
|
||||
| [openapi/](openapi/README.md) | The machine contract + how to regenerate it |
|
||||
| [config-matrix.md](config-matrix.md) | Every env var and appsettings key: client, server, docker, the bot |
|
||||
| [topology.md](topology.md) | The runtime dependency graph — 3 containers, Caddy, remote SQL, the OTP relay |
|
||||
|
||||
---
|
||||
|
||||
## Transport
|
||||
|
||||
HTTP/JSON. The client reads one base URL — `NEXT_PUBLIC_API_URL` — and prefixes every path with
|
||||
`/api/v1/`. Locally that is `http://localhost:5002` (**plain HTTP**; `launchSettings.json` binds no TLS);
|
||||
deployed it is `https://api.balinyaar.ir`, which Caddy terminates and forwards to `balinyaar-api:8080`.
|
||||
|
||||
**gRPC exists and the client does not use it.** `Baya.Web.Plugins.Grpc` serves exactly one service (User)
|
||||
over HTTP/2 on the same port. Nothing in `client/` speaks it. Treat it as an internal affordance.
|
||||
|
||||
**Two OpenAPI documents are served, `v1` and `v1.1`.** All 55 controllers declare `[ApiVersion("1")]`, and
|
||||
`ApiVersionDocumentProcessor` keeps only paths whose URL contains the document's version segment — so
|
||||
**`v1.1` is served but contains zero paths.** `v1` is the contract.
|
||||
|
||||
## The envelope
|
||||
|
||||
Every response — success *and* failure — is `ApiResult`. The payload is always under `data`.
|
||||
|
||||
```json
|
||||
{ "isSuccess": true, "statusCode": 200, "message": "Success",
|
||||
"requestId": "0af7651916cd43dd8448eb211c80319c", "code": null, "data": { } }
|
||||
```
|
||||
|
||||
`requestId` is the **W3C trace id** of the request (`Activity.Current.TraceId`), the same id
|
||||
OpenTelemetry traces on — so a support ticket maps 1:1 to a trace. `code` is an optional stable
|
||||
machine-readable error code (`otp_locked`, …), omitted from the wire when null.
|
||||
|
||||
The client never unwraps centrally: `clientFetch`/`serverFetch` return the **raw envelope**, and each
|
||||
service calls `unwrap()` from [`client/src/lib/api/types.ts`](../../client/src/lib/api/types.ts).
|
||||
|
||||
## Casing — camelCase bodies, snake_case URLs
|
||||
|
||||
Verified mechanically against the live swagger: of **427 distinct property names, 0 contain an underscore
|
||||
and 0 are PascalCase.** JSON bodies are camelCase. URL *segments* are snake_case, produced by the server's
|
||||
`SnakeCaseParameterTransformer` from `[controller]`/`[action]` tokens (`SetPrimary` → `/set_primary`).
|
||||
|
||||
Three routes break the snake_case rule with hardcoded hyphens — `admin/partner-centers`, `admin/tickets`,
|
||||
`admin/reviews` — see [domains/index.md](domains/index.md#route-shape-exceptions).
|
||||
|
||||
## Pagination
|
||||
|
||||
`{ items, total, page, pageSize }` (`total`/`page`/`pageSize` are int32). 29 operations are paginated.
|
||||
The **declared** query-param names are not uniform — 25 declare `Page`/`PageSize`, 3 declare
|
||||
`page`/`pageSize`, and `GET /search/nurses` declares `page`/`page_size`. Model binding is
|
||||
case-insensitive so the first two are interchangeable; `page_size` is a *different name* and is not.
|
||||
Full table in [api-contract.md](api-contract.md#pagination).
|
||||
|
||||
## Errors
|
||||
|
||||
`200` · `400` validation (field errors under `data`) · `401` unauthenticated · `403` forbidden ·
|
||||
`404` not found (**also returned for a tenancy mismatch**, deliberately, so a 403 never confirms a row
|
||||
exists) · `409` state-machine/idempotency conflict · `422` · `424` · `429` rate-limited · `5xx`.
|
||||
|
||||
`clientFetch` behaviour: **401** → one silent refresh + retry, then clear cookies, toast, redirect to
|
||||
login (no throw); **403** and **5xx** → toast + throw `ApiError`; **other 4xx** → throw without toasting
|
||||
(the calling hook owns the message); **network failure** → toast + `ApiError(0)`.
|
||||
|
||||
## Auth
|
||||
|
||||
**Bearer header, not cookie auth.** The JWE access token is *stored* in a cookie the client reads itself
|
||||
and *sent* as `Authorization: Bearer <token>`. The server's CORS policy therefore does **not** allow
|
||||
credentials, and the client sends no `credentials: 'include'`.
|
||||
|
||||
The token is opaque to the client (signed + AES-encrypted). Role and identity come from `GET /api/v1/me`;
|
||||
a multi-role user picks one with `POST /api/v1/me/select_role`. On a 401 the client runs one
|
||||
single-flight silent refresh (`POST /api/v1/auth/refresh`) — the server rotates the pair and detects
|
||||
reuse, so a replayed refresh token kills the session. `/auth/refresh`, `/auth/request_otp` and
|
||||
`/auth/verify_otp` are excluded from the retry.
|
||||
|
||||
**20 of 186 operations are anonymous** — the OTP pair, catalog + geo reference reads, the public nurse
|
||||
search/profile/reviews/trust-badge reads, `ping`, the three webhooks, and Development's
|
||||
`GET /api/v1/dev/last_otp/{phone}`. Full list in [api-contract.md](api-contract.md#the-anonymous-surface).
|
||||
|
||||
## Idempotency
|
||||
|
||||
`Idempotency-Key` is a request header. The server reads it on exactly **two** endpoints:
|
||||
|
||||
| Endpoint | Key scope |
|
||||
| --- | --- |
|
||||
| `POST /api/v1/bookings/{bookingRequestId}/payments` | one key per payment *attempt*, reused across retries of that attempt |
|
||||
| `POST /api/v1/checkout_bnpl/initiate` | same, per BNPL attempt |
|
||||
|
||||
The client also sends it on `admin_payouts` process/retry, where **the server does not read it** — see
|
||||
[domains/payouts.md](domains/payouts.md). Webhooks do not use the header; they dedupe on the provider's
|
||||
`external_event_id`. The header is allowed through CORS but is **not declared in swagger** (it is read
|
||||
from `Request.Headers`, not bound as a parameter).
|
||||
|
||||
## Money
|
||||
|
||||
**IRR Rials, integer, no floats, anywhere.** On the wire the direction matters, and it is consistent:
|
||||
|
||||
- **Outbound (DTOs/results): a digit string.** All 68 money properties on read models are `type: string`.
|
||||
Parse with the `@/utils` BigInt helpers — never `Number()`.
|
||||
- **Inbound (commands): `integer/int64`.** All 3 money properties on command bodies.
|
||||
|
||||
Toman is display-only and is converted **only** inside a provider adapter at its boundary
|
||||
(`ICurrencyNormalizer`, `Seams:Currency:TomanToIrrMultiplier`). `gross = commission + payout` always,
|
||||
and VAT is on Balinyaar's commission only. See [docs/rules/server/money.md](../rules/server/money.md)
|
||||
and [docs/rules/client/services.md](../rules/client/services.md).
|
||||
|
||||
## Enums
|
||||
|
||||
**Swagger declares no string enums** — 1 of 339 schemas has an `enum`, and it is the integer
|
||||
`ApiResultStatusCode`. Every status/code field is a bare `string` on the wire. The vocabulary therefore
|
||||
lives in the domain files here, cross-checked against the server's `Baya.Domain` code sets and the
|
||||
client's string-literal unions. **All 18 shared vocabularies match on both sides** as of this stamp; the
|
||||
one exception is noted in [domains/tickets.md](domains/tickets.md).
|
||||
|
||||
---
|
||||
|
||||
## What the server owes the client
|
||||
|
||||
1. The `ApiResult` envelope on every response, with `requestId` populated and `code` set on any failure
|
||||
the client must branch on.
|
||||
2. camelCase bodies; snake_case URL segments; `{ items, total, page, pageSize }` on every list.
|
||||
3. `404`, never `403`, for a row the caller does not own.
|
||||
4. Money as a digit string outbound; the three-amount split guaranteed server-side.
|
||||
5. A masked address and only unencrypted `customerNotes` before a booking is confirmed; full care
|
||||
instructions only after, only to the assigned nurse and admin.
|
||||
6. `is_internal` filtered at the query layer — a non-staff caller can never read or set one.
|
||||
7. Idempotent behaviour on the two keyed endpoints, and `409` (not `500`) on a converged replay.
|
||||
8. Reachability: `/healthz/live`, `/healthz/ready`, and CORS origins that list the client's real origin.
|
||||
|
||||
## What the client owes the server
|
||||
|
||||
1. `Authorization: Bearer <token>` on every authenticated call — and nothing else for auth. No cookies
|
||||
cross the wire.
|
||||
2. `Accept-Language` (`fa` default) on every call, from the active locale segment.
|
||||
3. `Content-Type: application/json` — **except** `FormData` bodies, where the browser sets the multipart
|
||||
boundary itself.
|
||||
4. One stable `Idempotency-Key` per payment/BNPL attempt; a new attempt gets a new key.
|
||||
5. `page`/`pageSize` within the server's cap; never an unbounded list request.
|
||||
6. IRR integers inbound; Toman conversion only at the UI boundary.
|
||||
7. Exactly one silent refresh per 401, single-flighted, and never on the refresh/OTP endpoints.
|
||||
8. No derived money. The client never computes commission, VAT, refund amounts or payout dates.
|
||||
@@ -0,0 +1,79 @@
|
||||
# OpenAPI snapshot
|
||||
|
||||
The **machine contract**. The server generates it with NSwag; this folder holds the published snapshot
|
||||
so the client can generate or verify types without booting the backend.
|
||||
|
||||
> Last verified: 2026-07-29 against commit `c99e3f4` (server code last changed in `5885280`, 2026-07-28).
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| File | [`swagger.v1.json`](swagger.v1.json) |
|
||||
| Taken | 2026-07-29 |
|
||||
| Commit | `c99e3f4` |
|
||||
| **Paths** | **178** |
|
||||
| **Operations** | **186** |
|
||||
| Component schemas | 339 |
|
||||
| Generator | NSwag v14.7.1.0 · OpenAPI 3.0.0 |
|
||||
| Size | 612 K |
|
||||
|
||||
## How to regenerate
|
||||
|
||||
The API binds **plain HTTP** on port 5002 (`launchSettings.json`), despite what most prose in this repo
|
||||
says — see contradiction C-3. Use whatever port you bind; the document is the same.
|
||||
|
||||
```bash
|
||||
cd server
|
||||
ASPNETCORE_ENVIRONMENT=Development dotnet run \
|
||||
--project src/API/Baya.Web.Api/Baya.Web.Api.csproj
|
||||
|
||||
# from another shell, once "Now listening" appears:
|
||||
curl -s --noproxy '*' http://127.0.0.1:5002/swagger/v1/swagger.json \
|
||||
-o docs/integration/openapi/swagger.v1.json
|
||||
```
|
||||
|
||||
Two things that cost time the first run:
|
||||
|
||||
- Booting in `Development` **migrates and seeds** against the DB in `appsettings.Development.json` —
|
||||
currently a *remote* SQL Server. First boot takes ~40 s and logs
|
||||
`Demo world already seeded — no-op.` when the world is present.
|
||||
- `--noproxy '*'` matters. With a proxy configured in the environment, `curl` to `localhost` returns
|
||||
**502** rather than the document.
|
||||
|
||||
Then update the table above — date, commit, and endpoint count — in the same change. A snapshot whose
|
||||
provenance is unrecorded is what this chain exists to stop.
|
||||
|
||||
## Scope — `v1`, and why `v1.1` is empty
|
||||
|
||||
Both documents **are** registered: `Program.cs` calls `AddSwagger("v1", "v1.1")`, so
|
||||
`/swagger/v1.1/swagger.json` is served. It contains **zero paths**.
|
||||
|
||||
`ApiVersionDocumentProcessor` removes every path whose URL does not contain the document's own version
|
||||
segment, and all **55 controllers declare `[ApiVersion("1")]`** with the route template
|
||||
`api/v{version:apiVersion}/…`. So every URL contains `v1` and none contains `v1.1`.
|
||||
|
||||
That resolves contradiction **C-9**: the old README's "publishes `v1` and `v1.1`" was literally true and
|
||||
substantively empty. `v1` is the contract. Only `v1` is worth committing.
|
||||
|
||||
## One provenance wrinkle
|
||||
|
||||
The document's `servers` block reads `http://127.0.0.1:5099`, while the regeneration command above uses
|
||||
port 5002. NSwag records whichever host answered the request, so this only means the phase-0 snapshot was
|
||||
taken from a run bound to 5099. **The document content is port-independent** — no path, schema or parameter
|
||||
depends on it — so this is a provenance note, not a defect. Re-taking the snapshot from 5002 would change
|
||||
that one string and nothing else.
|
||||
|
||||
## The human contract
|
||||
|
||||
The prose half — one file per domain — is in [`../domains/`](../domains/index.md): **22 files, one per
|
||||
client `services/` domain**, with all 186 operations assigned to exactly one of them. **The two must
|
||||
agree**: this JSON is the wire truth, the markdown explains it. When they disagree, the JSON wins and
|
||||
the markdown is wrong.
|
||||
|
||||
Two things the JSON **cannot** tell you, which is why the markdown exists:
|
||||
|
||||
- **Enum vocabularies.** Exactly 1 of 339 schemas has an `enum`, and it is the integer
|
||||
`ApiResultStatusCode`. Every status/code field is a bare `string`. The vocabularies live in the domain
|
||||
files, cross-checked against `Baya.Domain`'s code sets.
|
||||
- **`Idempotency-Key`.** Two endpoints require it, and neither declares it — it is read from
|
||||
`Request.Headers`, not bound as a parameter. See
|
||||
[`../api-contract.md`](../api-contract.md#idempotency).
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,145 @@
|
||||
# Topology — the runtime dependency graph
|
||||
|
||||
What talks to what at runtime, what breaks when each hop is down, and which file configures it. This is the
|
||||
answer to a deploy question; the deploy *procedure* is [DEPLOY.md](../../DEPLOY.md) and every key is in
|
||||
[config-matrix.md](config-matrix.md).
|
||||
|
||||
> Last verified: 2026-07-30 against commit `d3ec723`, `docker-compose.yml`, `deploy/Caddyfile` and
|
||||
> `server/src/API/Baya.Web.Api/Program.cs`.
|
||||
|
||||
---
|
||||
|
||||
## Deployed
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
browser["Browser<br/>balinyaar.ir"]
|
||||
|
||||
subgraph net["caddy_net (external docker network)"]
|
||||
caddy["Caddy<br/><i>pre-existing, not in this repo</i><br/>TLS terminator"]
|
||||
web["balinyaar-web:3000<br/>Next.js 16"]
|
||||
api["balinyaar-api:8080<br/>ASP.NET Core 10"]
|
||||
relay["balinyaar-otp-relay:5010<br/>Node, zero deps"]
|
||||
proxy["hysteria-client:8081<br/><i>pre-existing</i>"]
|
||||
end
|
||||
|
||||
sql[("Remote SQL Server<br/>87.107.152.16:1433<br/><b>not containerised</b>")]
|
||||
vol[["api-object-storage<br/>docker volume"]]
|
||||
tg["api.telegram.org"]
|
||||
rails["External rails<br/>PSP · BNPL · Finnotech · Neshan · مودیان<br/><i>all mocked by default</i>"]
|
||||
|
||||
browser -->|"HTTPS"| caddy
|
||||
caddy -->|"HTTP :3000"| web
|
||||
browser -->|"HTTPS api.balinyaar.ir<br/><b>every API call</b>"| caddy
|
||||
caddy -->|"HTTP :8080<br/>+ X-Forwarded-For"| api
|
||||
api -->|"TCP 1433"| sql
|
||||
api -->|"HTTP + X-Api-Key"| relay
|
||||
api --> vol
|
||||
api -.->|"per seam config"| rails
|
||||
relay -->|"HTTP CONNECT"| proxy
|
||||
proxy --> tg
|
||||
|
||||
web -.->|"builds absolute URLs only<br/><b>no server-to-server calls</b>"| api
|
||||
```
|
||||
|
||||
**The single most important edge is the dotted one.** The browser calls the API directly at
|
||||
`https://api.balinyaar.ir`; the `web` container does **not** proxy or fetch on the browser's behalf. That is
|
||||
why `NEXT_PUBLIC_API_URL` must be the **public hostname**, never the container name — a container name is
|
||||
unresolvable from a browser. It is also why every `NEXT_PUBLIC_*` change needs an image rebuild: the value
|
||||
is compiled into the bundle.
|
||||
|
||||
## Every edge
|
||||
|
||||
| # | From → To | Carries | Breaks if down | Configured in |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | browser → Caddy | All HTTPS for both hostnames | Everything. Caddy is the only TLS terminator and the only published port | `deploy/Caddyfile` (pasted into the pre-existing Caddy) |
|
||||
| 2 | Caddy → `balinyaar-web:3000` | The Next.js app shell, RSC payloads, static assets | The site does not load. **The API keeps working** — they are independent hostnames | `Caddyfile` · `docker-compose.yml` |
|
||||
| 3 | browser → Caddy → `balinyaar-api:8080` | Every `/api/v1/*` call, bearer-authenticated | The site loads and every screen shows its error state. No data, no login | `NEXT_PUBLIC_API_URL` (build-time) · `Cors:AllowedOrigins` |
|
||||
| 4 | Caddy → API, `X-Forwarded-For` | The real client IP | The rate limiter partitions every request onto **Caddy's** IP, so one noisy client 429s everyone | `ForwardedHeaders:KnownNetworks` — the docker bridge ranges |
|
||||
| 5 | API → remote SQL Server | All application data + the Serilog sink | **Total outage.** `/healthz/ready` fails and the API will not start | `ConnectionStrings:SqlServer` / `:logDb` |
|
||||
| 6 | API → `balinyaar-otp-relay:5010` | OTP codes, `X-Api-Key` authenticated | **Nobody can log in.** Every other authenticated screen keeps working for existing sessions | `Seams__Sms__Telegram__BaseUrl` (compose) · `Seams:Sms:Telegram:ApiKey` |
|
||||
| 7 | relay → `hysteria-client:8081` → Telegram | The code delivery itself | Same as 6 — codes are generated but never arrive. Fails **at relay boot** with a clear message, not silently per-OTP | `TELEGRAM_PROXY_URL` |
|
||||
| 8 | API → `api-object-storage` volume | Verification documents, avatars | Uploads fail; `/healthz/ready` fails (it does a real write probe). **Without the volume, existing documents vanish on the next `up --build`** | `Seams__ObjectStorage__RootPath` + the named volume |
|
||||
| 9 | API → external rails | Payments, BNPL, KYC, geocoding, e-invoicing | **Nothing, by default** — every rail is `mock`. Real behaviour begins the moment a `Provider` selector changes | `Seams:<rail>:Provider` — see [config-matrix.md](config-matrix.md#the-seam-selectors) |
|
||||
| 10 | PSP / BNPL / transferor → API webhooks | Payment capture, BNPL settlement, payout reconciliation | Payments are taken but **bookings are never confirmed** — the webhook is what creates the booking | `webhook` rate-limit policy, anonymous routes, per-provider signing secrets |
|
||||
|
||||
## Ports
|
||||
|
||||
| Port | Where it applies | Note |
|
||||
| --- | --- | --- |
|
||||
| `443` | The host | Caddy. **The only published port on the machine** |
|
||||
| `3000` | `caddy_net` only | Next.js. Not published |
|
||||
| `8080` | `caddy_net` only | The API **in-container** |
|
||||
| `5002` | A developer laptop only | The local `launchSettings.json` port — **plain HTTP**, and nothing in the deployment uses it |
|
||||
| `5010` | `caddy_net` only | The OTP relay |
|
||||
| `1433` | Outbound to `87.107.152.16` | Remote SQL Server |
|
||||
|
||||
> **5002 vs 8080 catches people out.** Most prose in this repo says 5002 because that is the local port.
|
||||
> The container binds 8080 (the ASP.NET default in a container) and the Caddyfile points there.
|
||||
|
||||
## Startup order
|
||||
|
||||
`depends_on: [otp-relay]` puts the relay before the API, but that only orders *container start*, not
|
||||
readiness. What actually gates the API's boot:
|
||||
|
||||
1. **`StartupSecretsGuard`** — refuses to start on a missing or placeholder secret, before any service reads
|
||||
config.
|
||||
2. **A reachable SQL Server** — required to start, full stop.
|
||||
3. **In Development** (which is what the deployment runs): apply migrations, then seed default users,
|
||||
payment gateways, the demo world and the demo lifecycle. All idempotent — a re-boot logs
|
||||
`Demo world already seeded — no-op.` **First boot takes ~40 s** against the remote DB.
|
||||
4. **When deployed as a non-Development environment**: DDL is a separate one-shot
|
||||
(`dotnet Baya.Web.Api.dll migrate`); boot only *checks* the schema is current and fails fast on a pending
|
||||
migration, then seeds roles.
|
||||
|
||||
The relay refuses to start without `API_KEY` (min 16 chars) or with an unreachable proxy — both fail loudly
|
||||
at boot rather than per-request.
|
||||
|
||||
## Request pipeline inside the API
|
||||
|
||||
Order matters, and two placements are deliberate:
|
||||
|
||||
```
|
||||
UseForwardedHeaders ← first, so the resolved client IP is in place before anything reads it
|
||||
UseSwaggerAndUi
|
||||
UseRouting
|
||||
UseCors ← after routing, BEFORE the rate limiter and auth, so a pre-flight OPTIONS
|
||||
UseRateLimiter is answered rather than rejected as 429 or 401
|
||||
UseAuthentication
|
||||
UseAuthorization
|
||||
MapControllers
|
||||
UseMetrics · UseHealthChecks
|
||||
ConfigureGrpcPipeline
|
||||
```
|
||||
|
||||
## What is not in the graph
|
||||
|
||||
| | Why |
|
||||
| --- | --- |
|
||||
| A database container | The DB is remote and pre-provisioned. `RUNBOOK.md`'s local-SQL-in-Docker path describes a **different world** — an unseeded one (contradiction **C-5**) |
|
||||
| Redis | `ICacheService` / `IDistributedLock` are single-process today. When a multi-instance deployment needs a real one, a `redis` readiness check gets added alongside it |
|
||||
| A job runner | The weekly payout batch is generated by an **in-process** `IRecurringJob` scheduler. No external cron, no queue |
|
||||
| A message broker | Nothing is asynchronous across a process boundary |
|
||||
| An OTLP collector | `OpenTelemetry:Otlp:Endpoint` is unset, so nothing is exported. Prometheus scrapes `/metrics` directly |
|
||||
| A second API instance | Single instance. Multi-instance needs the distributed lock to become real first |
|
||||
|
||||
## Local development
|
||||
|
||||
Same code, a different graph — no Caddy, no containers, and the **same remote database**:
|
||||
|
||||
```
|
||||
localhost:3000 (npm run dev) ──▸ localhost:5002 (dotnet run, plain HTTP)
|
||||
│
|
||||
├──▸ 87.107.152.16:1433 (the same remote DB)
|
||||
└──▸ 127.0.0.1:5010 (the relay, if you run it)
|
||||
```
|
||||
|
||||
Three things to know before the first run:
|
||||
|
||||
- **The API is plain HTTP locally.** `dotnet dev-certs https --trust` is not needed and there is no
|
||||
certificate to trust (contradiction **C-4**).
|
||||
- **You share the deployment's database.** Booting in Development migrates and seeds against it. The
|
||||
seeders are idempotent, but you and the demo site are looking at the same rows.
|
||||
- **If you run the relay locally**, copy `Seams:Sms:Telegram:ApiKey` from `appsettings.Development.json`
|
||||
into your own `telegram-otp-bot/.env`. The value in `.env.example` is published and
|
||||
`TelegramSmsSender` deliberately rejects it.
|
||||
@@ -0,0 +1,140 @@
|
||||
# Deferred — recorded, not re-decided
|
||||
|
||||
> Last verified: 2026-08-02 against commit `51e86a1`. Populated by phase 5 of the
|
||||
> [documentation clean-up chain](../../archive/clarify-chain/README.md).
|
||||
|
||||
**51 items** carry deferred status in [backlog.md](../status/backlog.md): the 43 in its dedicated
|
||||
["Deferred" section](../status/backlog.md#deferred-43) (BL-220–BL-262), plus 8 more filed by severity
|
||||
elsewhere in the same file whose Status column also reads `deferred` (BL-050, BL-075, BL-082, BL-096,
|
||||
BL-163, BL-168, BL-201, BL-219). This file **records them faithfully** — it does not re-litigate any of
|
||||
them. A deferral with a trigger is a decision; every row below has one, preserved in substance from its
|
||||
source. Grouped by theme, as backlog.md itself groups its Deferred section.
|
||||
|
||||
**How to read "size" below**: this column is this document's own estimate (S / M / L / XL), not carried from
|
||||
any source file — backlog.md doesn't size deferred items, so sizing them is this phase's proposal, made to
|
||||
help a future reader triage, and open to revision.
|
||||
|
||||
---
|
||||
|
||||
## The 8 unbuilt product tables
|
||||
|
||||
Named individually per the phase brief, cross-referenced from [implemented.md](../status/implemented.md):
|
||||
|
||||
| Table | Backs | BL-### | Pull-trigger |
|
||||
| --- | --- | --- | --- |
|
||||
| `organizations` | Employer/company account model | [BL-224](../status/backlog.md#deferred-43) | Product pulls it |
|
||||
| `organization_nurses` | Employer↔nurse membership | [BL-224](../status/backlog.md#deferred-43) | Product pulls it |
|
||||
| `fraud_flags` | ML-scored fraud signals | [BL-225](../status/backlog.md#deferred-43) | Product pulls it (manual suspension + support alerts cover this today) |
|
||||
| `recurring_booking_schedules` | Recurring/subscription bookings | [BL-226](../status/backlog.md#deferred-43) | Product pulls it |
|
||||
| `bnpl_settlement_entries` | Tranched BNPL settlement | [BL-227](../status/backlog.md#deferred-43) | Product pulls it, or a future BNPL provider tranches settlement (one settlement row covers today's provider) |
|
||||
| `nurse_availability_slots` | Soft scheduling-guidance windows | [BL-228](../status/backlog.md#deferred-43) | Product pulls it |
|
||||
| `nurse_availability_exceptions` | Time-off exceptions to the above | [BL-228](../status/backlog.md#deferred-43) | Product pulls it |
|
||||
| `incidents` | First-class incident entity | [BL-244](../status/backlog.md#deferred-43) | Product pulls it (support alerts cover this today) |
|
||||
|
||||
All eight are, per refinement-phase-9's own framing (item 9.11 in that phase's handoff, distilled in
|
||||
[decisions.md](../status/decisions.md)), **pure additive migrations** — nothing in the current schema needs
|
||||
to change first when one is pulled forward. The same source item 9.11 also grouped in customer national-ID
|
||||
KYC ([BL-229](#product-scope-cuts-not-building-yet-by-decision)) and geo bulk-import
|
||||
([BL-230](#product-scope-cuts-not-building-yet-by-decision)) alongside the tables; backlog.md split those into
|
||||
their own rows below since neither is a table.
|
||||
|
||||
---
|
||||
|
||||
## Scale-later infrastructure
|
||||
|
||||
Correct for a single instance / MVP load; each has a concrete, measurable trigger, not a date.
|
||||
|
||||
| Item | Why deferred | Pull-trigger | Size | Decided |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| [BL-220](../status/backlog.md#deferred-43) — Redis (shared cache, cross-instance scheduler/money lock) not deployed | Every current seam (the in-process scheduler, rate limiting) is correct only for one running instance | Running more than one instance | M | refinement-phase 7/8/9 handoffs |
|
||||
| [BL-221](../status/backlog.md#deferred-43) — Elasticsearch `INurseSearch` + outbox feeder not built | `SqlNurseSearch` is real, correct, and sufficient at MVP scale | SQL search shows real strain (latency/throughput on `nurse_search_index`) | L | backend-phase-7 follow-up, refinement-phase 9.7 |
|
||||
| [BL-252](../status/backlog.md#deferred-43) — no short-TTL cache over hot search-result pages | Shipped no-cache at MVP; premature to cache before there's a measured hot path | Search read latency becomes a real problem | S | backend-phase-7 follow-up |
|
||||
|
||||
## Product-scope cuts (not building yet, by decision)
|
||||
|
||||
| Item | Why deferred | Pull-trigger | Size | Decided |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| [BL-222](../status/backlog.md#deferred-43) — `IAnalyticsSink` / analytics warehouse not built | Events write to `ops.SystemEvents` (real, queryable) fire-and-forget; no cross-event pipeline yet | Product needs cross-event analytics beyond SQL queries | L | backend-phase-1, refinement-phase 9.8 |
|
||||
| [BL-223](../status/backlog.md#deferred-43) — no real holiday-calendar feed | `IHolidayCalendar` reads a real, seeded, manually-maintained table; a yearly ops top-up is an acceptable MVP alternative to an automated feed | Product pulls it (manual refresh becomes a burden) | M | backend-phase-1, refinement-phase 9.9 |
|
||||
| [BL-229](../status/backlog.md#deferred-43) — customer national-ID KYC collection not built | Deliberate: never gate browsing/booking on it | **Never, by design** — informational only | — | backend-phase-3, refinement-phase-9, business/01 |
|
||||
| [BL-230](../status/backlog.md#deferred-43) — geography bulk-import feed (`IGeoDataImporter`) not built | The idempotent seed + admin CRUD is sufficient for MVP | Product pulls it | M | backend-phase-4 follow-up |
|
||||
| [BL-231](../status/backlog.md#deferred-43) — holiday/surge pricing, a distinct Companionship tier, tiered per-category commission | All explicitly out of MVP scope in business/03 | Product pulls it | M (pricing rule) / L (new tier) | backend-phase-5 follow-up, business/03 |
|
||||
| [BL-232](../status/backlog.md#deferred-43) — GPS-radius "nurses near me" map discovery | Coverage stays named-district-only by design | **None — permanent product decision** | — | backend-phase 4/7, business/04 |
|
||||
| [BL-233](../status/backlog.md#deferred-43) — automated MoH/INO license lookup + professional-liability-insurance step type | Manual verification is the MVP path; no B2B lookup API is confirmed to exist yet | A lookup portal is confirmed to exist, or product pulls the insurance step | M | backend-phase-6, business/02 |
|
||||
| [BL-239](../status/backlog.md#deferred-43) — dedicated MoR center-settlement payout path (تسهیم split leg) | Per decision 6.6; the platform pays nurses directly today regardless of MoR | Product pulls it | M | refinement-phase 6/8 follow-ups |
|
||||
| [BL-241](../status/backlog.md#deferred-43) — on-demand/instant nurse payout, per-nurse payout frequency | MVP is one fixed weekly cadence for everyone | Product pulls it | M | backend-phase-13, business/10 |
|
||||
| [BL-242](../status/backlog.md#deferred-43) — automated clawback recovery beyond next-batch netting | Simple greedy netting covers MVP | Product pulls it | M | backend-phase-13 |
|
||||
| [BL-243](../status/backlog.md#deferred-43) — two-way double-blind reviews with timed reveal | One-way customer review is MVP scope per business/11 | Product pulls it | L | backend-phase-14, business/11 |
|
||||
| [BL-246](../status/backlog.md#deferred-43) — automated eNamad/MoH license verification for partner centers | Manual-approve is MVP scope per business/13 | Product pulls it | M | backend-phase-15 |
|
||||
| [BL-247](../status/backlog.md#deferred-43) — no telephony seam for emergencies | The emergency contact is an out-of-platform `tel:` link, by deliberate design | **None — permanent product decision** | — | backend-phase-15 |
|
||||
| [BL-248](../status/backlog.md#deferred-43) — SMS/push notification channels not built | Only in-app notifications are real; deliberate MVP scope (business/14) | Notification UX demands out-of-app reach | M | refinement-phase-9, refinement-phase 9.10 |
|
||||
| [BL-251](../status/backlog.md#deferred-43) — PWA/offline caching (Workbox) unbuilt | Marked "maybe" in the original product backlog — optional from the start | Product pulls it | M | product/notes/open-questions.md |
|
||||
| [BL-254](../status/backlog.md#deferred-43) — partner dashboard's sponsored-nurse list capped at 50, no pagination | No center has exceeded 50 sponsored nurses yet | A center exceeds 50 sponsored nurses | S | backend-phase-15 follow-up |
|
||||
| [BL-257](../status/backlog.md#deferred-43) — no true desktop search layout | The phone-width frame is the whole app's deliberate design; a full responsive pass was explicitly deferred post-chain | Product decides to support desktop | L | ui-phase-12 follow-up |
|
||||
| [BL-258](../status/backlog.md#deferred-43) — Persian OG image is Latin-only (also [BL-202](../status/backlog.md#minor-115)) | Deliberate scope cut on `/welcome` and other share cards | Persian social sharing becomes a priority | S | ui-phase-13 follow-up |
|
||||
| [BL-259](../status/backlog.md#deferred-43) — no list-row EVV "currently checked in" indicator | Needs a product/API decision to avoid an N+1 read; not free to add | Product prioritizes a list-level EVV signal | S | ui-phase-5 follow-up |
|
||||
| [BL-260](../status/backlog.md#deferred-43) — no web-push for new nurse booking requests | The 15-second poll is the only freshness mechanism today | Push infra (service worker + VAPID + dispatch rail) is built | L | ui-phase-7 follow-up, REQ-054 |
|
||||
| [BL-262](../status/backlog.md#deferred-43) — no real payment-gateway/Shaparak logos near the pay CTA | No licensed assets yet; a generic lock-icon notice stands in | Licensed gateway assets obtained | S | ui-phase-6 follow-up |
|
||||
|
||||
## Vendor / integration deferrals
|
||||
|
||||
| Item | Why deferred | Pull-trigger | Size | Decided |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| [BL-235](../status/backlog.md#deferred-43) — SMS.ir/Ghasedak adapters not built | Only Kavenegar's real-SMS path is wired; either alternative throws at startup by design rather than silently mocking | A second SMS vendor is needed | M | refinement-phase-8 follow-up |
|
||||
| [BL-236](../status/backlog.md#deferred-43) — Finnotech/Moadian token-exchange refresh + signing certificate not wired | Both are deploy-time actions that need real credentials, which don't exist yet | Going to a real Moadian integration ([pre-launch.md §5](pre-launch.md#5-legal--tax-items-that-are-code-side)) | S once credentials exist | refinement-phase-8 handoff |
|
||||
| [BL-238](../status/backlog.md#deferred-43) — per-provider-code BNPL revert incomplete (always drives SnappPay) | Only one BNPL provider is live today | A second BNPL provider goes live | S | refinement-phase-8 follow-up |
|
||||
|
||||
## Scheduled-ops deferrals (manual today, by choice or by not-yet-built)
|
||||
|
||||
| Item | Why deferred | Pull-trigger | Size | Decided |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| [BL-234](../status/backlog.md#deferred-43) — credential-expiry scan + EVV no-show sweep are manual-only endpoints | No cron calls either yet | A scheduled-ops phase | S | backend-phase 6/9 follow-ups |
|
||||
| [BL-237](../status/backlog.md#deferred-43) — Moadian reconciliation poll + refund-settlement poll are manual | No scheduled cron for either | A scheduled-ops phase | S | backend-phase-11, refinement 6/7/8 follow-ups |
|
||||
| [BL-240](../status/backlog.md#deferred-43) — payout batch *generation* is automatic; *processing* a batch stays a deliberate, explicit admin action | Not a bug — moving real money should have a human in the loop at this scale | **None — permanent product decision** | — | refinement-phase-7, backend-phase-13 |
|
||||
|
||||
## Cleanup-of-convenience and data-integrity hygiene
|
||||
|
||||
| Item | Why deferred | Pull-trigger | Size | Decided |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| [BL-219](../status/backlog.md#minor-115) — rename the `SET_VIA_USER_SECRETS_OR_ENV` placeholder sentinel | The name is a load-bearing sentinel `StartupSecretsGuard` checks for; renaming touches several files for a cosmetic gain | None — cleanup-of-convenience | S | phase 2 (`open-contradictions.md` C-2) |
|
||||
| [BL-250](../status/backlog.md#deferred-43) — the ESLint unused-vars gate is a repo-wide no-op | Config patches an export path that doesn't carry the rule; fixing it is a dedicated infra task, not a drive-by | A dedicated infra task | S | frontend-phase-13 follow-up |
|
||||
| [BL-253](../status/backlog.md#deferred-43) — payment-webhook confirm path uses two DB commits instead of one transaction | Kept safe today via idempotency + a forward-only guard; a real fix needs `IUnitOfWork` to grow a transaction scope first | `IUnitOfWork` grows a transaction scope | M | backend-phase-10 follow-up |
|
||||
| [BL-255](../status/backlog.md#deferred-43) — `Bookings`/`Invoices.partner_center_id` have no DB-level FK | Only `nurse_profiles.partner_center_id` got one, per that phase's own Definition of Done | A data-integrity pass on partner-center columns | S | backend-phase 11/15 follow-ups |
|
||||
| [BL-256](../status/backlog.md#deferred-43) — skeleton→content crossfade never retrofitted onto every list/detail page | Exists as a one-line-per-screen pattern; applying it everywhere is a dedicated visual pass, not a drive-by | A dedicated visual-polish pass | M | ui-phase-12 follow-up |
|
||||
| [BL-249](../status/backlog.md#deferred-43) — legacy `UserRefreshTokens` (gRPC auth path) still exists alongside real session-based REST auth | Removing it means confirming nothing still depends on the gRPC path | gRPC moves to sessions, or is dropped | S | backend-phase-2 follow-up |
|
||||
|
||||
---
|
||||
|
||||
## Deferred at the item level — gated behind a console that isn't prioritized yet
|
||||
|
||||
These 7 are filed by severity (major/minor) rather than in backlog.md's thematic Deferred section, because
|
||||
each is a specific, already-scoped gap rather than a standing product decision — but each is genuinely blocked
|
||||
on the same thing: an admin console this roadmap hasn't prioritized building yet. Recorded here for the same
|
||||
reason: each has a trigger, and none should be picked up piecemeal ahead of its console.
|
||||
|
||||
| Item | Why deferred | Pull-trigger | Size | Decided |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| [BL-050](../status/backlog.md#major-86) — no preview/approve/reject route for admin refunds (`POST admin_refunds` creates+executes in one call) | The three-step admin `RefundPanel` console has no real backend to call | Admin refund console prioritized | M | phase 3 flow gap |
|
||||
| [BL-075](../status/backlog.md#major-86) — `recordTransferReference` targets a route that doesn't exist | The payout batch-detail reconcile field would 404 on flip | Payout reconciliation console prioritized | S | phase 3 flow gap, REQ-036 |
|
||||
| [BL-082](../status/backlog.md#major-86) — `approveVerification`/`rejectVerification`/`getDocumentSignedUrl` target routes that don't exist | The admin verification case page's approve/reject CTAs would 404 the moment BL-001 (RBAC) is fixed | Verification-admin console prioritized | M | phase 3 flow gap, REQ-034 |
|
||||
| [BL-096](../status/backlog.md#major-86) — tier (c) of the public front door (guest search + public nurse profiles) unbuilt | Needs a backend phase (anonymous rate-limited search read, privacy-reviewed profile shape) **and** an explicit privacy sign-off on the nurse-profile field list — a decision, not a coding task | Privacy sign-off obtained **and** REQ-066/REQ-067 delivered | L | ui-phase-13, product/notes/open-questions.md |
|
||||
| [BL-163](../status/backlog.md#minor-115) — `POST tickets/{id}/assign` is a phantom endpoint the client already targets | Client-side assign UI exists; server route doesn't | Ticket assignment prioritized | S | phase 3 flow gap, REQ-063 |
|
||||
| [BL-168](../status/backlog.md#minor-115) / [BL-261](../status/backlog.md#deferred-43) — ticket attachments are fully designed/built client-side, gated off behind a flag | Waiting on the upload/signed-URL backend | Attachment backend delivered | M | phase 3 flow gap + REQ-060 (**note:** these are the same underlying fact filed as two BL-### by two different harvest passes — treat as one item when picking it up) |
|
||||
| [BL-201](../status/backlog.md#minor-115) — center self-onboarding (write-then-masked IBAN) never exercised on a real route | Deferred pending partner-center de-mock generally | Center self-service onboarding prioritized | M | phase 3 flow gap |
|
||||
|
||||
---
|
||||
|
||||
## Verification note
|
||||
|
||||
[BL-245](../status/backlog.md#deferred-43) is not listed above: its own trigger was "phase 5 verification
|
||||
pass," which this phase executed directly against the code rather than deferring further — see
|
||||
[decisions.md](../status/decisions.md) for the finding (2 of 3 admin actions confirmed built; only
|
||||
`FlagConcern` is a genuine, small, unfiled gap).
|
||||
|
||||
## What this file is not
|
||||
|
||||
This is a record, not a plan — nothing here is re-ranked or newly triggered by this phase. The proposed order
|
||||
of work that **isn't** deferred is [next-up.md](next-up.md); what the deferrals above cost to keep deferring
|
||||
is partly covered in [tech-debt.md](tech-debt.md) where a deferral and a debt item are the same underlying
|
||||
fact (e.g. Redis/BL-220 appears in both, because "not built yet" and "costs more the longer it's not built"
|
||||
are two different questions about the same gap).
|
||||
@@ -0,0 +1,88 @@
|
||||
# Roadmap — where it goes next
|
||||
|
||||
> Last verified: 2026-08-02 against commit `cd8144e`. Populated by phase 5 of the
|
||||
> [documentation clean-up chain](../../archive/clarify-chain/README.md).
|
||||
|
||||
A **proposal**, not a commitment. The ordering is reasoned and can be overruled; what is not negotiable is
|
||||
that every item traces to a `BL-###` in [status/backlog.md](../status/backlog.md), so nothing here was
|
||||
invented — it was triaged first, in phase 4.
|
||||
|
||||
**This page marks its own judgement calls.** Two kinds of claim appear below: **recorded** (came from
|
||||
`backlog.md`, `CLAUDE.md`, `DEPLOY.md`, or a `product/` decision — check the source, not this page's
|
||||
reasoning) and **proposed** (this phase's own synthesis — the ranking, the grouping into units, the sizing).
|
||||
Where the two could be confused, it's labeled.
|
||||
|
||||
## The four documents
|
||||
|
||||
| File | Answers | Ordered? |
|
||||
| --- | --- | --- |
|
||||
| [pre-launch.md](pre-launch.md) | What must be true before real money moves | Yes — but it's a **gate**, not a priority list. Everything in it blocks launch; none of it is optional |
|
||||
| [next-up.md](next-up.md) | What to build next, and why that and not something else | Yes — 5 units, sequenced by the principle below |
|
||||
| [deferred.md](deferred.md) | What's deliberately not being built, and what would change that | No — a record, picked up by its own trigger, not a schedule |
|
||||
| [tech-debt.md](tech-debt.md) | What's getting more expensive the longer it waits | No — each item has its own trigger; not a queue |
|
||||
|
||||
---
|
||||
|
||||
## The sequencing principle
|
||||
|
||||
**Recorded** — stated in the phase brief that produced this roadmap, not derived by this phase:
|
||||
|
||||
> The product's own promise is trust-first and money-holding. So the order is (1) anything that makes a
|
||||
> *money or trust* flow lie to a user, (2) anything that makes a built flow unusable, (3) anything that makes
|
||||
> a mocked flow real, (4) new surface area.
|
||||
|
||||
This principle was given, not derived — it isn't this phase's invention. **Applying it to the actual 262 open
|
||||
backlog items** — which item is a "lie" versus merely "unusable," which five units to carve out of 86 major
|
||||
items, how to size each — **is this phase's proposal**, in [next-up.md](next-up.md).
|
||||
|
||||
## The proposed order — **this phase's proposal**
|
||||
|
||||
| # | Unit | Category | Closes (blockers) | Size |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | [Nurse verification stops lying about status](next-up.md#unit-1--nurse-verification-stops-lying-about-verification-status) | 1 — trust lie | BL-010 | L |
|
||||
| 2 | [Checkout stops lying about money and time](next-up.md#unit-2--checkout-and-the-payment-window-stop-lying-about-money-and-time) | 1 — money lie | BL-006 | M |
|
||||
| 3 | [Admin RBAC: give seeded admins the claim the code checks for](next-up.md#unit-3--give-the-seeded-admins-the-claim-the-code-already-checks-for) | 2 — unusable | BL-001, BL-002 | M |
|
||||
| 4 | [De-mock refunds and BNPL](next-up.md#unit-4--de-mock-refunds-and-bnpl-onto-the-server-thats-already-built) | 3 — mocked→real | BL-007, BL-008, BL-009 | L |
|
||||
| 5 | [De-mock nurse payouts](next-up.md#unit-5--de-mock-nurse-payouts-including-the-one-action-with-no-ui-at-all) | 3 — mocked→real | BL-012, BL-016 | L |
|
||||
|
||||
**One tension, stated plainly rather than hidden:** unit 3 (RBAC) has zero prerequisites and unlocks more
|
||||
downstream work than any other single item — 11 of 14 business areas, per
|
||||
[implemented.md](../status/implemented.md#cross-cutting-the-rbac-finding). A reader who weighs "unlocks the
|
||||
most" above "is technically a lie versus merely unusable" would be right to run it first. It's ranked third
|
||||
here because the stated principle was applied literally. **This is exactly the kind of call this document
|
||||
exists to make visible and overridable** — see [next-up.md](next-up.md) for the full reasoning per unit,
|
||||
including two strong candidates (patient/care records, partner-center) that didn't fit the 3-5 slot cap and
|
||||
are named as the next two in line.
|
||||
|
||||
Running in parallel with all five, on its own track: everything in [pre-launch.md](pre-launch.md). None of it
|
||||
is feature work, so none of it competes with the units above for the same engineering time in the same way —
|
||||
but none of it is optional before real users arrive, regardless of which unit above ships first.
|
||||
|
||||
## What we're not doing, and why — pointer
|
||||
|
||||
The standing answer to "what are we not doing" is [deferred.md](deferred.md): **51 items** (43 recorded as a
|
||||
themed group in `backlog.md`, 8 more filed by severity elsewhere but also carrying deferred status), each with
|
||||
the trigger that would pull it forward, plus the **8 unbuilt product tables** named individually. None of them
|
||||
were re-decided here — phase 5's job was to record them faithfully, not re-litigate phase 4's or `product/`'s
|
||||
calls. The one exception: [BL-245](../status/backlog.md#deferred-43)'s own trigger named this phase directly
|
||||
("phase 5 verification pass"), so it was executed rather than deferred again — see
|
||||
[decisions.md](../status/decisions.md) for the finding.
|
||||
|
||||
## One item already known for `pre-launch.md` — **recorded**, from root CLAUDE.md and DEPLOY.md
|
||||
|
||||
The repo **contains live credentials on purpose** — a deliberate pre-launch trade for a demo deployment,
|
||||
documented in [root CLAUDE.md §6](../../CLAUDE.md) and [DEPLOY.md](../../DEPLOY.md). Before onboarding real
|
||||
users those values must be rotated and the secret half moved out of git.
|
||||
|
||||
One value is load-bearing and must **never** be rotated in place: `Seams:FieldEncryption:Key` and `:HashKey`
|
||||
decrypt every encrypted column and derive the `PhoneHash` every login looks up. Changing them without a
|
||||
migration locks every account out — [pre-launch.md §1](pre-launch.md#1-rotate-the-committed-credentials)
|
||||
spells out what that migration actually requires, since "never change it" and "never be able to change it"
|
||||
are different problems.
|
||||
|
||||
## How to use this page
|
||||
|
||||
Pick the next piece of work from the table above, or from [pre-launch.md](pre-launch.md) if a launch date is
|
||||
what's driving the decision. If neither fits, [tech-debt.md](tech-debt.md) names what's quietly getting more
|
||||
expensive, and [deferred.md](deferred.md) names what's already been decided against — check there before
|
||||
proposing something new, since it might already have a recorded reason and a trigger.
|
||||
@@ -0,0 +1,210 @@
|
||||
# Next up — the opinionated part
|
||||
|
||||
> Last verified: 2026-08-02 against commit `cd8144e`. Populated by phase 5 of the
|
||||
> [documentation clean-up chain](../../archive/clarify-chain/README.md).
|
||||
|
||||
Five units, each spec'd enough that a fresh agent session could start one without re-deriving context. The
|
||||
order applies the sequencing principle from [index.md](index.md#the-sequencing-principle): (1) stop a money
|
||||
or trust flow from lying to a user, (2) fix a built flow that's unusable, (3) make a mocked flow real, (4) new
|
||||
surface area. Nothing here conflicts with any `product/` business rule — every unit below is closing an
|
||||
implementation gap against a rule the business docs already decided, not proposing new behavior.
|
||||
|
||||
---
|
||||
|
||||
## Unit 1 — Nurse verification stops lying about verification status
|
||||
|
||||
**Goal.** Flip the `verification` client domain's mock off for the nurse-facing and public/search-visibility
|
||||
surfaces, so the app renders the server's real, already-correct status everywhere it matters: the nurse's own
|
||||
verification screen, the activation checklist's "go live" gate, search's `is_searchable` filter, and the
|
||||
public trust badge.
|
||||
|
||||
**Why now.** Verification *is* the platform's trust promise. Today the mock doesn't show stale data — it
|
||||
actively contradicts a real, server-computed truth: a server-verified nurse renders as unverified everywhere
|
||||
the client reads her status ([BL-010](../status/backlog.md#blockers-18)). Under the sequencing principle this
|
||||
is a category-1 item: a trust flow lying to a user, ranked above anything merely unbuilt.
|
||||
|
||||
**What it unblocks.** A trustworthy "go live" CTA; the real search-visibility gate becomes observable instead
|
||||
of hidden behind fabricated client state; closes the one confirmed production-seam breach
|
||||
(`nurse/verification/page.tsx` unconditionally imports the mock module regardless of its render gate —
|
||||
[BL-080](../status/backlog.md#major-86)); establishes the de-mock pattern units 4 and 5 reuse.
|
||||
|
||||
**Technical prerequisites.** None blocking. Scope this unit to the nurse-facing and public halves only — the
|
||||
admin approve/reject/suspend actions stay behind
|
||||
[BL-082](../status/backlog.md#major-86) (the routes don't exist yet, independent of RBAC), and are naturally
|
||||
picked up alongside unit 3 once both the routes and RBAC exist.
|
||||
|
||||
**Affected flows.** [nurse-verification](../flows/nurse-verification.md) (mocked → partial),
|
||||
[onboarding-nurse](../flows/onboarding-nurse.md), [search-and-discovery](../flows/search-and-discovery.md)
|
||||
(real gate becomes visible), [public-front-door](../flows/public-front-door.md) (trust badge becomes real).
|
||||
|
||||
**Rough size:** L — a 100%-mocked, 14-operation domain with real DTO reconciliation.
|
||||
|
||||
**Backlog ids closed:** [BL-010](../status/backlog.md#blockers-18) (blocker), BL-080, BL-081, BL-084, BL-190,
|
||||
BL-191, BL-192, BL-214. Worth fixing in the same pass, same trust-truth theme, different root cause:
|
||||
[BL-098](../status/backlog.md#major-86) (an unverified nurse's profile is still readable and asserts
|
||||
"verified" anonymously by id). BL-083 (admin catalog UI for suspend/scan-expiring) is explicitly out of scope
|
||||
— it's an admin surface, deferred with unit 3.
|
||||
|
||||
---
|
||||
|
||||
## Unit 2 — Checkout and the payment window stop lying about money and time
|
||||
|
||||
**Goal.** Fix the cluster of checkout-and-payment defects that tell a customer or nurse something false: the
|
||||
timezone-less deadline that can render a 30-minute window as ~4 hours and expire silently
|
||||
([BL-006](../status/backlog.md#blockers-18)), the checkout summary's missing `nurseVerified` field
|
||||
(BL-058), the payment confirmation that can't deep-link to its booking and hides the tracking code
|
||||
(BL-059), and the invoice screen's client-computed row that's wrong by exactly the VAT amount instead of
|
||||
showing the server's real total (BL-060). Close the two related blind spots while in the same code: no
|
||||
payment-history read (BL-061) and no escrow-ledger read surface at all (BL-062).
|
||||
|
||||
**Why now.** Category-1 by the letter of the sequencing principle: a countdown that shows hours when minutes
|
||||
remain, and an invoice wrong by a fixed, known amount, are exactly "a money/trust flow lying to a user" —
|
||||
not a missing feature, a false one.
|
||||
|
||||
**What it unblocks.** The one flow every paying customer touches becomes honest; removes a standing violation
|
||||
of the repo's "client never computes money" rule ([BL-060](../status/backlog.md#major-86), called out
|
||||
directly in [decisions.md](../status/decisions.md)); a trustworthy countdown is also a quiet prerequisite for
|
||||
ever safely flipping [BL-005](../status/backlog.md#blockers-18) (the dead card-payment rail) to a real PSP —
|
||||
no reason to give a real gateway a lying clock.
|
||||
|
||||
**Technical prerequisites.** None. Every item here is a DTO addition or a client display fix; nothing depends
|
||||
on RBAC or a mock flip elsewhere.
|
||||
|
||||
**Affected flows.** [checkout-and-payment](../flows/checkout-and-payment.md),
|
||||
[booking-request](../flows/booking-request.md) (shares the deadline field).
|
||||
|
||||
**Rough size:** M. Mostly additive DTO fields plus client display fixes; budget extra time for BL-006 since
|
||||
`DateTime` → `DateTimeOffset` touches a shared type used beyond this one screen.
|
||||
|
||||
**Backlog ids closed:** [BL-006](../status/backlog.md#blockers-18) (blocker), BL-042, BL-058, BL-059, BL-060,
|
||||
BL-061, BL-062.
|
||||
|
||||
---
|
||||
|
||||
## Unit 3 — Give the seeded admins the claim the code already checks for
|
||||
|
||||
**Goal.** `DynamicPermissionService.CanAccess` grants only the literal role `admin`; no seeded account holds
|
||||
it, so every seeded admin 403s on every `DynamicPermission`-gated controller
|
||||
([BL-001](../status/backlog.md#blockers-18)). Either broaden the check to also honor the roles actually
|
||||
seeded (`super_admin`/`finance`), or add the missing step that writes a `DynamicPermission` claim when those
|
||||
roles are granted — and seed at least one account holding the literal role so the fix is testable out of the
|
||||
box ([BL-002](../status/backlog.md#blockers-18)).
|
||||
|
||||
**Why now.** Category-2 under the stated principle (it makes a built, real, already-coded admin backoffice
|
||||
completely unusable, rather than lying about anything) — but it is the single highest-leverage fix in the
|
||||
whole backlog: one root cause independently degrades **11 of 14 business areas**
|
||||
([implemented.md](../status/implemented.md#cross-cutting-the-rbac-finding)). Worth naming the tension
|
||||
directly: nothing stops this from running before units 1-2 except the sequencing principle's letter — it has
|
||||
zero prerequisites of its own, same as they do. It's ranked third here because the principle is applied
|
||||
literally; a reader who weighs "unlocks the most downstream work" more heavily than the letter of the
|
||||
principle would be right to run this first, and that's exactly the kind of overrule this document exists to
|
||||
make easy.
|
||||
|
||||
**What it unblocks.** Admin verification review (once BL-082's routes exist), refund preview/approve/reject
|
||||
(unit 4), payout `mark_failed` and batch processing (unit 5), reviews ever leaving moderation
|
||||
([BL-017](../status/backlog.md#blockers-18)), ticket assignment, cancellation-policy admin edits, the admin
|
||||
user/role directory ([BL-029](../status/backlog.md#major-86)) — essentially every admin-side item in units
|
||||
4-5 and in [deferred.md](deferred.md#deferred-at-the-item-level--gated-behind-a-console-that-isnt-prioritized-yet).
|
||||
|
||||
**Technical prerequisites.** None.
|
||||
|
||||
**Affected flows.** [admin-backoffice](../flows/admin-backoffice.md) (mocked → real), plus the admin half of
|
||||
nurse-verification, cancellation-and-refunds, messaging-tickets, nurse-earnings-and-payouts, partner-center,
|
||||
reviews, booking-request, booking-lifecycle-evv, and nurse-catalog-and-pricing — the 11 areas
|
||||
[implemented.md](../status/implemented.md#cross-cutting-the-rbac-finding) names.
|
||||
|
||||
**Rough size:** M. The fix itself is small and surgical; the size is in re-verifying admin surfaces across 11
|
||||
areas afterward, since several have never been reachable long enough to know what else breaks once they 200.
|
||||
|
||||
**Backlog ids closed:** [BL-001](../status/backlog.md#blockers-18), BL-002 (both blockers). Directly unblocks
|
||||
without itself closing: BL-017, BL-029, BL-050, BL-082, BL-116, BL-117, BL-125, BL-163.
|
||||
|
||||
---
|
||||
|
||||
## Unit 4 — De-mock refunds and BNPL onto the server that's already built
|
||||
|
||||
**Goal.** Flip the `refunds` and `bnpl` client domains off their mocks. Both currently read a retired/wrong
|
||||
bookings-mock store that would break, not just look stale, on a naive flip: refunds would render a
|
||||
10000%-scale refund amount ([BL-009](../status/backlog.md#blockers-18)); the BNPL wizard 404s for every real
|
||||
booking id ([BL-008](../status/backlog.md#blockers-18)). Seed a real `Bnpl` payment-gateway row so BNPL calls
|
||||
stop 400ing ([BL-007](../status/backlog.md#blockers-18)). Fix the DTO drift the flow atlas already catalogued
|
||||
— BL-047 (refund channel pinned to a fixture id), BL-048 (client discards six real fee-split fields), BL-049
|
||||
(three enum mismatches) — *before* flipping, not after, exactly the order phase 3/4's analysis already sets up.
|
||||
|
||||
**Why now.** Both are money flows. Both are among the five domains
|
||||
[implemented.md](../status/implemented.md) flags as most at risk of breaking, not just showing stale data, on
|
||||
a flip — the reconciliation work is already scoped by the flow docs, so this is executing a known plan, not
|
||||
discovering one.
|
||||
|
||||
**What it unblocks.** A customer sees a real cancellation/refund preview instead of a fabricated one; the
|
||||
platform's second payment rail (BNPL) becomes exercisable end-to-end locally for the first time.
|
||||
|
||||
**Technical prerequisites.** None to start the customer-facing halves. The admin sides —
|
||||
[BL-050](../status/backlog.md#major-86) (refund preview/approve/reject route doesn't exist) and the
|
||||
BNPL-adjacent admin surfaces — stay deferred regardless of this unit, consistent with their own
|
||||
already-recorded "gated on a console" status in [deferred.md](deferred.md).
|
||||
|
||||
**Affected flows.** [cancellation-and-refunds](../flows/cancellation-and-refunds.md) (mocked → partial),
|
||||
[bnpl-installments](../flows/bnpl-installments.md) (mocked → partial).
|
||||
|
||||
**Rough size:** L. Two domains, DTO reconciliation on both, a gateway-seeding change. Worth solving
|
||||
[BL-131](../status/backlog.md#deferred-43) (nothing fires the BNPL webhook locally) in the same pass — without
|
||||
it, a real BNPL order can be initiated but never observed reaching `settled` in dev.
|
||||
|
||||
**Backlog ids closed:** [BL-007](../status/backlog.md#blockers-18), BL-008, BL-009 (all three blockers),
|
||||
BL-047, BL-048, BL-049, BL-052, BL-053, BL-054, BL-126, BL-127, BL-128, BL-129, BL-130.
|
||||
|
||||
---
|
||||
|
||||
## Unit 5 — De-mock nurse payouts, including the one action with no UI at all
|
||||
|
||||
**Goal.** Flip the `payouts` client domain to real. Build the client UI for `process` — the irreversible step
|
||||
that actually executes a payout batch, which currently has **zero client caller anywhere in the app**. Fix
|
||||
`DeriveEarningsState`'s wrong "paid" logic, which marks a booking paid whenever it merely has a payout *link*,
|
||||
regardless of that payout's actual status, and reconcile the four earnings buckets against the ledger
|
||||
([BL-012](../status/backlog.md#blockers-18)).
|
||||
|
||||
**Why now.** This is the flow where a nurse's trust in ever actually getting paid lives, and it currently
|
||||
tells her she's been paid when the payout may not have succeeded — a money lie hiding inside a mocked flow.
|
||||
It's category-3 (de-mock) and category-1 (stop a lie) at the same time, which is why it's grouped with the
|
||||
other de-mock units rather than ranked above unit 3 on category-1 grounds alone: fixing the lie and fixing the
|
||||
mock are the same commit here, unlike units 1-2 where the lie could be fixed without a mock flip.
|
||||
|
||||
**What it unblocks.** A truthful earnings/payout history for nurses; the one missing admin action (batch
|
||||
processing); [BL-076](../status/backlog.md#major-86) (`mark_failed` has no client op) becomes reachable once
|
||||
this unit and unit 3 (RBAC) have both landed.
|
||||
|
||||
**Technical prerequisites.** Unit 3 (RBAC), for the admin-side `process`/`mark_failed` actions specifically —
|
||||
the nurse-facing earnings/history half has no such dependency and can proceed independently. This is the one
|
||||
unit in this list with a real cross-unit dependency; sequence its admin half after unit 3 regardless of where
|
||||
the two land in a sprint.
|
||||
|
||||
**Affected flows.** [nurse-earnings-and-payouts](../flows/nurse-earnings-and-payouts.md) (mocked → partial).
|
||||
Worth fixing [BL-016](../status/backlog.md#blockers-18) in the same pass — a booking swept to `missed` never
|
||||
reaches a payable state today, so it can never enter a payout batch regardless of this unit's other fixes.
|
||||
|
||||
**Rough size:** L. Mock removal, a wholly new admin-action UI, a state-derivation bug fix, and a
|
||||
ledger-reconciliation check.
|
||||
|
||||
**Backlog ids closed:** [BL-012](../status/backlog.md#blockers-18), BL-016 (both blockers), BL-073, BL-074,
|
||||
BL-076, BL-179, BL-180, BL-181, BL-182, BL-183, BL-184. Unblocks without closing: BL-075.
|
||||
|
||||
---
|
||||
|
||||
## Considered, and deliberately not in this list
|
||||
|
||||
- **[BL-013](../status/backlog.md#blockers-18) — partner-center (100% mocked, zero tenancy).** Just as mocked
|
||||
as the four domains above, but structurally different: 5 core routes
|
||||
(`centers/me`, `/me/nurses`, `/me/bookings`, `/me/bookings/{id}`, `/me/settlement`) **don't exist
|
||||
server-side at all**. This isn't a de-mock, it's a small backend phase followed by a de-mock — a bigger unit
|
||||
than the others, and one where the "flip the mock" playbook from units 1/4/5 doesn't directly apply. Next
|
||||
in line after these five.
|
||||
- **[BL-011](../status/backlog.md#blockers-18) — patient/care records (100% mocked, server real).** Same
|
||||
shape as units 1, 4, and 5 (server real, client mocked, DTO drift already catalogued) and just as legitimate
|
||||
a candidate — it didn't fit this document's 3-5 cap, not a judgment that it matters less. Directly next in
|
||||
line if a sixth unit is wanted.
|
||||
- **[BL-096](../status/backlog.md#major-86) — tier (c) of the public front door (guest search + public nurse
|
||||
profiles).** New surface area (category 4), and explicitly named in the phase brief as ranking below making
|
||||
the authenticated flows honest regardless of visibility. Also blocked on a decision this document can't
|
||||
make: an explicit privacy sign-off on the nurse-profile field list, plus REQ-066/067 delivered. Stays in
|
||||
[deferred.md](deferred.md), not here.
|
||||
@@ -0,0 +1,177 @@
|
||||
# Pre-launch — the hard gate before real users touch this
|
||||
|
||||
> Last verified: 2026-08-02 against commit `cd8144e`. Populated by phase 5 of the
|
||||
> [documentation clean-up chain](../../archive/clarify-chain/README.md).
|
||||
|
||||
Everything below must be true before a real user with real money uses the platform. This is a gate, not a
|
||||
backlog — items here are not ranked by convenience, they're ranked by what "real user, real money" requires.
|
||||
Four things put you here: **committed credentials**, **mocked money/identity rails**, **Development running in
|
||||
production**, and **legal/tax code that isn't finished**. A fifth — **every Phase 4 blocker** — is included
|
||||
because "blocker" means *the product is wrong or unusable*, and that bar applies with or without real money.
|
||||
|
||||
---
|
||||
|
||||
## 1. Rotate the committed credentials
|
||||
|
||||
The repo contains live credentials **by deliberate pre-launch decision**
|
||||
([root CLAUDE.md §6](../../CLAUDE.md), [BL-003](../status/backlog.md#blockers-18)): DB `sa`, both
|
||||
JWE/field-encryption key halves, Kavenegar, Neshan, Finnotech, and the Telegram bot token. All of them must be
|
||||
rotated and the secret half moved out of git before onboarding real users.
|
||||
|
||||
| Credential | Rotation is | What it takes |
|
||||
| --- | --- | --- |
|
||||
| DB `sa` password | a straight rotation | Change on the SQL Server, update `appsettings.Production.json`. No data migration. |
|
||||
| Kavenegar / Neshan / Finnotech API keys | a straight rotation | Issue new keys with each vendor, update config. No data migration. |
|
||||
| Telegram bot token | a straight rotation | Regenerate via BotFather, update `docker-compose.yml` (`otp-relay.environment`) and the appsettings copy — the two must match ([DEPLOY.md](../../DEPLOY.md)). |
|
||||
| `IdentitySettings:SecretKey` / `Encryptkey` (JWE) | a straight rotation | Safe to rotate any time — the only side effect is signing everyone out. |
|
||||
| **`Seams:FieldEncryption:Key` / `:HashKey`** | **not** a rotation — see below | **Never** change without the migration in the next section. |
|
||||
|
||||
### The one item that is a project, not a config edit
|
||||
|
||||
`Seams:FieldEncryption:Key` decrypts every encrypted column (phone numbers, addresses, IBANs, clinical
|
||||
notes); `:HashKey` derives `users.PhoneHash`, which every login looks up. Changing either value in place makes
|
||||
all existing encrypted data unreadable and locks every account out — this is stated as a permanent invariant
|
||||
in [DEPLOY.md](../../DEPLOY.md) and [decisions.md](../status/decisions.md), and it is correct **as long as the
|
||||
key never needs to change**. It will need to change eventually (security incident, key-management policy,
|
||||
routine hygiene), and at that point it is its own migration project:
|
||||
|
||||
1. Decrypt every affected column with the old key, in place, inside a maintenance window or behind a
|
||||
dual-key read path.
|
||||
2. Re-encrypt with the new key.
|
||||
3. Recompute `PhoneHash` for every user with the new `HashKey`, since it's a one-way derivation — there is no
|
||||
way to "re-key" a hash without the plaintext.
|
||||
4. Decide the cutover strategy: a maintenance-window rewrite (simplest, requires downtime sized to the data
|
||||
volume) versus a dual-read migration (no downtime, more code to write and then delete).
|
||||
|
||||
**Rough size: large.** Nothing about this is hard, but nothing about it is a config edit either — track it as
|
||||
its own piece of work with its own testing pass, not a line item inside a generic "rotate credentials" task.
|
||||
|
||||
---
|
||||
|
||||
## 2. Still-mocked production seams
|
||||
|
||||
Exactly one `Seams:*:Provider` is set anywhere in the repo — `Seams:Sms:Provider = telegram`, and the Telegram
|
||||
relay is [documented as the pre-launch demo rail](../../CLAUDE.md), not a production SMS gateway. Every other
|
||||
rail runs on its mock, in both development and the deployed stack
|
||||
([docs/flows/index.md](../flows/index.md#mock-vs-real-map--server-seams)):
|
||||
|
||||
| Rail | Mock today | Real adapter | What flipping takes |
|
||||
| --- | --- | --- | --- |
|
||||
| SMS / OTP | `LoggingSmsSender` (real path is `telegram`, a demo relay) | `KavenegarSmsSender` — already coded | Set `Seams:Sms:Provider = kavenegar` + real API key/sender. Attempting `smsir`/`ghasedak` throws at startup by design — those adapters don't exist ([BL-235](../status/backlog.md#deferred-43)). |
|
||||
| Card PSP | `MockPaymentProvider` (redirects to a non-existent host — [BL-005](../status/backlog.md#blockers-18)) | `ZarinPalPaymentProvider` — already coded | Real merchant credentials from a licensed PSP — which requires e-namad (§5) — plus a reachable webhook endpoint. **The selector is a plain string check, not an enum** — any non-`mock` value silently selects ZarinPal, so a typo in this setting picks a real gateway by accident. |
|
||||
| Settlement split (تسهیم) | `MockSettlementSplitProvider` | `ProviderSettlementSplitProvider` — already coded | Real settlement-provider credentials. |
|
||||
| BNPL | `MockBnplProvider` (no gateway row even seeded — [BL-007](../status/backlog.md#blockers-18)) | `SnappPayBnplProvider` / `DigipayBnplProvider` — already coded | Real provider credentials, a seeded `Bnpl` gateway row, and a webhook receiver reachable from the provider (nothing fires it in dev today — [BL-131](../status/backlog.md#minor-115)). |
|
||||
| Object storage | `LocalDiskObjectStorage` (`GetUrl` returns a `file://` URI no browser can fetch — [BL-024](../status/backlog.md#major-86)) | `S3ObjectStorage` — already coded | A real bucket + credentials. Flipping this also happens to fix BL-024, since S3's `GetUrl` returns an actual HTTP(S) URL. |
|
||||
| Geocoder | `MockGeocoder` (server) / keyless grid stand-in (client, `NEXT_PUBLIC_NESHAN_KEY` unset — [BL-025](../status/backlog.md#major-86)) | `NeshanGeocoder` — already coded | A real Neshan API key on **both** sides — the client key is separate from any server-side one. |
|
||||
| **Bank transfer (payouts)** | `MockBankTransferProvider` | **none — moves no money** | Nothing to flip. This is the one rail with no real adapter written at all; even after every other rail above goes real, **payouts still cannot move real money** until a real bank-transfer integration is built from scratch. Size this as new work, not a config change. |
|
||||
| e-invoicing (مودیان) | `MockMoadianClient` | `MoadianClient` — already coded | Token-exchange refresh and the Moadian signing certificate are not wired ([BL-236](../status/backlog.md#deferred-43)) — both are deploy-time actions once real credentials exist, not code changes. |
|
||||
| Shahkar · e-KYC · IBAN ownership | mocks | Finnotech adapters — already coded | Real Finnotech credentials. |
|
||||
| Credential (MoH/INO) · eNamad · review moderation | mock, always | **none — deliberate** | These stay manual/mocked by product decision, not a launch gap — see [deferred.md](deferred.md). |
|
||||
| Search | *(no mock)* | `SqlNurseSearch` | Already real. Not a launch item. |
|
||||
|
||||
**Net: of 10 rails with a real adapter, 9 are a credentials-and-config flip; 1 (bank transfer) doesn't have a
|
||||
real adapter yet.** Two rails throw at startup rather than silently falling back to the mock if misconfigured
|
||||
(SMS providers other than Kavenegar, and any `Search:Backend` other than `sql`) — that fail-fast behavior is
|
||||
intentional and should stay.
|
||||
|
||||
---
|
||||
|
||||
## 3. Running as Development in production
|
||||
|
||||
[DEPLOY.md](../../DEPLOY.md) documents this as a **deliberate trade** for a demo deployment among trusted
|
||||
people. Every consequence below stops being acceptable the moment strangers can reach the site:
|
||||
|
||||
| Consequence | Why it matters | Fix |
|
||||
| --- | --- | --- |
|
||||
| The developer exception page is public | Any unhandled 500 on `api.balinyaar.ir` returns a stack trace and configuration detail to the caller | Switch to `Production` environment |
|
||||
| `GET /api/v1/dev/last_otp/{phone}` is live and anonymous ([BL-004](../status/backlog.md#blockers-18)) | Anyone who knows a registered phone number can read its login code and sign in as that user — **the single biggest exposure today** | Same — the endpoint is Development-gated in code; it disappears once the environment flips |
|
||||
| Swagger served at `/swagger` | Full API surface exposed | Same |
|
||||
| Seeders re-run on every boot; migrations auto-apply on boot | Safe today (idempotent), but not how a production release process should work | Run migrations as a one-shot command instead (`docker compose run --rm api dotnet Baya.Web.Api.dll migrate`) |
|
||||
| gRPC reflection enabled; the demo `bookings/convert` payment-capture simulator is wired | Extra attack surface + a fake-payment code path reachable in a real deployment | Same — disappears with the environment flip |
|
||||
|
||||
**What must change**, per [DEPLOY.md "Going to Production"](../../DEPLOY.md#going-to-production-later):
|
||||
|
||||
1. Set `ASPNETCORE_ENVIRONMENT: Production` in `docker-compose.yml`.
|
||||
2. Create `appsettings.Production.json` (it does not exist yet) with real `IdentitySettings:SecretKey` /
|
||||
`Encryptkey` — `StartupSecretsGuard` rejects any value containing `not-for-production` outside Development,
|
||||
so today's dev keys refuse to boot in Production by design. Keep `Seams:FieldEncryption` byte-identical to
|
||||
the Development file (§1).
|
||||
3. Run migrations as a one-shot instead of on boot.
|
||||
4. Swap the OTP rail to `kavenegar` (§2) — the Telegram relay broadcasting every code to a fixed recipient
|
||||
list stops being acceptable once someone outside that list can request a code.
|
||||
|
||||
---
|
||||
|
||||
## 4. Every Phase 4 blocker
|
||||
|
||||
"Blocker" means *the product is wrong or unusable* — that bar holds regardless of whether money is involved.
|
||||
All 18 are pre-launch gate items; full detail and code traces are in
|
||||
[backlog.md](../status/backlog.md#blockers-18).
|
||||
|
||||
| ID | One line | Rough effort |
|
||||
| --- | --- | --- |
|
||||
| BL-001 | Admin RBAC grants only the literal role `admin`; every seeded admin 403s everywhere | M — see [next-up.md](next-up.md) unit 3 |
|
||||
| BL-002 | No seeded account holds the literal `admin` role, so even a fixed BL-001 is untestable out of the box | S — bundled with BL-001 |
|
||||
| BL-003 | Committed live credentials, unrotated | See §1 |
|
||||
| BL-004 | Dev-only OTP-read endpoint live on the production domain | See §3 |
|
||||
| BL-005 | Card payment is a dead end everywhere — mock redirects to a non-existent host, no local webhook | M — see [next-up.md](next-up.md) unit 2 |
|
||||
| BL-006 | Booking deadlines ship without a timezone; a 30-min window can render as ~4h and expire silently | S — see [next-up.md](next-up.md) unit 2 |
|
||||
| BL-007 | No `bnpl` gateway row ever seeded; every BNPL call 400s | S — see [next-up.md](next-up.md) unit 4 |
|
||||
| BL-008 | BNPL wizard's mock cross-imports the bookings-mock store; dead end for every real booking id | M — see [next-up.md](next-up.md) unit 4 |
|
||||
| BL-009 | Refunds mock reads a retired store and would show a 10000%-scale refund on flip | M — see [next-up.md](next-up.md) unit 4 |
|
||||
| BL-010 | Verification is 100% client-mocked; a server-verified nurse renders unverified everywhere | L — see [next-up.md](next-up.md) unit 1 |
|
||||
| BL-011 | Patient/care records are 100% mocked; real DTO shapes would break on a naive flip | M |
|
||||
| BL-012 | Nurse payouts: mock hides 4 working endpoints; no UI for the irreversible "process batch" step; "paid" status computed wrong | M — see [next-up.md](next-up.md) unit 5 |
|
||||
| BL-013 | Partner center 100% mocked, zero real tenancy; 5 core routes don't exist server-side | L |
|
||||
| BL-014 | Search results are index rows, not de-duplicated nurses; trust dossier mocked; unverified nurse profile page asserts "verified" | M |
|
||||
| BL-015 | Editing an address silently nulls recipient name/phone/postal code on every save | S |
|
||||
| BL-016 | A booking swept to `missed` never reaches a payable state | M |
|
||||
| BL-017 | Reviews can never leave moderation on the live stack (sits behind BL-001; `AutoApproveClean` unset) | S once BL-001 lands |
|
||||
| BL-018 | Zero option groups outside Development — every builder collapses to two steps, deployed | M |
|
||||
|
||||
---
|
||||
|
||||
## 5. Legal / tax items that are code-side
|
||||
|
||||
From [product/business/13-tax-invoicing-and-legal.md](../../product/business/13-tax-invoicing-and-legal.md),
|
||||
confirmed the platform's weakest business area in [implemented.md](../status/implemented.md):
|
||||
|
||||
- **Terms & Privacy still ship placeholder legal copy behind a draft banner** — flagged for human/legal review
|
||||
since ui-phase-3 and still unreviewed ([BL-097](../status/backlog.md#major-86)). Swapping in
|
||||
counsel-reviewed copy (and removing the banner) is a pre-launch item, not a nice-to-have.
|
||||
- **مودیان (e-invoice) integration is mocked past the point the business doc calls MVP.** The `invoices` model,
|
||||
VAT split, and reference fields exist correctly (§ commission/VAT model below), but the Moadian
|
||||
token-exchange refresh and signing certificate are unwired ([BL-236](../status/backlog.md#deferred-43)), and
|
||||
seeded partner-center invoices carry null Moadian reference/PDF fields
|
||||
([BL-200](../status/backlog.md#major-86)). These are deploy-time actions once real Moadian credentials
|
||||
exist — but they don't exist yet, and مودیان readiness is explicitly named as MVP scope in the business doc.
|
||||
- **The commission/VAT split itself is already correct and does not need code work**: platform commission is
|
||||
15% of gross, VAT is 10% of the *commission only* — this became the single source of truth in
|
||||
refinement-phase-3 ([decisions.md](../status/decisions.md)) and matches the business doc's nurse-as-
|
||||
taxable-seller / platform-as-commission-seller model. The one open defect is a **display** bug, not a model
|
||||
bug: the invoice screen computes a client-side row that's wrong by exactly the VAT amount instead of
|
||||
rendering the server's real total ([BL-060](../status/backlog.md#major-86) — folded into
|
||||
[next-up.md](next-up.md) unit 2).
|
||||
- **`partner_centers` as merchant-of-record is the business doc's launch-critical legal vehicle** ("the fast,
|
||||
legal go-to-market is to partner with already-licensed centers") — and it is 100% mocked with zero real
|
||||
tenancy today ([BL-013](../status/backlog.md#blockers-18)). Until this is real, there is no functioning
|
||||
legal invoice-issuer path for a booking routed through a partner center.
|
||||
- **e-namad is a business/licensing prerequisite, not a code task, but it gates one**: per
|
||||
[legal-landscape.md](../../product/research/legal-landscape.md), a monetized Iranian site needs e-namad to
|
||||
obtain an online payment gateway at all — meaning the real ZarinPal flip in §2 cannot complete until the
|
||||
launch entity holds e-namad, independent of anything in this codebase.
|
||||
- **VAT-exempt-or-0% is a live legal question, not yet a code gap**: the business doc notes medical services'
|
||||
own VAT treatment is unconfirmed in Iran and asks for the rate to stay config-driven so it can land either
|
||||
way. Confirm with an Iranian tax advisor before launch, then confirm the rate is read from a
|
||||
`platform_configs` row (the repo's config-is-rows convention) rather than a hardcoded constant — this
|
||||
wasn't independently re-verified this phase and is worth a direct check before relying on it.
|
||||
|
||||
---
|
||||
|
||||
## Not in this file
|
||||
|
||||
Everything here is a **gate**, not a roadmap. What comes after the gate — the highest-leverage next units of
|
||||
work, what's deliberately deferred and why, and what technical debt is accruing — is
|
||||
[next-up.md](next-up.md), [deferred.md](deferred.md), and [tech-debt.md](tech-debt.md) respectively. Several
|
||||
pre-launch items and next-up items overlap on purpose (e.g. BL-006, BL-060): the same fix both closes a
|
||||
backlog item worth doing regardless of launch timing, and clears a launch gate.
|
||||
@@ -0,0 +1,142 @@
|
||||
# Tech debt — what it costs to not pay this yet
|
||||
|
||||
> Last verified: 2026-08-02 against commit `cd8144e`. Populated by phase 5 of the
|
||||
> [documentation clean-up chain](../../archive/clarify-chain/README.md).
|
||||
|
||||
Debt is different from [backlog.md](../status/backlog.md): a backlog item blocks a specific flow. A debt item
|
||||
doesn't block anything today — it raises the cost of *everything that comes after it*. The phase brief named
|
||||
six candidates to assess (items 2, 3, 4, 5, 7, 8 below); two of those (3, 4) turned out less severe than the
|
||||
framing assumed once checked against the code, and this phase adds two more (1, 6) found while doing that
|
||||
checking. Each entry below states what it costs now, what it costs at meaningfully more usage, and the
|
||||
trigger that should cause it to get paid.
|
||||
|
||||
---
|
||||
|
||||
## 1. No contract-generation pipeline — client DTOs hand-drift from the server
|
||||
|
||||
**What it is:** `docs/integration/openapi/swagger.v1.json` is a regenerated, dated snapshot, but nothing turns
|
||||
it into client types. `client/package.json` has no `openapi-typescript`/NSwag/orval step — every service
|
||||
domain's TypeScript types are hand-written and hand-kept in sync with the real server DTOs.
|
||||
|
||||
| Now | At meaningfully more usage |
|
||||
| --- | --- |
|
||||
| The cost has already been paid twice, expensively: phase 2 found **24 phantom client-side endpoints** nobody had checked against swagger; phase 3 found **34 client seam operations that would 404 on a mock→real flip** and several DTO shape mismatches severe enough to *break*, not just show stale data (verification, refunds, payouts, patient-records, partner-center — see [implemented.md](../status/implemented.md)). Both discoveries took a dedicated full-session audit to surface. | Every future domain repeats this discovery cost, and nothing stops a **new** drift from being introduced tomorrow — there is no CI check comparing client types to the live contract. This compounds with domain count, not with traffic: the more domains behind mocks, the more of these audits eventually needed. |
|
||||
|
||||
**Trigger to pay it:** don't wait for the next silent break — the next 2-3 units in [next-up.md](next-up.md)
|
||||
are exactly "flip a mocked domain to real." Wiring a generated-types step (even scoped to just the domains
|
||||
being de-mocked next, not a big-bang rewrite) pays for itself on the very next flip.
|
||||
**Rough size: M.**
|
||||
|
||||
## 2. Single-instance in-process scheduler
|
||||
|
||||
**What it is:** weekly payout-batch generation, and (once cron'd) the credential-expiry scan and EVV no-show
|
||||
sweep, all run as in-process jobs with no distributed lock. Correct today because exactly one API instance
|
||||
runs. Same root cause as [BL-220](deferred.md#scale-later-infrastructure) (Redis not deployed).
|
||||
|
||||
| Now | At meaningfully more usage |
|
||||
| --- | --- |
|
||||
| Zero cost — one instance, no race. | A rolling deploy that briefly runs two instances already risks a **duplicate weekly payout batch** — real money, generated twice. Horizontal scaling for any other reason (traffic, availability) can't happen without this being solved first; it's a hidden prerequisite baked into "just add another container." |
|
||||
|
||||
**Trigger to pay it:** the moment a second instance is even considered, for any reason — not just load.
|
||||
**Rough size: M** (a distributed lock via Redis, per BL-220's own scoping).
|
||||
|
||||
## 3. Testing-convention asymmetry between the two projects
|
||||
|
||||
**Reassessed — this is not a raw-count gap.** Server test files (119, spanning `Baya.Test.Api` WebApplication-
|
||||
Factory integration tests and `Baya.Test.Foundation` handler/unit tests) and client test files (125, Jest) are
|
||||
close in count, and money-path server coverage is genuinely strong: Payments, Bookings, BNPL, Payouts, and
|
||||
Refunds each have dedicated API-integration **and** handler-level test files. The real asymmetry is in
|
||||
**enforcement**:
|
||||
|
||||
- [docs/rules/server/cqrs.md](../rules/server/cqrs.md) makes testing a mandatory step for every new
|
||||
feature: "Add handler unit tests **and** at least one `WebApplicationFactory` integration test for the
|
||||
area: happy path 200, unauthenticated 401, validation 400."
|
||||
- Root [CLAUDE.md](../../CLAUDE.md)'s own client gate is reactive, not mandatory: "`npm run test:ci`
|
||||
**if you touched a tested component**." New client code has no enforced testing bar at all.
|
||||
|
||||
| Now | At meaningfully more usage |
|
||||
| --- | --- |
|
||||
| 125 client tests exist, but — consistent with the flow atlas's own mock-vs-real accounting — coverage almost certainly concentrates on the domains that were real early and thins out on the ones still mocked. | As the mocked domains in [next-up.md](next-up.md) flip to real, the newly-real client code lands with no enforced test the way a newly-shipped server handler always does. Regressions there have no automated net; the flow atlas's manual, one-session verification (§4 below) is the only thing that has ever caught them. |
|
||||
|
||||
**Trigger to pay it:** natural to pair with each domain de-mock in [next-up.md](next-up.md) rather than run
|
||||
as its own initiative — write the rule once, backfill tests as each domain is touched anyway.
|
||||
**Rough size: S** to write the convention, **M per domain** to backfill.
|
||||
|
||||
## 4. No automated test crosses the client↔server boundary
|
||||
|
||||
**Reassessed — "absence of E2E tests over the money paths" overstates it.** The money paths have real
|
||||
automated coverage at the handler and API-integration layers (§3). What's genuinely missing is anything that
|
||||
drives a **real browser against a real running API** — no Playwright, no Cypress, nothing in
|
||||
`client/package.json`'s scripts beyond Jest. That gap is exactly why phase 3's flow atlas had to boot the
|
||||
whole stack and manually click/curl through all 23 flows to get a trustworthy status — a deliberate,
|
||||
one-session, human/agent-driven substitute for automated E2E.
|
||||
|
||||
| Now | At meaningfully more usage |
|
||||
| --- | --- |
|
||||
| Absorbed as a one-time cost per audit — expensive (a full session), but infrequent. | This cost doesn't scale with traffic, it scales with **how often you need to trust a full-stack claim** — every future audit like phase 3's repeats the same manual walkthrough from scratch, because nothing keeps the previous one's findings mechanically re-checkable. |
|
||||
|
||||
**Trigger to pay it:** before the next full-stack audit is needed, or the first time a money-path regression
|
||||
reaches production undetected — whichever comes first. **Rough size: M** — the server side is already well
|
||||
tested, so this only needs to close the browser↔API gap: a handful of Playwright specs over the critical path
|
||||
(login → search → book → pay → cancel), not a rewrite of anything existing.
|
||||
|
||||
## 5. Search without Elasticsearch
|
||||
|
||||
`SqlNurseSearch` over `nurse_search_index` is real, correct, and — per
|
||||
[decisions.md](../status/decisions.md) — the deliberate MVP implementation, same item as
|
||||
[BL-221](deferred.md#scale-later-infrastructure).
|
||||
|
||||
| Now | At meaningfully more usage |
|
||||
| --- | --- |
|
||||
| Fine at MVP data volume; no measured strain. | A wide denormalized SQL index degrades on filter/sort/paging combinations well before a purpose-built search engine would. |
|
||||
|
||||
**Trigger to pay it:** measured latency/throughput strain on `nurse_search_index` — not a date.
|
||||
**Rough size: L.**
|
||||
|
||||
## 6. The ESLint unused-vars gate is a no-op
|
||||
|
||||
[BL-250](deferred.md#cleanup-of-convenience-and-data-integrity-hygiene): the config patches an export path
|
||||
that doesn't carry the rule, so `@typescript-eslint/no-unused-vars` never actually runs — despite
|
||||
`client/CLAUDE.md`'s own golden rule 11 claiming it does.
|
||||
|
||||
| Now | At meaningfully more usage |
|
||||
| --- | --- |
|
||||
| A safety net that looks present but isn't — dead code accumulates invisibly, with no signal to anyone that it's happening. | The longer this stays broken, the more has silently piled up by the time someone finally looks — this is a debt that compounds purely with time and commit count, independent of traffic or scale. |
|
||||
|
||||
**Trigger to pay it:** a dedicated infra task (this item's own stated trigger). **Rough size: S** — it's a
|
||||
config-path bug, not a redesign.
|
||||
|
||||
## 7. Two remaining raw-state admin forms
|
||||
|
||||
[BL-217](../status/backlog.md#minor-115): `GrantRoleDialog` and `PreviewBatchDialog` are the only two
|
||||
survivors of an otherwise-complete app-wide react-hook-form migration.
|
||||
|
||||
| Now | At meaningfully more usage |
|
||||
| --- | --- |
|
||||
| Trivial — two isolated components. | Still trivial. This is the one item on this list that genuinely doesn't get worse with scale; it's listed only because it's a rough edge for anyone reading the code expecting one form pattern everywhere. |
|
||||
|
||||
**Trigger to pay it:** whenever either dialog is next touched for an unrelated reason — not worth a dedicated
|
||||
pass. **Rough size: S.**
|
||||
|
||||
## 8. Windows-generated client lockfile
|
||||
|
||||
Documented directly in [DEPLOY.md](../../DEPLOY.md#known-wrinkle-the-client-lockfile-is-windows-generated):
|
||||
`client/package-lock.json`, generated on Windows, omits wasm32-only optional packages Linux's npm wants,
|
||||
so a bare `npm ci` fails in the container build. Absorbed today by a `npm install --package-lock-only` step
|
||||
baked into the client Dockerfile before `npm ci`.
|
||||
|
||||
| Now | At meaningfully more usage |
|
||||
| --- | --- |
|
||||
| Zero operational cost — the workaround runs on every image build without incident. | Still zero cost at scale; this is environment debt, not scale debt. The real risk is a future edit: anyone who "cleans up" the Dockerfile without knowing *why* that line is there reintroduces a build failure that only reproduces on Linux/CI, not on the Windows machine that likely made the edit. |
|
||||
|
||||
**Trigger to pay it:** DEPLOY.md already gives the exact fix — regenerate the lockfile on Linux once and
|
||||
commit it, then delete the workaround line. **Rough size: S.** Cheapest item on this list to close permanently.
|
||||
|
||||
---
|
||||
|
||||
## What didn't make this list
|
||||
|
||||
[`archive/clarify-chain/README.md`](../../archive/clarify-chain/README.md)'s own diagnosis (260+ markdown files, ~10 rule sources, contract drift) is the
|
||||
documentation debt this entire phase chain exists to retire — phases 0-4 already paid most of it down, and
|
||||
phase 6/7 finish the job. It isn't repeated here because it isn't *code* debt, and because tracking it twice
|
||||
would just be two ledgers again, the exact problem this chain was created to end.
|
||||
@@ -0,0 +1,184 @@
|
||||
# Client auth
|
||||
|
||||
Cookies, the session lifecycle, silent refresh, `RoleGuard`, and the middleware gate — plus an explicit
|
||||
statement of what is *not* a security boundary.
|
||||
|
||||
> Last verified: 2026-07-30 against commit `d3ec723`.
|
||||
|
||||
---
|
||||
|
||||
## 1. The credential is phone-OTP
|
||||
|
||||
There is **no username/password anywhere**, and email is never a login key. The flow lives in
|
||||
`src/components/auth/` (`LoginFlow` → `PhoneStep` → `OtpStep`) at `/login`, over the `services/auth` domain
|
||||
(`requestOtp` / `verifyOtp` / `refresh` / `logout` / `getMe` / `selectRole`).
|
||||
|
||||
`useWebOtp` is the WebOTP autofill seam — on a supporting browser the code fills itself from the SMS.
|
||||
|
||||
---
|
||||
|
||||
## 2. The two cookies
|
||||
|
||||
| Cookie | Constant | TTL | Written by |
|
||||
| --- | --- | --- | --- |
|
||||
| `access_token` | `COOKIE_NAMES.ACCESS_TOKEN` | 15 min | `persistAuthTokens` (`lib/auth/session.ts`) — via `useVerifyOtp`, `useRefresh`, `useSelectRole`, and the fetch-layer silent refresh |
|
||||
| `refresh_token` | `COOKIE_NAMES.REFRESH_TOKEN` | 7 days | same |
|
||||
|
||||
Lifecycle:
|
||||
|
||||
- **Written** by `persistAuthTokens` after verify / refresh / select-role, which also dispatches `LOG_IN` so
|
||||
`AuthContext` stays in sync without a reload.
|
||||
- **Deleted** by `useLogout()` — the single logout path: revoke the server session, clear both cookies,
|
||||
`LOG_OUT`, drop the `/me` cache, redirect. Also cleared by `clientFetch` when a 401 can't be recovered.
|
||||
- **Read** server-side by `serverFetch` / `getServerAuthState` via `getServerCookie`; client-side by
|
||||
`clientFetch` via `getClientCookie`, to attach `Authorization: Bearer`.
|
||||
|
||||
The `refresh_token` cookie's 7-day TTL is **shorter** than the server session default (30 days). Aligning the
|
||||
cookie `maxAge` to the server's `refreshExpiresAt` is a known follow-up, not a bug to fix blind.
|
||||
|
||||
All cookie access goes through the manager — see [services.md](services.md) §6.
|
||||
|
||||
---
|
||||
|
||||
## 3. Session state
|
||||
|
||||
`AuthContext` (`src/context/auth/`) carries `SessionUser { id?, phone, roles: AppRole[] }`.
|
||||
|
||||
The root layout resolves the session **on the server** with `getServerAuthState()` (`lib/auth/server.ts`),
|
||||
which reads the `access_token` cookie and checks the JWT `exp` via the shared `isTokenAlive`
|
||||
(`lib/auth/token.ts`), and passes it to `<AuthProvider initialState={…}>`. So the very first render already
|
||||
knows whether the user is authenticated.
|
||||
|
||||
**Roles are not derivable from the opaque JWE token server-side.** The server therefore seeds
|
||||
`isAuthenticated` only; `useSessionRoleSync()` — mounted in the `(private-routes)` layout — hydrates
|
||||
`currentUser.roles` from `/me`. That is the single source the shells read via `useActorRole()`.
|
||||
|
||||
`invalidateQueries(authKeys.me())` runs on login; `removeQueries(authKeys.all)` on logout.
|
||||
|
||||
---
|
||||
|
||||
## 4. Where the user lands: the role router
|
||||
|
||||
After a successful verify, `RoleRouter` reads `/me` and navigates — customer → the family app, nurse → the
|
||||
nurse app, admin → the console, empty roles → `/select-role`. It shows the branded splash while `/me` loads,
|
||||
**so the wrong shell never flashes.**
|
||||
|
||||
The decision itself is the **pure, unit-tested** `resolveRoleDestination(me, intendedRole)` in
|
||||
`services/auth/routing.ts`. That function is the single "which app" source — every other place that needs to
|
||||
send a user to their home calls it rather than re-deriving.
|
||||
|
||||
The middleware owns the auth *gate*; the router only decides which app.
|
||||
|
||||
---
|
||||
|
||||
## 5. `RoleGuard`: resolved vs. pending
|
||||
|
||||
Every private shell — `(customer)`, `nurse`, `admin`, `partner` — wraps its layout in **`RoleGuard`**.
|
||||
|
||||
It exists because the *core* role bug is conflating **"`/me` hasn't resolved yet"** with **"the user has no
|
||||
nurse/admin role"**. A `/me` in flight used to fall through the `DEFAULT_ROLE = customer` fallback and flash
|
||||
a nurse the customer app — or strand them there if `/me` failed.
|
||||
|
||||
`RoleGuard` reads **`useRoleHydration()`** (`services/auth`), a discriminated `loading | error | ready` over
|
||||
`useMe`:
|
||||
|
||||
| State | Behaviour |
|
||||
| --- | --- |
|
||||
| **loading** | A neutral brand splash. **Never the customer shell as a stand-in** |
|
||||
| **error** (`/me` failed — API down) | `AuthAccountError` with retry. **Never a silent customer fallback** — a transient error must not downgrade a nurse or an admin |
|
||||
| **role mismatch** | Redirect to the caller's real app via `resolveRoleDestination`, with a `guard_denied` toast — rather than rendering a shell they lack the role for |
|
||||
|
||||
A shell passes `expected={APP_ROLES.*}`. **The partner portal passes no `expected`** — a partner-centre admin
|
||||
is not an `AppRole`. It self-gates on `useMyPartnerCenter` (a 403/404 renders a non-leaking access-denied
|
||||
state, never a raw id), so `RoleGuard` there only hardens hydration.
|
||||
|
||||
`useActorRole()`'s `DEFAULT_ROLE` fallback is now a last resort only — the guard ensures roles are hydrated
|
||||
before a shell renders — never the loading state.
|
||||
|
||||
**`RoleGuard` is UX and chrome, not security.** The server authorizes every endpoint. A dual customer+nurse
|
||||
session holds both roles and moves freely between the two apps (`ActorSwitcher`).
|
||||
|
||||
---
|
||||
|
||||
## 6. Silent refresh
|
||||
|
||||
`clientFetch` attempts one **single-flight** `attemptTokenRefresh` (`lib/api/refresh.ts`) on a 401 and
|
||||
retries the request once. A failed refresh — unknown, expired, or reused token, at which point the server
|
||||
revokes the session — clears tokens and redirects to `/login`.
|
||||
|
||||
The refresh and OTP endpoints are **excluded** from this retry, or a failing refresh would recurse.
|
||||
|
||||
This mirrors the server's rotation + reuse-detection: a replayed refresh token revokes **all** the user's
|
||||
sessions.
|
||||
|
||||
---
|
||||
|
||||
## 7. Middleware
|
||||
|
||||
`middleware.ts` runs in this order, and the order is load-bearing:
|
||||
|
||||
1. **next-intl locale normalization.** If it is issuing a 307/308, return immediately.
|
||||
2. **The guest front door.** An **unauthenticated** exact-match on `/` is `NextResponse.rewrite()`d to
|
||||
`/{locale}/welcome` — **never a redirect**, so the URL and the SEO canonical stay `/`.
|
||||
3. An **authenticated** hit on `/welcome` redirects to `/`.
|
||||
4. **The auth gate.** A non-public path without a live token redirects to `/login`, appending the attempted
|
||||
locale-stripped path + query as **`?next=`** (`RETURN_URL_PARAM`) so a deep link — an SMS booking link, a
|
||||
shared nurse profile — survives the round trip.
|
||||
5. Otherwise: pass through, stamping the resolved locale on the request headers and preserving next-intl's
|
||||
response headers (the `Link: alternate` hreflang set).
|
||||
|
||||
`LoginFlow` reads `?next=` and `RoleRouter` resolves it via **`resolvePostLoginDestination`**
|
||||
(`services/auth/routing.ts`), which accepts **same-origin-relative and role-permitting destinations only**
|
||||
and otherwise falls back to `resolveRoleDestination`. **Never an open redirect.**
|
||||
|
||||
### Two traps in this file
|
||||
|
||||
**The matcher must list bare `'/'` explicitly** alongside the catch-all regex:
|
||||
|
||||
```ts
|
||||
matcher: ['/', '/((?!_next|_vercel|api|.*\\..*).*)']
|
||||
```
|
||||
|
||||
This Next 16 / Turbopack build does **not** reliably invoke middleware for the literal root through the
|
||||
negative-lookahead pattern alone — `/` skipped middleware entirely and 404'd, while every other path matched.
|
||||
It is load-bearing for the guest front door, which only fires on an exact `/` match. (Verified in dev; a
|
||||
production `next build && next start` confirmed the intended behaviour end to end, so the underlying quirk is
|
||||
dev-server-only — but the explicit entry stays.)
|
||||
|
||||
**Never append `ROUTES.HOME` (`'/'`) to `PUBLIC_PATHS`.** `PUBLIC_PATHS` is matched with `startsWith`, so
|
||||
`'/'` would silently make **every route public**. The guest-facing root is handled by the exact-match rewrite
|
||||
above instead. To add a genuinely public route, append it to `PUBLIC_PATHS` and the middleware picks it up
|
||||
automatically.
|
||||
|
||||
---
|
||||
|
||||
## 8. Security posture — what is and isn't a boundary
|
||||
|
||||
The design above is deliberate, and some of its hardening needs *server* coordination. **Don't silently "fix"
|
||||
these client-only.**
|
||||
|
||||
- **Tokens are non-httpOnly cookies** (JS-readable) so `clientFetch` can attach the bearer header. That
|
||||
trades XSS hardening for the bearer pattern. Real hardening — httpOnly cookies set by the server plus a
|
||||
same-origin proxy — spans both projects.
|
||||
- **The middleware check is UX-only, not a security boundary.** It decodes the JWT and checks `exp`; it does
|
||||
**not** verify the signature. The API is the only authority. **Never gate real authorization on the
|
||||
middleware or on `isTokenAlive`.**
|
||||
- **Role gating is coarse for chrome, fine for the backoffice.** Shells pick chrome from the collapsed
|
||||
`currentUser.roles` (`useActorRole`). `useAdminCapabilities()` (`@/hooks`) is a memoized selector over the
|
||||
session's **fine-grained** `roleCodes` (`super_admin` / `admin` / `support` / `finance` / `moderation`)
|
||||
returning per-console booleans — `canVerify`, `canRefund`, `canPayout`, `canModerate`, `canConfig`,
|
||||
`canManageAlerts`, `canManageTickets`, `canManagePartners`, `canViewAudit`, `canManageRoles`. `AdminLayout`'s
|
||||
nav and every admin action hide or disable on it **so a role never sees a control that will 403** — but it
|
||||
is a **display convenience only; the server authorizes every command.** Never gate real authz on it.
|
||||
- **Cross-actor route access is not hard-guarded client-side.** Add route guards when a feature needs them.
|
||||
- **The partner portal is a separate scope** — its pages resolve the caller's own centre via
|
||||
`useMyPartnerCenter()`, never a raw id.
|
||||
- **Signed URLs are fetched on demand, never cached long-lived.** Verification documents load via a
|
||||
short-lived signed URL from `useVerificationDocumentUrl(documentId)` (short `staleTime`, `retry: false`);
|
||||
`DocumentViewer` re-requests on expiry or error rather than reading an embedded URL out of the long-lived
|
||||
case query. **Reuse this pattern for any short-lived signed asset** — invoice PDFs included.
|
||||
- **Refresh-token rotation is wired** client-side (the fetch-layer silent refresh plus `useRefresh`), matching
|
||||
the server's rotation and reuse-detection.
|
||||
|
||||
Admin sub-roles are **server-granted and never self-selectable**; `POST me/select_role` accepts only
|
||||
`customer` / `nurse` and returns 403 for anything else. Don't build a UI that implies otherwise.
|
||||
@@ -0,0 +1,266 @@
|
||||
# Client components, shells and icons
|
||||
|
||||
What to reach for before writing something new, and the layout system every screen lives in.
|
||||
|
||||
> Last verified: 2026-07-30 against commit `d3ec723`.
|
||||
|
||||
---
|
||||
|
||||
## 1. There is one layout: a phone
|
||||
|
||||
`AppFrame` (`src/layout/AppFrame.tsx`) renders **every** screen inside a centered
|
||||
`APP_FRAME_MAX_WIDTH` (480px) column on a `--bal-frame-canvas` backdrop, at **every viewport**. A wider
|
||||
window gets more canvas, never a wider app — so you design one set of states and verify one set of states.
|
||||
|
||||
**Do not add a `≥md` branch that widens a shell, restores a sidebar, or lays a screen out in columns.**
|
||||
|
||||
`AppFrame` owns four structural guarantees, and is the only place any of them is solved:
|
||||
|
||||
1. **The width cap.** No shell stretches a header, a nav bar, or a content column across a monitor.
|
||||
2. **The frame, not the document, owns the scroll.** A single scrolling `<main>` fills the frame; header and
|
||||
footer are pinned over it and reserve their own space through `<main>`'s padding, so **no page needs a
|
||||
top offset of its own**.
|
||||
3. **Horizontal scroll is structurally impossible.** `overflowX: hidden` + `minWidth: 0` on the column mean
|
||||
an over-wide child clips instead of dragging the whole app sideways. Genuinely wide content (a data
|
||||
table) scrolls **inside its own container** — see `AdminDataTable`'s `TableContainer`.
|
||||
4. **Above `sm` the column floats** as a rounded, shadowed card with a gutter all round. On a phone it fills
|
||||
the viewport edge to edge — there is no canvas to float on.
|
||||
|
||||
Two mechanics that follow from (2) and are easy to get wrong:
|
||||
|
||||
- **The chrome is `position: absolute` against the frame, never `fixed`.** A viewport-fixed bar would break
|
||||
out of the centered column and span the whole window. The frame itself never scrolls (only `<main>` does),
|
||||
so on a phone — where the frame *is* the viewport — the two are visually identical.
|
||||
- **`AppFrame` publishes `--bal-chrome-top` and `--bal-chrome-bottom`** on the scroll container, so any
|
||||
`position: sticky` element anywhere in the tree can clear the bars without importing a constant or knowing
|
||||
which shell it is in. Both already include `env(safe-area-inset-*)`, and both resolve to `0px` in a
|
||||
chrome-free shell — which is why a sticky consumer can read them unconditionally. `StickyActionBar` is the
|
||||
reference consumer.
|
||||
|
||||
### Shell dimensions are constants
|
||||
|
||||
`src/layout/config.ts` holds them, and they are measured rather than guessed:
|
||||
|
||||
| Constant | Value | Note |
|
||||
| --- | --- | --- |
|
||||
| `APP_FRAME_MAX_WIDTH` | 480 | Mirrored by `components/config.ts`'s `CONTENT_MAX_WIDTH` — a page column can never be wider than the frame containing it |
|
||||
| `TOP_BAR_HEIGHT` | 56 | One height at every viewport; the frame never changes width, so the old mobile/desktop split had nothing to switch on |
|
||||
| `TOP_CHROME_HEIGHT` | 72 | Total space the floating header occupies. Deliberately equal to `BOTTOM_NAV_HEIGHT` — the two bars are the same object mirrored |
|
||||
| `BOTTOM_NAV_HEIGHT` | 72 | Total space the floating nav occupies. `AppFrame` reserves exactly this as `<main>` padding, so nothing hides behind the bar. **Keep in sync with `BottomBar`** |
|
||||
| `FLOATING_BAR_SX` | — | The ONE definition of the two bars' shared shape, so header and footer cannot drift apart |
|
||||
|
||||
---
|
||||
|
||||
## 2. The shells
|
||||
|
||||
**One authenticated shell.** `MobileShell` = `AppFrame` + a contextual `TopBar` + `BottomBar` +
|
||||
`ErrorBoundary` + `RouteFadeIn` + `PageTitleProvider`. The four actor layouts — `CustomerLayout`,
|
||||
`NurseLayout`, `AdminLayout`, `PartnerLayout`, each wrapped in `RoleGuard` — supply only `tabs` and
|
||||
`headerActions`.
|
||||
|
||||
**Add a destination by adding a tab or a hub row — never by forking the shell.**
|
||||
|
||||
| Shell | For |
|
||||
| --- | --- |
|
||||
| `MobileShell` (via the four actor layouts) | Every authenticated screen |
|
||||
| `PublicLayout` | Unauthenticated: the frame and **nothing else**, no top bar — so the login card's own `BrandMark` is the only mark on screen |
|
||||
| `FocusedLayout` | Framed but chrome-free, for flows a user must not tab away from mid-setup: onboarding, `/select-role`. A slim logo strip and content, no bottom nav. The route group above it still applies `RoleGuard` |
|
||||
| `PrivateLayout` | An authenticated passthrough wrapper; actor chrome lives in the shells above |
|
||||
|
||||
### Navigation is the bottom bar. There is no drawer.
|
||||
|
||||
- Tabs are `LinkToPage` arrays built with `useTranslations('nav')`, **3–5 of them**, and by convention the
|
||||
last is a settings/«بیشتر» hub.
|
||||
- Active state comes from the shared **`matchActivePath`** (longest-prefix, winner-takes-all) run over each
|
||||
tab's own path **plus its `matchPaths` claims**. Use `matchPaths` when a tab owns a destination outside
|
||||
its own URL subtree — `/nurse/finance` owning `/nurse/earnings`. **Never hand-roll
|
||||
`pathname.startsWith`**, which lights up a sibling tab as often as the right one.
|
||||
- `BottomBar` **floats**: inset from the frame edges, fully rounded (`--bal-radius-pill`), elevated — not an
|
||||
edge-to-edge slab sealing off the bottom of a 480px screen. It is **icon-only**: at five tabs the caption
|
||||
was the widest thing in the bar and cost a whole line, so the label survives as `aria-label`/`title`. Each
|
||||
tab is a fixed 44px circle that is simultaneously the target, the hover/press tint and the active fill,
|
||||
laid out `space-around` so the target keeps one size at any tab count.
|
||||
- `TopBar` is **not** an `AppBar` — no filled surface, no rule, no elevation of its own. `AppFrame` wraps it
|
||||
in `FLOATING_BAR_SX`, so it is the bottom bar mirrored. It shows a brand lockup on a tab's own path and a
|
||||
back chevron + `useRouteTitle()` on anything deeper.
|
||||
|
||||
### Group roots are real pages
|
||||
|
||||
A nav group's root is a page, not a drawer section: a short summary of that domain — read **only off
|
||||
queries that already answer it, never a fabricated figure** — over a `NavHubList` of its destinations.
|
||||
`/nurse/practice`, `/nurse/finance`, `/admin/trust`, `/admin/system` are the references. A count that is
|
||||
still in flight is **omitted, never faked**.
|
||||
|
||||
### Chrome carries no preferences, and no identity
|
||||
|
||||
Language and appearance live in `SettingsPanel` (`@/components/settings`), mounted in each actor's settings
|
||||
hub **and nowhere else**. Identity lives in each actor's «بیشتر»/account hub, one tap away on the nav. The
|
||||
top bar is for the page title and at most a notification bell.
|
||||
|
||||
`/admin/system` is always present in the admin nav even when every console inside it is denied, because it
|
||||
is the only route out of the app (settings + sign-out).
|
||||
|
||||
### Navigation goes through `@/i18n/navigation`
|
||||
|
||||
**All chrome navigation** uses `Link` / `usePathname` / `useRouter` from `@/i18n/navigation`
|
||||
(`createNavigation(routing)`). `usePathname` is locale-stripped, so unprefixed `ROUTES.*` compare directly,
|
||||
and `Link`/`router` add the locale automatically — **no manual `` `/${locale}` `` prefixing, and no
|
||||
middleware redirect hop.** Never a raw `next/link` for chrome.
|
||||
|
||||
Inside a *page*, `AppLink`/`AppButton`'s `to` is a plain `next/link` and still needs the prefix.
|
||||
|
||||
Prefer MUI breakpoints in `sx` for the little responsive branching that remains, over `useIsMobile()`
|
||||
(`@/hooks`) — the hook is JS/post-hydration and caused a real SSR flash. Reach for it only for genuinely
|
||||
non-structural, JS-only behaviour.
|
||||
|
||||
---
|
||||
|
||||
## 3. Reach for these before raw MUI
|
||||
|
||||
Shared primitives live in `src/components/`, barrel `@/components`. Prefer the `App*` wrapper over the bare
|
||||
MUI component — the wrappers carry the house defaults.
|
||||
|
||||
| Component | Use for | Notes |
|
||||
| --- | --- | --- |
|
||||
| `AppButton` | all buttons and button-links | default `variant="contained"`; pass `to`/`href` to render as a link; `startIcon`/`endIcon` accept an **icon name string** or a node |
|
||||
| `AppIconButton` | icon-only actions | takes an icon name, `title`, `to`/`onClick` |
|
||||
| `AppIcon` | any icon | `icon="home"` by registered name (§4); `size`, `color` |
|
||||
| `AppLink` | internal/external links | locale-aware; default underline `hover` |
|
||||
| `AppAlert` | inline alerts | defaults to a calm `severity="info"`, `variant="standard"` — a genuinely error-severity call site passes `severity="error"` explicitly |
|
||||
| `AppLoading` | loading state | circular, `primary`, `3rem` |
|
||||
|
||||
Defaults live in `src/components/config.ts` — `APP_BUTTON_VARIANT`, `APP_ICON_SIZE` (24),
|
||||
`APP_ICON_STROKE_WIDTH` (1.75), `APP_BUTTON_ICON_SIZE` (20), `CONTENT_MAX_WIDTH` (**480**),
|
||||
`CONTENT_MIN_WIDTH` (320), the alert/link defaults. **Change a default there, not per call site.**
|
||||
|
||||
**Concrete MUI primitives stay MUI.** Use `Button`, `Avatar`, `Paper`, `TextField`, `Box`, `Stack`,
|
||||
`Container`, `Grid`, `Card` directly (or the existing `App*` wrappers) — never invent a new root-level
|
||||
Button or Avatar. Use the `spacing`/`sx` system (theme unit = 8px); never inline pixel margins for rhythm.
|
||||
|
||||
**Composite, shareable components** built from primitives and reused in more than one place belong at the
|
||||
right *shared* level (`src/components/…`), not inline in a page and not buried in a leaf. Page-only,
|
||||
never-reused composition can stay in the page.
|
||||
|
||||
### The state kit — one pattern per state, and they are not optional
|
||||
|
||||
| Primitive | The one pattern for |
|
||||
| --- | --- |
|
||||
| `EmptyState` | "nothing here" — icon + title + body + action. Replaces every hand-rolled dashed-border `Paper` |
|
||||
| `ErrorState` | "this query failed" — `message` + a **required** `retryLabel` + `onRetry` |
|
||||
| `QueryStateGate` | A query's branching, in the fixed **skeleton → error → empty → children** order. Also requires `retryLabel` |
|
||||
| `PageHeader` | title + subtitle + `actions` (buttons) + `meta` (a chip row) + a back affordance (`backTo`, or `onBack` which takes precedence and pairs with `useAdminBackToList` for `router.back()`-with-fallback) |
|
||||
| `ConfirmDialog` | Any destructive confirm. Required-reason gating, busy-disable, and `requireTypedConfirmation` (confirm stays disabled until the typed value matches) — the guard for an irreversible money-moving action, e.g. the admin payout run |
|
||||
| `SurfaceCard` | A flat `Paper` wrapper; `padding: 'sm' \| 'md' \| 'lg'` |
|
||||
| `AccentCard` | `SurfaceCard` + a semantic `tone` for a **stateful** panel |
|
||||
| `Money` | The one money-rendering primitive (`amountIrr`, `size` incl. `xl`, `tone`, `deduction`, `hideUnit`, `strikethrough`) |
|
||||
| `StatusTimeline` | An ordered `TimelineNode[]` (completed/current/pending/failed) with an animated pulse on `current` |
|
||||
| `JalaliDatePicker` / `JalaliDateField` / `JalaliDateIntentPicker` | Any Persian-calendar date input. Never a native `type="date"` |
|
||||
| `StickyActionBar` | A scrolling screen's primary CTA, offset off `--bal-chrome-bottom` |
|
||||
| `Pager` | The shared prev/next "page X of Y" control. Never a per-screen inline pager |
|
||||
| `NavHubList` | The grouped destination list a group-root page is built from |
|
||||
| `InitialsAvatar` | A person with no photo — deterministic name hash → one of six `--bal-avatar-*` pairs, `aria-hidden` beside a visible name |
|
||||
| `FormDialogShell` | A form dialog: full-screen below `sm`, with a dirty-gated discard confirm |
|
||||
| `RouteFadeIn` | Route-content motion. Already mounted in all five shells |
|
||||
|
||||
**An error state is never an empty state.** A failed query renders `ErrorState`; a successful query with
|
||||
no rows renders `EmptyState`. Collapsing the two hides outages.
|
||||
|
||||
Two `AccentCard` details worth knowing: its colored **edge stripe is gone** — a column of striped cards read
|
||||
as a row of loose vertical rules down the RTL side of the screen. `tone` survives as the semantic label
|
||||
(reaching the DOM as `data-accent-tone`), and state is carried by the `StatusChip`, icon and copy inside the
|
||||
card. **Do not reintroduce the stripe.**
|
||||
|
||||
### Presentational purity in `components/common`
|
||||
|
||||
`next-intl` (and its `use-intl` dependency) ship ESM-only builds. `jest.config.ts` widens `next/jest`'s
|
||||
`transformIgnorePatterns` to let them through, but that only fixes real imports — it doesn't make the
|
||||
dependency free. **Any component at the top of the `@/components/common` barrel that imports `next-intl` at
|
||||
module scope forces every test file that transitively imports the barrel to deal with it**, including tests
|
||||
that never touch translations.
|
||||
|
||||
So `ErrorBoundary` and `ErrorState` are deliberately **caller-owned**: they take `title`/`body`/`retryLabel`/
|
||||
`message` as required string props instead of calling `useTranslations` internally, specifically to stay
|
||||
import-safe at the top of the barrel. `QueryStateGate` inherits the same `retryLabel` requirement by
|
||||
composition. `Money` is the sanctioned exception — it already had 30+ call sites depending on its
|
||||
locale-aware API before this was noticed, so the fix went the other way.
|
||||
|
||||
When adding a new `common` primitive: **prefer the caller-owned-copy pattern by default**, and reach for
|
||||
`useTranslations` inside it only if the component is genuinely leaf-level. Keep next-intl-importing
|
||||
primitives *below* the presentational ones in the barrel so the poisoning risk stays visible in review.
|
||||
|
||||
### New shared component
|
||||
|
||||
`src/components/<Name>/<Name>.tsx` + an `index.tsx` barrel + a **co-located `<Name>.test.tsx`** (mandatory
|
||||
for anything imported in more than one place — see [testing.md](testing.md)). Follow the `App*`
|
||||
prop-spreading and JSDoc style of `AppButton.tsx`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Icons are a name registry
|
||||
|
||||
`src/components/common/AppIcon/config.ts` maps **lowercase** names → components. Render with
|
||||
`<AppIcon icon="home" />`, or pass the name to `AppButton`/`AppIconButton` (`startIcon="search"`).
|
||||
|
||||
**One visual family: Lucide.** Every registered icon comes from `lucide-react` — a contemporary outline
|
||||
family on a 24px grid with round caps and joins, which reads far lighter than filled glyphs at the small
|
||||
sizes a phone-width app actually uses. **`@mui/icons-material` is no longer a dependency; never reintroduce
|
||||
it.** The house stroke weight is `APP_ICON_STROKE_WIDTH` (1.75 — Lucide ships at 2, which competes with
|
||||
Mikhak's lighter Persian strokes).
|
||||
|
||||
**The mapping is semantic, not incidental.** A name describes the domain concept ("verification",
|
||||
"earnings", "coverage") and the glyph depicts *that*, so swapping the underlying glyph never leaks into call
|
||||
sites. Related concepts share a visual root on purpose: trust names are shields, money names are coins or
|
||||
cards, clinical names are a pulse or a cross. Around 110 names are registered — read `AppIcon/config.ts`
|
||||
rather than duplicating the list.
|
||||
|
||||
- **`size` drives real `width`/`height`** (Lucide sizes off SVG attributes), so `size={48}` is 48px with no
|
||||
`fontSize`/`1em` indirection. Icons default to `flexShrink: 0` — an icon squashed by a flex sibling was
|
||||
the one layout bug this component kept quietly reintroducing on narrow rows.
|
||||
- **Directional icons mirror automatically.** Names authored for LTR that must flip under RTL are listed in
|
||||
`DIRECTIONAL_ICONS` (`back`, `chevron_start`, `chevron_end`, `forward`, `send`). `AppIcon` stamps
|
||||
`data-icon-directional`, and one CSS rule in `globals.css` does
|
||||
`[dir='rtl'] [data-icon-directional] { transform: scaleX(-1); }`. Adding a directional icon is a one-line
|
||||
registry addition — **never hand-roll a per-component flip.**
|
||||
- **A new icon** is an import from `lucide-react` into `config.ts` plus a lowercase `ICONS` key. Custom SVGs
|
||||
(the brand mark) go in `AppIcon/icons/` and must accept the same `size`/`color`/`strokeWidth` contract
|
||||
(`AppIcon/utils.ts`'s `IconProps`). An unregistered name logs a dev-only warning and falls back to
|
||||
`default`. **Never pass a raw icon component where a name is expected.**
|
||||
|
||||
---
|
||||
|
||||
## 5. Constants, not magic values
|
||||
|
||||
Every magic string or configurable value is a named constant. A value is "magic" if its meaning isn't
|
||||
obvious from the literal alone: cookie names, event names, route paths, query-param names, numeric
|
||||
timeouts, API slugs, repeated dimensions.
|
||||
|
||||
| Kind | Home |
|
||||
| --- | --- |
|
||||
| Cookie names and options | `src/lib/cookies/constants.ts` |
|
||||
| Feature-scope | a `constants.ts` co-located with that feature |
|
||||
| App-wide | `src/constants/<concern>.ts` — `routes.ts`, `roles.ts`, `headers.ts`, `policy.ts` |
|
||||
| Shell dimensions | `src/layout/config.ts` |
|
||||
| Component defaults | `src/components/config.ts` |
|
||||
|
||||
`constants/policy.ts` is the pattern applied to legally-sensitive numbers that trust-critical copy states
|
||||
in plain language — the payout dispute-window hours, the cancellation lead-time hours, the refund ETA day
|
||||
range. They are real server config with no public read yet, single-sourced here and fed into message keys
|
||||
as ICU params rather than baked into a string. See [i18n.md](i18n.md).
|
||||
|
||||
Import the constant; **never copy-paste the literal.** When renaming, change the definition and the rest
|
||||
follows.
|
||||
|
||||
---
|
||||
|
||||
## 6. Toasts
|
||||
|
||||
| From | Use |
|
||||
| --- | --- |
|
||||
| A component or hook | `useSnackbar()` → `enqueueSnackbar('…', { variant: 'success' })` |
|
||||
| Outside React (a plain function, the fetch layer) | `dispatchToast('…', 'error')` from `@/lib/toast` — it fires an `app:toast` window CustomEvent that `ToastBridge` picks up |
|
||||
|
||||
`ToastBridge` is already rendered in the root layout. **Do not add another instance.**
|
||||
|
||||
**Every mutation whose failure isn't already surfaced inline or by the fetch layer needs an `onError`
|
||||
toast.** A mutation that only handles `onSuccess` is a defect. But don't toast 401/403/5xx in a hook —
|
||||
`clientFetch` already does. See [services.md](services.md).
|
||||
@@ -0,0 +1,116 @@
|
||||
# Client forms
|
||||
|
||||
Every form with more than one field is a react-hook-form form. This is how you build one.
|
||||
|
||||
> Last verified: 2026-07-30 against commit `d3ec723`.
|
||||
|
||||
---
|
||||
|
||||
## 1. The rule, and why it exists
|
||||
|
||||
**Any form with more than one field uses react-hook-form.** A single-field control — a search box, a filter
|
||||
select, a message composer — does not: that is state, not a form.
|
||||
|
||||
This is not a style preference. The pattern it replaced was one `useState` per input **plus** a parallel
|
||||
`useState` per error flag. That meant every keystroke re-rendered the whole screen — including the
|
||||
query-backed cards, price previews and uploaders sitting beside the field — and left "is this form valid?"
|
||||
spread across ad-hoc `if` blocks at the top of each submit handler.
|
||||
|
||||
react-hook-form gives uncontrolled fields plus per-field subscriptions, so a keystroke re-renders one
|
||||
input.
|
||||
|
||||
28 files currently import it. Any multi-field form that still holds its state in `useState` is a defect to
|
||||
be migrated when that screen is next substantially touched.
|
||||
|
||||
---
|
||||
|
||||
## 2. How to build one
|
||||
|
||||
1. **`useForm<Values>({ mode: 'onTouched', defaultValues })`.** `onTouched` is the house default: an error
|
||||
appears once a field has been visited, never while it is first being typed into.
|
||||
|
||||
2. **Wrap the subtree in `<FormProvider {...form}>` and bind fields with the `@/components/common/form`
|
||||
wrappers.** They read `control` off the provider, so it is threaded once.
|
||||
**Never call `register` or `useController` at a call site.**
|
||||
|
||||
3. **Put the rule on the field it governs** — `rules={{ validate: … }}` — returning the **translated**
|
||||
message. Cross-field rules read `validate`'s second argument (all values); that is how the C4 request
|
||||
form's past-date guard reads the chosen start time.
|
||||
|
||||
4. **Render `<Stack component="form" noValidate onSubmit={handleSubmit(submit)}>`** and make the primary
|
||||
button `type="submit"`. Enter-to-submit then works for free.
|
||||
|
||||
5. **Async defaults come from a mounted-when-ready child, not an effect.** When the initial values depend on
|
||||
a query — the verification credentials read-back, the C4 variant/address defaults — keep the loading
|
||||
branch in the *parent* and mount the form component only once the data has resolved, so `defaultValues`
|
||||
**is** the server state instead of being copied into it later.
|
||||
|
||||
Point 5 is the one that gets skipped and then costs an afternoon: seeding a form from an effect means the
|
||||
form has two sources of truth for a moment, and a user who types during that moment loses the keystroke.
|
||||
|
||||
---
|
||||
|
||||
## 3. The wrappers
|
||||
|
||||
| Wrapper | For |
|
||||
| --- | --- |
|
||||
| `RhfTextField` | any `TextField`, including `select`. `transform` normalizes keystrokes **into form state** (digit-stripping, max length) so the *stored* value is canonical, not just the displayed one. A rule message replaces `helperText` |
|
||||
| `RhfChipSelect` | a chip group over stable codes — `string[]` (multi) or `string \| null` (single). `allowCustomValues` keeps a stored code that isn't in the option list visible |
|
||||
| `RhfJalaliDateField` | a Jalali date field; stores the wire ISO (Gregorian) string, or `null` |
|
||||
| `RhfControlGroup` | **any** non-input control — `GenderToggle`, `RatingInput`, `CascadingRegionSelect`, the map-pin picker, a `Switch`, a `Checkbox`. Gives it the same label/hint/error shell the text fields get |
|
||||
|
||||
Every wrapper falls back to the enclosing `FormProvider`'s `control`, so a form wires it once. All four are
|
||||
tested.
|
||||
|
||||
### Two conventions worth knowing
|
||||
|
||||
- **A control that renders its own error text gets a message-less rule** — `validate: (v) => cond`, no
|
||||
string. `RhfControlGroup` then flags the field without printing a second identical line. `AddressForm`'s
|
||||
region and pin fields are the reference.
|
||||
|
||||
- **When the displayed value isn't the stored value, drop to a bare `Controller`.** Exactly two cases exist
|
||||
and both are commented at the call site: the variant builder's display-name (stored = the override only;
|
||||
blank means the server names it — shown = the live auto-generated name) and the admin refund channel
|
||||
(stored = `""` until explicitly overridden; shown = the server's resolved channel).
|
||||
|
||||
---
|
||||
|
||||
## 4. Structure: `FormSection`
|
||||
|
||||
A long form is grouped into `FormSection`s — a heading, a one-line statement of *why* the group is being
|
||||
asked for, and an optional/status marker.
|
||||
|
||||
The point is that **an optional group reads as skippable and a blocked submit has somewhere to attribute
|
||||
itself.** A flat run of ten `TextField`s makes everything look equally mandatory, which is how a nurse ends
|
||||
up abandoning a verification form over a field that was never required.
|
||||
|
||||
Applies to the nurse profile (معرفی / تجربه و تحصیلات / تخصصها), the verification identity and credentials
|
||||
screens, and the variant builder.
|
||||
|
||||
---
|
||||
|
||||
## 5. Making a gate honest
|
||||
|
||||
Five habits that came out of the verification and variant-builder rebuilds. They are what separates a form
|
||||
that *validates* from a form a user can actually finish.
|
||||
|
||||
- **The submit gate is a real form field, not a caption near the bottom.** Verification B4's three asks
|
||||
(national id / card photo / selfie) each became a `FormSection` with the card marked optional and the
|
||||
selfie marked required — so the requirement is attached to the thing that satisfies it.
|
||||
- **Disable Next with the unanswered required groups *named* under it.** Not "always enabled, error after
|
||||
the tap".
|
||||
- **Derive a wizard's step list from the loaded data.** A category with no option groups skips straight to
|
||||
pricing rather than showing an empty middle step.
|
||||
- **Recap the chosen values on the final step**, so the last step doubles as a review.
|
||||
- **Never dead-end a returning user on a disabled button with no explanation.** If server-side state (an
|
||||
already-uploaded document, an already-submitted registry number) satisfies part of the gate, the gate must
|
||||
consider it — and a value the server won't read back by design should lock into a "recorded" row rather
|
||||
than re-prompting for it blank.
|
||||
|
||||
## 6. Unsaved work
|
||||
|
||||
- A form hosted in `FormDialogShell` reports `dirty` via an **`onDirtyChange`** prop, which drives the
|
||||
shell's discard-confirm on close, backdrop and escape.
|
||||
- A **staged-but-unsaved upload** gets a `beforeunload` guard — the nurse-profile avatar is the reference.
|
||||
- A destructive confirm goes through `ConfirmDialog`, whose destructive and dismiss labels must not be
|
||||
swapped. (They were, once, on the cancel-request dialog; check yours reads correctly out loud.)
|
||||
@@ -0,0 +1,215 @@
|
||||
# Client i18n and Persian copy
|
||||
|
||||
next-intl v4 mechanics, the namespace map, and the binding Persian style guide — the last of which is
|
||||
enforced by `npm run lint:copy`.
|
||||
|
||||
> Last verified: 2026-07-30 against commit `d3ec723`.
|
||||
|
||||
---
|
||||
|
||||
## 1. The rule
|
||||
|
||||
**No hard-coded user-facing strings.** Every user-visible string — label, placeholder, `aria-label`,
|
||||
button text, error message — is a key in **both** `messages/en.json` and `messages/fa.json`, and the two
|
||||
files stay in sync.
|
||||
|
||||
The one sanctioned exception is `app/global-error.tsx`, which replaces the root layout on a root-level crash
|
||||
and therefore renders its own `<html>` and cannot use next-intl. Keep it minimal and bilingual.
|
||||
|
||||
Locales: **`fa` (default, RTL)** and `en`. `/en` is explicitly accessed; a bare `/` normalizes to the
|
||||
default locale.
|
||||
|
||||
| Context | API |
|
||||
| --- | --- |
|
||||
| Client component | `const t = useTranslations('nav'); t('home')` |
|
||||
| Server component | `const t = await getTranslations('nav'); t('home')` |
|
||||
| Structured (array/object) values | `t.raw('terms_sections')` |
|
||||
| Rich text with tags | `t.rich('consent_line', { terms: …, privacy: … })` |
|
||||
|
||||
Top-level keys are namespaces. Adding a translation means adding the key to both files — never one.
|
||||
|
||||
---
|
||||
|
||||
## 2. The namespace map
|
||||
|
||||
MVP namespaces are complete. Add a key to an existing namespace where it fits; seed a new namespace only
|
||||
with a genuinely new surface, and seed it in both files at once.
|
||||
|
||||
| Namespace | Owns |
|
||||
| --- | --- |
|
||||
| `common` | Shared words — loading, retry, `currency_toman`, the brand wordmark |
|
||||
| `nav` | The actor shells' tab labels — every shell builds its nav from here |
|
||||
| `shell` | Actor-shell titles |
|
||||
| `auth` | Phone-OTP login, the role router, `RoleGuard` states, select-role, the login-hero trust bullets, the consent line |
|
||||
| `legal` | `/terms` and `/privacy`. **The one namespace with structured JSON values** — `terms_sections`/`privacy_sections` are arrays of `{title, body}` read via `t.raw` |
|
||||
| `welcome` | The public landing. Its `category_*` labels are **marketing copy, deliberately distinct from the live `catalog` category names** |
|
||||
| `onboarding` | The A3→A4 wizard, plus the shared enum labels (relation / condition / gender codes → labels) |
|
||||
| `home` | The family home — greeting, search entry, category grid, nudges |
|
||||
| `profile` | Customer profile and emergency contact |
|
||||
| `patients` | The care-circle list and CRUD |
|
||||
| `records` | The care-record viewer and the nurse visit-note panel. Reuses `onboarding`/`patients` enum labels — never re-keyed |
|
||||
| `geo` · `address` · `coverage` | The cascading region select · the customer address book · the nurse coverage editor |
|
||||
| `catalog` | **Shared** catalog vocabulary — the five `price_unit` labels, count nouns, the estimated-total label. Read by `PriceDisplay` on both sides |
|
||||
| `services` | The nurse services surface and the variant builder |
|
||||
| `nurseProfile` | The nurse profile bootstrap and the public-profile preview |
|
||||
| `activation` | The shared `ActivationChecklist` rows and its collapsed live state |
|
||||
| `bank` | Nurse payout bank settings and the three ownership states |
|
||||
| `verification` | The nurse trust flow — per-step and per-status labels keyed off the code, the honesty-sensitive manual-vs-auto copy, the journey group labels |
|
||||
| `search` | Discovery C1/C2/C3 — filters, the same-gender facet, all four result states, card and profile labels |
|
||||
| `booking` | The booking-request flow **and** post-payment engagement — `bstatus_*`, `sstatus_*`, EVV banners, care-instruction labels, `money_*`, the bookings list |
|
||||
| `payment` | Checkout and invoice — the breakdown rows, the **verbatim escrow copy** (`escrow_notice`), the card-flow states, the confirmation and invoice screens, `pstatus_*`, مودیان states |
|
||||
| `refunds` | Cancellation and refund status — policy tiers keyed off `cancellation_policy_code`, the refund-vs-fee breakdown, `step_*`/`rstatus_*`, per-channel ETA copy |
|
||||
| `bnpl` | Installment checkout D1–D5 — the ownership-truth copy, provider names keyed off `provider_{code}`, the plan/eligibility/schedule labels, the wallet due list |
|
||||
| `payouts` | Nurse earnings and payout history — the balance header incl. the negative "owed back" state, the four buckets, `estate_*`/`pstatus_*`/`bstatus_*`, the cadence explainer |
|
||||
| `reviews` | The review form, tag labels keyed off the code, moderation-status labels, the aggregate count |
|
||||
| `tickets` | The messaging surface — inbox, thread, composer, author-role labels, and both emergency surfaces |
|
||||
| `notifications` | The notification center and bell. Row `title`/`body` are **server-rendered copy, not keys** |
|
||||
| `admin` | Every backoffice console, the Persian legal terms (پروانه تأسیس / مسئول فنی / نماد اعتماد الکترونیکی), and the enum-label prefixes |
|
||||
| `partner` | The partner-centre portal — a separate authz scope |
|
||||
|
||||
### Enum labels
|
||||
|
||||
**A label is keyed off the stable code, never derived from the wire value.** `status_pending_moderation`,
|
||||
`pstatus_failed`, `tag_punctual` — the code is the key suffix, and the vocabulary of codes is a client
|
||||
constant, not something read off a response. Shared enum labels are **reused** across namespaces, never
|
||||
re-keyed.
|
||||
|
||||
This is what lets the server rename a display string without a client deploy, and lets the client show a
|
||||
Persian label for a code it has never seen without falling back to raw English.
|
||||
|
||||
---
|
||||
|
||||
## 3. Numbers and interpolation
|
||||
|
||||
Persian digits (۰۱۲۳۴۵۶۷۸۹) everywhere on `/fa` — both hard-coded literals (`"۲۴ ساعت"`) and interpolated
|
||||
numbers.
|
||||
|
||||
| Case | Do |
|
||||
| --- | --- |
|
||||
| A number inside an ICU message | Use the `number` sub-format — `{count, number}` — or a plain `#` inside a `plural` block. next-intl formats both through the active locale, so `fa` gets Persian digits automatically |
|
||||
| A raw number built into a string in code | Route it through `formatNumber` (`@/utils`). **Never template a raw JS number into Persian text** |
|
||||
| A date | `formatShamsiDate` / `formatShamsiDateTime` (`@/utils`) — UTC ISO in, Persian calendar out |
|
||||
| Money | `<Money>` or the `@/utils` money helpers. Never a float, never a raw digit run |
|
||||
|
||||
### Policy numbers are never hard-coded into a string
|
||||
|
||||
Legally or financially sensitive numbers that the admin config panel can change — the dispute-window hours,
|
||||
the cancellation lead-time hours, the refund ETA day range — **never** go into a message string. The message
|
||||
key takes a parameter (`{hours}`, `{minDays}`/`{maxDays}`) and the call site interpolates from
|
||||
`src/constants/policy.ts`, which single-sources them.
|
||||
|
||||
A config edit must never again silently make the UI copy lie. (These are real server config with no public
|
||||
read yet; the constants file is the interim single source.)
|
||||
|
||||
---
|
||||
|
||||
## 4. Persian style — binding for `fa.json`
|
||||
|
||||
`npm run lint:copy` (`client/scripts/check-copy.mjs`, part of `npm run check`) greps every leaf string in
|
||||
`fa.json` for the banned variants marked **linted** below, on every run. A regression fails the gate
|
||||
immediately rather than needing to be re-discovered by a human.
|
||||
|
||||
`en.json` is hand-written and reviewed for idiom, not linted.
|
||||
|
||||
### 4.1 Brand name — **linted**
|
||||
|
||||
**«بالینیار» — ZWNJ (``) between بالین and یار, always.** Never a plain space («بالین یار»). The brand name
|
||||
appears in money and trust copy — login, escrow, refunds — as often as anywhere else, and that is the worst
|
||||
place to be inconsistent.
|
||||
|
||||
### 4.2 تأیید — hamza, always — **linted**
|
||||
|
||||
Write **تأیید** and its derived forms — **تأییدشده**, **تأییدیه**, **تأیید کردن** — every time. Never
|
||||
تایید/تاییدشده/تاییدیه. This is the single most frequent word in a verification product: one spelling, no
|
||||
exceptions, in every namespace.
|
||||
|
||||
### 4.3 جستجو — one form — **linted**
|
||||
|
||||
Standard form: **جستجو** (no ZWNJ, one word). Not «جستوجو», not «جست و جو». Applies to the noun and any
|
||||
compound (`در جستجو`, `نتایج جستجو`).
|
||||
|
||||
### 4.4 ZWNJ (نیمفاصله)
|
||||
|
||||
Use ZWNJ — never a plain space, never nothing — in:
|
||||
|
||||
- **می + verb stem** — میشود، میکند، میپردازید، میماند. Never میشود or می شود.
|
||||
- **Plural ها** — keep the ZWNJ before ها when the base ends in a consonant that would otherwise misread
|
||||
(`شبها`, not `شبها`); a word already ending in a vowel or silent-h takes it too (`بچهها`).
|
||||
- **Compound past-participle adjectives** — تأییدشده، لغوشده، ردشده، منتشرشده، پرداختشده. One ZWNJ-joined
|
||||
word: not two spaced words («تایید شده»), not fused with no separator.
|
||||
- The brand name (§4.1).
|
||||
|
||||
### 4.5 Two other linted rules
|
||||
|
||||
- **The archaic passive میگردد is banned** — use میشود. (The check anchors on a leading space so the
|
||||
entirely legitimate «برمیگردد», which fuses «بر» directly on, is never flagged.)
|
||||
- **«بازی » is banned** — it catches an indefinite «ی» misattached to the wrong word.
|
||||
|
||||
### 4.6 Punctuation and quotes
|
||||
|
||||
- Persian prose uses **«…» guillemets** for quoted terms and labels. Prefer Persian «،» / «؛» inside new
|
||||
multi-clause translated sentences; most existing short labels use plain Latin `,`/`;` — **don't retrofit
|
||||
those**, just don't add more.
|
||||
- English uses **straight** apostrophes (`don't`, `couldn't`) throughout — never curly (`’`). Don't
|
||||
reintroduce curly quotes when editing English copy.
|
||||
|
||||
### 4.7 Domain glossary
|
||||
|
||||
| Term | Means | Never |
|
||||
| --- | --- | --- |
|
||||
| **بیمار** | the care recipient | «مددجو» — it appeared once and was dropped for the 99%-majority form |
|
||||
| **پرستار** | the caregiver | «مراقب» as a noun for the person. «مراقب» survives only as an adjective/role qualifier — "جنسیت مراقب" = the caregiver's gender |
|
||||
| **رزرو** | a confirmed, **paid** booking | calling a `booking_request` «رزرو» before it converts |
|
||||
| **درخواست رزرو** | a pre-payment request | conflating it with رزرو |
|
||||
| **ویزیت** | one scheduled visit/session within a booking | — |
|
||||
| **شبا** | IBAN | «شماره شبا» for the field label, «شبا» alone elsewhere |
|
||||
|
||||
The رزرو / درخواست رزرو split mirrors the code's `bookings` vs `bookingRequests` and the server's
|
||||
`Bookings` vs `Booking` areas. It is a money boundary, not a synonym.
|
||||
|
||||
### 4.8 Shell naming — one metaphor per audience class
|
||||
|
||||
- **End-user shells** (family, nurse — the apps people book or work through day to day) → **«اپلیکیشن»**:
|
||||
«اپلیکیشن خانواده», «اپلیکیشن پرستار».
|
||||
- **Back-office shells** (admin, partner-centre) → **«کنسول»**: «کنسول مدیریت», «کنسول همکار».
|
||||
- Never «نما» (view) or «پرتال» (portal) for a whole shell name.
|
||||
|
||||
(`booking.evv_nurse_view` "نمای پرستار" is a different thing — a chip labelling *whose perspective* a shared
|
||||
screen is rendered from, not a shell name. It correctly keeps «نما» in that narrower sense.)
|
||||
|
||||
### 4.9 Verification pipeline vs. the identity step
|
||||
|
||||
**«تأیید صلاحیت»** names the whole 7-step nurse trust pipeline — the nav entry, the hub title, its
|
||||
start/progress/approved states, the admin queue. **«احراز هویت»** stays the name of the *one* KYC step inside
|
||||
it (national ID + civil registry + liveness selfie), on both the nurse side
|
||||
(`verification.step_identity_kyc`) and the admin side (`admin.step_identity_kyc`).
|
||||
|
||||
A nurse who passed the KYC step but still saw a pipeline titled «احراز هویت» marked incomplete in the nav
|
||||
read that as a contradiction. They no longer share a name — keep it that way.
|
||||
|
||||
### 4.10 Status vocabulary — one nurse-facing form, one admin-facing form
|
||||
|
||||
For "this step/item was rejected" states that appear on **both** a nurse-facing and an admin-facing screen
|
||||
for the *same underlying concept* (a verification step's outcome):
|
||||
|
||||
| Audience | Form | Matches its siblings |
|
||||
| --- | --- | --- |
|
||||
| Nurse-facing | **«رد شد»** (`verification.status_failed`) | the declarative sentence register of `status_passed` («تأییدشده») / `status_in_review` («در حال بررسی») |
|
||||
| Admin-facing | **«ردشده»** (`admin.step_failed`, `agg_rejected`, `rstatus_rejected`, `mstatus_rejected`) | the admin namespace's compound-adjective pattern — `step_passed`/`agg_approved`/`center_state_verified` |
|
||||
|
||||
This does **not** extend to money-failure vocabulary. `payouts.pstatus_failed`, `refunds.rstatus_failed` and
|
||||
`admin.batch_status_failed` all legitimately use «ناموفق»: a transfer *failing* is a different concept from a
|
||||
document being *rejected*, and conflating them would blur a real distinction.
|
||||
|
||||
### 4.11 Register
|
||||
|
||||
Formal شما throughout, with polite imperatives (کنید) for actions and instructions. Never informal تو or bare
|
||||
imperative stems (نکن, برو).
|
||||
|
||||
---
|
||||
|
||||
## 5. Reference data with two names
|
||||
|
||||
Server reference data that carries `name_fa`/`name_en` returns **both**, and the client picks by locale.
|
||||
Don't ask the server for a locale-specific name, and don't translate a data row into a message key —
|
||||
categories, provinces and cities are rows an admin can add, not vocabulary.
|
||||
@@ -0,0 +1,221 @@
|
||||
# Client services and data
|
||||
|
||||
The fetch layer, the `services/{domain}` pattern, caching, and the money rules that make the UI honest.
|
||||
|
||||
> Last verified: 2026-07-30 against commit `d3ec723`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Fetch only through the two primitives
|
||||
|
||||
| File | Use from | Behaviour |
|
||||
| --- | --- | --- |
|
||||
| `lib/api/client.ts` | hooks, client components | `clientFetch<T>` — throws `ApiError` on error; silent-refreshes and retries once on 401 |
|
||||
| `lib/api/server.ts` | RSCs, Server Actions | `serverFetch<T>` — throws `ApiError` on error |
|
||||
| `lib/api/errors.ts` | anywhere | the `ApiError` class (`status`, `message`, `code`) |
|
||||
| `lib/api/types.ts` | anywhere | `ApiEnvelope<T>` + `unwrap()`, `Paginated<T>`, `PageParams` |
|
||||
| `lib/api/refresh.ts` | internal | `attemptTokenRefresh` — the single-flight refresh `clientFetch`'s 401 branch uses |
|
||||
|
||||
**Never call `fetch()` directly** in a component, hook, or service. Domain calls live in
|
||||
`src/services/{domain}/apis/`.
|
||||
|
||||
### The `clientFetch` error contract
|
||||
|
||||
| Status | What happens |
|
||||
| --- | --- |
|
||||
| **401** | Toast "session expired", clear cookies, redirect to login. **No throw** — the page navigates away |
|
||||
| **403** | Toast "forbidden", throw `ApiError` |
|
||||
| **5xx** | Toast "server error", throw `ApiError` |
|
||||
| Other **4xx** | Throw `ApiError`, **no toast** — the calling hook owns the user-facing message |
|
||||
| Network failure | Toast "network error", throw `ApiError` |
|
||||
|
||||
So: **never toast 401/403/5xx inside a hook.** Only a domain-specific 4xx earns an `onError` toast. A
|
||||
mutation that handles only `onSuccess` is still a defect — see [components.md](components.md) §6.
|
||||
|
||||
`serverFetch` throws on every error and toasts nothing (the server can't fire browser events). The RSC
|
||||
caller decides whether to `notFound()`, `redirect()`, or let it reach an error boundary.
|
||||
|
||||
**Never mix `clientFetch` and `serverFetch` in one file.** Keep `clientApi.ts` and `serverApi.ts` separate;
|
||||
Next enforces the environment boundary at build time.
|
||||
|
||||
### The wire envelope
|
||||
|
||||
The server wraps every response in `ApiEnvelope<T>` — `{ isSuccess, statusCode, message, requestId, data }`,
|
||||
camelCase. `clientFetch` returns the raw body, so a real `clientApi` reads the payload via `unwrap()`.
|
||||
|
||||
Types mirror the wire **exactly** and are derived from the published contract in
|
||||
[`docs/integration/`](../../integration/index.md) — never guessed. If a shape you need doesn't exist, say so
|
||||
and mock behind the seam meanwhile (§3).
|
||||
|
||||
---
|
||||
|
||||
## 2. The `services/{domain}` pattern
|
||||
|
||||
Every one of the 22 domains has the same shape. Copy `auth` or `patients`.
|
||||
|
||||
```
|
||||
services/{domain}/
|
||||
├── types.ts wire types + the domain's `Api` interface — this interface IS the seam
|
||||
├── keys.ts the React Query key factory, hierarchical
|
||||
├── constants.ts the mock toggle + staleTime values (when the domain has a mock)
|
||||
├── apis/
|
||||
│ ├── clientApi.ts real, wraps clientFetch, unwraps the envelope
|
||||
│ ├── mockApi.ts in-memory, same interface
|
||||
│ ├── serverApi.ts serverFetch — only when an RSC needs it
|
||||
│ └── index.ts selects real vs mock by config — the one line hooks import
|
||||
├── hooks/
|
||||
│ └── use{Action}.ts one hook per file — useQuery (deliberate staleTime) or useMutation (invalidates)
|
||||
└── index.ts the barrel: re-exports HOOKS ONLY
|
||||
```
|
||||
|
||||
Two hard boundaries on the barrels:
|
||||
|
||||
- **No top-level `src/services/index.ts`.** An import must name its domain:
|
||||
`import { useLogin } from '@/services/auth'`, never `from '@/services'`.
|
||||
- **A domain barrel exports hooks only** — never `types`, `keys`, or `apis/*`. Reaching past the hooks is
|
||||
how a component ends up depending on a mock's internals.
|
||||
|
||||
### Caching is deliberate, not incidental
|
||||
|
||||
- Set a **`staleTime`** on reads, so revisiting a screen doesn't refetch.
|
||||
- Mutations **invalidate** the affected list key (`queryClient.invalidateQueries`) or `setQueryData` —
|
||||
never leave the cache stale. See `services/patients/hooks/*`.
|
||||
- **Reference data is cached for the whole session.** Rarely-changing lookups use an **Infinite
|
||||
`staleTime`** plus a shared hierarchical key factory, so each level is fetched **once** and served from
|
||||
cache across every consumer — never refetched on a dropdown open. Two domains do this: `geography` (the
|
||||
province→city→district hierarchy, `geographyKeys`) and `catalog` (admin-seeded categories and a category's
|
||||
option groups, `CATALOG_REFERENCE_*`). **Reuse the pattern; do not reinvent per-consumer fetching.**
|
||||
- Contrast with mutable lists — addresses, coverage areas, the nurse's own variant list — which invalidate on
|
||||
every mutation.
|
||||
- **The filter object IS the query key.** `search` canonicalizes its filter set into the key
|
||||
(`canonicalizeSearchFilters`), so identical or reverted filters reuse cache with zero network;
|
||||
`keepPreviousData` avoids flashing. Filters and page belong **in the URL**, which is what makes the cache
|
||||
key shareable and the back button work.
|
||||
- Admin and partner queue pages use **`useAdminListState`** (`@/hooks`) for URL-synced worklist state:
|
||||
draft-vs-applied filters plus page, with `apply`/`applyFilters`/`clear`/`goToPage`. It is
|
||||
`useSearchParams`-based, so a caller needs a `<Suspense>` boundary.
|
||||
- Prefer RSC prefetch or `initialData` where it removes a client round-trip.
|
||||
|
||||
### Re-render cost is part of correctness
|
||||
|
||||
Stable references (`useCallback`/`useMemo` only where it pays), `select` to subscribe to a slice rather than
|
||||
a whole query, state colocated as low as it can go and lifted only when genuinely shared. **Don't put
|
||||
fast-changing state in a high context provider** — a 1-second countdown belongs inside the component that
|
||||
displays it, which is exactly what `CountdownTimer` does.
|
||||
|
||||
---
|
||||
|
||||
## 3. The mock seam
|
||||
|
||||
When a backend endpoint isn't live, implement the domain's `Api` interface **twice** — a real `clientApi.ts`
|
||||
and an in-memory `mockApi.ts` — and select in `apis/index.ts` by a config flag (`USE_{DOMAIN}_MOCK`). Hooks
|
||||
import the selected `api`; **the swap is one line and touches no caller.** Record every mock in
|
||||
`docs/status/` per [code-quality.md](../shared/code-quality.md) §4.
|
||||
|
||||
### Current state — 15 real, 7 mocked
|
||||
|
||||
**Real** (`USE_*_MOCK = false`): `auth`, `geography`, `patients`, `profiles`, `nurse` (bank), `addresses`,
|
||||
`serviceAreas`, `catalog`, `search`, `bookingRequests`, `bookings`, `payment`, `reviews`, `notifications`,
|
||||
`tickets`.
|
||||
|
||||
**Still mocked**, each blocked on a named contract gap:
|
||||
|
||||
| Domain | Blocked on |
|
||||
| --- | --- |
|
||||
| `verification` | the admin verification queue |
|
||||
| `refunds` | the admin refund preview |
|
||||
| `payouts` | the admin payout preview |
|
||||
| `admin` | the RBAC role endpoints |
|
||||
| `bnpl` | provider options / schedule / wallet installments |
|
||||
| `partnerCenter` | the portal split reads + a `/me` centre signal |
|
||||
| `patientRecords` | endpoints exist, but the client family-record `id` model is `string` vs the wire's `int` — the customer-edit PUT is **write-unsafe** until reconciled |
|
||||
|
||||
The open REQ numbers behind these live in `docs/status/backlog.md`. Before flipping a domain to real, check
|
||||
that its `clientApi.ts` actually consumes the fields the server serves — flipping the flag is necessary but
|
||||
not sufficient.
|
||||
|
||||
One coupled seam: **`EVV_GPS_MODE` auto-selects `off`** (real `navigator.geolocation`) once
|
||||
`USE_BOOKINGS_MOCK` is `false`, so you don't get mock coordinates against real bookings.
|
||||
|
||||
---
|
||||
|
||||
## 4. Money and time on the client
|
||||
|
||||
The client **displays** money. It does not compute it.
|
||||
|
||||
| Rule | Why |
|
||||
| --- | --- |
|
||||
| Money crosses the wire as an **IRR digit string** and is parsed with integer-safe `BigInt` helpers (`formatIrrToToman` / `formatIrr` / `parseIrr` in `@/utils`). **Money is never a float** | IRR aggregates exceed JS's safe integer range, and float coercion on money is a correctness bug, not a rounding one |
|
||||
| A **breakdown reconciles by construction** — `PriceBreakdown` dev-guards `console.error` when rows don't sum to the total | A total the user can't derive from the rows they were shown is a trust failure |
|
||||
| The client **never computes** a rate, an aggregate, a payout date, or a holiday shift | These are server truth. A commission rate is snapshotted server-side at compute time; a review aggregate is recomputed from source; a payout date shifts off the bank-closure calendar |
|
||||
| A **server-frozen deadline is rendered, never recomputed** — `CountdownTimer` takes the UTC instant and counts down to it | A client that recomputes a deadline from a config value will disagree with the server the moment the config changes |
|
||||
| The **signed net payable balance is never clamped** — a negative reads as an explicit "owed back" state (magnitude only, never a bare minus) | Clamping to zero tells a nurse they owe nothing when they do |
|
||||
| Toman is **display-only**; the boundary conversion happens once, at the field | Mixing units in the middle is how a price ends up 10× off |
|
||||
| Dates arrive as **UTC ISO** and display through `formatShamsiDate(Time)`. Shamsi is a client concern | Except bank-closure math, which the server owns |
|
||||
|
||||
### Money-path mechanics
|
||||
|
||||
- **The caller owns the per-attempt `Idempotency-Key`** on payment initiate. Per *attempt*, not per booking.
|
||||
- **Poll only while non-terminal**, with backoff and bounded attempts. `usePaymentOutcome`, `useBnplOrder`,
|
||||
`useRefundStatus` and `useBookingRequest` all stop at a terminal state.
|
||||
- **A 409 on the money path is benign convergence, never a toast.** It means the server already did what you
|
||||
asked.
|
||||
- **`invalidations.ts` is the one post-capture cache transition** per money domain — an explicit list of the
|
||||
request/booking/summary/outcome keys that change. **Never a blanket refetch.**
|
||||
|
||||
---
|
||||
|
||||
## 5. Non-negotiable data rules
|
||||
|
||||
These encode business invariants, not preferences. Breaking one leaks data or misreports money.
|
||||
|
||||
**Clinical data**
|
||||
|
||||
- **`is_internal` is never modelled in the user-app ticket types.** Both mappers drop an internal message —
|
||||
a server-strip mimic — and there is no internal affordance anywhere in a user-facing screen. The admin
|
||||
ticket types carry `isInternal`; the user types deliberately do not.
|
||||
- **The customer must never fire the care-instructions query.** The two-stage disclosure gate is proved by a
|
||||
test on `BookingDetailView`.
|
||||
- **A nurse's care-record access is append-only.** The nurse surface never wires the customer-edit mutation.
|
||||
- **Access-denied is a first-class, non-leaking state, gated *before* any clinical fetch** — not an error
|
||||
rendered after a 403 came back with a body in it.
|
||||
- **Clinical text is never logged, never put in `localStorage`, never put in a query string.**
|
||||
|
||||
**Visibility and trust**
|
||||
|
||||
- **A `pending_moderation` review is never injected into a public list or aggregate**, and the client never
|
||||
computes the aggregate.
|
||||
- **Every search result is verified-by-invariant** — the server's index only contains searchable rows, so
|
||||
the UI never re-filters. If an unverified nurse appears, that is a server bug, not something to paper over
|
||||
client-side.
|
||||
- **`districtId = null` means whole-city** — a real coverage choice, not missing data. Treating it as absent
|
||||
drops a nurse's entire coverage.
|
||||
- **A notification's `data_json` is a typed contract.** `parseNotificationData(type, dataJson)` returns a
|
||||
discriminated union, tolerates snake/camel, and degrades to `{ kind: 'none' }` on anything malformed,
|
||||
unknown, or missing an id. Never trust the blob; never index into it directly.
|
||||
|
||||
---
|
||||
|
||||
## 6. Cookies
|
||||
|
||||
**App and auth state goes through the cookie manager only** — never `document.cookie`, never `js-cookie`
|
||||
directly, never `localStorage` or `sessionStorage`.
|
||||
|
||||
| File | Import from | Holds |
|
||||
| --- | --- | --- |
|
||||
| `lib/cookies/constants.ts` | anywhere, via the barrel | `COOKIE_NAMES`, `CookieOptions`, `AUTH_*_COOKIE_OPTIONS`, `COLOR_SCHEME_COOKIE_OPTIONS` |
|
||||
| `lib/cookies/server.ts` | RSCs, Server Actions, Route Handlers **only** | `getServerCookie`, `getThemeMode`, `setServerCookie` |
|
||||
| `lib/cookies/client.ts` | client components / effects **only** | `getClientCookie`, `setClientCookie`, `deleteClientCookie`, `getColorSchemeCookie` |
|
||||
| `lib/cookies/index.ts` | anywhere | re-exports `constants.ts` **only** — a safe barrel |
|
||||
|
||||
Import constants via the barrel (`import { COOKIE_NAMES } from '@/lib/cookies'`) and the server/client
|
||||
utilities **directly** from their file. Never import `server.ts` in a client component or `client.ts` in an
|
||||
RSC.
|
||||
|
||||
`COOKIE_NAMES.COLOR_SCHEME = 'color-scheme'` is the single source of truth for the theme cookie name — do not
|
||||
redeclare it anywhere. `CookieOptions.maxAge` is in **seconds** (converted to an `expires: Date` internally).
|
||||
|
||||
**Never read `localStorage` or `document.cookie` in a render function** — use an effect, or read server-side
|
||||
via `next/headers`.
|
||||
|
||||
See [auth.md](auth.md) for the token cookies and the session lifecycle.
|
||||
@@ -0,0 +1,185 @@
|
||||
# Client structure
|
||||
|
||||
The route tree, the server/client boundary, and the page pattern every screen follows.
|
||||
|
||||
> Last verified: 2026-07-30 against commit `d3ec723`.
|
||||
|
||||
---
|
||||
|
||||
## 1. `src/` at a glance
|
||||
|
||||
| Folder | Holds |
|
||||
| --- | --- |
|
||||
| `app/` | The App Router tree. Everything under `[locale]/` |
|
||||
| `components/` | Shared UI — `common/` primitives plus one folder per domain composite family |
|
||||
| `constants/` | App-wide named constants (`routes.ts`, `roles.ts`, `headers.ts`, `policy.ts`) |
|
||||
| `context/` | React context providers — `auth/` (AuthContext + reducer) |
|
||||
| `hooks/` | Cross-cutting hooks (`auth.ts`, `capabilities.ts`, `layout.ts`, `useAdminListState.ts`) |
|
||||
| `i18n/` | next-intl wiring: `routing.ts`, `request.ts`, `navigation.ts` |
|
||||
| `layout/` | The one mobile app shell: `AppFrame`, `MobileShell`, the four actor layouts, chrome components |
|
||||
| `lib/` | Infrastructure: `api/` (fetch), `auth/` (token/session), `cookies/`, `query/`, `toast/` |
|
||||
| `services/` | 22 domain services, one folder each — the data layer |
|
||||
| `theme/` | Palette, tokens, typography, the pre-built themes |
|
||||
| `utils/` | Money, dates, numbers, CSV, text helpers |
|
||||
|
||||
Plus `messages/{en,fa}.json`, `middleware.ts`, and `next.config.mjs` (which only wires the next-intl
|
||||
plugin and `reactStrictMode`) outside `src/`.
|
||||
|
||||
This is a **pattern map, not a file listing**. `git ls-files client/src` enumerates files for free and
|
||||
never goes stale; what follows is the shape those files have to fit.
|
||||
|
||||
---
|
||||
|
||||
## 2. The one absolute rule: no layout above `[locale]`
|
||||
|
||||
**`src/app/[locale]/layout.tsx` IS the root layout.** It renders `<html>` and `<body>`. There is no
|
||||
`src/app/layout.tsx`, and adding one — or any layout above the `[locale]` segment — breaks both locales.
|
||||
|
||||
**Why**, because this is worth understanding rather than obeying: a layout above `[locale]` is *shared*
|
||||
between `/fa` and `/en`. Next.js statically caches it at build time with `defaultLocale` (`fa`) and never
|
||||
re-renders it on a client-side locale switch, because the segment it is keyed on doesn't change. Its
|
||||
`lang`, `dir`, messages, providers, and fonts therefore **freeze on `fa`/`rtl` for every route, including
|
||||
`/en`**. The `[locale]` layout is the lowest boundary keyed on the locale param, so it is the only place
|
||||
`<html lang dir>` can reliably track the active locale.
|
||||
|
||||
### What the root layout owns
|
||||
|
||||
- The locale, sourced from the **URL param** (`params.locale`), validated against `routing.locales` with a
|
||||
fallback to `defaultLocale`. **No header reads.**
|
||||
- `<html lang dir>` — `dir` from `getDirection(locale)` — plus `data-mui-color-scheme` from
|
||||
`getThemeMode()`.
|
||||
- The per-locale font class (Mikhak on `fa` only — see [theme.md](theme.md)).
|
||||
- `setRequestLocale(locale)`, so server components deeper in the tree can call `getLocale()` /
|
||||
`getTranslations()` reliably. **Never remove it** — without it, deeper RSCs always see `defaultLocale`.
|
||||
- `getMessages({ locale })` with the locale passed **explicitly**, so `getRequestConfig` receives it via
|
||||
`Promise.resolve(locale)` rather than through the `React.cache` read — which avoids a cache-ordering
|
||||
race. **Never call `getMessages()` bare.**
|
||||
- The providers: `NextIntlClientProvider`, `AuthProvider` (seeded with `getServerAuthState()`),
|
||||
`ThemeProvider`, `NotistackProvider` + `ToastBridge`.
|
||||
- `generateStaticParams`, so Next can enumerate locale routes at build time.
|
||||
- `generateMetadata` — the `'%s | بالینیار'` / `'%s | Balinyaar'` title template, the default title and
|
||||
description, and `metadataBase: new URL(SITE_URL)` so child pages' relative OG/canonical URLs resolve
|
||||
absolute. `SITE_URL` comes from `src/config.ts`, never a hard-coded origin.
|
||||
|
||||
**Never add `notFound()` to the `[locale]` layout.** Unknown locales are handled by middleware; a hard 404
|
||||
there breaks the fallback.
|
||||
|
||||
### The two files that legitimately sit above `[locale]`
|
||||
|
||||
| File | Why it's allowed |
|
||||
| --- | --- |
|
||||
| `app/global-error.tsx` | It *replaces* the root layout on a root-level crash, so it renders its own `<html>` — which means it **cannot use next-intl**. It is the one sanctioned static-string exception: keep it minimal and bilingual (fa + en) |
|
||||
| `app/robots.ts`, `app/sitemap.ts` | Route handlers, not layouts. They enumerate the public surface across both locales |
|
||||
|
||||
---
|
||||
|
||||
## 3. The server/client boundary
|
||||
|
||||
| Never import | From |
|
||||
| --- | --- |
|
||||
| `next/headers` | a client component |
|
||||
| `next-intl/server` | a client component |
|
||||
| `@/lib/cookies/server` | a client component |
|
||||
| `@/lib/cookies/client` | an RSC |
|
||||
|
||||
The build fails on the first three. The fourth fails at runtime, quietly, which is worse.
|
||||
|
||||
Route-group layouts (`(private-routes)/layout.tsx`, `(public-routes)/layout.tsx`) are `'use client'` — they
|
||||
only wrap a layout component and need no server capabilities.
|
||||
|
||||
Never mix `clientFetch` and `serverFetch` in the same file; keep `clientApi.ts` and `serverApi.ts`
|
||||
separate. Next enforces the environment boundary at build time.
|
||||
|
||||
---
|
||||
|
||||
## 4. The route tree, by shape
|
||||
|
||||
Everything lives under `src/app/[locale]/`. Route groups add no URL segment.
|
||||
|
||||
```
|
||||
[locale]/
|
||||
├── layout.tsx error.tsx not-found.tsx [...rest]/page.tsx
|
||||
├── (private-routes)/ layout.tsx mounts useSessionRoleSync
|
||||
│ ├── _chrome/ shared loading skeleton (private, not a route)
|
||||
│ ├── select-role/ first-use role picker, own FocusedLayout
|
||||
│ ├── (customer)/ the family app — no URL segment
|
||||
│ ├── (customer-focused)/ chrome-free counterpart, same URL space (onboarding)
|
||||
│ ├── nurse/ the nurse app
|
||||
│ ├── admin/ the backoffice
|
||||
│ └── partner/ the partner-centre portal — a SEPARATE authz scope
|
||||
└── (public-routes)/ login · terms · privacy · welcome
|
||||
```
|
||||
|
||||
| Convention | Meaning |
|
||||
| --- | --- |
|
||||
| `(parenthesised)` | A route group. Adds no URL segment; exists to attach a layout and a `RoleGuard` |
|
||||
| `_`-prefixed folder | Private, **not a route** — `_chrome/`, `admin/_hub/` |
|
||||
| `[...rest]/page.tsx` | The catch-all. Calls `notFound()` so any unmatched path under a locale renders `not-found.tsx` — next-intl's recommended 404 pattern |
|
||||
| `loading.tsx` | A route-group skeleton shaped like that group's content area. The `MobileShell` chrome is already rendered by the enclosing layout, so a skeleton shapes the content only |
|
||||
|
||||
Each private group's `layout.tsx` is `'use client'` and wraps `RoleGuard` → that actor's layout:
|
||||
`(customer)` → `CustomerLayout`, `nurse` → `NurseLayout`, `admin` → `AdminLayout`, `partner` →
|
||||
`PartnerLayout`. `(customer-focused)` and `select-role` wrap `FocusedLayout` instead, for flows a user must
|
||||
not be able to tab away from mid-setup.
|
||||
|
||||
The **partner portal is a separate authorization scope**: a centre admin is not a Balinyaar admin. Its
|
||||
`RoleGuard` passes no `expected` role (it isn't an `AppRole`) and each page resolves the caller's *own*
|
||||
centre via `useMyPartnerCenter`. See [auth.md](auth.md).
|
||||
|
||||
**When you add, remove, or rename a route group, a provider, or a top-level `src/` folder, update the
|
||||
"Project structure" section in [client/CLAUDE.md](../../../client/CLAUDE.md) and §1 above in the same
|
||||
change.**
|
||||
|
||||
---
|
||||
|
||||
## 5. The page pattern
|
||||
|
||||
The root layout owns a title *template*; a route supplies the `%s`. So a route that wants its own tab
|
||||
title splits in two:
|
||||
|
||||
```tsx
|
||||
// page.tsx — a thin RSC. No 'use client'.
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import HomeScreen from './HomeScreen';
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'shell' });
|
||||
return { title: t('customer_app') };
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return <HomeScreen />;
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// HomeScreen.tsx — 'use client'. All the logic and JSX.
|
||||
```
|
||||
|
||||
Rules that fall out of it:
|
||||
|
||||
- The screen component is **co-located** with `page.tsx` and named `<PageName>Screen.tsx`, so its existing
|
||||
relative imports keep working unchanged.
|
||||
- `page.tsx` never renders `<title>` and never touches `document.title`. Assigning `document.title` in a
|
||||
render body throws `ReferenceError: document is not defined` during build-time prerendering.
|
||||
- A static `metadata` export is fine when the title needs no translation lookup.
|
||||
- Page bodies stay **composition + content**. Reusable visuals move to `src/components/`; page-only,
|
||||
never-reused composition can stay in the page.
|
||||
|
||||
Adoption is partial by design: the landing pages (customer home, `/login`, `/search`, `/bookings`,
|
||||
`/nurse`, `/admin`, `/partner`) plus `/welcome` use it. The rest still render directly and gain it when
|
||||
the page is next substantially touched.
|
||||
|
||||
`metadataBase` makes the pattern extend to OG: `/welcome` sets `alternates.canonical` + `openGraph` in its
|
||||
`generateMetadata` and supplies `og:image` from a co-located `opengraph-image.tsx` (`next/og`'s
|
||||
`ImageResponse`).
|
||||
|
||||
---
|
||||
|
||||
## 6. This is not a static export
|
||||
|
||||
The app relies on server components, middleware, and server-side cookies. `next.config.mjs` wires the
|
||||
next-intl plugin and `reactStrictMode` and nothing else — don't add `output: 'export'`, and don't assume a
|
||||
page can be prerendered without its request context.
|
||||
@@ -0,0 +1,120 @@
|
||||
# Client testing, lint and types
|
||||
|
||||
What is tested and how, plus the two gate tools and their traps.
|
||||
|
||||
> Last verified: 2026-07-30 against commit `d3ec723`.
|
||||
|
||||
---
|
||||
|
||||
## 1. What is tested
|
||||
|
||||
**Every shared component has a co-located test file.** A component is "shared" if it is imported from more
|
||||
than one place — a page, a layout, or another component.
|
||||
|
||||
There are **125 test files** across `src/` (114 `*.test.tsx`, 11 `*.test.ts`). Location:
|
||||
`src/components/<Name>/<Name>.test.tsx`, next to the component.
|
||||
|
||||
### Coverage baseline for a shared component
|
||||
|
||||
1. It renders without crashing.
|
||||
2. Every documented prop produces the correct HTML attribute or CSS class.
|
||||
3. User interactions (click, change) call the expected callbacks.
|
||||
|
||||
That is a floor, not a ceiling. Where a component encodes a rule, test the rule: `BookingDetailView`'s test
|
||||
proves the customer **never fires the care-instructions query** (the two-stage disclosure gate), and
|
||||
`matchActivePath`'s test proves a nested route lights up its parent tab and never a sibling. Those are the
|
||||
tests worth writing.
|
||||
|
||||
### The two rules about the test itself
|
||||
|
||||
- **Wrap with `<ThemeProvider>`** if the component uses MUI theming.
|
||||
- **Do NOT mock MUI components.** Test against the rendered DOM. A test that mocks `Button` proves nothing
|
||||
about what a user sees.
|
||||
|
||||
### Before removing or renaming a shared component
|
||||
|
||||
Check whether any `src/**/*.test.{ts,tsx}` imports it. If so, update or delete those tests in the same
|
||||
change. A dangling test import fails the suite, and a test left behind for a deleted component is dead code.
|
||||
|
||||
### Deliberate coverage gaps
|
||||
|
||||
`NeshanMap` is **not** unit-tested — jsdom plus Leaflet is an integration problem, not a unit one — and it is
|
||||
unreachable in CI anyway, because `NEXT_PUBLIC_NESHAN_KEY` is unset there so `AddressMapPicker` falls back to
|
||||
the bounded-canvas stand-in. Both branches of that fork are tested; the map itself isn't.
|
||||
|
||||
---
|
||||
|
||||
## 2. Jest configuration
|
||||
|
||||
`jest.config.ts` **replaces** `next/jest`'s `transformIgnorePatterns` array outright rather than appending to
|
||||
it. This is deliberate and easy to undo by accident:
|
||||
|
||||
`next/jest`'s default pattern already broadly matches all of `node_modules` (its negative-lookahead allowlist
|
||||
carves out only a couple of Next-internal packages), and Jest's array semantics are **OR-based** — a file is
|
||||
ignored if *any* pattern matches. So appending a more permissive pattern can never "un-ignore" a package an
|
||||
earlier pattern already caught. The array has to be replaced, by post-processing the async config `next/jest`
|
||||
returns.
|
||||
|
||||
What it lets through: `next-intl`, `use-intl`, `@formatjs`, `intl-messageformat` — all ESM-only builds.
|
||||
|
||||
That fixes real imports; it does not make the dependency free. See
|
||||
[components.md](components.md) "Presentational purity" for why `ErrorBoundary`/`ErrorState` are caller-owned
|
||||
and `Money` is the one sanctioned exception.
|
||||
|
||||
---
|
||||
|
||||
## 3. The gate
|
||||
|
||||
```
|
||||
npm run check → npm run type && npm run lint && npm run lint:copy
|
||||
```
|
||||
|
||||
Plus `npm run test:ci` when you touched a component with a co-located test.
|
||||
|
||||
Both gate tools are plain CLI tools. **There is no `next lint`** — it was removed in Next 16, and calling it
|
||||
silently does nothing.
|
||||
|
||||
| Script | Runs |
|
||||
| --- | --- |
|
||||
| `type` | `tsc --noEmit`. `tsconfig.json`: `strict` on, `noEmit`, `@/*` → `src/*` |
|
||||
| `lint` | `eslint .`, driven by **flat config** in `eslint.config.mjs` |
|
||||
| `lint:copy` | `node scripts/check-copy.mjs` — see [i18n.md](i18n.md) §4 |
|
||||
|
||||
`eslint.config.mjs` spreads `eslint-config-next` (core-web-vitals + typescript + react + react-hooks +
|
||||
jsx-a11y + import) and applies `eslint-config-prettier` **last**, so ESLint never fights Prettier on
|
||||
formatting.
|
||||
|
||||
---
|
||||
|
||||
## 4. Lint rules for this project
|
||||
|
||||
- **Flat config only.** Do not add `.eslintrc*` files — put any rule change in `eslint.config.mjs`.
|
||||
- **ESLint owns correctness, Prettier owns formatting.** Don't add stylistic ESLint rules.
|
||||
- **No unused variables or imports.** `@typescript-eslint/no-unused-vars` is raised from
|
||||
eslint-config-next's default `warn` to **`error`**, so dead code fails `npm run check`. Delete it rather
|
||||
than disabling the rule; prefix a deliberately-unused binding with `_` (`_event`, `catch (_err)`) to opt
|
||||
out.
|
||||
- **Prefer fixing code over silencing the linter.** When a disable is genuinely correct — the real example
|
||||
here is a deliberate browser-only read after mount that trips `react-hooks/set-state-in-effect` — use a
|
||||
scoped `// eslint-disable-next-line <rule>` with a one-line reason. **Never a file-wide disable.**
|
||||
|
||||
### Two pinned constraints
|
||||
|
||||
- **ESLint is pinned to 9.** ESLint 10 currently crashes with this Next 16 toolchain
|
||||
(`scopeManager.addGlobals is not a function`). Don't bump it as a housekeeping change.
|
||||
- **`import/no-cycle` is disabled** — its TypeScript resolver has an interface mismatch here. The reason is
|
||||
noted in `eslint.config.mjs`; don't re-enable it without checking that note.
|
||||
|
||||
---
|
||||
|
||||
## 5. MUI v9 API only
|
||||
|
||||
The type gate catches most of this, but not all of it, and the failures are confusing when it doesn't.
|
||||
|
||||
- Use `sx={{ mb: 4 }}`, **not** `mb={4}` as a direct prop.
|
||||
- **Do not pass `flexWrap` or `useFlexGap` as direct props to `Stack`.** Neither is a valid v9 `Stack` prop;
|
||||
both cause a TypeScript overload error. Use `sx={{ flexWrap: 'wrap' }}`. `useFlexGap` was a v5 opt-in and
|
||||
does not exist in v9.
|
||||
- No other v5/v6-era props: `storageWindow`, `InitColorSchemeScript`. See [theme.md](theme.md) for the
|
||||
color-scheme ones specifically, which fail *silently* rather than at compile time.
|
||||
- Avoid deprecated MUI APIs that throw at runtime.
|
||||
@@ -0,0 +1,268 @@
|
||||
# Client theme
|
||||
|
||||
Colors, tokens, dark mode, direction, fonts, motion. The brand's *look* is the
|
||||
[frontend-designer](../../../.claude/skills/frontend-designer/SKILL.md) skill's job; this file is the
|
||||
mechanism it runs on.
|
||||
|
||||
> Last verified: 2026-07-30 against commit `d3ec723`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Brand identity
|
||||
|
||||
Balinyaar is a trust-first home-nursing marketplace in Iran. The tone is calm, warm,
|
||||
clinical-but-human — not a cold medical dashboard. The default audience is Persian (RTL); English is
|
||||
secondary.
|
||||
|
||||
| Role | Light | Dark |
|
||||
| --- | --- | --- |
|
||||
| Primary — deep teal | `#1d4a40` | `#6fc0ac` (lifted, readable on dark) |
|
||||
| Secondary — terracotta | `#d98c6a` | `#e6a98a` |
|
||||
| Page surface | `#faf9f5` cream | `#0f1c19` deep teal |
|
||||
| Paper / card | `#ffffff` | `#16302a` teal surface |
|
||||
| Text primary | `#1b2521` ink | `#f3efe9` cream |
|
||||
|
||||
**Teal ground, cream glyph, terracotta accent** is the whole identity. Use terracotta sparingly as the
|
||||
single accent; teal carries everything else.
|
||||
|
||||
The logo mark is a deep-teal rounded square, a cream lowercase "b" built from a stem plus a ring bowl, and
|
||||
one terracotta dot. Two SVGs under `components/common/AppIcon/icons/`: `LogoMark.tsx` (monochrome
|
||||
`currentColor` glyph only, registered as `ICONS.logo`) and `LogoLockup.tsx` (full colour, token-driven so
|
||||
it tracks the scheme, used by `BrandMark`). **The wordmark beside it stays a real, translated
|
||||
`<Typography>`** — never bake locale text into an SVG. The favicon and `public/img/favicon/*.png` are
|
||||
rasterized from the same construction with fixed brand hex, which is the one place a literal hex is
|
||||
correct; regenerate with a `sharp`-based script rather than hand-editing the binaries.
|
||||
|
||||
---
|
||||
|
||||
## 2. Two mirrored color homes
|
||||
|
||||
Colors exist in two places that must stay in sync. Pick the right one.
|
||||
|
||||
| Home | File | Reach it via | Use for |
|
||||
| --- | --- | --- | --- |
|
||||
| **MUI palette** | `theme/colors.ts` (`BRAND`, `LIGHT_PALETTE`, `DARK_PALETTE`) | `color="primary"`, `sx={{ color: 'text.secondary', bgcolor: 'background.paper' }}` | **The default** for styling a MUI component |
|
||||
| **`--bal-*` CSS variables** | `theme/tokens.css`, under `[data-mui-color-scheme='light'\|'dark']` | `var(--bal-primary)` | Custom CSS outside MUI's palette, and every semantic feedback color |
|
||||
|
||||
- Styling a MUI component → palette keys.
|
||||
- Need success / error / warning / info → **`--bal-*`, not MUI's defaults.** The MUI palette defines no
|
||||
semantic colors, and these tokens are brand-harmonized.
|
||||
- Need a custom color in raw CSS → add a `--bal-*` token **in both scheme blocks**, then `var(--…)`.
|
||||
- **Never hard-code a hex or rgb** in `sx`, `styled`, or a component.
|
||||
- Adding or changing a color means editing `tokens.css` **and** `colors.ts` together. Both file headers
|
||||
call out the sync requirement.
|
||||
|
||||
### The token catalogue
|
||||
|
||||
Every token is defined under both `[data-mui-color-scheme]` blocks unless noted.
|
||||
|
||||
| Group | Tokens | Notes |
|
||||
| --- | --- | --- |
|
||||
| Brand | `--bal-primary`, `-light`, `-dark`, `-contrast`, `-soft`; same five for `--bal-secondary` | |
|
||||
| Surfaces | `--bal-bg-default`, `--bal-bg-paper`, `--bal-frame-canvas` | `frame-canvas` is the backdrop `AppFrame` paints *outside* the phone-width column — **never a surface a component draws on** |
|
||||
| Text | `--bal-text-primary`, `--bal-text-secondary`, `--bal-divider` | |
|
||||
| Semantic | `--bal-{success,error,warning,info}` + each `-contrast` + each `-soft` | `-contrast` is the text color on that fill; `-soft` is the tinted background variant |
|
||||
| Elevation | `--bal-shadow-1/2/3` | Teal-tinted (black-teal in dark). They back `theme.ts`'s `shadows` array, so every MUI elevation resolves through them — never MUI's grey stack |
|
||||
| Radius | `--bal-radius-sm` 6px (controls), `-md` 8px (cards/paper, `= theme.shape.borderRadius`), `-lg` 12px (dialogs), `-pill` 999px | Reference the token, **never a numeric `sx={{ borderRadius: n }}`** — that multiplies the shape unit, which is how the login card once became a 30px pill. `MuiPaper` pins the `md` step so a Paper can't drift past it. `-pill` is for shapes that genuinely *are* pills (the floating nav, a segmented control's active chip), never a card |
|
||||
| Motion | `--bal-motion-fast/base/slow` (120/200/300ms), `--bal-easing-standard` | `theme.ts` points `MuiDialog`/`MuiDrawer`/`MuiPopover`/`MuiMenu`'s `defaultProps.transitionDuration` at the same numbers, in one place, instead of MUI's per-variant defaults |
|
||||
| Focus | `--bal-focus-ring` | The 2px ring `MuiCssBaseline`'s global `:focus-visible` override uses. **Don't hand-roll a focus style** — it is already uniform everywhere |
|
||||
| Rating | `--bal-rating`, `--bal-rating-empty` | `RatingInput` uses these, **not** `--bal-warning` |
|
||||
| Trust | `--bal-trust`, `--bal-trust-soft` | A distinct identity for verified marks — not primary, not success. `TrustBadge` and any future verification UI |
|
||||
| Money | `--bal-money-emphasis` | AA-contrast-safe emphasized money text. `--bal-secondary` (terracotta) **fails AA at small sizes on light backgrounds** — never use it for money text |
|
||||
| Avatar | `--bal-avatar-1..6` + each `-contrast` | Six warm pairs `InitialsAvatar` picks from by a deterministic name hash |
|
||||
| Map | `--bal-pin-shadow` | The address-picker pin |
|
||||
|
||||
`--bal-chrome-top` / `--bal-chrome-bottom` are **not** in `tokens.css` — `AppFrame` publishes them at
|
||||
runtime on its scroll container. See [components.md](components.md).
|
||||
|
||||
---
|
||||
|
||||
## 3. Dark mode, and the no-flash boot
|
||||
|
||||
The mechanism is **pure CSS. There is no boot script**, no inline `<script>`, and no
|
||||
`Storage.prototype` patching — matching how every other color decision in this app is made.
|
||||
|
||||
**Returning visitor (cookie present)**
|
||||
|
||||
1. `getThemeMode()` (`lib/cookies/server.ts`) reads the `'color-scheme'` cookie → `{ colorScheme, defaultMode: colorScheme }`.
|
||||
2. The root layout sets `data-mui-color-scheme={colorScheme}` on `<html>`, server-side.
|
||||
3. `tokens.css`'s explicit `[data-mui-color-scheme='light'|'dark']` blocks match immediately — correct on
|
||||
the very first paint, before any JS runs.
|
||||
|
||||
**First-ever visitor (no cookie)**
|
||||
|
||||
1. `getThemeMode()` returns `{ colorScheme: undefined, defaultMode: 'system' }`.
|
||||
2. The root layout renders `<html>` **without** the attribute at all (React omits `undefined`).
|
||||
3. `tokens.css` has a `@media (prefers-color-scheme: dark)` block scoped to
|
||||
`:root:not([data-mui-color-scheme])` — it applies only while the attribute is absent, and paints the
|
||||
OS-preferred scheme immediately with zero JS.
|
||||
4. Once React hydrates, `<MuiThemeProvider defaultMode="system">` resolves the *same* media query and
|
||||
stamps the attribute itself. The painted values already match, so nothing visibly flips.
|
||||
5. `ColorSchemeCookieSync` (inside `ThemeProvider.tsx`) writes the cookie from
|
||||
`useColorScheme().colorScheme` in an effect, so the next visit is a "returning visitor" — even before
|
||||
the user ever touches the control.
|
||||
|
||||
### The known gap, by design
|
||||
|
||||
This covers the dominant visual surface — every `--bal-*` token — because that is what the media-query
|
||||
fallback drives. MUI's own generated `--mui-palette-*` variables (consumed by a bare `color="primary"`
|
||||
fill: a contained Button, the default `MuiTabs` indicator) do **not** get the same free fallback: MUI's
|
||||
`colorSchemeSelector` supports attribute-based *or* `'media'`-based generation, not both at once. So on a
|
||||
cookie-less first visit with OS dark on, a raw MUI-primary fill can very briefly show the light value
|
||||
until hydration. It self-corrects in the same frame, and `disableTransitionOnChange` means it snaps rather
|
||||
than animating.
|
||||
|
||||
**Prefer sourcing colors from `var(--bal-*)` over `theme.vars.palette.*` in new `styleOverrides`** — most
|
||||
of `theme.ts`'s `components` block already does — to keep this gap as small as possible.
|
||||
|
||||
### MUI v9 traps in this area
|
||||
|
||||
**`colorSchemeSelector` must be the explicit attribute name.**
|
||||
|
||||
```ts
|
||||
// theme.ts
|
||||
cssVariables: {
|
||||
colorSchemeSelector: 'data-mui-color-scheme', // CORRECT
|
||||
// colorSchemeSelector: 'data', // WRONG
|
||||
}
|
||||
```
|
||||
|
||||
The shorthand `'data'` generates `[data-%s]` → boolean `data-dark=""` / `data-light=""` attributes. Our
|
||||
`tokens.css` selects on `[data-mui-color-scheme="dark"]`, which never matches a boolean attribute, so the
|
||||
whole token layer silently stops switching.
|
||||
|
||||
**Never use MUI's `InitColorSchemeScript`.** It reads localStorage, which diverges from our cookie
|
||||
(especially in `system` mode), and it is a script — this app's no-flash boot is CSS-only. Don't add *any*
|
||||
pre-paint color-scheme script; if a new token needs the same first-visit treatment, extend the `tokens.css`
|
||||
media-query fallback instead.
|
||||
|
||||
**Never use `storageWindow={null}`.** In MUI v9's `localStorageManager` the check is
|
||||
`if (!storageWindow && typeof window !== 'undefined')` — `null` is falsy, so it silently overrides to
|
||||
`window`. The prop is a no-op in browsers.
|
||||
|
||||
**MUI v9's localStorage key defaults differ from v5/v6** — mode key `'mode'` (was `'mui-mode'`), color
|
||||
scheme key `'color-scheme'` (was `'mui-color-scheme'`), HTML attribute `'data-color-scheme'` (was
|
||||
`'data-mui-color-scheme'`). We override the attribute via `colorSchemeSelector`; the cookie is ours and is
|
||||
named by `COOKIE_NAMES.COLOR_SCHEME`.
|
||||
|
||||
### `mode` vs `colorScheme`
|
||||
|
||||
Use **`colorScheme`** for an "is dark active" check. `mode` can be `'system'` even when dark is active.
|
||||
|
||||
The one exception is the control itself, which must read `mode`: that is the user's *choice*, while
|
||||
`colorScheme` is only the resolved result. `mode` is `undefined` until MUI mounts, so default it
|
||||
(`mode ?? 'system'`) rather than rendering an unselected control — server, first client render, and
|
||||
pre-mount state then agree, so there is no hydration mismatch and no flash of "nothing selected".
|
||||
|
||||
### The one appearance control
|
||||
|
||||
`components/settings/ThemeModeSetting.tsx` is the **only** component that subscribes to `useColorScheme()`
|
||||
and the app's only appearance control. It lives in each actor's settings hub (`/nurse/more`,
|
||||
`/admin/system`, `/partner/more`, the customer profile hub) **and nowhere else** — the old top-bar toggle
|
||||
spent a permanent slot of chrome in three shells on a preference set once.
|
||||
|
||||
It is a **three-way segmented control (light / dark / system), never a boolean switch.** `system` is the
|
||||
app's real default on a cookie-less first visit, so an on/off control cannot represent the current state
|
||||
and would silently misreport it.
|
||||
|
||||
The write path: `setMode('dark')` → `ColorSchemeCookieSync`'s effect writes the `'color-scheme'` cookie →
|
||||
MUI sets `data-mui-color-scheme` on `<html>` → CSS variables resolve → the browser repaints. No React
|
||||
re-render above the control.
|
||||
|
||||
### Pre-built themes
|
||||
|
||||
`APP_THEME_LTR` and `APP_THEME_RTL` are created once at module load. **Never call `createTheme()` inside a
|
||||
component or hook** — pass the appropriate pre-built theme to `MuiThemeProvider`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Direction
|
||||
|
||||
`getDirection(locale)` (`theme/direction.ts`) returns `'rtl'` for `fa`, `ar`, `he`, `ur`; `'ltr'` for
|
||||
everything else. `ThemeProvider` takes a `dir` prop and selects the matching pre-built theme; the RTL
|
||||
Emotion cache uses `stylis-plugin-rtl` to mirror all generated CSS.
|
||||
|
||||
The root layout sets `dir` on `<html>` and passes it to `ThemeProvider`. Because that layout is keyed on
|
||||
the `[locale]` URL param, a locale change re-renders it with a fresh `dir` on both hard and soft
|
||||
navigation, with no client state. Do **not** move the `<html dir>` render above `[locale]` — see
|
||||
[structure.md](structure.md) §2.
|
||||
|
||||
**RTL correctness is a rule, not a nicety.** Never use directional hard-coding for layout flow —
|
||||
`marginLeft`, `left:`, `textAlign: 'left'`. Use logical or MUI-flipped properties: `ml` (MUI flips it),
|
||||
`marginInlineStart`, `insetInline`, `start`/`end`. Verify the layout visually at `/fa`, then `/en`.
|
||||
|
||||
Bidi text needs explicit isolation: a Latin-digit code, an IBAN, or a date·time range inside Persian prose
|
||||
goes in a `dir="ltr"` span. `SessionCard` and `BookingRequestSummaryCard` are the references.
|
||||
|
||||
---
|
||||
|
||||
## 5. Fonts
|
||||
|
||||
Loaded **per locale**, so the Persian face is never shipped to English pages.
|
||||
|
||||
| Locale | Font | CSS variable | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| `fa` (RTL) | **Mikhak** | `--font-mikhak` | `next/font/local` — woff2 in `src/app/fonts/` |
|
||||
| `en` (LTR) | **Space Grotesk** | `--font-space-grotesk` | `next/font/google` — self-hosted at build time |
|
||||
|
||||
Rules:
|
||||
|
||||
- Both are declared with `preload: false`, and each `.variable` class is attached to `<html>` **only for
|
||||
its own locale** — never both, never neither. A `next/font` loader called unconditionally would preload
|
||||
on every route; `preload: false` ensures the file downloads only when its locale actually renders.
|
||||
- Mikhak's woff2 files live in `src/app/fonts/`, **not** `public/` — `next/font/local` resolves paths
|
||||
relative to the calling file at build time. Space Grotesk needs no local files.
|
||||
- **Never load a font inside a component or page.** All font loading lives in
|
||||
`src/app/[locale]/layout.tsx`.
|
||||
- To add a local font: add the woff2 files, declare via `localFont` in the root layout, attach its
|
||||
`.variable` class conditionally on the matching locale, and update the `BRAND_FONT_VARIABLE_*` constants
|
||||
in `typography.ts`.
|
||||
|
||||
### Typography
|
||||
|
||||
`TYPOGRAPHY_LTR` (Space Grotesk headings, system-stack body) and `TYPOGRAPHY_RTL` (Mikhak for *all* text,
|
||||
for full Persian glyph coverage) share one size/line-height scale (`SIZE_SCALE`), wrapped in
|
||||
`responsiveFontSizes()` in `theme.ts` for per-breakpoint heading scaling. **There is no `TYPOGRAPHY`
|
||||
alias** — import `TYPOGRAPHY_LTR`/`TYPOGRAPHY_RTL` explicitly, and not into components: use
|
||||
`<Typography variant=…>` and let the theme apply the direction-aware family.
|
||||
|
||||
**Never write `fontWeight: 600`.** Neither face loads a 600 weight, so a requested 600 silently renders
|
||||
full Bold. The system is **700** for headings (`h1`–`h6`), buttons and strong emphasis, **500** for lighter
|
||||
in-text emphasis (subtitles, row labels, chip text), **400** body. It is enforced globally in
|
||||
`typography.ts`; match it in any new `sx`.
|
||||
|
||||
Buttons are `textTransform: 'none'` at weight 700, set globally — never re-uppercase button text.
|
||||
|
||||
The Persian scale sets `letterSpacing: 0` on **every** variant (Persian is a joined script; tracking breaks
|
||||
glyph connections), body line-height ≥1.7, heading line-height ~1.4–1.5 for ascender/descender room. Don't
|
||||
hand-roll per-breakpoint `fontSize` overrides — `responsiveFontSizes()` already wraps both themes.
|
||||
|
||||
---
|
||||
|
||||
## 6. Motion and the reduced-motion gate
|
||||
|
||||
`RouteFadeIn` (`components/common/RouteFadeIn/`) is the one route-content fade/slide primitive. It wraps
|
||||
`{children}`, keyed on the locale-stripped pathname so it remounts (and replays the CSS `bal-fade-in`
|
||||
keyframe from `globals.css`) on navigation but never on an in-place re-render. It is mounted inside the
|
||||
`ErrorBoundary` in **all five shells**, so a new page gets the motion for free with no per-page wiring.
|
||||
|
||||
**`prefers-reduced-motion: reduce` has exactly one gate**, in `src/app/globals.css`: a universal
|
||||
`*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; … }`
|
||||
media-query block.
|
||||
|
||||
This is deliberately a global CSS reset rather than token-only zeroing. `tokens.css` *also* zeroes the
|
||||
duration tokens, but that alone would not reach MUI's own JS-driven Dialog / Drawer / Menu / Collapse
|
||||
transitions, which don't read CSS custom properties. **Never add a second, component-local reduced-motion
|
||||
branch** — extend this one rule if a new motion primitive needs the same treatment.
|
||||
|
||||
---
|
||||
|
||||
## 7. Toast colors
|
||||
|
||||
`NotistackProvider` maps every notistack variant to a `styled(MaterialDesignContent)` whose
|
||||
`backgroundColor`/`color` come from the `--bal-{success,error,warning,info}` (+ `-contrast`) tokens. Because
|
||||
those tokens are defined on `<html>`, they cascade into notistack's Portal and switch with the color scheme
|
||||
automatically. **Never hard-code a toast color** — adjust the tokens.
|
||||
|
||||
Direction is inherited too: the Portal mounts under `<body>` and picks up `dir` from `<html dir>`. Do
|
||||
**not** pass a `dir` prop to `SnackbarProvider` — it is not a valid prop (TS error) and is unnecessary.
|
||||
@@ -0,0 +1,131 @@
|
||||
# The documentation convention
|
||||
|
||||
How this repository keeps its own docs from lying. Read before writing or editing any `.md`.
|
||||
|
||||
> Last verified: 2026-08-02 against commit `51e86a1`.
|
||||
|
||||
---
|
||||
|
||||
## 1. One home per fact
|
||||
|
||||
Three trees, and a fact belongs to exactly one of them.
|
||||
|
||||
| Tree | Answers | Example |
|
||||
| --- | --- | --- |
|
||||
| [`product/`](../../product/index.md) | **What the business is** | escrow holds funds until check-out is confirmed |
|
||||
| `docs/` | **What we built, and how we work** | the escrow ledger is implemented; here is how to test it |
|
||||
| `archive/` | **How we got here** | the phase-10 prompt that built the ledger, and its report |
|
||||
|
||||
If you are about to write a business rule into `docs/`, it belongs in `product/`. If you are about to
|
||||
obey something in `archive/`, stop — it is a record, not an instruction.
|
||||
|
||||
Two documents stay outside `docs/` on purpose:
|
||||
|
||||
- [`DEPLOY.md`](../../DEPLOY.md) — the deploy *procedure*, at the repo root where an operator will look.
|
||||
- The three `CLAUDE.md` files — the hard-rule tier. See [rules/index.md](index.md).
|
||||
|
||||
Inside `docs/`, each section owns one question:
|
||||
|
||||
| Section | Owns |
|
||||
| --- | --- |
|
||||
| [`rules/`](index.md) | What must never be broken |
|
||||
| [`integration/`](../integration/index.md) | The client↔server seam: contract, config, topology, OpenAPI |
|
||||
| [`flows/`](../flows/index.md) | What is implemented and how to test it, one file per user journey |
|
||||
| [`status/`](../status/index.md) | Where the project actually is: implemented, backlog, decisions |
|
||||
| [`roadmap/`](../roadmap/index.md) | Where it goes next, and what gates a launch |
|
||||
|
||||
`product/` is a **structured docs tree** with a generated HTML view: the `.md` files are canonical, the
|
||||
matching `.html` files are built by `cd product && node build-docs.mjs`. Edit the Markdown and
|
||||
regenerate — never hand-edit the HTML. If you add or rename a `.md`, update the `NAV` manifest in
|
||||
`product/build-docs.mjs` in the same change.
|
||||
|
||||
---
|
||||
|
||||
## 2. What to update when X changes
|
||||
|
||||
This is the anti-drift contract. Each row is enforced by review — there is no pre-commit tooling behind
|
||||
it (a deliberate MVP-stage call; see [git-and-gates.md](shared/git-and-gates.md)).
|
||||
|
||||
| When you change… | Update, in the same change |
|
||||
| --- | --- |
|
||||
| An endpoint's route, shape, or status codes | [`docs/integration/`](../integration/index.md) + the OpenAPI snapshot |
|
||||
| A project, layer, route group, provider, or major folder | The matching **architecture section** — see §3 |
|
||||
| A user-facing flow, so it now works end to end | `docs/flows/<flow>.md` — what it does, and how to test it |
|
||||
| A backlog item, so it is now done | **Tick it** in `docs/status/backlog.md`. Never delete a row — a ticked row is the record that it shipped |
|
||||
| A decision that isn't derivable from the code | `docs/status/decisions.md` — the decision, the date, and why |
|
||||
| A business rule you discovered or decided | The relevant `product/**.md`, then regenerate the HTML. Record decisions; do not invent rules |
|
||||
| A rule, so it now says something different | The one file that owns it (see [rules/index.md](index.md)) — not a second copy elsewhere |
|
||||
| A new reusable pattern, seam, base class, or hook family | A short note in the reference file for that area, so the next change reuses it instead of reinventing it |
|
||||
| A mock or deferred external service | `docs/status/` — the seam (interface + file), what is faked, why, the config keys it reads, and step-by-step how to make it real |
|
||||
|
||||
**A mock is only sanctioned behind a DI-registered interface.** Mock and real implement the same
|
||||
interface; selection is by configuration, never by an `if (mock)` scattered through the code. An
|
||||
unrecorded mock is a defect.
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture sections are canonical, and there are exactly three
|
||||
|
||||
| Level | Canonical section |
|
||||
| --- | --- |
|
||||
| Repo | **"Repository layout"** in [root CLAUDE.md](../../CLAUDE.md) |
|
||||
| Frontend | **"Project structure"** in [client/CLAUDE.md](../../client/CLAUDE.md), expanded in [client/structure.md](client/structure.md) |
|
||||
| Backend | **"Project map"** in [server/CLAUDE.md](../../server/CLAUDE.md), expanded in [server/structure.md](server/structure.md) |
|
||||
|
||||
A map is only canonical if it stays accurate. **Stale instructions are worse than none** — an agent
|
||||
that trusts a wrong map spends its budget in the wrong place and lands a change in the wrong layer.
|
||||
|
||||
An architecture section describes **patterns and boundaries**, not a file listing. If it wants to grow a
|
||||
line per file, that is the signal it has stopped being a map: describe the shape of a route group, not
|
||||
each page inside it. `git ls-files` already lists files, for free, and never goes stale.
|
||||
|
||||
---
|
||||
|
||||
## 4. `Last verified`
|
||||
|
||||
Every doc that makes a **claim about the current state of the code** carries, directly under its title:
|
||||
|
||||
```
|
||||
> Last verified: <YYYY-MM-DD> against commit <short-sha>.
|
||||
```
|
||||
|
||||
A doc without one is a claim, not a fact.
|
||||
|
||||
| Must carry it | May omit it |
|
||||
| --- | --- |
|
||||
| Everything in `docs/rules/`, `docs/status/`, `docs/flows/`, `docs/integration/` | Index/README files that only link onward |
|
||||
| Any doc quoting a line count, a file count, a config key, or a default value | `product/` (business truth, not code state — it carries its own decision dates) |
|
||||
|
||||
Two companion rules:
|
||||
|
||||
- **Verify, don't copy.** A load-bearing claim is checked against the code or against a run before it is
|
||||
written down. Re-stamping the date without re-checking is the failure mode this is designed to catch.
|
||||
- **If it cannot be checked, mark it.** Prefix the sentence with `UNVERIFIED:` and say what would settle
|
||||
it. An honest gap is useful; a confident guess is not.
|
||||
|
||||
---
|
||||
|
||||
## 5. Length budgets
|
||||
|
||||
So this doesn't regrow into the thing it replaced.
|
||||
|
||||
| Tier | Budget | If it overflows |
|
||||
| --- | --- | --- |
|
||||
| A `CLAUDE.md` | **250 lines** | Something in it is reference, not a hard rule. Move it. |
|
||||
| A hard-rule list | **15–25 numbered items** | The weakest items aren't hard rules. Cut them. |
|
||||
| A `docs/rules/**` reference file | **400 lines** | Split by sub-topic, or you are listing where you should be describing. |
|
||||
| A `docs/flows/<flow>.md` | **200 lines** | It is covering two journeys. |
|
||||
|
||||
The rule behind the numbers: **loading a rule must not cost 40k tokens.** That is what the old
|
||||
1,098-line `client/CLAUDE.md` did to every frontend change, and it is why nobody read past the top.
|
||||
|
||||
---
|
||||
|
||||
## 6. Style
|
||||
|
||||
- **English throughout**, including in files that describe Persian UI copy. Quote the Persian, explain in
|
||||
English.
|
||||
- Prose over bullet soup for reasoning; tables for anything with more than three parallel cases.
|
||||
- Link, don't restate. Two copies of a rule drift; one copy and a link cannot.
|
||||
- Write the *why* down when it isn't obvious from the rule. A rule whose reason is recorded survives
|
||||
contact with a case it didn't anticipate; a bare prohibition gets worked around.
|
||||
@@ -0,0 +1,96 @@
|
||||
# Rules
|
||||
|
||||
What must never be broken, and nothing else.
|
||||
|
||||
> Last verified: 2026-08-02 against commit `51e86a1`.
|
||||
|
||||
---
|
||||
|
||||
## The tiering rule
|
||||
|
||||
Three tiers, and a rule lives in exactly one of them.
|
||||
|
||||
| Tier | Where | What goes in it | Budget |
|
||||
| --- | --- | --- | --- |
|
||||
| **Hard rules** | [root CLAUDE.md](../../CLAUDE.md) · [client/CLAUDE.md](../../client/CLAUDE.md) · [server/CLAUDE.md](../../server/CLAUDE.md) | Constraints whose violation breaks the build, the gate, or a business invariant. Imperative, no explanation. | ≤250 lines each |
|
||||
| **Reference** | `docs/rules/{shared,client,server}/*.md` — here | The *how* and the *why*. Read on demand when you are working in that area. | ≤400 lines per file |
|
||||
| **Procedure** | `.claude/skills/` | Step-by-step playbooks for recurring tasks: [frontend-designer](../../.claude/skills/frontend-designer/SKILL.md) (design), [backend-feature](../../.claude/skills/backend-feature/SKILL.md) (adding a server feature), [flow-testing](../../.claude/skills/flow-testing/SKILL.md) (walking a flow end to end). | — |
|
||||
|
||||
The test: **a rule that only matters once you are already editing theme code is reference.** A rule like
|
||||
"never change `Seams:FieldEncryption:Key`" is hard — it belongs inline where nobody can miss it.
|
||||
|
||||
So: open the `CLAUDE.md` for the side you are editing, then open **one** file below for the area you are
|
||||
touching. Not both trees, not every file.
|
||||
|
||||
---
|
||||
|
||||
## Reference files
|
||||
|
||||
### Shared — both projects
|
||||
|
||||
| File | Covers |
|
||||
| --- | --- |
|
||||
| [shared/naming.md](shared/naming.md) | `Baya*` vs `balinyaar-client`, the `@/*` alias, file and directory conventions |
|
||||
| [shared/git-and-gates.md](shared/git-and-gates.md) | Branches, commits, what "done" means per project |
|
||||
| [shared/code-quality.md](shared/code-quality.md) | No dead code, comment the *why*, no starter scaffolding, the seam rule for mocks |
|
||||
|
||||
### Client — `client/`
|
||||
|
||||
| Working on… | Read |
|
||||
| --- | --- |
|
||||
| Routes, layouts, the RSC/client boundary, page metadata | [client/structure.md](client/structure.md) |
|
||||
| Colors, tokens, dark mode, RTL, fonts, motion | [client/theme.md](client/theme.md) |
|
||||
| The `App*` library, the icon registry, shells and navigation | [client/components.md](client/components.md) |
|
||||
| Any form | [client/forms.md](client/forms.md) |
|
||||
| Copy, translations, Persian orthography | [client/i18n.md](client/i18n.md) |
|
||||
| Fetching, TanStack Query, the `services/{domain}` pattern, money display | [client/services.md](client/services.md) |
|
||||
| Cookies, sessions, refresh, `RoleGuard`, middleware | [client/auth.md](client/auth.md) |
|
||||
| Tests, ESLint, the type gate | [client/testing.md](client/testing.md) |
|
||||
|
||||
### Server — `server/`
|
||||
|
||||
| Working on… | Read |
|
||||
| --- | --- |
|
||||
| Projects, layers, startup wiring, the seam catalogue | [server/structure.md](server/structure.md) |
|
||||
| Adding a feature (command/query/handler/validator/controller) | [server/cqrs.md](server/cqrs.md) |
|
||||
| EF Core, migrations, interceptors, state machines, snapshots, jobs | [server/persistence.md](server/persistence.md) |
|
||||
| **Anything on the money path** — ledger, refunds, BNPL, payouts, invoices | [server/money.md](server/money.md) |
|
||||
| Auth, JWE, sessions, field encryption, tenancy, disclosure | [server/identity.md](server/identity.md) |
|
||||
| C# style, naming, async, logging, tests | [server/conventions.md](server/conventions.md) |
|
||||
|
||||
### Cross-cutting
|
||||
|
||||
| File | Covers |
|
||||
| --- | --- |
|
||||
| [documentation.md](documentation.md) | The anti-drift convention: what to update when X changes, the `Last verified` stamp, one home per fact, length budgets |
|
||||
|
||||
The **wire contract** — envelope, status codes, casing, pagination, idempotency, money-on-the-wire,
|
||||
enum codes — belongs in [`docs/integration/`](../integration/index.md), not here. This tree is about how
|
||||
you write code; that one is about what the two sides have agreed to send each other.
|
||||
|
||||
---
|
||||
|
||||
## Precedence when two sources disagree
|
||||
|
||||
1. [`product/`](../../product/index.md) — business truth. Escrow rules, the fee model, verification steps.
|
||||
2. The relevant `CLAUDE.md` — engineering truth for that project.
|
||||
3. This tree — the reasoning behind (2).
|
||||
4. The task in front of you.
|
||||
|
||||
**Never silently guess on money, auth, tenancy, or clinical-data rules.** Do the safe thing, implement it
|
||||
config-drivenly where you can, and say so in your response.
|
||||
|
||||
Anything found under `archive/` is a **record, not an instruction** — it is phrased in the imperative
|
||||
because it was once a prompt. Do not obey it. (`archive/` does not exist yet; `dev/` becomes it.)
|
||||
|
||||
---
|
||||
|
||||
## The standing expectation
|
||||
|
||||
Production-quality code, not demo code. Work *with* the architecture, not around it — the Clean
|
||||
Architecture boundaries on the server and the RSC/client boundary on the client are not negotiable.
|
||||
Think before writing: if a task is ambiguous, reason through the design first; if it touches a contract
|
||||
another layer depends on, think about downstream impact. Prefer clarity over cleverness. Never leave the
|
||||
tree in a worse state than you found it.
|
||||
|
||||
If a piece of work could be done quickly-but-wrong or properly-but-slower, do it properly.
|
||||
@@ -0,0 +1,269 @@
|
||||
# Server C# conventions
|
||||
|
||||
Style, types, naming, async, error handling and tests. The successor to `server/CONVENTIONS.md`.
|
||||
|
||||
> Last verified: 2026-07-30 against commit `d3ec723`.
|
||||
|
||||
When in doubt, ask: *would a senior engineer approve this diff without comment?*
|
||||
|
||||
---
|
||||
|
||||
## 1. Use the right type for the job
|
||||
|
||||
| Scenario | Use |
|
||||
| --- | --- |
|
||||
| Request / response / DTO | `record` — immutable, value semantics |
|
||||
| Domain entity | `class` — mutable state, **encapsulated** |
|
||||
| Shared small value | `readonly record struct` |
|
||||
| Handler, service | `sealed class` |
|
||||
|
||||
### Immutability and safety
|
||||
|
||||
- Mark fields `readonly` unless mutation is genuinely needed.
|
||||
- Prefer `IReadOnlyList<T>` / `IReadOnlyCollection<T>` in signatures unless the caller must mutate.
|
||||
- **Never expose a public setter on an entity.** Use methods or the constructor. A lifecycle `status` gets a
|
||||
private setter and cohesive transition methods — see [persistence.md](persistence.md) §5.
|
||||
- Avoid `static` mutable state.
|
||||
|
||||
### Null handling
|
||||
|
||||
- `<Nullable>enable</Nullable>` in any new project.
|
||||
- Guard clauses at the entry point; don't scatter null checks through a method.
|
||||
- Prefer `OperationResult.NotFoundResult(...)` over returning `null` from a handler.
|
||||
- **Never `null!`** unless you can prove the value cannot be null and the compiler cannot.
|
||||
|
||||
### Use the language
|
||||
|
||||
```csharp
|
||||
// primary constructor (C# 12)
|
||||
public sealed class OrderHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<…> { }
|
||||
|
||||
// switch expression over an if/else chain
|
||||
var label = status switch
|
||||
{
|
||||
OrderStatus.Pending => "Pending",
|
||||
OrderStatus.Shipped => "Shipped",
|
||||
OrderStatus.Cancelled => "Cancelled",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(status)),
|
||||
};
|
||||
|
||||
// pattern matching
|
||||
if (result is { IsSuccess: false, IsNotFound: true }) return NotFound();
|
||||
|
||||
// collection expressions (C# 12)
|
||||
List<string> tags = ["new", "sale"];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Naming
|
||||
|
||||
| Kind | Convention | Example |
|
||||
| --- | --- | --- |
|
||||
| Class, record, interface | PascalCase | `OrderHandler`, `IOrderRepository` |
|
||||
| Method | PascalCase | `GetUserOrdersAsync` |
|
||||
| Parameter, local | camelCase | `orderId`, `userEmail` |
|
||||
| Private field | `_camelCase` | `_unitOfWork` |
|
||||
| Constant | PascalCase | `MaxRetryCount` |
|
||||
| Generic type parameter | `T`, or descriptive `TEntity` | |
|
||||
| Command | `{Verb}{Noun}Command` | `CreateOrderCommand` |
|
||||
| Query | `{Verb}{Noun}Query` | `GetUserOrdersQuery` |
|
||||
| Handler | `{RequestName}Handler` | `CreateOrderCommandHandler` |
|
||||
| Result DTO | `{RequestName}Result` | `CreateOrderCommandResult` |
|
||||
|
||||
No abbreviations unless universally understood (`dto`, `id`, `url`). No Hungarian notation (`strName`,
|
||||
`intCount`).
|
||||
|
||||
The `Baya.*` prefix is project naming, not the brand — see [shared/naming.md](../shared/naming.md).
|
||||
|
||||
---
|
||||
|
||||
## 3. Routing
|
||||
|
||||
All URL segments are `snake_case`. `SnakeCaseParameterTransformer` (`Baya.WebFramework/Routing/`) is
|
||||
registered globally via `RouteTokenTransformerConvention` and converts `[controller]` and `[action]` tokens
|
||||
automatically.
|
||||
|
||||
```csharp
|
||||
// ✅ the transformer converts MyFeature → my_feature, GetBySlug → get_by_slug
|
||||
[Route("api/v{version:apiVersion}/[controller]")]
|
||||
public sealed class MyFeatureController : BaseController
|
||||
{
|
||||
[HttpGet("[action]")]
|
||||
public Task<IActionResult> GetBySlug(…) { }
|
||||
}
|
||||
|
||||
// ❌ hardcoded segments bypass the transformer and escape snake_case enforcement
|
||||
[Route("api/v{version:apiVersion}/MyFeature")]
|
||||
[HttpGet("GetBySlug")]
|
||||
```
|
||||
|
||||
**If a method name doesn't read cleanly as a URL, rename the method.** Don't hardcode the route string — it
|
||||
also breaks the dynamic-permission key, which is derived from the same route values.
|
||||
|
||||
The controller skeleton and authorization table are in [cqrs.md](cqrs.md) §4.
|
||||
|
||||
---
|
||||
|
||||
## 4. Async / await
|
||||
|
||||
```csharp
|
||||
// ✅ async all the way — no .Result, no .Wait()
|
||||
public async ValueTask<OperationResult<T>> Handle(MyQuery request, CancellationToken ct)
|
||||
{
|
||||
var entity = await _repository.GetAsync(request.Id, ct);
|
||||
return OperationResult<T>.SuccessResult(_mapper.Map(entity));
|
||||
}
|
||||
|
||||
// ❌ blocks the thread, risks deadlock
|
||||
var result = _repository.GetAsync(id).Result;
|
||||
|
||||
// ❌ fire and forget with no error handling
|
||||
_ = DoSomethingAsync();
|
||||
```
|
||||
|
||||
- **Every public async method accepts a `CancellationToken` and passes it downstream** — including into
|
||||
`SaveChangesAsync(ct)` and `sender.Send(command, ct)`.
|
||||
- Use **`ValueTask<T>`** for hot paths (handlers, repositories); `Task<T>` for rarely-called or always-async
|
||||
methods.
|
||||
- **Never `async void`** — it swallows exceptions. Use `async Task` even for an event-like callback.
|
||||
- **Do not add `.ConfigureAwait(false)`** in this ASP.NET Core app. It is unnecessary here and adds noise.
|
||||
|
||||
---
|
||||
|
||||
## 5. Error handling and logging
|
||||
|
||||
```csharp
|
||||
// ✅ expected failure — return, don't throw
|
||||
if (user is null)
|
||||
return OperationResult<T>.NotFoundResult("User not found.");
|
||||
|
||||
// ❌ swallowing an exception into a generic failure
|
||||
try { … } catch { return OperationResult<T>.FailureResult(…); }
|
||||
```
|
||||
|
||||
The global `ExceptionHandler` middleware catches unhandled exceptions and logs them. **Do not add a try/catch
|
||||
for unknown exceptions in a handler** — let them propagate. Catch only what you can actually handle.
|
||||
|
||||
Logging rules are in [identity.md](identity.md) §9: structured templates, no PII or secrets, correct level.
|
||||
|
||||
---
|
||||
|
||||
## 6. Validation
|
||||
|
||||
- Every command that accepts user input needs a FluentValidation validator. `ValidateCommandBehavior` runs it
|
||||
automatically before the handler, and `RegisterValidatorsAsServices()` registers them.
|
||||
- **Validate at the boundary** — the command or query — not deep in the domain or a repository.
|
||||
- **Never validate a route-supplied id in the body command.** See [cqrs.md](cqrs.md) §3.
|
||||
|
||||
---
|
||||
|
||||
## 7. Mapping — Mapster
|
||||
|
||||
- Use the injected `IMapper` for entity↔DTO mapping **in handlers**.
|
||||
- Register type-adapter configs in `Program.cs` via `TypeAdapterConfig.GlobalSettings.Scan(...)`; add new
|
||||
assemblies containing mapping configs there.
|
||||
- Never write manual mapping code where Mapster can infer it. Only write a custom `TypeAdapterConfig` when
|
||||
shapes genuinely diverge.
|
||||
- **Mapping happens in the handler after the DB query**, never in the repository — the repository projects.
|
||||
|
||||
---
|
||||
|
||||
## 8. Testing
|
||||
|
||||
### Arrange — Act — Assert, always
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task CreateOrder_ValidCommand_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var command = new CreateOrderCommand(UserId: 1, Items: [new(ProductId: 5, Quantity: 2)]);
|
||||
var handler = new CreateOrderCommandHandler(_unitOfWork, _mapper);
|
||||
|
||||
// Act
|
||||
var result = await handler.Handle(command, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Result.Should().NotBeNull();
|
||||
}
|
||||
```
|
||||
|
||||
- **Test the handler directly**, not the controller — controllers are thin wrappers.
|
||||
- **`NSubstitute`** for mocking: `Substitute.For<IUnitOfWork>()`.
|
||||
- **Persistence tests use the in-memory SQLite context** from `Baya.Tests.Setup` rather than mocking the DB.
|
||||
- Name tests `{MethodUnderTest}_{Scenario}_{ExpectedOutcome}`.
|
||||
- One assertion *concept* per test. Multiple `.Should()` calls are fine if they verify the same outcome.
|
||||
- **Don't test EF internals** (tracking, migrations) — test behaviour through the handler.
|
||||
|
||||
### Integration tests — the HTTP pipeline
|
||||
|
||||
Handler tests leave the whole HTTP stack untested: routing, the auth pipeline, middleware, and the
|
||||
`OperationResult → IActionResult` translation. **Each feature area needs at least one
|
||||
`WebApplicationFactory<Program>` test** in `Baya.Test.Api` (environment `Testing`, in-memory SQLite) covering:
|
||||
|
||||
1. **Happy path** — an authenticated request returns 200 with the right body shape.
|
||||
2. **Unauthenticated** — returns 401.
|
||||
3. **Validation failure** — returns 400 with field-level error detail.
|
||||
|
||||
```csharp
|
||||
public class MyFeatureApiTests(WebApplicationFactory<Program> factory)
|
||||
: IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetSomething_Authenticated_Returns200()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", TestTokens.ValidAdminToken);
|
||||
|
||||
var response = await client.GetAsync("/api/v1/my_feature/get_something");
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The recurring-job scheduler is **dormant under `Testing`**, so a background tick can't make an integration
|
||||
test flaky.
|
||||
|
||||
---
|
||||
|
||||
## 9. Service registration
|
||||
|
||||
- Every new infrastructure service gets an extension method in that project's `ServiceConfiguration/` folder,
|
||||
called from `Program.cs`. **No inline DI registration in `Program.cs`.**
|
||||
- Lifetimes: **Singleton** for stateless, thread-safe services (`IHttpContextAccessor`, `IFieldEncryptor` —
|
||||
which *must* be a singleton, see [identity.md](identity.md) §3); **Scoped** for per-request services
|
||||
(repositories, `DbContext`, handlers); **Transient** for lightweight stateless ones (validators,
|
||||
transformers).
|
||||
- **All NuGet versions live only in `Directory.Packages.props`.** Never add `Version=` to a
|
||||
`<PackageReference>` in a `.csproj`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Code organisation
|
||||
|
||||
- **One type per file**, file name matching the type name exactly.
|
||||
- Handlers and validators live in the **same feature folder** — not in a root `Handlers/` or `Validators/`.
|
||||
- A file over **~150 lines** usually means mixed concerns. Consider splitting it.
|
||||
- **Partial classes are only for generated code** (source generators, EF scaffolding) — and the one deliberate
|
||||
exception, `DemoLifecycleSeeder`'s `.Money.cs`/`.Social.cs` partials, which split a Development-only seeder
|
||||
by domain.
|
||||
- **`Program.cs` stays an orchestrator** — extension-method calls only, no logic.
|
||||
|
||||
---
|
||||
|
||||
## 11. No unused code, and comment the *why*
|
||||
|
||||
Both are shared rules with real teeth on this side: the gate is **zero new warnings**, and `CS0168` / `CS0219`
|
||||
/ `CS0169` / `IDE0005` all surface dead code. **Delete it — don't `#pragma warning disable` it.**
|
||||
|
||||
The one exception: a parameter that must exist to satisfy an interface or delegate signature but is genuinely
|
||||
unused. Keep it, name it conventionally, and add a one-line `// why` only if the reason isn't obvious.
|
||||
|
||||
Full rules, with examples of a comment that earns its place: [shared/code-quality.md](../shared/code-quality.md).
|
||||
|
||||
Known pre-existing warnings that must **not** be fixed unless a task says so:
|
||||
[shared/git-and-gates.md](../shared/git-and-gates.md) §5.
|
||||
@@ -0,0 +1,149 @@
|
||||
# How a server feature is shaped
|
||||
|
||||
Adding a command, a query, a validator, and the controller action that reaches them.
|
||||
|
||||
> Last verified: 2026-07-30 against commit `d3ec723`.
|
||||
|
||||
---
|
||||
|
||||
## 1. The dispatcher is not MediatR
|
||||
|
||||
CQRS runs on **`martinothamar/Mediator`** — a source-generator-based dispatcher. Use `ISender` / `ICommand` /
|
||||
`IQuery` from that package. Any prose anywhere that says "MediatR" is wrong; do not add MediatR types or
|
||||
`IMediator`.
|
||||
|
||||
---
|
||||
|
||||
## 2. The folder shape
|
||||
|
||||
```
|
||||
Baya.Application/Features/<Area>/
|
||||
├── Commands/<VerbNoun>Command/
|
||||
│ ├── <VerbNoun>Command.cs record : IRequest<OperationResult<T>>
|
||||
│ ├── <VerbNoun>Command.Handler.cs internal sealed class : IRequestHandler<…>
|
||||
│ └── <VerbNoun>Command.Validator.cs AbstractValidator<Command> (omit when there is nothing to validate)
|
||||
└── Queries/<VerbNoun>Query/
|
||||
├── <VerbNoun>Query.cs
|
||||
├── <VerbNoun>Query.Handler.cs
|
||||
└── <VerbNoun>Query.Result.cs record Result(…) ← the DTO returned
|
||||
```
|
||||
|
||||
`Features/System/Queries/Ping/` is the minimal live example — query, handler, result — surfaced by
|
||||
`Controllers/V1/PingController`.
|
||||
|
||||
One type per file, and the file name matches the type name.
|
||||
|
||||
---
|
||||
|
||||
## 3. The rules
|
||||
|
||||
- **Requests are `record`s** — immutable, value semantics.
|
||||
- **Handlers are `internal sealed`** — they are never used outside the Application layer.
|
||||
- **Exactly one handler per request type.** No conditional dispatch.
|
||||
- **Never throw for an expected failure.** Return an `OperationResult`:
|
||||
|
||||
| Factory | Maps to |
|
||||
| --- | --- |
|
||||
| `OperationResult<T>.SuccessResult(value)` | 200 |
|
||||
| `OperationResult<T>.FailureResult(errors)` | 400 — validation or business-rule failure, with field-level detail |
|
||||
| `OperationResult<T>.NotFoundResult(message)` | 404 |
|
||||
| `OperationResult.ConflictResult(message)` | 409 — idempotency, duplicate, or an illegal state transition |
|
||||
|
||||
Let a genuinely *unexpected* exception propagate to the global `ExceptionHandler` middleware. Don't
|
||||
try/catch unknown exceptions in a handler, and never swallow one into a `FailureResult`.
|
||||
|
||||
- **Contracts the handler depends on are interfaces in `Application/Contracts/`**, implemented in
|
||||
Infrastructure. A handler never references a concrete infrastructure type.
|
||||
|
||||
- **Validators are FluentValidation** `AbstractValidator<TRequest>`, auto-registered from the Application
|
||||
assembly by `AddApplicationServices` and run by the `ValidateCommandBehavior` pipeline behavior before the
|
||||
handler. Validate **at the boundary** — the command or query — not deep in the domain or a repository.
|
||||
|
||||
```csharp
|
||||
public sealed class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
|
||||
{
|
||||
public CreateOrderCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.UserId).GreaterThan(0);
|
||||
RuleFor(x => x.Items).NotEmpty().WithMessage("Order must have at least one item.");
|
||||
RuleForEach(x => x.Items).ChildRules(item =>
|
||||
{
|
||||
item.RuleFor(i => i.ProductId).GreaterThan(0);
|
||||
item.RuleFor(i => i.Quantity).InclusiveBetween(1, 100);
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**A route-supplied id must NOT be validated in the body command.** Route values (e.g.
|
||||
`patients/update/{id}`) aren't bound into the body, so a `GreaterThan(0)` on them fails every request.
|
||||
|
||||
- **Pipeline order is Logging → Metrics → Validate.** A new behavior slots into that chain in
|
||||
`AddApplicationServices`, not into a handler.
|
||||
|
||||
---
|
||||
|
||||
## 4. The controller
|
||||
|
||||
Every controller follows this skeleton:
|
||||
|
||||
```csharp
|
||||
[ApiVersion("1")]
|
||||
[ApiController]
|
||||
[Route("api/v{version:apiVersion}/[controller]")]
|
||||
[Display(Description = "One-line description shown in Swagger")]
|
||||
[Authorize(ConstantPolicies.DynamicPermission)] // or [Authorize], or omit for public
|
||||
public sealed class MyFeatureController(ISender sender) : BaseController
|
||||
{
|
||||
[HttpGet("[action]")]
|
||||
[ProducesOkApiResponseType<MyQueryResult>]
|
||||
public async Task<IActionResult> GetSomething(CancellationToken ct)
|
||||
=> OperationResult(await sender.Send(new MyQuery(), ct));
|
||||
|
||||
[HttpPost("[action]")]
|
||||
[ProducesOkApiResponseType<MyCommandResult>]
|
||||
public async Task<IActionResult> CreateSomething(MyCommand command, CancellationToken ct)
|
||||
=> OperationResult(await sender.Send(command, ct));
|
||||
}
|
||||
```
|
||||
|
||||
- **`sealed`.** Controllers are not designed for inheritance beyond `BaseController`.
|
||||
- **Inject `ISender` via the primary constructor**, not `IMediator`.
|
||||
- **Never call `Ok()`, `BadRequest()`, or `NotFound()` directly.** Always `base.OperationResult(result)` —
|
||||
that is what maps `OperationResult` (including 401/403/409) onto the envelope every client already parses.
|
||||
- **Keep the method thin: one `Send`, one `OperationResult`.** No business logic in a controller.
|
||||
- **Use `[Display(Description = "…")]`** so NSwag generates meaningful Swagger tags.
|
||||
- **Pass the `CancellationToken`** from the action into `sender.Send(...)`.
|
||||
- **Route segments come from `[controller]`/`[action]` tokens**, which `SnakeCaseParameterTransformer`
|
||||
converts. Never hardcode a route string — that bypasses the transformer. If a method name doesn't read
|
||||
cleanly as a URL, **rename the method**.
|
||||
|
||||
### Authorization — the narrowest that fits
|
||||
|
||||
| Attribute | When |
|
||||
| --- | --- |
|
||||
| *(none)* | Truly public — health check, metrics, a webhook (which is signature-verified instead) |
|
||||
| `[Authorize]` | Any authenticated user |
|
||||
| `[Authorize(ConstantPolicies.DynamicPermission)]` | A role/claim-gated admin action |
|
||||
| `[RequireTokenWithoutAuthorization]` | A token must be present but may be expired — the refresh endpoint |
|
||||
|
||||
Apply at the **controller** level for a uniform policy; override at the action level only for a genuine
|
||||
exception. Least privilege: an admin action gets `DynamicPermission`, not a bare `[Authorize]`.
|
||||
|
||||
Rate-limit the sensitive ones — see [identity.md](identity.md) §5.
|
||||
|
||||
---
|
||||
|
||||
## 5. To add a feature
|
||||
|
||||
1. Create the folder under `Features/<Area>/{Commands|Queries}/<VerbNoun>/`.
|
||||
2. Implement the request, the handler, and a validator if it takes input.
|
||||
3. Add any new dependency as an interface in `Application/Contracts/`, and implement it in Infrastructure —
|
||||
mock and real both, if it is an external rail. See [structure.md](structure.md) §3.
|
||||
4. Wire a controller action to `sender.Send(...)`.
|
||||
5. Add handler unit tests (NSubstitute) **and** at least one `WebApplicationFactory` integration test for the
|
||||
area: happy path 200, unauthenticated 401, validation 400. See [conventions.md](conventions.md) §5.
|
||||
6. Publish the endpoint's contract to [`docs/integration/`](../../integration/index.md).
|
||||
|
||||
If the feature adds a table, read [persistence.md](persistence.md) first — the money, snapshot, state-machine
|
||||
and soft-delete rules there are invariants, not suggestions.
|
||||
@@ -0,0 +1,218 @@
|
||||
# Server identity, encryption and disclosure
|
||||
|
||||
Auth, JWE, sessions, field encryption, tenancy, and the two-stage clinical disclosure rule.
|
||||
|
||||
> Last verified: 2026-08-02 against commit `51e86a1`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Phone-OTP is the public login
|
||||
|
||||
There is no username/password path for a normal user. `Controllers/V1/AuthController`
|
||||
(`request_otp` / `verify_otp` / `refresh` / `logout`) plus `MeController` (`/me`, `select_role`) drive the
|
||||
`Features/Identity/` slices.
|
||||
|
||||
OTP delivery goes through the **`ISmsSender`** seam. The mock (`LoggingSmsSender`) logs the code; the real
|
||||
rails are config-selected — see [structure.md](structure.md) §3.
|
||||
|
||||
### The OTP-capture bridge
|
||||
|
||||
`AddDevelopmentOtpCapture()` decorates the registered `ISmsSender` to capture each OTP in memory for
|
||||
`GET /api/v1/dev/last_otp/{phone}`. It is:
|
||||
|
||||
- **never wired outside Development**, and
|
||||
- **only** wired for a capture-safe provider — `mock`/unset, or the Development-only `telegram` relay.
|
||||
|
||||
**A real gateway (`kavenegar`) disables it**, so a production OTP only ever leaves the process over the SMS
|
||||
wire. `TelegramSmsSender` is the one non-mock provider that keeps the bridge enabled, because it is a
|
||||
**broadcast, not a gateway**: it pushes every code to a fixed list of chat ids so a human tester can read them
|
||||
without grepping logs. Its API key is not committed.
|
||||
|
||||
`DevController` returns 404 outside Development.
|
||||
|
||||
---
|
||||
|
||||
## 2. Tokens and sessions
|
||||
|
||||
- **JWE** — a signed *and* AES-128-encrypted JWT — issued by `IJwtService`
|
||||
(`Baya.Infrastructure.Identity/Jwt/JwtService.cs`). `GenerateAccessTokenAsync` mints an access token only
|
||||
(the REST flow); the legacy `GenerateAsync` additionally writes a `UserRefreshTokens` row and still feeds the
|
||||
gRPC path.
|
||||
- **Every login creates a revocable `usr.UserSessions` row** storing **only the refresh token's
|
||||
`IFieldEncryptor.Hash`** — never the token itself.
|
||||
- **Refresh rotates**: the old session is revoked and a new pair issued.
|
||||
- **A replayed or revoked token revokes ALL of the user's sessions** and returns 401. This is reuse detection,
|
||||
and it is the reason the client's silent refresh is single-flight.
|
||||
- **Logout revokes the session AND rotates the security stamp**, so outstanding access tokens fail the JWE
|
||||
`OnTokenValidated` stamp check. Revoking the session alone would leave a valid access token live for up to
|
||||
its full lifetime.
|
||||
|
||||
Settings bind from `appsettings.json` → `IdentitySettings`. `RequireHttpsMetadata` is **on outside
|
||||
Dev/Testing** (passed into `RegisterIdentityServices`), the access-token lifetime is `ExpirationMinutes: 60`,
|
||||
and `Issuer`/`Audience` are real (`Balinyaar` / `BalinyaarClient`).
|
||||
|
||||
**`SecretKey` and `Encryptkey` belong in the environment-specific file**, never in the base
|
||||
`appsettings.json`, which stays at its `StartupSecretsGuard`-rejected placeholder. **Never hardcode a secret
|
||||
in C#** — keys, connection strings and tokens come from configuration bound to typed settings, never a literal
|
||||
in a handler or service.
|
||||
|
||||
---
|
||||
|
||||
## 3. Encrypted PII
|
||||
|
||||
`users.PhoneNumber` / `Email` / `NationalId` are encrypted at rest through an EF value converter over
|
||||
**`IFieldEncryptor`**, wired in `ApplicationDbContext.OnModelCreating`.
|
||||
|
||||
Two consequences that are easy to get wrong:
|
||||
|
||||
- **The encryptor must stay a process-wide singleton**, because EF caches the model. A scoped encryptor gives
|
||||
you a model whose converters point at a disposed instance.
|
||||
- **Equality lookups go through the deterministic `PhoneHash` column** (UNIQUE, synced on `SaveChanges` —
|
||||
which also resets `ShahkarVerifiedAt` when the phone actually changes). **Never query `PhoneNumber == x`**:
|
||||
the ciphertext is not deterministic, so the comparison silently matches nothing.
|
||||
|
||||
### What else is encrypted
|
||||
|
||||
| Column | Notes |
|
||||
| --- | --- |
|
||||
| `customer_profiles` emergency contact | |
|
||||
| `patients.initial_medical_notes` | |
|
||||
| `customer_addresses` — address line, postal code, recipient name/phone | Decrypted **only in the owner's own read** |
|
||||
| `nurse_bank_accounts.iban` | Plus `UNIQUE(iban_hash)` as a deterministic-hash duplicate guard |
|
||||
| `nurse_payouts.iban_snapshot` | `[AuditRedacted]`, frozen from the verified primary account |
|
||||
| `partner_centers.settlement_iban` | `[AuditRedacted]`, **masked to last 4 in every read** |
|
||||
| `payment_gateways.config_json` | |
|
||||
| `booking_care_instructions` — every field | See §6 |
|
||||
| `patient_care_records.body_encrypted` | Ciphertext with **no EF value converter** — the handler encrypts on write and decrypts only *after* the access check passes |
|
||||
| `messaging.TicketMessages.Body` | Ticket bodies are the refund/dispute paper trail — phone numbers, addresses, clinical detail. Column widened to `nvarchar(max)`; the 4000-char cap stays a boundary-validation rule |
|
||||
|
||||
Annotate any encrypted or PII property with **`[AuditRedacted]`** so the audit diff records a marker rather
|
||||
than plaintext.
|
||||
|
||||
> `Seams:FieldEncryption:Key` and `:HashKey` are **load-bearing and must never change.** They decrypt all
|
||||
> existing PII and derive the phone-lookup hash. Rotating them without a re-encryption migration makes every
|
||||
> PII read throw and every phone lookup miss.
|
||||
|
||||
---
|
||||
|
||||
## 4. Roles and permissions
|
||||
|
||||
The full vocabulary is in `Domain/Entities/User/RoleNames`.
|
||||
|
||||
- **`SeedDataBase` always seeds the roles**, and seeds a **bootstrap admin only when
|
||||
`Seed:AdminUsername`/`Seed:AdminPassword` are configured** — break-glass only. There is no committed
|
||||
`admin`/`qw123321` any more. Day-to-day admins come from the phone-OTP demo seeds or are provisioned
|
||||
out-of-band.
|
||||
- **`customer` and `nurse` are self-selectable** via `POST me/select_role` — audited (`granted_by`,
|
||||
`granted_at`), idempotent, and **both can be held** by one user (a dual session moves freely between the
|
||||
family and nurse apps).
|
||||
- **Admin sub-roles are internal-only** and `select_role` returns **403** for them. Never build a flow that
|
||||
implies a user can grant themselves an admin role.
|
||||
- **`user_roles.revoked_at` has a global query filter**, so a revoked grant disappears from every role read
|
||||
automatically.
|
||||
- The **dynamic permission system** (`DynamicPermissionHandler`) reads the `[controller]` + `[action]` route
|
||||
values and checks role claims. **Always use the tokens** so the permission keys stay consistent — a
|
||||
hardcoded route string produces a key nothing grants.
|
||||
|
||||
Auth knobs — `auth_otp_resend_seconds`, `auth_otp_max_attempts`, `auth_session_ttl_days` — are
|
||||
`platform_configs` rows read via `IPlatformConfig`, not constants.
|
||||
|
||||
**`nurse_profiles.is_verified` has no public setter.** It is flipped only by the verification pipeline's
|
||||
guarded cross-aggregate transition — see [persistence.md](persistence.md) §5.
|
||||
|
||||
---
|
||||
|
||||
## 5. Rate limiting
|
||||
|
||||
Auth and OTP endpoints **must** be rate-limited, using ASP.NET Core's built-in limiter (no extra package).
|
||||
|
||||
| Endpoint | Policy |
|
||||
| --- | --- |
|
||||
| `request_otp`, `verify_otp` | `otp`, plus a per-phone resend window via `ICacheService` |
|
||||
| `refresh` | `auth` |
|
||||
| The PSP and BNPL webhooks | the single deliberate `webhook` policy — bursty-tolerant, partitioned **per provider** |
|
||||
| Admin money/trust actions | `sensitive` |
|
||||
| Everything else | the per-resolved-IP global policy |
|
||||
|
||||
Behind a reverse proxy the limiter partitions on the **forwarded** client IP, which is why
|
||||
`UseForwardedHeaders()` runs first and `UseRateLimiter()` runs before `UseAuthentication()`. See
|
||||
[structure.md](structure.md) §4.
|
||||
|
||||
---
|
||||
|
||||
## 6. Two-stage clinical disclosure
|
||||
|
||||
This is the platform's central privacy invariant. A nurse learns progressively more about a patient as the
|
||||
engagement becomes real, and each stage is enforced **at the query layer**.
|
||||
|
||||
| Stage | When | What the nurse can see |
|
||||
| --- | --- | --- |
|
||||
| **1** — a booking request | Before payment | **Only** the unencrypted, limited `customer_notes` — never routed through `IFieldEncryptor`. The full address is **masked** to a coarse city/district: no line, no postal code, no recipient |
|
||||
| **2** — a confirmed booking | After capture | `booking_care_instructions` (every field encrypted), readable **only post-confirmation** and **only** by the **assigned nurse + admin**. `GetCareInstructionsQuery` enforces it |
|
||||
|
||||
Stage-2 fields are **never projected into a list and never logged.**
|
||||
|
||||
`patient_care_records` are **patient-scoped, not booking-scoped**, encrypted, and behind a strict access check:
|
||||
the owning customer, a nurse with a confirmed booking for that patient, or an admin. Anyone else gets **403**.
|
||||
The handler decrypts only *after* the check passes.
|
||||
|
||||
---
|
||||
|
||||
## 7. Tenancy
|
||||
|
||||
**Child rows must belong to the caller.** A patient and an address must be in the caller's `customer_id`; a
|
||||
variant must belong to the requested `nurse_id`.
|
||||
|
||||
Two rules:
|
||||
|
||||
- **Resolve the owner from `ICurrentUser`, never from the request body.** A body-supplied `customer_id` is an
|
||||
authorization bypass waiting to happen.
|
||||
- **A mismatch is a clean 404, never a 403 and never a leak.** A 403 confirms the row exists.
|
||||
|
||||
The same applies to a cross-tenant booking on a review submit, and to the partner portal: a centre resolves
|
||||
from the caller, never from a raw id in the URL.
|
||||
|
||||
**`INotificationService` and the notification endpoints are always tenant-scoped to `ICurrentUser`.**
|
||||
`support_alerts` are **admin-only and must never appear on a user-facing route.**
|
||||
|
||||
---
|
||||
|
||||
## 8. `is_internal` is a hard visibility boundary
|
||||
|
||||
Ticket messages can be internal staff notes. **The boundary is enforced at the QUERY layer, never in the UI.**
|
||||
|
||||
`GetTicketThreadQuery` takes an `AsAdmin` flag:
|
||||
|
||||
- `false` (the user view) — the repository projection **strips every `is_internal` message**
|
||||
(`GetMessagesAsync(includeInternal: false)`).
|
||||
- `true` (staff only) — returns them.
|
||||
|
||||
A non-staff caller can never *set* `is_internal` on `PostMessage`, and can never *read* one. The client mirrors
|
||||
this by not modelling `is_internal` in its user-app types at all — see
|
||||
[client/services.md](../client/services.md) §5 — but **that is a second layer, not the boundary.**
|
||||
|
||||
Related messaging invariants:
|
||||
|
||||
- **There is no direct nurse↔customer channel.** All post-booking communication is ticket-mediated and
|
||||
admin-readable. Participation (`TicketParticipant`, `UNIQUE(ticket_id, user_id)`, soft-remove via
|
||||
`removed_at`) plus staff status *is* the authorization boundary.
|
||||
- `reference_code` is minted once, collision-checked, UNIQUE, and stable.
|
||||
- `booking_id` and `refund_id` links are both nullable — handle a ticket with neither.
|
||||
- A coordination ticket is auto-created (idempotent, one per booking) on confirmation, dispatched from the card
|
||||
confirm and the BNPL settle handlers. A refund ticket is auto-opened by `CreateRefundCommand` when the caller
|
||||
supplies none, so `refunds.ticket_id` is always non-null.
|
||||
- `LogEmergencyTicket` records the aftermath of an out-of-platform emergency call and **exposes no phone
|
||||
number**. There is no telephony seam by design; the call is a `tel:` link.
|
||||
|
||||
---
|
||||
|
||||
## 9. Logging
|
||||
|
||||
- **Structured logging with message templates**, never string interpolation of values:
|
||||
`_logger.LogInformation("Order {OrderId} created for user {UserId}", order.Id, userId)`.
|
||||
- **Never log passwords, tokens, secrets, or full PII.** Email is borderline — use `userId` in logs instead.
|
||||
- The mock SMS sender **never logs the OTP code**; clinical text and IBANs are encrypted or masked before they
|
||||
could reach a log.
|
||||
- Levels: `Debug` for trace detail, `Information` for meaningful events, `Warning` for recoverable issues,
|
||||
`Error` for unexpected failures. Deployed environments write Information+ to `Baya_Logs`, with framework
|
||||
categories held at Warning.
|
||||
@@ -0,0 +1,244 @@
|
||||
# Server money path
|
||||
|
||||
IRR integers, the append-only ledger, idempotency, and the invariants of refunds, BNPL, payouts and invoices.
|
||||
|
||||
> Last verified: 2026-07-30 against commit `d3ec723`.
|
||||
|
||||
Read this before touching anything under `Features/{Payments,Refunds,Invoices,Bnpl,Payouts}` or the
|
||||
`payments` / `payouts` schemas. Every rule here is enforced in code **and** by a database constraint, and the
|
||||
constraint is the authority.
|
||||
|
||||
---
|
||||
|
||||
## 1. Money is IRR `BIGINT`, integer-only
|
||||
|
||||
**Every monetary value is IRR Rials stored as `long` / `BIGINT`.** There is **no float or decimal path on
|
||||
money** — not in entities, not in DTOs, not in the API, not in arithmetic. If a money value object is ever
|
||||
introduced it must be integer-only.
|
||||
|
||||
- **Toman is display-only**, and converts to/from Rials **only inside a provider adapter at its boundary** —
|
||||
never in domain or shared code.
|
||||
- On the wire, money is a **digit string** (IRR aggregates exceed JS's safe integer range).
|
||||
- Currency is normalized to IRR **at the provider boundary only**, via `ICurrencyNormalizer`.
|
||||
|
||||
### The three booking amounts always reconcile
|
||||
|
||||
```
|
||||
gross_price_irr = balinyaar_commission_irr + nurse_payout_amount (all ≥ 0)
|
||||
```
|
||||
|
||||
This is a **DB CHECK** *and* a handler invariant. Commission is `integer-round(gross × platform_fee_rate)`
|
||||
with the rate **snapshotted onto the booking**; the payout is *derived*, never free-entered.
|
||||
|
||||
And per session: **`Σ(visit_payout_amount) = nurse_payout_amount` exactly** — an integer split with the
|
||||
remainder on the last session (`BookingAmounts`).
|
||||
|
||||
### A rate change is never retroactive
|
||||
|
||||
Money-critical constants — commission percentage, VAT rate, deadlines, cancellation tiers — live in
|
||||
`ops.PlatformConfigs` and are read via `IPlatformConfig.GetConfig<T>`. **Never hardcode one.**
|
||||
|
||||
> **Changing a rate must never retroactively alter an already-computed amount.** The rate is snapshotted at
|
||||
> compute time. Do not live-re-read a rate for an already-priced row.
|
||||
|
||||
---
|
||||
|
||||
## 2. The ledger is the source of truth
|
||||
|
||||
`payments.LedgerEntries` is **append-only**: it implements `IEntity` only, with **no `ITimeModification`** (so
|
||||
the audit interceptor never stamps it) and **no soft delete**. There is no update or delete path.
|
||||
|
||||
Every posting group is **balanced** — Σdebit = Σcredit per `transaction_group_id` — and built through
|
||||
**`LedgerPosting`**, which throws if the frozen amounts don't reconcile. Never hand-write a leg.
|
||||
|
||||
| Posting group | Legs |
|
||||
| --- | --- |
|
||||
| `CardCapture` | DEBIT `escrow_held` gross = CREDIT `platform_revenue` commission + `nurse_payable` payout |
|
||||
| `BnplSettle` | The card-capture legs **plus** DEBIT `bnpl_fee_expense` / CREDIT `escrow_held` for the provider commission |
|
||||
| `RefundReversalPrePayout` | DEBIT `nurse_payable` — a clean reversal |
|
||||
| `ClawbackReversalPostPayout` | DEBIT `nurse_clawback_receivable` — the nurse was already paid |
|
||||
| `RefundPayableClearing` | Posted only once the customer cash-back confirms |
|
||||
| `ClawbackWriteOff` | An admin write-off |
|
||||
| `NursePayout` | DEBIT `nurse_payable` / CREDIT `escrow_held` for the paid net |
|
||||
| `ClawbackRecovery` | DEBIT `nurse_payable` / CREDIT `nurse_clawback_receivable` |
|
||||
|
||||
**Escrow IS the ledger.** `GetNursePayableBalance` is the **signed sum** over `nurse_payable` legs — never a
|
||||
stored column. There is no `payout_released` boolean anywhere: paid-ness is *derived* from a
|
||||
`nurse_payout_booking_links` row plus the ledger.
|
||||
|
||||
The lawful split is **تسهیم via `ISettlementSplitProvider`** to registered IBANs. **The platform never moves
|
||||
money itself.**
|
||||
|
||||
---
|
||||
|
||||
## 3. Idempotency
|
||||
|
||||
Three patterns, all mandatory on this path.
|
||||
|
||||
**Upsert the webhook event first.** `HandlePaymentWebhook` upserts on `(provider_code, external_event_id)` and
|
||||
**no-ops on a duplicate** before doing anything else. On a *new* success event it **re-verifies server-side**
|
||||
(`IPaymentProvider.VerifyAsync`) — never trusting the payload — then dispatches
|
||||
`ConfirmPaymentAndPostLedger`, all under `IDistributedLock("booking-request:{id}:payment")`.
|
||||
**A unique-violation on confirm is an idempotent no-op success, not an error.**
|
||||
|
||||
**Claim first, execute second.** Persist the state claim *before* the external call. The refund row is
|
||||
persisted (approved) before the channel call for exactly this reason — it is the crash-window fix, and it
|
||||
matches the webhook handler's shape. A crash between claim and execute leaves a recoverable record; a crash
|
||||
between execute and claim leaves money moved with nothing recording it.
|
||||
|
||||
**The DB constraint is the authoritative backstop** behind every friendly pre-check. The two filtered uniques
|
||||
on `payment_transactions` — `UNIQUE(gateway_reference_code) WHERE NOT NULL` and
|
||||
`UNIQUE(booking_id) WHERE status='succeeded'` — are the anti-double-capture guard, not the handler's `if`.
|
||||
|
||||
A **forward-only status machine** is the idempotency spine of each money entity: a replayed transition that
|
||||
would re-drive a completed edge is an idempotent no-op. See [persistence.md](persistence.md) §5.
|
||||
|
||||
---
|
||||
|
||||
## 4. Capture and conversion
|
||||
|
||||
- A `bookings` row exists **only** when the nurse accepted **and** payment was captured. So a payment is
|
||||
initiated against the `accepted_awaiting_payment` **request**, and `payment_transactions.booking_id` is
|
||||
**nullable**, bound only when the confirm creates or loads the booking.
|
||||
- A booking request carries **no money and no `bookings` row**. Accept only opens the payment window.
|
||||
- Conversion goes through the shared **`BookingFactory` / `Features/Bookings/BookingConversion`** helper. The
|
||||
card confirm and the BNPL settle both call it rather than re-implementing the split.
|
||||
- `IPaymentCaptureSimulator` is **out of the production registration** — production gets the fail-closed
|
||||
`DisabledPaymentCaptureSimulator`, and the `bookings/convert` path is a Dev/Testing affordance. Production
|
||||
converts through the webhook confirm.
|
||||
|
||||
---
|
||||
|
||||
## 5. Refunds and clawbacks
|
||||
|
||||
A refund **decomposes across both fee legs and reverses the ledger.** `CreateRefundCommand` runs the whole
|
||||
money path under `lock(booking:{id}:refund)`: it reads the booking's frozen split, the cancellation snapshot
|
||||
and the captured transaction, splits `amount = platform_fee_refunded_irr + nurse_payout_refunded_irr`
|
||||
**pro-rata at the resolved percentage**, enforces **`Σ refunded ≤ captured`** as a handler backstop, executes
|
||||
the channel behind its seam, and posts the balanced reversal through `LedgerPosting`.
|
||||
|
||||
The channel-execution and ledger steps are cohesive **private** steps inside the handler, so they stay atomic.
|
||||
|
||||
### The pre-payout / post-payout fork
|
||||
|
||||
`INursePayoutStatus` answers *"was the nurse already paid?"*
|
||||
|
||||
| Answer | What the reversal debits | Plus |
|
||||
| --- | --- | --- |
|
||||
| Not yet paid | `nurse_payable` — a clean reversal | — |
|
||||
| Already paid | `nurse_clawback_receivable` | Opens a `pending` `nurse_clawbacks` row **and** raises a `nurse_clawback` support alert |
|
||||
|
||||
The fork exists because **an Iranian IBAN transfer is irreversible.** Once money has left, the platform holds a
|
||||
receivable, not a reversal.
|
||||
|
||||
The authoritative implementation is `NursePayoutLinkStatusService` — a booking is paid iff it is linked to a
|
||||
`paid` payout.
|
||||
|
||||
### Channel parity
|
||||
|
||||
`psp_card` and `bnpl_revert` post the **same** reversal legs. Only three things differ:
|
||||
|
||||
| | `psp_card` | `bnpl_revert` |
|
||||
| --- | --- | --- |
|
||||
| Initial status | immediate `succeeded` | `processing` |
|
||||
| Clearing | posts now | deferred to reconciliation |
|
||||
| Customer ETA | immediate | `expected_customer_refund_eta` ≈ now + config **business** days (~7–10) |
|
||||
|
||||
The `refund_payable ↔ escrow_held` clearing posts **only once the customer cash-back confirms** — reached by
|
||||
`ConfirmRefundSettlementCommand` (admin `POST admin_refunds/{id}/confirm_settlement`, or the BNPL cash-back
|
||||
callback branch), which transitions `processing → succeeded`, stamps the settled instant, and posts
|
||||
`RefundPayableClearing` in the same commit, idempotently under the refund lock.
|
||||
`MarkRefundSettlementFailedCommand` is the counterpart.
|
||||
|
||||
The canonical wire code for the manual channel is **`manual`** (the data model calls it `manual_bank`).
|
||||
|
||||
**Clawback recovery is the payout engine's job** (§7), not the refund's. A refund only opens the receivable and
|
||||
supports an admin `write_off`.
|
||||
|
||||
`refunds.ticket_id` is always non-null — `CreateRefundCommand` auto-opens a `category=refund` ticket when the
|
||||
caller supplies none.
|
||||
|
||||
---
|
||||
|
||||
## 6. BNPL — provider-financed installments
|
||||
|
||||
**In our books, a BNPL order is a card payment that lands net-of-fee.** There is no customer-installment
|
||||
tracking on our side: the provider owns the schedule and **100% of the default risk**.
|
||||
|
||||
- `BnplTransactions` is **1:1 with its `payment_transaction`** (`UNIQUE(payment_transaction_id)`).
|
||||
- The forward-only machine is `eligible → token_issued → verified → settled → reverted/cancelled/failed`
|
||||
(`BnplTransitions`), mutated only through the entity's `mark-*` methods.
|
||||
- **Settle** posts the net-of-fee group (§2) so escrow reflects the **net** cash
|
||||
(`settled_amount_irr = order − commission`), and confirms the parent `payment_transaction` — which triggers
|
||||
the booking conversion — exactly like a card capture.
|
||||
- **The nurse's payout is invariant to payment method.** `nurse_payable` comes from the booking split
|
||||
(`gross − commission`), **never** from `settled_amount_irr`. **The BNPL commission is a platform expense.**
|
||||
- **`settled_at` is per-transaction and nullable** — never assume it is instant. The commission is read from
|
||||
the **actual settlement**, never hardcoded.
|
||||
- **Revert reuses the refund path** with `refund_channel='bnpl_revert'`. Money flows
|
||||
customer ↔ provider ↔ Balinyaar only.
|
||||
- `IBnplProvider` is selected per `provider_code` by `IBnplProviderResolver`. **`balinyaar` is the in-house
|
||||
provider** and resolves to the net-of-fee model with no external API.
|
||||
- `bnpl_settlement_entries` (tranched settlement) is **deferred — modelled but not built.** Do not create it.
|
||||
|
||||
---
|
||||
|
||||
## 7. Weekly payouts
|
||||
|
||||
- **Eligibility ≠ completed.** A booking enters a batch only when `status='completed'` **AND**
|
||||
`dispute_window_ends_at < now` **AND** it has no active refund **AND** it isn't already in a link row.
|
||||
`SetDisputeWindow` is the only eligibility trigger:
|
||||
`dispute_window_ends_at = completed_at + config(dispute_window_hours, 72)`.
|
||||
- **One payout per booking, forever.** `nurse_payout_booking_links.booking_id` is an **unconditional** UNIQUE —
|
||||
*not* filtered on soft-delete. The "not already linked" filter is the fast first line; the UNIQUE is the
|
||||
backstop.
|
||||
- **The payout drains `nurse_payable`.** A netted clawback posts `ClawbackRecovery` and marks the
|
||||
`nurse_clawbacks` row `recovered` (`recovered_in_payout_id` + `resolved_at`). **Netting recovers WHOLE
|
||||
pending clawbacks up to earnings** — never a negative net, never a partial single-clawback recovery.
|
||||
- **`net = gross − clawback`** is a DB CHECK on `NursePayouts`. `iban_snapshot` is **encrypted** and
|
||||
`[AuditRedacted]`, frozen from the verified primary account.
|
||||
- **Holiday-aware.** `period_end` and `processing_date` shift off `is_bank_closed` days via
|
||||
`IHolidayCalendar`; a retry **refuses on a bank-closed day**.
|
||||
- **First-payout gate.** Only an account with `is_primary=1 AND is_verified=1 AND matched_national_id=1` is
|
||||
paid. A nurse without one is **skipped with a recorded reason**, never silently.
|
||||
- **A retried process never double-sends an irreversible transfer**: the forward-only `PayoutStatus` machine,
|
||||
the ledger-exists guard, and a batch idempotency key together.
|
||||
- `IBankTransferProvider` is the PAYA/SATNA rail; PAYA vs SATNA is chosen by the `payout_satna_threshold_irr`
|
||||
config. The real Jibit adapter is **async**: it accepts as `submitted`, and the HMAC-verified callback
|
||||
`POST webhooks/payouts/{provider}` → `ReconcilePayoutBatchCommand` flips `submitted → paid/failed`.
|
||||
- The BNPL `settled_at` guard is the default-off `require_bnpl_settlement_for_payout` flag.
|
||||
|
||||
### Money movement stays human-approved
|
||||
|
||||
The `weekly_payout_generation` job schedules **generation only** — a `draft` batch, recorded system-initiated
|
||||
(`NursePayoutBatch.InitiatedByAdminId` nullable = "no human initiator"). **The irreversible `process` step
|
||||
remains an explicit admin action**, and `AdminPayoutsController` **neutralizes any request-supplied
|
||||
`SystemInitiated` value** — that flag is scheduler-only.
|
||||
|
||||
---
|
||||
|
||||
## 8. Invoices
|
||||
|
||||
- **VAT is on the commission line only**: `vat_irr = round(platform_commission_irr × vat_rate)` (config
|
||||
`vat_rate`, default 0.10; `vat_rate = 0` ⇒ 0). **Never on the nurse payout.**
|
||||
- **The invoice number is gap-free and sequential**, drawn from the single-row `InvoiceNumberSequences` counter,
|
||||
locked and committed with the invoice — **portable across SQL Server and SQLite, so no DB sequence.**
|
||||
- **Idempotent per booking** (`UNIQUE(booking_id)`).
|
||||
- The issuing entity follows the **merchant-of-record resolver**: booking → nurse → `partner_center_id`, and the
|
||||
target is the partner centre **only** when it `is_merchant_of_record`, else `platform`. Never a hardcoded
|
||||
platform.
|
||||
- `IMoadianClient` submits to سامانه مودیان; the mock leaves `moadian_status = pending` with no reference. A
|
||||
`MoadianReconciliationJob` walks `pending/submitted → registered` every 6 hours.
|
||||
|
||||
---
|
||||
|
||||
## 9. Cancellation
|
||||
|
||||
The applicable `cancellation_policies` tier is resolved by **`(actor, lead-time bucket)`**, and its `code` +
|
||||
`refund_percentage` + the computed refundable amount are **frozen onto the booking**.
|
||||
|
||||
**Only still-`scheduled` sessions are refundable.** A session already started or completed is not, and the
|
||||
per-session split is what makes a partial refund on a multi-session package correct.
|
||||
|
||||
Cancellation itself **posts no refund ledger** — it snapshots the policy and computes the refundable amount.
|
||||
The reversal is the refund path's job (§5).
|
||||
@@ -0,0 +1,382 @@
|
||||
# Server persistence
|
||||
|
||||
EF Core rules, money, state machines, snapshots, the scheduler, and the domain invariants that live in the
|
||||
database.
|
||||
|
||||
> Last verified: 2026-07-30 against commit `d3ec723`.
|
||||
|
||||
---
|
||||
|
||||
## 1. EF Core basics
|
||||
|
||||
```csharp
|
||||
// ✅ project to a DTO in the query
|
||||
var dto = await _db.Orders
|
||||
.AsNoTracking()
|
||||
.Where(o => o.UserId == userId)
|
||||
.Select(o => new OrderResult(o.Id, o.Status, o.CreatedAt))
|
||||
.ToListAsync(ct);
|
||||
|
||||
// ❌ loads the entity graph then maps in memory — N+1 risk
|
||||
var orders = await _db.Orders.Include(o => o.Lines).ToListAsync();
|
||||
var dtos = _mapper.Map<List<OrderResult>>(orders);
|
||||
```
|
||||
|
||||
- **Always `AsNoTracking()`** on a read-only query.
|
||||
- **Always project with `.Select()`** in a query — never hydrate full entities just to map them, and **never
|
||||
return an entity from a handler**.
|
||||
- **Pagination is mandatory** on any unbounded list (`Skip`/`Take`). No unbounded `ToListAsync()`.
|
||||
- Use `Include` **only** in a command handler that needs navigation properties loaded to mutate the aggregate.
|
||||
- **Access the DB through `IUnitOfWork`** in Application handlers. `ApplicationDbContext` is referenced
|
||||
directly only inside Infrastructure.
|
||||
- **Commit once per command**, at the end: `await unitOfWork.CommitAsync(ct)`.
|
||||
- **One `IEntityTypeConfiguration<T>` per entity**, in `Persistence/Configuration/<Area>Config/`.
|
||||
- **Mapster maps in the handler after the query**, never in the repository. Only write a custom
|
||||
`TypeAdapterConfig` when shapes genuinely diverge; register scans in `Program.cs`.
|
||||
- **Never concatenate raw SQL.** EF parameterizes automatically. If you must drop to SQL, use
|
||||
`FromSqlInterpolated`, never `FromSqlRaw` with user data.
|
||||
|
||||
**Migrations:**
|
||||
|
||||
```bash
|
||||
dotnet ef migrations add <Name> --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api
|
||||
dotnet ef database update --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api
|
||||
```
|
||||
|
||||
### Migrations are split from boot
|
||||
|
||||
`dotnet run -- migrate` (the deploy-time one-shot, or a CI `dotnet ef database update`) applies migrations
|
||||
plus the idempotent seeders, then exits — so multi-instance boots never race on DDL and the runtime login
|
||||
needs no permanent DDL rights.
|
||||
|
||||
| Environment | What boot does |
|
||||
| --- | --- |
|
||||
| Development | Migrates + seeds (roles always; a bootstrap admin **only if** `Seed:AdminUsername`/`Seed:AdminPassword` are configured), plus the Development-only gateway, demo-world and demo-lifecycle seeders |
|
||||
| Deployed | Only **checks** the schema is current (`EnsureSchemaUpToDateAsync` — fail fast on a pending migration) and seeds roles / the break-glass admin, idempotently |
|
||||
|
||||
A reachable SQL Server is required to start.
|
||||
|
||||
### Soft delete
|
||||
|
||||
Every soft-deletable entity **must** declare a global query filter in its configuration:
|
||||
|
||||
```csharp
|
||||
builder.HasQueryFilter(o => !o.IsDeleted);
|
||||
```
|
||||
|
||||
Without it, soft-deleted rows appear in every query that doesn't explicitly exclude them — a silent data
|
||||
leak. **Never add `Where(x => !x.IsDeleted)` per query**; the filter makes it automatic and auditable.
|
||||
|
||||
**Deactivate, never hard-delete.** `user_roles.revoked_at` has the same treatment, so a revoked grant
|
||||
disappears from every role read automatically.
|
||||
|
||||
---
|
||||
|
||||
## 2. Audit
|
||||
|
||||
| Field | Type | Set by |
|
||||
| --- | --- | --- |
|
||||
| `CreatedAt` / `ModifiedAt` | `DateTimeOffset` | `AuditFieldInterceptor` |
|
||||
| `CreatedById` / `ModifiedById` | `int?` | `AuditFieldInterceptor`, via `ICurrentUser` |
|
||||
|
||||
The base type is `BaseEntity` / `IAuditableEntity` (`Baya.Domain/Common/`). Stamping happens in
|
||||
`AuditFieldInterceptor` (a `SaveChangesInterceptor` in `Persistence/Interceptors/`) which reads time from
|
||||
`IDateTimeProvider` and the user from `ICurrentUser` — **not** in the `DbContext`, and **not** in a handler.
|
||||
|
||||
Audit fields cannot be backfilled retroactively, so design them in from the start.
|
||||
|
||||
### The append-only audit trail
|
||||
|
||||
Mark a compliance-sensitive entity with **`IAuditable`** and the interceptor writes an old/new diff row into
|
||||
`ops.AuditLogs` **in the same transaction as the change**. Annotate any encrypted or PII property with
|
||||
**`[AuditRedacted]`** so the diff records a marker, never plaintext.
|
||||
|
||||
`audit_logs` is **immutable — there is no update or delete path in app code.** Current `IAuditable` entities:
|
||||
`PlatformConfig`, `PartnerCenter`, `Review`, and the admin-decided money and trust entities `Refund`,
|
||||
`NurseClawback`, `NursePayout`, `NursePayoutBatch`, `NurseVerification`.
|
||||
|
||||
Retention is a two-tier sweep via `IAuditLogger.PurgeExpiredAsync`: financial and verification entity types
|
||||
keep `audit_retention_financial_days` (default 2555 ≈ 7 years), everyday rows `audit_retention_general_days`
|
||||
(default 730 ≈ 2 years). Oldest-first, capped, id-keyed delete, idempotent.
|
||||
|
||||
---
|
||||
|
||||
## 3. Config is rows, read at compute time
|
||||
|
||||
Money-critical constants — commission percentage, VAT, deadlines, EVV tolerance, cancellation tiers, job
|
||||
cadences — live in `ops.PlatformConfigs` and are read via **`IPlatformConfig.GetConfig<T>`** (cached, parsed by
|
||||
the row's `data_type`). **Never hardcode one.**
|
||||
|
||||
And the corollary, which is the part that actually matters:
|
||||
|
||||
> **Changing a rate must never retroactively alter an already-computed amount.** A rate is **snapshotted onto
|
||||
> the booking or invoice at compute time**. Do not live-re-read a rate for an already-priced row.
|
||||
|
||||
The DB-backed platform facades — `IPlatformConfig`, `IHolidayCalendar`, `IAnalyticsSink`, `IAuditLogger`,
|
||||
`INotificationService`, `ISupportAlertService` — live in `Persistence/Services/` and are the contracts other
|
||||
domains reuse. **Don't re-query those tables directly.** `IAnalyticsSink` is fire-and-forget and never fails
|
||||
the caller; `INotificationService` is always tenant-scoped to `ICurrentUser`; `support_alerts` are admin-only
|
||||
and must never appear on a user-facing route.
|
||||
|
||||
### Self-committing facades come *after* the atomic commit
|
||||
|
||||
`ISupportAlertService.RaiseAsync`, `INotificationDispatcher.DispatchAsync`, `IAuditLogger.WriteAsync` and
|
||||
`IPlatformConfig.SetConfig` each call `SaveChanges` on the **shared scoped** `DbContext`. Calling one
|
||||
mid-build flushes your partial tracked changes. **Invoke them only after `unitOfWork.CommitAsync()`.**
|
||||
|
||||
In a batch loop that commits per item: load and guard **every** dependency *before* mutating tracked state, or
|
||||
an early `continue` leaks a dirty entity that a later iteration's commit will flush.
|
||||
|
||||
---
|
||||
|
||||
## 4. Money
|
||||
|
||||
**Money has its own file: [money.md](money.md).** IRR `BIGINT` integers, the append-only balanced ledger, the
|
||||
three-amount reconciliation, webhook idempotency, and the refund / BNPL / payout / invoice invariants all live
|
||||
there. Read it before touching anything under `Features/{Payments,Refunds,Invoices,Bnpl,Payouts}`.
|
||||
|
||||
The one line to carry in your head meanwhile: **money is an integer number of IRR Rials, and there is no float
|
||||
path on it anywhere.**
|
||||
|
||||
---
|
||||
|
||||
## 5. Forward-only status machines
|
||||
|
||||
When an entity has a lifecycle `status` with a fixed set of allowed transitions, model the machine as a
|
||||
**static allowed-edges table** and route **every** write through it. Never assign `status` ad hoc.
|
||||
|
||||
- **Statuses are `const string` codes**, persisted as the stable snake_case string — no C# enum, no value
|
||||
converter needed.
|
||||
- **Edges live in a static `CanTransition(from, to)`** built from a
|
||||
`Dictionary<string, IReadOnlyCollection<string>>`; a terminal state maps to an empty set.
|
||||
- **The entity owns the transition.** `status` has a **private setter**, and the only mutators are cohesive
|
||||
domain methods (`Accept`/`Reject`/`Cancel…`) calling a private `Transition(target)` that asserts the edge is
|
||||
legal — throwing on an illegal edge, because that is a programming error, since the handler pre-checks.
|
||||
Side-effect fields are set in the same method.
|
||||
- **The handler pre-checks and returns a clean 409**:
|
||||
`if (!entity.CanTransitionTo(target)) return OperationResult.ConflictResult(...)`. Never throw for the
|
||||
expected "already moved / terminal" case.
|
||||
- **A replayed transition that is already complete is an idempotent no-op**, not a failure.
|
||||
|
||||
Machines in the codebase: `BookingRequestTransitions`, the `bookings` machine, `BnplTransitions`,
|
||||
`PayoutBatchStatus`/`PayoutStatus` transitions, `VerificationStatus`, `ReviewModerationStatus`.
|
||||
|
||||
### When the enum is a C# enum
|
||||
|
||||
Persist it as its **stable snake_case code** via `HasConversion(e => e.ToCode(), s => Parse(s))` (see
|
||||
`VerificationCodes`) so the DB and the wire carry `in_review`, not `InReview`. Enum→code mapping in a
|
||||
projected read happens **in memory after materialization** — `.ToCode()` is not LINQ-translatable. DTOs expose
|
||||
the code string.
|
||||
|
||||
### Guarded cross-aggregate flips
|
||||
|
||||
When one write must atomically change a header row's state **and** a derived boolean on a *different*
|
||||
aggregate (`nurse_verifications.status` → `nurse_profiles.is_verified`): load **both** as tracked entities,
|
||||
mutate them through a single pure domain helper (`VerificationAggregator.Finalize`), then `CommitAsync`
|
||||
**once**. Never flip the derived flag from a controller, a partial write, or an out-of-band update, and never
|
||||
leave an in-between state.
|
||||
|
||||
`NurseProfile.is_verified` has **no public setter** for this reason.
|
||||
|
||||
### Two SQL Server / SQLite portability rules
|
||||
|
||||
- **A deadline column that is compared or sorted uses `DateTime` (UTC `datetime2`), not `DateTimeOffset`** —
|
||||
the SQLite test provider cannot translate `DateTimeOffset` comparison or `ORDER BY`. Order lists and sweeps
|
||||
by `Id` for the same reason.
|
||||
- **Sequential numbers come from a counter row, not a DB sequence** (`InvoiceNumberSequences`), locked and
|
||||
committed with the row it numbers, so it is portable and gap-free.
|
||||
|
||||
---
|
||||
|
||||
## 6. Uniqueness patterns
|
||||
|
||||
| Need | Pattern |
|
||||
| --- | --- |
|
||||
| A nullable column must participate in uniqueness | **The filtered-index pair.** SQL Server treats NULLs as distinct, so `district_id = NULL` needs `UNIQUE(nurse_id, city_id) WHERE district_id IS NULL` **plus** `UNIQUE(nurse_id, city_id, district_id) WHERE district_id IS NOT NULL`, both `AND deleted_at IS NULL` |
|
||||
| "No two rows may share the same *set* of child rows" | **A deterministic set-hash.** `Baya.Application.Common.OptionSetHash.Compute(pairs)` sorts the `(long, long)` pairs and SHA-256s them into a stable, **order-independent** 64-char hex hash. Persist `NVARCHAR(64)` and back it with a filtered unique index as the race-safe backstop, plus a handler pre-check for a friendly 409. **Do not reuse `IFieldEncryptor.Hash`** — that is for PII equality lookups |
|
||||
| One-per-parent, forever | An **unconditional** UNIQUE, not filtered on soft-delete — `nurse_payout_booking_links.booking_id` |
|
||||
| One flagged row per parent | A filtered UNIQUE plus clear-then-set in one transaction — `UNIQUE(customer_id) WHERE is_primary=1 AND deleted_at IS NULL` |
|
||||
| PII equality lookup | A deterministic hash column, UNIQUE, synced on `SaveChanges` — `users.PhoneHash`. See [identity.md](identity.md) |
|
||||
|
||||
A duplicate returns **409** through `OperationResult.ConflictResult` → `BaseController`'s 409 mapping.
|
||||
|
||||
---
|
||||
|
||||
## 7. Snapshots freeze history
|
||||
|
||||
A row that represents a past agreement must not change when its sources are edited later. Frozen at their
|
||||
moment, and never mutated afterwards:
|
||||
|
||||
- `variant_snapshot_json` (via `IVariantSnapshotSerializer`) and the **encrypted** `address_snapshot_json`
|
||||
- `platform_fee_rate` on the booking
|
||||
- The resolved cancellation policy `code` + `refund_percentage`
|
||||
- `iban_snapshot` on a payout (**encrypted**, `[AuditRedacted]`), frozen from the verified primary account
|
||||
- Deadlines: `nurse_response_deadline_at` = `now + config`, `payment_deadline_at` = `now + config` — both
|
||||
stored as **absolute UTC**, so a later config change cannot move them
|
||||
|
||||
A later edit to the source variant, address, or policy **never** mutates an existing booking.
|
||||
|
||||
---
|
||||
|
||||
## 8. The search projection
|
||||
|
||||
`search.NurseSearchIndices` is **one flat row per (bookable variant × covered service area)** — a fan-out
|
||||
denormalization carrying the variant's category/price/unit, the covered `city_id`/`district_id`, the nurse's
|
||||
gender and rating aggregates, and one visibility gate. It is a **read-only projection**, written only by
|
||||
`ISearchIndexMaintainer`.
|
||||
|
||||
Three invariants:
|
||||
|
||||
- **`is_searchable = 1` only when** the nurse `is_verified = 1` **AND** `nurse_verifications.status !=
|
||||
'suspended'` **AND** `is_accepting_bookings = 1` **AND** the variant `is_active = 1` — recomputed on **every**
|
||||
relevant source write. An unverified, paused, suspended, or deactivated nurse or variant must **never**
|
||||
surface.
|
||||
- **`district_id = NULL` means whole city, both directions.** A city search matches every row in the city; a
|
||||
district search matches that district's rows **plus** the NULL-district rows.
|
||||
- **Incremental maintenance and a full rebuild must converge.** The index is fully re-derivable from source;
|
||||
`RebuildAsync` is idempotent.
|
||||
|
||||
The maintainer keeps the index consistent **inline, inside the source write's own unit of work** — it shares
|
||||
the request-scoped `DbContext`, so it only *stages* changes and the handler's single `CommitAsync` flushes
|
||||
source and projection atomically. It reads the facts a trigger does not change from the DB, and takes the
|
||||
facts it *does* change as **tracked arguments**, so it never reads a stale pre-commit value. It resurrects a
|
||||
soft-deleted row on re-upsert, so each (variant × area) has exactly one live row.
|
||||
|
||||
`INurseSearch` (read) reads **only `is_searchable = 1`** rows. Callers depend on the interface, so a later
|
||||
Elasticsearch backend is a config-selected drop-in.
|
||||
|
||||
**Coverage is named districts, not GPS radii.** Address lat/lng exists only for the EVV distance check; it is
|
||||
never used for coverage matching.
|
||||
|
||||
---
|
||||
|
||||
## 9. Reference-data caching
|
||||
|
||||
Public and reference reads are cached through `ICacheService` behind a **generation-token key scheme** —
|
||||
`GeoCache`, `CatalogCache`, `ReviewCache`. Any admin write to that area **bumps the token**, which invalidates
|
||||
the whole namespace at once rather than enumerating keys.
|
||||
|
||||
The catalog is **EAV/data, not code**: an admin adds a category or a pricing dimension as *rows*, never a
|
||||
migration. The only closed code enum in the area is `PriceUnits`. A `ServiceOptionGroups.ServiceCategoryId =
|
||||
NULL` marks a **cross-category** dimension that applies to every category — and "applicable groups" means the
|
||||
category's own groups **plus** every NULL group, everywhere: public browse, required-group validation, and the
|
||||
duplicate guard. All required groups must be answered; one value per dimension.
|
||||
|
||||
**The bookable unit is the variant, not the nurse.** Keep it a clean projectable source. The engagement total
|
||||
is `price` + `price_unit` + `session_count` — never `price` alone.
|
||||
|
||||
---
|
||||
|
||||
## 10. Domain invariants that live here
|
||||
|
||||
The rules a change in these areas must not break. Each is enforced in code *and* by a constraint.
|
||||
|
||||
**Bookings and EVV**
|
||||
|
||||
- A `bookings` row exists **only** when the nurse accepted **and** payment was captured. So a payment is
|
||||
initiated against the `accepted_awaiting_payment` *request*, and `payment_transactions.booking_id` is
|
||||
**nullable**, bound only when the confirm creates or loads the booking.
|
||||
- Conversion goes through the shared `BookingFactory` / `BookingConversion` helper — the card confirm and the
|
||||
BNPL settle both call it rather than re-implementing the split.
|
||||
- A booking request carries **no money and no `bookings` row**; accept only opens the payment window.
|
||||
- **EVV is per session, and a mismatch is advisory.** Check-in computes the distance to the *frozen* booking
|
||||
address against `evv_location_tolerance_meters`; a mismatch raises a `location_mismatch` support alert and
|
||||
notifies **without blocking**. GPS-denied still checks in, flagged null.
|
||||
- **`SetDisputeWindow` is the only payout-eligibility trigger.** Completion sets
|
||||
`dispute_window_ends_at = completed_at + config(dispute_window_hours, 72)` and each completed session's
|
||||
`payout_eligible_at`.
|
||||
- **Cancellation refunds only un-started sessions**; the applicable policy tier is resolved by
|
||||
`(actor, lead-time bucket)` and frozen onto the booking.
|
||||
|
||||
**Refunds, clawbacks, invoices, BNPL, payouts** → all in [money.md](money.md).
|
||||
|
||||
**Reviews**
|
||||
|
||||
- Reviews are for **completed/closed bookings only, owned by the caller, 1:1** (`UNIQUE(booking_id)` is the
|
||||
backstop; the handler pre-checks for a clean 409). A cross-tenant booking is a **404**, never a leak.
|
||||
- **Recompute the nurse aggregate from source on EVERY transition — not a delta.** Read
|
||||
`COUNT`/`SUM(rating)` over the nurse's currently-`published` reviews *excluding* the transitioning review,
|
||||
fold that review's *new* status in memory, set the guarded aggregates, and stage the reindex — all in the
|
||||
**same transaction** as the status change. The exclude-and-fold avoids a stale pre-commit re-query. This is
|
||||
the fix for inflated-rating-after-hide drift.
|
||||
- **`pending_moderation` is never public** — list and aggregate filter to `published` at the query layer.
|
||||
- `rating <= min_rating_for_support_alert` (config, default 2) raises a support alert **reliably** — after the
|
||||
main commit, never silently swallowed.
|
||||
|
||||
**Partner centres**
|
||||
|
||||
- Merchant-of-record resolution follows `partner_centers` through the single resolver, **not a hardcoded
|
||||
platform**: booking → nurse → `partner_center_id`, and the issuer/settlement target is the centre **only**
|
||||
when it `is_merchant_of_record`, else `platform`.
|
||||
- `partner_centers` (the licensing *sponsor*) **≠** `organizations` (the future *employer*, deferred).
|
||||
`settlement_iban` is encrypted, `[AuditRedacted]`, and **masked to the last 4 in every read**. The centre's
|
||||
`commission_rate` is separate from `platform_fee_rate`.
|
||||
|
||||
**Deferred by design — do not create these tables:** `bnpl_settlement_entries`, `organizations`,
|
||||
`organization_nurses`, `fraud_flags`, `recurring_booking_schedules`.
|
||||
|
||||
---
|
||||
|
||||
## 11. The recurring-job scheduler
|
||||
|
||||
A single in-process scheduler, `Persistence/Services/Scheduling/RecurringJobSchedulerHostedService`, drives
|
||||
every registered `IRecurringJob` on its own cadence — using **no new infrastructure**, so SQL Server stays the
|
||||
only external dependency.
|
||||
|
||||
| Job | Cadence |
|
||||
| --- | --- |
|
||||
| `booking_request_expiry` | 1 min (const) |
|
||||
| `notification_retention` | 24 h (const) — the predicate is exactly `is_read = 1 AND age > 90d`; **unread is never auto-deleted** |
|
||||
| `verification_expiry_scan` | `verification_expiry_scan_cadence_hours` |
|
||||
| `no_show_sweep` | `no_show_scan_cadence_hours` |
|
||||
| `weekly_payout_generation` | `nurse_payout_interval_days` |
|
||||
| `MoadianReconciliationJob` | 6 h |
|
||||
| `audit_log_retention` | `audit_retention_scan_cadence_hours` |
|
||||
|
||||
- **Adding a cron = implement `IRecurringJob` + one `AddSingleton<IRecurringJob, …>()`** in
|
||||
`AddPersistenceServices`. The scheduler owns the per-tick DI scope, error isolation (a throwing tick never
|
||||
kills the loop), and the lock. A job says only *how often* and *what one idempotent run does*.
|
||||
- **Jobs must be idempotent.** A retry — or a second instance, once the lock is Redis-backed — must never
|
||||
double-pay or double-post. The DB uniques and state machines are the backstop. Each tick runs under
|
||||
`IDistributedLock("scheduler:{name}")`, which is in-process today and is **the >1-instance scale-out gate**:
|
||||
swap the seam to Redis to serialize ticks across nodes. A single-instance MVP needs neither Redis nor
|
||||
Hangfire/Quartz.
|
||||
- **Money movement stays human-approved.** The payout job schedules *generation* only — a `draft` batch,
|
||||
recorded system-initiated (`InitiatedByAdminId` nullable = "no human initiator"). The irreversible `process`
|
||||
step remains an explicit admin action, and `AdminPayoutsController` **neutralizes any request-supplied
|
||||
`SystemInitiated` value**.
|
||||
- **Admin manual triggers are overrides**, running the same idempotent commands.
|
||||
- **The scheduler is dormant under the `Testing` environment**, so integration tests stay deterministic. Each
|
||||
job and command is unit-tested directly.
|
||||
- A time-sensitive command **self-guards** against a passed deadline via `IDateTimeProvider` rather than
|
||||
trusting that a sweep has run; a sweep's re-queried `WHERE status = …` predicate **is** the concurrency guard
|
||||
— a row a racing action moved is simply not reloaded.
|
||||
|
||||
---
|
||||
|
||||
## 12. Development seeders
|
||||
|
||||
Both are **Development-only** and idempotent.
|
||||
|
||||
- **`DemoWorldSeeder`** — a coherent demo marketplace on top of the reference `HasData` seeds: 3 nurses (2
|
||||
verified with variants, Tehran coverage, `approved` verification, credentials and a `matched_national_id`
|
||||
bank account; 1 unverified), 2 customers with patients and addresses, **2 phone-OTP admins** (a `super_admin`
|
||||
plus a scoped `finance` operator, so the console is reachable through the normal login and capability gating
|
||||
is demonstrable), and one cross-category required option group.
|
||||
- **`DemoLifecycleSeeder`** (+ `.Money.cs` / `.Social.cs` partials) — a full lifecycle world layered on those
|
||||
personas so every flow is manually testable: booking requests in every status, 8 bookings across every
|
||||
reachable state, the balanced payment ledger behind each, refunds on all three forks, a paid and a draft
|
||||
payout batch, moderated reviews with recomputed aggregates, tickets (including an `is_internal` note),
|
||||
notifications, patient care records, a merchant-of-record partner centre, and a mid-pipeline verification
|
||||
case.
|
||||
|
||||
Three rules they establish:
|
||||
|
||||
1. **Write through the real entities and commands** — the guarded transition methods, `BookingFactory`,
|
||||
`GeneratePayoutBatch`/`ExecutePayoutBatch`, `LedgerPosting`, `OpenTicketCommand`. Business timestamps are
|
||||
backdated explicitly. (Application grants `InternalsVisibleTo` to Persistence for this.)
|
||||
2. **Drive the search projection through `ISearchIndexMaintainer.RebuildAsync`** — never hand-insert index
|
||||
rows.
|
||||
3. **Never guard idempotency on a Persian string.** The `ApplicationDbContext` save hook normalizes Persian
|
||||
digits and ZWNJ in every stored string, so a Persian literal **never round-trips equal**. Guard on a phone
|
||||
number, a code, or another natural key.
|
||||
@@ -0,0 +1,209 @@
|
||||
# Server structure
|
||||
|
||||
The layers, the projects, startup wiring, and the seam catalogue.
|
||||
|
||||
> Last verified: 2026-07-30 against commit `d3ec723` — 14 `.csproj` projects, 55 V1 controllers.
|
||||
|
||||
---
|
||||
|
||||
## 1. Clean Architecture, and the one hard boundary
|
||||
|
||||
**Dependencies point inward.**
|
||||
|
||||
```
|
||||
Domain ← Application ← Infrastructure
|
||||
← API
|
||||
```
|
||||
|
||||
- **Domain** references nothing.
|
||||
- **Application** references only Domain.
|
||||
- **Infrastructure** and **API** implement and consume Application contracts.
|
||||
- **Never** make Domain or Application reference Infrastructure or the API. This is not a preference; it is
|
||||
the thing that keeps handlers unit-testable and lets a mock become a real vendor without touching a caller.
|
||||
|
||||
## 2. The projects
|
||||
|
||||
```
|
||||
src/
|
||||
├── Core/
|
||||
│ ├── Baya.Domain Entities, value objects, status-code sets, transition tables
|
||||
│ └── Baya.Application Features/ (CQRS slices) · Contracts/ (the seams) · Models/ · pipeline behaviors
|
||||
├── Infrastructure/
|
||||
│ ├── Baya.Infrastructure.Persistence ApplicationDbContext · ValueConversion/ · Repositories/ · Configuration/<Area>Config/ · Migrations/ · Interceptors/ · Services/ (DB-backed facades, Scheduling/, Search/, Seeding/)
|
||||
│ ├── Baya.Infrastructure.Identity Jwt/ · Identity/ (Managers, Stores, PermissionManager, Seed, CurrentUser/)
|
||||
│ ├── Baya.Infrastructure.CrossCutting Serilog wiring · Seams/ (mocks) · Seams/Real/ (vendor adapters) · AddCrossCuttingSeams
|
||||
│ └── Baya.Infrastructure.Monitoring HealthChecks (live/ready) · OpenTelemetry
|
||||
├── API/
|
||||
│ ├── Baya.Web.Api Program.cs · Controllers/V1/ · appsettings*.json
|
||||
│ ├── Baya.WebFramework BaseController · Filters/ · Middlewares/ · Swagger/ · Routing/ · ServiceConfiguration/
|
||||
│ └── Plugins/Baya.Web.Plugins.Grpc gRPC services + .proto (User only)
|
||||
├── Shared/Baya.SharedKernel Extensions + validation base
|
||||
└── Tests/
|
||||
├── Baya.Tests.Setup Shared test infrastructure (SQLite, NSubstitute setup, TestFieldEncryptor)
|
||||
├── Baya.Test.Infrastructure.Identity xUnit identity tests
|
||||
├── Baya.Test.Foundation Cross-cutting plumbing + identity handler unit tests
|
||||
└── Baya.Test.Api WebApplicationFactory integration tests (in-memory SQLite, env "Testing")
|
||||
```
|
||||
|
||||
**Domain entity folders**, one per bounded area: `User/`, `Identity/`, `Geography/`, `Catalog/`,
|
||||
`Verification/`, `Search/`, `Booking/`, `Payments/`, `Refunds/`, `Invoices/`, `Bnpl/`, `Payouts/`, `Reviews/`,
|
||||
`Messaging/`, `PartnerCenters/`, plus `Configuration/`, `Audit/`, `Analytics/`, `Holidays/`,
|
||||
`Notifications/`, `SupportAlerts/`. `Common/` holds `BaseEntity`, `IEntity`, `ITimeModification`,
|
||||
`IAuditableEntity`, `IAuditable`, `[AuditRedacted]`.
|
||||
|
||||
**Application feature areas** mirror them: `Identity`, `Geography`, `ServiceAreas`, `Addresses`, `Catalog`,
|
||||
`Variants`, `Verification`, `Search`, `Booking` (singular — pre-payment requests), `Bookings` (plural — the
|
||||
post-payment engine), `Payments`, `Refunds`, `Invoices`, `Bnpl`, `Payouts`, `Reviews`, `PatientCareRecords`,
|
||||
`Messaging`, `PartnerCenters`, `Configuration`, `Audit`, `Analytics`, `Holidays`, `Notifications`,
|
||||
`SupportAlerts`, `System`.
|
||||
|
||||
> `Booking` (singular) and `Bookings` (plural) are **different areas, not a rename.** A booking request is
|
||||
> the money-free pre-payment intent; a booking exists only after capture. The entity type `Booking` is
|
||||
> aliased where the two namespaces collide. The same split is load-bearing in the client's
|
||||
> `bookingRequests`/`bookings` domains and in Persian copy («درخواست رزرو» vs «رزرو»).
|
||||
|
||||
**Database schemas**, one per area, mirroring how Identity uses `usr`: `usr`, `ops`, `geo`, `catalog`,
|
||||
`verif`, `search`, `booking`, `payments`, `payouts`, `reviews`, `messaging`, `partner`.
|
||||
|
||||
**Keeping this current is mandatory.** When a change adds, removes, or renames a project, a layer, or a major
|
||||
folder, or changes a cross-layer dependency, update the **Project map** in
|
||||
[server/CLAUDE.md](../../../server/CLAUDE.md) and this section in the **same** change. A map is only canonical
|
||||
if it stays accurate.
|
||||
|
||||
---
|
||||
|
||||
## 3. The seams
|
||||
|
||||
The Application layer defines every mock-able external dependency as an interface. Implementations live in
|
||||
Infrastructure and are chosen by **registration**, never by a branch in a handler.
|
||||
|
||||
| Contracts folder | Seams |
|
||||
| --- | --- |
|
||||
| `Contracts/Common/` | `IDateTimeProvider`, `IFieldEncryptor`, `ICacheService`, `IObjectStorage`, `INotificationDispatcher`, `IGeocoder`, `IShahkarVerifier`, `IIdentityKycProvider`, `ICredentialVerifier`, `ILicenseVerificationService`, `IBankAccountOwnershipVerifier`, `IVariantSnapshotSerializer`, `IPaymentCaptureSimulator`, `ISmsSender`, `ICurrentUser` |
|
||||
| `Contracts/Payments/` | `IPaymentProvider`, `ISettlementSplitProvider`, `IWebhookVerifier`, `IDistributedLock`, `IBnplProvider`, `IBnplProviderResolver`, `ICurrencyNormalizer`, `IBankTransferProvider`, `IMoadianClient`, `INursePayoutStatus` |
|
||||
| `Contracts/Search/` | `INurseSearch` (read), `ISearchIndexMaintainer` (write) |
|
||||
| `Contracts/Reviews/` | `IReviewModerationService` (the AI pre-screen) |
|
||||
| `Contracts/Persistence/` | The per-domain repositories, all exposed on `IUnitOfWork` |
|
||||
| Platform facades | `IPlatformConfig`, `IHolidayCalendar`, `IAnalyticsSink`, `IAuditLogger`, `INotificationService`, `ISupportAlertService` |
|
||||
|
||||
### Where each implementation lives
|
||||
|
||||
| Kind | Location | Registered by |
|
||||
| --- | --- | --- |
|
||||
| Mocks | `CrossCutting/Seams/` | `AddCrossCuttingSeams(configuration)` — config section `Seams` |
|
||||
| Real vendor adapters | `CrossCutting/Seams/Real/` | the same, selected per rail |
|
||||
| Platform facades (DB-backed) | `Persistence/Services/` | `AddPersistenceServices` — **not** CrossCutting, because they are DB-backed |
|
||||
| `ICurrentUser` | `Infrastructure.Identity` | `RegisterIdentityServices` |
|
||||
|
||||
Audit fields are stamped by `AuditFieldInterceptor` (Persistence), never in a handler.
|
||||
|
||||
### Real rails are config-selected, and the default falls closed
|
||||
|
||||
Every vendor rail has a real HTTP adapter, selected by a per-rail **`Seams:*:Provider`** key in
|
||||
`AddCrossCuttingSeams`. **The default is the mock, and a typo falls closed to the mock** — so an unconfigured
|
||||
environment behaves exactly as before, and a misconfigured one does not silently reach a live vendor.
|
||||
|
||||
Real adapters use `HttpClient` (typed via `IHttpClientFactory`), `System.Text.Json`, and BCL crypto —
|
||||
**no new NuGet packages**. Credentials come from `Seams:*`.
|
||||
|
||||
| Rail | Selector | Adapter |
|
||||
| --- | --- | --- |
|
||||
| SMS/OTP | `Sms:Provider=kavenegar` | `KavenegarSmsSender` — **launch-critical** |
|
||||
| SMS/OTP (demo) | `Sms:Provider=telegram` | `TelegramSmsSender` — a **broadcast, not a gateway** |
|
||||
| Shahkar / KYC / IBAN ownership | `{Shahkar,IdentityKyc,BankOwnership}:Provider=finnotech` | `Finnotech*`, shared `Seams:Finnotech` creds |
|
||||
| Geocoding | `Geocoding:Provider=neshan` | `NeshanGeocoder` |
|
||||
| Object storage | `ObjectStorage:Provider=s3` | `S3ObjectStorage` — MinIO/S3/ArvanCloud via manual AWS SigV4; presigned GET is the real signed-URL contract |
|
||||
| PSP | `Payments:Provider=zarinpal` | `ZarinPalPaymentProvider` + `HmacWebhookVerifier` + `ProviderSettlementSplitProvider` |
|
||||
| BNPL | `Bnpl:Provider=real` | `SnappPayBnplProvider` / `DigipayBnplProvider` + `ConfiguredBnplProviderResolver`. **`balinyaar` = in-house, resolving to the net-of-fee model with no external API** |
|
||||
| Bank transfer | `BankTransfer:Provider=jibit` | `JibitBankTransferProvider` — an **async rail**: it accepts as `submitted`, and the HMAC-verified reconciliation callback `POST webhooks/payouts/{provider}` flips `submitted → paid/failed` |
|
||||
| e-invoicing | `Moadian:Provider=moadian` | `MoadianClient` + a 6-hour `MoadianReconciliationJob` walking `pending/submitted → registered` |
|
||||
|
||||
Three deliberate exceptions:
|
||||
|
||||
- **`IPaymentCaptureSimulator` is out of the production registration.** Production gets the fail-closed
|
||||
`DisabledPaymentCaptureSimulator`; Dev and Testing re-register the succeeding mock. The `bookings/convert`
|
||||
path is a Dev/Testing affordance — production converts through the b10 webhook confirm.
|
||||
- **`ICredentialVerifier` / `ILicenseVerificationService` stay mock**, because **manual MoH / INO / eNamad
|
||||
review is the intended MVP** — there is no public B2B API. Don't "finish" them.
|
||||
- **There is no telephony/VoIP seam.** The emergency call is an out-of-platform `tel:` link by design.
|
||||
|
||||
`ICurrencyNormalizer` is already config-driven with a real implementation.
|
||||
|
||||
---
|
||||
|
||||
## 4. Startup wiring
|
||||
|
||||
Service registration is composed from per-layer extension methods, each in that project's
|
||||
`ServiceConfiguration/` folder. **`Program.cs` is an orchestrator: extension-method calls only, no logic and
|
||||
no inline registration.**
|
||||
|
||||
```
|
||||
builder.ValidateRequiredSecrets() // fail fast on a missing/placeholder DB or crypto secret
|
||||
ConfigureHealthChecks() · SetupOpenTelemetry()
|
||||
AddApplicationServices() // Mediator + pipeline behaviors (Logging → Metrics → Validate)
|
||||
RegisterIdentityServices(…, requireHttpsMetadata)
|
||||
AddPersistenceServices(...) // DbContext (+ AuditFieldInterceptor), UnitOfWork, repositories,
|
||||
// the IRecurringJob crons + RecurringJobSchedulerHostedService
|
||||
AddCrossCuttingSeams(config)
|
||||
AddWebFrameworkServices() // API versioning + snake_case routing
|
||||
AddCorsPolicies(config) // from Cors:AllowedOrigins
|
||||
AddForwardedHeadersConfiguration(config) // trust ForwardedHeaders:KnownProxies/KnownNetworks
|
||||
AddRateLimitingPolicies() // per-resolved-IP global + named (otp/auth/sensitive/webhook)
|
||||
AddSwagger("v1", "v1.1") · RegisterValidatorsAsServices() · AddMapster()
|
||||
ConfigureGrpcPluginServices(builder.Environment) // gRPC reflection: Development only
|
||||
// Development-only: AddDevelopmentOtpCapture() decorates ISmsSender to capture each OTP in memory for
|
||||
// GET /api/v1/dev/last_otp/{phone}. Never wired outside Development, and only for a capture-safe
|
||||
// Seams:Sms:Provider (mock/unset, or the Development-only telegram relay). Kavenegar disables it.
|
||||
```
|
||||
|
||||
**When you add infrastructure, expose it as an extension method and call it from `Program.cs`.**
|
||||
|
||||
### Middleware order, and why each position matters
|
||||
|
||||
```
|
||||
forwarded headers → exception handler → Swagger → routing → CORS → rate limiter
|
||||
→ authentication → authorization → controllers → metrics → health checks → gRPC
|
||||
```
|
||||
|
||||
- **`UseForwardedHeaders()` is first**, so the resolved client IP (`X-Forwarded-For` from a trusted proxy) is
|
||||
in place before the rate limiter partitions on it. Behind a proxy without it, the limiter sees one IP and
|
||||
throttles everyone together.
|
||||
- **`UseCors(...)` sits after `UseRouting()` and before `UseRateLimiter()`**, so a pre-flight `OPTIONS` is
|
||||
answered before the limiter and auth run.
|
||||
- **`UseRateLimiter()` is before `UseAuthentication()`**, so over-limit auth and OTP attempts are rejected
|
||||
with 429 before hitting the auth stack.
|
||||
|
||||
### Fail-fast on secrets
|
||||
|
||||
`StartupSecretsGuard` (via `ValidateRequiredSecrets()`) refuses to start if a load-bearing secret is missing
|
||||
or left at its committed `SET_VIA_USER_SECRETS_OR_ENV` placeholder: the DB connection strings always, plus the
|
||||
JWE and field-encryption keys in deployed environments.
|
||||
|
||||
> The placeholder's *name* is stale — `dotnet user-secrets` is **not used** and the `<UserSecretsId>` was
|
||||
> removed, so that store is never read. The behaviour is correct; the string is a legacy name. See
|
||||
> [code-quality.md](../shared/code-quality.md) §6 for where config actually lives.
|
||||
|
||||
---
|
||||
|
||||
## 5. Observability and health
|
||||
|
||||
One **OpenTelemetry** stack (`Baya.Infrastructure.Monitoring`, `SetupOpenTelemetry`):
|
||||
|
||||
- **Metrics** — runtime + ASP.NET Core + the `mediator_meter` histogram, scraped at `/metrics` via the OTel
|
||||
Prometheus exporter. (The duplicate prometheus-net stack was removed.)
|
||||
- **Tracing** — ASP.NET Core + EF Core, sharing `service.name = Baya.Web.Api`.
|
||||
- **OTLP export (traces + metrics) is opt-in** — wired only when `OpenTelemetry:Otlp:Endpoint` is set, so an
|
||||
MVP running Prometheus alone is unchanged.
|
||||
- **`ApiResult.RequestId` IS the W3C trace id** (`Activity.Current.TraceId`, `Activity.DefaultIdFormat = W3C`),
|
||||
so a support ticket maps 1:1 to a trace. Don't replace it with a random correlation id.
|
||||
|
||||
**Health checks are split**: `/healthz/live` (process only, dependency-free — for a liveness probe),
|
||||
`/healthz/ready` (app DB + `logDb` in deployed environments + an `IObjectStorage` write probe), and
|
||||
`/HealthCheck` (the aggregate, kept for compatibility).
|
||||
|
||||
**Logs**: deployed environments write Information+ to `Baya_Logs`, with framework categories held at Warning.
|
||||
**No PII, no secrets** — the mock SMS sender never logs the OTP code, and clinical text and IBANs are
|
||||
encrypted or masked. Set the OTLP collector to ship logs off-box; the SQL sink is the deployed default.
|
||||
|
||||
**gRPC reflection is Development-only** (`GrpcPluginStartup` gates it on `IsDevelopment`); the plugin shares
|
||||
the mixed-protocol Kestrel listener.
|
||||
@@ -0,0 +1,159 @@
|
||||
# Code quality
|
||||
|
||||
The four rules that apply identically to both projects: no dead code, comment the *why*, no starter
|
||||
scaffolding, and a mock is only a mock behind a seam.
|
||||
|
||||
> Last verified: 2026-07-30 against commit `d3ec723`.
|
||||
|
||||
---
|
||||
|
||||
## 1. No dead code
|
||||
|
||||
Unused variables, imports/usings, parameters, and private members are removed — not left behind, not
|
||||
commented out, and **not suppressed**.
|
||||
|
||||
| Project | How it surfaces | Gate |
|
||||
| --- | --- | --- |
|
||||
| Client | `@typescript-eslint/no-unused-vars`, raised from eslint-config-next's default `warn` to **`error`** in `eslint.config.mjs` | Dead code **fails `npm run check`** |
|
||||
| Server | `CS0168` (declared, never used), `CS0219` (assigned, never read), `CS0169` (private field never used), `IDE0005` (unnecessary `using`) | The gate is **zero new warnings**, so dead code is a gate failure |
|
||||
|
||||
**Delete it — don't silence it.** No `#pragma warning disable`, no throwaway discards, no `_ =`
|
||||
assignments to quiet an analyzer, no file-wide ESLint disable.
|
||||
|
||||
Two sanctioned opt-outs, both narrow:
|
||||
|
||||
- **Client:** a deliberately-unused binding is prefixed with `_` — `_event`, `catch (_err)`.
|
||||
- **Server:** a parameter that must exist to satisfy an interface or delegate signature but is genuinely
|
||||
unused stays, named conventionally, with a one-line `// why` only if the reason isn't obvious.
|
||||
|
||||
When a lint disable is genuinely correct — a deliberate browser-only read after mount that trips
|
||||
`react-hooks/set-state-in-effect` is the real example in this codebase — use a scoped
|
||||
`// eslint-disable-next-line <rule>` with a one-line reason on the line above. Never a file-wide disable,
|
||||
and never in preference to fixing the code.
|
||||
|
||||
---
|
||||
|
||||
## 2. Comment the *why*, never the *what*
|
||||
|
||||
Code that needs a comment to be understood usually needs a **better name** instead. Reach for the name
|
||||
first, then a small helper, then a comment.
|
||||
|
||||
**Don't** write a comment that restates what the code already says:
|
||||
|
||||
```csharp
|
||||
// ❌ restates the obvious
|
||||
// increment the retry counter
|
||||
retryCount++;
|
||||
```
|
||||
|
||||
```tsx
|
||||
// ❌ restates the obvious
|
||||
// set the access token
|
||||
setClientCookie(COOKIE_NAMES.ACCESS_TOKEN, token);
|
||||
```
|
||||
|
||||
No XML-doc or JSDoc that merely echoes a function's name either.
|
||||
|
||||
**Do** add a tight comment where a non-obvious decision, constraint, business rule, workaround, ordering
|
||||
or security requirement, or deliberate deviation is *not* evident from the code. Explain the reasoning,
|
||||
not the mechanics:
|
||||
|
||||
```csharp
|
||||
// ✅ captures a constraint the code can't express on its own
|
||||
// Payment gateway rejects amounts above 50M IRR per call; split larger settlements upstream.
|
||||
if (amount > MaxPerCallRial) …
|
||||
```
|
||||
|
||||
The models to follow in this codebase:
|
||||
|
||||
| File | What its comment earns |
|
||||
| --- | --- |
|
||||
| `client/src/app/[locale]/layout.tsx` | Why `<html>` lives in the `[locale]` layout and not above it |
|
||||
| `client/src/lib/auth/token.ts` | Why the JWT `exp` check is UX-only and never a security boundary |
|
||||
| `client/src/layout/config.ts` | Why the two chrome-bar heights are measured rather than guessed, and must stay in sync with the bars |
|
||||
| `client/middleware.ts` | Why the matcher lists bare `'/'` explicitly alongside the catch-all regex |
|
||||
|
||||
Delete comments that no longer match the code. A wrong comment costs more than no comment.
|
||||
|
||||
---
|
||||
|
||||
## 3. Don't reintroduce starter scaffolding
|
||||
|
||||
Both projects were derived from open-source starters. Their branding, demo/showcase pages, and
|
||||
`_TITLE_`/`_DESCRIPTION_` placeholders were **intentionally removed**. Don't add them back — not as a
|
||||
convenience, not while editing docs, not as an example.
|
||||
|
||||
Specifically:
|
||||
|
||||
- No placeholder page, showcase route, or "example component" gallery.
|
||||
- No `_TITLE_` / `_DESCRIPTION_` / lorem-ipsum copy anywhere, including in message files.
|
||||
- No starter README boilerplate reinstated into a project README.
|
||||
- `PlaceholderScreen` exists for a genuinely not-yet-built screen and must not be reachable from a shell's
|
||||
navigation. `/admin/notifications` is the current example: it is a placeholder, and it is deliberately
|
||||
absent from `AdminLayout`'s nav for that reason.
|
||||
|
||||
---
|
||||
|
||||
## 4. A mock is only a mock behind a seam
|
||||
|
||||
Some integrations are intentionally out of scope and must be **mocked, not invented**: real PSP and BNPL
|
||||
connections, the Shahkar / MoH / INO / criminal-record vendors, MinIO/S3 credentials, the سامانه مودیان
|
||||
enrollment. Reaching one is not a blocker.
|
||||
|
||||
The only sanctioned form of "not real yet" is:
|
||||
|
||||
1. **An interface.** Server: an interface in `Application/Contracts/`, implemented twice, selected by
|
||||
configuration in `AddCrossCuttingSeams` — and **the default is the mock, with a typo falling closed to
|
||||
the mock**. Client: the domain's `Api` interface in `services/{domain}/types.ts`, implemented by
|
||||
`clientApi.ts` and `mockApi.ts`, selected in `apis/index.ts` by `USE_{DOMAIN}_MOCK`.
|
||||
2. **Selection by registration, never by branching.** No `if (mock)` inside a handler, hook, or component.
|
||||
Swapping a mock for the real thing is a one-line registration change and touches no caller.
|
||||
3. **A record**, in `docs/status/`: the seam (interface name + file), what is faked, why, the config keys
|
||||
it reads, and **step-by-step how to make it real** — which provider, which settings, which methods,
|
||||
what to test.
|
||||
|
||||
An unrecorded mock is a defect, because the next agent cannot tell a deliberate stand-in from a bug.
|
||||
|
||||
Two mocks in this repo are **deliberate MVP endpoints, not stand-ins waiting for a vendor**:
|
||||
`ICredentialVerifier` / `ILicenseVerificationService` stay mock because manual MoH / INO / eNamad review
|
||||
*is* the intended MVP — there is no public B2B API. Don't "finish" them.
|
||||
|
||||
---
|
||||
|
||||
## 5. Scale and cost are part of correctness
|
||||
|
||||
Every decision should consider what it costs at scale, not only whether it works once:
|
||||
|
||||
**Server** — indexing, pagination on every unbounded list, caching read-heavy and reference data behind
|
||||
the cache seam, idempotency and locks on the money path, the DB constraint as the authoritative backstop
|
||||
behind every friendly pre-check.
|
||||
|
||||
**Client** — query caching with a deliberate `staleTime` so you never refetch what you already hold,
|
||||
invalidation on mutation, re-render cost (stable references, `select` to subscribe to a slice, state
|
||||
colocated low), and bundle size.
|
||||
|
||||
And in both: the seam that lets a mock become real without touching a caller.
|
||||
|
||||
---
|
||||
|
||||
## 6. Configuration lives in files, not a secret store
|
||||
|
||||
`dotnet user-secrets` is **not used** in this repo, and the `<UserSecretsId>` was removed from
|
||||
`Baya.Web.Api.csproj`, so that store **is not read at all**. Any instruction telling you to set a value
|
||||
with `dotnet user-secrets` is stale.
|
||||
|
||||
| Where config lives | What |
|
||||
| --- | --- |
|
||||
| `server/src/API/Baya.Web.Api/appsettings.*.json` | Server config, including dev crypto keys |
|
||||
| `client/.env.development` / `.env.production` | Client config |
|
||||
| root `docker-compose.yml` | The deployment's container-specific overrides |
|
||||
|
||||
This is a deliberate pre-launch trade for a demo deployment, which means **the repo contains live
|
||||
credentials**. Before onboarding real users they must be rotated and the secret half moved out of git —
|
||||
see [`DEPLOY.md`](../../../DEPLOY.md) "Going to Production". Never hardcode a secret in code either way:
|
||||
keys, connection strings, and tokens come from configuration bound to typed settings, never a literal in
|
||||
a handler, service, or component.
|
||||
|
||||
One value is load-bearing and must never change: `Seams:FieldEncryption:Key` / `:HashKey` decrypt all
|
||||
existing PII and derive the phone-lookup hash. Changing them makes every PII read throw and every phone
|
||||
lookup miss.
|
||||
@@ -0,0 +1,106 @@
|
||||
# Git and the quality gates
|
||||
|
||||
What must pass before work is done, and what the repo refuses to let you commit.
|
||||
|
||||
> Last verified: 2026-08-02 against commit `51e86a1`.
|
||||
|
||||
---
|
||||
|
||||
## 1. The gates
|
||||
|
||||
Each project is built, linted, and tested **on its own**. There is no root-level build, package, or
|
||||
solution, so there is no single command that gates the repo. Run the gate for the side you edited.
|
||||
|
||||
### Client — `cd client`
|
||||
|
||||
| Command | What it runs |
|
||||
| --- | --- |
|
||||
| `npm run check` | **The gate.** `type` → `lint` → `lint:copy`, in that order |
|
||||
| `npm run type` | `tsc --noEmit` (`strict` on) |
|
||||
| `npm run lint` | `eslint .` (flat config) |
|
||||
| `npm run lint:copy` | `node scripts/check-copy.mjs` — greps `fa.json` for banned Persian orthography variants |
|
||||
| `npm run test:ci` | `jest --ci` — **also required** when you touched a component with a co-located `*.test.tsx` |
|
||||
|
||||
`npm run check` must be green. `en.json` and `fa.json` must be in sync.
|
||||
|
||||
> `lint:copy` is part of `check`, not a separate step you can forget. It is what stops a copy regression
|
||||
> — a hamza-less «تایید», a space in the brand name — from needing to be re-discovered by a human. The
|
||||
> rules it enforces are in [client/i18n.md](../client/i18n.md).
|
||||
|
||||
### Server — `cd server`
|
||||
|
||||
| Command | What it runs |
|
||||
| --- | --- |
|
||||
| `dotnet build Baya.sln` | **Zero new warnings.** Unused usings, locals, parameters, private fields or members count as failures — delete them, don't suppress them |
|
||||
| `dotnet test Baya.sln` | All tests pass, including the ones your change adds |
|
||||
|
||||
A reachable SQL Server is required to run the API (not to build or unit-test it).
|
||||
|
||||
### Both
|
||||
|
||||
Read your own diff as if you were reviewing the PR: **would a senior engineer approve it without
|
||||
comment?** A change that passes the mechanical gate and fails that question is not done.
|
||||
|
||||
---
|
||||
|
||||
## 2. What "done" means
|
||||
|
||||
A change is done when all of these hold:
|
||||
|
||||
- [ ] The full scope is implemented. No `// TODO: implement later`, no stub that returns fake data.
|
||||
Anything not real is behind a **DI-registered seam** and recorded (see [code-quality.md](code-quality.md)).
|
||||
- [ ] It follows the rules for that project — the relevant `CLAUDE.md` plus the one reference file for the
|
||||
area you touched.
|
||||
- [ ] No dead code. Comments explain *why*, not *what*.
|
||||
- [ ] The project's own gate above is green.
|
||||
- [ ] If the structure changed, the matching **architecture section** is updated in the same change
|
||||
(see [documentation.md](../documentation.md) §3).
|
||||
- [ ] If a business rule was discovered or decided, `product/` reflects it — recorded, not invented.
|
||||
- [ ] If a new reusable pattern or seam landed, the reference file for that area names it, so the next
|
||||
change reuses it instead of reinventing it.
|
||||
|
||||
A change that doesn't pass its own gate is **not done**, regardless of how complete the code looks.
|
||||
|
||||
---
|
||||
|
||||
## 3. No pre-commit secret scan (for now)
|
||||
|
||||
There is no git hook enforcing anything in this repo — `.githooks/` was removed in phase 7 as an
|
||||
MVP-stage call: this is a pre-launch demo project and the mechanical backstop wasn't worth the overhead
|
||||
yet. The underlying rule is unchanged — **never commit a real secret** — it's just unenforced by tooling.
|
||||
Root [CLAUDE.md](../../../CLAUDE.md) §6 already documents the repo's actual trade: config lives in
|
||||
committed files, including live credentials, until real users exist (see
|
||||
[DEPLOY.md](../../../DEPLOY.md) "Going to Production" for the rotation step that unblocks that). Revisit
|
||||
adding a hook — or a CI scanner (gitleaks, trufflehog) — if that trade changes before this one does.
|
||||
|
||||
---
|
||||
|
||||
## 4. Branches and commits
|
||||
|
||||
`main` is the default branch and the base for PRs.
|
||||
|
||||
- **Commit or push only when asked.** If you are on `main` and about to commit, branch first.
|
||||
- One coherent change per commit. The repo's history reads as a sequence of completed units of work
|
||||
(`ui phase 11`, `remove user-secrets approach & prepare a pilot deploy`) — keep that.
|
||||
- Never skip hooks (`--no-verify`) or bypass signing unless explicitly asked. If a hook fails,
|
||||
fix the underlying issue.
|
||||
- Prefer a new commit over amending an existing one.
|
||||
- Before a destructive git operation (`reset --hard`, `push --force`, `checkout --`), consider whether a
|
||||
safer route reaches the same place.
|
||||
|
||||
---
|
||||
|
||||
## 5. Known pre-existing warnings
|
||||
|
||||
These are expected and **must not be "fixed"** unless a task says so — a change that touches them is
|
||||
scope creep, and one that silences them is worse.
|
||||
|
||||
| Warning | Project | Note |
|
||||
| --- | --- | --- |
|
||||
| `NU1510` on `Microsoft.Extensions.Logging.Debug` | `Baya.Web.Api` | Redundant transitive reference, harmless |
|
||||
| `NETSDK1057` (preview SDK) | all server projects | The .NET 10 SDK is preview on this machine |
|
||||
|
||||
On the client, `import/no-cycle` is disabled in `eslint.config.mjs` (its TypeScript resolver has an
|
||||
interface mismatch with this toolchain), and **ESLint is pinned to 9** — ESLint 10 crashes against this
|
||||
Next 16 toolchain with `scopeManager.addGlobals is not a function`. See
|
||||
[client/testing.md](../client/testing.md).
|
||||
@@ -0,0 +1,94 @@
|
||||
# Naming
|
||||
|
||||
The names that are load-bearing across both projects, and the ones that are only conventions.
|
||||
|
||||
> Last verified: 2026-07-30 against commit `d3ec723`.
|
||||
|
||||
---
|
||||
|
||||
## The two names, and why there are two
|
||||
|
||||
The product and brand are **Balinyaar** (Persian: «بالینیار»). The server's code namespace is **`Baya*`**
|
||||
— a legacy prefix from before the name settled.
|
||||
|
||||
| Layer | Name | Rule |
|
||||
| --- | --- | --- |
|
||||
| Server namespaces, projects, solution | `Baya.*` / `Baya.sln` | Keep it. **Do not rename without explicit instruction** — it touches 14 `.csproj` files, every namespace, and the solution. |
|
||||
| Client package | `balinyaar-client` | — |
|
||||
| Client import alias | `@/*` → `client/src/*` | Defined in `client/tsconfig.json`. Use it; don't write deep relative paths across folders. |
|
||||
| User-facing copy | «بالینیار» / "Balinyaar" | Never `Baya`. See [client/i18n.md](../client/i18n.md) for the ZWNJ rule — it is linted. |
|
||||
|
||||
So `Baya.Application` is correct in C# and wrong in a UI string, and «بالینیار» is correct in a UI string
|
||||
and would be wrong as a namespace. That is the whole split.
|
||||
|
||||
---
|
||||
|
||||
## Agent-facing docs
|
||||
|
||||
`CLAUDE.md` is the single source of truth at every level of the repo. `AGENTS.md` files exist only so the
|
||||
convention is discoverable under that name too — they are **thin pointers**, never content. If you find
|
||||
yourself writing a rule into an `AGENTS.md`, it belongs in the `CLAUDE.md` beside it.
|
||||
|
||||
Three `AGENTS.md` files exist: repo root, `client/`, `server/`.
|
||||
|
||||
---
|
||||
|
||||
## Server naming
|
||||
|
||||
Full C# conventions in [server/conventions.md](../server/conventions.md). The names that matter beyond
|
||||
style:
|
||||
|
||||
| Kind | Convention | Example |
|
||||
| --- | --- | --- |
|
||||
| Command | `{Verb}{Noun}Command` | `CreateOrderCommand` |
|
||||
| Query | `{Verb}{Noun}Query` | `GetUserOrdersQuery` |
|
||||
| Handler | `{RequestName}Handler` | `CreateOrderCommandHandler` |
|
||||
| Result DTO | `{RequestName}Result` | `CreateOrderCommandResult` |
|
||||
| Feature folder | `Features/<Area>/{Commands\|Queries}/<VerbNoun>/` | `Features/Payments/Commands/InitiatePayment/` |
|
||||
| EF config folder | `Persistence/Configuration/<Area>Config/` | `PaymentsConfig/` |
|
||||
| Seam interface | `I{Capability}` in `Application/Contracts/` | `IBankTransferProvider` |
|
||||
| Real adapter | `{Vendor}{Capability}` in `Seams/Real/` | `JibitBankTransferProvider` |
|
||||
| Mock adapter | `Mock{Capability}` in `Seams/` | `MockBankTransferProvider` |
|
||||
|
||||
**Controller and action names become URLs.** All URL segments are `snake_case`, produced automatically
|
||||
from `[controller]`/`[action]` tokens by `SnakeCaseParameterTransformer`. So `GetBySlug` becomes
|
||||
`get_by_slug`. If a method name doesn't read cleanly as a URL, **rename the method** — never hardcode the
|
||||
route string, which bypasses the transformer.
|
||||
|
||||
One type per file, and the file name matches the type name exactly.
|
||||
|
||||
---
|
||||
|
||||
## Client naming
|
||||
|
||||
| Kind | Convention | Example |
|
||||
| --- | --- | --- |
|
||||
| Shared component | `src/components/<Name>/<Name>.tsx` + `index.tsx` barrel | `components/TrustBadge/TrustBadge.tsx` |
|
||||
| Its test | co-located `<Name>.test.tsx` | `components/TrustBadge/TrustBadge.test.tsx` |
|
||||
| Page body | `<PageName>Screen.tsx`, co-located with `page.tsx` | `HomeScreen.tsx`, `SearchScreen.tsx` |
|
||||
| Private (non-route) folder under `app/` | `_`-prefixed | `_chrome/`, `_hub/` |
|
||||
| Route group (adds no URL segment) | parenthesised | `(customer)`, `(public-routes)` |
|
||||
| Service domain | `src/services/{domain}/` | `services/bookingRequests/` |
|
||||
| Query hook | one per file, `hooks/use{Action}.ts` | `hooks/useBookingDetail.ts` |
|
||||
| Icon registry key | **lowercase**, semantic | `icon="verification"`, not `icon="ShieldCheck"` |
|
||||
| i18n namespace | a top-level key in both message files | `booking`, `payouts` |
|
||||
| Constant | `SCREAMING_SNAKE` in a `constants.ts` | `APP_FRAME_MAX_WIDTH` |
|
||||
|
||||
`bookings` and `bookingRequests` are **siblings, not a rename** — a booking request is the money-free
|
||||
pre-payment intent, a booking exists only after capture. The same distinction is load-bearing in Persian
|
||||
copy («درخواست رزرو» vs «رزرو») and in the server's singular `Booking` vs plural `Bookings` feature areas.
|
||||
|
||||
---
|
||||
|
||||
## Directory conventions that carry meaning
|
||||
|
||||
| Path | Meaning |
|
||||
| --- | --- |
|
||||
| `client/src/components/common/` | Foundational primitives, imported via `@/components` |
|
||||
| `client/src/components/<domain>/` | Domain composites (`booking/`, `messaging/`, `admin/`, `geography/`, `notifications/`, `settings/`, `auth/`) |
|
||||
| `client/src/services/{domain}/apis/` | The seam: `clientApi.ts` (real), `mockApi.ts`, `serverApi.ts`, `index.ts` (selects) |
|
||||
| `server/src/Core/` | Domain + Application — no outward dependencies |
|
||||
| `server/src/Infrastructure/` | Implementations of Application contracts |
|
||||
| `server/src/API/` | Controllers, framework, plugins |
|
||||
| `dev/` | The finished build-plan chain. History, not a project — nothing to build in it |
|
||||
| `product/` | Business truth. Markdown canonical, HTML generated |
|
||||
@@ -0,0 +1,135 @@
|
||||
# Backlog — closed items
|
||||
|
||||
> Last verified: 2026-08-02 against commit `b876490`.
|
||||
|
||||
Items confirmed done during phase 4's reconciliation, with what closed them. This is what keeps
|
||||
[backlog.md](backlog.md)'s open count honest — an item only leaves this file if later evidence proves it
|
||||
regressed.
|
||||
|
||||
---
|
||||
|
||||
## Hardening ledger (3 of 18 items — see [backlog.md](backlog.md) for the 11 still open + 4 partially-fixed)
|
||||
|
||||
| Origin | Item | Closed by |
|
||||
|--------|------|-----------|
|
||||
| H-02 | `isTokenAlive` could never read the real token (JWE) — treated every session as dead. | `baa3cc6` ("manual improvement 1") — a well-formed 5-part JWE is now treated as alive without decoding. |
|
||||
| H-03 | Anonymous visitor to a private shell hit an infinite splash screen. | `baa3cc6` — `useRoleHydration` now has an explicit `unauthenticated` status; `RoleGuard` redirects to `/login` on it. |
|
||||
| H-12 | Raw stack-trace error boundary; no route-level error pages. | `370c1be` (ui-phase-1) + `d33568b` (ui-phase-12) — branded `ErrorBoundary` with retry, plus `error.tsx`/`global-error.tsx`. |
|
||||
|
||||
## REQ ledger — delivered (28 of 67; see [backlog.md](backlog.md) for the remaining 39 open/partial/deferred)
|
||||
|
||||
| REQ | Item | Evidence |
|
||||
|-----|------|----------|
|
||||
| REQ-001 | `ApiResult` envelope, camelCase casing, `Paginated` shape | `ApiResult.cs`, `PagedResult.cs` |
|
||||
| REQ-002 | `codeLength`/`expiresInSeconds` on OTP request result | `RequestOtpResult.cs:8` |
|
||||
| REQ-003 | Machine-readable OTP failure codes + `retryAfterSeconds` | `VerifyOtpCommand.Handler.cs:43,58` |
|
||||
| REQ-005 | `relation`/`conditions` on `PatientDto` | `Patient.cs:28,33` |
|
||||
| REQ-006 | Multipart avatar-upload endpoint (nurse + customer) | `UploadCustomerAvatarCommand.cs`, `UploadNurseAvatarCommand.cs` |
|
||||
| REQ-007 | Customer name + preferred-language update | `UpsertCustomerProfileCommand.cs` |
|
||||
| REQ-008 | Client map-pin lat/long on address create/update | `CreateAddressCommand.cs:21`, `UpdateAddressCommand.Handler.cs:62-71` |
|
||||
| REQ-009 | `provinceId` on `CustomerAddressDto` | `CustomerAddressDto.cs:12` |
|
||||
| REQ-010 | Pagination param name confirmed (`pageSize`) | `PagedResult.cs:4` |
|
||||
| REQ-011 | Nurse-facing structured credential-details endpoint | `NurseVerificationController.cs:47-51` |
|
||||
| REQ-012 | Search enrichment + public nurse-profile aggregate | `NurseSearchResultDto.cs:21-23`, `NursesController.cs:33-35` |
|
||||
| REQ-013 | Variant price on `BookingRequestDto` | `BookingRequestDto.cs:20-21,45` |
|
||||
| REQ-014 | `variantLabel`+`patientAge` on nurse-inbox list DTO | `BookingRequestListItemDto.cs:23` |
|
||||
| REQ-015 | Booking/session/EVV enum wire codes confirmed | `BookingStatus.cs:11-30` |
|
||||
| REQ-016 | Checkout summary read | `BookingRequestsController.cs:64-66` |
|
||||
| REQ-017 | `bookingId` on converted request | `BookingRequestDto.cs:49` |
|
||||
| REQ-018 | Invoice reachable right after capture (auto-issue) | `ConfirmPaymentAndPostLedgerCommand.Handler.cs:98` |
|
||||
| REQ-019 | Customer-initiated cancellation + refund | `BookingsController.cs:67-71` |
|
||||
| REQ-020 | Pre-cancel policy preview | `BookingsController.cs:74-77` |
|
||||
| REQ-021 | Refund lookup-by-booking + fee-leg decomposition | `RefundsController.cs:29` |
|
||||
| REQ-023 | BNPL eligibility accepts D3 KYC inputs | `CheckBnplEligibilityQuery.cs:21-23` |
|
||||
| REQ-025 | Nurse earnings balance + list + detail + `failureReason` | `NursePayoutsController.cs:27-47` |
|
||||
| REQ-026 | Review eligibility + my-review-for-booking reads | `BookingReviewsController.cs:33-41` |
|
||||
| REQ-027 | Family care record + record-access reads | `PatientCareRecordsController.cs:44-59` |
|
||||
| REQ-028 | Ticket inbox enrichment + message idempotency | `MessagingProjections.cs:13,16-17,62` |
|
||||
| REQ-029 | `updatedAt`/`updatedBy` on `PlatformConfigDto` | `PlatformConfigDto.cs:11-12` |
|
||||
| REQ-030 | Audit-trail actor/action/date-range filters | `GetAuditTrailQuery.cs:8-15` |
|
||||
| REQ-037 | `tagCodes` on the moderation-queue DTO | `ReviewProjections.cs:37-48` |
|
||||
|
||||
**Note:** REQ-029/030's server work is closed, but `client/src/services/admin/constants.ts` still cites both
|
||||
as reasons the admin mock stays primary — a small residual client cleanup, not tracked separately (fold into
|
||||
whichever admin-mock item is next touched).
|
||||
|
||||
## REQ ledger — obsolete (1)
|
||||
|
||||
| REQ | Item | Reason |
|
||||
|-----|------|--------|
|
||||
| REQ-004 | Confirm multi-role disambiguation (`activeRole`?) on `/me` | Resolved by product decision in refinement-phase-2: the client owns active-role choice; no `MeResult` change was ever needed. |
|
||||
|
||||
## Manual-testing iterations 1 & 2 (19 of 20 bullets — 1 partially-fixed, see [backlog.md](backlog.md) BL-217)
|
||||
|
||||
| Origin | Item | Closed by |
|
||||
|--------|------|-----------|
|
||||
| iteration-1 #1 | Login card / all Papers had too large a border radius | `theme.ts:76-84`, `tokens.css:46-51` |
|
||||
| iteration-1 #2 | Login-page options should move inside the card | `AuthCard.tsx:8-33`, `PhoneStep.tsx:100-117` |
|
||||
| iteration-1 #3 | No top bar needed on the login page | `PublicLayout.tsx:8-14` |
|
||||
| iteration-1 #4 | Language/theme switches should live only in Settings | `PublicLayout.tsx:11-13`, `SettingsPanel.tsx:10-16` |
|
||||
| iteration-1 #5 | Icon set should be modernized with semantic mapping | `AppIcon/config.ts:1-19` — MUI Icons replaced with Lucide |
|
||||
| iteration-1 #6 | Default route should resolve by role, not spin forever | `RoleGuard.tsx:34-38`, `RoleRouter.tsx:31-46` |
|
||||
| iteration-1 #7 | React-child console error on phone submit | `OtpStep.tsx:104-115` + regression test `richText.test.tsx` |
|
||||
| iteration-1 #8 | OTP input spacing broken in `fa` locale | `OtpInput.tsx:119-127` — `gap` not RTL-mirrored `spacing` |
|
||||
| iteration-1 #9 | Nurse dashboard needed a full visual refactor | `NurseDashboardScreen.tsx:28-45` |
|
||||
| iteration-1 #10 | Replace drawer nav with a bottom navbar | `NurseLayout.tsx:11-26`, `BottomBar.tsx:18-40` |
|
||||
| iteration-1 #11 | Each nav group needs a summary root page | `NursePracticeScreen.tsx:13-18`, `NurseFinanceScreen.tsx:10-15` |
|
||||
| iteration-1 #12 | Horizontal scroll present on nurse dashboard | `AppFrame.tsx:31-33,96-103` — structurally disabled |
|
||||
| iteration-1 #13 | App should be phone-width at every viewport | `layout/config.ts:5-14` — `APP_FRAME_MAX_WIDTH=480` |
|
||||
| iteration-2 #1 | Nurse greeting section useless; card borders looked bad | `NurseDashboardScreen.tsx:38-41`, `AccentCard.tsx:15-21` |
|
||||
| iteration-2 #2 | Bottom nav needed a better, floating form | `BottomBar.tsx:22-34,93-101`, `layout/config.ts:37-44` |
|
||||
| iteration-2 #3 | No easy way to reach the nurse requests page | `NurseLayout.tsx:16-21,39-44` — now a tab with a badge |
|
||||
| iteration-2 #4 | Theme-mode toggle buttons had no gap | `ThemeModeSetting.tsx:60-75` |
|
||||
| iteration-2 #5 | `/nurse/verification` and `/nurse/profile` forms were an undifferentiated list | `nurse/profile/page.tsx:68-80`, `FormSection.tsx:26-40` |
|
||||
| iteration-2 #6 | `/nurse/services` had the same forms problem | `VariantBuilder.tsx:1-70` — react-hook-form + `StepperHeader` wizard |
|
||||
|
||||
## Report follow-ups confirmed completed by a later phase (curated — the notable ones)
|
||||
|
||||
Verification method: cross-referenced against the later phase's own "What was built" section, not re-traced
|
||||
against current code (these predate the flow-gap sweep and were folded in only where a later report's own
|
||||
claim is unambiguous). Treat as **strong evidence**, not the same rigor as the code-verified items above.
|
||||
|
||||
| Origin | Item | Closed by |
|
||||
|--------|------|-----------|
|
||||
| backend-phase-0 follow-up | Extend audit interceptor to write `audit_logs`; evolve schema; add `IHolidayCalendar` seed | backend-phase-1 |
|
||||
| backend-phase-0 follow-up | `ISmsSender` seam + auth/OTP REST surface + rate-limit policies | backend-phase-2 |
|
||||
| backend-phase-0 follow-up | `WebApplicationFactory` integration-test project | backend-phase-2 (`Baya.Test.Api`) |
|
||||
| backend-phase-2 follow-up | Profiles/patients/addresses/nurse bank accounts; `/me` completion flags read real tables | backend-phase-3 |
|
||||
| backend-phase-2 follow-up | Shahkar/KYC populate `NationalId`; `/me` surfaces real `nurseVerificationStatus` | backend-phase-6 |
|
||||
| backend-phase-3 follow-up | `customer_addresses`+`nurse_service_areas` geography + geocoder | backend-phase-4 |
|
||||
| backend-phase-3 follow-up | `is_verified` flip transaction; Shahkar/KYC; bank-ownership step coupling | backend-phase-6 |
|
||||
| backend-phase-3 follow-up | `average_rating`/`total_reviews` aggregate recompute | backend-phase-14 (`total_completed_bookings` recompute unconfirmed — see BL-245-adjacent residue) |
|
||||
| backend-phase-4 follow-up | `nurse_search_index` fan-out wired to service-area add/remove | backend-phase-7 |
|
||||
| backend-phase-4 follow-up | EVV distance check consuming address lat/long | backend-phase-9 |
|
||||
| backend-phase-5 follow-up | `nurse_search_index`, `INurseSearch`, search query, index fan-out | backend-phase-7 |
|
||||
| backend-phase-5 follow-up | `booking_requests.variant_snapshot_json` persistence | backend-phase-9 (not b8 as originally planned) |
|
||||
| backend-phase-6 follow-up | Search reads `nurse_profiles.is_verified` for `is_searchable` | backend-phase-7 |
|
||||
| backend-phase-7 follow-up | Search results feed the booking flow; `required_caregiver_gender` capture | backend-phase-8 |
|
||||
| backend-phase-8 follow-up | `accepted_awaiting_payment` → real `bookings` row on capture | backend-phase-9/10 |
|
||||
| backend-phase-8 follow-up | Stage-2 encrypted `booking_care_instructions` | backend-phase-9 |
|
||||
| backend-phase-8 follow-up | Three-amount split, snapshots, sessions, EVV, dispute window | backend-phase-9 |
|
||||
| backend-phase-9 follow-up | Real card capture replacing `IPaymentCaptureSimulator` | backend-phase-10 |
|
||||
| backend-phase-9 follow-up | Reviews-on-completed-booking; `partner_center_id` wiring | backend-phase-14, backend-phase-15 |
|
||||
| backend-phase-11 follow-up | Real `IBnplProvider` (beyond the b11 revert-only stub) | backend-phase-12 |
|
||||
| backend-phase-11 follow-up | Clawback netting/recovery + real `INursePayoutStatus` | backend-phase-13 |
|
||||
| backend-phase-12 follow-up | `settled_at`-gates-payout coupling flag (`require_bnpl_settlement_for_payout`) | backend-phase-13 (shipped, default off) |
|
||||
| backend-phase-14 follow-up | Ticket system + partner centers | backend-phase-15 |
|
||||
| frontend-phase-5 follow-up 3 | Admin verification review queue (pass/reject, doc viewer) | frontend-phase-15 |
|
||||
| frontend-phase-10 follow-up 1 | Admin refund console (`RefundPanel`) | frontend-phase-15 |
|
||||
| frontend-phase-12 follow-up 2 | Admin payout console (`/admin/payouts`) | frontend-phase-15 |
|
||||
| frontend-phase-13 follow-up 2 | Review moderation console (`/admin/reviews`) | frontend-phase-15 |
|
||||
| frontend-phase-14 follow-up 1 | Admin global ticket queue + internal-note composer | frontend-phase-15 |
|
||||
| frontend-phase-14 follow-up 2 | Support-alert worklist, partner-center console, audit viewer | frontend-phase-15 |
|
||||
| ui-phase-0/1 follow-up | Core primitives (EmptyState/ErrorState/PageHeader/`<Money>`/Jalali picker) | ui-phase-1 |
|
||||
| ui-phase-0/1 follow-up | App-wide motion pass consuming `--bal-motion-*` tokens | ui-phase-12 |
|
||||
| ui-phase-3 follow-up | Skip-onboarding («بعداً تکمیل میکنم») | ui-phase-4 |
|
||||
| ui-phase-4 follow-up | Public/guest storefront + landing page | ui-phase-13 |
|
||||
| ui-phase-4/5 follow-up | Zero-case copy sweep; `continue_payment` CTA string sweep | ui-phase-12 |
|
||||
| ui-phase-7 follow-up 1 | `DashboardActivationSlot`'s full "go live" checklist content | ui-phase-8 |
|
||||
| ui-phase-8 follow-up | Coverage map visualization | ui-phase-9 |
|
||||
| handoff-after-refinement-phase-0 | No `USE_*_MOCK` flag had been flipped yet | refinement-phase-4 (14 of 22 domains flipped to real) |
|
||||
|
||||
**Not included above** (checked and found still genuinely open, or not confirmed by a later report):
|
||||
`support_alerts` FK constraints (BL-not-filed — low-priority schema hygiene, see decisions.md), region
|
||||
bulk-import feed, `SuspendNurse`/`ResolveSupportAlert`/`FlagConcern` admin actions (BL-245), and everything
|
||||
else already carried into [backlog.md](backlog.md)'s Deferred section.
|
||||
@@ -0,0 +1,306 @@
|
||||
# Backlog — every open item, triaged
|
||||
|
||||
> Last verified: 2026-08-02 against commit `51e86a1`.
|
||||
|
||||
Reconciled from five ledgers — [hardening/issues.md](../../archive/post-phase/hardening/issues.md) (18 items),
|
||||
[for-backend.md](../../archive/build-chain/working-context/frontend/requests/for-backend.md) (67 REQs), 53
|
||||
report "Follow-ups" sections, 22 backend handoff files, two manual-testing iterations — plus
|
||||
[product/notes/open-questions.md](../../product/notes/open-questions.md), root [CLAUDE.md](../../CLAUDE.md)
|
||||
§6, phase 2's drift list, and phase 3's **283 flow gaps** (the primary, freshest, code-verified input; see
|
||||
[docs/flows/index.md](../flows/index.md)). ~700 raw candidate rows were harvested; this file is the deduped,
|
||||
triaged result. Old ids stay greppable in their source files — nothing was rewritten.
|
||||
|
||||
**Area**: `client` · `server` · `contract` · `ops` · `product` · `docs`. **Sev**: `blocker` (product is wrong
|
||||
or unusable) · `major` · `minor` · `deferred`. **Status**: `open` · `in-progress` · `blocked (on what)` ·
|
||||
`deferred (trigger)`. **Blocks**: the [docs/flows/](../flows/index.md) journey(s) it degrades.
|
||||
|
||||
Closed items (with what closed them) are in [backlog-closed.md](backlog-closed.md). The distilled decision
|
||||
log is in [decisions.md](decisions.md). The business-area overlay is in [implemented.md](implemented.md).
|
||||
|
||||
---
|
||||
|
||||
## Blockers (18)
|
||||
|
||||
| ID | Area | Item | Origin | Status | Blocks |
|
||||
|----|------|------|--------|--------|--------|
|
||||
| BL-001 | server | Admin RBAC is structurally dead: `DynamicPermissionService.CanAccess` grants only the literal role `admin`; every seeded admin holds `super_admin`/`finance` and 403s on all `DynamicPermission`-gated controllers. No code path ever writes a `DynamicPermission` claim. | H-04, H-05, admin-backoffice gap-1/3/12, bnpl gap-7, cancellation-and-refunds gap-9, messaging-tickets gap-1/3, nurse-verification gap-3, nurse-earnings-and-payouts gap-7, partner-center gap-8, reviews gap-1/2, booking-request gap-3, booking-lifecycle-evv gap-3/5, nurse-catalog-and-pricing gap-2 | open | admin-backoffice, bnpl-installments, cancellation-and-refunds, messaging-tickets, nurse-verification, nurse-earnings-and-payouts, partner-center, reviews, booking-request, booking-lifecycle-evv, nurse-catalog-and-pricing |
|
||||
| BL-002 | ops | No seeded account holds the literal `admin` role (`Seed:AdminUsername`/`Password` unset, `SeedDataBase.cs` returns early) — even a fixed BL-001 is untestable out of the box. | admin-backoffice gap-2 | open | admin-backoffice |
|
||||
| BL-003 | ops | Committed live credentials (DB `sa`, JWE/field-encryption key halves, Kavenegar, Neshan, Finnotech, Telegram bot token) must be rotated and moved out of git before onboarding real users. | CLAUDE.md §6, refinement-phase-0 follow-up 3 | open | — (pre-launch) |
|
||||
| BL-004 | ops | `GET /api/v1/dev/last_otp/{phone}` is anonymous and Development-only by design, but the deployment runs as Development — live on `api.balinyaar.ir`, lets anyone who knows a phone read its login code. | auth-login-otp gap-9, public-front-door gap-10 | open | auth-login-otp, public-front-door |
|
||||
| BL-005 | server | Card payment is a dead end everywhere: `MockPaymentProvider` redirects to a non-existent host, and nothing fires the PSP webhook locally, so no new card payment can ever reach `ConfirmPaymentAndPostLedger`. | checkout-and-payment gap-1/2 | open | checkout-and-payment |
|
||||
| BL-006 | contract | Booking deadline timestamps ship without a timezone (`DateTime` not `DateTimeOffset`); the client reads the offset-less value as local time — in Tehran the 30-minute payment window renders ~4h and expires while the timer still shows time left. | booking-request gap-1 | open | booking-request, checkout-and-payment |
|
||||
| BL-007 | ops | No `bnpl` payment-gateway row is ever seeded — every BNPL initiate/eligibility call 400s with "No active BNPL gateway is configured" on the live server. | bnpl gap-1 | open | bnpl-installments |
|
||||
| BL-008 | client | The BNPL wizard is a dead end for every real booking-request id: its mock cross-imports the bookings-mock store (seeded ids 1-2 only), bypassing the real-data flip entirely. | H-07, bnpl gap-2/3 | open | bnpl-installments |
|
||||
| BL-009 | client | Refunds mock reads the retired bookings-mock store (ids 5001-5005 only), so any real booking id 404s inside the cancel-policy preview; flipping the flag today would also render a 10000% refund (percent-scale bug on an already-0-100 field). | H-06, cancellation-and-refunds gap-1/2/4 | open | cancellation-and-refunds |
|
||||
| BL-010 | client | Verification is 100% client-mocked under one flag covering nurse + public + admin; the mock resets to empty on every reload, so even a server-verified nurse renders unverified everywhere the app reads it, hiding the real search-visibility gate and "go live" CTA. `submitCredentialDetails` is also a no-op though the real endpoint matches field-for-field. | H-08, nurse-verification gap-1/2/4, onboarding-nurse gap-1/2/3 | open | nurse-verification, onboarding-nurse, search-and-discovery, public-front-door |
|
||||
| BL-011 | client | Patient/care records are 100% mocked on both the family record and nurse visit-note panel, and the real DTO shapes would break a naive flip: medication/routine fields mismatch server field-for-field, and care-plan ids are `string` client-side vs `long` server-side. | care-circle-patients gap-1..4, patient-care-records gap-1..3, H-17 | open | care-circle-patients, patient-care-records, booking-lifecycle-evv |
|
||||
| BL-012 | server | Nurse earnings/payouts: mock hides four working endpoints; no `process` operation exists anywhere in the client (the irreversible payout step has no UI); `DeriveEarningsState` marks a booking "paid" whenever it merely has a payout *link* regardless of that payout's actual status; the four earnings buckets don't reconcile against the ledger. | H-09, nurse-earnings-and-payouts gap-1/4/5/9/10/11 | open | nurse-earnings-and-payouts |
|
||||
| BL-013 | client | Partner center is 100% mocked with zero tenancy (any signed-in caller resolves to center id 1), and 5 core routes (`centers/me`, `/me/nurses`, `/me/bookings`, `/me/bookings/{id}`, `/me/settlement`) don't exist server-side; a center owner has no legal read of its own issued invoices. | partner-center gap-1/2/3/9/10, REQ-032, REQ-033, REQ-064 | open | partner-center |
|
||||
| BL-014 | server | Search results are index rows, not nurses — one nurse with 3 variants × 3 areas shows as "9 پرستار" with no de-dup; the trust dossier on results is mocked, and the by-id profile page asserts "verified" unconditionally, even for an `in_review` nurse. | search-and-discovery gap-1/3/4 | open | search-and-discovery, public-front-door |
|
||||
| BL-015 | client | Editing an address through the UI silently destroys data: the form never collects `postalCode`/`recipientName`/`recipientPhone`, and the update handler nulls them unconditionally on every save. | addresses-and-map gap-4 | open | addresses-and-map |
|
||||
| BL-016 | server | A booking whose remaining sessions get swept to `missed` never reaches a payable state — the `allSettled → Completed` re-check only lives inside the checkout path, so already-checked-out sessions can never enter a payout batch. The "today" session feed also applies no date filter and lists a nurse's whole history. | booking-lifecycle-evv gap-1/2 | open | booking-lifecycle-evv, nurse-earnings-and-payouts |
|
||||
| BL-017 | server | Reviews can never leave moderation on the live stack: the queue and status-PATCH sit behind BL-001, and `AutoApproveClean` is absent from config — a real review never reaches a nurse's profile except via the seeder. | reviews gap-1/2/3 | open | reviews |
|
||||
| BL-018 | server | A production database has zero option groups outside Development — the one seeded «نوع شیفت» group only exists because the demo seeder is Dev-only, so every builder collapses to two steps and every variant in a category duplicates every other, deployed. | nurse-catalog-and-pricing gap-3 | open | nurse-catalog-and-pricing |
|
||||
|
||||
## Major (86)
|
||||
|
||||
| ID | Area | Item | Origin | Status | Blocks |
|
||||
|----|------|------|--------|--------|--------|
|
||||
| BL-019 | client | Customer without an emergency contact cannot save name or language — the profile validator requires it non-empty but the UI has no field-level pre-check, and the resulting 400 is completely silent (no `onError`). | account-and-settings gap-1/2 | open | account-and-settings |
|
||||
| BL-020 | client | Partner settings hub shows fabricated identity — `USE_PARTNER_MOCK` resolves the same center for any caller, zero real tenancy on the screen. | account-and-settings gap-6 | open | account-and-settings, partner-center |
|
||||
| BL-021 | client | Sign-out confirmation is inconsistent — customer hub gates it behind a confirm dialog; nurse/admin/partner fire on the first tap and end every session on the account. | account-and-settings gap-7 | open | account-and-settings |
|
||||
| BL-022 | client | A customer cannot set an avatar — the real endpoint exists but the client's `uploadAvatar` only targets the nurse route. | account-and-settings gap-8 | open | account-and-settings |
|
||||
| BL-023 | product | `preferredLanguage` is stored and never consumed anywhere — the UI locale comes only from the URL prefix, so the زبان sheet has two controls that do different things. | account-and-settings gap-3 | open | account-and-settings |
|
||||
| BL-024 | server | Nurse avatars never load anywhere in the app — `LocalDiskObjectStorage.GetUrl` returns a `file://` URI a browser cannot fetch. | account-and-settings gap-4, onboarding-nurse gap-4, search-and-discovery gap-6, public-front-door gap-2 | open | account-and-settings, onboarding-nurse, search-and-discovery, public-front-door, booking-request |
|
||||
| BL-025 | ops | `NEXT_PUBLIC_NESHAN_KEY` is unset in every environment, so the real map (tiles, address search, locate-me) never renders anywhere — every environment gets the keyless grid stand-in, and the response shapes are unverified against the live Neshan API. | addresses-and-map gap-1/2 | open | addresses-and-map |
|
||||
| BL-026 | client | `CITY_CENTROIDS` is keyed on mock-seed city ids; only Tehran matches the real seeded cities, so the map picker opens ~400km away for every other city. | addresses-and-map gap-3 | open | addresses-and-map |
|
||||
| BL-027 | client | Recipient name/phone/postal-code are never displayed anywhere on an address card either, even where the (broken, BL-015) form would collect them. | addresses-and-map gap-5 | open | addresses-and-map |
|
||||
| BL-028 | contract | Admin list pagination silently breaks on flip: `admin/apis/clientApi.ts` sends `page_size`; controllers declare `PageSize`. Binding is case- but not separator-insensitive, so every admin list falls back to the server default page size. | admin-backoffice gap-6, search-and-discovery gap-13 | open | admin-backoffice, search-and-discovery |
|
||||
| BL-029 | server | `/fa/admin/roles` and `/fa/admin/users` have no server — `admin_roles/*` and `admin_users/*` are phantom; REQ-061 (admin user directory) was never given a ledger header despite ten live client files citing it. | admin-backoffice gap-7/8, REQ-031, REQ-061 | open | admin-backoffice |
|
||||
| BL-030 | client | `admin_cancellation_policies/list|upsert` are unwired — no screen edits the cancellation tiers; they are effectively read-only in production. | admin-backoffice gap-11 | open | admin-backoffice, cancellation-and-refunds |
|
||||
| BL-031 | client | Reuse-detection cannot tell theft from two tabs: the single-flight refresh guard is per-tab module state, so two tabs 401ing at once race and the loser's classified-as-reuse kills every session with no explanation. `useSelectRole`'s own rotation bypasses the guard the same way. | auth-login-otp gap-2/3 | open | auth-login-otp |
|
||||
| BL-032 | server | Session revocation does not kill access tokens — `RefreshTokenCommandHandler` revokes sessions but never rotates the security stamp, so "logged out everywhere" is up to 60 minutes late. | auth-login-otp gap-4 | open | auth-login-otp |
|
||||
| BL-033 | client | The access cookie expires 45 minutes before the token does (900s vs 60min), so 15 idle minutes bounces any full page load to `/login` even though the refresh cookie could have recovered the session. | auth-login-otp gap-5 | open | auth-login-otp |
|
||||
| BL-034 | client | A `?next=` pointing at `/partner/...` never survives login — `appRoleForPath` has no partner branch, and `/me` carries no signal that the caller administers a partner center. | auth-login-otp gap-11, partner-center gap-4, REQ-038 | open | auth-login-otp, partner-center |
|
||||
| BL-035 | client | `useLogout` always signs out every device — an absent `refreshToken` means "everywhere"; no UI offers a single-device choice. | auth-login-otp gap-12 | open | auth-login-otp |
|
||||
| BL-036 | client | `resolveRoleDestination` checks admin before customer/nurse, so a user holding both an admin sub-role and `customer` can never reach the family app from login. | auth-login-otp gap-13 | open | auth-login-otp |
|
||||
| BL-037 | ops | `POST /auth/request_otp` 500s on a fresh clone — the committed SMS provider is `telegram` with nothing listening; login is unreachable until booted with the mock provider. | auth-login-otp gap-1 | open | auth-login-otp |
|
||||
| BL-038 | client | Query cache is never cleared on logout/login — only `authKeys` are invalidated, so a device that signs out of one account and into another can render stale, cross-account data from the previous session's cache. | H-11 | open | auth-login-otp, account-and-settings |
|
||||
| BL-039 | server | `BookingRoles.Admin` over-grants: Support/Moderation roles get clinical care-instruction reads and financial nurse-payable-balance reads, wider than the "admin/finance" comment claims. | H-05 | open | booking-lifecycle-evv, nurse-earnings-and-payouts |
|
||||
| BL-040 | client | The «پاسخداده» nurse-inbox tab is unpaged — the API filters one status at a time with no group filter, so `nurse/requests` fires three page-1 queries and concatenates; a nurse with >20 answered requests silently loses rows. | booking-request gap-4, REQ-050 (residue) | open | booking-request |
|
||||
| BL-041 | ops | Nothing seeded is actionable in the booking-request flow — no `pending`/`accepted` request survives the demo seeder's epoch-anchored aging, so the nurse inbox, accept/reject and payment-window handoff are all unwalkable without creating a fresh request. | booking-request gap-2 | open | booking-request |
|
||||
| BL-042 | server | `nurseAvatarUrl` on a booking request is served as a raw local-disk path — same root cause as BL-024, called out because checkout renders it directly. | booking-request gap-11 | open | checkout-and-payment |
|
||||
| BL-043 | client | `NEXT_PUBLIC_EVV_MOCK_GPS` now defaults to off (derived from the now-real bookings flag), so a tester away from the seeded Tehran address fires a real GPS mismatch — and a support alert — on every check-in; the doc-comment still describes the old default. | booking-lifecycle-evv gap-6 | open | booking-lifecycle-evv |
|
||||
| BL-044 | server | The nurse never sees the address on a confirmed booking — `BookingMapper.ToDetailDto` nulls the address snapshot for the nurse role even though the server itself reads it to compute the EVV match. | booking-lifecycle-evv gap-7, REQ-051 | open | booking-lifecycle-evv |
|
||||
| BL-045 | client | `POST bookings/submit_care_instructions/{id}` is unwired — no customer form exists anywhere; on a booking a tester creates, the nurse's care-instructions card is empty. | booking-lifecycle-evv gap-10 | open | booking-lifecycle-evv |
|
||||
| BL-046 | client | The nurse booking detail is half real: the bookings/EVV half is server truth, but the mounted visit-notes panel (task checklist, continuity history, note-save) is entirely `patientRecords`-mocked — same root cause as BL-011. | booking-lifecycle-evv gap-12 | open | booking-lifecycle-evv |
|
||||
| BL-047 | client | H-06's exact mocked-cross-import defect also shows up as: `CHANNEL_BY_BOOKING` pins the BNPL-revert refund channel to one fixture id, so every real booking silently demos as a card refund. | cancellation-and-refunds gap-3 (folded severity note) | open | cancellation-and-refunds |
|
||||
| BL-048 | contract | The client discards six fields the server now serves on refund status (`platformFeeRefundedIrr`, `nursePayoutRefundedIrr`, `refundPercentageApplied`, `cancellationPolicyCode`, `createdAt`, `completedAt`) — the fee-split transparency section can never render even once BL-009 is fixed. | cancellation-and-refunds gap-5 | open | cancellation-and-refunds |
|
||||
| BL-049 | contract | Cancellation enum drift on three fields (`CancellationPolicyCode`, `CancellationLeadTime`, `appliesTo`) between client and the seeded/wire values — every one drives an i18n lookup that will miss on flip. | cancellation-and-refunds gap-6 | open | cancellation-and-refunds |
|
||||
| BL-050 | server | `POST admin_refunds` creates and executes in one call — no preview/approve/reject route exists, so the admin `RefundPanel`'s three-step console has no real backend. | cancellation-and-refunds gap-10, REQ-035 | deferred (trigger: admin refund console prioritized) | cancellation-and-refunds |
|
||||
| BL-051 | client | No admin console exists for refund/clawback settlement — `confirm_settlement`, `mark_failed`, `write_off` all have server handlers but zero client screens. | cancellation-and-refunds gap-12 | open | cancellation-and-refunds |
|
||||
| BL-052 | server | `expectedCustomerRefundEta` is hardcoded null in the cancellation preview — the BNPL 10-business-day window is invisible before the customer confirms, exactly where it matters most. | cancellation-and-refunds gap-14 | open | cancellation-and-refunds |
|
||||
| BL-053 | ops | `customer_no_show` has no seeded cancellation-policy row — the product doc's "up to 100% charge" tier does not exist; a customer no-show falls through to the 50%-back tier. | cancellation-and-refunds gap-15 | open | cancellation-and-refunds |
|
||||
| BL-054 | server | No credit note / invoice reversal on refund — a refunded booking's VAT-bearing commission invoice stays as originally issued. | cancellation-and-refunds gap-16 | open | cancellation-and-refunds |
|
||||
| BL-055 | server | Seeded visit-note `taskResults` labels are lost — the handler deserializes with case-sensitive options against camelCase seeded JSON; any externally-written JSON in that column silently degrades to blank labels. | care-circle-patients gap-5, patient-care-records gap-7 | open | care-circle-patients, patient-care-records |
|
||||
| BL-056 | client | Editing a patient destroys their birth date — the form always submits `ageToBirthDate(age)` = `YYYY-01-01`, overwriting the real recorded date on every save even with no change. | care-circle-patients gap-10 | open | care-circle-patients |
|
||||
| BL-057 | server | Tenancy leak on patient records: a foreign patient returns 403, not 404, across all three handlers — confirms the patient exists to an unauthorized caller, violating the repo's 404-not-403 invariant. | patient-care-records gap-8 | open | care-circle-patients, patient-care-records |
|
||||
| BL-058 | contract | A verified nurse is shown as unverified on the checkout screen — the checkout summary DTO declares `nurseVerified` but the server never constructs it. | checkout-and-payment gap-3, REQ-046 | open | checkout-and-payment |
|
||||
| BL-059 | client | Payment confirmation cannot deep-link to the booking — the server does serve `bookingId` but the client types it away and hardcodes null; the receipt also always hides the tracking code and paid-at. | checkout-and-payment gap-5, H-10, REQ-046 | open | checkout-and-payment |
|
||||
| BL-060 | client | The invoice screen derives a money row client-side (`gross − commission − vat`) that is wrong by exactly the VAT amount, and never shows the server's real `totalIrr` — violates the "client never computes money" rule. | checkout-and-payment gap-7 | open | checkout-and-payment |
|
||||
| BL-061 | server | Customer payment history is a live 404 — `bookings/payment_history` doesn't exist; the wallet «پرداختها» tab is permanently empty for a card-paying customer. | checkout-and-payment gap-8, REQ-047 | open | checkout-and-payment |
|
||||
| BL-062 | server | The escrow ledger has no read surface at all — a customer, admin or tester has no way to see the balanced capture group the flow's core money invariant depends on. | checkout-and-payment gap-10 | open | checkout-and-payment |
|
||||
| BL-063 | client | `getUnreadTotal` is a hardcoded `null` on the real path — the chrome support badge can never light up. | messaging-tickets gap-4, account-and-settings gap-10, REQ-059 | open | messaging-tickets, account-and-settings |
|
||||
| BL-064 | client | `mapSummary` hardcodes `lastMessagePreview`/`lastAuthorRole` to null on the real path — the ticket inbox card shows no message preview. | messaging-tickets gap-5, REQ-059 | open | messaging-tickets |
|
||||
| BL-065 | client | `POST /tickets/emergency` has zero client callers — the nurse's emergency banner only dials `tel:`; the "then opens a ticket" half of the business playbook is manual. | messaging-tickets gap-6 | open | messaging-tickets |
|
||||
| BL-066 | contract | 11 of 14 server notification types are unknown to the client's parser — only 3 deep-link; the nurse's most important notification (`booking_confirmed_nurse`) and the entire `booking_request_*` lifecycle are non-navigable. | notifications gap-1/2/3 | open | notifications, booking-request |
|
||||
| BL-067 | client | The client handles 10 notification types the server never emits, while the server's real payout-paid/payout-failed events have no client branch at all — a nurse is never told a payout paid or failed. | notifications gap-6 | open | notifications, nurse-earnings-and-payouts |
|
||||
| BL-068 | server | Every notification title/body is an English literal rendered verbatim into the Persian RTL feed — no i18n path for notification content, only for chrome. | notifications gap-7 | open | notifications |
|
||||
| BL-069 | client | No catalogue-authoring UI exists at all — all seven `admin_catalog/*` routes have zero client callers; an admin cannot add a pricing dimension without SQL, compounding BL-018. | nurse-catalog-and-pricing gap-1 | open | nurse-catalog-and-pricing |
|
||||
| BL-070 | server | ZWNJ is stripped from every stored Persian string via a global `FixPersianChars` normalizer, contradicting the repo's "with a ZWNJ, always" naming rule. | nurse-catalog-and-pricing gap-4 | open | nurse-catalog-and-pricing |
|
||||
| BL-071 | server | A nurse's price edit is not shielded from an in-flight booking request — the conversion reads the live variant price, not a request-time snapshot, so editing price inside the 30-minute payment window changes what the customer pays. | nurse-catalog-and-pricing gap-5 | open | nurse-catalog-and-pricing, booking-request |
|
||||
| BL-072 | client | The «همراهی و مراقبت روزمره» category is data-only per its own seed comment, but the builder offers it like any real pricing path. | nurse-catalog-and-pricing gap-8 | open | nurse-catalog-and-pricing |
|
||||
| BL-073 | client | `toHistoryItem` hardcodes `failureReason: null` though the live wire carries it — a failed payout loses its reason in the nurse's history on flip. | nurse-earnings-and-payouts gap-3 | open | nurse-earnings-and-payouts |
|
||||
| BL-074 | client | `previewPayoutBatch` sums net amounts and fabricates a processing date client-side — the client computing money and a payout date, violating the "client never computes money" rule. | nurse-earnings-and-payouts gap-6 | open | nurse-earnings-and-payouts |
|
||||
| BL-075 | contract | `recordTransferReference` targets a route that doesn't exist — the batch-detail reconcile field 404s on flip. | nurse-earnings-and-payouts gap-12, REQ-036 | deferred (trigger: payout reconciliation console prioritized) | nurse-earnings-and-payouts |
|
||||
| BL-076 | client | `POST admin_payouts/{id}/mark_failed` exists server-side but has no client op — a reconciled bank rejection cannot be recorded from the console. | nurse-earnings-and-payouts gap-14 | open | nurse-earnings-and-payouts |
|
||||
| BL-077 | server | Whole-city + a specific district in the same city are both accepted for one nurse, and search then returns her twice with no de-dup on `(nurseId, variantId)`. | nurse-service-areas gap-1 | open | nurse-service-areas, search-and-discovery |
|
||||
| BL-078 | client | The coverage screen has no error state — `isError` is dropped, so a failed list load renders the "no area registered" empty state, telling a nurse with real coverage she is invisible in search. | nurse-service-areas gap-3 | open | nurse-service-areas |
|
||||
| BL-079 | contract | No edit and no deactivate for a service area — changing a district means remove-then-add, which silently drops the nurse from search between the two calls. | nurse-service-areas gap-5 | open | nurse-service-areas |
|
||||
| BL-080 | client | `nurse/verification/page.tsx` unconditionally imports the verification mock module — the one production seam breach in the client; the mock ships in every build regardless of the render gate. | nurse-verification gap-4 | open | nurse-verification |
|
||||
| BL-081 | client | `foldQueueRows` folds the per-step admin verification queue to per-nurse items but leaves the step-progress counts at zero and the pager count wrong — a nurse's steps can straddle a page boundary. | nurse-verification gap-6, REQ-062 | open | nurse-verification |
|
||||
| BL-082 | contract | `approveVerification`/`rejectVerification`/`getDocumentSignedUrl` target routes that don't exist — the admin case page's visible approve/reject CTAs would 404 the moment BL-001 is fixed. | nurse-verification gap-7, REQ-034 | deferred (trigger: verification-admin console prioritized) | nurse-verification |
|
||||
| BL-083 | client | No admin UI exists for `suspend`/`scan_expiring` or the verification step-type catalog — the "data-driven catalog" design rule has no admin surface. | nurse-verification gap-8 | open | nurse-verification |
|
||||
| BL-084 | client | Admin verification case URLs are mistyped — the route folder is `[nurseId]` but the value passed is a `nurseVerificationId`; typing a nurse id into the URL opens the wrong case. | nurse-verification gap-11 | open | nurse-verification |
|
||||
| BL-085 | product | Onboarding never creates a `customer_profile` — it creates a patient and stops; nothing blocks a customer from booking without ever setting an emergency contact. | onboarding-customer gap-1 | open | onboarding-customer |
|
||||
| BL-086 | contract | A customer's own `gender` is unsettable through the app — no client call and no command field carries it; a fresh account's `/me.gender` stays null forever. | onboarding-customer gap-3 | open | onboarding-customer |
|
||||
| BL-087 | client | The customer profile upsert has no PATCH semantics — every sheet save rewrites the whole profile including name, so a stale form on one device can silently overwrite a name changed on another. | onboarding-customer gap-5 | open | onboarding-customer, account-and-settings |
|
||||
| BL-088 | client | `/fa/select-role` is reachable by any authenticated user with no guard, letting a customer permanently self-grant the `nurse` role by typing a URL, with no confirmation step for an irreversible grant. | onboarding-customer gap-7 | open | onboarding-customer |
|
||||
| BL-089 | client | `useSelectRole` swallows a failed post-select token rotation — the role persists server-side but the client keeps a stale token until the next silent refresh happens to fire, so a role-gated call in between 403s. | onboarding-customer gap-9 | open | onboarding-customer |
|
||||
| BL-090 | client | The duplicate-IBAN error message never reaches the nurse — the bank page discards the server's field error and shows a generic "registration failed" toast. | onboarding-nurse gap-7 | open | onboarding-nurse |
|
||||
| BL-091 | server | Bank-ownership inquiry ignores the national id entirely — a nurse who never did identity KYC (`national_id = NULL`) still gets `matchedNationalId = true`, so the payout gate opens on a claim nothing actually checked. | onboarding-nurse gap-8 | open | onboarding-nurse, nurse-earnings-and-payouts |
|
||||
| BL-092 | client | `verifyOwnership` is a dead seam op — no hook calls it, so a `mismatch` bank account has no re-inquiry affordance; the UI only offers "re-enter the IBAN". | onboarding-nurse gap-9 | open | onboarding-nurse |
|
||||
| BL-093 | contract | Shape mismatch blocks the partner portal even once mocked data is off: the server serves one capped/inline dashboard aggregate; the portal needs paginated `/me`-scoped splits it cannot get from that shape. | partner-center gap-5 | open | partner-center |
|
||||
| BL-094 | client | `GET /centers/{id}/dashboard` — the one portal endpoint that *is* real — needs a center id the portal has no way to discover, so it goes unused. | partner-center gap-6 | open | partner-center |
|
||||
| BL-095 | server | Structured `taskResults` are discarded in both directions on the real patient-records path — accepted on write and returned on read by the server, but the client hardcodes them away; the "wire has no structured field" client comments are simply wrong. | patient-care-records gap-6 | open | patient-care-records |
|
||||
| BL-096 | product | Tier (c) of the public front door (guest search + public nurse profiles) is unbuilt — REQ-066/067 need a backend phase plus an explicit privacy sign-off before any guest-facing search/profile screen is built. | public-front-door gap-6, ui-phase-13 follow-up | deferred (trigger: privacy sign-off + REQ-066/067 delivered) | public-front-door, search-and-discovery |
|
||||
| BL-097 | product | `/terms` and `/privacy` ship placeholder legal copy behind a draft banner, flagged for human/legal review since ui-phase-3 and still unreviewed — a real pre-launch item. | public-front-door gap-5, ui-phase-3 follow-up 1 | open | public-front-door |
|
||||
| BL-098 | server | `GET /api/v1/nurses/{id}/profile` returns 200 anonymously for an unverified (`in_review`) nurse — the persona that "must never appear in search" is reachable and enumerable by id, even though search itself correctly hides her. | public-front-door gap-3 | open | public-front-door, search-and-discovery |
|
||||
| BL-099 | client | The nurse cannot see their own reviews anywhere in the app — the only signal is a notification the (BL-001-blocked) moderation handler dispatches. | reviews gap-8 | open | reviews |
|
||||
| BL-100 | client | The client hardcodes the five review tag codes instead of reading the server's tag master — a newly seeded tag is invisible to the UI and an unseeded code fails submit outright. | reviews gap-7 | open | reviews |
|
||||
| BL-101 | server | `ReviewModerationStatus.Rejected` is unreachable end to end — the submit-time banned-word path maps to `Hidden`, not `Rejected`, and the only other producer is the BL-001-blocked admin PATCH; the client still renders a `rejected` chip nobody can ever trigger. | reviews gap-4 | open | reviews |
|
||||
| BL-102 | server | Review eligibility distinguishes "booking not found" from "not your booking" with a 200, letting any authenticated caller probe whether an arbitrary booking id exists — violates the repo's 404-not-403 tenancy invariant. | reviews gap-9 | open | reviews |
|
||||
| BL-103 | product | No free-text search exists — a customer who knows a nurse's name by name cannot find her; category/city/gender/price filters only. | search-and-discovery gap-10, REQ-041 | open | search-and-discovery |
|
||||
| BL-104 | client | Guests cannot reach search at all — every search route sits behind a customer-only route guard and `/search` is not in the public-path list, even though the underlying endpoints are already anonymous. | search-and-discovery gap-11 | open | search-and-discovery, public-front-door |
|
||||
|
||||
## Minor (115)
|
||||
|
||||
| ID | Area | Item | Origin | Status | Blocks |
|
||||
|----|------|------|--------|--------|--------|
|
||||
| BL-105 | client | Admin hub renders a raw i18n key as the role label — the translation keys were never added. | account-and-settings gap-5 | open | account-and-settings |
|
||||
| BL-106 | client | Customer hub navigates with a hand-built locale prefix instead of the shared navigation helper — works today, breaks silently if `localePrefix` ever changes. | account-and-settings gap-11 | open | account-and-settings |
|
||||
| BL-107 | client | UNVERIFIED: a system-theme user may see one light/dark flip after hydration (SSR cookie vs. post-hydration MUI resolution can disagree). | account-and-settings gap-12 | open | account-and-settings |
|
||||
| BL-108 | product | No notification-preference surface exists anywhere — the settings row only deep-links to the notification centre. | account-and-settings gap-9 | open | account-and-settings, notifications |
|
||||
| BL-109 | client | The pin is a required address-form field, so the API's "no pin" state and its UI badge are unreachable except via direct API calls or seed data. | addresses-and-map gap-6 | open | addresses-and-map |
|
||||
| BL-110 | ops | Geography is seeded one city per province; districts exist only for Tehran, so the region cascade is a two-step formality everywhere else. | addresses-and-map gap-7 | open | addresses-and-map |
|
||||
| BL-111 | client | `GET geo/tree` is live but unused — the region cascade makes three round trips instead of one. | addresses-and-map gap-8 | open | addresses-and-map |
|
||||
| BL-112 | client | Map-pin RTL geometry is dodged via inline style, untested for RTL drift beyond the keyless fallback path. | addresses-and-map gap-9 | open | addresses-and-map |
|
||||
| BL-113 | docs | No `product/` doc describes address entry, the map-pin picker, or the Neshan integration. | addresses-and-map gap-10 | open | addresses-and-map |
|
||||
| BL-114 | client | `/fa/admin/notifications` is a placeholder stub and a true orphan — no link reaches it and no bell is mounted in the admin shell. | admin-backoffice gap-9, notifications gap-8 | open | admin-backoffice, notifications |
|
||||
| BL-115 | client | `POST holidays/delete_holiday` is unwired — the admin console offers no delete affordance for a holiday row. | admin-backoffice gap-10 | open | admin-backoffice |
|
||||
| BL-116 | ops | `admin_search/rebuild_index` and `admin_booking_requests/expire` are reachable only by curl, and both 403 for a seeded admin anyway. | admin-backoffice gap-12 | open | admin-backoffice, booking-request |
|
||||
| BL-117 | client | `useAdminCapabilities` hides tabs but never blocks the route itself — every admin console stays URL-reachable regardless of capability; server enforcement is currently just the blanket BL-001 403. | admin-backoffice gap-13, H-14 | open | admin-backoffice |
|
||||
| BL-118 | client | A 429 on OTP verify renders as "wrong code" — the empty error body leaves nothing to branch on, unlike the phone-step's correct 429 handling. | auth-login-otp gap-6 | open | auth-login-otp |
|
||||
| BL-119 | contract | The client ignores the server's OTP metadata (`codeLength`, `expiresInSeconds`) and hardcodes its own — the 60-second code expiry is never shown, only the 120-second resend cooldown. | auth-login-otp gap-7 | open | auth-login-otp |
|
||||
| BL-120 | docs | Stale client comments claim the server exposes no OTP code length or machine-readable failure code — it exposes both. | auth-login-otp gap-8 | open | auth-login-otp |
|
||||
| BL-121 | client | A signed-in user visiting `/login` sees the phone form again instead of being redirected away, unlike `/welcome`. | auth-login-otp gap-10 | open | auth-login-otp |
|
||||
| BL-122 | product | The OTP SMS template is not WebOTP-conformant, so the client's WebOTP autofill ships but never fires; landing this is a Kavenegar-dashboard template edit (ops action), not a code change. | auth-login-otp gap-14, REQ-039 | open | auth-login-otp |
|
||||
| BL-123 | ops | No seeded account is role-less, so `/fa/select-role` has no natural path in the demo world. | auth-login-otp gap-15, onboarding-nurse gap-10 | open | onboarding-customer, onboarding-nurse |
|
||||
| BL-124 | client | Client hardcodes English toasts for 401/403/5xx/network errors — no i18n dictionary. | H-13 | open | (all authenticated flows) |
|
||||
| BL-125 | client | Admin read-only consoles are inconsistently client-guarded — some pages check capabilities only to gate mutation buttons, `admin/audit` has zero check, and no shared `CapabilityGuard` component exists. | H-14 | open | admin-backoffice |
|
||||
| BL-126 | docs | `bnpl/apis/clientApi.ts` and its header comment both claim the by-request BNPL read 404s — it exists and returns 200, a stale comment blocking a partial de-mock. | bnpl gap-5 | open | bnpl-installments |
|
||||
| BL-127 | client | `acceptBnplSchedule` derives request status client-side from the order status, mislabelling a failed/reverted/cancelled order as awaiting-payment — the expired-window branch can never fire on the real path. | bnpl gap-6 | open | bnpl-installments |
|
||||
| BL-128 | product | `providerCommissionReversedAmount` is left null on a reverted BNPL order — nothing in the UI or admin surfaces the resulting commission shortfall. | bnpl gap-8 | open | bnpl-installments |
|
||||
| BL-129 | ops | Seeded BNPL eligibility status (`"approved"`) is not in the closed status vocabulary on either side of the wire. | bnpl gap-9 | open | bnpl-installments |
|
||||
| BL-130 | server | D3 KYC inputs are half-wired — the client sends `nationalId`/`mobile`/`consent`, but eligibility only reads the mobile; legal consent is collected and silently discarded. | bnpl gap-10, REQ-023 (residue note) | open | bnpl-installments |
|
||||
| BL-131 | ops | Nothing fires the BNPL provider webhook in dev, so even a successfully initiated real order would never reach `settled` — settlement is webhook-driven by design. | bnpl gap-11 | open | bnpl-installments |
|
||||
| BL-132 | client | `/fa/bookings/checkout/bnpl/gateway` is an orphan route on the real path, reachable only from the mock and gated outside development. | bnpl gap-12 | open | bnpl-installments |
|
||||
| BL-133 | client | No client path produces a `disputed` booking status — it renders in the status map but nothing ever raises one. | booking-lifecycle-evv gap-4 | open | booking-lifecycle-evv |
|
||||
| BL-134 | contract | The nurse's today-feed session row carries no service/variant label — every row says patient name + visit index only. | booking-lifecycle-evv gap-8, REQ-052 | open | booking-lifecycle-evv |
|
||||
| BL-135 | contract | A booking list row has no `patientId` — a booking card can never deep-link to the care record. | booking-lifecycle-evv gap-9, care-circle-patients gap-13, REQ-057 | open | booking-lifecycle-evv, care-circle-patients |
|
||||
| BL-136 | client | `POST booking_sessions/cancel/{id}` is unwired — a nurse cannot cancel a single visit from the UI. | booking-lifecycle-evv gap-11 | open | booking-lifecycle-evv |
|
||||
| BL-137 | docs | `services/bookings/apis/serverApi.ts` is dead code with no importer, and its own doc-block still claims the domain is mock-primary. | booking-lifecycle-evv gap-13 | open | booking-lifecycle-evv |
|
||||
| BL-138 | contract | No structured rejection-reason code exists — the client runs a seven-keyword heuristic on free text to decide whether to offer "retry with the same nurse," and it silently misses any other wording. | booking-request gap-5, REQ-044 | open | booking-request |
|
||||
| BL-139 | contract | `variantLabel` is typed as client-augmented/optional though the server always serves it, and the list row still lacks `variantPrice`/`variantPriceUnit`, so the nurse inbox card can't show the money without opening the detail. | booking-request gap-6/7, REQ-050 (residue) | open | booking-request |
|
||||
| BL-140 | contract | `patientAge` is served on every nurse-inbox row but never modelled client-side — a pure client-side type-widening fix. | booking-request gap-8 | open | booking-request |
|
||||
| BL-141 | client | The customer's pending-request list doesn't poll while the detail view does, so countdowns on `/bookings` go stale until a manual refresh. | booking-request gap-9 | open | booking-request |
|
||||
| BL-142 | client | `useBookingRequest` has no auth gate (unlike both list hooks) — a hard reload before hydration can flash an error card. | booking-request gap-10 | open | booking-request |
|
||||
| BL-143 | product | The nurse sees the patient's display name pre-accept, wider than the integration doc's "notes + coarse address only" — confirm this is the intended stage-1 boundary. | booking-request gap-12 | open | booking-request |
|
||||
| BL-144 | client | The client's 22 in-memory mocks are module-scoped and reset on reload/HMR — a mocked cancel demo doesn't survive navigating to the refund-status screen. | cancellation-and-refunds gap-17 | open | cancellation-and-refunds |
|
||||
| BL-145 | contract | `preview.refundableSessionIds` doesn't exist on the wire (only `sessions[]`) — harmless today, but the client type lies. | cancellation-and-refunds gap-7 | open | cancellation-and-refunds |
|
||||
| BL-146 | docs | The refunds `clientApi.ts` doc-block is stale — claims REQ-019/020/021 are contract gaps; all three routes exist and return 200. | cancellation-and-refunds gap-8 | open | cancellation-and-refunds |
|
||||
| BL-147 | server | No `GET refunds/my` — the wallet «استردادها» tab renders empty on the real path. | cancellation-and-refunds gap-11, REQ-048 | open | cancellation-and-refunds |
|
||||
| BL-148 | client | `/fa/admin/finance` is a hub with a single payouts tile — no refunds, clawbacks, or invoice-issue surface. | cancellation-and-refunds gap-13 | open | cancellation-and-refunds |
|
||||
| BL-149 | client | Client discards a field the server does serve on the visit-note read — the "N of M tasks" summary chip can never render on the real path. | care-circle-patients gap-6 | open | care-circle-patients |
|
||||
| BL-150 | contract | `deniedReason` enum mismatch on patient-record access denial — server sends `not_authorized`, client/docs declare `no_access`/`not_found`. | care-circle-patients gap-7, patient-care-records gap-5 | open | care-circle-patients, patient-care-records |
|
||||
| BL-151 | docs | Stale client comments assert patient-record endpoints "have no backend" — all exist, are authorized, and were probed 200. | care-circle-patients gap-8/9, patient-care-records gap-12 | open | care-circle-patients, patient-care-records |
|
||||
| BL-152 | ops | Seeded care plans are empty and seeded patients have no relation/conditions — three of four record tabs would be blank the moment the mock flag flips. | care-circle-patients gap-11/12, patient-care-records gap-9 | open | care-circle-patients, patient-care-records |
|
||||
| BL-153 | docs | No `product/business/` file covers patient care records at all — no documented rule for append-only, encryption, or the clinical-access gate. | care-circle-patients gap-14, patient-care-records gap-10 | open | care-circle-patients, patient-care-records |
|
||||
| BL-154 | client | Archiving a patient is optimistic with no undo — a stale/cross-tenant id 404s and the card silently reappears with a generic toast. | care-circle-patients gap-15 | open | care-circle-patients |
|
||||
| BL-155 | client | The only way to demo patient-record access-denial uses a hardcoded sentinel patient id — untested against how the real server denies. | patient-care-records gap-11 | open | patient-care-records |
|
||||
| BL-156 | contract | `sessionCount` is served nullable but typed non-null client-side; UNVERIFIED how the i18n label renders a null count. | checkout-and-payment gap-4 | open | checkout-and-payment |
|
||||
| BL-157 | docs | Payment client constants/comments are stale — claim a live endpoint 404s, claim the mock is still primary, and hardcode a platform-fee rate that no longer matches the server's seeded value. | checkout-and-payment gap-6 | open | checkout-and-payment |
|
||||
| BL-158 | server | Invoice carries no payment method, transaction reference, or seller fiscal identity — those rows never render on the real path. | checkout-and-payment gap-9, REQ-049 | open | checkout-and-payment |
|
||||
| BL-159 | server | `GET invoices/{bookingId}` tenancy is enforced only inside the query handler; UNVERIFIED whether a foreign customer actually gets 404. | checkout-and-payment gap-11 | open | checkout-and-payment |
|
||||
| BL-160 | server | Staff authorization is inconsistent by layer for tickets — the handler-level role set includes Support/Finance, but the controller attribute blocks them; two different definitions of "staff" in one request path. | messaging-tickets gap-3 | open | messaging-tickets |
|
||||
| BL-161 | client | `POST`/`DELETE tickets/{id}/participants` are unwired — no UI can attach a third party to a thread. | messaging-tickets gap-7 | open | messaging-tickets |
|
||||
| BL-162 | client | `close`/`reopen` are live and working server-side but unreachable client-side — one shared flag gates them alongside the genuinely-missing `assign`, so two already-shippable features stay dark. | messaging-tickets gap-8, REQ-063 (residue) | open | messaging-tickets |
|
||||
| BL-163 | server | `POST tickets/{id}/assign` is a phantom endpoint the client already targets. | messaging-tickets gap-9, REQ-063 | deferred (trigger: ticket assignment prioritized) | messaging-tickets |
|
||||
| BL-164 | docs | Stale client comment claims the server has no field for `clientMessageId` echo — it is sent and echoed today. | messaging-tickets gap-10 | open | messaging-tickets |
|
||||
| BL-165 | contract | Client `TicketAuthorRole` declares `system`; the server's role vocabulary doesn't — one side must move. | messaging-tickets gap-11 | open | messaging-tickets |
|
||||
| BL-166 | product | Auto-created coordination tickets open with no first message — both actors see an empty thread with no explanation of what it's for. | messaging-tickets gap-12 | open | messaging-tickets |
|
||||
| BL-167 | contract | Ticket message history is unpaginated by contract — a long thread has no incremental read. | messaging-tickets gap-13 | open | messaging-tickets |
|
||||
| BL-168 | client | Ticket attachments are fully designed but gated off behind a flag. | messaging-tickets gap-14, REQ-060 | deferred (trigger: attachment upload prioritized) | messaging-tickets |
|
||||
| BL-169 | contract | `refund_issued`/`refund_processed` and `review_moderated`/`review_published` are near-miss names on either side of the same contract. | notifications gap-4 | open | notifications |
|
||||
| BL-170 | server | `verification_expiry_prompt` is dispatched with a null data payload — even a matching client branch couldn't route it. | notifications gap-5 | open | notifications, nurse-verification |
|
||||
| BL-171 | client | `NotificationBellPopover` (full mark-read + deep-link logic) is exported but mounted nowhere — dead UI. | notifications gap-9 | open | notifications |
|
||||
| BL-172 | client | Notification "load more" isn't real pagination — it grows the page size and refetches the whole feed every time. | notifications gap-10 | open | notifications |
|
||||
| BL-173 | server | Notification retention (90d/24h) is a hardcoded constant rather than a `platform_configs` row, against the repo's config-is-rows convention. | notifications gap-11 | open | notifications |
|
||||
| BL-174 | client | Both notification pages are client components with no `generateMetadata`, departing from the thin-RSC-page convention. | notifications gap-12 | open | notifications |
|
||||
| BL-175 | product | No unread-notification cap or archive — the feed grows until the 90-day sweep; unread rows are never separately swept. | notifications gap-13 | open | notifications |
|
||||
| BL-176 | docs | `IVariantSnapshotSerializer`'s doc-comment names the wrong table for the frozen JSON snapshot. | nurse-catalog-and-pricing gap-6 | open | nurse-catalog-and-pricing |
|
||||
| BL-177 | server | Category `iconKey` and description fields are null for all seeded categories — the catalog grid falls back to a generic icon with no explainer copy. | nurse-catalog-and-pricing gap-7 | open | nurse-catalog-and-pricing |
|
||||
| BL-178 | server | `sessionCount` is unvalidated against `priceUnit` — nothing stops an invalid combination from being saved. | nurse-catalog-and-pricing gap-9 | open | nurse-catalog-and-pricing |
|
||||
| BL-179 | client | The mock payout store's fixture booking ids deep-link into the (now real) bookings screens and 404. | nurse-earnings-and-payouts gap-2 | open | nurse-earnings-and-payouts |
|
||||
| BL-180 | contract | The client sends `Idempotency-Key` on payout generate/retry, which the controller never reads — decorative and misleading to a reader. | nurse-earnings-and-payouts gap-13 | open | nurse-earnings-and-payouts |
|
||||
| BL-181 | server | `holidayShifted` is always false — `PayoutBatchDto` carries no such flag, so a bank-holiday date shift can never be shown. | nurse-earnings-and-payouts gap-15 | open | nurse-earnings-and-payouts |
|
||||
| BL-182 | product | No payout forecast — the nurse dashboard's "next batch" line renders nothing on the real path. | nurse-earnings-and-payouts gap-16, REQ-053 | open | nurse-earnings-and-payouts |
|
||||
| BL-183 | client | Only one bank-rail failure reason is mapped to a label; every other reason falls back to a generic message. | nurse-earnings-and-payouts gap-17 | open | nurse-earnings-and-payouts |
|
||||
| BL-184 | docs | Stale payouts doc-blocks assert three live endpoints don't exist server-side — all three return 200. | nurse-earnings-and-payouts gap-18 | open | nurse-earnings-and-payouts |
|
||||
| BL-185 | client | Duplicate React keys on the nurse-service-areas search results whenever the BL-077 duplicate-listing bug fires. | nurse-service-areas gap-2, search-and-discovery gap-2 | open | nurse-service-areas, search-and-discovery |
|
||||
| BL-186 | docs | The service-areas integration doc states a conflict rule that the live server doesn't enforce (probed 200 where it claims a conflict). | nurse-service-areas gap-4 | open | nurse-service-areas |
|
||||
| BL-187 | client | No warning that removing a nurse's last service area de-lists her from search — the confirm-dialog copy doesn't distinguish this case. | nurse-service-areas gap-6 | open | nurse-service-areas |
|
||||
| BL-188 | ops | Remove-service-area was left end-to-end unverified this pass (token expired mid-probe); code-traced only. | nurse-service-areas gap-7 | open | nurse-service-areas |
|
||||
| BL-189 | ops | The shared demo DB has drifted from the seeder's own definitions (nurse 1 now has 5 service areas, not the seeded 3) and the idempotent seeder will never correct it. | nurse-service-areas gap-8 | open | nurse-service-areas |
|
||||
| BL-190 | client | Verification step model omits `isRequired` though the wire serves it, so the "X of Y" progress meter would be wrong the moment an optional step type is added. | nurse-verification gap-9 | open | nurse-verification |
|
||||
| BL-191 | contract | No `submittedAt` on the nurse verification status, and no per-step detail on the public trust-badge read. | nurse-verification gap-10, REQ-043, REQ-055 | open | nurse-verification, search-and-discovery |
|
||||
| BL-192 | ops | Seeded verification data leaves two of six catalog step types unexercisable in the demo world. | nurse-verification gap-12 | open | nurse-verification |
|
||||
| BL-193 | client | `POST customer_profiles/avatar` is live and implemented but no client code calls it — a customer can never set a photo. | onboarding-customer gap-2 | open | onboarding-customer, account-and-settings |
|
||||
| BL-194 | client | `preferredLanguage` write silently commits a default `'fa'` the customer never chose, the first time they open and save the language sheet. | onboarding-customer gap-4/6 | open | onboarding-customer |
|
||||
| BL-195 | client | `avatarUrl` on the nurse-profile upsert input is dead on the real path — populated by the form but never sent (harmless today, since the multipart route already persisted it separately). | onboarding-nurse gap-5 | open | onboarding-nurse |
|
||||
| BL-196 | docs | The onboarding integration doc states duplicate-IBAN returns 409; the handler actually returns 400 with a field error. | onboarding-nurse gap-6 | open | onboarding-nurse |
|
||||
| BL-197 | client | The bank-account page polls a stuck ownership inquiry forever, with no ceiling and no timeout copy. | onboarding-nurse gap-11 | open | onboarding-nurse |
|
||||
| BL-198 | server | The HTTP merchant-of-record resolver (`internal/bookings/{id}/center`) is `DynamicPermission`-gated and 403s for both seeded admins — reachable only in-process today. | partner-center gap-7 | open | partner-center |
|
||||
| BL-199 | server | Two merchant-of-record resolvers can disagree — BNPL reads a platform config value while invoicing resolves the actual center, so a MoR center's BNPL order and invoice could name different sellers. | partner-center gap-10 (folded severity, see BL-013) | open | partner-center, bnpl-installments |
|
||||
| BL-200 | ops | Seeded partner-center invoices carry null Moadian reference/PDF fields — the settlement view's document columns have nothing real to render. | partner-center gap-11 | open | partner-center |
|
||||
| BL-201 | product | Center self-onboarding (write-then-masked IBAN) is deferred and has never been exercised on a real route. | partner-center gap-12 | deferred (trigger: center self-service onboarding prioritized) | partner-center |
|
||||
| BL-202 | client | Welcome page's Open-Graph image is Latin-only — no Persian glyph rendering for the `fa` share card. | public-front-door gap-7 | open | public-front-door |
|
||||
| BL-203 | client | `/fa/welcome` has no in-app link at all — reachable only as the `/` rewrite body or by typing the URL. | public-front-door gap-8 | open | public-front-door |
|
||||
| BL-204 | ops | `NEXT_PUBLIC_SITE_URL` is unset in dev, so `robots.txt`/`sitemap.xml`/OG tags fall back to localhost (dev-only caveat, `.env.production` is correct). | public-front-door gap-9 | open | public-front-door |
|
||||
| BL-205 | ops | `next dev` misrepresents this flow entirely — stale prerenders serve ahead of the middleware; only a production build reproduces real behavior. | public-front-door gap-1 | open | public-front-door |
|
||||
| BL-206 | server | No anonymous rate limit on the public search/profile reads — falls back to the shared global per-IP limiter. | public-front-door gap-4, search-and-discovery gap-12, REQ-066 (residue) | open | public-front-door, search-and-discovery |
|
||||
| BL-207 | client | `GET nurses/{id}/review_tags` (the "% of reviewers said X" rollup) and `POST reviews/{id}/tags` are both unwired — no chip-aggregate view and no post-submit tag amendment. | reviews gap-5/6 | open | reviews |
|
||||
| BL-208 | server | The low-rating support alert is raised on submit but has no reachable triage surface — reads sit behind BL-001. | reviews gap-10 | open | reviews |
|
||||
| BL-209 | docs | Stale review doc comments claim the mock is still primary; the real client is live and the 14.6KB mock module is dead behind the flag. | reviews gap-11 | open | reviews |
|
||||
| BL-210 | server | `distanceKm` is always null on search results — the covering index carries no coordinate, so the distance chip can never render. | search-and-discovery gap-7 | open | search-and-discovery |
|
||||
| BL-211 | contract | `topReviewTag` and `variantDisplayName` are never served on search results — dead code on the client's tag chip and no multi-variant collapse. | search-and-discovery gap-8, REQ-040 | open | search-and-discovery |
|
||||
| BL-212 | client | `nurseGender` is hardcoded `'female'` in the public-profile mapping because the DTO carries no gender field — unused today, but a live lie in the typed model. | search-and-discovery gap-9, REQ-042 | open | search-and-discovery |
|
||||
| BL-213 | server | `attributeChips` is empty for every seeded nurse, and a masked reviewer name maps to an empty string — the profile snippet renders a bare separator with nothing around it. | search-and-discovery gap-14 | open | search-and-discovery |
|
||||
| BL-214 | contract | No nurse-facing read-back of submitted credential details (INO-on-file, specialties) — the returning-nurse form can't hydrate from the server. | REQ-056 | open | nurse-verification |
|
||||
| BL-215 | contract | Policy numbers (dispute-window hours, cancellation lead-time, refund ETA) have no public/authenticated read — the client keeps them single-sourced in a local constants file. | REQ-065, ui-phase-12 follow-up 3 | open | cancellation-and-refunds |
|
||||
| BL-216 | contract | No typed address/variant snapshot objects — `BookingDetailDto` still carries opaque JSON strings, forcing the client's defensive multi-key parse in three separate places. | REQ-045, ui-phase-6 follow-up 2, ui-phase-7 follow-up 2 | open | booking-lifecycle-evv, checkout-and-payment |
|
||||
| BL-217 | client | Two admin-only multi-field dialogs (`GrantRoleDialog` in `admin/roles`, `PreviewBatchDialog` in `admin/payouts`) still hold raw `useState` instead of react-hook-form — the only genuine residue of the app-wide form migration. | iteration-2 #7 | open | admin-backoffice |
|
||||
| BL-218 | client | `H-01` residue: the auth-gate root-cause fix landed, but `client/middleware.ts` was never migrated to Next 16's `src/proxy.ts` as the original fix prescribed, and `outputFileTracingRoot` was never added alongside `turbopack.root` — a production build may still warn from the stray root-level lockfile. | H-01 | open | auth-login-otp |
|
||||
| BL-219 | docs | Rename the `SET_VIA_USER_SECRETS_OR_ENV` placeholder sentinel now that `user-secrets` is confirmedly removed — filed by phase 2 with a worklist (`appsettings.*.json`, `StartupSecretsGuard.PlaceholderMarkers`, `StartupSecretsGuardTests.cs`, `docs/rules/server/structure.md`); the two `.githooks/*` files in the original worklist no longer exist (phase 7 dropped the pre-commit hook — see [decisions.md](decisions.md)). Optional cleanup, not urgent — the name is a load-bearing sentinel and the mechanism is already documented correctly elsewhere. | C-2 (open-contradictions.md) | deferred (trigger: none — cleanup-of-convenience) | — |
|
||||
|
||||
## Deferred (43)
|
||||
|
||||
These are recorded decisions with pull-triggers, not bugs — carried here (per this phase's Status vocabulary)
|
||||
for phase 5's `roadmap/deferred.md` to pick up. Grouped by theme; each origin id is greppable in the source
|
||||
handoff/report files for the full context.
|
||||
|
||||
| ID | Area | Item | Origin | Trigger | Blocks |
|
||||
|----|------|------|--------|---------|--------|
|
||||
| BL-220 | ops | Redis (shared cache, cross-instance scheduler/money lock) is not deployed — every current seam is correct only for a single instance. | handoff-after-refinement-phase-7/8, refinement-phase-7/8/9 follow-ups | running more than one instance | — |
|
||||
| BL-221 | server | Elasticsearch `INurseSearch` backend + outbox feeder not built — SQL search is the real MVP implementation. | handoff-after-backend-phase-7, handoff-after-refinement-phase-7/8/9, backend-phase-7 follow-up | SQL search shows real strain | search-and-discovery |
|
||||
| BL-222 | product | `IAnalyticsSink` / an analytics warehouse is not built — events aren't routed anywhere durable yet. | handoff-after-backend-phase-1, handoff-after-refinement-phase-9 | product pulls it | — |
|
||||
| BL-223 | server | A real holiday-calendar feed (vs. the seeded/admin-CRUD calendar) is not built. | handoff-after-backend-phase-1, handoff-after-refinement-phase-9 | product pulls it | — |
|
||||
| BL-224 | server | `organizations`/`organization_nurses` (employer model) tables are not built. | handoff-after-refinement-phase-9, frontend-phase-15 follow-up 2 | product pulls it | — |
|
||||
| BL-225 | product | `fraud_flags` / ML fraud scoring is not built — manual suspension + support alerts cover this for now. | handoff-after-backend-phase-6/14, handoff-after-refinement-phase-9 | product pulls it | — |
|
||||
| BL-226 | server | `recurring_booking_schedules` is not built. | handoff-after-refinement-phase-9, frontend-phase-15 follow-up 2 | product pulls it | — |
|
||||
| BL-227 | server | `bnpl_settlement_entries` (tranched settlement) is modelled but not built. | handoff-after-backend-phase-12, handoff-after-refinement-phase-9 | product pulls it | bnpl-installments |
|
||||
| BL-228 | product | Nurse availability slots/exceptions (soft scheduling guidance) are not built. | handoff-after-backend-phase-5/7, handoff-after-refinement-phase-9 | product pulls it | booking-request |
|
||||
| BL-229 | product | Customer national-ID KYC collection is deliberately not built — the product decision is to never gate browsing/booking on it. | handoff-after-backend-phase-3, handoff-after-refinement-phase-9 | never, by design (informational) | — |
|
||||
| BL-230 | server | A geography bulk-import feed (`IGeoDataImporter`) is deferred — the idempotent seed + admin CRUD is sufficient for MVP. | handoff-after-backend-phase-4, backend-phase-4 follow-up | product pulls it | addresses-and-map |
|
||||
| BL-231 | product | Holiday/surge pricing, a distinct Companionship pricing tier, and tiered per-category commission are all deferred. | handoff-after-backend-phase-5, backend-phase-5 follow-up | product pulls it | nurse-catalog-and-pricing |
|
||||
| BL-232 | product | GPS-radius "nurses near me" map discovery is explicitly not planned — coverage stays named-district-only. | handoff-after-backend-phase-4/7 | none — permanent product decision | search-and-discovery |
|
||||
| BL-233 | server | Automated MoH/INO license lookup and a professional-liability-insurance verification step type are both deferred. | handoff-after-backend-phase-6 | a lookup portal exists / product pulls it | nurse-verification |
|
||||
| BL-234 | ops | The credential-expiry scan and the EVV no-show sweep both run only via manual admin endpoints — no cron calls either yet. | handoff-after-backend-phase-6/9, backend-phase-6 follow-up | scheduled-ops phase | nurse-verification, booking-lifecycle-evv |
|
||||
| BL-235 | server | SMS.ir/Ghasedak SMS adapters are not built — only Kavenegar's real-SMS path is wired (attempting either throws at startup by design, never silently mocks). | handoff-after-refinement-phase-8, refinement-phase-8 follow-up | a second SMS vendor is needed | auth-login-otp |
|
||||
| BL-236 | ops | Finnotech/Moadian token-exchange refresh and the Moadian signing certificate are not wired — both are deploy-time actions once real credentials exist. | handoff-after-refinement-phase-8 | going to a real Moadian integration | partner-center |
|
||||
| BL-237 | ops | The Moadian reconciliation poll and the refund-settlement (BNPL processing→succeeded) poll are both thin/manual — no scheduled cron for either. | handoff-after-backend-phase-11, handoff-after-refinement-phase-7/8, refinement-phase-6/7/8 follow-ups | scheduled-ops phase | cancellation-and-refunds, bnpl-installments |
|
||||
| BL-238 | server | Per-provider-code BNPL revert is incomplete — the refund path only drives the SnappPay default regardless of the transaction's actual provider. | handoff-after-refinement-phase-8, refinement-phase-8 follow-up | a second BNPL provider goes live | bnpl-installments |
|
||||
| BL-239 | product | A dedicated merchant-of-record center-settlement payout path (تسهیم split leg) is deferred per decision 6.6. | handoff-after-refinement-phase-8, refinement-phase-6/8 follow-ups | product pulls it | partner-center |
|
||||
| BL-240 | ops | The weekly payout-batch cron generates automatically, but processing a batch stays a deliberate, explicit admin action by design (not a bug). | handoff-after-refinement-phase-7, backend-phase-13 follow-up | none — permanent product decision | nurse-earnings-and-payouts |
|
||||
| BL-241 | product | On-demand/instant nurse payout withdrawal and per-nurse payout-frequency configuration are both deferred (MVP is one fixed weekly cadence for everyone). | handoff-after-backend-phase-13, frontend-phase-12 follow-up 4 | product pulls it | nurse-earnings-and-payouts |
|
||||
| BL-242 | server | Automated clawback recovery beyond simple next-batch netting is not built. | handoff-after-backend-phase-13 | product pulls it | nurse-earnings-and-payouts |
|
||||
| BL-243 | server | Two-way (nurse-reviews-customer) double-blind reviews with timed reveal are not built. | handoff-after-backend-phase-14 | product pulls it | reviews |
|
||||
| BL-244 | server | A first-class `incidents` entity is not built — manual suspension + support alerts stand in for now. | handoff-after-backend-phase-14 | product pulls it | admin-backoffice |
|
||||
| BL-245 | server | **Verified 2026-08-02 (phase 5), resolving this row's own trigger:** `ResolveSupportAlert`, `AssignSupportAlert` (`SupportAlertsController.cs`), and nurse suspension (`AdminSuspendVerificationCommand`, feature folder `SuspendVerification`) are all real, built, and code-traced. Only `FlagConcern` — a softer flag-without-suspending action — is genuinely absent from the codebase; not separately filed as it has no UI or caller either. | backend-phase-14 follow-up, phase-5 roadmap verification | none — resolved; the residual `FlagConcern` gap is small enough to pick up opportunistically, not worth its own BL-### | admin-backoffice |
|
||||
| BL-246 | server | Partner-center license verification is mocked to manual-approve at MVP rather than an automated eNamad/MoH check. | handoff-after-backend-phase-15 | product pulls it | partner-center |
|
||||
| BL-247 | product | There is no telephony seam for emergencies — the emergency contact is an out-of-platform `tel:` link by deliberate design. | handoff-after-backend-phase-15 | none — permanent product decision | messaging-tickets |
|
||||
| BL-248 | client | SMS/push notification channels are not built — only in-app notifications are real. | handoff-after-refinement-phase-9 | notification UX demands out-of-app reach | notifications |
|
||||
| BL-249 | server | Legacy `UserRefreshTokens` (gRPC auth path) still exists alongside the real session-based REST auth. | backend-phase-2 follow-up | gRPC moves to sessions, or is dropped | auth-login-otp |
|
||||
| BL-250 | client | The ESLint unused-vars gate is a repo-wide no-op — the config patches an export path that doesn't carry the rule, so `@typescript-eslint/no-unused-vars` never actually runs despite `client/CLAUDE.md`'s golden rule 11 claiming it does. | frontend-phase-13 follow-up 1 | dedicated infra task | — |
|
||||
| BL-251 | client | PWA/offline caching (Workbox) is unbuilt — marked "maybe" in the original product backlog, i.e. optional. | product/notes open-questions.md | product pulls it | — |
|
||||
| BL-252 | server | Optional short-TTL cache over hot search result pages was shipped no-cache at MVP. | backend-phase-7 follow-up | search read latency becomes a real problem | search-and-discovery |
|
||||
| BL-253 | server | A single explicit DB transaction for the payment-webhook confirm path (currently two commits, kept safe via idempotency + a forward-only guard) is flagged as future hardening. | backend-phase-10 follow-up | `IUnitOfWork` grows a transaction scope | checkout-and-payment |
|
||||
| BL-254 | server | The partner-center dashboard's sponsored-nurse list is capped at 50 with an exact count — needs pagination if a center's roster grows past that. | backend-phase-15 follow-up | a center exceeds 50 sponsored nurses | partner-center |
|
||||
| BL-255 | server | `Bookings`/`Invoices.partner_center_id` columns exist with no DB-level FK — only `nurse_profiles.partner_center_id` got one, per the shipping phase's own Definition of Done. | backend-phase-15 follow-up, backend-phase-11 follow-up | a data-integrity pass on partner-center columns | partner-center |
|
||||
| BL-256 | client | Skeleton→content crossfade exists as a one-line-per-screen pattern but was never retrofitted onto every list/detail page. | ui-phase-12 follow-up 1 | a dedicated visual-polish pass | — |
|
||||
| BL-257 | client | No true desktop search layout — a full responsive pass beyond the phone-width frame was explicitly deferred post-chain. | ui-phase-12 follow-up 2 | product decides to support desktop | search-and-discovery |
|
||||
| BL-258 | client | Persian OG image variant is Latin-only by deliberate scope cut on `/welcome` and other share cards. | ui-phase-13 follow-up, public-front-door gap-7 | Persian social sharing becomes a priority | public-front-door |
|
||||
| BL-259 | server | List-row EVV presence indicator (a lightweight "currently checked in" flag on the booking list DTO) was never built — needs a product/API decision to avoid an N+1 read. | ui-phase-5 follow-up 1 | product prioritizes a list-level EVV signal | booking-lifecycle-evv |
|
||||
| BL-260 | client | Web-push for new nurse booking requests remains deferred — the 15-second poll is the only freshness mechanism. | ui-phase-7 follow-up 5, REQ-054 | push infra (service worker + VAPID + dispatch rail) is built | booking-request |
|
||||
| BL-261 | client | Ticket attachments' composer button is fully built but gated off pending the upload/signed-URL backend. | REQ-060 (see also BL-168) | attachment backend delivered | messaging-tickets |
|
||||
| BL-262 | product | Real payment-gateway/Shaparak logos near the pay CTA are deferred — no licensed assets yet; a generic lock-icon trust notice stands in. | ui-phase-6 follow-up 5 | licensed gateway assets obtained | checkout-and-payment |
|
||||
@@ -0,0 +1,147 @@
|
||||
# Decisions — the distilled engineering decision log
|
||||
|
||||
> Last verified: 2026-08-02 against commit `51e86a1`.
|
||||
|
||||
Non-obvious decisions with a reason, extracted from `dev/`'s ~3MB of build history so they survive
|
||||
`dev/`'s move to `archive/` in phase 6. **`product/` wins for business rules** — this file is for
|
||||
*engineering* decisions, and for business decisions made **during** the build that never made it back into
|
||||
`product/` (each of those also gets a note filed in [product/notes/](../../product/notes/open-questions.md)).
|
||||
Each entry: what was decided, when, why, where it binds.
|
||||
|
||||
---
|
||||
|
||||
## Business rules made during the build (candidates for `product/` too)
|
||||
|
||||
**Commission 0.15 / VAT 0.10, VAT on commission only.** The canonical fee model as of refinement-phase-3:
|
||||
platform commission is 15% of the gross booking amount; VAT is 10% of the *commission*, not of gross. This
|
||||
became the single source of truth after the pre-refinement code computed VAT two different ways on two
|
||||
surfaces (carved out of commission at checkout vs. added to it on the invoice, a 341 IRR disagreement on one
|
||||
seeded booking — see [backlog.md](backlog.md) BL-060, still open on the invoice screen specifically). Binds:
|
||||
`server/src/Core/Baya.Application/Features/Payments/`, every invoice/checkout DTO.
|
||||
|
||||
**`district_id = NULL` means whole-city, in both directions.** A nurse service area with no district covers
|
||||
every district in the city; a search with no district filter matches both district-scoped and whole-city
|
||||
rows. Binds: `nurse_service_areas`, `SqlNurseSearch`, the coverage-picker UI. See
|
||||
[backlog.md](backlog.md) BL-077 for the one place this invariant currently double-counts a nurse.
|
||||
|
||||
**Verification `status` is the source of truth; `is_verified` is a guarded flip.** The nurse-facing status
|
||||
enum drives all business logic; `nurse_profiles.is_verified` only flips inside the same transaction that
|
||||
moves status to `approved`, never independently. Decided backend-phase-6. Binds: `NurseVerificationService`,
|
||||
`nurse_search_index.is_searchable`.
|
||||
|
||||
**Booking status is forward-only; the three-amount split has a DB CHECK constraint.** No booking status
|
||||
transition may move backward, enforced in `BookingTransitions`/`BookingSessionTransitions`
|
||||
(CONVENTIONS §6 pattern). The `gross`/`platform_commission`/`nurse_payout` three-way split on a booking is
|
||||
CHECK-constrained to balance at the DB layer, not just in application code. Decided backend-phase-9.
|
||||
|
||||
**The two-stage clinical-disclosure gate; EVV is advisory, never a block.** Care details are disclosed in two
|
||||
stages — coarse notes pre-acceptance, full encrypted `booking_care_instructions` only to the assigned nurse
|
||||
post-confirmation. A geofence mismatch on check-in/out (EVV) raises a support alert but never blocks the
|
||||
visit from proceeding — decided explicitly to avoid a GPS false-positive stranding a nurse mid-shift. Binds:
|
||||
`booking_requests` vs `booking_care_instructions`, `CheckInVisitCommand`/`CheckOutVisitCommand`.
|
||||
|
||||
**Webhook idempotency is upsert-first; ledger postings must balance.** Every external webhook handler
|
||||
(payment PSP, BNPL, payout transfer) upserts on the provider's idempotency key *before* any side effect, so a
|
||||
replayed webhook is a no-op rather than a double-post. Every `LedgerPosting` is validated to balance (debits
|
||||
= credits) before commit — decided backend-phase-10, the same phase that found the payment-webhook confirm
|
||||
path needs two DB commits (booking creation, then transaction+ledger) because ledger legs need the
|
||||
DB-generated `booking_id`; flagged as future hardening once `IUnitOfWork` grows a transaction scope (see
|
||||
[backlog.md](backlog.md) BL-253).
|
||||
|
||||
**Reviews recompute from source; nurse care records are append-only.** A nurse's `average_rating`/
|
||||
`total_reviews` are always recomputed from the live review rows, never incrementally maintained, to avoid
|
||||
drift. Visit-note/care-record writes are append-only — no record is ever edited or deleted, only superseded
|
||||
by a newer entry. Decided backend-phase-14.
|
||||
|
||||
**`is_internal` ticket messages never appear in user-facing types.** The admin-only internal-note boundary on
|
||||
a support ticket is enforced by keeping `isInternal` out of every client-facing TypeScript type entirely —
|
||||
not by a runtime filter that could be bypassed. Decided frontend-phase-14, re-confirmed frontend-phase-15
|
||||
when the admin console was built on top of the same domain.
|
||||
|
||||
**One payout per booking (UNIQUE); whole-clawback greedy netting.** A `nurse_payouts` row is
|
||||
UNIQUE-constrained to one per booking — a booking can never be paid out twice. Clawback recovery uses
|
||||
greedy whole-amount netting against the next batch rather than partial installments. Decided
|
||||
backend-phase-13; automated recovery beyond simple netting is deferred (see [backlog.md](backlog.md)
|
||||
BL-242).
|
||||
|
||||
**Escrow releases after a confirmed check-out; weekly payout generation is automatic, processing stays
|
||||
manual.** Funds move from `escrow_held` to `nurse_payable` only after a checked-out session passes its
|
||||
dispute window. Payout *batch generation* runs on a weekly cron (refinement-phase-7); actually *transferring*
|
||||
money in a batch stays a deliberate, explicit admin action by design — decided refinement-phase-7,
|
||||
re-affirmed in the backend-phase-13 handoff. Not a bug; see [backlog.md](backlog.md) BL-240.
|
||||
|
||||
**Config lives in files, not a secret store — deliberate pre-launch trade.** `dotnet user-secrets` was
|
||||
removed (`<UserSecretsId>` dropped from the `.csproj`); all configuration, including live credentials, lives
|
||||
in `appsettings.*.json` / `.env.*` / `docker-compose.yml`. This is explicitly temporary — root
|
||||
[CLAUDE.md](../../CLAUDE.md) §6 requires rotating every credential and moving the secret half out of git
|
||||
before real users (see [backlog.md](backlog.md) BL-003). The one value that must **never** change once real
|
||||
data exists: `Seams:FieldEncryption:Key`/`:HashKey`.
|
||||
|
||||
**Error state is never an empty state (the client convention).** A failed query must never silently render
|
||||
the same UI as "no data" — decided in the frontend-phase-1 primitives pass. Still occasionally violated; see
|
||||
[backlog.md](backlog.md) BL-078 for the one live regression found this phase (`nurse-service-areas`'s
|
||||
coverage screen drops `isError`).
|
||||
|
||||
**Root `/` forks by auth via a middleware rewrite, never a redirect.** An anonymous visitor to `/` gets the
|
||||
public landing page's content rewritten in at the same URL; the canonical URL and address bar never change.
|
||||
Decided ui-phase-13, chosen specifically so `/` stays a stable, shareable, indexable URL for both audiences.
|
||||
|
||||
---
|
||||
|
||||
## Engineering decisions
|
||||
|
||||
**Phase 2 → Phase 4 handoff, "the rename is filed."** The placeholder secret sentinel
|
||||
`SET_VIA_USER_SECRETS_OR_ENV` keeps its name — renaming it needs a server-code + test change that is out of
|
||||
a documentation phase's scope, and the string is load-bearing across several live files. Filed as
|
||||
[backlog.md](backlog.md) BL-219, deferred (cleanup-of-convenience, no urgency).
|
||||
|
||||
**Phase 7 dropped the pre-commit secret-scan hook — MVP stage, no need for it yet.** `.githooks/pre-commit`
|
||||
(the `qw123321`/private-key/AWS-key/SQL-host/connection-string scan) and `.githooks/README.md` were deleted
|
||||
outright, along with every doc reference to them (root `CLAUDE.md`'s repo-layout table and quick start,
|
||||
[git-and-gates.md](../rules/shared/git-and-gates.md), [documentation.md](../rules/documentation.md),
|
||||
[identity.md](../rules/server/identity.md), [config-matrix.md](../integration/config-matrix.md),
|
||||
[DEPLOY.md](../../DEPLOY.md)). The underlying trade this repo already made — committed live credentials,
|
||||
config in files not a secret store (root [CLAUDE.md](../../CLAUDE.md) §6) — is unchanged; this only removes
|
||||
the local mechanical backstop against a *new* leak. Revisit before onboarding real users, alongside the
|
||||
credential rotation already required by that trade.
|
||||
|
||||
**Hardening ledger re-verification (C-10) confirms the ledger was right to distrust its own checkboxes.**
|
||||
All 18 hardening items were re-traced against `b876490` rather than trusted as-filed: 3 were already fixed
|
||||
(client-only, landed in the "manual improvement" commits well before this doc chain started), 4 are
|
||||
partially fixed (real progress with concrete residue), and 11 are unchanged since 2026-07-16 despite 14 UI
|
||||
phases, 2 manual-testing iterations, and a deploy running on top of them. See
|
||||
[backlog-closed.md](backlog-closed.md) and [backlog.md](backlog.md) for the full disposition of each.
|
||||
|
||||
**REQ-061 (admin user directory) is filed for real, despite never getting a ledger header (C-15).**
|
||||
`ui-phase-11-report.md` claimed "REQ-061…064 appended," but the append-only ledger's numbering jumps
|
||||
060→062 — REQ-061's body survived as an orphaned, headerless block. Ten live client files depend on the
|
||||
endpoints it describes. Ruling: it is a genuine, currently-undelivered backend gap and is carried into
|
||||
[backlog.md](backlog.md) as BL-029, not treated as a documentation artifact to discard.
|
||||
|
||||
**Five REQs were filed narrower or wrong relative to the live server (C-16).** REQ-050, REQ-063, REQ-066,
|
||||
REQ-067 and the REQ-029/030 admin-mock justification were all checked against the live swagger rather than
|
||||
against another document, during phase 2. `variantLabel` already shipped before REQ-050 was filed;
|
||||
`tickets/close`+`/reopen` already ship (only `assign` is missing); `search/nurses` and
|
||||
`nurses/{id}/profile` are already anonymous (only rate-limiting and a privacy sign-off are missing,
|
||||
respectively). Ruling: REQ status is derived from code, never copied from the ledger's own prose — this
|
||||
phase's REQ classification (see [backlog.md](backlog.md) and [backlog-closed.md](backlog-closed.md))
|
||||
followed the same rule throughout, and found one further instance on its own: REQ-050's "neither field
|
||||
exists" claim was stale even for the field it explicitly named (`variantLabel`), and REQ-057's residue was
|
||||
found duplicated across two unrelated flows independently citing the same missing DTO field.
|
||||
|
||||
**A `true` mock flag does not mean the domain is fully fake, and a `false` flag does not mean it's fully
|
||||
real.** `verification` has 10 of 14 operations live and probed while mocked; `payment` is flag-real with only
|
||||
2 of 6 operations actually working. Decided as the framing for this whole reconciliation (phase 3's finding)
|
||||
— every BL item in this backlog that touches a `USE_*_MOCK` flag states the *specific* operations affected,
|
||||
never just cites the flag.
|
||||
|
||||
**Deferred items keep their pull-trigger, not a target date.** Following the phase-chain convention set in
|
||||
`dev/shared-working-context/backend/handoff/`, every item in [backlog.md](backlog.md)'s Deferred section
|
||||
carries the condition that should cause it to be picked up (a specific product decision, a scale threshold,
|
||||
a second integration) rather than a scheduled date. Phase 5 owns turning these into a sequenced roadmap.
|
||||
|
||||
**BL-245's own trigger ("phase 5 verification pass") was executed during phase 5.** `ResolveSupportAlert`,
|
||||
`AssignSupportAlert`, and nurse suspension (`AdminSuspendVerificationCommand`) were all confirmed real and
|
||||
code-traced; only `FlagConcern` is genuinely unbuilt. Recorded here rather than left as an open question in
|
||||
[roadmap/deferred.md](../roadmap/deferred.md) — a small, direct illustration of "verify, don't copy" applied
|
||||
one phase later than the item that requested it.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Implemented — the product overlay
|
||||
|
||||
> Last verified: 2026-08-02 against commit `b876490`.
|
||||
|
||||
The mapping [`product/`](../../product/index.md)'s 14 business areas need and never had: which flow builds
|
||||
each area, and what state it's actually in. `product/` itself stays untouched — this file is the bridge
|
||||
between "what the business is" and "what we built." States: `built` (end-to-end real) · `partial` (real with
|
||||
named gaps) · `mocked` (UI real, data fake) · `not started` · `deferred`.
|
||||
|
||||
Every row's Gaps column links into [backlog.md](backlog.md); the full evidence is in the linked
|
||||
[docs/flows/](../flows/index.md) file, not restated here.
|
||||
|
||||
## Business area → build state
|
||||
|
||||
| # | Business area | Doc | Primary flow(s) | State | Gaps |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 01 | Actors & Onboarding | [product/business/01-…](../../product/business/01-actors-and-onboarding.md) | [auth-login-otp](../flows/auth-login-otp.md) **(built)**, [onboarding-customer](../flows/onboarding-customer.md), [onboarding-nurse](../flows/onboarding-nurse.md) | partial | BL-023, BL-031..038, BL-085..092, BL-193..197, BL-249 |
|
||||
| 02 | Nurse Verification | [product/business/02-…](../../product/business/02-nurse-verification.md) | [nurse-verification](../flows/nurse-verification.md) | **mocked** | BL-010 (blocker), BL-080..084, BL-190..192, BL-214, BL-233 |
|
||||
| 03 | Catalog & Pricing | [product/business/03-…](../../product/business/03-service-catalog-and-pricing.md) | [nurse-catalog-and-pricing](../flows/nurse-catalog-and-pricing.md) | partial | BL-018 (blocker — zero option groups outside Development), BL-069..072, BL-176..178, BL-231 |
|
||||
| 04 | Search & Matching | [product/business/04-…](../../product/business/04-search-and-matching.md) | [search-and-discovery](../flows/search-and-discovery.md) | partial | BL-014 (blocker), BL-103, BL-104, BL-210..213, BL-221, BL-232 |
|
||||
| 05 | Booking & Scheduling | [product/business/05-…](../../product/business/05-booking-and-scheduling.md) | [booking-request](../flows/booking-request.md) | partial | BL-006 (blocker), BL-040..044, BL-071, BL-138..143, BL-228, BL-260 |
|
||||
| 06 | EVV / Service Delivery | [product/business/06-…](../../product/business/06-evv-and-service-delivery.md) | [booking-lifecycle-evv](../flows/booking-lifecycle-evv.md) | partial | BL-016 (blocker), BL-039, BL-043..046, BL-133..137, BL-259 |
|
||||
| 07 | Cancellation & Refunds | [product/business/07-…](../../product/business/07-cancellation-and-refunds.md) | [cancellation-and-refunds](../flows/cancellation-and-refunds.md) | **mocked** | BL-009 (blocker), BL-047..054, BL-144..148, BL-215, BL-237 |
|
||||
| 08 | Payments & Escrow | [product/business/08-…](../../product/business/08-payments-and-escrow.md) | [checkout-and-payment](../flows/checkout-and-payment.md) | partial | BL-005 (blocker), BL-058..062, BL-156..159, BL-253, BL-262 |
|
||||
| 09 | Installments / BNPL | [product/business/09-…](../../product/business/09-installments-bnpl.md) | [bnpl-installments](../flows/bnpl-installments.md) | **mocked** | BL-007, BL-008 (blockers), BL-126..132, BL-227, BL-238 |
|
||||
| 10 | Payouts | [product/business/10-…](../../product/business/10-payouts.md) | [nurse-earnings-and-payouts](../flows/nurse-earnings-and-payouts.md) | **mocked** | BL-012 (blocker), BL-073..076, BL-179..184, BL-240..242 |
|
||||
| 11 | Reviews, Trust & Safety | [product/business/11-…](../../product/business/11-reviews-trust-and-safety.md) | [reviews](../flows/reviews.md) | partial | BL-017 (blocker), BL-099..102, BL-207..209, BL-243 |
|
||||
| 12 | Messaging & Emergencies | [product/business/12-…](../../product/business/12-messaging-and-emergencies.md) | [messaging-tickets](../flows/messaging-tickets.md) | partial | BL-063..068, BL-160..168, BL-247, BL-261 |
|
||||
| 13 | Tax, Invoicing & Legal | [product/business/13-…](../../product/business/13-tax-invoicing-and-legal.md) | [partner-center](../flows/partner-center.md) (+ invoice half of `checkout-and-payment`, VAT — see BL-060) | **mocked — weakest area** | BL-013 (blocker), BL-093..095, BL-198..201, BL-236, BL-239, BL-246, BL-254, BL-255 |
|
||||
| 14 | Notifications & Admin | [product/business/14-…](../../product/business/14-notifications-and-admin.md) | [notifications](../flows/notifications.md) (partial), [admin-backoffice](../flows/admin-backoffice.md) (**mocked**) | **mocked (admin half)** | BL-001, BL-002 (blockers — the RBAC finding that touches 11 of 14 areas), BL-114..117, BL-124, BL-125, BL-169..175, BL-244, BL-245 |
|
||||
|
||||
**No area is orphaned** — all 14 map to a primary flow, matching [docs/flows/index.md](../flows/index.md)'s
|
||||
coverage accounting. **Area 13 is confirmed the weakest**: invoicing and VAT are split across two flows with
|
||||
no single owner, the entire partner-center portal is mocked with zero tenancy, and BNPL/invoicing can name
|
||||
two different merchant-of-record centers for the same booking (BL-199).
|
||||
|
||||
**One flow has no `product/` source at all**: `account-and-settings` is a ui-phase-9 UI decision with no
|
||||
business-area file behind it — not a gap in this table, just a fact worth knowing before editing it.
|
||||
|
||||
## Cross-cutting: the RBAC finding
|
||||
|
||||
[BL-001](backlog.md#blockers-18) (admin RBAC grants only the literal role `admin`, which no seeded account
|
||||
holds) is the single defect with the widest blast radius in this table — it independently degrades the admin
|
||||
half of areas 02, 07, 09, 10, 11, 12, 13 and 14, plus the admin-only surfaces inside 03, 05 and 06. Fixing it
|
||||
does not fix any area's `mocked` state on its own (most of those are separately blocked by their own
|
||||
`USE_*_MOCK` flag or missing route — see each row above), but it is the single highest-leverage fix in the
|
||||
whole backlog: [docs/flows/index.md](../flows/index.md#the-six-things-that-surprise-everyone) independently
|
||||
found the same root cause from the code side.
|
||||
|
||||
## Data model — never-built product tables
|
||||
|
||||
The STATUS logs and [product/data-model/index.md](../../product/data-model/index.md) name **8 tables**
|
||||
modeled in the product data model that have no implementation today, all `[DEFERRED]` by product decision
|
||||
(not oversight — see [decisions.md](decisions.md)):
|
||||
|
||||
| Table | Backs | Product doc |
|
||||
| --- | --- | --- |
|
||||
| `organizations` | Employer/company account model | [13-partner-centers-and-future.md](../../product/data-model/13-partner-centers-and-future.md) |
|
||||
| `organization_nurses` | Employer↔nurse membership | same |
|
||||
| `fraud_flags` | ML-scored fraud signals (manual suspension covers this today) | same |
|
||||
| `recurring_booking_schedules` | Recurring/subscription bookings | same |
|
||||
| `bnpl_settlement_entries` | Tranched BNPL settlement (one settlement row covers it today) | [08-bnpl.md](../../product/data-model/08-bnpl.md) |
|
||||
| `nurse_availability_slots` | Soft scheduling-guidance windows | [05-booking-and-scheduling.md](../../product/data-model/05-booking-and-scheduling.md) |
|
||||
| `nurse_availability_exceptions` | Time-off exceptions to the above | same |
|
||||
| `incidents` | First-class incident entity (support alerts cover this today) | [11-reviews-trust-and-safety.md](../../product/business/11-reviews-trust-and-safety.md) |
|
||||
|
||||
None of these are backlog items — they're recorded product decisions to not build yet, carried in
|
||||
[backlog.md](backlog.md)'s Deferred section (BL-222 through BL-247) with their pull-triggers.
|
||||
@@ -0,0 +1,75 @@
|
||||
# Status — where the project actually is
|
||||
|
||||
> Last verified: 2026-08-02 against commit `b876490`. Populated by phase 4 of the
|
||||
> [documentation clean-up chain](../../archive/clarify-chain/README.md).
|
||||
|
||||
This is the page you open after two weeks away. "What's left?" now has one honest answer instead of five
|
||||
unreconciled ledgers.
|
||||
|
||||
## The picture in one table
|
||||
|
||||
| | Count |
|
||||
| --- | --- |
|
||||
| Flows: built · partial · mocked · not started | 1 · 15 · 7 · 0 (of 23) |
|
||||
| Business areas covered (of 14) | 14 — none orphaned; **area 13 (Tax, Invoicing & Legal) is weakest** |
|
||||
| Backlog items — open + deferred | **262** (`BL-001`…`BL-262`) |
|
||||
| — blocker | 18 |
|
||||
| — major | 86 |
|
||||
| — minor | 115 |
|
||||
| — deferred (has a pull-trigger) | 43 |
|
||||
| Backlog items — closed this phase | 88 |
|
||||
| Raw candidate rows harvested (pre-dedup, across 10 sources) | ~700 |
|
||||
|
||||
Full detail: [backlog.md](backlog.md) (open) · [backlog-closed.md](backlog-closed.md) (closed) ·
|
||||
[implemented.md](implemented.md) (business-area overlay) · [decisions.md](decisions.md) (the ADR log).
|
||||
|
||||
## The five things standing between here and a usable product
|
||||
|
||||
1. **Admin RBAC is structurally dead ([BL-001](backlog.md#blockers-18)/[BL-002](backlog.md#blockers-18)).**
|
||||
`DynamicPermissionService` grants only the literal role `admin`, which no seeded account holds — every
|
||||
admin surface 403s. This one root cause independently degrades **11 of 14 business areas**
|
||||
([implemented.md](implemented.md#cross-cutting-the-rbac-finding)). Highest leverage fix in the backlog.
|
||||
2. **The two real money rails both dead-end before completion.** Card payment redirects to a host that
|
||||
doesn't exist and nothing fires the PSP webhook locally ([BL-005](backlog.md#blockers-18)); no BNPL
|
||||
gateway is ever seeded, so every BNPL call 400s, and the wizard's own mock cross-imports a store that
|
||||
404s on any real booking id ([BL-007](backlog.md#blockers-18)/[BL-008](backlog.md#blockers-18)). Neither
|
||||
money path can be walked end-to-end from a browser today.
|
||||
3. **Five domains are 100% client-mocked while a working server sits behind them** — verification, refunds,
|
||||
nurse payouts, patient/care records, and the entire partner-center portal
|
||||
([BL-009](backlog.md#blockers-18), [BL-010](backlog.md#blockers-18), [BL-011](backlog.md#blockers-18),
|
||||
[BL-012](backlog.md#blockers-18), [BL-013](backlog.md#blockers-18)). Three of the five would also *break*
|
||||
on a naive flip — the client and server DTO shapes have drifted.
|
||||
4. **The booking payment window lies to the customer.** Deadline timestamps ship with no timezone, so in
|
||||
Tehran a 30-minute countdown renders as ~4 hours and the request silently expires while the timer still
|
||||
shows time left ([BL-006](backlog.md#blockers-18)).
|
||||
5. **Two pre-launch security items are live right now.** The repo's committed credentials (DB `sa`, both
|
||||
encryption-key halves, three third-party API keys) have never been rotated
|
||||
([BL-003](backlog.md#blockers-18)), and a Development-only OTP-read endpoint is reachable on the
|
||||
production domain because the deployment runs as Development
|
||||
([BL-004](backlog.md#blockers-18)).
|
||||
|
||||
## Where to go next
|
||||
|
||||
| Question | Answer |
|
||||
| --- | --- |
|
||||
| Is flow X built? | [docs/flows/index.md](../flows/index.md) — the status table, 23 flows |
|
||||
| How do I test it? | [docs/flows/testing-setup.md](../flows/testing-setup.md) |
|
||||
| What's the client↔server contract? | [docs/integration/index.md](../integration/index.md) |
|
||||
| What are the hard rules? | [docs/rules/index.md](../rules/index.md) |
|
||||
| What's open, and how bad is it? | [backlog.md](backlog.md) |
|
||||
| What's already closed? | [backlog-closed.md](backlog-closed.md) |
|
||||
| Which business area is built vs. mocked? | [implemented.md](implemented.md) |
|
||||
| Why was X built that way? | [decisions.md](decisions.md) |
|
||||
| What's next, in order? | [docs/roadmap/index.md](../roadmap/index.md) |
|
||||
|
||||
## How this file's numbers were produced
|
||||
|
||||
Five ledgers — the [hardening issues list](../../archive/post-phase/hardening/issues.md) (18 items, all
|
||||
unticked since 2026-07-16), the [67-REQ contract ledger](../../archive/build-chain/working-context/frontend/requests/for-backend.md),
|
||||
53 phase reports' own "Follow-ups" sections, 22 backend hand-off files' deferrals, and two rounds of raw
|
||||
manual-testing notes — were harvested in full, then every hardening item, every REQ, and every manual-testing
|
||||
bullet was **re-verified against the code at `b876490`**, not trusted as filed. Phase 3's **283
|
||||
already-verified flow gaps** (each with a live code trace, several walked against a booted server) are this
|
||||
phase's primary, freshest input and form the backbone of [backlog.md](backlog.md). Nothing here is copied
|
||||
from a stale document without a fresh check — the phase-chain's own rule (["verify, don't
|
||||
copy"](../../archive/clarify-chain/README.md#non-negotiables-for-every-phase)) held throughout.
|
||||
Reference in New Issue
Block a user