cleanup phase 1

This commit is contained in:
hamid
2026-07-30 02:26:52 +03:30
parent d3ec723119
commit c889c46110
36 changed files with 4251 additions and 2552 deletions
+3 -3
View File
@@ -27,7 +27,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 | not yet written · phase 1 |
| [rules/](rules/index.md) | What must never be broken — the tiered rule set behind the `CLAUDE.md` files | **written** · phase 1 |
| [integration/](integration/index.md) | The client↔server seam in one place: contract, config, topology, OpenAPI | OpenAPI snapshot only · 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 |
@@ -52,5 +52,5 @@ Two documents stay outside this tree on purpose:
3. **Write short.** A reference doc over ~400 lines should be split.
4. **English throughout**, including in files that describe Persian UI copy.
The full convention lands in `docs/rules/documentation.md` (phase 1), enforced by a pre-commit warning
(phase 7).
The full convention is in [docs/rules/documentation.md](rules/documentation.md), to be enforced by a
pre-commit warning (phase 7).
+27 -4
View File
@@ -18,7 +18,7 @@ written up as they actually are, with the evidence, in [§ Corrections to the se
| --- | --- | --- | --- | --- |
| 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 | open |
| 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-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 |
@@ -26,8 +26,7 @@ written up as they actually are, with the evidence, in [§ Corrections to the se
| 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-11 | **The frontend-designer skill is the design-language authority** — [SKILL.md §§17](../../.claude/skills/frontend-designer/SKILL.md) | **`client/CLAUDE.md` also states theme, tokens, typography, icons and anti-patterns** — [§Theme System:646](../../client/CLAUDE.md), [§Forms:590](../../client/CLAUDE.md), [:2223](../../client/CLAUDE.md) (icons). Overlapping scope, two files, no stated precedence | 1 + 7 | open |
| 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 | open |
| 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) |
@@ -100,4 +99,28 @@ the repo's documentation surface. Phase 0's brief says to delete it; that delete
## Resolved
_(none yet — phases move rows here with the decision and the commit that made it)_
Each row records the decision. **These decisions still need folding into `docs/status/decisions.md`
when phase 4 creates it** — that file does not exist yet, so this table is their only home.
| # | 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 |
### Corrections landed by phase 1 that were not on the seeded list
Six rule statements were **false against the code**, not merely duplicated. Each was rewritten against
reality rather than carried over. They are recorded here because a future reader of `dev/`'s history will
find the old wording and needs to know it was checked.
| R- | The stale claim | Reality | Where it was |
| --- | --- | --- | --- |
| R-1 | "Use `ColorSchemeScript` from `@/theme`" | **No such export exists.** The no-flash boot is CSS-only; `theme/index.ts` exports only `ThemeProvider`, `getDirection`, `APP_THEME_*` | `client/CLAUDE.md:857` |
| R-2 | A `Storage.prototype.setItem` intercept writes the theme cookie (3 occurrences) | **There is no such patch.** `ColorSchemeCookieSync` — a `useColorScheme()` effect in `ThemeProvider.tsx` — writes it via `setClientCookie` | `client/CLAUDE.md:698, 711, 741` |
| R-3 | `AppImage` is part of the component library | **No such component** under `src/components/` | `SKILL.md:154` |
| R-4 | `CONTENT_MAX_WIDTH = 800` | It is **480**, mirroring `APP_FRAME_MAX_WIDTH` — iteration 1 changed it and the skill was never updated (this is C-12's concrete shape) | `SKILL.md:159` |
| R-5 | `AppFrame`'s header/`<main>`/footer are flex siblings, so a top bar is `position: static` | Iteration 2 pinned both bars **`position: absolute`** over a single scrolling `<main>`, which reserves their height as padding and publishes `--bal-chrome-top`/`-bottom` | `SKILL.md:181186` |
| R-6 | `DarkModeButton.tsx` is the `common` namespace's consumer | **Component deleted**; `ThemeModeSetting` (a three-way segmented control in `SettingsPanel`) replaced it | `client/CLAUDE.md:507` |
Two counts were also wrong and are corrected in the new docs: `client/CLAUDE.md:925` said "14 domains are
now REAL" and then listed **15** (verified against `services/*/constants.ts`: 15 real, 7 mocked), and
`:61` described `npm run check` as "type then lint", omitting **`lint:copy`**.
+69 -1
View File
@@ -172,4 +172,72 @@ which one file to open next for the area it is touching.
## Handoff
_(filled in by the agent that runs this phase)_
**Run 2026-07-30 against commit `d3ec723`. Complete.**
### What shipped
| | Before | After |
| --- | --- | --- |
| `client/CLAUDE.md` | 1,098 lines / 158 K | **177 lines** |
| `server/CLAUDE.md` | 772 lines / 73 K | **184 lines** |
| root `CLAUDE.md` | 136 lines | **164 lines** |
| `server/CONVENTIONS.md` | 508 lines | **deleted**`docs/rules/server/conventions.md` |
| `client/messages/STYLE.md` | 116 lines | **deleted**`docs/rules/client/i18n.md` §4 |
| `docs/rules/` | 1 stub | **18 files, 3,486 lines**, every one ≤400 |
The cost of opening the client rules before editing client code went from ~40k tokens to ~5k: 177 lines of
hard rules plus one ~200-line reference file for the area you are in.
### Deviations from the plan, and why
1. **`docs/rules/server/money.md` is a 6th server file**, not in the Outputs tree. `persistence.md` came in
at 456 lines with the money content in it, over the 400-line budget this phase itself sets. Splitting the
money path out is the sanctioned response to overflow, and it is the most-consulted sub-topic on that
side — `persistence.md` is now 382 and `money.md` 244. Both `CLAUDE.md` and `rules/index.md` route to it.
2. **`docs/rules/shared/api-conventions.md` and `money-and-types.md` were not created**, though
`_plan/inventory.md:134135` assigns them owner phase 1. The phase file (lines 3839) says **phase 2 owns
those two contract files** and to read them for cross-check only, and `docs/README.md` puts the wire
contract in `docs/integration/`. The plan file is the more specific and later instruction, so it won.
**Phase 2 must therefore write `docs/integration/api-contract.md`** covering the envelope, status codes,
casing, pagination, idempotency keys, money-on-the-wire, enum codes, PII masking, and the Shamsi
`day_of_week` rule. `docs/rules/index.md` already points there and says so.
3. **`.claude/skills/frontend-designer/SKILL.md` was edited**, which the tree lists as phase 7's. C-11 could
not be resolved without stating precedence *in the skill*, and four of its factual claims were wrong
(R-3…R-5 in [open-contradictions.md](open-contradictions.md)). Only the design-language half was touched;
§8's workflow and §10's Figma section are untouched and still phase 7's.
### Also changed, to keep the tree consistent
- `AGENTS.md` ×3 — repointed at `docs/rules/`; still thin pointers (14 lines each).
- `client/scripts/check-copy.mjs` — its doc comment now names `docs/rules/client/i18n.md` §4 (it never read
`STYLE.md` by path, so the delete was safe; `npm run check` confirms).
- `server/README.md`, `server/.dockerignore` — dropped the `CONVENTIONS.md` references.
- `server/docker-compose.yml`, `client/.env.sample` — two live files still instructed `dotnet user-secrets`.
Neither was on C-1's list. Fixed.
- `docs/README.md``rules/` marked written.
### What the next phases inherit
| Phase | What phase 1 leaves it |
| --- | --- |
| **2** | Write `docs/integration/api-contract.md` (see deviation 2). Fix C-3's remaining half in `api-conventions.md`. The rules tree links to `docs/integration/index.md` and expects it to answer the wire contract. |
| **3** | C-1's one genuinely wrong live doc (`manual-testing-plan.md`) is still open — phase 1 fixed two other files it didn't know about. C-3's RUNBOOK half too. |
| **4** | **Six decisions need folding into `docs/status/decisions.md`** — the C-11 resolution and R-1…R-6, all recorded in [open-contradictions.md](open-contradictions.md) § Resolved, which is currently their only home. Also: `docs/rules/` links to `docs/status/backlog.md` for the 7 mock-blocking REQs and to `docs/status/` as the mock registry's new home. And one drift worth a backlog item: `client/src/services/payment/constants.ts` has `MOCK_PLATFORM_FEE_RATE = 0.12`, while refinement-phase-3 settled the canonical model at 0.15 — mock-only today, but it will lie on a checkout screenshot. |
| **6** | The `dev/`-lane handoff protocol (STATUS.md, `for-backend.md`, per-phase reports, the "save memory" step) was deliberately **not** carried into `docs/rules/` — the parallel-agent chain is finished. Its durable half (contract-first, record every mock) is in `documentation.md`. |
| **7** | Owns the pre-commit warning that enforces `documentation.md` §2, and the rest of the skill. C-12's non-design half is still open. |
### Verification
- [x] `client/CLAUDE.md` 177 lines, `server/CLAUDE.md` 184 — both under 250.
- [x] 20 ledger rules spot-checked for single-home placement; the 6 stale ones are gone from every file.
- [x] No rule describes pre-iteration-1/2 client behaviour (R-1…R-6 rewritten against the code).
- [x] `grep -rn "user-secrets"` over tracked `client/ server/ CLAUDE.md docs/` returns only statements that
it is **not** used (plus `_plan/`'s own descriptions of the contradiction).
- [x] `cd client && npm run check` passes — `check-copy: 2005 strings checked, 0 banned variants found.`
- [x] All 3 `AGENTS.md` resolve; a link check over all 27 new/changed markdown files found 0 broken links.
- [x] C-11 resolved with its decision; C-3 and C-12 marked partly resolved with what remains and to whom.
The Step-1 rule ledger (109 numbered rules across 11 groups, each tagged with source, scope, tier,
destination and state) was scratch by design and is not committed, per the phase brief. Its content is fully
represented in the output files; the six corrections and two count fixes it surfaced are recorded durably in
[open-contradictions.md](open-contradictions.md) § Resolved.
+184
View File
@@ -0,0 +1,184 @@
# Client auth
Cookies, the session lifecycle, silent refresh, `RoleGuard`, and the middleware gate — plus an explicit
statement of what is *not* a security boundary.
> Last verified: 2026-07-30 against commit `d3ec723`.
---
## 1. The credential is phone-OTP
There is **no username/password anywhere**, and email is never a login key. The flow lives in
`src/components/auth/` (`LoginFlow``PhoneStep``OtpStep`) at `/login`, over the `services/auth` domain
(`requestOtp` / `verifyOtp` / `refresh` / `logout` / `getMe` / `selectRole`).
`useWebOtp` is the WebOTP autofill seam — on a supporting browser the code fills itself from the SMS.
---
## 2. The two cookies
| Cookie | Constant | TTL | Written by |
| --- | --- | --- | --- |
| `access_token` | `COOKIE_NAMES.ACCESS_TOKEN` | 15 min | `persistAuthTokens` (`lib/auth/session.ts`) — via `useVerifyOtp`, `useRefresh`, `useSelectRole`, and the fetch-layer silent refresh |
| `refresh_token` | `COOKIE_NAMES.REFRESH_TOKEN` | 7 days | same |
Lifecycle:
- **Written** by `persistAuthTokens` after verify / refresh / select-role, which also dispatches `LOG_IN` so
`AuthContext` stays in sync without a reload.
- **Deleted** by `useLogout()` — the single logout path: revoke the server session, clear both cookies,
`LOG_OUT`, drop the `/me` cache, redirect. Also cleared by `clientFetch` when a 401 can't be recovered.
- **Read** server-side by `serverFetch` / `getServerAuthState` via `getServerCookie`; client-side by
`clientFetch` via `getClientCookie`, to attach `Authorization: Bearer`.
The `refresh_token` cookie's 7-day TTL is **shorter** than the server session default (30 days). Aligning the
cookie `maxAge` to the server's `refreshExpiresAt` is a known follow-up, not a bug to fix blind.
All cookie access goes through the manager — see [services.md](services.md) §6.
---
## 3. Session state
`AuthContext` (`src/context/auth/`) carries `SessionUser { id?, phone, roles: AppRole[] }`.
The root layout resolves the session **on the server** with `getServerAuthState()` (`lib/auth/server.ts`),
which reads the `access_token` cookie and checks the JWT `exp` via the shared `isTokenAlive`
(`lib/auth/token.ts`), and passes it to `<AuthProvider initialState={…}>`. So the very first render already
knows whether the user is authenticated.
**Roles are not derivable from the opaque JWE token server-side.** The server therefore seeds
`isAuthenticated` only; `useSessionRoleSync()` — mounted in the `(private-routes)` layout — hydrates
`currentUser.roles` from `/me`. That is the single source the shells read via `useActorRole()`.
`invalidateQueries(authKeys.me())` runs on login; `removeQueries(authKeys.all)` on logout.
---
## 4. Where the user lands: the role router
After a successful verify, `RoleRouter` reads `/me` and navigates — customer → the family app, nurse → the
nurse app, admin → the console, empty roles → `/select-role`. It shows the branded splash while `/me` loads,
**so the wrong shell never flashes.**
The decision itself is the **pure, unit-tested** `resolveRoleDestination(me, intendedRole)` in
`services/auth/routing.ts`. That function is the single "which app" source — every other place that needs to
send a user to their home calls it rather than re-deriving.
The middleware owns the auth *gate*; the router only decides which app.
---
## 5. `RoleGuard`: resolved vs. pending
Every private shell — `(customer)`, `nurse`, `admin`, `partner` — wraps its layout in **`RoleGuard`**.
It exists because the *core* role bug is conflating **"`/me` hasn't resolved yet"** with **"the user has no
nurse/admin role"**. A `/me` in flight used to fall through the `DEFAULT_ROLE = customer` fallback and flash
a nurse the customer app — or strand them there if `/me` failed.
`RoleGuard` reads **`useRoleHydration()`** (`services/auth`), a discriminated `loading | error | ready` over
`useMe`:
| State | Behaviour |
| --- | --- |
| **loading** | A neutral brand splash. **Never the customer shell as a stand-in** |
| **error** (`/me` failed — API down) | `AuthAccountError` with retry. **Never a silent customer fallback** — a transient error must not downgrade a nurse or an admin |
| **role mismatch** | Redirect to the caller's real app via `resolveRoleDestination`, with a `guard_denied` toast — rather than rendering a shell they lack the role for |
A shell passes `expected={APP_ROLES.*}`. **The partner portal passes no `expected`** — a partner-centre admin
is not an `AppRole`. It self-gates on `useMyPartnerCenter` (a 403/404 renders a non-leaking access-denied
state, never a raw id), so `RoleGuard` there only hardens hydration.
`useActorRole()`'s `DEFAULT_ROLE` fallback is now a last resort only — the guard ensures roles are hydrated
before a shell renders — never the loading state.
**`RoleGuard` is UX and chrome, not security.** The server authorizes every endpoint. A dual customer+nurse
session holds both roles and moves freely between the two apps (`ActorSwitcher`).
---
## 6. Silent refresh
`clientFetch` attempts one **single-flight** `attemptTokenRefresh` (`lib/api/refresh.ts`) on a 401 and
retries the request once. A failed refresh — unknown, expired, or reused token, at which point the server
revokes the session — clears tokens and redirects to `/login`.
The refresh and OTP endpoints are **excluded** from this retry, or a failing refresh would recurse.
This mirrors the server's rotation + reuse-detection: a replayed refresh token revokes **all** the user's
sessions.
---
## 7. Middleware
`middleware.ts` runs in this order, and the order is load-bearing:
1. **next-intl locale normalization.** If it is issuing a 307/308, return immediately.
2. **The guest front door.** An **unauthenticated** exact-match on `/` is `NextResponse.rewrite()`d to
`/{locale}/welcome`**never a redirect**, so the URL and the SEO canonical stay `/`.
3. An **authenticated** hit on `/welcome` redirects to `/`.
4. **The auth gate.** A non-public path without a live token redirects to `/login`, appending the attempted
locale-stripped path + query as **`?next=`** (`RETURN_URL_PARAM`) so a deep link — an SMS booking link, a
shared nurse profile — survives the round trip.
5. Otherwise: pass through, stamping the resolved locale on the request headers and preserving next-intl's
response headers (the `Link: alternate` hreflang set).
`LoginFlow` reads `?next=` and `RoleRouter` resolves it via **`resolvePostLoginDestination`**
(`services/auth/routing.ts`), which accepts **same-origin-relative and role-permitting destinations only**
and otherwise falls back to `resolveRoleDestination`. **Never an open redirect.**
### Two traps in this file
**The matcher must list bare `'/'` explicitly** alongside the catch-all regex:
```ts
matcher: ['/', '/((?!_next|_vercel|api|.*\\..*).*)']
```
This Next 16 / Turbopack build does **not** reliably invoke middleware for the literal root through the
negative-lookahead pattern alone — `/` skipped middleware entirely and 404'd, while every other path matched.
It is load-bearing for the guest front door, which only fires on an exact `/` match. (Verified in dev; a
production `next build && next start` confirmed the intended behaviour end to end, so the underlying quirk is
dev-server-only — but the explicit entry stays.)
**Never append `ROUTES.HOME` (`'/'`) to `PUBLIC_PATHS`.** `PUBLIC_PATHS` is matched with `startsWith`, so
`'/'` would silently make **every route public**. The guest-facing root is handled by the exact-match rewrite
above instead. To add a genuinely public route, append it to `PUBLIC_PATHS` and the middleware picks it up
automatically.
---
## 8. Security posture — what is and isn't a boundary
The design above is deliberate, and some of its hardening needs *server* coordination. **Don't silently "fix"
these client-only.**
- **Tokens are non-httpOnly cookies** (JS-readable) so `clientFetch` can attach the bearer header. That
trades XSS hardening for the bearer pattern. Real hardening — httpOnly cookies set by the server plus a
same-origin proxy — spans both projects.
- **The middleware check is UX-only, not a security boundary.** It decodes the JWT and checks `exp`; it does
**not** verify the signature. The API is the only authority. **Never gate real authorization on the
middleware or on `isTokenAlive`.**
- **Role gating is coarse for chrome, fine for the backoffice.** Shells pick chrome from the collapsed
`currentUser.roles` (`useActorRole`). `useAdminCapabilities()` (`@/hooks`) is a memoized selector over the
session's **fine-grained** `roleCodes` (`super_admin` / `admin` / `support` / `finance` / `moderation`)
returning per-console booleans — `canVerify`, `canRefund`, `canPayout`, `canModerate`, `canConfig`,
`canManageAlerts`, `canManageTickets`, `canManagePartners`, `canViewAudit`, `canManageRoles`. `AdminLayout`'s
nav and every admin action hide or disable on it **so a role never sees a control that will 403** — but it
is a **display convenience only; the server authorizes every command.** Never gate real authz on it.
- **Cross-actor route access is not hard-guarded client-side.** Add route guards when a feature needs them.
- **The partner portal is a separate scope** — its pages resolve the caller's own centre via
`useMyPartnerCenter()`, never a raw id.
- **Signed URLs are fetched on demand, never cached long-lived.** Verification documents load via a
short-lived signed URL from `useVerificationDocumentUrl(documentId)` (short `staleTime`, `retry: false`);
`DocumentViewer` re-requests on expiry or error rather than reading an embedded URL out of the long-lived
case query. **Reuse this pattern for any short-lived signed asset** — invoice PDFs included.
- **Refresh-token rotation is wired** client-side (the fetch-layer silent refresh plus `useRefresh`), matching
the server's rotation and reuse-detection.
Admin sub-roles are **server-granted and never self-selectable**; `POST me/select_role` accepts only
`customer` / `nurse` and returns 403 for anything else. Don't build a UI that implies otherwise.
+266
View File
@@ -0,0 +1,266 @@
# Client components, shells and icons
What to reach for before writing something new, and the layout system every screen lives in.
> Last verified: 2026-07-30 against commit `d3ec723`.
---
## 1. There is one layout: a phone
`AppFrame` (`src/layout/AppFrame.tsx`) renders **every** screen inside a centered
`APP_FRAME_MAX_WIDTH` (480px) column on a `--bal-frame-canvas` backdrop, at **every viewport**. A wider
window gets more canvas, never a wider app — so you design one set of states and verify one set of states.
**Do not add a `≥md` branch that widens a shell, restores a sidebar, or lays a screen out in columns.**
`AppFrame` owns four structural guarantees, and is the only place any of them is solved:
1. **The width cap.** No shell stretches a header, a nav bar, or a content column across a monitor.
2. **The frame, not the document, owns the scroll.** A single scrolling `<main>` fills the frame; header and
footer are pinned over it and reserve their own space through `<main>`'s padding, so **no page needs a
top offset of its own**.
3. **Horizontal scroll is structurally impossible.** `overflowX: hidden` + `minWidth: 0` on the column mean
an over-wide child clips instead of dragging the whole app sideways. Genuinely wide content (a data
table) scrolls **inside its own container** — see `AdminDataTable`'s `TableContainer`.
4. **Above `sm` the column floats** as a rounded, shadowed card with a gutter all round. On a phone it fills
the viewport edge to edge — there is no canvas to float on.
Two mechanics that follow from (2) and are easy to get wrong:
- **The chrome is `position: absolute` against the frame, never `fixed`.** A viewport-fixed bar would break
out of the centered column and span the whole window. The frame itself never scrolls (only `<main>` does),
so on a phone — where the frame *is* the viewport — the two are visually identical.
- **`AppFrame` publishes `--bal-chrome-top` and `--bal-chrome-bottom`** on the scroll container, so any
`position: sticky` element anywhere in the tree can clear the bars without importing a constant or knowing
which shell it is in. Both already include `env(safe-area-inset-*)`, and both resolve to `0px` in a
chrome-free shell — which is why a sticky consumer can read them unconditionally. `StickyActionBar` is the
reference consumer.
### Shell dimensions are constants
`src/layout/config.ts` holds them, and they are measured rather than guessed:
| Constant | Value | Note |
| --- | --- | --- |
| `APP_FRAME_MAX_WIDTH` | 480 | Mirrored by `components/config.ts`'s `CONTENT_MAX_WIDTH` — a page column can never be wider than the frame containing it |
| `TOP_BAR_HEIGHT` | 56 | One height at every viewport; the frame never changes width, so the old mobile/desktop split had nothing to switch on |
| `TOP_CHROME_HEIGHT` | 72 | Total space the floating header occupies. Deliberately equal to `BOTTOM_NAV_HEIGHT` — the two bars are the same object mirrored |
| `BOTTOM_NAV_HEIGHT` | 72 | Total space the floating nav occupies. `AppFrame` reserves exactly this as `<main>` padding, so nothing hides behind the bar. **Keep in sync with `BottomBar`** |
| `FLOATING_BAR_SX` | — | The ONE definition of the two bars' shared shape, so header and footer cannot drift apart |
---
## 2. The shells
**One authenticated shell.** `MobileShell` = `AppFrame` + a contextual `TopBar` + `BottomBar` +
`ErrorBoundary` + `RouteFadeIn` + `PageTitleProvider`. The four actor layouts — `CustomerLayout`,
`NurseLayout`, `AdminLayout`, `PartnerLayout`, each wrapped in `RoleGuard` — supply only `tabs` and
`headerActions`.
**Add a destination by adding a tab or a hub row — never by forking the shell.**
| Shell | For |
| --- | --- |
| `MobileShell` (via the four actor layouts) | Every authenticated screen |
| `PublicLayout` | Unauthenticated: the frame and **nothing else**, no top bar — so the login card's own `BrandMark` is the only mark on screen |
| `FocusedLayout` | Framed but chrome-free, for flows a user must not tab away from mid-setup: onboarding, `/select-role`. A slim logo strip and content, no bottom nav. The route group above it still applies `RoleGuard` |
| `PrivateLayout` | An authenticated passthrough wrapper; actor chrome lives in the shells above |
### Navigation is the bottom bar. There is no drawer.
- Tabs are `LinkToPage` arrays built with `useTranslations('nav')`, **35 of them**, and by convention the
last is a settings/«بیشتر» hub.
- Active state comes from the shared **`matchActivePath`** (longest-prefix, winner-takes-all) run over each
tab's own path **plus its `matchPaths` claims**. Use `matchPaths` when a tab owns a destination outside
its own URL subtree — `/nurse/finance` owning `/nurse/earnings`. **Never hand-roll
`pathname.startsWith`**, which lights up a sibling tab as often as the right one.
- `BottomBar` **floats**: inset from the frame edges, fully rounded (`--bal-radius-pill`), elevated — not an
edge-to-edge slab sealing off the bottom of a 480px screen. It is **icon-only**: at five tabs the caption
was the widest thing in the bar and cost a whole line, so the label survives as `aria-label`/`title`. Each
tab is a fixed 44px circle that is simultaneously the target, the hover/press tint and the active fill,
laid out `space-around` so the target keeps one size at any tab count.
- `TopBar` is **not** an `AppBar` — no filled surface, no rule, no elevation of its own. `AppFrame` wraps it
in `FLOATING_BAR_SX`, so it is the bottom bar mirrored. It shows a brand lockup on a tab's own path and a
back chevron + `useRouteTitle()` on anything deeper.
### Group roots are real pages
A nav group's root is a page, not a drawer section: a short summary of that domain — read **only off
queries that already answer it, never a fabricated figure** — over a `NavHubList` of its destinations.
`/nurse/practice`, `/nurse/finance`, `/admin/trust`, `/admin/system` are the references. A count that is
still in flight is **omitted, never faked**.
### Chrome carries no preferences, and no identity
Language and appearance live in `SettingsPanel` (`@/components/settings`), mounted in each actor's settings
hub **and nowhere else**. Identity lives in each actor's «بیشتر»/account hub, one tap away on the nav. The
top bar is for the page title and at most a notification bell.
`/admin/system` is always present in the admin nav even when every console inside it is denied, because it
is the only route out of the app (settings + sign-out).
### Navigation goes through `@/i18n/navigation`
**All chrome navigation** uses `Link` / `usePathname` / `useRouter` from `@/i18n/navigation`
(`createNavigation(routing)`). `usePathname` is locale-stripped, so unprefixed `ROUTES.*` compare directly,
and `Link`/`router` add the locale automatically — **no manual `` `/${locale}` `` prefixing, and no
middleware redirect hop.** Never a raw `next/link` for chrome.
Inside a *page*, `AppLink`/`AppButton`'s `to` is a plain `next/link` and still needs the prefix.
Prefer MUI breakpoints in `sx` for the little responsive branching that remains, over `useIsMobile()`
(`@/hooks`) — the hook is JS/post-hydration and caused a real SSR flash. Reach for it only for genuinely
non-structural, JS-only behaviour.
---
## 3. Reach for these before raw MUI
Shared primitives live in `src/components/`, barrel `@/components`. Prefer the `App*` wrapper over the bare
MUI component — the wrappers carry the house defaults.
| Component | Use for | Notes |
| --- | --- | --- |
| `AppButton` | all buttons and button-links | default `variant="contained"`; pass `to`/`href` to render as a link; `startIcon`/`endIcon` accept an **icon name string** or a node |
| `AppIconButton` | icon-only actions | takes an icon name, `title`, `to`/`onClick` |
| `AppIcon` | any icon | `icon="home"` by registered name (§4); `size`, `color` |
| `AppLink` | internal/external links | locale-aware; default underline `hover` |
| `AppAlert` | inline alerts | defaults to a calm `severity="info"`, `variant="standard"` — a genuinely error-severity call site passes `severity="error"` explicitly |
| `AppLoading` | loading state | circular, `primary`, `3rem` |
Defaults live in `src/components/config.ts` — `APP_BUTTON_VARIANT`, `APP_ICON_SIZE` (24),
`APP_ICON_STROKE_WIDTH` (1.75), `APP_BUTTON_ICON_SIZE` (20), `CONTENT_MAX_WIDTH` (**480**),
`CONTENT_MIN_WIDTH` (320), the alert/link defaults. **Change a default there, not per call site.**
**Concrete MUI primitives stay MUI.** Use `Button`, `Avatar`, `Paper`, `TextField`, `Box`, `Stack`,
`Container`, `Grid`, `Card` directly (or the existing `App*` wrappers) — never invent a new root-level
Button or Avatar. Use the `spacing`/`sx` system (theme unit = 8px); never inline pixel margins for rhythm.
**Composite, shareable components** built from primitives and reused in more than one place belong at the
right *shared* level (`src/components/…`), not inline in a page and not buried in a leaf. Page-only,
never-reused composition can stay in the page.
### The state kit — one pattern per state, and they are not optional
| Primitive | The one pattern for |
| --- | --- |
| `EmptyState` | "nothing here" — icon + title + body + action. Replaces every hand-rolled dashed-border `Paper` |
| `ErrorState` | "this query failed" — `message` + a **required** `retryLabel` + `onRetry` |
| `QueryStateGate` | A query's branching, in the fixed **skeleton → error → empty → children** order. Also requires `retryLabel` |
| `PageHeader` | title + subtitle + `actions` (buttons) + `meta` (a chip row) + a back affordance (`backTo`, or `onBack` which takes precedence and pairs with `useAdminBackToList` for `router.back()`-with-fallback) |
| `ConfirmDialog` | Any destructive confirm. Required-reason gating, busy-disable, and `requireTypedConfirmation` (confirm stays disabled until the typed value matches) — the guard for an irreversible money-moving action, e.g. the admin payout run |
| `SurfaceCard` | A flat `Paper` wrapper; `padding: 'sm' \| 'md' \| 'lg'` |
| `AccentCard` | `SurfaceCard` + a semantic `tone` for a **stateful** panel |
| `Money` | The one money-rendering primitive (`amountIrr`, `size` incl. `xl`, `tone`, `deduction`, `hideUnit`, `strikethrough`) |
| `StatusTimeline` | An ordered `TimelineNode[]` (completed/current/pending/failed) with an animated pulse on `current` |
| `JalaliDatePicker` / `JalaliDateField` / `JalaliDateIntentPicker` | Any Persian-calendar date input. Never a native `type="date"` |
| `StickyActionBar` | A scrolling screen's primary CTA, offset off `--bal-chrome-bottom` |
| `Pager` | The shared prev/next "page X of Y" control. Never a per-screen inline pager |
| `NavHubList` | The grouped destination list a group-root page is built from |
| `InitialsAvatar` | A person with no photo — deterministic name hash → one of six `--bal-avatar-*` pairs, `aria-hidden` beside a visible name |
| `FormDialogShell` | A form dialog: full-screen below `sm`, with a dirty-gated discard confirm |
| `RouteFadeIn` | Route-content motion. Already mounted in all five shells |
**An error state is never an empty state.** A failed query renders `ErrorState`; a successful query with
no rows renders `EmptyState`. Collapsing the two hides outages.
Two `AccentCard` details worth knowing: its colored **edge stripe is gone** — a column of striped cards read
as a row of loose vertical rules down the RTL side of the screen. `tone` survives as the semantic label
(reaching the DOM as `data-accent-tone`), and state is carried by the `StatusChip`, icon and copy inside the
card. **Do not reintroduce the stripe.**
### Presentational purity in `components/common`
`next-intl` (and its `use-intl` dependency) ship ESM-only builds. `jest.config.ts` widens `next/jest`'s
`transformIgnorePatterns` to let them through, but that only fixes real imports — it doesn't make the
dependency free. **Any component at the top of the `@/components/common` barrel that imports `next-intl` at
module scope forces every test file that transitively imports the barrel to deal with it**, including tests
that never touch translations.
So `ErrorBoundary` and `ErrorState` are deliberately **caller-owned**: they take `title`/`body`/`retryLabel`/
`message` as required string props instead of calling `useTranslations` internally, specifically to stay
import-safe at the top of the barrel. `QueryStateGate` inherits the same `retryLabel` requirement by
composition. `Money` is the sanctioned exception — it already had 30+ call sites depending on its
locale-aware API before this was noticed, so the fix went the other way.
When adding a new `common` primitive: **prefer the caller-owned-copy pattern by default**, and reach for
`useTranslations` inside it only if the component is genuinely leaf-level. Keep next-intl-importing
primitives *below* the presentational ones in the barrel so the poisoning risk stays visible in review.
### New shared component
`src/components/<Name>/<Name>.tsx` + an `index.tsx` barrel + a **co-located `<Name>.test.tsx`** (mandatory
for anything imported in more than one place — see [testing.md](testing.md)). Follow the `App*`
prop-spreading and JSDoc style of `AppButton.tsx`.
---
## 4. Icons are a name registry
`src/components/common/AppIcon/config.ts` maps **lowercase** names → components. Render with
`<AppIcon icon="home" />`, or pass the name to `AppButton`/`AppIconButton` (`startIcon="search"`).
**One visual family: Lucide.** Every registered icon comes from `lucide-react` — a contemporary outline
family on a 24px grid with round caps and joins, which reads far lighter than filled glyphs at the small
sizes a phone-width app actually uses. **`@mui/icons-material` is no longer a dependency; never reintroduce
it.** The house stroke weight is `APP_ICON_STROKE_WIDTH` (1.75 — Lucide ships at 2, which competes with
Mikhak's lighter Persian strokes).
**The mapping is semantic, not incidental.** A name describes the domain concept ("verification",
"earnings", "coverage") and the glyph depicts *that*, so swapping the underlying glyph never leaks into call
sites. Related concepts share a visual root on purpose: trust names are shields, money names are coins or
cards, clinical names are a pulse or a cross. Around 110 names are registered — read `AppIcon/config.ts`
rather than duplicating the list.
- **`size` drives real `width`/`height`** (Lucide sizes off SVG attributes), so `size={48}` is 48px with no
`fontSize`/`1em` indirection. Icons default to `flexShrink: 0` — an icon squashed by a flex sibling was
the one layout bug this component kept quietly reintroducing on narrow rows.
- **Directional icons mirror automatically.** Names authored for LTR that must flip under RTL are listed in
`DIRECTIONAL_ICONS` (`back`, `chevron_start`, `chevron_end`, `forward`, `send`). `AppIcon` stamps
`data-icon-directional`, and one CSS rule in `globals.css` does
`[dir='rtl'] [data-icon-directional] { transform: scaleX(-1); }`. Adding a directional icon is a one-line
registry addition — **never hand-roll a per-component flip.**
- **A new icon** is an import from `lucide-react` into `config.ts` plus a lowercase `ICONS` key. Custom SVGs
(the brand mark) go in `AppIcon/icons/` and must accept the same `size`/`color`/`strokeWidth` contract
(`AppIcon/utils.ts`'s `IconProps`). An unregistered name logs a dev-only warning and falls back to
`default`. **Never pass a raw icon component where a name is expected.**
---
## 5. Constants, not magic values
Every magic string or configurable value is a named constant. A value is "magic" if its meaning isn't
obvious from the literal alone: cookie names, event names, route paths, query-param names, numeric
timeouts, API slugs, repeated dimensions.
| Kind | Home |
| --- | --- |
| Cookie names and options | `src/lib/cookies/constants.ts` |
| Feature-scope | a `constants.ts` co-located with that feature |
| App-wide | `src/constants/<concern>.ts` — `routes.ts`, `roles.ts`, `headers.ts`, `policy.ts` |
| Shell dimensions | `src/layout/config.ts` |
| Component defaults | `src/components/config.ts` |
`constants/policy.ts` is the pattern applied to legally-sensitive numbers that trust-critical copy states
in plain language — the payout dispute-window hours, the cancellation lead-time hours, the refund ETA day
range. They are real server config with no public read yet, single-sourced here and fed into message keys
as ICU params rather than baked into a string. See [i18n.md](i18n.md).
Import the constant; **never copy-paste the literal.** When renaming, change the definition and the rest
follows.
---
## 6. Toasts
| From | Use |
| --- | --- |
| A component or hook | `useSnackbar()` → `enqueueSnackbar('…', { variant: 'success' })` |
| Outside React (a plain function, the fetch layer) | `dispatchToast('…', 'error')` from `@/lib/toast` — it fires an `app:toast` window CustomEvent that `ToastBridge` picks up |
`ToastBridge` is already rendered in the root layout. **Do not add another instance.**
**Every mutation whose failure isn't already surfaced inline or by the fetch layer needs an `onError`
toast.** A mutation that only handles `onSuccess` is a defect. But don't toast 401/403/5xx in a hook —
`clientFetch` already does. See [services.md](services.md).
+116
View File
@@ -0,0 +1,116 @@
# Client forms
Every form with more than one field is a react-hook-form form. This is how you build one.
> Last verified: 2026-07-30 against commit `d3ec723`.
---
## 1. The rule, and why it exists
**Any form with more than one field uses react-hook-form.** A single-field control — a search box, a filter
select, a message composer — does not: that is state, not a form.
This is not a style preference. The pattern it replaced was one `useState` per input **plus** a parallel
`useState` per error flag. That meant every keystroke re-rendered the whole screen — including the
query-backed cards, price previews and uploaders sitting beside the field — and left "is this form valid?"
spread across ad-hoc `if` blocks at the top of each submit handler.
react-hook-form gives uncontrolled fields plus per-field subscriptions, so a keystroke re-renders one
input.
28 files currently import it. Any multi-field form that still holds its state in `useState` is a defect to
be migrated when that screen is next substantially touched.
---
## 2. How to build one
1. **`useForm<Values>({ mode: 'onTouched', defaultValues })`.** `onTouched` is the house default: an error
appears once a field has been visited, never while it is first being typed into.
2. **Wrap the subtree in `<FormProvider {...form}>` and bind fields with the `@/components/common/form`
wrappers.** They read `control` off the provider, so it is threaded once.
**Never call `register` or `useController` at a call site.**
3. **Put the rule on the field it governs**`rules={{ validate: … }}` — returning the **translated**
message. Cross-field rules read `validate`'s second argument (all values); that is how the C4 request
form's past-date guard reads the chosen start time.
4. **Render `<Stack component="form" noValidate onSubmit={handleSubmit(submit)}>`** and make the primary
button `type="submit"`. Enter-to-submit then works for free.
5. **Async defaults come from a mounted-when-ready child, not an effect.** When the initial values depend on
a query — the verification credentials read-back, the C4 variant/address defaults — keep the loading
branch in the *parent* and mount the form component only once the data has resolved, so `defaultValues`
**is** the server state instead of being copied into it later.
Point 5 is the one that gets skipped and then costs an afternoon: seeding a form from an effect means the
form has two sources of truth for a moment, and a user who types during that moment loses the keystroke.
---
## 3. The wrappers
| Wrapper | For |
| --- | --- |
| `RhfTextField` | any `TextField`, including `select`. `transform` normalizes keystrokes **into form state** (digit-stripping, max length) so the *stored* value is canonical, not just the displayed one. A rule message replaces `helperText` |
| `RhfChipSelect` | a chip group over stable codes — `string[]` (multi) or `string \| null` (single). `allowCustomValues` keeps a stored code that isn't in the option list visible |
| `RhfJalaliDateField` | a Jalali date field; stores the wire ISO (Gregorian) string, or `null` |
| `RhfControlGroup` | **any** non-input control — `GenderToggle`, `RatingInput`, `CascadingRegionSelect`, the map-pin picker, a `Switch`, a `Checkbox`. Gives it the same label/hint/error shell the text fields get |
Every wrapper falls back to the enclosing `FormProvider`'s `control`, so a form wires it once. All four are
tested.
### Two conventions worth knowing
- **A control that renders its own error text gets a message-less rule** — `validate: (v) => cond`, no
string. `RhfControlGroup` then flags the field without printing a second identical line. `AddressForm`'s
region and pin fields are the reference.
- **When the displayed value isn't the stored value, drop to a bare `Controller`.** Exactly two cases exist
and both are commented at the call site: the variant builder's display-name (stored = the override only;
blank means the server names it — shown = the live auto-generated name) and the admin refund channel
(stored = `""` until explicitly overridden; shown = the server's resolved channel).
---
## 4. Structure: `FormSection`
A long form is grouped into `FormSection`s — a heading, a one-line statement of *why* the group is being
asked for, and an optional/status marker.
The point is that **an optional group reads as skippable and a blocked submit has somewhere to attribute
itself.** A flat run of ten `TextField`s makes everything look equally mandatory, which is how a nurse ends
up abandoning a verification form over a field that was never required.
Applies to the nurse profile (معرفی / تجربه و تحصیلات / تخصص‌ها), the verification identity and credentials
screens, and the variant builder.
---
## 5. Making a gate honest
Five habits that came out of the verification and variant-builder rebuilds. They are what separates a form
that *validates* from a form a user can actually finish.
- **The submit gate is a real form field, not a caption near the bottom.** Verification B4's three asks
(national id / card photo / selfie) each became a `FormSection` with the card marked optional and the
selfie marked required — so the requirement is attached to the thing that satisfies it.
- **Disable Next with the unanswered required groups *named* under it.** Not "always enabled, error after
the tap".
- **Derive a wizard's step list from the loaded data.** A category with no option groups skips straight to
pricing rather than showing an empty middle step.
- **Recap the chosen values on the final step**, so the last step doubles as a review.
- **Never dead-end a returning user on a disabled button with no explanation.** If server-side state (an
already-uploaded document, an already-submitted registry number) satisfies part of the gate, the gate must
consider it — and a value the server won't read back by design should lock into a "recorded" row rather
than re-prompting for it blank.
## 6. Unsaved work
- A form hosted in `FormDialogShell` reports `dirty` via an **`onDirtyChange`** prop, which drives the
shell's discard-confirm on close, backdrop and escape.
- A **staged-but-unsaved upload** gets a `beforeunload` guard — the nurse-profile avatar is the reference.
- A destructive confirm goes through `ConfirmDialog`, whose destructive and dismiss labels must not be
swapped. (They were, once, on the cancel-request dialog; check yours reads correctly out loud.)
+215
View File
@@ -0,0 +1,215 @@
# Client i18n and Persian copy
next-intl v4 mechanics, the namespace map, and the binding Persian style guide — the last of which is
enforced by `npm run lint:copy`.
> Last verified: 2026-07-30 against commit `d3ec723`.
---
## 1. The rule
**No hard-coded user-facing strings.** Every user-visible string — label, placeholder, `aria-label`,
button text, error message — is a key in **both** `messages/en.json` and `messages/fa.json`, and the two
files stay in sync.
The one sanctioned exception is `app/global-error.tsx`, which replaces the root layout on a root-level crash
and therefore renders its own `<html>` and cannot use next-intl. Keep it minimal and bilingual.
Locales: **`fa` (default, RTL)** and `en`. `/en` is explicitly accessed; a bare `/` normalizes to the
default locale.
| Context | API |
| --- | --- |
| Client component | `const t = useTranslations('nav'); t('home')` |
| Server component | `const t = await getTranslations('nav'); t('home')` |
| Structured (array/object) values | `t.raw('terms_sections')` |
| Rich text with tags | `t.rich('consent_line', { terms: …, privacy: … })` |
Top-level keys are namespaces. Adding a translation means adding the key to both files — never one.
---
## 2. The namespace map
MVP namespaces are complete. Add a key to an existing namespace where it fits; seed a new namespace only
with a genuinely new surface, and seed it in both files at once.
| Namespace | Owns |
| --- | --- |
| `common` | Shared words — loading, retry, `currency_toman`, the brand wordmark |
| `nav` | The actor shells' tab labels — every shell builds its nav from here |
| `shell` | Actor-shell titles |
| `auth` | Phone-OTP login, the role router, `RoleGuard` states, select-role, the login-hero trust bullets, the consent line |
| `legal` | `/terms` and `/privacy`. **The one namespace with structured JSON values**`terms_sections`/`privacy_sections` are arrays of `{title, body}` read via `t.raw` |
| `welcome` | The public landing. Its `category_*` labels are **marketing copy, deliberately distinct from the live `catalog` category names** |
| `onboarding` | The A3→A4 wizard, plus the shared enum labels (relation / condition / gender codes → labels) |
| `home` | The family home — greeting, search entry, category grid, nudges |
| `profile` | Customer profile and emergency contact |
| `patients` | The care-circle list and CRUD |
| `records` | The care-record viewer and the nurse visit-note panel. Reuses `onboarding`/`patients` enum labels — never re-keyed |
| `geo` · `address` · `coverage` | The cascading region select · the customer address book · the nurse coverage editor |
| `catalog` | **Shared** catalog vocabulary — the five `price_unit` labels, count nouns, the estimated-total label. Read by `PriceDisplay` on both sides |
| `services` | The nurse services surface and the variant builder |
| `nurseProfile` | The nurse profile bootstrap and the public-profile preview |
| `activation` | The shared `ActivationChecklist` rows and its collapsed live state |
| `bank` | Nurse payout bank settings and the three ownership states |
| `verification` | The nurse trust flow — per-step and per-status labels keyed off the code, the honesty-sensitive manual-vs-auto copy, the journey group labels |
| `search` | Discovery C1/C2/C3 — filters, the same-gender facet, all four result states, card and profile labels |
| `booking` | The booking-request flow **and** post-payment engagement — `bstatus_*`, `sstatus_*`, EVV banners, care-instruction labels, `money_*`, the bookings list |
| `payment` | Checkout and invoice — the breakdown rows, the **verbatim escrow copy** (`escrow_notice`), the card-flow states, the confirmation and invoice screens, `pstatus_*`, مودیان states |
| `refunds` | Cancellation and refund status — policy tiers keyed off `cancellation_policy_code`, the refund-vs-fee breakdown, `step_*`/`rstatus_*`, per-channel ETA copy |
| `bnpl` | Installment checkout D1D5 — the ownership-truth copy, provider names keyed off `provider_{code}`, the plan/eligibility/schedule labels, the wallet due list |
| `payouts` | Nurse earnings and payout history — the balance header incl. the negative "owed back" state, the four buckets, `estate_*`/`pstatus_*`/`bstatus_*`, the cadence explainer |
| `reviews` | The review form, tag labels keyed off the code, moderation-status labels, the aggregate count |
| `tickets` | The messaging surface — inbox, thread, composer, author-role labels, and both emergency surfaces |
| `notifications` | The notification center and bell. Row `title`/`body` are **server-rendered copy, not keys** |
| `admin` | Every backoffice console, the Persian legal terms (پروانه تأسیس / مسئول فنی / نماد اعتماد الکترونیکی), and the enum-label prefixes |
| `partner` | The partner-centre portal — a separate authz scope |
### Enum labels
**A label is keyed off the stable code, never derived from the wire value.** `status_pending_moderation`,
`pstatus_failed`, `tag_punctual` — the code is the key suffix, and the vocabulary of codes is a client
constant, not something read off a response. Shared enum labels are **reused** across namespaces, never
re-keyed.
This is what lets the server rename a display string without a client deploy, and lets the client show a
Persian label for a code it has never seen without falling back to raw English.
---
## 3. Numbers and interpolation
Persian digits (۰۱۲۳۴۵۶۷۸۹) everywhere on `/fa` — both hard-coded literals (`"۲۴ ساعت"`) and interpolated
numbers.
| Case | Do |
| --- | --- |
| A number inside an ICU message | Use the `number` sub-format — `{count, number}` — or a plain `#` inside a `plural` block. next-intl formats both through the active locale, so `fa` gets Persian digits automatically |
| A raw number built into a string in code | Route it through `formatNumber` (`@/utils`). **Never template a raw JS number into Persian text** |
| A date | `formatShamsiDate` / `formatShamsiDateTime` (`@/utils`) — UTC ISO in, Persian calendar out |
| Money | `<Money>` or the `@/utils` money helpers. Never a float, never a raw digit run |
### Policy numbers are never hard-coded into a string
Legally or financially sensitive numbers that the admin config panel can change — the dispute-window hours,
the cancellation lead-time hours, the refund ETA day range — **never** go into a message string. The message
key takes a parameter (`{hours}`, `{minDays}`/`{maxDays}`) and the call site interpolates from
`src/constants/policy.ts`, which single-sources them.
A config edit must never again silently make the UI copy lie. (These are real server config with no public
read yet; the constants file is the interim single source.)
---
## 4. Persian style — binding for `fa.json`
`npm run lint:copy` (`client/scripts/check-copy.mjs`, part of `npm run check`) greps every leaf string in
`fa.json` for the banned variants marked **linted** below, on every run. A regression fails the gate
immediately rather than needing to be re-discovered by a human.
`en.json` is hand-written and reviewed for idiom, not linted.
### 4.1 Brand name — **linted**
**«بالین‌یار» — ZWNJ (``) between بالین and یار, always.** Never a plain space («بالین یار»). The brand name
appears in money and trust copy — login, escrow, refunds — as often as anywhere else, and that is the worst
place to be inconsistent.
### 4.2 تأیید — hamza, always — **linted**
Write **تأیید** and its derived forms — **تأییدشده**, **تأییدیه**, **تأیید کردن** — every time. Never
تایید/تاییدشده/تاییدیه. This is the single most frequent word in a verification product: one spelling, no
exceptions, in every namespace.
### 4.3 جستجو — one form — **linted**
Standard form: **جستجو** (no ZWNJ, one word). Not «جست‌وجو», not «جست و جو». Applies to the noun and any
compound (`در جستجو`, `نتایج جستجو`).
### 4.4 ZWNJ (نیم‌فاصله)
Use ZWNJ — never a plain space, never nothing — in:
- **می‌ + verb stem** — می‌شود، می‌کند، می‌پردازید، می‌ماند. Never میشود or می شود.
- **Plural ها** — keep the ZWNJ before ها when the base ends in a consonant that would otherwise misread
(`شب‌ها`, not `شبها`); a word already ending in a vowel or silent-h takes it too (`بچه‌ها`).
- **Compound past-participle adjectives** — تأییدشده، لغوشده، ردشده، منتشرشده، پرداخت‌شده. One ZWNJ-joined
word: not two spaced words («تایید شده»), not fused with no separator.
- The brand name (§4.1).
### 4.5 Two other linted rules
- **The archaic passive می‌گردد is banned** — use می‌شود. (The check anchors on a leading space so the
entirely legitimate «برمی‌گردد», which fuses «بر» directly on, is never flagged.)
- **«بازی » is banned** — it catches an indefinite «ی» misattached to the wrong word.
### 4.6 Punctuation and quotes
- Persian prose uses **«…» guillemets** for quoted terms and labels. Prefer Persian «،» / «؛» inside new
multi-clause translated sentences; most existing short labels use plain Latin `,`/`;` — **don't retrofit
those**, just don't add more.
- English uses **straight** apostrophes (`don't`, `couldn't`) throughout — never curly (``). Don't
reintroduce curly quotes when editing English copy.
### 4.7 Domain glossary
| Term | Means | Never |
| --- | --- | --- |
| **بیمار** | the care recipient | «مددجو» — it appeared once and was dropped for the 99%-majority form |
| **پرستار** | the caregiver | «مراقب» as a noun for the person. «مراقب» survives only as an adjective/role qualifier — "جنسیت مراقب" = the caregiver's gender |
| **رزرو** | a confirmed, **paid** booking | calling a `booking_request` «رزرو» before it converts |
| **درخواست رزرو** | a pre-payment request | conflating it with رزرو |
| **ویزیت** | one scheduled visit/session within a booking | — |
| **شبا** | IBAN | «شماره شبا» for the field label, «شبا» alone elsewhere |
The رزرو / درخواست رزرو split mirrors the code's `bookings` vs `bookingRequests` and the server's
`Bookings` vs `Booking` areas. It is a money boundary, not a synonym.
### 4.8 Shell naming — one metaphor per audience class
- **End-user shells** (family, nurse — the apps people book or work through day to day) → **«اپلیکیشن»**:
«اپلیکیشن خانواده», «اپلیکیشن پرستار».
- **Back-office shells** (admin, partner-centre) → **«کنسول»**: «کنسول مدیریت», «کنسول همکار».
- Never «نما» (view) or «پرتال» (portal) for a whole shell name.
(`booking.evv_nurse_view` "نمای پرستار" is a different thing — a chip labelling *whose perspective* a shared
screen is rendered from, not a shell name. It correctly keeps «نما» in that narrower sense.)
### 4.9 Verification pipeline vs. the identity step
**«تأیید صلاحیت»** names the whole 7-step nurse trust pipeline — the nav entry, the hub title, its
start/progress/approved states, the admin queue. **«احراز هویت»** stays the name of the *one* KYC step inside
it (national ID + civil registry + liveness selfie), on both the nurse side
(`verification.step_identity_kyc`) and the admin side (`admin.step_identity_kyc`).
A nurse who passed the KYC step but still saw a pipeline titled «احراز هویت» marked incomplete in the nav
read that as a contradiction. They no longer share a name — keep it that way.
### 4.10 Status vocabulary — one nurse-facing form, one admin-facing form
For "this step/item was rejected" states that appear on **both** a nurse-facing and an admin-facing screen
for the *same underlying concept* (a verification step's outcome):
| Audience | Form | Matches its siblings |
| --- | --- | --- |
| Nurse-facing | **«رد شد»** (`verification.status_failed`) | the declarative sentence register of `status_passed` («تأییدشده») / `status_in_review` («در حال بررسی») |
| Admin-facing | **«ردشده»** (`admin.step_failed`, `agg_rejected`, `rstatus_rejected`, `mstatus_rejected`) | the admin namespace's compound-adjective pattern — `step_passed`/`agg_approved`/`center_state_verified` |
This does **not** extend to money-failure vocabulary. `payouts.pstatus_failed`, `refunds.rstatus_failed` and
`admin.batch_status_failed` all legitimately use «ناموفق»: a transfer *failing* is a different concept from a
document being *rejected*, and conflating them would blur a real distinction.
### 4.11 Register
Formal شما throughout, with polite imperatives (کنید) for actions and instructions. Never informal تو or bare
imperative stems (نکن, برو).
---
## 5. Reference data with two names
Server reference data that carries `name_fa`/`name_en` returns **both**, and the client picks by locale.
Don't ask the server for a locale-specific name, and don't translate a data row into a message key —
categories, provinces and cities are rows an admin can add, not vocabulary.
+221
View File
@@ -0,0 +1,221 @@
# Client services and data
The fetch layer, the `services/{domain}` pattern, caching, and the money rules that make the UI honest.
> Last verified: 2026-07-30 against commit `d3ec723`.
---
## 1. Fetch only through the two primitives
| File | Use from | Behaviour |
| --- | --- | --- |
| `lib/api/client.ts` | hooks, client components | `clientFetch<T>` — throws `ApiError` on error; silent-refreshes and retries once on 401 |
| `lib/api/server.ts` | RSCs, Server Actions | `serverFetch<T>` — throws `ApiError` on error |
| `lib/api/errors.ts` | anywhere | the `ApiError` class (`status`, `message`, `code`) |
| `lib/api/types.ts` | anywhere | `ApiEnvelope<T>` + `unwrap()`, `Paginated<T>`, `PageParams` |
| `lib/api/refresh.ts` | internal | `attemptTokenRefresh` — the single-flight refresh `clientFetch`'s 401 branch uses |
**Never call `fetch()` directly** in a component, hook, or service. Domain calls live in
`src/services/{domain}/apis/`.
### The `clientFetch` error contract
| Status | What happens |
| --- | --- |
| **401** | Toast "session expired", clear cookies, redirect to login. **No throw** — the page navigates away |
| **403** | Toast "forbidden", throw `ApiError` |
| **5xx** | Toast "server error", throw `ApiError` |
| Other **4xx** | Throw `ApiError`, **no toast** — the calling hook owns the user-facing message |
| Network failure | Toast "network error", throw `ApiError` |
So: **never toast 401/403/5xx inside a hook.** Only a domain-specific 4xx earns an `onError` toast. A
mutation that handles only `onSuccess` is still a defect — see [components.md](components.md) §6.
`serverFetch` throws on every error and toasts nothing (the server can't fire browser events). The RSC
caller decides whether to `notFound()`, `redirect()`, or let it reach an error boundary.
**Never mix `clientFetch` and `serverFetch` in one file.** Keep `clientApi.ts` and `serverApi.ts` separate;
Next enforces the environment boundary at build time.
### The wire envelope
The server wraps every response in `ApiEnvelope<T>``{ isSuccess, statusCode, message, requestId, data }`,
camelCase. `clientFetch` returns the raw body, so a real `clientApi` reads the payload via `unwrap()`.
Types mirror the wire **exactly** and are derived from the published contract in
[`docs/integration/`](../../integration/index.md) — never guessed. If a shape you need doesn't exist, say so
and mock behind the seam meanwhile (§3).
---
## 2. The `services/{domain}` pattern
Every one of the 22 domains has the same shape. Copy `auth` or `patients`.
```
services/{domain}/
├── types.ts wire types + the domain's `Api` interface — this interface IS the seam
├── keys.ts the React Query key factory, hierarchical
├── constants.ts the mock toggle + staleTime values (when the domain has a mock)
├── apis/
│ ├── clientApi.ts real, wraps clientFetch, unwraps the envelope
│ ├── mockApi.ts in-memory, same interface
│ ├── serverApi.ts serverFetch — only when an RSC needs it
│ └── index.ts selects real vs mock by config — the one line hooks import
├── hooks/
│ └── use{Action}.ts one hook per file — useQuery (deliberate staleTime) or useMutation (invalidates)
└── index.ts the barrel: re-exports HOOKS ONLY
```
Two hard boundaries on the barrels:
- **No top-level `src/services/index.ts`.** An import must name its domain:
`import { useLogin } from '@/services/auth'`, never `from '@/services'`.
- **A domain barrel exports hooks only** — never `types`, `keys`, or `apis/*`. Reaching past the hooks is
how a component ends up depending on a mock's internals.
### Caching is deliberate, not incidental
- Set a **`staleTime`** on reads, so revisiting a screen doesn't refetch.
- Mutations **invalidate** the affected list key (`queryClient.invalidateQueries`) or `setQueryData`
never leave the cache stale. See `services/patients/hooks/*`.
- **Reference data is cached for the whole session.** Rarely-changing lookups use an **Infinite
`staleTime`** plus a shared hierarchical key factory, so each level is fetched **once** and served from
cache across every consumer — never refetched on a dropdown open. Two domains do this: `geography` (the
province→city→district hierarchy, `geographyKeys`) and `catalog` (admin-seeded categories and a category's
option groups, `CATALOG_REFERENCE_*`). **Reuse the pattern; do not reinvent per-consumer fetching.**
- Contrast with mutable lists — addresses, coverage areas, the nurse's own variant list — which invalidate on
every mutation.
- **The filter object IS the query key.** `search` canonicalizes its filter set into the key
(`canonicalizeSearchFilters`), so identical or reverted filters reuse cache with zero network;
`keepPreviousData` avoids flashing. Filters and page belong **in the URL**, which is what makes the cache
key shareable and the back button work.
- Admin and partner queue pages use **`useAdminListState`** (`@/hooks`) for URL-synced worklist state:
draft-vs-applied filters plus page, with `apply`/`applyFilters`/`clear`/`goToPage`. It is
`useSearchParams`-based, so a caller needs a `<Suspense>` boundary.
- Prefer RSC prefetch or `initialData` where it removes a client round-trip.
### Re-render cost is part of correctness
Stable references (`useCallback`/`useMemo` only where it pays), `select` to subscribe to a slice rather than
a whole query, state colocated as low as it can go and lifted only when genuinely shared. **Don't put
fast-changing state in a high context provider** — a 1-second countdown belongs inside the component that
displays it, which is exactly what `CountdownTimer` does.
---
## 3. The mock seam
When a backend endpoint isn't live, implement the domain's `Api` interface **twice** — a real `clientApi.ts`
and an in-memory `mockApi.ts` — and select in `apis/index.ts` by a config flag (`USE_{DOMAIN}_MOCK`). Hooks
import the selected `api`; **the swap is one line and touches no caller.** Record every mock in
`docs/status/` per [code-quality.md](../shared/code-quality.md) §4.
### Current state — 15 real, 7 mocked
**Real** (`USE_*_MOCK = false`): `auth`, `geography`, `patients`, `profiles`, `nurse` (bank), `addresses`,
`serviceAreas`, `catalog`, `search`, `bookingRequests`, `bookings`, `payment`, `reviews`, `notifications`,
`tickets`.
**Still mocked**, each blocked on a named contract gap:
| Domain | Blocked on |
| --- | --- |
| `verification` | the admin verification queue |
| `refunds` | the admin refund preview |
| `payouts` | the admin payout preview |
| `admin` | the RBAC role endpoints |
| `bnpl` | provider options / schedule / wallet installments |
| `partnerCenter` | the portal split reads + a `/me` centre signal |
| `patientRecords` | endpoints exist, but the client family-record `id` model is `string` vs the wire's `int` — the customer-edit PUT is **write-unsafe** until reconciled |
The open REQ numbers behind these live in `docs/status/backlog.md`. Before flipping a domain to real, check
that its `clientApi.ts` actually consumes the fields the server serves — flipping the flag is necessary but
not sufficient.
One coupled seam: **`EVV_GPS_MODE` auto-selects `off`** (real `navigator.geolocation`) once
`USE_BOOKINGS_MOCK` is `false`, so you don't get mock coordinates against real bookings.
---
## 4. Money and time on the client
The client **displays** money. It does not compute it.
| Rule | Why |
| --- | --- |
| Money crosses the wire as an **IRR digit string** and is parsed with integer-safe `BigInt` helpers (`formatIrrToToman` / `formatIrr` / `parseIrr` in `@/utils`). **Money is never a float** | IRR aggregates exceed JS's safe integer range, and float coercion on money is a correctness bug, not a rounding one |
| A **breakdown reconciles by construction**`PriceBreakdown` dev-guards `console.error` when rows don't sum to the total | A total the user can't derive from the rows they were shown is a trust failure |
| The client **never computes** a rate, an aggregate, a payout date, or a holiday shift | These are server truth. A commission rate is snapshotted server-side at compute time; a review aggregate is recomputed from source; a payout date shifts off the bank-closure calendar |
| A **server-frozen deadline is rendered, never recomputed**`CountdownTimer` takes the UTC instant and counts down to it | A client that recomputes a deadline from a config value will disagree with the server the moment the config changes |
| The **signed net payable balance is never clamped** — a negative reads as an explicit "owed back" state (magnitude only, never a bare minus) | Clamping to zero tells a nurse they owe nothing when they do |
| Toman is **display-only**; the boundary conversion happens once, at the field | Mixing units in the middle is how a price ends up 10× off |
| Dates arrive as **UTC ISO** and display through `formatShamsiDate(Time)`. Shamsi is a client concern | Except bank-closure math, which the server owns |
### Money-path mechanics
- **The caller owns the per-attempt `Idempotency-Key`** on payment initiate. Per *attempt*, not per booking.
- **Poll only while non-terminal**, with backoff and bounded attempts. `usePaymentOutcome`, `useBnplOrder`,
`useRefundStatus` and `useBookingRequest` all stop at a terminal state.
- **A 409 on the money path is benign convergence, never a toast.** It means the server already did what you
asked.
- **`invalidations.ts` is the one post-capture cache transition** per money domain — an explicit list of the
request/booking/summary/outcome keys that change. **Never a blanket refetch.**
---
## 5. Non-negotiable data rules
These encode business invariants, not preferences. Breaking one leaks data or misreports money.
**Clinical data**
- **`is_internal` is never modelled in the user-app ticket types.** Both mappers drop an internal message —
a server-strip mimic — and there is no internal affordance anywhere in a user-facing screen. The admin
ticket types carry `isInternal`; the user types deliberately do not.
- **The customer must never fire the care-instructions query.** The two-stage disclosure gate is proved by a
test on `BookingDetailView`.
- **A nurse's care-record access is append-only.** The nurse surface never wires the customer-edit mutation.
- **Access-denied is a first-class, non-leaking state, gated *before* any clinical fetch** — not an error
rendered after a 403 came back with a body in it.
- **Clinical text is never logged, never put in `localStorage`, never put in a query string.**
**Visibility and trust**
- **A `pending_moderation` review is never injected into a public list or aggregate**, and the client never
computes the aggregate.
- **Every search result is verified-by-invariant** — the server's index only contains searchable rows, so
the UI never re-filters. If an unverified nurse appears, that is a server bug, not something to paper over
client-side.
- **`districtId = null` means whole-city** — a real coverage choice, not missing data. Treating it as absent
drops a nurse's entire coverage.
- **A notification's `data_json` is a typed contract.** `parseNotificationData(type, dataJson)` returns a
discriminated union, tolerates snake/camel, and degrades to `{ kind: 'none' }` on anything malformed,
unknown, or missing an id. Never trust the blob; never index into it directly.
---
## 6. Cookies
**App and auth state goes through the cookie manager only** — never `document.cookie`, never `js-cookie`
directly, never `localStorage` or `sessionStorage`.
| File | Import from | Holds |
| --- | --- | --- |
| `lib/cookies/constants.ts` | anywhere, via the barrel | `COOKIE_NAMES`, `CookieOptions`, `AUTH_*_COOKIE_OPTIONS`, `COLOR_SCHEME_COOKIE_OPTIONS` |
| `lib/cookies/server.ts` | RSCs, Server Actions, Route Handlers **only** | `getServerCookie`, `getThemeMode`, `setServerCookie` |
| `lib/cookies/client.ts` | client components / effects **only** | `getClientCookie`, `setClientCookie`, `deleteClientCookie`, `getColorSchemeCookie` |
| `lib/cookies/index.ts` | anywhere | re-exports `constants.ts` **only** — a safe barrel |
Import constants via the barrel (`import { COOKIE_NAMES } from '@/lib/cookies'`) and the server/client
utilities **directly** from their file. Never import `server.ts` in a client component or `client.ts` in an
RSC.
`COOKIE_NAMES.COLOR_SCHEME = 'color-scheme'` is the single source of truth for the theme cookie name — do not
redeclare it anywhere. `CookieOptions.maxAge` is in **seconds** (converted to an `expires: Date` internally).
**Never read `localStorage` or `document.cookie` in a render function** — use an effect, or read server-side
via `next/headers`.
See [auth.md](auth.md) for the token cookies and the session lifecycle.
+185
View File
@@ -0,0 +1,185 @@
# Client structure
The route tree, the server/client boundary, and the page pattern every screen follows.
> Last verified: 2026-07-30 against commit `d3ec723`.
---
## 1. `src/` at a glance
| Folder | Holds |
| --- | --- |
| `app/` | The App Router tree. Everything under `[locale]/` |
| `components/` | Shared UI — `common/` primitives plus one folder per domain composite family |
| `constants/` | App-wide named constants (`routes.ts`, `roles.ts`, `headers.ts`, `policy.ts`) |
| `context/` | React context providers — `auth/` (AuthContext + reducer) |
| `hooks/` | Cross-cutting hooks (`auth.ts`, `capabilities.ts`, `layout.ts`, `useAdminListState.ts`) |
| `i18n/` | next-intl wiring: `routing.ts`, `request.ts`, `navigation.ts` |
| `layout/` | The one mobile app shell: `AppFrame`, `MobileShell`, the four actor layouts, chrome components |
| `lib/` | Infrastructure: `api/` (fetch), `auth/` (token/session), `cookies/`, `query/`, `toast/` |
| `services/` | 22 domain services, one folder each — the data layer |
| `theme/` | Palette, tokens, typography, the pre-built themes |
| `utils/` | Money, dates, numbers, CSV, text helpers |
Plus `messages/{en,fa}.json`, `middleware.ts`, and `next.config.mjs` (which only wires the next-intl
plugin and `reactStrictMode`) outside `src/`.
This is a **pattern map, not a file listing**. `git ls-files client/src` enumerates files for free and
never goes stale; what follows is the shape those files have to fit.
---
## 2. The one absolute rule: no layout above `[locale]`
**`src/app/[locale]/layout.tsx` IS the root layout.** It renders `<html>` and `<body>`. There is no
`src/app/layout.tsx`, and adding one — or any layout above the `[locale]` segment — breaks both locales.
**Why**, because this is worth understanding rather than obeying: a layout above `[locale]` is *shared*
between `/fa` and `/en`. Next.js statically caches it at build time with `defaultLocale` (`fa`) and never
re-renders it on a client-side locale switch, because the segment it is keyed on doesn't change. Its
`lang`, `dir`, messages, providers, and fonts therefore **freeze on `fa`/`rtl` for every route, including
`/en`**. The `[locale]` layout is the lowest boundary keyed on the locale param, so it is the only place
`<html lang dir>` can reliably track the active locale.
### What the root layout owns
- The locale, sourced from the **URL param** (`params.locale`), validated against `routing.locales` with a
fallback to `defaultLocale`. **No header reads.**
- `<html lang dir>``dir` from `getDirection(locale)` — plus `data-mui-color-scheme` from
`getThemeMode()`.
- The per-locale font class (Mikhak on `fa` only — see [theme.md](theme.md)).
- `setRequestLocale(locale)`, so server components deeper in the tree can call `getLocale()` /
`getTranslations()` reliably. **Never remove it** — without it, deeper RSCs always see `defaultLocale`.
- `getMessages({ locale })` with the locale passed **explicitly**, so `getRequestConfig` receives it via
`Promise.resolve(locale)` rather than through the `React.cache` read — which avoids a cache-ordering
race. **Never call `getMessages()` bare.**
- The providers: `NextIntlClientProvider`, `AuthProvider` (seeded with `getServerAuthState()`),
`ThemeProvider`, `NotistackProvider` + `ToastBridge`.
- `generateStaticParams`, so Next can enumerate locale routes at build time.
- `generateMetadata` — the `'%s | بالین‌یار'` / `'%s | Balinyaar'` title template, the default title and
description, and `metadataBase: new URL(SITE_URL)` so child pages' relative OG/canonical URLs resolve
absolute. `SITE_URL` comes from `src/config.ts`, never a hard-coded origin.
**Never add `notFound()` to the `[locale]` layout.** Unknown locales are handled by middleware; a hard 404
there breaks the fallback.
### The two files that legitimately sit above `[locale]`
| File | Why it's allowed |
| --- | --- |
| `app/global-error.tsx` | It *replaces* the root layout on a root-level crash, so it renders its own `<html>` — which means it **cannot use next-intl**. It is the one sanctioned static-string exception: keep it minimal and bilingual (fa + en) |
| `app/robots.ts`, `app/sitemap.ts` | Route handlers, not layouts. They enumerate the public surface across both locales |
---
## 3. The server/client boundary
| Never import | From |
| --- | --- |
| `next/headers` | a client component |
| `next-intl/server` | a client component |
| `@/lib/cookies/server` | a client component |
| `@/lib/cookies/client` | an RSC |
The build fails on the first three. The fourth fails at runtime, quietly, which is worse.
Route-group layouts (`(private-routes)/layout.tsx`, `(public-routes)/layout.tsx`) are `'use client'` — they
only wrap a layout component and need no server capabilities.
Never mix `clientFetch` and `serverFetch` in the same file; keep `clientApi.ts` and `serverApi.ts`
separate. Next enforces the environment boundary at build time.
---
## 4. The route tree, by shape
Everything lives under `src/app/[locale]/`. Route groups add no URL segment.
```
[locale]/
├── layout.tsx error.tsx not-found.tsx [...rest]/page.tsx
├── (private-routes)/ layout.tsx mounts useSessionRoleSync
│ ├── _chrome/ shared loading skeleton (private, not a route)
│ ├── select-role/ first-use role picker, own FocusedLayout
│ ├── (customer)/ the family app — no URL segment
│ ├── (customer-focused)/ chrome-free counterpart, same URL space (onboarding)
│ ├── nurse/ the nurse app
│ ├── admin/ the backoffice
│ └── partner/ the partner-centre portal — a SEPARATE authz scope
└── (public-routes)/ login · terms · privacy · welcome
```
| Convention | Meaning |
| --- | --- |
| `(parenthesised)` | A route group. Adds no URL segment; exists to attach a layout and a `RoleGuard` |
| `_`-prefixed folder | Private, **not a route**`_chrome/`, `admin/_hub/` |
| `[...rest]/page.tsx` | The catch-all. Calls `notFound()` so any unmatched path under a locale renders `not-found.tsx` — next-intl's recommended 404 pattern |
| `loading.tsx` | A route-group skeleton shaped like that group's content area. The `MobileShell` chrome is already rendered by the enclosing layout, so a skeleton shapes the content only |
Each private group's `layout.tsx` is `'use client'` and wraps `RoleGuard` → that actor's layout:
`(customer)``CustomerLayout`, `nurse``NurseLayout`, `admin``AdminLayout`, `partner`
`PartnerLayout`. `(customer-focused)` and `select-role` wrap `FocusedLayout` instead, for flows a user must
not be able to tab away from mid-setup.
The **partner portal is a separate authorization scope**: a centre admin is not a Balinyaar admin. Its
`RoleGuard` passes no `expected` role (it isn't an `AppRole`) and each page resolves the caller's *own*
centre via `useMyPartnerCenter`. See [auth.md](auth.md).
**When you add, remove, or rename a route group, a provider, or a top-level `src/` folder, update the
"Project structure" section in [client/CLAUDE.md](../../../client/CLAUDE.md) and §1 above in the same
change.**
---
## 5. The page pattern
The root layout owns a title *template*; a route supplies the `%s`. So a route that wants its own tab
title splits in two:
```tsx
// page.tsx — a thin RSC. No 'use client'.
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import HomeScreen from './HomeScreen';
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'shell' });
return { title: t('customer_app') };
}
export default function Page() {
return <HomeScreen />;
}
```
```tsx
// HomeScreen.tsx — 'use client'. All the logic and JSX.
```
Rules that fall out of it:
- The screen component is **co-located** with `page.tsx` and named `<PageName>Screen.tsx`, so its existing
relative imports keep working unchanged.
- `page.tsx` never renders `<title>` and never touches `document.title`. Assigning `document.title` in a
render body throws `ReferenceError: document is not defined` during build-time prerendering.
- A static `metadata` export is fine when the title needs no translation lookup.
- Page bodies stay **composition + content**. Reusable visuals move to `src/components/`; page-only,
never-reused composition can stay in the page.
Adoption is partial by design: the landing pages (customer home, `/login`, `/search`, `/bookings`,
`/nurse`, `/admin`, `/partner`) plus `/welcome` use it. The rest still render directly and gain it when
the page is next substantially touched.
`metadataBase` makes the pattern extend to OG: `/welcome` sets `alternates.canonical` + `openGraph` in its
`generateMetadata` and supplies `og:image` from a co-located `opengraph-image.tsx` (`next/og`'s
`ImageResponse`).
---
## 6. This is not a static export
The app relies on server components, middleware, and server-side cookies. `next.config.mjs` wires the
next-intl plugin and `reactStrictMode` and nothing else — don't add `output: 'export'`, and don't assume a
page can be prerendered without its request context.
+120
View File
@@ -0,0 +1,120 @@
# Client testing, lint and types
What is tested and how, plus the two gate tools and their traps.
> Last verified: 2026-07-30 against commit `d3ec723`.
---
## 1. What is tested
**Every shared component has a co-located test file.** A component is "shared" if it is imported from more
than one place — a page, a layout, or another component.
There are **125 test files** across `src/` (114 `*.test.tsx`, 11 `*.test.ts`). Location:
`src/components/<Name>/<Name>.test.tsx`, next to the component.
### Coverage baseline for a shared component
1. It renders without crashing.
2. Every documented prop produces the correct HTML attribute or CSS class.
3. User interactions (click, change) call the expected callbacks.
That is a floor, not a ceiling. Where a component encodes a rule, test the rule: `BookingDetailView`'s test
proves the customer **never fires the care-instructions query** (the two-stage disclosure gate), and
`matchActivePath`'s test proves a nested route lights up its parent tab and never a sibling. Those are the
tests worth writing.
### The two rules about the test itself
- **Wrap with `<ThemeProvider>`** if the component uses MUI theming.
- **Do NOT mock MUI components.** Test against the rendered DOM. A test that mocks `Button` proves nothing
about what a user sees.
### Before removing or renaming a shared component
Check whether any `src/**/*.test.{ts,tsx}` imports it. If so, update or delete those tests in the same
change. A dangling test import fails the suite, and a test left behind for a deleted component is dead code.
### Deliberate coverage gaps
`NeshanMap` is **not** unit-tested — jsdom plus Leaflet is an integration problem, not a unit one — and it is
unreachable in CI anyway, because `NEXT_PUBLIC_NESHAN_KEY` is unset there so `AddressMapPicker` falls back to
the bounded-canvas stand-in. Both branches of that fork are tested; the map itself isn't.
---
## 2. Jest configuration
`jest.config.ts` **replaces** `next/jest`'s `transformIgnorePatterns` array outright rather than appending to
it. This is deliberate and easy to undo by accident:
`next/jest`'s default pattern already broadly matches all of `node_modules` (its negative-lookahead allowlist
carves out only a couple of Next-internal packages), and Jest's array semantics are **OR-based** — a file is
ignored if *any* pattern matches. So appending a more permissive pattern can never "un-ignore" a package an
earlier pattern already caught. The array has to be replaced, by post-processing the async config `next/jest`
returns.
What it lets through: `next-intl`, `use-intl`, `@formatjs`, `intl-messageformat` — all ESM-only builds.
That fixes real imports; it does not make the dependency free. See
[components.md](components.md) "Presentational purity" for why `ErrorBoundary`/`ErrorState` are caller-owned
and `Money` is the one sanctioned exception.
---
## 3. The gate
```
npm run check → npm run type && npm run lint && npm run lint:copy
```
Plus `npm run test:ci` when you touched a component with a co-located test.
Both gate tools are plain CLI tools. **There is no `next lint`** — it was removed in Next 16, and calling it
silently does nothing.
| Script | Runs |
| --- | --- |
| `type` | `tsc --noEmit`. `tsconfig.json`: `strict` on, `noEmit`, `@/*``src/*` |
| `lint` | `eslint .`, driven by **flat config** in `eslint.config.mjs` |
| `lint:copy` | `node scripts/check-copy.mjs` — see [i18n.md](i18n.md) §4 |
`eslint.config.mjs` spreads `eslint-config-next` (core-web-vitals + typescript + react + react-hooks +
jsx-a11y + import) and applies `eslint-config-prettier` **last**, so ESLint never fights Prettier on
formatting.
---
## 4. Lint rules for this project
- **Flat config only.** Do not add `.eslintrc*` files — put any rule change in `eslint.config.mjs`.
- **ESLint owns correctness, Prettier owns formatting.** Don't add stylistic ESLint rules.
- **No unused variables or imports.** `@typescript-eslint/no-unused-vars` is raised from
eslint-config-next's default `warn` to **`error`**, so dead code fails `npm run check`. Delete it rather
than disabling the rule; prefix a deliberately-unused binding with `_` (`_event`, `catch (_err)`) to opt
out.
- **Prefer fixing code over silencing the linter.** When a disable is genuinely correct — the real example
here is a deliberate browser-only read after mount that trips `react-hooks/set-state-in-effect` — use a
scoped `// eslint-disable-next-line <rule>` with a one-line reason. **Never a file-wide disable.**
### Two pinned constraints
- **ESLint is pinned to 9.** ESLint 10 currently crashes with this Next 16 toolchain
(`scopeManager.addGlobals is not a function`). Don't bump it as a housekeeping change.
- **`import/no-cycle` is disabled** — its TypeScript resolver has an interface mismatch here. The reason is
noted in `eslint.config.mjs`; don't re-enable it without checking that note.
---
## 5. MUI v9 API only
The type gate catches most of this, but not all of it, and the failures are confusing when it doesn't.
- Use `sx={{ mb: 4 }}`, **not** `mb={4}` as a direct prop.
- **Do not pass `flexWrap` or `useFlexGap` as direct props to `Stack`.** Neither is a valid v9 `Stack` prop;
both cause a TypeScript overload error. Use `sx={{ flexWrap: 'wrap' }}`. `useFlexGap` was a v5 opt-in and
does not exist in v9.
- No other v5/v6-era props: `storageWindow`, `InitColorSchemeScript`. See [theme.md](theme.md) for the
color-scheme ones specifically, which fail *silently* rather than at compile time.
- Avoid deprecated MUI APIs that throw at runtime.
+268
View File
@@ -0,0 +1,268 @@
# Client theme
Colors, tokens, dark mode, direction, fonts, motion. The brand's *look* is the
[frontend-designer](../../../.claude/skills/frontend-designer/SKILL.md) skill's job; this file is the
mechanism it runs on.
> Last verified: 2026-07-30 against commit `d3ec723`.
---
## 1. Brand identity
Balinyaar is a trust-first home-nursing marketplace in Iran. The tone is calm, warm,
clinical-but-human — not a cold medical dashboard. The default audience is Persian (RTL); English is
secondary.
| Role | Light | Dark |
| --- | --- | --- |
| Primary — deep teal | `#1d4a40` | `#6fc0ac` (lifted, readable on dark) |
| Secondary — terracotta | `#d98c6a` | `#e6a98a` |
| Page surface | `#faf9f5` cream | `#0f1c19` deep teal |
| Paper / card | `#ffffff` | `#16302a` teal surface |
| Text primary | `#1b2521` ink | `#f3efe9` cream |
**Teal ground, cream glyph, terracotta accent** is the whole identity. Use terracotta sparingly as the
single accent; teal carries everything else.
The logo mark is a deep-teal rounded square, a cream lowercase "b" built from a stem plus a ring bowl, and
one terracotta dot. Two SVGs under `components/common/AppIcon/icons/`: `LogoMark.tsx` (monochrome
`currentColor` glyph only, registered as `ICONS.logo`) and `LogoLockup.tsx` (full colour, token-driven so
it tracks the scheme, used by `BrandMark`). **The wordmark beside it stays a real, translated
`<Typography>`** — never bake locale text into an SVG. The favicon and `public/img/favicon/*.png` are
rasterized from the same construction with fixed brand hex, which is the one place a literal hex is
correct; regenerate with a `sharp`-based script rather than hand-editing the binaries.
---
## 2. Two mirrored color homes
Colors exist in two places that must stay in sync. Pick the right one.
| Home | File | Reach it via | Use for |
| --- | --- | --- | --- |
| **MUI palette** | `theme/colors.ts` (`BRAND`, `LIGHT_PALETTE`, `DARK_PALETTE`) | `color="primary"`, `sx={{ color: 'text.secondary', bgcolor: 'background.paper' }}` | **The default** for styling a MUI component |
| **`--bal-*` CSS variables** | `theme/tokens.css`, under `[data-mui-color-scheme='light'\|'dark']` | `var(--bal-primary)` | Custom CSS outside MUI's palette, and every semantic feedback color |
- Styling a MUI component → palette keys.
- Need success / error / warning / info → **`--bal-*`, not MUI's defaults.** The MUI palette defines no
semantic colors, and these tokens are brand-harmonized.
- Need a custom color in raw CSS → add a `--bal-*` token **in both scheme blocks**, then `var(--…)`.
- **Never hard-code a hex or rgb** in `sx`, `styled`, or a component.
- Adding or changing a color means editing `tokens.css` **and** `colors.ts` together. Both file headers
call out the sync requirement.
### The token catalogue
Every token is defined under both `[data-mui-color-scheme]` blocks unless noted.
| Group | Tokens | Notes |
| --- | --- | --- |
| Brand | `--bal-primary`, `-light`, `-dark`, `-contrast`, `-soft`; same five for `--bal-secondary` | |
| Surfaces | `--bal-bg-default`, `--bal-bg-paper`, `--bal-frame-canvas` | `frame-canvas` is the backdrop `AppFrame` paints *outside* the phone-width column — **never a surface a component draws on** |
| Text | `--bal-text-primary`, `--bal-text-secondary`, `--bal-divider` | |
| Semantic | `--bal-{success,error,warning,info}` + each `-contrast` + each `-soft` | `-contrast` is the text color on that fill; `-soft` is the tinted background variant |
| Elevation | `--bal-shadow-1/2/3` | Teal-tinted (black-teal in dark). They back `theme.ts`'s `shadows` array, so every MUI elevation resolves through them — never MUI's grey stack |
| Radius | `--bal-radius-sm` 6px (controls), `-md` 8px (cards/paper, `= theme.shape.borderRadius`), `-lg` 12px (dialogs), `-pill` 999px | Reference the token, **never a numeric `sx={{ borderRadius: n }}`** — that multiplies the shape unit, which is how the login card once became a 30px pill. `MuiPaper` pins the `md` step so a Paper can't drift past it. `-pill` is for shapes that genuinely *are* pills (the floating nav, a segmented control's active chip), never a card |
| Motion | `--bal-motion-fast/base/slow` (120/200/300ms), `--bal-easing-standard` | `theme.ts` points `MuiDialog`/`MuiDrawer`/`MuiPopover`/`MuiMenu`'s `defaultProps.transitionDuration` at the same numbers, in one place, instead of MUI's per-variant defaults |
| Focus | `--bal-focus-ring` | The 2px ring `MuiCssBaseline`'s global `:focus-visible` override uses. **Don't hand-roll a focus style** — it is already uniform everywhere |
| Rating | `--bal-rating`, `--bal-rating-empty` | `RatingInput` uses these, **not** `--bal-warning` |
| Trust | `--bal-trust`, `--bal-trust-soft` | A distinct identity for verified marks — not primary, not success. `TrustBadge` and any future verification UI |
| Money | `--bal-money-emphasis` | AA-contrast-safe emphasized money text. `--bal-secondary` (terracotta) **fails AA at small sizes on light backgrounds** — never use it for money text |
| Avatar | `--bal-avatar-1..6` + each `-contrast` | Six warm pairs `InitialsAvatar` picks from by a deterministic name hash |
| Map | `--bal-pin-shadow` | The address-picker pin |
`--bal-chrome-top` / `--bal-chrome-bottom` are **not** in `tokens.css``AppFrame` publishes them at
runtime on its scroll container. See [components.md](components.md).
---
## 3. Dark mode, and the no-flash boot
The mechanism is **pure CSS. There is no boot script**, no inline `<script>`, and no
`Storage.prototype` patching — matching how every other color decision in this app is made.
**Returning visitor (cookie present)**
1. `getThemeMode()` (`lib/cookies/server.ts`) reads the `'color-scheme'` cookie → `{ colorScheme, defaultMode: colorScheme }`.
2. The root layout sets `data-mui-color-scheme={colorScheme}` on `<html>`, server-side.
3. `tokens.css`'s explicit `[data-mui-color-scheme='light'|'dark']` blocks match immediately — correct on
the very first paint, before any JS runs.
**First-ever visitor (no cookie)**
1. `getThemeMode()` returns `{ colorScheme: undefined, defaultMode: 'system' }`.
2. The root layout renders `<html>` **without** the attribute at all (React omits `undefined`).
3. `tokens.css` has a `@media (prefers-color-scheme: dark)` block scoped to
`:root:not([data-mui-color-scheme])` — it applies only while the attribute is absent, and paints the
OS-preferred scheme immediately with zero JS.
4. Once React hydrates, `<MuiThemeProvider defaultMode="system">` resolves the *same* media query and
stamps the attribute itself. The painted values already match, so nothing visibly flips.
5. `ColorSchemeCookieSync` (inside `ThemeProvider.tsx`) writes the cookie from
`useColorScheme().colorScheme` in an effect, so the next visit is a "returning visitor" — even before
the user ever touches the control.
### The known gap, by design
This covers the dominant visual surface — every `--bal-*` token — because that is what the media-query
fallback drives. MUI's own generated `--mui-palette-*` variables (consumed by a bare `color="primary"`
fill: a contained Button, the default `MuiTabs` indicator) do **not** get the same free fallback: MUI's
`colorSchemeSelector` supports attribute-based *or* `'media'`-based generation, not both at once. So on a
cookie-less first visit with OS dark on, a raw MUI-primary fill can very briefly show the light value
until hydration. It self-corrects in the same frame, and `disableTransitionOnChange` means it snaps rather
than animating.
**Prefer sourcing colors from `var(--bal-*)` over `theme.vars.palette.*` in new `styleOverrides`** — most
of `theme.ts`'s `components` block already does — to keep this gap as small as possible.
### MUI v9 traps in this area
**`colorSchemeSelector` must be the explicit attribute name.**
```ts
// theme.ts
cssVariables: {
colorSchemeSelector: 'data-mui-color-scheme', // CORRECT
// colorSchemeSelector: 'data', // WRONG
}
```
The shorthand `'data'` generates `[data-%s]` → boolean `data-dark=""` / `data-light=""` attributes. Our
`tokens.css` selects on `[data-mui-color-scheme="dark"]`, which never matches a boolean attribute, so the
whole token layer silently stops switching.
**Never use MUI's `InitColorSchemeScript`.** It reads localStorage, which diverges from our cookie
(especially in `system` mode), and it is a script — this app's no-flash boot is CSS-only. Don't add *any*
pre-paint color-scheme script; if a new token needs the same first-visit treatment, extend the `tokens.css`
media-query fallback instead.
**Never use `storageWindow={null}`.** In MUI v9's `localStorageManager` the check is
`if (!storageWindow && typeof window !== 'undefined')``null` is falsy, so it silently overrides to
`window`. The prop is a no-op in browsers.
**MUI v9's localStorage key defaults differ from v5/v6** — mode key `'mode'` (was `'mui-mode'`), color
scheme key `'color-scheme'` (was `'mui-color-scheme'`), HTML attribute `'data-color-scheme'` (was
`'data-mui-color-scheme'`). We override the attribute via `colorSchemeSelector`; the cookie is ours and is
named by `COOKIE_NAMES.COLOR_SCHEME`.
### `mode` vs `colorScheme`
Use **`colorScheme`** for an "is dark active" check. `mode` can be `'system'` even when dark is active.
The one exception is the control itself, which must read `mode`: that is the user's *choice*, while
`colorScheme` is only the resolved result. `mode` is `undefined` until MUI mounts, so default it
(`mode ?? 'system'`) rather than rendering an unselected control — server, first client render, and
pre-mount state then agree, so there is no hydration mismatch and no flash of "nothing selected".
### The one appearance control
`components/settings/ThemeModeSetting.tsx` is the **only** component that subscribes to `useColorScheme()`
and the app's only appearance control. It lives in each actor's settings hub (`/nurse/more`,
`/admin/system`, `/partner/more`, the customer profile hub) **and nowhere else** — the old top-bar toggle
spent a permanent slot of chrome in three shells on a preference set once.
It is a **three-way segmented control (light / dark / system), never a boolean switch.** `system` is the
app's real default on a cookie-less first visit, so an on/off control cannot represent the current state
and would silently misreport it.
The write path: `setMode('dark')``ColorSchemeCookieSync`'s effect writes the `'color-scheme'` cookie →
MUI sets `data-mui-color-scheme` on `<html>` → CSS variables resolve → the browser repaints. No React
re-render above the control.
### Pre-built themes
`APP_THEME_LTR` and `APP_THEME_RTL` are created once at module load. **Never call `createTheme()` inside a
component or hook** — pass the appropriate pre-built theme to `MuiThemeProvider`.
---
## 4. Direction
`getDirection(locale)` (`theme/direction.ts`) returns `'rtl'` for `fa`, `ar`, `he`, `ur`; `'ltr'` for
everything else. `ThemeProvider` takes a `dir` prop and selects the matching pre-built theme; the RTL
Emotion cache uses `stylis-plugin-rtl` to mirror all generated CSS.
The root layout sets `dir` on `<html>` and passes it to `ThemeProvider`. Because that layout is keyed on
the `[locale]` URL param, a locale change re-renders it with a fresh `dir` on both hard and soft
navigation, with no client state. Do **not** move the `<html dir>` render above `[locale]` — see
[structure.md](structure.md) §2.
**RTL correctness is a rule, not a nicety.** Never use directional hard-coding for layout flow —
`marginLeft`, `left:`, `textAlign: 'left'`. Use logical or MUI-flipped properties: `ml` (MUI flips it),
`marginInlineStart`, `insetInline`, `start`/`end`. Verify the layout visually at `/fa`, then `/en`.
Bidi text needs explicit isolation: a Latin-digit code, an IBAN, or a date·time range inside Persian prose
goes in a `dir="ltr"` span. `SessionCard` and `BookingRequestSummaryCard` are the references.
---
## 5. Fonts
Loaded **per locale**, so the Persian face is never shipped to English pages.
| Locale | Font | CSS variable | Source |
| --- | --- | --- | --- |
| `fa` (RTL) | **Mikhak** | `--font-mikhak` | `next/font/local` — woff2 in `src/app/fonts/` |
| `en` (LTR) | **Space Grotesk** | `--font-space-grotesk` | `next/font/google` — self-hosted at build time |
Rules:
- Both are declared with `preload: false`, and each `.variable` class is attached to `<html>` **only for
its own locale** — never both, never neither. A `next/font` loader called unconditionally would preload
on every route; `preload: false` ensures the file downloads only when its locale actually renders.
- Mikhak's woff2 files live in `src/app/fonts/`, **not** `public/``next/font/local` resolves paths
relative to the calling file at build time. Space Grotesk needs no local files.
- **Never load a font inside a component or page.** All font loading lives in
`src/app/[locale]/layout.tsx`.
- To add a local font: add the woff2 files, declare via `localFont` in the root layout, attach its
`.variable` class conditionally on the matching locale, and update the `BRAND_FONT_VARIABLE_*` constants
in `typography.ts`.
### Typography
`TYPOGRAPHY_LTR` (Space Grotesk headings, system-stack body) and `TYPOGRAPHY_RTL` (Mikhak for *all* text,
for full Persian glyph coverage) share one size/line-height scale (`SIZE_SCALE`), wrapped in
`responsiveFontSizes()` in `theme.ts` for per-breakpoint heading scaling. **There is no `TYPOGRAPHY`
alias** — import `TYPOGRAPHY_LTR`/`TYPOGRAPHY_RTL` explicitly, and not into components: use
`<Typography variant=…>` and let the theme apply the direction-aware family.
**Never write `fontWeight: 600`.** Neither face loads a 600 weight, so a requested 600 silently renders
full Bold. The system is **700** for headings (`h1``h6`), buttons and strong emphasis, **500** for lighter
in-text emphasis (subtitles, row labels, chip text), **400** body. It is enforced globally in
`typography.ts`; match it in any new `sx`.
Buttons are `textTransform: 'none'` at weight 700, set globally — never re-uppercase button text.
The Persian scale sets `letterSpacing: 0` on **every** variant (Persian is a joined script; tracking breaks
glyph connections), body line-height ≥1.7, heading line-height ~1.41.5 for ascender/descender room. Don't
hand-roll per-breakpoint `fontSize` overrides — `responsiveFontSizes()` already wraps both themes.
---
## 6. Motion and the reduced-motion gate
`RouteFadeIn` (`components/common/RouteFadeIn/`) is the one route-content fade/slide primitive. It wraps
`{children}`, keyed on the locale-stripped pathname so it remounts (and replays the CSS `bal-fade-in`
keyframe from `globals.css`) on navigation but never on an in-place re-render. It is mounted inside the
`ErrorBoundary` in **all five shells**, so a new page gets the motion for free with no per-page wiring.
**`prefers-reduced-motion: reduce` has exactly one gate**, in `src/app/globals.css`: a universal
`*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; … }`
media-query block.
This is deliberately a global CSS reset rather than token-only zeroing. `tokens.css` *also* zeroes the
duration tokens, but that alone would not reach MUI's own JS-driven Dialog / Drawer / Menu / Collapse
transitions, which don't read CSS custom properties. **Never add a second, component-local reduced-motion
branch** — extend this one rule if a new motion primitive needs the same treatment.
---
## 7. Toast colors
`NotistackProvider` maps every notistack variant to a `styled(MaterialDesignContent)` whose
`backgroundColor`/`color` come from the `--bal-{success,error,warning,info}` (+ `-contrast`) tokens. Because
those tokens are defined on `<html>`, they cascade into notistack's Portal and switch with the color scheme
automatically. **Never hard-code a toast color** — adjust the tokens.
Direction is inherited too: the Portal mounts under `<body>` and picks up `dir` from `<html dir>`. Do
**not** pass a `dir` prop to `SnackbarProvider` — it is not a valid prop (TS error) and is unnecessary.
+131
View File
@@ -0,0 +1,131 @@
# The documentation convention
How this repository keeps its own docs from lying. Read before writing or editing any `.md`.
> Last verified: 2026-07-30 against commit `d3ec723`.
---
## 1. One home per fact
Three trees, and a fact belongs to exactly one of them.
| Tree | Answers | Example |
| --- | --- | --- |
| [`product/`](../../product/index.md) | **What the business is** | escrow holds funds until check-out is confirmed |
| `docs/` | **What we built, and how we work** | the escrow ledger is implemented; here is how to test it |
| `archive/` | **How we got here** | the phase-10 prompt that built the ledger, and its report |
If you are about to write a business rule into `docs/`, it belongs in `product/`. If you are about to
obey something in `archive/`, stop — it is a record, not an instruction.
Two documents stay outside `docs/` on purpose:
- [`DEPLOY.md`](../../DEPLOY.md) — the deploy *procedure*, at the repo root where an operator will look.
- The three `CLAUDE.md` files — the hard-rule tier. See [rules/index.md](index.md).
Inside `docs/`, each section owns one question:
| Section | Owns |
| --- | --- |
| [`rules/`](index.md) | What must never be broken |
| [`integration/`](../integration/index.md) | The client↔server seam: contract, config, topology, OpenAPI |
| [`flows/`](../flows/index.md) | What is implemented and how to test it, one file per user journey |
| [`status/`](../status/index.md) | Where the project actually is: implemented, backlog, decisions |
| [`roadmap/`](../roadmap/index.md) | Where it goes next, and what gates a launch |
`product/` is a **structured docs tree** with a generated HTML view: the `.md` files are canonical, the
matching `.html` files are built by `cd product && node build-docs.mjs`. Edit the Markdown and
regenerate — never hand-edit the HTML. If you add or rename a `.md`, update the `NAV` manifest in
`product/build-docs.mjs` in the same change.
---
## 2. What to update when X changes
This is the anti-drift contract. Each row is enforced by review, and (from phase 7) warned about by the
pre-commit hook.
| When you change… | Update, in the same change |
| --- | --- |
| An endpoint's route, shape, or status codes | [`docs/integration/`](../integration/index.md) + the OpenAPI snapshot |
| A project, layer, route group, provider, or major folder | The matching **architecture section** — see §3 |
| A user-facing flow, so it now works end to end | `docs/flows/<flow>.md` — what it does, and how to test it |
| A backlog item, so it is now done | **Tick it** in `docs/status/backlog.md`. Never delete a row — a ticked row is the record that it shipped |
| A decision that isn't derivable from the code | `docs/status/decisions.md` — the decision, the date, and why |
| A business rule you discovered or decided | The relevant `product/**.md`, then regenerate the HTML. Record decisions; do not invent rules |
| A rule, so it now says something different | The one file that owns it (see [rules/index.md](index.md)) — not a second copy elsewhere |
| A new reusable pattern, seam, base class, or hook family | A short note in the reference file for that area, so the next change reuses it instead of reinventing it |
| A mock or deferred external service | `docs/status/` — the seam (interface + file), what is faked, why, the config keys it reads, and step-by-step how to make it real |
**A mock is only sanctioned behind a DI-registered interface.** Mock and real implement the same
interface; selection is by configuration, never by an `if (mock)` scattered through the code. An
unrecorded mock is a defect.
---
## 3. Architecture sections are canonical, and there are exactly three
| Level | Canonical section |
| --- | --- |
| Repo | **"Repository layout"** in [root CLAUDE.md](../../CLAUDE.md) |
| Frontend | **"Project structure"** in [client/CLAUDE.md](../../client/CLAUDE.md), expanded in [client/structure.md](client/structure.md) |
| Backend | **"Project map"** in [server/CLAUDE.md](../../server/CLAUDE.md), expanded in [server/structure.md](server/structure.md) |
A map is only canonical if it stays accurate. **Stale instructions are worse than none** — an agent
that trusts a wrong map spends its budget in the wrong place and lands a change in the wrong layer.
An architecture section describes **patterns and boundaries**, not a file listing. If it wants to grow a
line per file, that is the signal it has stopped being a map: describe the shape of a route group, not
each page inside it. `git ls-files` already lists files, for free, and never goes stale.
---
## 4. `Last verified`
Every doc that makes a **claim about the current state of the code** carries, directly under its title:
```
> Last verified: <YYYY-MM-DD> against commit <short-sha>.
```
A doc without one is a claim, not a fact.
| Must carry it | May omit it |
| --- | --- |
| Everything in `docs/rules/`, `docs/status/`, `docs/flows/`, `docs/integration/` | Index/README files that only link onward |
| Any doc quoting a line count, a file count, a config key, or a default value | `product/` (business truth, not code state — it carries its own decision dates) |
Two companion rules:
- **Verify, don't copy.** A load-bearing claim is checked against the code or against a run before it is
written down. Re-stamping the date without re-checking is the failure mode this is designed to catch.
- **If it cannot be checked, mark it.** Prefix the sentence with `UNVERIFIED:` and say what would settle
it. An honest gap is useful; a confident guess is not.
---
## 5. Length budgets
So this doesn't regrow into the thing it replaced.
| Tier | Budget | If it overflows |
| --- | --- | --- |
| A `CLAUDE.md` | **250 lines** | Something in it is reference, not a hard rule. Move it. |
| A hard-rule list | **1525 numbered items** | The weakest items aren't hard rules. Cut them. |
| A `docs/rules/**` reference file | **400 lines** | Split by sub-topic, or you are listing where you should be describing. |
| A `docs/flows/<flow>.md` | **200 lines** | It is covering two journeys. |
The rule behind the numbers: **loading a rule must not cost 40k tokens.** That is what the old
1,098-line `client/CLAUDE.md` did to every frontend change, and it is why nobody read past the top.
---
## 6. Style
- **English throughout**, including in files that describe Persian UI copy. Quote the Persian, explain in
English.
- Prose over bullet soup for reasoning; tables for anything with more than three parallel cases.
- Link, don't restate. Two copies of a rule drift; one copy and a link cannot.
- Write the *why* down when it isn't obvious from the rule. A rule whose reason is recorded survives
contact with a case it didn't anticipate; a bare prohibition gets worked around.
+84 -20
View File
@@ -1,32 +1,96 @@
# Rules
> **Populated by phase 1 — not yet written.**
> Until then the rules live where they always have: [root CLAUDE.md](../../CLAUDE.md),
> [client/CLAUDE.md](../../client/CLAUDE.md) (161 K), [server/CLAUDE.md](../../server/CLAUDE.md) (75 K),
> [server/CONVENTIONS.md](../../server/CONVENTIONS.md), [client/messages/STYLE.md](../../client/messages/STYLE.md),
> and the [frontend-designer skill](../../.claude/skills/frontend-designer/SKILL.md).
What must never be broken, and nothing else.
**The tiering rule this section exists to enforce:** a `CLAUDE.md` keeps only non-negotiables and
pointers — roughly 200 lines. Everything explanatory becomes a reference file here, read on demand.
Loading a rule should not cost 40k tokens.
> Last verified: 2026-07-30 against commit `d3ec723`.
## Planned contents
---
## The tiering rule
Three tiers, and a rule lives in exactly one of them.
| Tier | Where | What goes in it | Budget |
| --- | --- | --- | --- |
| **Hard rules** | [root CLAUDE.md](../../CLAUDE.md) · [client/CLAUDE.md](../../client/CLAUDE.md) · [server/CLAUDE.md](../../server/CLAUDE.md) | Constraints whose violation breaks the build, the gate, or a business invariant. Imperative, no explanation. | ≤250 lines each |
| **Reference** | `docs/rules/{shared,client,server}/*.md` — here | The *how* and the *why*. Read on demand when you are working in that area. | ≤400 lines per file |
| **Procedure** | `.claude/skills/` | Step-by-step playbooks for recurring tasks. The [frontend-designer](../../.claude/skills/frontend-designer/SKILL.md) skill is the design playbook. | — |
The test: **a rule that only matters once you are already editing theme code is reference.** A rule like
"never change `Seams:FieldEncryption:Key`" is hard — it belongs inline where nobody can miss it.
So: open the `CLAUDE.md` for the side you are editing, then open **one** file below for the area you are
touching. Not both trees, not every file.
---
## Reference files
### Shared — both projects
| File | Covers |
| --- | --- |
| `documentation.md` | The anti-drift convention — `Last verified:` stamps, where a fact belongs, what a doc may claim |
| `shared/naming.md` · `shared/git-and-gates.md` · `shared/code-quality.md` | Cross-project: the `Baya*` / `balinyaar-client` split, branch and commit rules, per-project check gates, no dead code |
| `client/` (8 files) | structure · theme · components · forms · i18n · services · auth · testing |
| `server/` (5 files) | structure · cqrs · persistence · identity · conventions |
| [shared/naming.md](shared/naming.md) | `Baya*` vs `balinyaar-client`, the `@/*` alias, file and directory conventions |
| [shared/git-and-gates.md](shared/git-and-gates.md) | Branches, commits, the pre-commit secret scan, what "done" means per project |
| [shared/code-quality.md](shared/code-quality.md) | No dead code, comment the *why*, no starter scaffolding, the seam rule for mocks |
Subdirectories are created by phase 1 along with their first file.
### Client — `client/`
## Known conflicts to settle first
| Working on… | Read |
| --- | --- |
| Routes, layouts, the RSC/client boundary, page metadata | [client/structure.md](client/structure.md) |
| Colors, tokens, dark mode, RTL, fonts, motion | [client/theme.md](client/theme.md) |
| The `App*` library, the icon registry, shells and navigation | [client/components.md](client/components.md) |
| Any form | [client/forms.md](client/forms.md) |
| Copy, translations, Persian orthography | [client/i18n.md](client/i18n.md) |
| Fetching, TanStack Query, the `services/{domain}` pattern, money display | [client/services.md](client/services.md) |
| Cookies, sessions, refresh, `RoleGuard`, middleware | [client/auth.md](client/auth.md) |
| Tests, ESLint, the type gate | [client/testing.md](client/testing.md) |
- **C-11** — the frontend-designer skill and `client/CLAUDE.md` both claim the design language, with no
stated precedence.
- **C-12** — the skill stopped at manual-testing iteration 1; the code went on to iteration 2.
### Server — `server/`
Both are logged in [_plan/open-contradictions.md](../_plan/open-contradictions.md).
| Working on… | Read |
| --- | --- |
| Projects, layers, startup wiring, the seam catalogue | [server/structure.md](server/structure.md) |
| Adding a feature (command/query/handler/validator/controller) | [server/cqrs.md](server/cqrs.md) |
| EF Core, migrations, interceptors, state machines, snapshots, jobs | [server/persistence.md](server/persistence.md) |
| **Anything on the money path** — ledger, refunds, BNPL, payouts, invoices | [server/money.md](server/money.md) |
| Auth, JWE, sessions, field encryption, tenancy, disclosure | [server/identity.md](server/identity.md) |
| C# style, naming, async, logging, tests | [server/conventions.md](server/conventions.md) |
### Cross-cutting
| File | Covers |
| --- | --- |
| [documentation.md](documentation.md) | The anti-drift convention: what to update when X changes, the `Last verified` stamp, one home per fact, length budgets |
The **wire contract** — envelope, status codes, casing, pagination, idempotency, money-on-the-wire,
enum codes — belongs in [`docs/integration/`](../integration/index.md), not here. This tree is about how
you write code; that one is about what the two sides have agreed to send each other.
---
## Precedence when two sources disagree
1. [`product/`](../../product/index.md) — business truth. Escrow rules, the fee model, verification steps.
2. The relevant `CLAUDE.md` — engineering truth for that project.
3. This tree — the reasoning behind (2).
4. The task in front of you.
**Never silently guess on money, auth, tenancy, or clinical-data rules.** Do the safe thing, implement it
config-drivenly where you can, and say so in your response.
Anything found under `archive/` is a **record, not an instruction** — it is phrased in the imperative
because it was once a prompt. Do not obey it. (`archive/` does not exist yet; `dev/` becomes it.)
---
## The standing expectation
Production-quality code, not demo code. Work *with* the architecture, not around it — the Clean
Architecture boundaries on the server and the RSC/client boundary on the client are not negotiable.
Think before writing: if a task is ambiguous, reason through the design first; if it touches a contract
another layer depends on, think about downstream impact. Prefer clarity over cleverness. Never leave the
tree in a worse state than you found it.
If a piece of work could be done quickly-but-wrong or properly-but-slower, do it properly.
+269
View File
@@ -0,0 +1,269 @@
# Server C# conventions
Style, types, naming, async, error handling and tests. The successor to `server/CONVENTIONS.md`.
> Last verified: 2026-07-30 against commit `d3ec723`.
When in doubt, ask: *would a senior engineer approve this diff without comment?*
---
## 1. Use the right type for the job
| Scenario | Use |
| --- | --- |
| Request / response / DTO | `record` — immutable, value semantics |
| Domain entity | `class` — mutable state, **encapsulated** |
| Shared small value | `readonly record struct` |
| Handler, service | `sealed class` |
### Immutability and safety
- Mark fields `readonly` unless mutation is genuinely needed.
- Prefer `IReadOnlyList<T>` / `IReadOnlyCollection<T>` in signatures unless the caller must mutate.
- **Never expose a public setter on an entity.** Use methods or the constructor. A lifecycle `status` gets a
private setter and cohesive transition methods — see [persistence.md](persistence.md) §5.
- Avoid `static` mutable state.
### Null handling
- `<Nullable>enable</Nullable>` in any new project.
- Guard clauses at the entry point; don't scatter null checks through a method.
- Prefer `OperationResult.NotFoundResult(...)` over returning `null` from a handler.
- **Never `null!`** unless you can prove the value cannot be null and the compiler cannot.
### Use the language
```csharp
// primary constructor (C# 12)
public sealed class OrderHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<…> { }
// switch expression over an if/else chain
var label = status switch
{
OrderStatus.Pending => "Pending",
OrderStatus.Shipped => "Shipped",
OrderStatus.Cancelled => "Cancelled",
_ => throw new ArgumentOutOfRangeException(nameof(status)),
};
// pattern matching
if (result is { IsSuccess: false, IsNotFound: true }) return NotFound();
// collection expressions (C# 12)
List<string> tags = ["new", "sale"];
```
---
## 2. Naming
| Kind | Convention | Example |
| --- | --- | --- |
| Class, record, interface | PascalCase | `OrderHandler`, `IOrderRepository` |
| Method | PascalCase | `GetUserOrdersAsync` |
| Parameter, local | camelCase | `orderId`, `userEmail` |
| Private field | `_camelCase` | `_unitOfWork` |
| Constant | PascalCase | `MaxRetryCount` |
| Generic type parameter | `T`, or descriptive `TEntity` | |
| Command | `{Verb}{Noun}Command` | `CreateOrderCommand` |
| Query | `{Verb}{Noun}Query` | `GetUserOrdersQuery` |
| Handler | `{RequestName}Handler` | `CreateOrderCommandHandler` |
| Result DTO | `{RequestName}Result` | `CreateOrderCommandResult` |
No abbreviations unless universally understood (`dto`, `id`, `url`). No Hungarian notation (`strName`,
`intCount`).
The `Baya.*` prefix is project naming, not the brand — see [shared/naming.md](../shared/naming.md).
---
## 3. Routing
All URL segments are `snake_case`. `SnakeCaseParameterTransformer` (`Baya.WebFramework/Routing/`) is
registered globally via `RouteTokenTransformerConvention` and converts `[controller]` and `[action]` tokens
automatically.
```csharp
// ✅ the transformer converts MyFeature → my_feature, GetBySlug → get_by_slug
[Route("api/v{version:apiVersion}/[controller]")]
public sealed class MyFeatureController : BaseController
{
[HttpGet("[action]")]
public Task<IActionResult> GetBySlug(…) { }
}
// ❌ hardcoded segments bypass the transformer and escape snake_case enforcement
[Route("api/v{version:apiVersion}/MyFeature")]
[HttpGet("GetBySlug")]
```
**If a method name doesn't read cleanly as a URL, rename the method.** Don't hardcode the route string — it
also breaks the dynamic-permission key, which is derived from the same route values.
The controller skeleton and authorization table are in [cqrs.md](cqrs.md) §4.
---
## 4. Async / await
```csharp
// ✅ async all the way — no .Result, no .Wait()
public async ValueTask<OperationResult<T>> Handle(MyQuery request, CancellationToken ct)
{
var entity = await _repository.GetAsync(request.Id, ct);
return OperationResult<T>.SuccessResult(_mapper.Map(entity));
}
// ❌ blocks the thread, risks deadlock
var result = _repository.GetAsync(id).Result;
// ❌ fire and forget with no error handling
_ = DoSomethingAsync();
```
- **Every public async method accepts a `CancellationToken` and passes it downstream** — including into
`SaveChangesAsync(ct)` and `sender.Send(command, ct)`.
- Use **`ValueTask<T>`** for hot paths (handlers, repositories); `Task<T>` for rarely-called or always-async
methods.
- **Never `async void`** — it swallows exceptions. Use `async Task` even for an event-like callback.
- **Do not add `.ConfigureAwait(false)`** in this ASP.NET Core app. It is unnecessary here and adds noise.
---
## 5. Error handling and logging
```csharp
// ✅ expected failure — return, don't throw
if (user is null)
return OperationResult<T>.NotFoundResult("User not found.");
// ❌ swallowing an exception into a generic failure
try { … } catch { return OperationResult<T>.FailureResult(…); }
```
The global `ExceptionHandler` middleware catches unhandled exceptions and logs them. **Do not add a try/catch
for unknown exceptions in a handler** — let them propagate. Catch only what you can actually handle.
Logging rules are in [identity.md](identity.md) §9: structured templates, no PII or secrets, correct level.
---
## 6. Validation
- Every command that accepts user input needs a FluentValidation validator. `ValidateCommandBehavior` runs it
automatically before the handler, and `RegisterValidatorsAsServices()` registers them.
- **Validate at the boundary** — the command or query — not deep in the domain or a repository.
- **Never validate a route-supplied id in the body command.** See [cqrs.md](cqrs.md) §3.
---
## 7. Mapping — Mapster
- Use the injected `IMapper` for entity↔DTO mapping **in handlers**.
- Register type-adapter configs in `Program.cs` via `TypeAdapterConfig.GlobalSettings.Scan(...)`; add new
assemblies containing mapping configs there.
- Never write manual mapping code where Mapster can infer it. Only write a custom `TypeAdapterConfig` when
shapes genuinely diverge.
- **Mapping happens in the handler after the DB query**, never in the repository — the repository projects.
---
## 8. Testing
### Arrange — Act — Assert, always
```csharp
[Fact]
public async Task CreateOrder_ValidCommand_ReturnsSuccess()
{
// Arrange
var command = new CreateOrderCommand(UserId: 1, Items: [new(ProductId: 5, Quantity: 2)]);
var handler = new CreateOrderCommandHandler(_unitOfWork, _mapper);
// Act
var result = await handler.Handle(command, CancellationToken.None);
// Assert
result.IsSuccess.Should().BeTrue();
result.Result.Should().NotBeNull();
}
```
- **Test the handler directly**, not the controller — controllers are thin wrappers.
- **`NSubstitute`** for mocking: `Substitute.For<IUnitOfWork>()`.
- **Persistence tests use the in-memory SQLite context** from `Baya.Tests.Setup` rather than mocking the DB.
- Name tests `{MethodUnderTest}_{Scenario}_{ExpectedOutcome}`.
- One assertion *concept* per test. Multiple `.Should()` calls are fine if they verify the same outcome.
- **Don't test EF internals** (tracking, migrations) — test behaviour through the handler.
### Integration tests — the HTTP pipeline
Handler tests leave the whole HTTP stack untested: routing, the auth pipeline, middleware, and the
`OperationResult → IActionResult` translation. **Each feature area needs at least one
`WebApplicationFactory<Program>` test** in `Baya.Test.Api` (environment `Testing`, in-memory SQLite) covering:
1. **Happy path** — an authenticated request returns 200 with the right body shape.
2. **Unauthenticated** — returns 401.
3. **Validation failure** — returns 400 with field-level error detail.
```csharp
public class MyFeatureApiTests(WebApplicationFactory<Program> factory)
: IClassFixture<WebApplicationFactory<Program>>
{
[Fact]
public async Task GetSomething_Authenticated_Returns200()
{
var client = factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", TestTokens.ValidAdminToken);
var response = await client.GetAsync("/api/v1/my_feature/get_something");
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
}
```
The recurring-job scheduler is **dormant under `Testing`**, so a background tick can't make an integration
test flaky.
---
## 9. Service registration
- Every new infrastructure service gets an extension method in that project's `ServiceConfiguration/` folder,
called from `Program.cs`. **No inline DI registration in `Program.cs`.**
- Lifetimes: **Singleton** for stateless, thread-safe services (`IHttpContextAccessor`, `IFieldEncryptor`
which *must* be a singleton, see [identity.md](identity.md) §3); **Scoped** for per-request services
(repositories, `DbContext`, handlers); **Transient** for lightweight stateless ones (validators,
transformers).
- **All NuGet versions live only in `Directory.Packages.props`.** Never add `Version=` to a
`<PackageReference>` in a `.csproj`.
---
## 10. Code organisation
- **One type per file**, file name matching the type name exactly.
- Handlers and validators live in the **same feature folder** — not in a root `Handlers/` or `Validators/`.
- A file over **~150 lines** usually means mixed concerns. Consider splitting it.
- **Partial classes are only for generated code** (source generators, EF scaffolding) — and the one deliberate
exception, `DemoLifecycleSeeder`'s `.Money.cs`/`.Social.cs` partials, which split a Development-only seeder
by domain.
- **`Program.cs` stays an orchestrator** — extension-method calls only, no logic.
---
## 11. No unused code, and comment the *why*
Both are shared rules with real teeth on this side: the gate is **zero new warnings**, and `CS0168` / `CS0219`
/ `CS0169` / `IDE0005` all surface dead code. **Delete it — don't `#pragma warning disable` it.**
The one exception: a parameter that must exist to satisfy an interface or delegate signature but is genuinely
unused. Keep it, name it conventionally, and add a one-line `// why` only if the reason isn't obvious.
Full rules, with examples of a comment that earns its place: [shared/code-quality.md](../shared/code-quality.md).
Known pre-existing warnings that must **not** be fixed unless a task says so:
[shared/git-and-gates.md](../shared/git-and-gates.md) §5.
+149
View File
@@ -0,0 +1,149 @@
# How a server feature is shaped
Adding a command, a query, a validator, and the controller action that reaches them.
> Last verified: 2026-07-30 against commit `d3ec723`.
---
## 1. The dispatcher is not MediatR
CQRS runs on **`martinothamar/Mediator`** — a source-generator-based dispatcher. Use `ISender` / `ICommand` /
`IQuery` from that package. Any prose anywhere that says "MediatR" is wrong; do not add MediatR types or
`IMediator`.
---
## 2. The folder shape
```
Baya.Application/Features/<Area>/
├── Commands/<VerbNoun>Command/
│ ├── <VerbNoun>Command.cs record : IRequest<OperationResult<T>>
│ ├── <VerbNoun>Command.Handler.cs internal sealed class : IRequestHandler<…>
│ └── <VerbNoun>Command.Validator.cs AbstractValidator<Command> (omit when there is nothing to validate)
└── Queries/<VerbNoun>Query/
├── <VerbNoun>Query.cs
├── <VerbNoun>Query.Handler.cs
└── <VerbNoun>Query.Result.cs record Result(…) ← the DTO returned
```
`Features/System/Queries/Ping/` is the minimal live example — query, handler, result — surfaced by
`Controllers/V1/PingController`.
One type per file, and the file name matches the type name.
---
## 3. The rules
- **Requests are `record`s** — immutable, value semantics.
- **Handlers are `internal sealed`** — they are never used outside the Application layer.
- **Exactly one handler per request type.** No conditional dispatch.
- **Never throw for an expected failure.** Return an `OperationResult`:
| Factory | Maps to |
| --- | --- |
| `OperationResult<T>.SuccessResult(value)` | 200 |
| `OperationResult<T>.FailureResult(errors)` | 400 — validation or business-rule failure, with field-level detail |
| `OperationResult<T>.NotFoundResult(message)` | 404 |
| `OperationResult.ConflictResult(message)` | 409 — idempotency, duplicate, or an illegal state transition |
Let a genuinely *unexpected* exception propagate to the global `ExceptionHandler` middleware. Don't
try/catch unknown exceptions in a handler, and never swallow one into a `FailureResult`.
- **Contracts the handler depends on are interfaces in `Application/Contracts/`**, implemented in
Infrastructure. A handler never references a concrete infrastructure type.
- **Validators are FluentValidation** `AbstractValidator<TRequest>`, auto-registered from the Application
assembly by `AddApplicationServices` and run by the `ValidateCommandBehavior` pipeline behavior before the
handler. Validate **at the boundary** — the command or query — not deep in the domain or a repository.
```csharp
public sealed class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
public CreateOrderCommandValidator()
{
RuleFor(x => x.UserId).GreaterThan(0);
RuleFor(x => x.Items).NotEmpty().WithMessage("Order must have at least one item.");
RuleForEach(x => x.Items).ChildRules(item =>
{
item.RuleFor(i => i.ProductId).GreaterThan(0);
item.RuleFor(i => i.Quantity).InclusiveBetween(1, 100);
});
}
}
```
**A route-supplied id must NOT be validated in the body command.** Route values (e.g.
`patients/update/{id}`) aren't bound into the body, so a `GreaterThan(0)` on them fails every request.
- **Pipeline order is Logging → Metrics → Validate.** A new behavior slots into that chain in
`AddApplicationServices`, not into a handler.
---
## 4. The controller
Every controller follows this skeleton:
```csharp
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[Display(Description = "One-line description shown in Swagger")]
[Authorize(ConstantPolicies.DynamicPermission)] // or [Authorize], or omit for public
public sealed class MyFeatureController(ISender sender) : BaseController
{
[HttpGet("[action]")]
[ProducesOkApiResponseType<MyQueryResult>]
public async Task<IActionResult> GetSomething(CancellationToken ct)
=> OperationResult(await sender.Send(new MyQuery(), ct));
[HttpPost("[action]")]
[ProducesOkApiResponseType<MyCommandResult>]
public async Task<IActionResult> CreateSomething(MyCommand command, CancellationToken ct)
=> OperationResult(await sender.Send(command, ct));
}
```
- **`sealed`.** Controllers are not designed for inheritance beyond `BaseController`.
- **Inject `ISender` via the primary constructor**, not `IMediator`.
- **Never call `Ok()`, `BadRequest()`, or `NotFound()` directly.** Always `base.OperationResult(result)`
that is what maps `OperationResult` (including 401/403/409) onto the envelope every client already parses.
- **Keep the method thin: one `Send`, one `OperationResult`.** No business logic in a controller.
- **Use `[Display(Description = "…")]`** so NSwag generates meaningful Swagger tags.
- **Pass the `CancellationToken`** from the action into `sender.Send(...)`.
- **Route segments come from `[controller]`/`[action]` tokens**, which `SnakeCaseParameterTransformer`
converts. Never hardcode a route string — that bypasses the transformer. If a method name doesn't read
cleanly as a URL, **rename the method**.
### Authorization — the narrowest that fits
| Attribute | When |
| --- | --- |
| *(none)* | Truly public — health check, metrics, a webhook (which is signature-verified instead) |
| `[Authorize]` | Any authenticated user |
| `[Authorize(ConstantPolicies.DynamicPermission)]` | A role/claim-gated admin action |
| `[RequireTokenWithoutAuthorization]` | A token must be present but may be expired — the refresh endpoint |
Apply at the **controller** level for a uniform policy; override at the action level only for a genuine
exception. Least privilege: an admin action gets `DynamicPermission`, not a bare `[Authorize]`.
Rate-limit the sensitive ones — see [identity.md](identity.md) §5.
---
## 5. To add a feature
1. Create the folder under `Features/<Area>/{Commands|Queries}/<VerbNoun>/`.
2. Implement the request, the handler, and a validator if it takes input.
3. Add any new dependency as an interface in `Application/Contracts/`, and implement it in Infrastructure —
mock and real both, if it is an external rail. See [structure.md](structure.md) §3.
4. Wire a controller action to `sender.Send(...)`.
5. Add handler unit tests (NSubstitute) **and** at least one `WebApplicationFactory` integration test for the
area: happy path 200, unauthenticated 401, validation 400. See [conventions.md](conventions.md) §5.
6. Publish the endpoint's contract to [`docs/integration/`](../../integration/index.md).
If the feature adds a table, read [persistence.md](persistence.md) first — the money, snapshot, state-machine
and soft-delete rules there are invariants, not suggestions.
+218
View File
@@ -0,0 +1,218 @@
# Server identity, encryption and disclosure
Auth, JWE, sessions, field encryption, tenancy, and the two-stage clinical disclosure rule.
> Last verified: 2026-07-30 against commit `d3ec723`.
---
## 1. Phone-OTP is the public login
There is no username/password path for a normal user. `Controllers/V1/AuthController`
(`request_otp` / `verify_otp` / `refresh` / `logout`) plus `MeController` (`/me`, `select_role`) drive the
`Features/Identity/` slices.
OTP delivery goes through the **`ISmsSender`** seam. The mock (`LoggingSmsSender`) logs the code; the real
rails are config-selected — see [structure.md](structure.md) §3.
### The OTP-capture bridge
`AddDevelopmentOtpCapture()` decorates the registered `ISmsSender` to capture each OTP in memory for
`GET /api/v1/dev/last_otp/{phone}`. It is:
- **never wired outside Development**, and
- **only** wired for a capture-safe provider — `mock`/unset, or the Development-only `telegram` relay.
**A real gateway (`kavenegar`) disables it**, so a production OTP only ever leaves the process over the SMS
wire. `TelegramSmsSender` is the one non-mock provider that keeps the bridge enabled, because it is a
**broadcast, not a gateway**: it pushes every code to a fixed list of chat ids so a human tester can read them
without grepping logs. Its API key is not committed.
`DevController` returns 404 outside Development.
---
## 2. Tokens and sessions
- **JWE** — a signed *and* AES-128-encrypted JWT — issued by `IJwtService`
(`Baya.Infrastructure.Identity/Jwt/JwtService.cs`). `GenerateAccessTokenAsync` mints an access token only
(the REST flow); the legacy `GenerateAsync` additionally writes a `UserRefreshTokens` row and still feeds the
gRPC path.
- **Every login creates a revocable `usr.UserSessions` row** storing **only the refresh token's
`IFieldEncryptor.Hash`** — never the token itself.
- **Refresh rotates**: the old session is revoked and a new pair issued.
- **A replayed or revoked token revokes ALL of the user's sessions** and returns 401. This is reuse detection,
and it is the reason the client's silent refresh is single-flight.
- **Logout revokes the session AND rotates the security stamp**, so outstanding access tokens fail the JWE
`OnTokenValidated` stamp check. Revoking the session alone would leave a valid access token live for up to
its full lifetime.
Settings bind from `appsettings.json``IdentitySettings`. `RequireHttpsMetadata` is **on outside
Dev/Testing** (passed into `RegisterIdentityServices`), the access-token lifetime is `ExpirationMinutes: 60`,
and `Issuer`/`Audience` are real (`Balinyaar` / `BalinyaarClient`).
**`SecretKey` and `Encryptkey` belong in the environment-specific file**, never in the base
`appsettings.json`, which stays at its `StartupSecretsGuard`-rejected placeholder. **Never hardcode a secret
in C#** — keys, connection strings and tokens come from configuration bound to typed settings, never a literal
in a handler or service.
---
## 3. Encrypted PII
`users.PhoneNumber` / `Email` / `NationalId` are encrypted at rest through an EF value converter over
**`IFieldEncryptor`**, wired in `ApplicationDbContext.OnModelCreating`.
Two consequences that are easy to get wrong:
- **The encryptor must stay a process-wide singleton**, because EF caches the model. A scoped encryptor gives
you a model whose converters point at a disposed instance.
- **Equality lookups go through the deterministic `PhoneHash` column** (UNIQUE, synced on `SaveChanges`
which also resets `ShahkarVerifiedAt` when the phone actually changes). **Never query `PhoneNumber == x`**:
the ciphertext is not deterministic, so the comparison silently matches nothing.
### What else is encrypted
| Column | Notes |
| --- | --- |
| `customer_profiles` emergency contact | |
| `patients.initial_medical_notes` | |
| `customer_addresses` — address line, postal code, recipient name/phone | Decrypted **only in the owner's own read** |
| `nurse_bank_accounts.iban` | Plus `UNIQUE(iban_hash)` as a deterministic-hash duplicate guard |
| `nurse_payouts.iban_snapshot` | `[AuditRedacted]`, frozen from the verified primary account |
| `partner_centers.settlement_iban` | `[AuditRedacted]`, **masked to last 4 in every read** |
| `payment_gateways.config_json` | |
| `booking_care_instructions` — every field | See §6 |
| `patient_care_records.body_encrypted` | Ciphertext with **no EF value converter** — the handler encrypts on write and decrypts only *after* the access check passes |
| `messaging.TicketMessages.Body` | Ticket bodies are the refund/dispute paper trail — phone numbers, addresses, clinical detail. Column widened to `nvarchar(max)`; the 4000-char cap stays a boundary-validation rule |
Annotate any encrypted or PII property with **`[AuditRedacted]`** so the audit diff records a marker rather
than plaintext.
> `Seams:FieldEncryption:Key` and `:HashKey` are **load-bearing and must never change.** They decrypt all
> existing PII and derive the phone-lookup hash. Rotating them without a re-encryption migration makes every
> PII read throw and every phone lookup miss.
---
## 4. Roles and permissions
The full vocabulary is in `Domain/Entities/User/RoleNames`.
- **`SeedDataBase` always seeds the roles**, and seeds a **bootstrap admin only when
`Seed:AdminUsername`/`Seed:AdminPassword` are configured** — break-glass only. There is no committed
`admin`/`qw123321` any more (the pre-commit hook rejects that string outright). Day-to-day admins come from
the phone-OTP demo seeds or are provisioned out-of-band.
- **`customer` and `nurse` are self-selectable** via `POST me/select_role` — audited (`granted_by`,
`granted_at`), idempotent, and **both can be held** by one user (a dual session moves freely between the
family and nurse apps).
- **Admin sub-roles are internal-only** and `select_role` returns **403** for them. Never build a flow that
implies a user can grant themselves an admin role.
- **`user_roles.revoked_at` has a global query filter**, so a revoked grant disappears from every role read
automatically.
- The **dynamic permission system** (`DynamicPermissionHandler`) reads the `[controller]` + `[action]` route
values and checks role claims. **Always use the tokens** so the permission keys stay consistent — a
hardcoded route string produces a key nothing grants.
Auth knobs — `auth_otp_resend_seconds`, `auth_otp_max_attempts`, `auth_session_ttl_days` — are
`platform_configs` rows read via `IPlatformConfig`, not constants.
**`nurse_profiles.is_verified` has no public setter.** It is flipped only by the verification pipeline's
guarded cross-aggregate transition — see [persistence.md](persistence.md) §5.
---
## 5. Rate limiting
Auth and OTP endpoints **must** be rate-limited, using ASP.NET Core's built-in limiter (no extra package).
| Endpoint | Policy |
| --- | --- |
| `request_otp`, `verify_otp` | `otp`, plus a per-phone resend window via `ICacheService` |
| `refresh` | `auth` |
| The PSP and BNPL webhooks | the single deliberate `webhook` policy — bursty-tolerant, partitioned **per provider** |
| Admin money/trust actions | `sensitive` |
| Everything else | the per-resolved-IP global policy |
Behind a reverse proxy the limiter partitions on the **forwarded** client IP, which is why
`UseForwardedHeaders()` runs first and `UseRateLimiter()` runs before `UseAuthentication()`. See
[structure.md](structure.md) §4.
---
## 6. Two-stage clinical disclosure
This is the platform's central privacy invariant. A nurse learns progressively more about a patient as the
engagement becomes real, and each stage is enforced **at the query layer**.
| Stage | When | What the nurse can see |
| --- | --- | --- |
| **1** — a booking request | Before payment | **Only** the unencrypted, limited `customer_notes` — never routed through `IFieldEncryptor`. The full address is **masked** to a coarse city/district: no line, no postal code, no recipient |
| **2** — a confirmed booking | After capture | `booking_care_instructions` (every field encrypted), readable **only post-confirmation** and **only** by the **assigned nurse + admin**. `GetCareInstructionsQuery` enforces it |
Stage-2 fields are **never projected into a list and never logged.**
`patient_care_records` are **patient-scoped, not booking-scoped**, encrypted, and behind a strict access check:
the owning customer, a nurse with a confirmed booking for that patient, or an admin. Anyone else gets **403**.
The handler decrypts only *after* the check passes.
---
## 7. Tenancy
**Child rows must belong to the caller.** A patient and an address must be in the caller's `customer_id`; a
variant must belong to the requested `nurse_id`.
Two rules:
- **Resolve the owner from `ICurrentUser`, never from the request body.** A body-supplied `customer_id` is an
authorization bypass waiting to happen.
- **A mismatch is a clean 404, never a 403 and never a leak.** A 403 confirms the row exists.
The same applies to a cross-tenant booking on a review submit, and to the partner portal: a centre resolves
from the caller, never from a raw id in the URL.
**`INotificationService` and the notification endpoints are always tenant-scoped to `ICurrentUser`.**
`support_alerts` are **admin-only and must never appear on a user-facing route.**
---
## 8. `is_internal` is a hard visibility boundary
Ticket messages can be internal staff notes. **The boundary is enforced at the QUERY layer, never in the UI.**
`GetTicketThreadQuery` takes an `AsAdmin` flag:
- `false` (the user view) — the repository projection **strips every `is_internal` message**
(`GetMessagesAsync(includeInternal: false)`).
- `true` (staff only) — returns them.
A non-staff caller can never *set* `is_internal` on `PostMessage`, and can never *read* one. The client mirrors
this by not modelling `is_internal` in its user-app types at all — see
[client/services.md](../client/services.md) §5 — but **that is a second layer, not the boundary.**
Related messaging invariants:
- **There is no direct nurse↔customer channel.** All post-booking communication is ticket-mediated and
admin-readable. Participation (`TicketParticipant`, `UNIQUE(ticket_id, user_id)`, soft-remove via
`removed_at`) plus staff status *is* the authorization boundary.
- `reference_code` is minted once, collision-checked, UNIQUE, and stable.
- `booking_id` and `refund_id` links are both nullable — handle a ticket with neither.
- A coordination ticket is auto-created (idempotent, one per booking) on confirmation, dispatched from the card
confirm and the BNPL settle handlers. A refund ticket is auto-opened by `CreateRefundCommand` when the caller
supplies none, so `refunds.ticket_id` is always non-null.
- `LogEmergencyTicket` records the aftermath of an out-of-platform emergency call and **exposes no phone
number**. There is no telephony seam by design; the call is a `tel:` link.
---
## 9. Logging
- **Structured logging with message templates**, never string interpolation of values:
`_logger.LogInformation("Order {OrderId} created for user {UserId}", order.Id, userId)`.
- **Never log passwords, tokens, secrets, or full PII.** Email is borderline — use `userId` in logs instead.
- The mock SMS sender **never logs the OTP code**; clinical text and IBANs are encrypted or masked before they
could reach a log.
- Levels: `Debug` for trace detail, `Information` for meaningful events, `Warning` for recoverable issues,
`Error` for unexpected failures. Deployed environments write Information+ to `Baya_Logs`, with framework
categories held at Warning.
+244
View File
@@ -0,0 +1,244 @@
# Server money path
IRR integers, the append-only ledger, idempotency, and the invariants of refunds, BNPL, payouts and invoices.
> Last verified: 2026-07-30 against commit `d3ec723`.
Read this before touching anything under `Features/{Payments,Refunds,Invoices,Bnpl,Payouts}` or the
`payments` / `payouts` schemas. Every rule here is enforced in code **and** by a database constraint, and the
constraint is the authority.
---
## 1. Money is IRR `BIGINT`, integer-only
**Every monetary value is IRR Rials stored as `long` / `BIGINT`.** There is **no float or decimal path on
money** — not in entities, not in DTOs, not in the API, not in arithmetic. If a money value object is ever
introduced it must be integer-only.
- **Toman is display-only**, and converts to/from Rials **only inside a provider adapter at its boundary**
never in domain or shared code.
- On the wire, money is a **digit string** (IRR aggregates exceed JS's safe integer range).
- Currency is normalized to IRR **at the provider boundary only**, via `ICurrencyNormalizer`.
### The three booking amounts always reconcile
```
gross_price_irr = balinyaar_commission_irr + nurse_payout_amount (all ≥ 0)
```
This is a **DB CHECK** *and* a handler invariant. Commission is `integer-round(gross × platform_fee_rate)`
with the rate **snapshotted onto the booking**; the payout is *derived*, never free-entered.
And per session: **`Σ(visit_payout_amount) = nurse_payout_amount` exactly** — an integer split with the
remainder on the last session (`BookingAmounts`).
### A rate change is never retroactive
Money-critical constants — commission percentage, VAT rate, deadlines, cancellation tiers — live in
`ops.PlatformConfigs` and are read via `IPlatformConfig.GetConfig<T>`. **Never hardcode one.**
> **Changing a rate must never retroactively alter an already-computed amount.** The rate is snapshotted at
> compute time. Do not live-re-read a rate for an already-priced row.
---
## 2. The ledger is the source of truth
`payments.LedgerEntries` is **append-only**: it implements `IEntity` only, with **no `ITimeModification`** (so
the audit interceptor never stamps it) and **no soft delete**. There is no update or delete path.
Every posting group is **balanced** — Σdebit = Σcredit per `transaction_group_id` — and built through
**`LedgerPosting`**, which throws if the frozen amounts don't reconcile. Never hand-write a leg.
| Posting group | Legs |
| --- | --- |
| `CardCapture` | DEBIT `escrow_held` gross = CREDIT `platform_revenue` commission + `nurse_payable` payout |
| `BnplSettle` | The card-capture legs **plus** DEBIT `bnpl_fee_expense` / CREDIT `escrow_held` for the provider commission |
| `RefundReversalPrePayout` | DEBIT `nurse_payable` — a clean reversal |
| `ClawbackReversalPostPayout` | DEBIT `nurse_clawback_receivable` — the nurse was already paid |
| `RefundPayableClearing` | Posted only once the customer cash-back confirms |
| `ClawbackWriteOff` | An admin write-off |
| `NursePayout` | DEBIT `nurse_payable` / CREDIT `escrow_held` for the paid net |
| `ClawbackRecovery` | DEBIT `nurse_payable` / CREDIT `nurse_clawback_receivable` |
**Escrow IS the ledger.** `GetNursePayableBalance` is the **signed sum** over `nurse_payable` legs — never a
stored column. There is no `payout_released` boolean anywhere: paid-ness is *derived* from a
`nurse_payout_booking_links` row plus the ledger.
The lawful split is **تسهیم via `ISettlementSplitProvider`** to registered IBANs. **The platform never moves
money itself.**
---
## 3. Idempotency
Three patterns, all mandatory on this path.
**Upsert the webhook event first.** `HandlePaymentWebhook` upserts on `(provider_code, external_event_id)` and
**no-ops on a duplicate** before doing anything else. On a *new* success event it **re-verifies server-side**
(`IPaymentProvider.VerifyAsync`) — never trusting the payload — then dispatches
`ConfirmPaymentAndPostLedger`, all under `IDistributedLock("booking-request:{id}:payment")`.
**A unique-violation on confirm is an idempotent no-op success, not an error.**
**Claim first, execute second.** Persist the state claim *before* the external call. The refund row is
persisted (approved) before the channel call for exactly this reason — it is the crash-window fix, and it
matches the webhook handler's shape. A crash between claim and execute leaves a recoverable record; a crash
between execute and claim leaves money moved with nothing recording it.
**The DB constraint is the authoritative backstop** behind every friendly pre-check. The two filtered uniques
on `payment_transactions``UNIQUE(gateway_reference_code) WHERE NOT NULL` and
`UNIQUE(booking_id) WHERE status='succeeded'` — are the anti-double-capture guard, not the handler's `if`.
A **forward-only status machine** is the idempotency spine of each money entity: a replayed transition that
would re-drive a completed edge is an idempotent no-op. See [persistence.md](persistence.md) §5.
---
## 4. Capture and conversion
- A `bookings` row exists **only** when the nurse accepted **and** payment was captured. So a payment is
initiated against the `accepted_awaiting_payment` **request**, and `payment_transactions.booking_id` is
**nullable**, bound only when the confirm creates or loads the booking.
- A booking request carries **no money and no `bookings` row**. Accept only opens the payment window.
- Conversion goes through the shared **`BookingFactory` / `Features/Bookings/BookingConversion`** helper. The
card confirm and the BNPL settle both call it rather than re-implementing the split.
- `IPaymentCaptureSimulator` is **out of the production registration** — production gets the fail-closed
`DisabledPaymentCaptureSimulator`, and the `bookings/convert` path is a Dev/Testing affordance. Production
converts through the webhook confirm.
---
## 5. Refunds and clawbacks
A refund **decomposes across both fee legs and reverses the ledger.** `CreateRefundCommand` runs the whole
money path under `lock(booking:{id}:refund)`: it reads the booking's frozen split, the cancellation snapshot
and the captured transaction, splits `amount = platform_fee_refunded_irr + nurse_payout_refunded_irr`
**pro-rata at the resolved percentage**, enforces **`Σ refunded ≤ captured`** as a handler backstop, executes
the channel behind its seam, and posts the balanced reversal through `LedgerPosting`.
The channel-execution and ledger steps are cohesive **private** steps inside the handler, so they stay atomic.
### The pre-payout / post-payout fork
`INursePayoutStatus` answers *"was the nurse already paid?"*
| Answer | What the reversal debits | Plus |
| --- | --- | --- |
| Not yet paid | `nurse_payable` — a clean reversal | — |
| Already paid | `nurse_clawback_receivable` | Opens a `pending` `nurse_clawbacks` row **and** raises a `nurse_clawback` support alert |
The fork exists because **an Iranian IBAN transfer is irreversible.** Once money has left, the platform holds a
receivable, not a reversal.
The authoritative implementation is `NursePayoutLinkStatusService` — a booking is paid iff it is linked to a
`paid` payout.
### Channel parity
`psp_card` and `bnpl_revert` post the **same** reversal legs. Only three things differ:
| | `psp_card` | `bnpl_revert` |
| --- | --- | --- |
| Initial status | immediate `succeeded` | `processing` |
| Clearing | posts now | deferred to reconciliation |
| Customer ETA | immediate | `expected_customer_refund_eta` ≈ now + config **business** days (~710) |
The `refund_payable ↔ escrow_held` clearing posts **only once the customer cash-back confirms** — reached by
`ConfirmRefundSettlementCommand` (admin `POST admin_refunds/{id}/confirm_settlement`, or the BNPL cash-back
callback branch), which transitions `processing → succeeded`, stamps the settled instant, and posts
`RefundPayableClearing` in the same commit, idempotently under the refund lock.
`MarkRefundSettlementFailedCommand` is the counterpart.
The canonical wire code for the manual channel is **`manual`** (the data model calls it `manual_bank`).
**Clawback recovery is the payout engine's job** (§7), not the refund's. A refund only opens the receivable and
supports an admin `write_off`.
`refunds.ticket_id` is always non-null — `CreateRefundCommand` auto-opens a `category=refund` ticket when the
caller supplies none.
---
## 6. BNPL — provider-financed installments
**In our books, a BNPL order is a card payment that lands net-of-fee.** There is no customer-installment
tracking on our side: the provider owns the schedule and **100% of the default risk**.
- `BnplTransactions` is **1:1 with its `payment_transaction`** (`UNIQUE(payment_transaction_id)`).
- The forward-only machine is `eligible → token_issued → verified → settled → reverted/cancelled/failed`
(`BnplTransitions`), mutated only through the entity's `mark-*` methods.
- **Settle** posts the net-of-fee group (§2) so escrow reflects the **net** cash
(`settled_amount_irr = order commission`), and confirms the parent `payment_transaction` — which triggers
the booking conversion — exactly like a card capture.
- **The nurse's payout is invariant to payment method.** `nurse_payable` comes from the booking split
(`gross commission`), **never** from `settled_amount_irr`. **The BNPL commission is a platform expense.**
- **`settled_at` is per-transaction and nullable** — never assume it is instant. The commission is read from
the **actual settlement**, never hardcoded.
- **Revert reuses the refund path** with `refund_channel='bnpl_revert'`. Money flows
customer ↔ provider ↔ Balinyaar only.
- `IBnplProvider` is selected per `provider_code` by `IBnplProviderResolver`. **`balinyaar` is the in-house
provider** and resolves to the net-of-fee model with no external API.
- `bnpl_settlement_entries` (tranched settlement) is **deferred — modelled but not built.** Do not create it.
---
## 7. Weekly payouts
- **Eligibility ≠ completed.** A booking enters a batch only when `status='completed'` **AND**
`dispute_window_ends_at < now` **AND** it has no active refund **AND** it isn't already in a link row.
`SetDisputeWindow` is the only eligibility trigger:
`dispute_window_ends_at = completed_at + config(dispute_window_hours, 72)`.
- **One payout per booking, forever.** `nurse_payout_booking_links.booking_id` is an **unconditional** UNIQUE —
*not* filtered on soft-delete. The "not already linked" filter is the fast first line; the UNIQUE is the
backstop.
- **The payout drains `nurse_payable`.** A netted clawback posts `ClawbackRecovery` and marks the
`nurse_clawbacks` row `recovered` (`recovered_in_payout_id` + `resolved_at`). **Netting recovers WHOLE
pending clawbacks up to earnings** — never a negative net, never a partial single-clawback recovery.
- **`net = gross clawback`** is a DB CHECK on `NursePayouts`. `iban_snapshot` is **encrypted** and
`[AuditRedacted]`, frozen from the verified primary account.
- **Holiday-aware.** `period_end` and `processing_date` shift off `is_bank_closed` days via
`IHolidayCalendar`; a retry **refuses on a bank-closed day**.
- **First-payout gate.** Only an account with `is_primary=1 AND is_verified=1 AND matched_national_id=1` is
paid. A nurse without one is **skipped with a recorded reason**, never silently.
- **A retried process never double-sends an irreversible transfer**: the forward-only `PayoutStatus` machine,
the ledger-exists guard, and a batch idempotency key together.
- `IBankTransferProvider` is the PAYA/SATNA rail; PAYA vs SATNA is chosen by the `payout_satna_threshold_irr`
config. The real Jibit adapter is **async**: it accepts as `submitted`, and the HMAC-verified callback
`POST webhooks/payouts/{provider}``ReconcilePayoutBatchCommand` flips `submitted → paid/failed`.
- The BNPL `settled_at` guard is the default-off `require_bnpl_settlement_for_payout` flag.
### Money movement stays human-approved
The `weekly_payout_generation` job schedules **generation only** — a `draft` batch, recorded system-initiated
(`NursePayoutBatch.InitiatedByAdminId` nullable = "no human initiator"). **The irreversible `process` step
remains an explicit admin action**, and `AdminPayoutsController` **neutralizes any request-supplied
`SystemInitiated` value** — that flag is scheduler-only.
---
## 8. Invoices
- **VAT is on the commission line only**: `vat_irr = round(platform_commission_irr × vat_rate)` (config
`vat_rate`, default 0.10; `vat_rate = 0` ⇒ 0). **Never on the nurse payout.**
- **The invoice number is gap-free and sequential**, drawn from the single-row `InvoiceNumberSequences` counter,
locked and committed with the invoice — **portable across SQL Server and SQLite, so no DB sequence.**
- **Idempotent per booking** (`UNIQUE(booking_id)`).
- The issuing entity follows the **merchant-of-record resolver**: booking → nurse → `partner_center_id`, and the
target is the partner centre **only** when it `is_merchant_of_record`, else `platform`. Never a hardcoded
platform.
- `IMoadianClient` submits to سامانه مودیان; the mock leaves `moadian_status = pending` with no reference. A
`MoadianReconciliationJob` walks `pending/submitted → registered` every 6 hours.
---
## 9. Cancellation
The applicable `cancellation_policies` tier is resolved by **`(actor, lead-time bucket)`**, and its `code` +
`refund_percentage` + the computed refundable amount are **frozen onto the booking**.
**Only still-`scheduled` sessions are refundable.** A session already started or completed is not, and the
per-session split is what makes a partial refund on a multi-session package correct.
Cancellation itself **posts no refund ledger** — it snapshots the policy and computes the refundable amount.
The reversal is the refund path's job (§5).
+382
View File
@@ -0,0 +1,382 @@
# Server persistence
EF Core rules, money, state machines, snapshots, the scheduler, and the domain invariants that live in the
database.
> Last verified: 2026-07-30 against commit `d3ec723`.
---
## 1. EF Core basics
```csharp
// ✅ project to a DTO in the query
var dto = await _db.Orders
.AsNoTracking()
.Where(o => o.UserId == userId)
.Select(o => new OrderResult(o.Id, o.Status, o.CreatedAt))
.ToListAsync(ct);
// ❌ loads the entity graph then maps in memory — N+1 risk
var orders = await _db.Orders.Include(o => o.Lines).ToListAsync();
var dtos = _mapper.Map<List<OrderResult>>(orders);
```
- **Always `AsNoTracking()`** on a read-only query.
- **Always project with `.Select()`** in a query — never hydrate full entities just to map them, and **never
return an entity from a handler**.
- **Pagination is mandatory** on any unbounded list (`Skip`/`Take`). No unbounded `ToListAsync()`.
- Use `Include` **only** in a command handler that needs navigation properties loaded to mutate the aggregate.
- **Access the DB through `IUnitOfWork`** in Application handlers. `ApplicationDbContext` is referenced
directly only inside Infrastructure.
- **Commit once per command**, at the end: `await unitOfWork.CommitAsync(ct)`.
- **One `IEntityTypeConfiguration<T>` per entity**, in `Persistence/Configuration/<Area>Config/`.
- **Mapster maps in the handler after the query**, never in the repository. Only write a custom
`TypeAdapterConfig` when shapes genuinely diverge; register scans in `Program.cs`.
- **Never concatenate raw SQL.** EF parameterizes automatically. If you must drop to SQL, use
`FromSqlInterpolated`, never `FromSqlRaw` with user data.
**Migrations:**
```bash
dotnet ef migrations add <Name> --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api
dotnet ef database update --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api
```
### Migrations are split from boot
`dotnet run -- migrate` (the deploy-time one-shot, or a CI `dotnet ef database update`) applies migrations
plus the idempotent seeders, then exits — so multi-instance boots never race on DDL and the runtime login
needs no permanent DDL rights.
| Environment | What boot does |
| --- | --- |
| Development | Migrates + seeds (roles always; a bootstrap admin **only if** `Seed:AdminUsername`/`Seed:AdminPassword` are configured), plus the Development-only gateway, demo-world and demo-lifecycle seeders |
| Deployed | Only **checks** the schema is current (`EnsureSchemaUpToDateAsync` — fail fast on a pending migration) and seeds roles / the break-glass admin, idempotently |
A reachable SQL Server is required to start.
### Soft delete
Every soft-deletable entity **must** declare a global query filter in its configuration:
```csharp
builder.HasQueryFilter(o => !o.IsDeleted);
```
Without it, soft-deleted rows appear in every query that doesn't explicitly exclude them — a silent data
leak. **Never add `Where(x => !x.IsDeleted)` per query**; the filter makes it automatic and auditable.
**Deactivate, never hard-delete.** `user_roles.revoked_at` has the same treatment, so a revoked grant
disappears from every role read automatically.
---
## 2. Audit
| Field | Type | Set by |
| --- | --- | --- |
| `CreatedAt` / `ModifiedAt` | `DateTimeOffset` | `AuditFieldInterceptor` |
| `CreatedById` / `ModifiedById` | `int?` | `AuditFieldInterceptor`, via `ICurrentUser` |
The base type is `BaseEntity` / `IAuditableEntity` (`Baya.Domain/Common/`). Stamping happens in
`AuditFieldInterceptor` (a `SaveChangesInterceptor` in `Persistence/Interceptors/`) which reads time from
`IDateTimeProvider` and the user from `ICurrentUser`**not** in the `DbContext`, and **not** in a handler.
Audit fields cannot be backfilled retroactively, so design them in from the start.
### The append-only audit trail
Mark a compliance-sensitive entity with **`IAuditable`** and the interceptor writes an old/new diff row into
`ops.AuditLogs` **in the same transaction as the change**. Annotate any encrypted or PII property with
**`[AuditRedacted]`** so the diff records a marker, never plaintext.
`audit_logs` is **immutable — there is no update or delete path in app code.** Current `IAuditable` entities:
`PlatformConfig`, `PartnerCenter`, `Review`, and the admin-decided money and trust entities `Refund`,
`NurseClawback`, `NursePayout`, `NursePayoutBatch`, `NurseVerification`.
Retention is a two-tier sweep via `IAuditLogger.PurgeExpiredAsync`: financial and verification entity types
keep `audit_retention_financial_days` (default 2555 ≈ 7 years), everyday rows `audit_retention_general_days`
(default 730 ≈ 2 years). Oldest-first, capped, id-keyed delete, idempotent.
---
## 3. Config is rows, read at compute time
Money-critical constants — commission percentage, VAT, deadlines, EVV tolerance, cancellation tiers, job
cadences — live in `ops.PlatformConfigs` and are read via **`IPlatformConfig.GetConfig<T>`** (cached, parsed by
the row's `data_type`). **Never hardcode one.**
And the corollary, which is the part that actually matters:
> **Changing a rate must never retroactively alter an already-computed amount.** A rate is **snapshotted onto
> the booking or invoice at compute time**. Do not live-re-read a rate for an already-priced row.
The DB-backed platform facades — `IPlatformConfig`, `IHolidayCalendar`, `IAnalyticsSink`, `IAuditLogger`,
`INotificationService`, `ISupportAlertService` — live in `Persistence/Services/` and are the contracts other
domains reuse. **Don't re-query those tables directly.** `IAnalyticsSink` is fire-and-forget and never fails
the caller; `INotificationService` is always tenant-scoped to `ICurrentUser`; `support_alerts` are admin-only
and must never appear on a user-facing route.
### Self-committing facades come *after* the atomic commit
`ISupportAlertService.RaiseAsync`, `INotificationDispatcher.DispatchAsync`, `IAuditLogger.WriteAsync` and
`IPlatformConfig.SetConfig` each call `SaveChanges` on the **shared scoped** `DbContext`. Calling one
mid-build flushes your partial tracked changes. **Invoke them only after `unitOfWork.CommitAsync()`.**
In a batch loop that commits per item: load and guard **every** dependency *before* mutating tracked state, or
an early `continue` leaks a dirty entity that a later iteration's commit will flush.
---
## 4. Money
**Money has its own file: [money.md](money.md).** IRR `BIGINT` integers, the append-only balanced ledger, the
three-amount reconciliation, webhook idempotency, and the refund / BNPL / payout / invoice invariants all live
there. Read it before touching anything under `Features/{Payments,Refunds,Invoices,Bnpl,Payouts}`.
The one line to carry in your head meanwhile: **money is an integer number of IRR Rials, and there is no float
path on it anywhere.**
---
## 5. Forward-only status machines
When an entity has a lifecycle `status` with a fixed set of allowed transitions, model the machine as a
**static allowed-edges table** and route **every** write through it. Never assign `status` ad hoc.
- **Statuses are `const string` codes**, persisted as the stable snake_case string — no C# enum, no value
converter needed.
- **Edges live in a static `CanTransition(from, to)`** built from a
`Dictionary<string, IReadOnlyCollection<string>>`; a terminal state maps to an empty set.
- **The entity owns the transition.** `status` has a **private setter**, and the only mutators are cohesive
domain methods (`Accept`/`Reject`/`Cancel…`) calling a private `Transition(target)` that asserts the edge is
legal — throwing on an illegal edge, because that is a programming error, since the handler pre-checks.
Side-effect fields are set in the same method.
- **The handler pre-checks and returns a clean 409**:
`if (!entity.CanTransitionTo(target)) return OperationResult.ConflictResult(...)`. Never throw for the
expected "already moved / terminal" case.
- **A replayed transition that is already complete is an idempotent no-op**, not a failure.
Machines in the codebase: `BookingRequestTransitions`, the `bookings` machine, `BnplTransitions`,
`PayoutBatchStatus`/`PayoutStatus` transitions, `VerificationStatus`, `ReviewModerationStatus`.
### When the enum is a C# enum
Persist it as its **stable snake_case code** via `HasConversion(e => e.ToCode(), s => Parse(s))` (see
`VerificationCodes`) so the DB and the wire carry `in_review`, not `InReview`. Enum→code mapping in a
projected read happens **in memory after materialization**`.ToCode()` is not LINQ-translatable. DTOs expose
the code string.
### Guarded cross-aggregate flips
When one write must atomically change a header row's state **and** a derived boolean on a *different*
aggregate (`nurse_verifications.status``nurse_profiles.is_verified`): load **both** as tracked entities,
mutate them through a single pure domain helper (`VerificationAggregator.Finalize`), then `CommitAsync`
**once**. Never flip the derived flag from a controller, a partial write, or an out-of-band update, and never
leave an in-between state.
`NurseProfile.is_verified` has **no public setter** for this reason.
### Two SQL Server / SQLite portability rules
- **A deadline column that is compared or sorted uses `DateTime` (UTC `datetime2`), not `DateTimeOffset`**
the SQLite test provider cannot translate `DateTimeOffset` comparison or `ORDER BY`. Order lists and sweeps
by `Id` for the same reason.
- **Sequential numbers come from a counter row, not a DB sequence** (`InvoiceNumberSequences`), locked and
committed with the row it numbers, so it is portable and gap-free.
---
## 6. Uniqueness patterns
| Need | Pattern |
| --- | --- |
| A nullable column must participate in uniqueness | **The filtered-index pair.** SQL Server treats NULLs as distinct, so `district_id = NULL` needs `UNIQUE(nurse_id, city_id) WHERE district_id IS NULL` **plus** `UNIQUE(nurse_id, city_id, district_id) WHERE district_id IS NOT NULL`, both `AND deleted_at IS NULL` |
| "No two rows may share the same *set* of child rows" | **A deterministic set-hash.** `Baya.Application.Common.OptionSetHash.Compute(pairs)` sorts the `(long, long)` pairs and SHA-256s them into a stable, **order-independent** 64-char hex hash. Persist `NVARCHAR(64)` and back it with a filtered unique index as the race-safe backstop, plus a handler pre-check for a friendly 409. **Do not reuse `IFieldEncryptor.Hash`** — that is for PII equality lookups |
| One-per-parent, forever | An **unconditional** UNIQUE, not filtered on soft-delete — `nurse_payout_booking_links.booking_id` |
| One flagged row per parent | A filtered UNIQUE plus clear-then-set in one transaction — `UNIQUE(customer_id) WHERE is_primary=1 AND deleted_at IS NULL` |
| PII equality lookup | A deterministic hash column, UNIQUE, synced on `SaveChanges``users.PhoneHash`. See [identity.md](identity.md) |
A duplicate returns **409** through `OperationResult.ConflictResult``BaseController`'s 409 mapping.
---
## 7. Snapshots freeze history
A row that represents a past agreement must not change when its sources are edited later. Frozen at their
moment, and never mutated afterwards:
- `variant_snapshot_json` (via `IVariantSnapshotSerializer`) and the **encrypted** `address_snapshot_json`
- `platform_fee_rate` on the booking
- The resolved cancellation policy `code` + `refund_percentage`
- `iban_snapshot` on a payout (**encrypted**, `[AuditRedacted]`), frozen from the verified primary account
- Deadlines: `nurse_response_deadline_at` = `now + config`, `payment_deadline_at` = `now + config` — both
stored as **absolute UTC**, so a later config change cannot move them
A later edit to the source variant, address, or policy **never** mutates an existing booking.
---
## 8. The search projection
`search.NurseSearchIndices` is **one flat row per (bookable variant × covered service area)** — a fan-out
denormalization carrying the variant's category/price/unit, the covered `city_id`/`district_id`, the nurse's
gender and rating aggregates, and one visibility gate. It is a **read-only projection**, written only by
`ISearchIndexMaintainer`.
Three invariants:
- **`is_searchable = 1` only when** the nurse `is_verified = 1` **AND** `nurse_verifications.status !=
'suspended'` **AND** `is_accepting_bookings = 1` **AND** the variant `is_active = 1` — recomputed on **every**
relevant source write. An unverified, paused, suspended, or deactivated nurse or variant must **never**
surface.
- **`district_id = NULL` means whole city, both directions.** A city search matches every row in the city; a
district search matches that district's rows **plus** the NULL-district rows.
- **Incremental maintenance and a full rebuild must converge.** The index is fully re-derivable from source;
`RebuildAsync` is idempotent.
The maintainer keeps the index consistent **inline, inside the source write's own unit of work** — it shares
the request-scoped `DbContext`, so it only *stages* changes and the handler's single `CommitAsync` flushes
source and projection atomically. It reads the facts a trigger does not change from the DB, and takes the
facts it *does* change as **tracked arguments**, so it never reads a stale pre-commit value. It resurrects a
soft-deleted row on re-upsert, so each (variant × area) has exactly one live row.
`INurseSearch` (read) reads **only `is_searchable = 1`** rows. Callers depend on the interface, so a later
Elasticsearch backend is a config-selected drop-in.
**Coverage is named districts, not GPS radii.** Address lat/lng exists only for the EVV distance check; it is
never used for coverage matching.
---
## 9. Reference-data caching
Public and reference reads are cached through `ICacheService` behind a **generation-token key scheme**
`GeoCache`, `CatalogCache`, `ReviewCache`. Any admin write to that area **bumps the token**, which invalidates
the whole namespace at once rather than enumerating keys.
The catalog is **EAV/data, not code**: an admin adds a category or a pricing dimension as *rows*, never a
migration. The only closed code enum in the area is `PriceUnits`. A `ServiceOptionGroups.ServiceCategoryId =
NULL` marks a **cross-category** dimension that applies to every category — and "applicable groups" means the
category's own groups **plus** every NULL group, everywhere: public browse, required-group validation, and the
duplicate guard. All required groups must be answered; one value per dimension.
**The bookable unit is the variant, not the nurse.** Keep it a clean projectable source. The engagement total
is `price` + `price_unit` + `session_count` — never `price` alone.
---
## 10. Domain invariants that live here
The rules a change in these areas must not break. Each is enforced in code *and* by a constraint.
**Bookings and EVV**
- A `bookings` row exists **only** when the nurse accepted **and** payment was captured. So a payment is
initiated against the `accepted_awaiting_payment` *request*, and `payment_transactions.booking_id` is
**nullable**, bound only when the confirm creates or loads the booking.
- Conversion goes through the shared `BookingFactory` / `BookingConversion` helper — the card confirm and the
BNPL settle both call it rather than re-implementing the split.
- A booking request carries **no money and no `bookings` row**; accept only opens the payment window.
- **EVV is per session, and a mismatch is advisory.** Check-in computes the distance to the *frozen* booking
address against `evv_location_tolerance_meters`; a mismatch raises a `location_mismatch` support alert and
notifies **without blocking**. GPS-denied still checks in, flagged null.
- **`SetDisputeWindow` is the only payout-eligibility trigger.** Completion sets
`dispute_window_ends_at = completed_at + config(dispute_window_hours, 72)` and each completed session's
`payout_eligible_at`.
- **Cancellation refunds only un-started sessions**; the applicable policy tier is resolved by
`(actor, lead-time bucket)` and frozen onto the booking.
**Refunds, clawbacks, invoices, BNPL, payouts** → all in [money.md](money.md).
**Reviews**
- Reviews are for **completed/closed bookings only, owned by the caller, 1:1** (`UNIQUE(booking_id)` is the
backstop; the handler pre-checks for a clean 409). A cross-tenant booking is a **404**, never a leak.
- **Recompute the nurse aggregate from source on EVERY transition — not a delta.** Read
`COUNT`/`SUM(rating)` over the nurse's currently-`published` reviews *excluding* the transitioning review,
fold that review's *new* status in memory, set the guarded aggregates, and stage the reindex — all in the
**same transaction** as the status change. The exclude-and-fold avoids a stale pre-commit re-query. This is
the fix for inflated-rating-after-hide drift.
- **`pending_moderation` is never public** — list and aggregate filter to `published` at the query layer.
- `rating <= min_rating_for_support_alert` (config, default 2) raises a support alert **reliably** — after the
main commit, never silently swallowed.
**Partner centres**
- Merchant-of-record resolution follows `partner_centers` through the single resolver, **not a hardcoded
platform**: booking → nurse → `partner_center_id`, and the issuer/settlement target is the centre **only**
when it `is_merchant_of_record`, else `platform`.
- `partner_centers` (the licensing *sponsor*) **≠** `organizations` (the future *employer*, deferred).
`settlement_iban` is encrypted, `[AuditRedacted]`, and **masked to the last 4 in every read**. The centre's
`commission_rate` is separate from `platform_fee_rate`.
**Deferred by design — do not create these tables:** `bnpl_settlement_entries`, `organizations`,
`organization_nurses`, `fraud_flags`, `recurring_booking_schedules`.
---
## 11. The recurring-job scheduler
A single in-process scheduler, `Persistence/Services/Scheduling/RecurringJobSchedulerHostedService`, drives
every registered `IRecurringJob` on its own cadence — using **no new infrastructure**, so SQL Server stays the
only external dependency.
| Job | Cadence |
| --- | --- |
| `booking_request_expiry` | 1 min (const) |
| `notification_retention` | 24 h (const) — the predicate is exactly `is_read = 1 AND age > 90d`; **unread is never auto-deleted** |
| `verification_expiry_scan` | `verification_expiry_scan_cadence_hours` |
| `no_show_sweep` | `no_show_scan_cadence_hours` |
| `weekly_payout_generation` | `nurse_payout_interval_days` |
| `MoadianReconciliationJob` | 6 h |
| `audit_log_retention` | `audit_retention_scan_cadence_hours` |
- **Adding a cron = implement `IRecurringJob` + one `AddSingleton<IRecurringJob, …>()`** in
`AddPersistenceServices`. The scheduler owns the per-tick DI scope, error isolation (a throwing tick never
kills the loop), and the lock. A job says only *how often* and *what one idempotent run does*.
- **Jobs must be idempotent.** A retry — or a second instance, once the lock is Redis-backed — must never
double-pay or double-post. The DB uniques and state machines are the backstop. Each tick runs under
`IDistributedLock("scheduler:{name}")`, which is in-process today and is **the >1-instance scale-out gate**:
swap the seam to Redis to serialize ticks across nodes. A single-instance MVP needs neither Redis nor
Hangfire/Quartz.
- **Money movement stays human-approved.** The payout job schedules *generation* only — a `draft` batch,
recorded system-initiated (`InitiatedByAdminId` nullable = "no human initiator"). The irreversible `process`
step remains an explicit admin action, and `AdminPayoutsController` **neutralizes any request-supplied
`SystemInitiated` value**.
- **Admin manual triggers are overrides**, running the same idempotent commands.
- **The scheduler is dormant under the `Testing` environment**, so integration tests stay deterministic. Each
job and command is unit-tested directly.
- A time-sensitive command **self-guards** against a passed deadline via `IDateTimeProvider` rather than
trusting that a sweep has run; a sweep's re-queried `WHERE status = …` predicate **is** the concurrency guard
— a row a racing action moved is simply not reloaded.
---
## 12. Development seeders
Both are **Development-only** and idempotent.
- **`DemoWorldSeeder`** — a coherent demo marketplace on top of the reference `HasData` seeds: 3 nurses (2
verified with variants, Tehran coverage, `approved` verification, credentials and a `matched_national_id`
bank account; 1 unverified), 2 customers with patients and addresses, **2 phone-OTP admins** (a `super_admin`
plus a scoped `finance` operator, so the console is reachable through the normal login and capability gating
is demonstrable), and one cross-category required option group.
- **`DemoLifecycleSeeder`** (+ `.Money.cs` / `.Social.cs` partials) — a full lifecycle world layered on those
personas so every flow is manually testable: booking requests in every status, 8 bookings across every
reachable state, the balanced payment ledger behind each, refunds on all three forks, a paid and a draft
payout batch, moderated reviews with recomputed aggregates, tickets (including an `is_internal` note),
notifications, patient care records, a merchant-of-record partner centre, and a mid-pipeline verification
case.
Three rules they establish:
1. **Write through the real entities and commands** — the guarded transition methods, `BookingFactory`,
`GeneratePayoutBatch`/`ExecutePayoutBatch`, `LedgerPosting`, `OpenTicketCommand`. Business timestamps are
backdated explicitly. (Application grants `InternalsVisibleTo` to Persistence for this.)
2. **Drive the search projection through `ISearchIndexMaintainer.RebuildAsync`** — never hand-insert index
rows.
3. **Never guard idempotency on a Persian string.** The `ApplicationDbContext` save hook normalizes Persian
digits and ZWNJ in every stored string, so a Persian literal **never round-trips equal**. Guard on a phone
number, a code, or another natural key.
+209
View File
@@ -0,0 +1,209 @@
# Server structure
The layers, the projects, startup wiring, and the seam catalogue.
> Last verified: 2026-07-30 against commit `d3ec723` — 14 `.csproj` projects, 55 V1 controllers.
---
## 1. Clean Architecture, and the one hard boundary
**Dependencies point inward.**
```
Domain ← Application ← Infrastructure
← API
```
- **Domain** references nothing.
- **Application** references only Domain.
- **Infrastructure** and **API** implement and consume Application contracts.
- **Never** make Domain or Application reference Infrastructure or the API. This is not a preference; it is
the thing that keeps handlers unit-testable and lets a mock become a real vendor without touching a caller.
## 2. The projects
```
src/
├── Core/
│ ├── Baya.Domain Entities, value objects, status-code sets, transition tables
│ └── Baya.Application Features/ (CQRS slices) · Contracts/ (the seams) · Models/ · pipeline behaviors
├── Infrastructure/
│ ├── Baya.Infrastructure.Persistence ApplicationDbContext · ValueConversion/ · Repositories/ · Configuration/<Area>Config/ · Migrations/ · Interceptors/ · Services/ (DB-backed facades, Scheduling/, Search/, Seeding/)
│ ├── Baya.Infrastructure.Identity Jwt/ · Identity/ (Managers, Stores, PermissionManager, Seed, CurrentUser/)
│ ├── Baya.Infrastructure.CrossCutting Serilog wiring · Seams/ (mocks) · Seams/Real/ (vendor adapters) · AddCrossCuttingSeams
│ └── Baya.Infrastructure.Monitoring HealthChecks (live/ready) · OpenTelemetry
├── API/
│ ├── Baya.Web.Api Program.cs · Controllers/V1/ · appsettings*.json
│ ├── Baya.WebFramework BaseController · Filters/ · Middlewares/ · Swagger/ · Routing/ · ServiceConfiguration/
│ └── Plugins/Baya.Web.Plugins.Grpc gRPC services + .proto (User only)
├── Shared/Baya.SharedKernel Extensions + validation base
└── Tests/
├── Baya.Tests.Setup Shared test infrastructure (SQLite, NSubstitute setup, TestFieldEncryptor)
├── Baya.Test.Infrastructure.Identity xUnit identity tests
├── Baya.Test.Foundation Cross-cutting plumbing + identity handler unit tests
└── Baya.Test.Api WebApplicationFactory integration tests (in-memory SQLite, env "Testing")
```
**Domain entity folders**, one per bounded area: `User/`, `Identity/`, `Geography/`, `Catalog/`,
`Verification/`, `Search/`, `Booking/`, `Payments/`, `Refunds/`, `Invoices/`, `Bnpl/`, `Payouts/`, `Reviews/`,
`Messaging/`, `PartnerCenters/`, plus `Configuration/`, `Audit/`, `Analytics/`, `Holidays/`,
`Notifications/`, `SupportAlerts/`. `Common/` holds `BaseEntity`, `IEntity`, `ITimeModification`,
`IAuditableEntity`, `IAuditable`, `[AuditRedacted]`.
**Application feature areas** mirror them: `Identity`, `Geography`, `ServiceAreas`, `Addresses`, `Catalog`,
`Variants`, `Verification`, `Search`, `Booking` (singular — pre-payment requests), `Bookings` (plural — the
post-payment engine), `Payments`, `Refunds`, `Invoices`, `Bnpl`, `Payouts`, `Reviews`, `PatientCareRecords`,
`Messaging`, `PartnerCenters`, `Configuration`, `Audit`, `Analytics`, `Holidays`, `Notifications`,
`SupportAlerts`, `System`.
> `Booking` (singular) and `Bookings` (plural) are **different areas, not a rename.** A booking request is
> the money-free pre-payment intent; a booking exists only after capture. The entity type `Booking` is
> aliased where the two namespaces collide. The same split is load-bearing in the client's
> `bookingRequests`/`bookings` domains and in Persian copy («درخواست رزرو» vs «رزرو»).
**Database schemas**, one per area, mirroring how Identity uses `usr`: `usr`, `ops`, `geo`, `catalog`,
`verif`, `search`, `booking`, `payments`, `payouts`, `reviews`, `messaging`, `partner`.
**Keeping this current is mandatory.** When a change adds, removes, or renames a project, a layer, or a major
folder, or changes a cross-layer dependency, update the **Project map** in
[server/CLAUDE.md](../../../server/CLAUDE.md) and this section in the **same** change. A map is only canonical
if it stays accurate.
---
## 3. The seams
The Application layer defines every mock-able external dependency as an interface. Implementations live in
Infrastructure and are chosen by **registration**, never by a branch in a handler.
| Contracts folder | Seams |
| --- | --- |
| `Contracts/Common/` | `IDateTimeProvider`, `IFieldEncryptor`, `ICacheService`, `IObjectStorage`, `INotificationDispatcher`, `IGeocoder`, `IShahkarVerifier`, `IIdentityKycProvider`, `ICredentialVerifier`, `ILicenseVerificationService`, `IBankAccountOwnershipVerifier`, `IVariantSnapshotSerializer`, `IPaymentCaptureSimulator`, `ISmsSender`, `ICurrentUser` |
| `Contracts/Payments/` | `IPaymentProvider`, `ISettlementSplitProvider`, `IWebhookVerifier`, `IDistributedLock`, `IBnplProvider`, `IBnplProviderResolver`, `ICurrencyNormalizer`, `IBankTransferProvider`, `IMoadianClient`, `INursePayoutStatus` |
| `Contracts/Search/` | `INurseSearch` (read), `ISearchIndexMaintainer` (write) |
| `Contracts/Reviews/` | `IReviewModerationService` (the AI pre-screen) |
| `Contracts/Persistence/` | The per-domain repositories, all exposed on `IUnitOfWork` |
| Platform facades | `IPlatformConfig`, `IHolidayCalendar`, `IAnalyticsSink`, `IAuditLogger`, `INotificationService`, `ISupportAlertService` |
### Where each implementation lives
| Kind | Location | Registered by |
| --- | --- | --- |
| Mocks | `CrossCutting/Seams/` | `AddCrossCuttingSeams(configuration)` — config section `Seams` |
| Real vendor adapters | `CrossCutting/Seams/Real/` | the same, selected per rail |
| Platform facades (DB-backed) | `Persistence/Services/` | `AddPersistenceServices`**not** CrossCutting, because they are DB-backed |
| `ICurrentUser` | `Infrastructure.Identity` | `RegisterIdentityServices` |
Audit fields are stamped by `AuditFieldInterceptor` (Persistence), never in a handler.
### Real rails are config-selected, and the default falls closed
Every vendor rail has a real HTTP adapter, selected by a per-rail **`Seams:*:Provider`** key in
`AddCrossCuttingSeams`. **The default is the mock, and a typo falls closed to the mock** — so an unconfigured
environment behaves exactly as before, and a misconfigured one does not silently reach a live vendor.
Real adapters use `HttpClient` (typed via `IHttpClientFactory`), `System.Text.Json`, and BCL crypto —
**no new NuGet packages**. Credentials come from `Seams:*`.
| Rail | Selector | Adapter |
| --- | --- | --- |
| SMS/OTP | `Sms:Provider=kavenegar` | `KavenegarSmsSender`**launch-critical** |
| SMS/OTP (demo) | `Sms:Provider=telegram` | `TelegramSmsSender` — a **broadcast, not a gateway** |
| Shahkar / KYC / IBAN ownership | `{Shahkar,IdentityKyc,BankOwnership}:Provider=finnotech` | `Finnotech*`, shared `Seams:Finnotech` creds |
| Geocoding | `Geocoding:Provider=neshan` | `NeshanGeocoder` |
| Object storage | `ObjectStorage:Provider=s3` | `S3ObjectStorage` — MinIO/S3/ArvanCloud via manual AWS SigV4; presigned GET is the real signed-URL contract |
| PSP | `Payments:Provider=zarinpal` | `ZarinPalPaymentProvider` + `HmacWebhookVerifier` + `ProviderSettlementSplitProvider` |
| BNPL | `Bnpl:Provider=real` | `SnappPayBnplProvider` / `DigipayBnplProvider` + `ConfiguredBnplProviderResolver`. **`balinyaar` = in-house, resolving to the net-of-fee model with no external API** |
| Bank transfer | `BankTransfer:Provider=jibit` | `JibitBankTransferProvider` — an **async rail**: it accepts as `submitted`, and the HMAC-verified reconciliation callback `POST webhooks/payouts/{provider}` flips `submitted → paid/failed` |
| e-invoicing | `Moadian:Provider=moadian` | `MoadianClient` + a 6-hour `MoadianReconciliationJob` walking `pending/submitted → registered` |
Three deliberate exceptions:
- **`IPaymentCaptureSimulator` is out of the production registration.** Production gets the fail-closed
`DisabledPaymentCaptureSimulator`; Dev and Testing re-register the succeeding mock. The `bookings/convert`
path is a Dev/Testing affordance — production converts through the b10 webhook confirm.
- **`ICredentialVerifier` / `ILicenseVerificationService` stay mock**, because **manual MoH / INO / eNamad
review is the intended MVP** — there is no public B2B API. Don't "finish" them.
- **There is no telephony/VoIP seam.** The emergency call is an out-of-platform `tel:` link by design.
`ICurrencyNormalizer` is already config-driven with a real implementation.
---
## 4. Startup wiring
Service registration is composed from per-layer extension methods, each in that project's
`ServiceConfiguration/` folder. **`Program.cs` is an orchestrator: extension-method calls only, no logic and
no inline registration.**
```
builder.ValidateRequiredSecrets() // fail fast on a missing/placeholder DB or crypto secret
ConfigureHealthChecks() · SetupOpenTelemetry()
AddApplicationServices() // Mediator + pipeline behaviors (Logging → Metrics → Validate)
RegisterIdentityServices(…, requireHttpsMetadata)
AddPersistenceServices(...) // DbContext (+ AuditFieldInterceptor), UnitOfWork, repositories,
// the IRecurringJob crons + RecurringJobSchedulerHostedService
AddCrossCuttingSeams(config)
AddWebFrameworkServices() // API versioning + snake_case routing
AddCorsPolicies(config) // from Cors:AllowedOrigins
AddForwardedHeadersConfiguration(config) // trust ForwardedHeaders:KnownProxies/KnownNetworks
AddRateLimitingPolicies() // per-resolved-IP global + named (otp/auth/sensitive/webhook)
AddSwagger("v1", "v1.1") · RegisterValidatorsAsServices() · AddMapster()
ConfigureGrpcPluginServices(builder.Environment) // gRPC reflection: Development only
// Development-only: AddDevelopmentOtpCapture() decorates ISmsSender to capture each OTP in memory for
// GET /api/v1/dev/last_otp/{phone}. Never wired outside Development, and only for a capture-safe
// Seams:Sms:Provider (mock/unset, or the Development-only telegram relay). Kavenegar disables it.
```
**When you add infrastructure, expose it as an extension method and call it from `Program.cs`.**
### Middleware order, and why each position matters
```
forwarded headers → exception handler → Swagger → routing → CORS → rate limiter
→ authentication → authorization → controllers → metrics → health checks → gRPC
```
- **`UseForwardedHeaders()` is first**, so the resolved client IP (`X-Forwarded-For` from a trusted proxy) is
in place before the rate limiter partitions on it. Behind a proxy without it, the limiter sees one IP and
throttles everyone together.
- **`UseCors(...)` sits after `UseRouting()` and before `UseRateLimiter()`**, so a pre-flight `OPTIONS` is
answered before the limiter and auth run.
- **`UseRateLimiter()` is before `UseAuthentication()`**, so over-limit auth and OTP attempts are rejected
with 429 before hitting the auth stack.
### Fail-fast on secrets
`StartupSecretsGuard` (via `ValidateRequiredSecrets()`) refuses to start if a load-bearing secret is missing
or left at its committed `SET_VIA_USER_SECRETS_OR_ENV` placeholder: the DB connection strings always, plus the
JWE and field-encryption keys in deployed environments.
> The placeholder's *name* is stale — `dotnet user-secrets` is **not used** and the `<UserSecretsId>` was
> removed, so that store is never read. The behaviour is correct; the string is a legacy name. See
> [code-quality.md](../shared/code-quality.md) §6 for where config actually lives.
---
## 5. Observability and health
One **OpenTelemetry** stack (`Baya.Infrastructure.Monitoring`, `SetupOpenTelemetry`):
- **Metrics** — runtime + ASP.NET Core + the `mediator_meter` histogram, scraped at `/metrics` via the OTel
Prometheus exporter. (The duplicate prometheus-net stack was removed.)
- **Tracing** — ASP.NET Core + EF Core, sharing `service.name = Baya.Web.Api`.
- **OTLP export (traces + metrics) is opt-in** — wired only when `OpenTelemetry:Otlp:Endpoint` is set, so an
MVP running Prometheus alone is unchanged.
- **`ApiResult.RequestId` IS the W3C trace id** (`Activity.Current.TraceId`, `Activity.DefaultIdFormat = W3C`),
so a support ticket maps 1:1 to a trace. Don't replace it with a random correlation id.
**Health checks are split**: `/healthz/live` (process only, dependency-free — for a liveness probe),
`/healthz/ready` (app DB + `logDb` in deployed environments + an `IObjectStorage` write probe), and
`/HealthCheck` (the aggregate, kept for compatibility).
**Logs**: deployed environments write Information+ to `Baya_Logs`, with framework categories held at Warning.
**No PII, no secrets** — the mock SMS sender never logs the OTP code, and clinical text and IBANs are
encrypted or masked. Set the OTLP collector to ship logs off-box; the SQL sink is the deployed default.
**gRPC reflection is Development-only** (`GrpcPluginStartup` gates it on `IsDevelopment`); the plugin shares
the mixed-protocol Kestrel listener.
+159
View File
@@ -0,0 +1,159 @@
# Code quality
The four rules that apply identically to both projects: no dead code, comment the *why*, no starter
scaffolding, and a mock is only a mock behind a seam.
> Last verified: 2026-07-30 against commit `d3ec723`.
---
## 1. No dead code
Unused variables, imports/usings, parameters, and private members are removed — not left behind, not
commented out, and **not suppressed**.
| Project | How it surfaces | Gate |
| --- | --- | --- |
| Client | `@typescript-eslint/no-unused-vars`, raised from eslint-config-next's default `warn` to **`error`** in `eslint.config.mjs` | Dead code **fails `npm run check`** |
| Server | `CS0168` (declared, never used), `CS0219` (assigned, never read), `CS0169` (private field never used), `IDE0005` (unnecessary `using`) | The gate is **zero new warnings**, so dead code is a gate failure |
**Delete it — don't silence it.** No `#pragma warning disable`, no throwaway discards, no `_ =`
assignments to quiet an analyzer, no file-wide ESLint disable.
Two sanctioned opt-outs, both narrow:
- **Client:** a deliberately-unused binding is prefixed with `_``_event`, `catch (_err)`.
- **Server:** a parameter that must exist to satisfy an interface or delegate signature but is genuinely
unused stays, named conventionally, with a one-line `// why` only if the reason isn't obvious.
When a lint disable is genuinely correct — a deliberate browser-only read after mount that trips
`react-hooks/set-state-in-effect` is the real example in this codebase — use a scoped
`// eslint-disable-next-line <rule>` with a one-line reason on the line above. Never a file-wide disable,
and never in preference to fixing the code.
---
## 2. Comment the *why*, never the *what*
Code that needs a comment to be understood usually needs a **better name** instead. Reach for the name
first, then a small helper, then a comment.
**Don't** write a comment that restates what the code already says:
```csharp
// ❌ restates the obvious
// increment the retry counter
retryCount++;
```
```tsx
// ❌ restates the obvious
// set the access token
setClientCookie(COOKIE_NAMES.ACCESS_TOKEN, token);
```
No XML-doc or JSDoc that merely echoes a function's name either.
**Do** add a tight comment where a non-obvious decision, constraint, business rule, workaround, ordering
or security requirement, or deliberate deviation is *not* evident from the code. Explain the reasoning,
not the mechanics:
```csharp
// ✅ captures a constraint the code can't express on its own
// Payment gateway rejects amounts above 50M IRR per call; split larger settlements upstream.
if (amount > MaxPerCallRial) …
```
The models to follow in this codebase:
| File | What its comment earns |
| --- | --- |
| `client/src/app/[locale]/layout.tsx` | Why `<html>` lives in the `[locale]` layout and not above it |
| `client/src/lib/auth/token.ts` | Why the JWT `exp` check is UX-only and never a security boundary |
| `client/src/layout/config.ts` | Why the two chrome-bar heights are measured rather than guessed, and must stay in sync with the bars |
| `client/middleware.ts` | Why the matcher lists bare `'/'` explicitly alongside the catch-all regex |
Delete comments that no longer match the code. A wrong comment costs more than no comment.
---
## 3. Don't reintroduce starter scaffolding
Both projects were derived from open-source starters. Their branding, demo/showcase pages, and
`_TITLE_`/`_DESCRIPTION_` placeholders were **intentionally removed**. Don't add them back — not as a
convenience, not while editing docs, not as an example.
Specifically:
- No placeholder page, showcase route, or "example component" gallery.
- No `_TITLE_` / `_DESCRIPTION_` / lorem-ipsum copy anywhere, including in message files.
- No starter README boilerplate reinstated into a project README.
- `PlaceholderScreen` exists for a genuinely not-yet-built screen and must not be reachable from a shell's
navigation. `/admin/notifications` is the current example: it is a placeholder, and it is deliberately
absent from `AdminLayout`'s nav for that reason.
---
## 4. A mock is only a mock behind a seam
Some integrations are intentionally out of scope and must be **mocked, not invented**: real PSP and BNPL
connections, the Shahkar / MoH / INO / criminal-record vendors, MinIO/S3 credentials, the سامانه مودیان
enrollment. Reaching one is not a blocker.
The only sanctioned form of "not real yet" is:
1. **An interface.** Server: an interface in `Application/Contracts/`, implemented twice, selected by
configuration in `AddCrossCuttingSeams` — and **the default is the mock, with a typo falling closed to
the mock**. Client: the domain's `Api` interface in `services/{domain}/types.ts`, implemented by
`clientApi.ts` and `mockApi.ts`, selected in `apis/index.ts` by `USE_{DOMAIN}_MOCK`.
2. **Selection by registration, never by branching.** No `if (mock)` inside a handler, hook, or component.
Swapping a mock for the real thing is a one-line registration change and touches no caller.
3. **A record**, in `docs/status/`: the seam (interface name + file), what is faked, why, the config keys
it reads, and **step-by-step how to make it real** — which provider, which settings, which methods,
what to test.
An unrecorded mock is a defect, because the next agent cannot tell a deliberate stand-in from a bug.
Two mocks in this repo are **deliberate MVP endpoints, not stand-ins waiting for a vendor**:
`ICredentialVerifier` / `ILicenseVerificationService` stay mock because manual MoH / INO / eNamad review
*is* the intended MVP — there is no public B2B API. Don't "finish" them.
---
## 5. Scale and cost are part of correctness
Every decision should consider what it costs at scale, not only whether it works once:
**Server** — indexing, pagination on every unbounded list, caching read-heavy and reference data behind
the cache seam, idempotency and locks on the money path, the DB constraint as the authoritative backstop
behind every friendly pre-check.
**Client** — query caching with a deliberate `staleTime` so you never refetch what you already hold,
invalidation on mutation, re-render cost (stable references, `select` to subscribe to a slice, state
colocated low), and bundle size.
And in both: the seam that lets a mock become real without touching a caller.
---
## 6. Configuration lives in files, not a secret store
`dotnet user-secrets` is **not used** in this repo, and the `<UserSecretsId>` was removed from
`Baya.Web.Api.csproj`, so that store **is not read at all**. Any instruction telling you to set a value
with `dotnet user-secrets` is stale.
| Where config lives | What |
| --- | --- |
| `server/src/API/Baya.Web.Api/appsettings.*.json` | Server config, including dev crypto keys |
| `client/.env.development` / `.env.production` | Client config |
| root `docker-compose.yml` | The deployment's container-specific overrides |
This is a deliberate pre-launch trade for a demo deployment, which means **the repo contains live
credentials**. Before onboarding real users they must be rotated and the secret half moved out of git —
see [`DEPLOY.md`](../../../DEPLOY.md) "Going to Production". Never hardcode a secret in code either way:
keys, connection strings, and tokens come from configuration bound to typed settings, never a literal in
a handler, service, or component.
One value is load-bearing and must never change: `Seams:FieldEncryption:Key` / `:HashKey` decrypt all
existing PII and derive the phone-lookup hash. Changing them makes every PII read throw and every phone
lookup miss.
+131
View File
@@ -0,0 +1,131 @@
# Git and the quality gates
What must pass before work is done, and what the repo refuses to let you commit.
> Last verified: 2026-07-30 against commit `d3ec723`.
---
## 1. The gates
Each project is built, linted, and tested **on its own**. There is no root-level build, package, or
solution, so there is no single command that gates the repo. Run the gate for the side you edited.
### Client — `cd client`
| Command | What it runs |
| --- | --- |
| `npm run check` | **The gate.** `type``lint``lint:copy`, in that order |
| `npm run type` | `tsc --noEmit` (`strict` on) |
| `npm run lint` | `eslint .` (flat config) |
| `npm run lint:copy` | `node scripts/check-copy.mjs` — greps `fa.json` for banned Persian orthography variants |
| `npm run test:ci` | `jest --ci`**also required** when you touched a component with a co-located `*.test.tsx` |
`npm run check` must be green. `en.json` and `fa.json` must be in sync.
> `lint:copy` is part of `check`, not a separate step you can forget. It is what stops a copy regression
> — a hamza-less «تایید», a space in the brand name — from needing to be re-discovered by a human. The
> rules it enforces are in [client/i18n.md](../client/i18n.md).
### Server — `cd server`
| Command | What it runs |
| --- | --- |
| `dotnet build Baya.sln` | **Zero new warnings.** Unused usings, locals, parameters, private fields or members count as failures — delete them, don't suppress them |
| `dotnet test Baya.sln` | All tests pass, including the ones your change adds |
A reachable SQL Server is required to run the API (not to build or unit-test it).
### Both
Read your own diff as if you were reviewing the PR: **would a senior engineer approve it without
comment?** A change that passes the mechanical gate and fails that question is not done.
---
## 2. What "done" means
A change is done when all of these hold:
- [ ] The full scope is implemented. No `// TODO: implement later`, no stub that returns fake data.
Anything not real is behind a **DI-registered seam** and recorded (see [code-quality.md](code-quality.md)).
- [ ] It follows the rules for that project — the relevant `CLAUDE.md` plus the one reference file for the
area you touched.
- [ ] No dead code. Comments explain *why*, not *what*.
- [ ] The project's own gate above is green.
- [ ] If the structure changed, the matching **architecture section** is updated in the same change
(see [documentation.md](../documentation.md) §3).
- [ ] If a business rule was discovered or decided, `product/` reflects it — recorded, not invented.
- [ ] If a new reusable pattern or seam landed, the reference file for that area names it, so the next
change reuses it instead of reinventing it.
A change that doesn't pass its own gate is **not done**, regardless of how complete the code looks.
---
## 3. The pre-commit secret scan
Repo-managed hooks live in `.githooks/` (in version control, unlike `.git/hooks`). **Enable them once per
clone:**
```bash
git config core.hooksPath .githooks
```
`pre-commit` is a fast, dependency-free backstop against a credential leaking into a file that shouldn't
hold one. It scans **only staged additions**, so it is quick. It rejects a commit that stages:
- the retired hardcoded admin password `qw123321`, anywhere;
- private-key material or an AWS access-key id, anywhere;
- the deployment's SQL Server host `87.107.152.16` **outside the declared config files**;
- a **real** connection-string password in any `appsettings*.json` **outside the declared config files**
(elsewhere only the `SET_VIA_USER_SECRETS_OR_ENV` placeholder is allowed).
### The declared-config allow-list
The pre-launch demo deployment configures itself from committed files rather than a secret store (see
[`DEPLOY.md`](../../../DEPLOY.md)), so a short allow-list is exempt from the last two checks:
`appsettings.Development.json` · `docker-compose.yml` · `telegram-otp-bot/.env.example` · `DEPLOY.md`
It is maintained in the `declared_config` function in the hook, and it is **the honest record of where
this repo's secrets are**. **Shrink it, never grow it.** Once real users exist, those values must be
rotated and moved out of git.
### Limits
This is the local first line of defence, **not** a replacement for a full scanner (gitleaks, trufflehog)
in CI. Bypass a false positive with `git commit --no-verify` — sparingly, and only when you are certain
the flagged line is not a secret.
---
## 4. Branches and commits
`main` is the default branch and the base for PRs.
- **Commit or push only when asked.** If you are on `main` and about to commit, branch first.
- One coherent change per commit. The repo's history reads as a sequence of completed units of work
(`ui phase 11`, `remove user-secrets approach & prepare a pilot deploy`) — keep that.
- Never skip hooks (`--no-verify`) or bypass signing unless explicitly asked. If a hook fails,
fix the underlying issue.
- Prefer a new commit over amending an existing one.
- Before a destructive git operation (`reset --hard`, `push --force`, `checkout --`), consider whether a
safer route reaches the same place.
---
## 5. Known pre-existing warnings
These are expected and **must not be "fixed"** unless a task says so — a change that touches them is
scope creep, and one that silences them is worse.
| Warning | Project | Note |
| --- | --- | --- |
| `NU1510` on `Microsoft.Extensions.Logging.Debug` | `Baya.Web.Api` | Redundant transitive reference, harmless |
| `NETSDK1057` (preview SDK) | all server projects | The .NET 10 SDK is preview on this machine |
On the client, `import/no-cycle` is disabled in `eslint.config.mjs` (its TypeScript resolver has an
interface mismatch with this toolchain), and **ESLint is pinned to 9** — ESLint 10 crashes against this
Next 16 toolchain with `scopeManager.addGlobals is not a function`. See
[client/testing.md](../client/testing.md).
+94
View File
@@ -0,0 +1,94 @@
# Naming
The names that are load-bearing across both projects, and the ones that are only conventions.
> Last verified: 2026-07-30 against commit `d3ec723`.
---
## The two names, and why there are two
The product and brand are **Balinyaar** (Persian: «بالین‌یار»). The server's code namespace is **`Baya*`**
— a legacy prefix from before the name settled.
| Layer | Name | Rule |
| --- | --- | --- |
| Server namespaces, projects, solution | `Baya.*` / `Baya.sln` | Keep it. **Do not rename without explicit instruction** — it touches 14 `.csproj` files, every namespace, and the solution. |
| Client package | `balinyaar-client` | — |
| Client import alias | `@/*``client/src/*` | Defined in `client/tsconfig.json`. Use it; don't write deep relative paths across folders. |
| User-facing copy | «بالین‌یار» / "Balinyaar" | Never `Baya`. See [client/i18n.md](../client/i18n.md) for the ZWNJ rule — it is linted. |
So `Baya.Application` is correct in C# and wrong in a UI string, and «بالین‌یار» is correct in a UI string
and would be wrong as a namespace. That is the whole split.
---
## Agent-facing docs
`CLAUDE.md` is the single source of truth at every level of the repo. `AGENTS.md` files exist only so the
convention is discoverable under that name too — they are **thin pointers**, never content. If you find
yourself writing a rule into an `AGENTS.md`, it belongs in the `CLAUDE.md` beside it.
Three `AGENTS.md` files exist: repo root, `client/`, `server/`.
---
## Server naming
Full C# conventions in [server/conventions.md](../server/conventions.md). The names that matter beyond
style:
| Kind | Convention | Example |
| --- | --- | --- |
| Command | `{Verb}{Noun}Command` | `CreateOrderCommand` |
| Query | `{Verb}{Noun}Query` | `GetUserOrdersQuery` |
| Handler | `{RequestName}Handler` | `CreateOrderCommandHandler` |
| Result DTO | `{RequestName}Result` | `CreateOrderCommandResult` |
| Feature folder | `Features/<Area>/{Commands\|Queries}/<VerbNoun>/` | `Features/Payments/Commands/InitiatePayment/` |
| EF config folder | `Persistence/Configuration/<Area>Config/` | `PaymentsConfig/` |
| Seam interface | `I{Capability}` in `Application/Contracts/` | `IBankTransferProvider` |
| Real adapter | `{Vendor}{Capability}` in `Seams/Real/` | `JibitBankTransferProvider` |
| Mock adapter | `Mock{Capability}` in `Seams/` | `MockBankTransferProvider` |
**Controller and action names become URLs.** All URL segments are `snake_case`, produced automatically
from `[controller]`/`[action]` tokens by `SnakeCaseParameterTransformer`. So `GetBySlug` becomes
`get_by_slug`. If a method name doesn't read cleanly as a URL, **rename the method** — never hardcode the
route string, which bypasses the transformer.
One type per file, and the file name matches the type name exactly.
---
## Client naming
| Kind | Convention | Example |
| --- | --- | --- |
| Shared component | `src/components/<Name>/<Name>.tsx` + `index.tsx` barrel | `components/TrustBadge/TrustBadge.tsx` |
| Its test | co-located `<Name>.test.tsx` | `components/TrustBadge/TrustBadge.test.tsx` |
| Page body | `<PageName>Screen.tsx`, co-located with `page.tsx` | `HomeScreen.tsx`, `SearchScreen.tsx` |
| Private (non-route) folder under `app/` | `_`-prefixed | `_chrome/`, `_hub/` |
| Route group (adds no URL segment) | parenthesised | `(customer)`, `(public-routes)` |
| Service domain | `src/services/{domain}/` | `services/bookingRequests/` |
| Query hook | one per file, `hooks/use{Action}.ts` | `hooks/useBookingDetail.ts` |
| Icon registry key | **lowercase**, semantic | `icon="verification"`, not `icon="ShieldCheck"` |
| i18n namespace | a top-level key in both message files | `booking`, `payouts` |
| Constant | `SCREAMING_SNAKE` in a `constants.ts` | `APP_FRAME_MAX_WIDTH` |
`bookings` and `bookingRequests` are **siblings, not a rename** — a booking request is the money-free
pre-payment intent, a booking exists only after capture. The same distinction is load-bearing in Persian
copy («درخواست رزرو» vs «رزرو») and in the server's singular `Booking` vs plural `Bookings` feature areas.
---
## Directory conventions that carry meaning
| Path | Meaning |
| --- | --- |
| `client/src/components/common/` | Foundational primitives, imported via `@/components` |
| `client/src/components/<domain>/` | Domain composites (`booking/`, `messaging/`, `admin/`, `geography/`, `notifications/`, `settings/`, `auth/`) |
| `client/src/services/{domain}/apis/` | The seam: `clientApi.ts` (real), `mockApi.ts`, `serverApi.ts`, `index.ts` (selects) |
| `server/src/Core/` | Domain + Application — no outward dependencies |
| `server/src/Infrastructure/` | Implementations of Application contracts |
| `server/src/API/` | Controllers, framework, plugins |
| `dev/` | The finished build-plan chain. History, not a project — nothing to build in it |
| `product/` | Business truth. Markdown canonical, HTML generated |