doc clean up phase 2

This commit is contained in:
hamid
2026-07-30 12:49:46 +03:30
parent c889c46110
commit c841bded26
36 changed files with 2970 additions and 51 deletions
+16 -3
View File
@@ -3,6 +3,12 @@
A first, shareable deployment of the whole stack under **balinyaar.ir**, in Docker, behind an existing
Caddy reverse proxy that terminates TLS.
> **This file is the deploy *procedure*.** The runtime dependency graph — every edge, what breaks when it is
> down, and where it is configured — is [docs/integration/topology.md](docs/integration/topology.md), and
> every configuration key on both sides is
> [docs/integration/config-matrix.md](docs/integration/config-matrix.md). Read those to answer "what talks
> to what" or "where is this value set"; read this one to actually ship.
| Host | Serves | Container |
| --- | --- | --- |
| `balinyaar.ir`, `www.balinyaar.ir` | Next.js web client | `balinyaar-web:3000` |
@@ -28,9 +34,16 @@ machine is now inert and can be deleted. Every value lives in a file in the repo
| Client build-time config (API URL, site origin) | `client/.env.production` |
| Telegram relay config (bot token, chat ids, API key, proxy) | `docker-compose.yml``otp-relay.environment` |
The placeholder string `SET_VIA_USER_SECRETS_OR_ENV` in the base `appsettings.json` names that removed
store; the *name* is a historical artifact, kept only because it is the sentinel `StartupSecretsGuard` and
the pre-commit hook both reject. **The mechanism is appsettings files and environment variables** — see
[docs/integration/config-matrix.md](docs/integration/config-matrix.md), which lists every key, its default,
and who reads it.
The API runs as **`ASPNETCORE_ENVIRONMENT=Development`**, so `appsettings.Development.json` is the file
that actually loads. An `appsettings.Production.json` would be ignored — put changes in the Development
file, or change the environment name first.
that actually loads. There is **no `appsettings.Production.json` in the repo at all**, and adding one would
be ignored until the environment name changes too — put changes in the Development file, or change the
environment name first.
The relay's shared secret appears twice and the two must match: `Seams:Sms:Telegram:ApiKey` in the
appsettings file and `API_KEY` in the compose file. It was rotated away from the value in
@@ -64,7 +77,7 @@ people you trust, and none of which are acceptable once strangers can reach the
### Going to Production later
1. Set `ASPNETCORE_ENVIRONMENT: Production` in `docker-compose.yml`.
2. Create `appsettings.Production.json` with the same content as the Development file, but with **real**
2. **Create** `appsettings.Production.json` (it does not exist) with the same content as the Development file, but with **real**
`IdentitySettings:SecretKey` / `Encryptkey``StartupSecretsGuard` rejects anything containing
`not-for-production` outside Development, so the current dev keys will refuse to boot (by design).
Keep `Seams:FieldEncryption` byte-identical.
+28 -1
View File
@@ -1,4 +1,29 @@
# Contracts — the shared interface between `client/` and `server/`
# Contracts — MOVED to `docs/integration/`
> ## ⛔ This folder is history. Do not read it as the contract.
>
> **The live client↔server contract is [`docs/integration/`](../../docs/integration/index.md).** Start at
> its [index.md](../../docs/integration/index.md) — the whole seam on one page — then open the one
> [domain file](../../docs/integration/domains/index.md) you need.
>
> | Was here | Now |
> | --- | --- |
> | `conventions/api-conventions.md` + `conventions/money-and-types.md` | [`docs/integration/api-contract.md`](../../docs/integration/api-contract.md) |
> | `domains/*.md` (17 files, named after backend phases) | [`docs/integration/domains/`](../../docs/integration/domains/index.md) (22 files, named after the client's `services/` domains) |
> | `openapi/swagger.v1.json` (2026-07-13) | [`docs/integration/openapi/`](../../docs/integration/openapi/README.md) (refreshed 2026-07-29) |
>
> Everything here was **frozen 2026-07-13** and has been superseded. It was audited against the live
> swagger during phase 2: the route-level content held up (no route named here is missing from the live
> API), but the body-casing rule, the server's local URL scheme, the envelope's field count and the enum
> vocabularies had all drifted. What changed and why:
> [domains/index.md § What replaced what](../../docs/integration/domains/index.md#what-replaced-what).
>
> Phase 6 archives this folder. Until then it stays readable **as a record**, not as an instruction.
---
<details>
<summary>The original README, kept for the record</summary>
The two projects are independent (no shared build). This folder is their **single shared source of
truth** for everything that crosses the wire: API routes, request/response shapes, status codes, enums,
@@ -43,3 +68,5 @@ shared flows, and money/format conventions. It lets a frontend agent build again
> Keep contracts **versioned by being honest**: when a shipped shape changes, update its `domains/*` doc
> and the OpenAPI snapshot in the same change, and call it out in the handoff so the frontend re-syncs.
</details>
+17
View File
@@ -1,3 +1,20 @@
# messaging — MERGED into `docs/integration/domains/`
> ⛔ **This headerless 851-byte fragment silently amended
> [`messaging-notifications-admin.md`](messaging-notifications-admin.md) next to it** — two live files
> describing one domain, which is contradiction **C-8**.
>
> Both are superseded. Phase 2 split the merged content three ways, along the client's actual domain
> boundaries:
>
> | Content | Now |
> | --- | --- |
> | Tickets, threads, messages, `is_internal`, the staff queue | [`docs/integration/domains/tickets.md`](../../../docs/integration/domains/tickets.md) |
> | The notification feed and unread badge | [`docs/integration/domains/notifications.md`](../../../docs/integration/domains/notifications.md) |
> | Platform config, holidays, audit, support alerts | [`docs/integration/domains/admin.md`](../../../docs/integration/domains/admin.md) |
>
> Every REQ-028 amendment below is folded into `tickets.md` as current fact, not as a change log. Phase 6
> archives this folder; until then, read it as a record.
---
+13 -1
View File
@@ -1,4 +1,16 @@
# OpenAPI snapshots
# OpenAPI snapshots — MOVED to `docs/integration/openapi/`
> ⛔ **The `swagger.v1.json` in this folder is the 2026-07-13 snapshot and is stale.** The current one is
> [`docs/integration/openapi/`](../../../docs/integration/openapi/README.md) (2026-07-29, 178 paths / 186
> operations), with its provenance and regeneration steps recorded there.
>
> The claim below that the server "publishes documents `v1`, `v1.1`" is literally true and substantively
> empty: both documents are registered, but all 55 controllers are `[ApiVersion("1")]`, so the `v1.1`
> document contains **zero paths** (contradiction **C-9**, resolved in phase 2).
>
> Phase 6 archives this folder. Until then, read it as a record.
---
The server already generates OpenAPI via **NSwag** (Swagger UI at `/swagger`, documents `v1`, `v1.1`).
This folder holds the **published `swagger.json` snapshot(s)** so the frontend can generate/verify types
+1 -1
View File
@@ -28,7 +28,7 @@ The entry point. Start here, follow one link, stop reading.
| 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 | OpenAPI snapshot only · phase 2 |
| [integration/](integration/index.md) | The client↔server seam in one place: contract, config, topology, OpenAPI | **written** · phase 2 |
| [flows/](flows/index.md) | What is implemented, and how to test it — one file per user journey | not yet written · phase 3 |
| [status/](status/index.md) | Where the project actually is: implemented, backlog, decisions | not yet written · phase 4 |
| [roadmap/](roadmap/index.md) | Where it goes next, and what gates a launch | not yet written · phase 5 |
+2 -2
View File
@@ -153,8 +153,8 @@ stays small.
| Phase | Status | Finished | Notes |
| --- | --- | --- | --- |
| 0 Inventory & scaffold | **done** | 2026-07-29 | 275 files surveyed (not ~260). OpenAPI refreshed — drift is **1 endpoint**, not a rewrite. 3 of 5 seeded contradictions were narrower than stated; see [open-contradictions.md](open-contradictions.md#corrections-to-the-seeded-list). Phase 4 owns 149 files |
| 1 Rules consolidation | not started | | |
| 2 Integration & dependency | not started | | |
| 1 Rules consolidation | **done** | 2026-07-30 | Row reconstructed by phase 2 — phase 1 finished without updating it. Evidence: all 18 `docs/rules/**` files exist and are stamped `2026-07-30 against d3ec723`, `docs/README.md` marks the section **written**, and C-11 + R-1…R-6 are recorded as resolved-by-phase-1 |
| 2 Integration & dependency | **done** | 2026-07-30 | 27 files under `docs/integration/`. All 186 operations assigned to exactly one of 22 domain files (1:1 with `client/src/services/`). **Main finding: 24 phantom endpoints** — the client↔swagger axis nobody had checked; 2 are live 404s. Also 5 mis-filed REQs, 1 never-filed REQ (**C-15**), 8 client drifts, 5 config gaps. Resolved C-2, C-6, C-7, C-8, C-9 + C-3's contract half |
| 3 Flow atlas | not started | | |
| 4 Backlog reconciliation | not started | | |
| 5 Roadmap | not started | | |
+28 -11
View File
@@ -2,6 +2,8 @@
> Opened 2026-07-29 by [phase 0](phase-0-inventory-and-scaffold.md), against commit `c99e3f4`.
> **Phase 0 only finds these. It resolves none of them.**
> Last updated 2026-07-30 by [phase 2](phase-2-integration.md), against commit `d3ec723`:
> **C-2, C-6, C-7, C-8 and C-9 resolved**, C-3's contract-layer half resolved, **C-15 and C-16 added**.
Each row is *claim A* vs *claim B* with a location for both, and the phase that owns the resolution.
When a phase resolves one, it moves the row to **Resolved** at the bottom with the decision, and — per
@@ -17,18 +19,15 @@ written up as they actually are, with the evidence, in [§ Corrections to the se
| # | Claim A | Claim B | Owner | Status |
| --- | --- | --- | --- | --- |
| C-1 | **Set the crypto keys with `dotnet user-secrets`** — [manual-testing-plan.md:22](../../dev/post-phase/manual-testing-plan.md), [:233](../../dev/post-phase/manual-testing-plan.md) | **`user-secrets` is not used; `<UserSecretsId>` was removed so the store is not read** — [CLAUDE.md:90](../../CLAUDE.md), [DEPLOY.md:20](../../DEPLOY.md), [server/CLAUDE.md:70](../../server/CLAUDE.md), and `Baya.Web.Api.csproj` (no `UserSecretsId` element) | 3 | open |
| C-2 | **Placeholder value is literally `SET_VIA_USER_SECRETS_OR_ENV`**`server/src/API/Baya.Web.Api/appsettings.json` (6 occurrences), enforced by `StartupSecretsGuard` and by the pre-commit hook | **That store does not exist any more** (C-1's B side). The name instructs a reader to use a removed mechanism | 2 | open — naming only, behaviour is correct |
| C-3 | **The server listens on `https://localhost:5002`** — [CLAUDE.md:62](../../CLAUDE.md), [:135](../../CLAUDE.md), [api-conventions.md:6](../../dev/contracts/conventions/api-conventions.md), [RUNBOOK.md:7](../../dev/post-phase/refinement/RUNBOOK.md), [:84](../../dev/post-phase/refinement/RUNBOOK.md), [:112](../../dev/post-phase/refinement/RUNBOOK.md), + 12 more | **It listens on `http://localhost:5002`**`launchSettings.json:25` (`"applicationUrl": "http://localhost:5002"`), and `client/.env.development:20` (`NEXT_PUBLIC_API_URL = http://localhost:5002`) | 2 + 3 | **partly resolved by phase 1** — the three rule-file occurrences (root `CLAUDE.md` ×2, `server/CLAUDE.md`) now say `http`. `api-conventions.md` (phase 2) and `RUNBOOK.md` + the remaining 12 (phase 3) are untouched |
| C-3 | **The server listens on `https://localhost:5002`** — [CLAUDE.md:62](../../CLAUDE.md), [:135](../../CLAUDE.md), [api-conventions.md:6](../../dev/contracts/conventions/api-conventions.md), [RUNBOOK.md:7](../../dev/post-phase/refinement/RUNBOOK.md), [:84](../../dev/post-phase/refinement/RUNBOOK.md), [:112](../../dev/post-phase/refinement/RUNBOOK.md), + 12 more | **It listens on `http://localhost:5002`**`launchSettings.json:25` (`"applicationUrl": "http://localhost:5002"`), and `client/.env.development:20` (`NEXT_PUBLIC_API_URL = http://localhost:5002`) | 2 + 3 | **partly resolved by phases 1 + 2** — the three rule-file occurrences now say `http`; phase 2's [api-contract.md](../integration/api-contract.md) states `http` and banner-marks `api-conventions.md` as history. **Phase 2 also found a new occurrence the survey missed: `client/.env.sample:20`**, the file a fresh clone copies. `RUNBOOK.md` + the remaining occurrences are phase 3 |
| C-4 | **RUNBOOK's `dotnet dev-certs https --trust` step is required**, because the browser would otherwise reject the API — [RUNBOOK.md:26](../../dev/post-phase/refinement/RUNBOOK.md) | **The API is plain HTTP locally** (C-3's B side), so there is no certificate to trust | 3 | open — likely a dead step |
| C-5 | **Bring-up starts a local SQL Server in Docker on `localhost:1433`** — [RUNBOOK.md:18](../../dev/post-phase/refinement/RUNBOOK.md), [:3548](../../dev/post-phase/refinement/RUNBOOK.md), [:63](../../dev/post-phase/refinement/RUNBOOK.md) | **The committed dev config points at a remote SQL Server**`appsettings.Development.json` (`Server=87.107.152.16,1433`), and [manual-testing-plan.md:20](../../dev/post-phase/manual-testing-plan.md) calls the remote one "currently" the target | 3 | open — the two bring-up paths give *different worlds*: the remote DB is already seeded, a fresh local one is not |
| C-6 | **`GET /api/v1/webhooks/payouts/{provider}` does not exist** — absent from `dev/contracts/openapi/swagger.v1.json` (frozen 2026-07-13) and from every `dev/contracts/domains/*.md` | **It exists** — present in the fresh snapshot `docs/integration/openapi/swagger.v1.json` (2026-07-29). See [§ OpenAPI drift](#openapi-drift) | 2 | open |
| C-7 | **`GeneratePayoutBatchCommand`'s shape** per the 07-13 snapshot | **A different shape** in the 07-29 snapshot — the only schema of 339 whose definition changed | 2 | open |
| C-8 | **Two contract files describe the same domain** — [`dev/contracts/domains/messaging.md`](../../dev/contracts/domains/messaging.md) is a headerless 851-byte fragment ("Refinement phase 3 additions (REQ-028)") sitting beside the 10.8 K [`messaging-notifications-admin.md`](../../dev/contracts/domains/messaging-notifications-admin.md), which it silently amends | — | 2 | open — merge, don't move both |
| C-9 | **The OpenAPI folder publishes documents `v1` *and* `v1.1`** — [openapi/README.md:3](../../dev/contracts/openapi/README.md) | **Only `swagger.v1.json` has ever been committed**; `v1.1` was not fetched during this survey | 2 | open — `UNVERIFIED`, check `/swagger/v1.1/swagger.json` when the server is next up |
| C-10 | **18 hardening items are open** — [issues.md](../../dev/post-phase/hardening/issues.md), 18 of 18 checkboxes unticked, last touched 2026-07-17 | **Fourteen UI phases, two manual-testing iterations, a Telegram integration and a deploy commit ran afterwards** (`12ce7fa``96b57eb`, 07-20 → 07-28) without ticking any box | 4 | open — Phase 4 must re-verify each item against code, not trust the checkbox |
| C-12 | **The skill is current** | **It is exactly one iteration behind the code.** SKILL.md's last commit is `baa3cc6` ("manual improvement 1"); `client/CLAUDE.md`'s is `e6a8f93` ("manual improvement 2"), which changed **44 files, +3419/2449** under `client/src`. Anything iteration 2 changed is absent from the skill | 7 | **partly resolved by phase 1** — the design-language half is corrected (see R-2); phase 7 still owns the skill's own workflow/procedure content |
| C-13 | **`dev/` is "the plan for building Balinyaar"**, written in the imperative — [dev/README.md:3](../../dev/README.md) | **It is a record of work already done.** `dev/phases/` last touched 2026-06-28; the code it describes shipped weeks ago | 6 | open — resolved by the archive banner, not by editing 199 files |
| C-14 | **`temp/swagger.json` is a stale committed duplicate** — [_plan/README.md](README.md) diagnosis table | **It is not committed at all**`.gitignore:1` ignores `temp`, and `git ls-files temp/` is empty. It is local clutter, not repo content | 0 | see [§ Corrections](#corrections-to-the-seeded-list) |
| C-15 | **`ui-phase-11-report.md:235` records "REQ-061…064 appended"** to the frontend request ledger | **REQ-061 was never appended.** The ledger goes 060 → 062; **ten live client files** cite REQ-061 for the admin user directory (`admin_users/search`, `admin_users/lookup`), which are 2 of the 24 phantom endpoints | 4 | **found by phase 2** — phase 4 must *file* it, not assume it is tracked |
| C-16 | **Five REQs' filed status** — REQ-050, REQ-063, REQ-066, REQ-067 (open, as filed) and the reasons `admin/constants.ts` gives for the admin mock | **All five are wrong or narrower than filed**, checked against the live swagger rather than against another document. `variantLabel` already ships; `tickets/close`+`reopen` already ship; `search/nurses` and `nurses/{id}/profile` are already anonymous; REQ-029/030 already shipped | 4 | **found by phase 2** — the table is in [phase-2's handoff § B](phase-2-integration.md) |
---
@@ -52,14 +51,27 @@ at `dev/contracts/openapi/swagger.v1.json`:
**Schema definitions changed (1)**
- `GeneratePayoutBatchCommand`
- `GeneratePayoutBatchCommand` — gained `systemInitiated: boolean` (**C-7**, resolved)
> **This is Phase 2's worklist for the machine contract, and it is two lines long.** The 16-day gap
> between the snapshot and the code produced almost no wire-level drift. Phase 2 should therefore spend
> its budget on the **hand-written** `dev/contracts/domains/*.md` — 17 files, frozen the same day, never
> mechanically checked against anything — rather than on the JSON. See
> **This was Phase 2's worklist for the machine contract, and it was two lines long.** The 16-day gap
> between the snapshot and the code produced almost no wire-level drift. Phase 2 therefore spent its
> budget on the **hand-written** contract docs instead. See
> [§ Corrections](#corrections-to-the-seeded-list) C-14b.
### What phase 2 found once it looked
Re-scoping was right, but the conclusion "the hand-written docs are where the drift is" turned out to be
only half true, and the more useful axis was a third one nobody had checked:
| Axis | Result |
| --- | --- |
| Old contract docs → live swagger, at **route** level | **Clean.** Every route named across the 17 files exists in the live API (the two apparent misses are prose counter-examples: "X, *not* Y") |
| Old contract docs → live swagger, at **convention** level | **Drifted.** Body casing ("typically snake_case" vs. proven camelCase), the server's local URL scheme, the envelope's field count (5 vs. 6 — `code` was added), and the enum vocabularies |
| **Client's real API clients → live swagger** | **24 phantom routes** — calls to endpoints the server does not expose, 2 of them reachable today. This axis had never been checked and is where the real gap was |
The third row is the phase's main finding, and it is in
[phase-2's handoff § A](phase-2-integration.md).
---
## Corrections to the seeded list
@@ -105,6 +117,11 @@ when phase 4 creates it** — that file does not exist yet, so this table is the
| # | Was | Decision | By |
| --- | --- | --- | --- |
| **C-11** | The frontend-designer skill and `client/CLAUDE.md` both claimed the design language, with no stated precedence | **Precedence is now stated in both directions.** The skill is the **design** contract (brand, tone, logo construction, the visual decisions, and the workflow for turning a design into a screen); [`docs/rules/client/`](../rules/client/) is the **engineering** contract and **wins on every overlap** — tokens, typography, the component library, shells, icons. SKILL.md's header carries the precedence statement plus a table pointing at the four files it defers to, and the overlapping detail was removed from the skill rather than duplicated. `client/CLAUDE.md` no longer restates design content at all. | phase 1 |
| **C-2** | The placeholder `SET_VIA_USER_SECRETS_OR_ENV` names a store that was removed, so its *name* instructs a reader to use a removed mechanism | **The name stays; the mechanism is documented authoritatively elsewhere.** The string is a load-bearing sentinel in **seven live files**`appsettings.json` (×6), `StartupSecretsGuard.PlaceholderMarkers`, `.githooks/pre-commit`, `.githooks/README.md`, `Baya.Test.Api/StartupSecretsGuardTests.cs` (×2), `docs/rules/shared/git-and-gates.md`, `docs/rules/server/structure.md`. Renaming it is a server-code + git-hook + test change needing `dotnet build`/`dotnet test` to prove the gate still fires — outside a documentation phase's scope. [config-matrix.md](../integration/config-matrix.md#the-placeholders-name) is now the single authoritative statement that **config lives in appsettings files and environment variables and `user-secrets` is not read**, and `DEPLOY.md` says the same. **The rename is filed for phase 4 with that exact seven-file worklist.** | phase 2 |
| **C-6** | `POST /api/v1/webhooks/payouts/{provider}` was absent from the frozen contract | **It exists and is now documented** — the payout transferor's reconciliation callback, anonymous, `webhook` rate-limit policy, in [domains/payouts.md](../integration/domains/payouts.md). | phase 2 |
| **C-7** | `GeneratePayoutBatchCommand` changed shape between the two snapshots, unexplained | **It gained `systemInitiated: boolean`** alongside `periodStart`/`periodEnd`. That is the refinement-phase-7 scheduler flag distinguishing a job-generated draft batch from an admin-generated one — which is what keeps the "generation may be automatic, *processing* is always an explicit admin action" rule auditable. Recorded in [domains/payouts.md](../integration/domains/payouts.md). | phase 2 |
| **C-8** | Two live contract files for one domain: a headerless 851-byte `messaging.md` silently amending the 10.6 K `messaging-notifications-admin.md` | **Merged and split three ways along the client's real domain boundaries** — tickets → [domains/tickets.md](../integration/domains/tickets.md), the notification feed → [domains/notifications.md](../integration/domains/notifications.md), config/holidays/audit/alerts → [domains/admin.md](../integration/domains/admin.md). Every REQ-028 amendment is folded in as current fact, not as a change log. Both old files carry a merged-into banner so neither reads as live; phase 6 archives them. | phase 2 |
| **C-9** | The OpenAPI folder claimed documents `v1` **and** `v1.1`; only `v1` was ever committed | **Both documents genuinely are registered** (`AddSwagger("v1","v1.1")` in `Program.cs`), so `/swagger/v1.1/swagger.json` is served — **and it contains zero paths.** `ApiVersionDocumentProcessor` drops every path whose URL lacks the document's version segment, and all **55 controllers are `[ApiVersion("1")]`** on the route template `api/v{version:apiVersion}/…`. The old claim was literally true and substantively empty. `v1` is the contract; only `v1` is worth committing. **Resolved by reading the code — no server boot needed, so the `UNVERIFIED` marker is retired.** | phase 2 |
### Corrections landed by phase 1 that were not on the seeded list
+133 -2
View File
@@ -145,5 +145,136 @@ file, and a deploy question is answered by `topology.md` + `config-matrix.md` wi
## Handoff
_(filled in by the agent that runs this phase — especially: the drift list, since Phase 4 turns the
`phantom` and `drifted` rows into backlog items)_
> Run 2026-07-30 against commit `d3ec723`. **All verification items pass**, checked mechanically
> (script in the session scratchpad, results reproduced below).
### What shipped
```
docs/integration/
index.md 153 lines — the whole seam, one screen and a half
api-contract.md envelope · casing · status codes · auth · pagination · idempotency · money · enums · rate limits
config-matrix.md every key in both appsettings, docker-compose, 3 .env files, the bot, Caddy, CORS
topology.md mermaid graph + a 10-edge table + ports + startup order + the local variant
domains/
index.md the census, the route-shape exceptions, the enum map, what-replaced-what
<22 files> one per client services/ domain, 1:1
openapi/README.md updated: C-9 resolved, the servers-block provenance wrinkle recorded
```
Also updated: `DEPLOY.md` (links topology + config-matrix; states there is no `appsettings.Production.json`
so step 2 of "Going to Production" is a *create*), `docs/README.md` (integration → **written**),
`dev/contracts/README.md` + `openapi/README.md` + `domains/messaging.md` (moved/merged banners).
### Verification
| Item | Result |
| --- | --- |
| Every swagger path declared in exactly one `domains/*.md` | **178/178**, 0 unreferenced, 0 duplicated |
| Operation census adds up | 186 = 184 domain + 2 `ping` in `api-contract.md` |
| Every phantom declared with a reason + REQ | **24/24** |
| `config-matrix.md` accounts for every key | Diffed mechanically; 5 gaps found and written up |
| `DEPLOY.md` no longer instructs `user-secrets` | Pass — it already documented the removal; now also links the new files |
| `index.md` fits a screen and a half | 153 lines |
| Every doc carries a `Last verified` stamp | 27/27 |
| No reference file over 400 lines | Pass |
| Relative links resolve | **216 checked, 0 broken** |
### Scope note
`api-contract.md` was **merged into `index.md`'s siblings as planned**, but the plan's `domains/` count of
22 was met by re-cutting the file set along the **client's** domain boundaries rather than the backend
phases the old 17 files used. That is what makes the mapping 1:1 and is why the file names changed.
`swagger.v1.json` was **not** re-fetched — Phase 0's 2026-07-29 snapshot is 1 day older than this run and
the server was not booted. Everything here is derived from that snapshot plus current source.
---
### Drift list — Phase 4's input
**A. Phantom endpoints (24).** The client's real API clients call 24 routes the server does not expose.
Each is declared in its domain file with its REQ. Grouped by REQ:
| REQ | Status | Phantoms | Domain |
| --- | --- | --- | --- |
| REQ-031 | deferred | 3 (`admin_roles/*`) | admin |
| **REQ-061** | **never filed** | 2 (`admin_users/search`, `/lookup`) | admin |
| REQ-022 | partial | 3 (`checkout_bnpl/options`, `/schedule`, `/wallet_installments`) | bnpl |
| REQ-032 | partial | 5 (`centers/me/*`, `partner-centers/{id}/nurses`) | partner-center |
| REQ-033 | partial | 1 (`centers/me/settlement`) | partner-center |
| REQ-064 | open | 1 (`centers/me/bookings/{id}`) | partner-center |
| REQ-034 | deferred | 3 (`admin_verifications` documents-url / approve / reject) | verification |
| REQ-035 | deferred | 3 (`admin_refunds` preview / approve / reject) | refunds |
| REQ-036 | deferred | 1 (`admin_payouts/{id}/transfer_reference`) | payouts |
| REQ-047 | open | 1 (`bookings/payment_history`) | payment |
| REQ-048 | open | 1 (`refunds/my`) | refunds |
| REQ-063 | open | 1 (`tickets/{id}/assign`) | tickets |
**Two are live 404s** because their domain's mock is off: `bookings/payment_history` (payment) and
`tickets/{id}/assign` (tickets). Both are guarded in the client, so neither is user-visible — but they are
reachable, not hypothetical.
**B. Five REQs whose filed status is wrong or narrower than reality.** Each was checked against the
swagger, not against another document. Phase 4 should re-triage these rather than copy the ledger.
| REQ | Filed as | Actually |
| --- | --- | --- |
| REQ-050 | open — "the list DTO carries neither field" | `BookingRequestListItemDto` **has `variantLabel`** (and `patientAge`). It lacks `variantPrice`/`variantPriceUnit` and the `status=answered` group filter. The REQ was verified against the *client type*, not the wire |
| REQ-063 | open — "no close/reopen/assign routes yet" | `tickets/{id}/close` and `/reopen` **exist and are wired**. Only `assign` is missing. `tickets/constants.ts` carries the stale reason |
| REQ-066 | open — needs an anonymous search read | `GET /search/nurses` **is already anonymous**. What is missing is the rate limit — `SearchController` has no `[EnableRateLimiting]` |
| REQ-067 | open — needs an anonymous profile read | `GET /nurses/{id}/profile` **is already anonymous**. What is missing is the privacy review |
| REQ-029 / REQ-030 | delivered | Correct — but `admin/constants.ts` still names them as reasons the admin mock is primary. The only live reasons are REQ-031 and REQ-061 |
**C. Client-side drifts (client work, not contract work).**
| # | Where | What |
| --- | --- | --- |
| 1 | `client/src/lib/api/types.ts` | `ApiEnvelope<T>` omits **`code`**, which the server sends and `clientFetch` already reads at runtime. Incomplete type, no defect |
| 2 | `client/src/services/admin/apis/clientApi.ts` | `pageQuery()` sends **`page_size`**; these endpoints declare `PageSize`. Model binding is case-insensitive, **not separator-insensitive**, so it binds nothing and every admin list silently gets the default page size. Latent until `USE_ADMIN_MOCK` flips |
| 3 | `client/src/services/bookingRequests/types.ts` | Marks `variantLabel` as client-augmented/absent; the server serves it. Widening the type would light up the redesigned inbox card today |
| 4 | `client/src/services/payouts/apis/clientApi.ts` | Comment says `NursePayoutHistoryDto` has no `failureReason`; **the wire has it** on all three payout read models |
| 5 | `client/src/services/tickets/constants.ts` | Says the backend has no close/reopen/assign; close and reopen exist (B, above) |
| 6 | `client/src/services/payouts/apis/clientApi.ts` | Sends `Idempotency-Key` on `admin_payouts` process/retry, where the server never reads it. Decorative, not harmful |
| 7 | `client/.env.sample` | Still `NEXT_PUBLIC_API_URL = https://localhost:5002` — the `https` half of **C-3**, in the file a fresh clone copies. `.env.development` is correct |
| 8 | `client/src/services/profiles/` | `POST customer_profiles/avatar` exists and is live; the client only wires the nurse route, so a customer cannot set a photo |
**D. Cross-side vocabulary asymmetry (1).** The client's `TicketAuthorRole` includes **`system`**;
`Entities/Messaging/TicketCodes.cs` defines only `customer`/`nurse`/`admin`. Safe on the reading side, but a
reader of the client types would wrongly conclude the server emits it. All other 17 shared vocabularies
match exactly, both directions.
**E. Route-shape exceptions (4).** `admin/partner-centers` (+ `/set-active`, `/sponsor-nurse`),
`admin/tickets`, `admin/reviews/…` and `internal/bookings/…` hardcode route strings instead of using the
`[controller]`/`[action]` tokens; three introduce hyphens, against the snake_case rule. **Because the route
also derives the dynamic-permission key, normalising them breaks permissions as well as URLs** — recorded,
not fixed.
**F. Config gaps (5).** Written up in
[config-matrix.md § What the diff found](../integration/config-matrix.md#what-the-diff-found):
`NEXT_PUBLIC_EVV_MOCK_GPS` and `NEXT_PUBLIC_VERSION` read but declared in no `.env`;
`OpenTelemetry:Otlp:Endpoint`, `Search:Backend` and `Seed:Admin*` read but set nowhere (and a bad
`Search:Backend` **throws at startup**); `.env.sample`'s stale `https`; the Telegram shared secret
duplicated across two files with nothing keeping them equal; no `appsettings.Production.json`.
**G. Unfiled REQ (1).** `ui-phase-11-report.md` records "REQ-061…064 appended" to the ledger. **Only
062/063/064 were.** Ten live client files cite REQ-061 for the admin user directory
(`admin_users/search`, `admin_users/lookup`). Phase 4 must **file it**, not assume it is tracked.
### Contradictions resolved here
**C-6** · **C-7** · **C-8** · **C-9** closed; **C-2** closed as a documentation decision; **C-3**'s
contract-layer half closed. Decisions recorded in
[open-contradictions.md § Resolved](open-contradictions.md#resolved).
### What Phase 3 should know
- The seam facts it needs are settled and mechanically verified: camelCase bodies, snake_case URLs, bearer
header (**not** cookie auth), money as a digit string outbound and an int64 inbound, `Idempotency-Key` on
exactly two endpoints, 20 anonymous operations.
- **`GET /api/v1/dev/last_otp/{phone}` is live on `api.balinyaar.ir`** — the deployment runs as Development
with a capture-safe SMS provider, so both gate conditions hold. Any flow doc that describes logging in
should say this is how you do it today, and that it is the deployment's largest exposure.
- C-4 (the `dev-certs` step) is confirmed dead: the API binds plain HTTP locally, so there is no certificate
to trust. C-5's two bring-up worlds are real and give **different data** — the committed dev config points
at the same remote, already-seeded database the deployment uses.
+284
View File
@@ -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.
+264
View File
@@ -0,0 +1,264 @@
# 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-07-30 against commit `d3ec723`. 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 and the pre-commit hook blocks its return |
| `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` and the pre-commit hook both reject — 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 seven live files:
`appsettings.json` (×6), `StartupSecretsGuard.cs`, `.githooks/pre-commit`, `.githooks/README.md`,
`Baya.Test.Api/StartupSecretsGuardTests.cs` (×2), `docs/rules/shared/git-and-gates.md` and
`docs/rules/server/structure.md`. Renaming it is a server-code + git-hook + 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
exact seven-file worklist.
+45
View File
@@ -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.
+99
View File
@@ -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 |
+79
View File
@@ -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 |
+92
View File
@@ -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+13 /
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 |
+97
View File
@@ -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) |
+63
View File
@@ -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.
+64
View File
@@ -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).
+121
View File
@@ -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 [`dev/contracts/domains/`](../../../dev/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 |
+48
View File
@@ -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 |
+53
View File
@@ -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 |
+52
View File
@@ -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 |
+95
View File
@@ -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 |
+102
View File
@@ -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 |
+57
View File
@@ -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.
+88
View File
@@ -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) |
+85
View File
@@ -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) |
+81
View File
@@ -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 |
+53
View File
@@ -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).
+91
View File
@@ -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 |
+117
View File
@@ -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 |
+142 -24
View File
@@ -1,34 +1,152 @@
# Integration — the client↔server seam
> **Populated by phase 2 — not yet written**, except the OpenAPI snapshot, which phase 0 refreshed.
> Until then the contract lives in [dev/contracts/](../../dev/contracts/README.md) (frozen 2026-07-13)
> and the deployment shape in [DEPLOY.md](../../DEPLOY.md).
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.
The two projects have no shared build. Everything that crosses between them — envelope shape, casing,
pagination, idempotency, cookies and refresh, CORS, `NEXT_PUBLIC_API_URL`, three containers behind Caddy,
a remote SQL Server and the OTP relay — is described **here, as one thing**. Today it is described
nowhere as one thing, which is the gap this section closes.
## Planned contents
> 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` | Envelope, casing, pagination, error shape, idempotency, auth headers/cookies |
| `domains/` | One file per service domain, refreshed against the live swagger |
| [`openapi/`](openapi/README.md) | The machine contract **snapshot is current as of 2026-07-29** |
| `config-matrix.md` | Every env var and appsettings key: client, server, docker, the bot |
| `topology.md` | The runtime dependency graph — 3 containers + Caddy + remote SQL + OTP relay |
| [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 |
## What phase 0 already established
---
The machine contract barely drifted. Between the 2026-07-13 snapshot and the live server on 2026-07-29:
**one endpoint added** (`POST /api/v1/webhooks/payouts/{provider}`), **one schema changed**
(`GeneratePayoutBatchCommand`), nothing removed. Full diff in
[_plan/open-contradictions.md § OpenAPI drift](../_plan/open-contradictions.md#openapi-drift).
## Transport
So phase 2's real work is the **17 hand-written** `dev/contracts/domains/*.md` files, which were frozen
the same day and have never been checked against anything mechanically — not the JSON.
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`.
Also open for this section: **C-2** (`SET_VIA_USER_SECRETS_OR_ENV` names a store that was removed),
**C-3** (`https://localhost:5002` in 18 places vs `http://` in `launchSettings.json`), **C-8** (two
contract files for messaging), **C-9** (a `v1.1` OpenAPI document that may not exist).
**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.
+29 -6
View File
@@ -42,15 +42,38 @@ Two things that cost time the first run:
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
## Scope`v1`, and why `v1.1` is empty
`v1` only. [The old README](../../../dev/contracts/openapi/README.md) claimed the server publishes
documents `v1` **and** `v1.1`, but only `v1` has ever been committed and `v1.1` was not fetched during
the phase 0 survey. `UNVERIFIED:` check `/swagger/v1.1/swagger.json` when the server is next up
(contradiction C-9).
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 — lands in [`../domains/`](../index.md) in phase 2. **The two must
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).
+145
View File
@@ -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.