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
+97 -23
View File
@@ -12,9 +12,22 @@ description: >-
# Balinyaar Frontend Designer
Build UI that looks like Balinyaar and behaves correctly in both locales and both
color schemes on the first try. This skill is the design contract; the engineering
contract (providers, fetch, cookies, routing) lives in [client/CLAUDE.md](../../../client/CLAUDE.md) — read it
before touching layout/provider/data code, **don't restate it**, and never violate it.
color schemes on the first try.
**Precedence.** This skill is the **design** contract — brand, tone, and the visual
decisions. The **engineering** contract is [client/CLAUDE.md](../../../client/CLAUDE.md)
(hard rules) plus [docs/rules/client/](../../../docs/rules/client/) (one reference file
per area). Where the two overlap — tokens, typography, the component library, shells,
icons — **`docs/rules/client/` is authoritative and this skill defers to it.** Read the
relevant one before touching layout, provider, or data code; don't restate it here, and
never violate it.
| For | Read |
|-----|------|
| Tokens, palette, dark mode, RTL, fonts, motion | [docs/rules/client/theme.md](../../../docs/rules/client/theme.md) |
| The `App*` library, shells, navigation, icons | [docs/rules/client/components.md](../../../docs/rules/client/components.md) |
| Copy and Persian orthography | [docs/rules/client/i18n.md](../../../docs/rules/client/i18n.md) |
| Forms | [docs/rules/client/forms.md](../../../docs/rules/client/forms.md) |
**Stack:** Next.js 16 (App Router, Turbopack) · React 19 · MUI v9 (`@mui/material`) ·
Emotion (RTL via `stylis-plugin-rtl`) · next-intl v4 · notistack. Everything below
@@ -106,6 +119,16 @@ these; they're define-only in CSS):
- **Money emphasis** — `--bal-money-emphasis`, an AA-contrast-safe color for emphasized
money text. `--bal-secondary` (terracotta) fails AA contrast at small sizes on light
backgrounds — never use it for money text, use this token instead.
- **Soft fills** — every brand and semantic color has a `-soft` variant
(`--bal-primary-soft`, `--bal-warning-soft`, …) for a tinted background. Reach for it
before hand-mixing an alpha over a surface.
- **Avatar** — `--bal-avatar-1..6` (+ each `-contrast`), the six warm pairs
`InitialsAvatar` picks from by a deterministic name hash. Add a seventh to **both**
scheme blocks or don't add one.
- **Map** — `--bal-pin-shadow`, the address-picker pin.
Full catalogue, with what each group backs:
[docs/rules/client/theme.md](../../../docs/rules/client/theme.md) §2.
---
@@ -149,15 +172,23 @@ wrapper over the bare MUI component — the wrappers carry the house defaults.
| `AppIconButton` | icon-only actions | takes an icon `name`, `title`, `to`/`onClick` |
| `AppIcon` | any icon | `icon="home"` by registered name (§6); `size`, `color` props |
| `AppLink` | internal/external links | locale-aware Next navigation; default underline `hover` |
| `AppAlert` | inline alerts | default `severity="error"`, `variant="filled"` |
| `AppImage` | images | wrapper around next/image conventions |
| `AppAlert` | inline alerts | defaults to a calm `severity="info"`, `variant="standard"` — pass `severity="error"` explicitly when it really is an error |
| `AppLoading` | loading state | default circular, `primary`, `3rem` |
| `ErrorBoundary` | wrapping fault-prone subtrees | already wraps page content in the shell |
| `ProfileSummary` | the identity card in chrome | avatar+name+masked phone+role label+optional `TrustBadge`; vertical or `compact` horizontal chip |
Defaults for these live in `src/components/config.ts` (`APP_BUTTON_VARIANT`,
`APP_ICON_SIZE = 24`, `CONTENT_MAX_WIDTH = 800`, `CONTENT_MIN_WIDTH = 320`, alert/link/
loading defaults). Change a default there, not per-call-site.
`APP_ICON_SIZE = 24`, `APP_ICON_STROKE_WIDTH = 1.75`, `APP_BUTTON_ICON_SIZE = 20`,
`CONTENT_MAX_WIDTH = 480`, `CONTENT_MIN_WIDTH = 320`, alert/link/loading defaults).
Change a default there, not per-call-site.
Beyond the `App*` wrappers there is a **state kit**`EmptyState`, `ErrorState`,
`QueryStateGate`, `PageHeader`, `ConfirmDialog`, `SurfaceCard`, `AccentCard`, `Money`,
`StatusTimeline`, the `Jalali*` date inputs, `StickyActionBar`, `Pager`, `NavHubList`,
`InitialsAvatar`, `FormDialogShell` — with **one pattern per state**. Never hand-roll a
dashed-border "nothing here" block or a per-screen pager; and **an error state is never an
empty state.** Catalogue in
[docs/rules/client/components.md](../../../docs/rules/client/components.md).
For layout/spacing use MUI primitives directly: `Box`, `Stack`, `Container`, `Grid`,
`Paper`, `Card`. Use the `spacing`/`sx` system (theme spacing unit = 8px) — never inline
@@ -166,7 +197,18 @@ pixel margins for rhythm.
**New shared component?** Put it in `src/components/<Name>/<Name>.tsx` with an
`index.tsx` barrel, follow the `App*` prop-spreading + JSDoc style of `AppButton.tsx`,
and add a co-located `.test.tsx` (mandatory for anything imported in >1 place — see
CLAUDE.md "Unit Testing"; wrap with `<ThemeProvider>`, never mock MUI).
[docs/rules/client/testing.md](../../../docs/rules/client/testing.md); wrap with
`<ThemeProvider>`, never mock MUI). If it goes at the top of the `@/components/common`
barrel, prefer **caller-owned copy** (required `title`/`body`/`retryLabel` string props)
over calling `useTranslations` inside it — `next-intl` is ESM-only and poisons every test
that transitively imports the barrel. `ErrorBoundary`/`ErrorState` are the model;
[components.md](../../../docs/rules/client/components.md) has the why.
**Any form with more than one field is a react-hook-form form**, bound through the
`@/components/common/form` wrappers (`RhfTextField`, `RhfChipSelect`,
`RhfJalaliDateField`, `RhfControlGroup`) and grouped into `FormSection`s. A single-field
control is state, not a form. Full pattern:
[docs/rules/client/forms.md](../../../docs/rules/client/forms.md).
---
@@ -178,12 +220,19 @@ backdrop, at **every viewport**. A wider window gets more canvas, never a wider
design one set of states, 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 three structural guarantees, and is the only place any of them is
solved: the width cap; the **frame, not the document, owns the scroll** (header /
`<main>` / footer are flex siblings, so a top bar is `position: static` and no page
needs a top offset); and `overflowX: hidden` + `minWidth: 0`, so an over-wide child
clips rather than dragging the app sideways. Genuinely wide content (a data table)
scrolls **inside its own container** — see `AdminDataTable`'s `TableContainer`.
- `AppFrame` owns four structural guarantees, and is the only place any of them is
solved: the width cap; the **frame, not the document, owns the scroll** (a single
scrolling `<main>` fills the frame, with the bars pinned **`position: absolute`** over
it — never `fixed`, which would break out of the centered column — and `<main>`
reserving each bar's exact height as padding, so no page needs a top offset);
`overflowX: hidden` + `minWidth: 0`, so an over-wide child clips rather than dragging
the app sideways; and, above `sm`, the column **floats** as a rounded shadowed card
with a gutter all round (edge-to-edge on a phone). Genuinely wide content (a data
table) scrolls **inside its own container** — see `AdminDataTable`'s `TableContainer`.
- `AppFrame` also publishes **`--bal-chrome-top` / `--bal-chrome-bottom`** on the scroll
container (already including `env(safe-area-inset-*)`, and `0px` in a chrome-free
shell), so any `position: sticky` element can clear the bars without importing a
constant. `StickyActionBar` is the reference consumer — don't recompute an offset.
- **One authenticated shell**: `MobileShell` = `AppFrame` + a contextual `TopBar` (brand
lockup on a tab's own path, back chevron + `useRouteTitle()` on anything deeper) +
`BottomBar` + `ErrorBoundary` + `RouteFadeIn`. The four actor layouts (`CustomerLayout`
@@ -191,9 +240,17 @@ a shell, restores a sidebar, or lays a screen out in columns.
only `tabs` and `headerActions`. Add a destination by adding a tab or a hub row — never
by forking the shell.
- **The chrome is light, not structural.** The top bar is *not* an `AppBar` — no filled
surface, no rule, no elevation; it sits on the page background. The bottom bar *floats*:
inset from the frame edges, fully rounded (`--bal-radius-pill`), elevated. Neither should
read as a slab sealing off an edge of a 480px screen.
surface, no rule, no elevation of its own; `AppFrame` wraps both bars in the shared
`FLOATING_BAR_SX`, so the header is the bottom bar mirrored: inset from the frame edges,
fully rounded (`--bal-radius-pill`), elevated. Neither should read as a slab sealing off
an edge of a 480px screen. The bottom bar is **icon-only** (at five tabs the caption was
the widest thing in it and cost a whole line — the label survives as `aria-label`/
`title`), each tab a fixed 44px circle laid out `space-around`.
- **A stateful card carries its state in its content, not a stripe.** `AccentCard`'s
colored edge stripe was removed — a column of them read as a row of loose vertical rules
down the RTL edge of the screen. `tone` survives as the semantic label (reaching the DOM
as `data-accent-tone`); the `StatusChip`, icon and copy inside carry the state.
**Do not reintroduce the stripe.**
- **Navigation is the bottom bar. There is no drawer.** Tabs are `LinkToPage` arrays
(`@/utils`) built with `useTranslations('nav')`, 35 of them, and by convention the last
is a settings/«بیشتر» hub. Active state comes from the shared `matchActivePath`
@@ -254,7 +311,7 @@ Icons also default to `flexShrink: 0` — an icon squashed by a flex sibling was
layout bug this component kept quietly reintroducing on narrow rows.
**Directional icons mirror automatically.** Icons authored for LTR that must flip under
RTL (`back`, `chevron_start`, `chevron_end`) are registered in `AppIcon/config.ts`'s
RTL (`back`, `chevron_start`, `chevron_end`, `forward`, `send`) are registered in `AppIcon/config.ts`'s
`DIRECTIONAL_ICONS` set. `AppIcon` stamps `data-icon-directional` on those, and one CSS
rule (`app/globals.css`) does
`[dir='rtl'] [data-icon-directional] { transform: scaleX(-1); }`. Adding a new directional
@@ -284,11 +341,16 @@ Every screen/component you produce must satisfy **all** of these:
switches automatically. Verify on both schemes — never assume a light background.
4. **Tokens, not hexes.** No raw color literals in `sx`/`styled`/components (§2).
5. **Constants, not magic values.** Cookie names, routes, repeated dimensions, event
names → named constants (CLAUDE.md "Constants").
names → named constants ([components.md](../../../docs/rules/client/components.md) §5).
6. **Use the wrappers** (§4) and the **icon registry** (§6) before bare MUI.
7. **Shared component ⇒ co-located test** (§4).
8. **MUI v9 API only.** No v5/v6-era props (e.g. `Stack` `useFlexGap`, `storageWindow`).
Avoid deprecated APIs that throw.
9. **Persian copy follows the style guide** — «بالین‌یار» with a ZWNJ, تأیید with a hamza,
جستجو in one form, formal شما. `npm run lint:copy` fails the gate on a banned variant.
Glossary and the full rules: [i18n.md](../../../docs/rules/client/i18n.md) §4.
10. **A screen never fabricates a figure.** A summary reads only off a query that already
answers it; a count still in flight is omitted, never faked or defaulted.
---
@@ -304,13 +366,15 @@ Every screen/component you produce must satisfy **all** of these:
5. **Verify the four axes:** `/fa` (RTL) and `/en` (LTR) × light and dark. The default
route is `/fa` — start there.
6. **Tests** for any new shared component; **never** add a layout above `[locale]`
(breaks locale/dir — see CLAUDE.md).
7. Data/fetch/auth/cookies/toasts → follow CLAUDE.md (`serverFetch`/`clientFetch`,
(breaks locale/dir — see [structure.md](../../../docs/rules/client/structure.md)).
7. Data/fetch/auth/cookies/toasts → follow
[services.md](../../../docs/rules/client/services.md) and
[auth.md](../../../docs/rules/client/auth.md) (`serverFetch`/`clientFetch`,
`@/lib/cookies/*`, `dispatchToast`/`useSnackbar`). Don't reinvent these.
---
## 9. Anti-patterns (design-specific — CLAUDE.md has the full engineering list)
## 9. Anti-patterns (design-specific — `docs/rules/client/` has the full engineering list)
- Hard-coded hex/rgb in components → use palette keys or `--bal-*` tokens.
- MUI default success/error colors for feedback → use `--bal-*` semantic tokens.
@@ -321,6 +385,14 @@ Every screen/component you produce must satisfy **all** of these:
- Raw MUI icon where a registry name is expected → register it in `AppIcon/config.ts`.
- New shared component without a `.test.tsx`, or mocking MUI in tests.
- Re-introducing `src/app/layout.tsx` / any layout above `[locale]`.
- A `≥md` branch that widens a shell, restores a sidebar, or goes multi-column → there is
one layout, and it is a phone (§5).
- A numeric `sx={{ borderRadius: n }}` → it multiplies the shape unit; use the radius token.
- `fontWeight: 600` → neither face loads it, so it silently renders full Bold. 700/500/400.
- Reintroducing `AccentCard`'s edge stripe, a drawer, a top-bar theme/locale toggle, or a
caption under a bottom-nav icon → each was deliberately removed.
- A hand-rolled empty/error/loading block, or a per-screen pager → use the state kit (§4).
- A second `prefers-reduced-motion` branch → there is exactly one, in `globals.css`.
---
@@ -351,4 +423,6 @@ pushing code back into Figma.
| Layout shells | `client/src/layout/` |
| Layout dimensions | `client/src/layout/config.ts` |
| Messages (i18n) | `client/messages/{en,fa}.json` |
| Engineering contract | `client/CLAUDE.md` |
| Persian copy lint | `client/scripts/check-copy.mjs` |
| Engineering hard rules | `client/CLAUDE.md` |
| Engineering reference (per area) | `docs/rules/client/` |
+4
View File
@@ -6,5 +6,9 @@ The canonical guidance for AI coding agents in this repository lives in **[CLAUD
- Frontend → [client/CLAUDE.md](client/CLAUDE.md)
- Backend → [server/CLAUDE.md](server/CLAUDE.md)
Those hold the **hard rules**. The reasoning behind them, and everything you need on demand for a
specific area, is in **[docs/rules/](docs/rules/index.md)** — start at its index, which maps
"working on X" to the one file to open. Business rules live in **[product/](product/index.md)**.
`CLAUDE.md` is the single source of truth at every level of this repo; these `AGENTS.md` files are
just pointers so the convention is discoverable under either name.
+102 -74
View File
@@ -1,127 +1,152 @@
# Balinyaar — Repository Guide (root)
This is the **shared, repo-wide** guide for AI coding agents. It is intentionally short.
Everything specific to one side of the stack lives in that project's own `CLAUDE.md`.
The **shared, repo-wide** guide for AI coding agents. It is intentionally short. Everything specific to one
side of the stack lives in that project's own `CLAUDE.md`.
> **Read the guide for the side you are editing — and only that one.**
> Working in `client/`? Read [client/CLAUDE.md](client/CLAUDE.md).
> Working in `server/`? Read [server/CLAUDE.md](server/CLAUDE.md) (+ [server/CONVENTIONS.md](server/CONVENTIONS.md)).
> Working in `server/`? Read [server/CLAUDE.md](server/CLAUDE.md).
> You almost never need both. A frontend change does not touch server files, and vice-versa.
> `AGENTS.md` files in this repo are thin pointers to the `CLAUDE.md` in the same folder.
> `CLAUDE.md` is the single source of truth at every level.
> Last verified: 2026-07-30 against commit `d3ec723`.
---
## What Balinyaar is
Balinyaar is a **trust-first home-nursing marketplace in Iran**. Independent nurses (and
nursing-company employees) list configurable services; families search, book, pay, and review.
The platform holds funds in an escrow-style ledger and pays nurses out weekly after a confirmed
check-out.
Balinyaar is a **trust-first home-nursing marketplace in Iran**. Independent nurses (and nursing-company
employees) list configurable services; families search, book, pay, and review. The platform holds funds in an
escrow-style ledger and pays nurses out weekly after a confirmed check-out.
Product/domain knowledge — business rules, the database model, payments/BNPL, escrow, the
verification pipeline — is **not** in the code. It lives in [`product/`](product/), organized as a
**structured docs tree** (one topic per file; start at [product/index.md](product/index.md) or its
[README](product/README.md)):
Product and domain knowledge — business rules, the database model, payments/BNPL, escrow, the verification
pipeline — is **not in the code**. It lives in [`product/`](product/index.md), a structured docs tree with one
topic per file.
| Folder | What it covers |
| --- | --- |
| [product/overview/](product/overview/platform-summary.md) | What Balinyaar is, the four cross-cutting ground truths, Persian glossary. **Read first.** |
| [product/overview/](product/overview/platform-summary.md) | What Balinyaar is, the four cross-cutting ground truths, the Persian glossary. **Read first.** |
| [product/business/](product/business/index.md) | The 14 functional/business requirement areas, one file each |
| [product/data-model/](product/data-model/index.md) | The ~54-table SQL Server schema across 13 domains + [diagrams](product/data-model/diagrams.md) |
| [product/payments/](product/payments/index.md) | BNPL, escrow ledger, settlement, VAT, integrations (with sources) |
| [product/data-model/](product/data-model/index.md) | The ~54-table SQL Server schema across 13 domains, + [diagrams](product/data-model/diagrams.md) |
| [product/payments/](product/payments/index.md) | BNPL, the escrow ledger, settlement, VAT, integrations (with sources) |
| [product/research/](product/research/index.md) | Market/legal/verification research & go-to-market (EN) |
| [product/notes/](product/notes/open-questions.md) | Living notes: open questions, future ideas |
| [product/fa/](product/fa/index.html) | Farsi versions (research report + verification flow) |
**Read the relevant `product/` doc before designing any schema, API, or feature.** Don't infer
business rules from code — the code is young and the docs are the source of truth.
**Read the relevant `product/` doc before designing any schema, API, or feature.** Don't infer business rules
from code — the code is young and the docs are the source of truth.
> **Docs format:** the `.md` files are canonical; matching `.html` files are a generated, cross-linked
> browsing view (`cd product && node build-docs.mjs`). Edit the Markdown and regenerate — never
> hand-edit the `.html`. If you add/rename a `.md`, update the `NAV` manifest in `product/build-docs.mjs`.
> **Docs format:** the `.md` files are canonical; matching `.html` files are a generated, cross-linked browsing
> view (`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`.
---
## Repository layout
This is **two independent projects in one repo**. There is no root-level build, package, or
solution — each project is built, linted, and run on its own.
This is **two independent projects in one repo**, plus their documentation. There is no root-level build,
package, or solution — each project is built, linted, and run on its own.
| Path | Project | Stack | Guide |
| Path | What it is | Stack | Guide |
| --- | --- | --- | --- |
| [`client/`](client/) | Web frontend | Next.js 16 (App Router) · React 19 · TypeScript · MUI v9 · next-intl | [client/CLAUDE.md](client/CLAUDE.md) |
| [`server/`](server/) | Backend API | ASP.NET Core (.NET 10) · Clean Architecture · CQRS · EF Core | [server/CLAUDE.md](server/CLAUDE.md) |
| [`product/`](product/) | Product docs | Markdown | — (see table above) |
| [`dev/`](dev/) | Build plan (not app code) | Markdown | [dev/README.md](dev/README.md) |
| [`telegram-otp-bot/`](telegram-otp-bot/) | OTP relay (standalone) | Node 18+, zero deps | [telegram-otp-bot/README.md](telegram-otp-bot/README.md) |
| [`product/`](product/index.md) | **Business truth** — what to build and why | Markdown (+ generated HTML) | the table above |
| [`docs/`](docs/README.md) | **Engineering truth** — rules, the client↔server contract, flows, status, roadmap | Markdown | [docs/README.md](docs/README.md) |
| [`dev/`](dev/README.md) | The finished build-plan chain. **History, not a project** — nothing to build in it | Markdown | [dev/README.md](dev/README.md) |
| [`telegram-otp-bot/`](telegram-otp-bot/) | OTP relay (standalone, the pre-launch demo rail) | Node 18+, zero deps | [telegram-otp-bot/README.md](telegram-otp-bot/README.md) |
| [`deploy/`](deploy/) | Reverse-proxy config | Caddyfile | [DEPLOY.md](DEPLOY.md) |
| [`.githooks/`](.githooks/README.md) | Repo-managed git hooks (the pre-commit secret scan) | shell | [docs/rules/shared/git-and-gates.md](docs/rules/shared/git-and-gates.md) |
The two communicate over **HTTP/JSON** (optionally gRPC). The client reads the API base URL from
`NEXT_PUBLIC_API_URL`; the server listens on `https://localhost:5002` by default.
`AGENTS.md` files in this repo are thin pointers to the `CLAUDE.md` in the same folder. **`CLAUDE.md` is the
single source of truth at every level.**
**Deployment** is three Docker containers — one `Dockerfile` per project directory, orchestrated by the
root [`docker-compose.yml`](docker-compose.yml) — behind an existing Caddy reverse proxy on the external
`caddy_net` network, serving `balinyaar.ir` (client) and `api.balinyaar.ir` (server). The database is
**not** containerised; it is a remote SQL Server. Full runbook: [DEPLOY.md](DEPLOY.md).
The two projects communicate over **HTTP/JSON** (optionally gRPC). The client reads the API base URL from
`NEXT_PUBLIC_API_URL`; the server listens on `http://localhost:5002` by default.
[`dev/`](dev/README.md) holds the **phased build plan** that takes the repo from its current baseline to
the MVP: a chain of agent-runnable prompt files split into a `backend/` and a `frontend/` track
([dev/phases/](dev/phases/README.md)), the cross-project API [`contracts/`](dev/contracts/README.md), and
a [`shared-working-context/`](dev/shared-working-context/README.md) that lets a backend agent and a
frontend agent run in parallel without touching the same files. It is planning/tooling, **not** a third
project — there is nothing to build in it.
**Deployment** is three Docker containers — one `Dockerfile` per project directory, orchestrated by the root
[`docker-compose.yml`](docker-compose.yml) — behind an existing Caddy reverse proxy on the external `caddy_net`
network, serving `balinyaar.ir` (client) and `api.balinyaar.ir` (server). The database is **not**
containerised; it is a remote SQL Server. Full runbook: [DEPLOY.md](DEPLOY.md).
`archive/` does not exist yet. When it does, it will hold `dev/`'s history — and the rule will be that
**anything in it is a record, not an instruction**, because it is written in the imperative from having once
been a prompt.
---
## Where the rules live
Three tiers. Open the `CLAUDE.md` for the side you are editing, then **one** reference file for the area you
are touching.
| Tier | Where | What |
| --- | --- | --- |
| **Hard rules** | this file · [client/CLAUDE.md](client/CLAUDE.md) · [server/CLAUDE.md](server/CLAUDE.md) | Constraints whose violation breaks the build, the gate, or a business invariant |
| **Reference** | [`docs/rules/`](docs/rules/index.md) | The *how* and the *why*, read on demand — 3 shared files, 8 client, 6 server, plus the documentation convention |
| **Procedure** | `.claude/skills/` | Playbooks. The **frontend-designer** skill is the design contract for `client/` UI |
Start at [docs/rules/index.md](docs/rules/index.md) — it maps "working on X" to the one file to open.
**Precedence when two sources disagree:** `product/` (business truth) → the relevant `CLAUDE.md` (engineering
truth) → `docs/rules/` (the reasoning behind it) → the task in front of you. **Never silently guess on money,
auth, tenancy, or clinical-data rules** — do the safe thing, and say so.
---
## Working agreements (apply to both projects)
1. **Stay within one project per change** unless the task explicitly spans both.
2. **Match the surrounding style.** Mirror existing patterns; don't introduce new ones. Each
project documents its conventions in its own `CLAUDE.md`.
2. **Match the surrounding style.** Mirror existing patterns; don't introduce new ones. Each project documents
its conventions in its own `CLAUDE.md`.
3. **Run that project's own checks before declaring work done:**
- client: `npm run check` (type + lint), plus `npm run test:ci` if you touched a tested component.
- server: `dotnet build Baya.sln` and `dotnet test Baya.sln`.
- client: `cd client && npm run check` (type + lint + copy), plus `npm run test:ci` if you touched a tested
component.
- server: `cd server && dotnet build Baya.sln` (**zero new warnings**) and `dotnet test Baya.sln`.
- What "done" means in full: [docs/rules/shared/git-and-gates.md](docs/rules/shared/git-and-gates.md).
4. **Read the product docs before changing behavior.** Business rules are decisions, not guesses.
5. **Don't reintroduce template/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.
6. **Configuration lives in files, not in a secret store.** `dotnet user-secrets` is **not** used — the
`<UserSecretsId>` was removed from `Baya.Web.Api.csproj`, so that store isn't even read. Server config
(including keys) lives in `appsettings.*.json`; client config in `.env.development` / `.env.production`;
the deployment's container-specific overrides in `docker-compose.yml`. This is a deliberate pre-launch
trade for a demo deployment — **the repo therefore contains live credentials**. Before onboarding real
users, rotate them and move the secret half out of git (see [DEPLOY.md](DEPLOY.md) "Going to Production").
One value is load-bearing and must never change: `Seams:FieldEncryption:Key`/`:HashKey` decrypt all
existing PII and derive the phone-lookup hash.
7. **Keep docs honest, and keep the architecture map current.** If you change how something works,
update the `CLAUDE.md` that describes it in the same change. Each level documents its architecture
in one canonical place — **this file's "Repository layout"** (repo), **client/CLAUDE.md "Project
Structure"** (frontend), **server/CLAUDE.md "Project map"** (backend). When a change alters that
structure — adds, removes, or renames a project, layer, route group, provider, or major folder, or
changes a cross-project / cross-layer boundary — update the matching architecture section in the
same change. Stale instructions are worse than none.
5. **Don't reintroduce template/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.
6. **Configuration lives in files, not a secret store.** `dotnet user-secrets` is **not used** — the
`<UserSecretsId>` was removed from `Baya.Web.Api.csproj`, so that store **is not even read**. Any
instruction anywhere to set a value with it is stale. Server config (including keys) lives in
`appsettings.*.json`; client config in `.env.development` / `.env.production`; the deployment's
container-specific overrides in `docker-compose.yml`.
This is a deliberate pre-launch trade for a demo deployment — **the repo therefore contains live
credentials.** Before onboarding real users, rotate them and move the secret half out of git (see
[DEPLOY.md](DEPLOY.md) "Going to Production"). **One value is load-bearing and must never change:**
`Seams:FieldEncryption:Key` / `:HashKey` decrypt all existing PII and derive the phone-lookup hash.
7. **Keep docs honest, and keep the architecture map current.** If you change how something works, update the
doc that describes it in the **same** change. Each level documents its architecture in one canonical place —
**this file's "Repository layout"** (repo), **client/CLAUDE.md "Project structure"** (frontend),
**server/CLAUDE.md "Project map"** (backend). When a change alters that structure — adds, removes, or
renames a project, layer, route group, provider, or major folder, or changes a cross-project / cross-layer
boundary — update the matching section in the same change. The full anti-drift convention (what to update
when X changes, the `> Last verified:` stamp, length budgets) is
[docs/rules/documentation.md](docs/rules/documentation.md). **Stale instructions are worse than none.**
8. **Write clean, self-documenting code.**
- **No dead code.** Remove unused variables, imports/usings, parameters, and private members —
don't leave them behind and don't suppress the warning. The client enforces this with ESLint
(`@typescript-eslint/no-unused-vars` as an *error*); on the server they are build warnings and
the gate is zero new warnings. Per-project specifics live in each project's `CLAUDE.md` /
`CONVENTIONS.md`.
- **Comment the *why*, not the *what*.** Don't write verbose comments that restate what the code
already says. Add a comment only where a non-obvious decision, constraint, business rule, or
trade-off isn't evident from the code itself. Prefer a clearer name over a comment.
- **No dead code.** Remove unused variables, imports/usings, parameters, and private members — don't leave
them behind and don't suppress the warning. The client enforces this with ESLint
(`@typescript-eslint/no-unused-vars` as an *error*); on the server they are build warnings and the gate is
zero new warnings.
- **Comment the *why*, not the *what*.** Don't write verbose comments that restate what the code already
says. Add a comment only where a non-obvious decision, constraint, business rule, or trade-off isn't
evident from the code itself. Prefer a clearer name over a comment.
- Details and worked examples: [docs/rules/shared/code-quality.md](docs/rules/shared/code-quality.md).
9. **A mock is only sanctioned behind a DI-registered seam**, selected by configuration, defaulting to the
mock, and recorded in `docs/status/`. Never an `if (mock)` branch scattered through the code.
---
## Naming
- The **server**'s C# namespaces, projects, and solution all use the `Baya*` prefix
(`Baya.Web.Api`, `Baya.sln`). Keep new server code under the `Baya.*` convention.
- The **server**'s C# namespaces, projects, and solution all use the `Baya*` prefix (`Baya.Web.Api`,
`Baya.sln`). Keep new server code under the `Baya.*` convention.
- The **client** package is `balinyaar-client`; the `@/*` import alias maps to `client/src/*`.
The product/brand name is **Balinyaar**; the server's `Baya*` prefix is a legacy code namespace —
do not rename it without explicit instruction.
The product/brand name is **Balinyaar** — «بالین‌یار» in Persian copy, with a ZWNJ, always. The server's
`Baya*` prefix is a legacy code namespace: **do not rename it without explicit instruction.** Full
conventions: [docs/rules/shared/naming.md](docs/rules/shared/naming.md).
---
@@ -132,5 +157,8 @@ do not rename it without explicit instruction.
cd client && npm install && npm run dev # http://localhost:3000
# Backend
cd server && dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj # https://localhost:5002/swagger
cd server && dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj # http://localhost:5002/swagger
# Once per clone — enable the repo's git hooks
git config core.hooksPath .githooks
```
+1 -1
View File
@@ -23,6 +23,6 @@ NEXT_PUBLIC_API_URL = https://localhost:5002
# Neshan **web** key (client-embeddable maps/search/reverse-geocode) — get one from
# https://platform.neshan.org (a separate key from the server's NeshanGeocoder key, which lives in
# server appsettings/user-secrets, never here). Leave unset to keep the address map-pin picker's
# server appsettings, never here). Leave unset to keep the address map-pin picker's
# bounded-canvas grid fallback (dev/CI/jsdom all work without a key).
# NEXT_PUBLIC_NESHAN_KEY = your-neshan-web-key
+5 -3
View File
@@ -1,10 +1,12 @@
# AGENTS.md — Balinyaar Web Client
The canonical agent guide for the frontend is **[CLAUDE.md](CLAUDE.md)** (same folder). It is the
engineering contract: stack, commands, lint/type gates, routing, providers, data fetching, theming,
i18n, cookies, and the rules every change must follow.
The canonical agent guide for the frontend is **[CLAUDE.md](CLAUDE.md)** (same folder): stack,
commands, the quality gates, the project structure, and the hard rules every change must follow.
- Reference rules, read on demand per area → [../docs/rules/client/](../docs/rules/client/)
(structure · theme · components · forms · i18n · services · auth · testing)
- Repo-wide context → [../CLAUDE.md](../CLAUDE.md)
- Business rules (what to build) → [../product/](../product/index.md)
- Human setup/run instructions → [README.md](README.md)
- UI/design work → the **frontend-designer** skill
+141 -1062
View File
File diff suppressed because it is too large Load Diff
-116
View File
@@ -1,116 +0,0 @@
# Persian (fa) copy style guide
One page, binding for `messages/fa.json`. `scripts/check-copy.mjs` (`npm run lint:copy`, part of
`npm run check`) enforces the banned-variant rules below so these decisions cannot silently regress.
This file does not repeat `en.json` conventions beyond what's noted in §7 — the English catalog is
hand-written and reviewed for idiom, not linted.
## 1. Brand name
**«بالین‌یار» — ZWNJ (``) between بالین and یار, always.** Never a plain space («بالین یار»).
The brand name appears in money/trust copy (login, escrow, refunds) as often as anywhere else — an
unstable brand mark there is the worst place to be inconsistent.
## 2. تأیید — hamza, always
Write **تأیید** (with hamza) and its derived forms — **تأییدشده**, **تأییدیه**, **تأیید کردن** — every
time, never تایید/تاییدشده/تاییدیه (hamza-less). This is the single most frequent word in a
verification product; one spelling, no exceptions, in every namespace (booking, payment, auth,
verification, admin, payouts, bnpl, refunds, legal — all of them).
## 3. جستجو — one form
Standard form: **جستجو** (no ZWNJ, one word). Not «جست‌وجو» / «جست و جو». Applies to the noun and any
compound (`در جستجو`, `نتایج جستجو`).
## 4. ZWNJ (نیم‌فاصله) rules
Use ZWNJ (``) — never a plain space or no separator — in:
- **می‌ + verb stem**: می‌شود، می‌کند، می‌پردازید، می‌ماند (never میشود/می شود).
- **Plural ها**: مراقب‌ها-style compounds keep the ZWNJ before ها when the base ends in a consonant that
would otherwise misread (`شب‌ها` not `شبها`); a plain plural on a word already ending in a vowel/silent-h
takes the ZWNJ too (`بچه‌ها`).
- **Compound past-participle adjectives**: تأییدشده، لغوشده، ردشده، منتشرشده، پرداخت‌شده — the doer/state
compound is one ZWNJ-joined word, not two spaced words («تایید شده») and not fused with no separator.
- Brand name itself (§1) is the other load-bearing ZWNJ case.
## 5. Punctuation & quotes
- Persian text uses «...» guillemets for quoted terms/labels in prose (as this document does), and
Persian «،» / «؛» for commas/semicolons *inside translated sentences* where the surrounding punctuation
is itself Persian prose (most UI strings use plain Latin `,`/`;` today for simplicity in short labels —
don't retrofit existing short strings, but prefer «،»/«؛» in new multi-clause sentences).
- English (`en.json`) uses **straight** apostrophes (`don't`, `couldn't`) throughout — never curly
(``, ``). One admin-namespace holdout (curly `don't`/`couldn't`) is fixed by this phase; don't
reintroduce curly quotes when editing English copy.
## 6. Domain glossary
- **بیمار** — the care recipient, used consistently everywhere except one parenthetical. Do not adopt
«مددجو» — it appeared exactly once (`booking.patient_label`) and has been dropped in favor of the
99%-majority «بیمار».
- **پرستار** — the caregiver, always (never «مراقب» as a noun for the person — «مراقب» only survives as
an adjective/role qualifier, e.g. `booking.gender_label` "جنسیت مراقب" meaning "the caregiver's gender").
- **رزرو** — a confirmed, paid booking. **درخواست رزرو** — a pre-payment request. Never conflate the two;
a `booking_request` is never called «رزرو» before it converts.
- **ویزیت** — one scheduled visit/session within a booking.
- **شبا** — IBAN, always («شماره شبا» for the field label, «شبا» alone elsewhere).
## 7. Shell naming system
One metaphor per audience class, not four:
- **End-user shells** (family, nurse — the apps people book/work through day to day) → **«اپلیکیشن»**:
«اپلیکیشن خانواده», «اپلیکیشن پرستار».
- **Back-office shells** (staff consoles — admin, partner-center) → **«کنسول»**: «کنسول مدیریت»,
«کنسول همکار».
- Never «نما» (view) or «پرتال» (portal) for a whole shell name — those read as one-off inconsistent
metaphors. (`booking.evv_nurse_view` "نمای پرستار" is a different thing — a chip labeling *whose
perspective* a shared booking-detail screen is rendered from, not a shell name; it correctly keeps
«نما» in that narrower sense.)
## 8. Verification pipeline vs. the identity step
**«تأیید صلاحیت»** names the whole 7-step nurse trust pipeline (nav entry, the verification hub's 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) — both on the nurse side
(`verification.step_identity_kyc`) and the admin side (`admin.step_identity_kyc`). A nurse who passed the
KYC step but still sees a pipeline titled «احراز هویت» incomplete in the nav used to read as a
contradiction; they no longer share a name.
## 9. Status vocabulary — one nurse-facing form, one admin-facing form
For "this step/item was rejected/failed" style states that appear on both a nurse-facing screen and an
admin-facing screen for the *same underlying concept* (a verification step's outcome):
- **Nurse-facing**: «رد شد» (`verification.status_failed`) — a short declarative sentence-style status,
matching the register of its sibling `status_passed` ("تأییدشده")/`status_in_review` ("در حال بررسی").
- **Admin-facing**: «ردشده» (`admin.step_failed`, `admin.agg_rejected`, `admin.rstatus_rejected`,
`admin.mstatus_rejected`) — the compound-adjective state form, matching the admin namespace's own
`step_passed`/`agg_approved`/`center_state_verified` ("تأییدشده") pattern.
This does **not** extend to unrelated money-failure vocabulary (`payouts.pstatus_failed`,
`refunds.rstatus_failed`, `admin.batch_status_failed` all legitimately use «ناموفق» — a transfer/payment
*failing* is a different concept from a document being *rejected*, and conflating them would blur a real
distinction).
## 10. Digits policy
Persian digits (۰۱۲۳۴۵۶۷۸۹) everywhere on `/fa` — both hard-coded literals (`"۲۴ ساعت"`) and
interpolated numbers. For an interpolated `{count}`/`{hours}`/… inside an ICU message, use the ICU
`number` sub-format (`{count, number}`) or a plain `#` inside a `plural` block — next-intl formats both
through the active locale (`fa` → Persian digits) automatically. When a raw number is interpolated at a
call site instead of through ICU (e.g. built into a larger string in code, not a message placeholder),
route it through `formatNumber` (`@/utils`) — never template a raw JS number directly into Persian text.
## 11. Register
Formal شما throughout, with polite imperatives (کنید) for actions and instructions. Already consistent
across the whole catalog — this codifies it so a future addition can't drift into informal تو/imperative
stems (نکن, برو).
## 12. Policy numbers
Legally/financially sensitive numbers that the admin config panel can change (the dispute-window hours,
cancellation lead-time hours, refund ETA days) are **never hard-coded into a message string**. The
message key takes a parameter (`{hours}`, `{minDays}`/`{maxDays}`) and the call site interpolates from
`client/src/constants/policy.ts` (single-sourced, REQ-065 tracks the eventual public config-read that
replaces the constants file). A config edit must never again silently make the UI copy lie.
+4 -1
View File
@@ -1,8 +1,11 @@
#!/usr/bin/env node
/**
* Lints client/messages/fa.json against the banned-orthography-variant rules in
* client/messages/STYLE.md enforces the phase-12 sweep so it cannot silently regress.
* docs/rules/client/i18n.md §4 enforces the phase-12 copy sweep so it cannot silently regress.
* Exits non-zero (and prints every offending key) on any match.
*
* The rules below are the machine-checkable subset. The full Persian style guide (glossary, register,
* shell naming, the ZWNJ cases a grep can't express) lives in that doc; keep the two in step.
*/
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
+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 |
-1
View File
@@ -12,7 +12,6 @@ Dockerfile
.dockerignore
docker-compose.yml
CLAUDE.md
CONVENTIONS.md
AGENTS.md
README.md
LICENSE.md
+6 -5
View File
@@ -1,12 +1,13 @@
# AGENTS.md — Balinyaar Server
The canonical agent guide for the backend is **[CLAUDE.md](CLAUDE.md)** (same folder): role, stack,
commands, architecture, project map, and a conventions quick-reference.
The **full coding rule set** is in **[CONVENTIONS.md](CONVENTIONS.md)** — read it before writing any
server code.
The canonical agent guide for the backend is **[CLAUDE.md](CLAUDE.md)** (same folder): stack,
commands, the quality gates, the project map, and the hard rules every change must follow.
- Reference rules, read on demand per area → [../docs/rules/server/](../docs/rules/server/)
(structure · cqrs · persistence · **money** · identity · conventions). `conventions.md` is the
successor to the old `CONVENTIONS.md`, which was distilled into it.
- Repo-wide context → [../CLAUDE.md](../CLAUDE.md)
- Business rules (schema, payments, escrow, verification) → [../product/](../product/index.md)
- Human setup/run instructions → [README.md](README.md)
`CLAUDE.md` is the single source of truth; this file is just a pointer so the convention is
+137 -725
View File
@@ -1,29 +1,14 @@
# Balinyaar Server — Claude Code Guidelines
# Balinyaar Server
The backend API of **Balinyaar**, a trust-first home-nursing marketplace in Iran.
The backend API of **Balinyaar**, a trust-first home-nursing marketplace in Iran. It owns the booking
lifecycle, an escrow-style double-entry ledger, weekly nurse payouts, the nurse verification pipeline, and
every piece of encrypted PII and clinical data on the platform.
- **Coding rules** (the full rule set you must follow) → [CONVENTIONS.md](CONVENTIONS.md). Read it
before writing any server code.
- Repo-wide context and the frontend → root [CLAUDE.md](../CLAUDE.md).
- Product/domain rules (business logic, schema, payments, escrow, verification) → [`product/`](../product/).
Read the relevant doc before designing an entity, feature, or endpoint — don't infer business rules
from code.
> Last verified: 2026-07-30 against commit `d3ec723`.
---
## Role
You are a **senior .NET software engineer** working on this codebase. That means:
- You write production-quality code, not demo code. Every file you touch should look like it was
written by someone who has shipped .NET APIs at scale.
- You understand the architecture and work _with_ it, not around it. Clean Architecture boundaries
are non-negotiable.
- You think before you write. If a task is ambiguous, reason through the design first. If it touches a
contract other layers depend on, think about downstream impact.
- You prefer simplicity and clarity over cleverness. The next engineer (or agent) should read your
code without a guide.
- You never leave the codebase in a worse state than you found it.
- Repo-wide context and the frontend → root [CLAUDE.md](../CLAUDE.md)
- Business rules (schema, payments, escrow, verification) → [`product/`](../product/index.md). **Read the
relevant doc before designing an entity, feature, or endpoint** — don't infer a business rule from code.
---
@@ -31,742 +16,169 @@ You are a **senior .NET software engineer** working on this codebase. That means
- **ASP.NET Core / .NET 10** (`net10.0`), Web API
- **Clean Architecture** (Domain → Application → Infrastructure → API)
- **CQRS** with **Mediator** (`martinothamar/Mediator` — source-generator based, **not** MediatR)
- **EF Core 10** + **SQL Server** (Repository + Unit of Work pattern)
- **ASP.NET Core Identity** with **JWE** (signed + AES-128-encrypted JWT), OTP, and dynamic permission authorization
- **CQRS** with **Mediator** (`martinothamar/Mediator` — source-generator based, **not** MediatR). Use
`ISender`/`ICommand`/`IQuery`; any prose that says "MediatR" is wrong.
- **EF Core 10** + **SQL Server** (Repository + Unit of Work)
- **ASP.NET Core Identity** with **JWE** (signed + AES-128-encrypted JWT), phone-OTP, and dynamic permission
authorization
- **Mapster** for mapping, **FluentValidation** for validation, **Serilog** for structured logging
- **OpenTelemetry** (metrics + tracing; Prometheus-scrape at `/metrics`, opt-in OTLP export) for observability, **NSwag** for OpenAPI, **Asp.Versioning** for versioning
- **OpenTelemetry** (metrics at `/metrics`, tracing, opt-in OTLP), **NSwag** for OpenAPI, **Asp.Versioning**
- **xUnit** + **NSubstitute** for tests
- All NuGet versions are centrally pinned in `Directory.Packages.props`
> Note: some prose elsewhere may say "MediatR" — the actual dispatcher is `martinothamar/Mediator`.
> Use `ISender`/`ICommand`/`IQuery` from that package, not MediatR types.
---
## Commands (run from `server/`)
| Task | Command |
| ----------------- | ------- |
| Restore | `dotnet restore Baya.sln` |
| Build | `dotnet build Baya.sln` |
| --- | --- |
| Restore / build | `dotnet restore Baya.sln` · `dotnet build Baya.sln` |
| Run API | `dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj` |
| Test | `dotnet test Baya.sln` |
| Apply migrations (deploy-time one-shot) | `dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj -- migrate` |
| Add migration | `dotnet ef migrations add <Name> --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api` |
| Update DB | `dotnet ef database update --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api` |
**Default URL:** `https://localhost:5002` Swagger at `/swagger`.
**Migrations are split from boot (refinement-phase-7).** `dotnet run -- migrate` (the deploy-time one-shot / a CI
`dotnet ef database update`) applies migrations + the idempotent seeders, then exits — so multi-instance boots never
race on DDL and the runtime login needs no permanent DDL rights. **In Development**, boot still migrates + seeds for
convenience: `Program.cs` calls `ApplyMigrationsAsync()` + `SeedDefaultUsersAsync()` (roles always; a bootstrap admin
**only if `Seed:AdminUsername`/`Seed:AdminPassword` are configured** — never a committed credential) + the
Development-only `SeedPaymentGatewaysAsync()` (sandbox gateway) + `SeedDemoWorldAsync()` (demo marketplace, see
Persistence below). **In deployed environments**, boot instead only *checks* the schema is current
(`EnsureSchemaUpToDateAsync` — fail fast on a pending migration) and seeds roles/break-glass admin (idempotent). A
reachable SQL Server is required to start. Startup **fails fast**
(`StartupSecretsGuard`) if a load-bearing secret — the DB connection strings, and in deployed environments the
JWE + field-encryption keys — is missing or left at its committed `SET_VIA_USER_SECRETS_OR_ENV` placeholder
(refinement-phase-5). **`dotnet user-secrets` is no longer used** — the `<UserSecretsId>` was removed from
`Baya.Web.Api.csproj`, so that store is not read at all. Every value, connection strings and dev-only crypto keys
alike, lives in `appsettings.Development.json`; the deployment's two container-specific overrides live in the root
`docker-compose.yml` (see [DEPLOY.md](../DEPLOY.md)).
**Default URL: `http://localhost:5002`** (per `launchSettings.json`), Swagger at `/swagger`. A reachable SQL
Server is required to start.
## Quality gates
1. `dotnet build Baya.sln`**zero new warnings.** Unused usings, locals, parameters, private fields or
members count as failures. Delete them; don't suppress them.
2. `dotnet test Baya.sln` — all tests pass, including the ones your change adds.
3. Read your own diff as if reviewing a PR: would a senior engineer approve it without comment?
4. If the change alters the architecture, update the **Project map** below in the same change.
Two pre-existing warnings are expected and must **not** be "fixed" unless a task says so: `NU1510` on
`Microsoft.Extensions.Logging.Debug` (`Baya.Web.Api`), and `NETSDK1057` (the .NET 10 SDK is preview here).
---
## Quality gates — run before declaring work done
## Hard rules
1. `dotnet build Baya.sln` — zero new warnings introduced. Unused `using`s, locals, parameters,
private fields, or members count as failures — delete them, don't suppress them
([CONVENTIONS.md](CONVENTIONS.md) §2 "No unused code").
2. `dotnet test Baya.sln` — all tests pass.
3. Read your own diff as if reviewing a PR: would a senior engineer approve it without comment?
4. If the change alters the architecture, update the **Project map** below in the same change
(see "Keeping the Project map current").
1. **Dependencies point inward.** Domain references nothing; Application references only Domain.
**Never reference Infrastructure or the API from Domain or Application.**
2. **Never throw for an expected failure.** Return `OperationResult.SuccessResult` / `FailureResult` /
`NotFoundResult` / `ConflictResult`. Let genuinely unexpected exceptions reach the global
`ExceptionHandler`; never swallow one.
3. **Controllers are `sealed`, inherit `BaseController`, inject `ISender`, and return
`base.OperationResult(result)`.** Never call `Ok()`/`BadRequest()`/`NotFound()` directly. One `Send`, one
result, no business logic.
4. **Route segments come from `[controller]`/`[action]` tokens** (the snake_case transformer). Never hardcode a
route string — it also breaks the dynamic-permission key. If a method name doesn't read as a URL, rename it.
5. **Handlers are `internal sealed`; requests are `record`s; one handler per request.** Entities are `class`
with **no public setters**.
6. **Reads use `AsNoTracking()` and project with `.Select()` to a DTO.** Never hydrate entities to map them,
never return an entity from a handler. **Every unbounded list is paginated.**
7. **Access the DB through `IUnitOfWork`; commit once per command.** `ApplicationDbContext` is referenced
directly only inside Infrastructure.
8. **Every soft-deletable entity declares a global query filter** in its `IEntityTypeConfiguration<T>`. A
missing filter is a silent data leak. Never `Where(x => !x.IsDeleted)` per query.
9. **Money is IRR `BIGINT`, integer-only — no float path anywhere.** `gross = commission + payout` always.
Toman converts only inside a provider adapter at its boundary. `ledger_entries` is append-only and every
posting group balances.
10. **Config is rows, read at compute time** via `IPlatformConfig` — never hardcoded. And **a rate change is
never retroactive**: snapshot the rate onto the row at compute time.
11. **Money-path writes are idempotent**: upsert the webhook event first and no-op on a duplicate, claim before
executing, and treat a unique-violation on confirm as an idempotent success. The DB constraint is the
authoritative backstop, not the handler's `if`.
12. **Money movement stays human-approved.** A scheduled job may *generate* a draft payout batch; the
irreversible `process` step is always an explicit admin action.
13. **Route every status write through the forward-only transition table.** `status` has a private setter and
only cohesive domain methods mutate it; the handler pre-checks and returns a clean **409**.
14. **A guarded cross-aggregate flip is one transaction**: load both tracked, mutate through one pure domain
helper, `CommitAsync` once. Never flip a derived flag from a controller or out of band.
15. **Self-committing facades run *after* `CommitAsync()`**`RaiseAsync`, `DispatchAsync`, `WriteAsync` and
`SetConfig` each call `SaveChanges` on the shared scoped context and will flush your partial changes.
16. **`Seams:FieldEncryption:Key` and `:HashKey` are load-bearing — never change them.** They decrypt all
existing PII and derive the phone-lookup hash.
17. **PII goes through `IFieldEncryptor`; equality lookups go through the deterministic hash column.**
**Never query `PhoneNumber == x`.** The encryptor must stay a process-wide singleton.
18. **Two-stage clinical disclosure.** A booking request exposes only limited unencrypted `customer_notes` and
masks the address to a coarse city/district; encrypted care instructions are readable only
post-confirmation, only by the assigned nurse and admin, and never projected into a list or logged.
19. **`is_internal` is a hard visibility boundary enforced at the QUERY layer**, never in the UI. A non-staff
caller can never set or read one.
20. **Tenancy is resolved from `ICurrentUser`, never from the request body, and a mismatch is a clean 404**
never a 403, which confirms the row exists.
21. **Auth, OTP and money endpoints are rate-limited** (`otp` / `auth` / `sensitive` / `webhook` policies).
22. **Never hardcode a secret in C#**, and never put a real value in the base `appsettings.json` — it stays at
its `StartupSecretsGuard`-rejected placeholder. **`dotnet user-secrets` is not used and is not read** (the
`<UserSecretsId>` was removed), so any instruction to use it is stale.
23. **Never concatenate raw SQL.** EF parameterizes; if you must, `FromSqlInterpolated`, never `FromSqlRaw`
with user data.
24. **`async`/`await` all the way, `CancellationToken` threaded through every call.** Never `.Result`,
`.Wait()`, or `async void`. Don't add `.ConfigureAwait(false)` in this app.
25. **Never log PII or secrets.** Structured templates only; use `userId`, not an email.
26. **Package versions live only in `Directory.Packages.props`** — never `Version=` in a `.csproj`.
27. **Register infrastructure through a `ServiceConfiguration/` extension method** called from `Program.cs`.
No inline registration; `Program.cs` stays an orchestrator.
28. **A mock lives behind a DI-registered seam, selected by config, defaulting to the mock.** Never an
`if (mock)` in a handler. Record every mock in `docs/status/`.
29. **No dead code** (the gate is zero new warnings) and **comment the *why*, never the *what*.**
30. **When you change the architecture, update the Project map below in the same change.**
---
## Project map
This tree is the **canonical description of the server's architecture** — the authoritative list of
projects/assemblies, Clean-Architecture layers, and cross-layer dependencies.
The canonical list of projects, layers, and cross-layer dependencies — **14 `.csproj` projects, 55 V1
controllers.** Expanded, with the seam catalogue and startup wiring, in
[`docs/rules/server/structure.md`](../docs/rules/server/structure.md).
```
src/
├── Core/
│ ├── Baya.Domain Entities (User, Role, UserSession, RoleNames…, Identity/ (NurseProfile, CustomerProfile, Patient, NurseBankAccount, CustomerAddress), Geography/ (Province, City, District, NurseServiceArea), Catalog/ (ServiceCategory, ServiceOptionGroup, ServiceOptionValue, NurseServiceVariant, NurseServiceVariantOption, PriceUnits), Verification/ (NurseVerification, VerificationStepType, VerificationStep, VerificationDocument, NurseCredential + VerificationStatus/VerificationStepStatus enums), Search/ (NurseSearchIndex — the denormalized search projection), Booking/ (BookingRequest — the money-free pre-payment intent + BookingRequestStatus/BookingRequestTransitions forward-only status guard + CaregiverGender codes; b9 adds Booking/BookingSession/BookingCareInstruction/VisitVerification/CancellationPolicy + their status/transition tables + BookingAmounts money split), Payments/ (b10 ledger/txn/webhook/gateway + LedgerPosting; b11 adds Refunds/ + Invoices/), Bnpl/ (b12 BnplTransaction + BnplStatus/BnplTransitions/BnplEligibilityStatus/BnplProviderCodes — the net-of-fee card-payment model), Payouts/ (b13 NursePayoutBatch/NursePayout/NursePayoutBookingLink + PayoutBatchStatus/PayoutStatus/*Transitions — the weekly payout run), Reviews/ (b14 Review (IAuditable) + ReviewModerationStatus/ReviewModerationAction codes + ReviewTagMaster/ReviewTagLink + PatientCareRecord — moderated reviews, tag vocab & patient-scoped encrypted clinical notes), + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts), BaseEntity, IEntity, ITimeModification, IAuditableEntity, IAuditable (audit-row marker)
└── Baya.Application Features/ (Commands & Queries; Identity area = auth + profiles/patients/nurse-bank-accounts; Geography/ServiceAreas/Addresses areas = geo hierarchy + nurse service areas + customer addresses; Catalog/Variants areas = admin catalog skeleton + nurse pricing variants; Verification area = the b6 nurse-verification pipeline (submit/status/uploads/automated runs + admin review/suspend/scan + public trust badge); Search area = the b7 discovery query + admin index-rebuild; Booking area = the b8 booking-request lifecycle (create/accept/reject/cancel + role-scoped inbox/detail + the expiry sweep command); Bookings area = the b9 booking engine (convert/detail/list/transition, care-instructions submit+gated read, EVV check-in/out + today's sessions + admin EVV queue, cancel booking/session, no-show sweep, cancellation-policy CRUD); Payments area = the b10 money core (initiate/webhook/confirm-post-ledger/nurse-payable-balance); Refunds + Invoices areas = the b11 reversal leg (create refund/confirm-settlement/mark-failed [refinement-phase-6: the BNPL/manual `processing → succeeded` clearing]/write-off clawback/list/refund-status; issue invoice/get invoice); Bnpl area = the b12 provider-financed-installment checkout (eligibility/initiate/verify/settle/revert/callback/status + BookingConversion shared with b10); Payouts area = the b13 weekly payout engine (compute-eligible/generate-batch/process/retry/mark-failed + admin batch detail/list + nurse history; PayoutSettlement shared ledger+clawback-netting step); Reviews area = the b14 reviews & ratings (submit/moderate/attach-tags + public list/tag-aggregates + admin moderation-queue; RecomputeNurseRating from-source helper + ReviewCache); PatientCareRecords area = the b14 encrypted patient-scoped clinical notes (write/history under strict clinical access); + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams incl. IBankAccountOwnershipVerifier + IGeocoder + IVariantSnapshotSerializer + IShahkarVerifier + IIdentityKycProvider + ICredentialVerifier + Contracts/Reviews IReviewModerationService (AI review pre-screen seam) + the platform-signal facade contracts + Contracts/Search (INurseSearch read seam + ISearchIndexMaintainer write seam) + Contracts/Persistence per-domain repositories on IUnitOfWork incl. IVerificationRepository + IReviewRepository + IPatientCareRecordRepository), Models/, pipeline behaviors (Common/ — validators auto-registered from this assembly; VerificationAggregator + IdentityNameMatch helpers)
│ ├── Baya.Domain Entities per area (User, Identity, Geography, Catalog, Verification,
│ Search, Booking, Payments, Refunds, Invoices, Bnpl, Payouts, Reviews,
│ │ Messaging, PartnerCenters, + Configuration/Audit/Analytics/Holidays/
│ │ Notifications/SupportAlerts) · Common/ (BaseEntity, IAuditable,
│ │ [AuditRedacted]) · status-code sets + transition tables
│ └── Baya.Application Features/<Area>/{Commands|Queries}/ · Contracts/ (the seams:
│ Common, Payments, Search, Reviews, Persistence) · Models/ ·
│ pipeline behaviors (Logging → Metrics → Validate)
├── Infrastructure/
│ ├── Baya.Infrastructure.Persistence ApplicationDbContext (+ encrypted-PII value converters & phone-hash sync), ValueConversion/, Repositories/, Configuration/ (per-area EF config incl. SearchConfig/ + BookingConfig/ — b8 BookingRequest + b9 bookings/sessions/care/EVV/cancellation-policy configs & seed + ReviewsConfig/ — b14 reviews/tags-master (seeded)/tag-links/patient-care-records configs), Repositories/ (incl. b9 BookingRepository + CancellationPolicyRepository + b14 ReviewRepository + PatientCareRecordRepository), Migrations/, Interceptors/ (AuditFieldInterceptor — audit-fields + audit-log rows), Services/ (DB-backed platform-signal facades + Scheduling/ = RecurringJobSchedulerHostedService + Jobs/ (the IRecurringJob crons — refinement-phase-7) + Search/ = SearchIndexMaintainer + SqlNurseSearch)
├── Baya.Infrastructure.Identity Jwt/, Identity/ (Managers, Stores, PermissionManager, Seed, CurrentUser/)
├── Baya.Infrastructure.CrossCutting Serilog wiring + Seams/ (mock impls of the cross-cutting seams incl. LoggingSmsSender + MockBankAccountOwnershipVerifier + MockShahkarVerifier + MockIdentityKycProvider + MockCredentialVerifier + MockPaymentCaptureSimulator + MockBankTransferProvider + MockReviewModerationService) + AddCrossCuttingSeams
└── Baya.Infrastructure.Monitoring HealthChecks (live/ready split + IObjectStorage write-probe → refs Baya.Application), OpenTelemetry (one stack: metrics + tracing, opt-in OTLP)
│ ├── Baya.Infrastructure.Persistence ApplicationDbContext (+ encrypted-PII converters & phone-hash sync) ·
│ ValueConversion/ · Configuration/<Area>Config/ · Repositories/ ·
│ Migrations/ · Interceptors/ (AuditFieldInterceptor) ·
│ Services/ (DB-backed platform 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 split) · OpenTelemetry
├── API/
│ ├── Baya.Web.Api Program.cs, Controllers/V1/ (Ping + Development-only Dev (dev/last_otp OTP helper, 404 outside Development) + Auth/Me phone-OTP surface + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications + public Geo + admin AdminGeo + nurse NurseServiceAreas + customer CustomerAddresses + public Catalog + admin AdminCatalog + nurse NurseVariants + nurse NurseVerification + admin AdminVerificationStepTypes/AdminVerifications + public Nurses (trust badge) + public Search + admin AdminSearch + customer/nurse BookingRequests + admin AdminBookingRequests + customer/nurse/admin Bookings + nurse/admin BookingSessions + admin AdminEvv + admin AdminCancellationPolicies + customer PaymentsController + public WebhooksController + admin AdminRefunds/AdminClawbacks/AdminInvoices + customer Refunds/Invoices + customer CheckoutBnpl + public WebhooksBnpl + admin AdminBnpl + admin AdminPayouts + nurse NursePayouts + customer BookingReviews (submit) + owner/admin Reviews (tags + moderate status) + admin AdminReviews (moderation queue) + public Nurses (reviews + review_tags) + nurse/owner/admin PatientCareRecords), appsettings*.json
│ ├── Baya.WebFramework BaseController (incl. 401/403 OperationResult mapping), Filters/, Middlewares/, Swagger/, Routing/, ServiceConfiguration/ (rate limiting)
└── Plugins/Baya.Web.Plugins.Grpc gRPC services + .proto models (User only)
├── Shared/Baya.SharedKernel Extensions + validation base
│ ├── Baya.Web.Api Program.cs · Controllers/V1/ (55) · appsettings*.json
│ ├── Baya.WebFramework BaseController · Filters/ · Middlewares/ · Swagger/ · Routing/ ·
│ ServiceConfiguration/ (rate limiting)
│ └── 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.Tests.Setup Shared test infrastructure (SQLite, NSubstitute, TestFieldEncryptor)
├── Baya.Test.Infrastructure.Identity xUnit identity tests
├── Baya.Test.Foundation xUnit tests for cross-cutting plumbing + identity handler unit tests
└── Baya.Test.Api WebApplicationFactory integration tests (full HTTP pipeline over in-memory SQLite, env "Testing")
├── Baya.Test.Foundation Cross-cutting plumbing + identity handler unit tests
└── Baya.Test.Api WebApplicationFactory integration tests (in-memory SQLite, env "Testing")
```
**Dependency direction points inward.** Domain has no dependencies. Application depends only on
Domain. Infrastructure and API implement/consume Application contracts. Never make Domain or
Application reference Infrastructure or the API — this is a hard rule.
**DB schemas**, one per area: `usr`, `ops`, `geo`, `catalog`, `verif`, `search`, `booking`, `payments`,
`payouts`, `reviews`, `messaging`, `partner`.
**Cross-cutting seams.** Application defines mock-able external dependencies as interfaces in
`Contracts/Common/` (`IDateTimeProvider`, `IFieldEncryptor`, `ICacheService`, `IObjectStorage`,
`INotificationDispatcher`, `IGeocoder`, `IShahkarVerifier`, `IIdentityKycProvider`, `ICredentialVerifier`,
`IPaymentCaptureSimulator`, plus `ICurrentUser`). Their in-memory/local mock implementations live in
`Baya.Infrastructure.CrossCutting/Seams/` and are registered by `AddCrossCuttingSeams(configuration)`
(config section `Seams`); `ICurrentUser` is registered in the Identity layer. Swapping a mock for a
real provider is a registration change — handlers depend only on the contract. Audit fields are
stamped by `AuditFieldInterceptor` (Persistence), not in handlers.
**External rails go real — config-selected vendor adapters (refinement-phase-8).** Every vendor rail now has a
**real HTTP adapter** in `Baya.Infrastructure.CrossCutting/Seams/Real/`, **config-selected** by a per-rail
`Seams:*:Provider` selector in `AddCrossCuttingSeams` (default = the mock, so an unconfigured env is unchanged;
a typo falls closed to the mock). Real adapters use `HttpClient` (typed via `IHttpClientFactory`) +
`System.Text.Json` + BCL crypto — **no new NuGet packages**; credentials come from `Seams:*` (appsettings/env). Swapping is a registration change; **no handler is touched**. The adapters:
`KavenegarSmsSender` (`Sms:Provider=kavenegar`**launch-critical**; when a real gateway is selected the
Development OTP-in-logs bridge is **disabled**, so the OTP is never logged), `TelegramSmsSender`
(`Sms:Provider=telegram`**broadcast, not a gateway**; the pre-launch demo OTP rail: it posts to the standalone
`telegram-otp-bot/` relay, which pushes *every* code to a fixed list of Telegram chat ids, so manual testing
beats reading OTPs out of the log. It is the **one non-mock SMS provider that keeps the OTP-capture bridge
enabled** — see Startup wiring — and its `Seams:Sms:Telegram:ApiKey` is a user-secret, never committed),
`Finnotech{Shahkar,IdentityKyc,
BankAccountOwnership}` (`{Shahkar,IdentityKyc,BankOwnership}:Provider=finnotech`, shared `Seams:Finnotech`
creds), `NeshanGeocoder` (`Geocoding:Provider=neshan`), `S3ObjectStorage` (`ObjectStorage:Provider=s3` — MinIO/
S3/ArvanCloud via **manual AWS SigV4**, presigned GET = the real b6 signed-URL contract), `ZarinPalPaymentProvider`
+ `HmacWebhookVerifier` (per-provider HMAC over the raw body) + `ProviderSettlementSplitProvider`
(`Payments:Provider=zarinpal`), `SnappPayBnplProvider`/`DigipayBnplProvider` + `ConfiguredBnplProviderResolver`
(`Bnpl:Provider=real`; **`balinyaar` = in-house, resolves to the net-of-fee model, no external API**),
`JibitBankTransferProvider` (`BankTransfer:Provider=jibit`**async rail**: accepts as `submitted`, the
reconciliation callback `POST webhooks/payouts/{provider}``ReconcilePayoutBatchCommand` [HMAC-verified] flips
`submitted → paid/failed`), and `MoadianClient` (`Moadian:Provider=moadian`) with the `MoadianReconciliationJob`
`IRecurringJob` (6 h, walks `pending/submitted → registered`). **6.4:** `IPaymentCaptureSimulator` is out of the
production registration — prod gets the fail-closed `DisabledPaymentCaptureSimulator`; Dev/Testing re-register the
succeeding `MockPaymentCaptureSimulator` (the `bookings/convert` path is a Dev/Testing affordance — prod converts
via the b10 webhook confirm). **5.6:** `ICredentialVerifier`/`ILicenseVerificationService` stay mock —
**manual MoH/INO/eNamad review is the intended MVP** (no public B2B API). `ICurrencyNormalizer` is already
config-driven (the real impl). See the mocks-registry for the per-rail config keys.
**Platform-signal facades (backend-phase-1).** The cross-cutting marketplace tables live in a dedicated
**`ops` schema** (mirroring how Identity uses `usr`): `PlatformConfigs`, `AuditLogs`, `SystemEvents`,
`IranianHolidays`, `Notifications`, `SupportAlerts`. Because they are DB-backed, their Application
contracts — `IPlatformConfig` (typed cached config), `IHolidayCalendar` (bank-closure calendar),
`IAnalyticsSink` (fire-and-forget `system_events`), `IAuditLogger` (explicit append-only writes +
trail), `INotificationService` (per-user notification reads/commands), `ISupportAlertService` (internal
worklist) — are implemented in **`Baya.Infrastructure.Persistence/Services/`** and registered by
`AddPersistenceServices`, *not* in CrossCutting. The real `INotificationDispatcher` (in-app
`notifications` write) also lives there and **supersedes** the b0 log stub. Other domains call these
contracts; they never re-create the tables. The
`AuditFieldInterceptor` additionally writes an append-only `audit_logs` row for any `IAuditable` entity
(`PlatformConfig`, `PartnerCenter`, `Review`, and — refinement-phase-6 — the admin-decided money & trust
entities `Refund`, `NurseClawback`, `NursePayout`, `NursePayoutBatch`, `NurseVerification`; encrypted columns
like `NursePayout.IbanSnapshot` carry `[AuditRedacted]` so the diff records a marker, never plaintext) in the
same transaction as the change.
**Identity profiles, patients & nurse bank accounts (backend-phase-3).** On top of the b2 auth spine,
the `usr` schema gains four role-attached tables: `NurseProfiles` (1:1 with `Users`; guarded
`is_verified` with **no public setter** — flipped only by b6; read-only aggregates), `CustomerProfiles`
(thin payer extension; encrypted emergency contact), `Patients` (care recipient, tenancy-scoped to its
`customer_id`; `is_active` archive flag; encrypted `initial_medical_notes`) and `NurseBankAccounts`
(encrypted `iban` + `UNIQUE(iban_hash)` deterministic-hash duplicate guard + filtered
`UNIQUE(nurse_id) WHERE is_primary=1`). Features live under `Baya.Application/Features/Identity/{Commands|Queries}/`;
one `IEntityTypeConfiguration<T>` each in `Persistence/Configuration/IdentityConfig/`; per-domain
repositories in `Persistence/Repositories/` exposed on `IUnitOfWork` (reads project to DTOs, incl. the
masked IBAN). The **`IBankAccountOwnershipVerifier`** seam (Application `Contracts/Common`; mock
`MockBankAccountOwnershipVerifier` in CrossCutting, registered in `AddCrossCuttingSeams`) runs the mocked
استعلام شبا IBAN-owner ↔ national-id inquiry that sets `matched_national_id` (the b13 first-payout gate).
Encrypted-PII value converters for the new columns are wired in `ApplicationDbContext.OnModelCreating`
alongside the b2 `User` ones. **FluentValidation activation:** `AddApplicationServices` now registers
every `AbstractValidator<T>` in the Application assembly as `IValidator<T>` so the pre-existing
`ValidateCommandBehavior` (and the `ModelStateValidationAttribute` controller filter) actually run —
route-supplied ids (e.g. `patients/update/{id}`) must therefore **not** be validated in the body command.
**Geography, addresses & nurse service areas (backend-phase-4).** A new **`geo` schema** holds the
`Provinces` 1:N `Cities` 1:N `Districts` reference hierarchy (tables, not code lists — new regions launch
by admin insert; `is_active`/`sort_order` drive ordered, toggleable dropdowns) plus `NurseServiceAreas`
(where a nurse travels). `usr.CustomerAddresses` (identity-domain) holds saved service locations. Seeded
via `HasData` (b1 path): 31 provinces + their capital cities (covers the white-space targets) + Tehran's 22
مناطق. Features under `Baya.Application/Features/{Geography|ServiceAreas|Addresses}/`; configs in
`Persistence/Configuration/{GeographyConfig|IdentityConfig}/`; per-domain repos (`IGeoRepository`,
`INurseServiceAreaRepository`, `ICustomerAddressRepository`) on `IUnitOfWork`. Load-bearing rules:
- **`district_id = NULL` means "entire city"** — a real coverage choice, not missing data. Whole-city
uniqueness is enforced with a **filtered-index pair** (`UNIQUE(nurse_id, city_id) WHERE district_id IS
NULL …` + `UNIQUE(nurse_id, city_id, district_id) WHERE district_id IS NOT NULL …`, both `AND deleted_at
IS NULL`), because SQL Server treats NULLs as distinct. A duplicate area returns **409** (`OperationResult.ConflictResult` → new `IsConflict` → `BaseController` 409 mapping).
- **Coverage is named districts, not GPS radii.** Address lat/lng exists only for the later EVV distance
check (b9); it is never used for coverage matching.
- **Single primary address** per customer via filtered `UNIQUE(customer_id) WHERE is_primary=1 AND
deleted_at IS NULL` + clear-then-set in one transaction; the first address is primary by default.
- **Address PII** (`address_line`, `postal_code`, recipient name/phone) is encrypted at rest through
`IFieldEncryptor` (converters in `ApplicationDbContext`); decrypted only in the owner's own read.
- **`IGeocoder`** (new seam, `Contracts/Common`; mock `MockGeocoder` in CrossCutting, config
`Seams:Geocoding`) turns a typed address into deterministic `decimal` coordinates with no network call;
a config switch / `NO_GEO` marker forces the null-coordinate path.
- **Reference reads are cached** through `ICacheService` behind a generation-token key scheme (`GeoCache`);
any admin geo write bumps the token, invalidating the whole geo cache namespace at once.
**Service catalog & nurse pricing variants (backend-phase-5).** A new **`catalog` schema** holds the two-tier
service model. The **admin skeleton**`ServiceCategories``ServiceOptionGroups``ServiceOptionValues`
is intentionally **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. The **nurse layer**
`NurseServiceVariants` (the atomic **bookable unit**: FK `nurse_profiles` + category + `Price` **BIGINT IRR**
+ `PriceUnit` code + `SessionCount?` + auto-generated-but-editable `DisplayName`) + `NurseServiceVariantOptions`
(one row per answered dimension, `UNIQUE(variant_id, option_group_id)`) — turns the skeleton into priced
offerings. Features under `Baya.Application/Features/{Catalog|Variants}/`; configs +
seed in `Persistence/Configuration/CatalogConfig/`; per-domain repos (`ICatalogRepository`,
`INurseServiceVariantRepository`) on `IUnitOfWork`. Load-bearing rules:
- **The bookable unit is the variant, not the nurse.** b7 (search) and b8 (booking) operate on a variant;
keep it a clean projectable source. `price` is IRR `BIGINT` (no floats) and crosses the wire as a digit
string; the engagement total is `price` + `price_unit` + `session_count`, never `price` alone.
- **Duplicate-listing guard** = a deterministic `OptionSetHash` (see `CONVENTIONS.md`) + a filtered
`UNIQUE(nurse_id, service_category_id, option_set_hash) WHERE deleted_at IS NULL` backstop, plus a friendly
pre-check (409) — a multi-row option-set can't be a plain composite unique.
- **Applicable groups = the category's own groups + every cross-category (NULL) group** everywhere (public
browse, required-group validation, duplicate guard). All required groups must be answered; one value per
dimension; deactivate, never hard-delete (soft-delete query filters).
- **Public catalog reads are cached** through `ICacheService` behind a `CatalogCache` generation-token scheme;
any admin catalog write bumps the token.
- **`IVariantSnapshotSerializer`** (Application contract, single real impl in `Application/Common`) emits the
canonical `variant_snapshot_json` and is **consumed by b8** (which owns the `booking_requests` column);
this phase ships and unit-tests it but persists nothing. `nurse_search_index` is **b7's** (not built here).
**Search & matching (backend-phase-7).** A new **`search` schema** holds the single denormalized read model
`NurseSearchIndex` (table `NurseSearchIndices`) — **one flat row per (bookable variant × covered service
area)** (fan-out), copying the variant's category/price/unit, the covered `city_id`/`district_id`
(`district_id = NULL` = whole city), the nurse's `nurse_gender` + rating aggregates, and the single
`is_searchable` visibility gate. It is a **read-only projection**, written only by the maintainer that
re-derives it from source. Features under `Baya.Application/Features/Search/{Queries|Commands}/`; config in
`Persistence/Configuration/SearchConfig/`; the maintainer + SQL search in `Persistence/Services/Search/`.
Two seams live in `Application/Contracts/Search/`, registered by `AddPersistenceServices` (config key
`Search:Backend`, default `sql`):
- **`INurseSearch`** (read) — impl `SqlNurseSearch` reads **only `is_searchable = 1`** rows, applies the
category/city/district/gender/price filters + rating sort + pagination. The real MVP backend; a later
`ElasticNurseSearch` is a config-selected drop-in and callers depend only on the interface.
- **`ISearchIndexMaintainer`** (write, the "ISearchIndexWriter" shape) — `SearchIndexMaintainer` keeps the
index consistent **inline, inside the source write's own unit of work** (single `CommitAsync`), invoked
from the b3/b4/b5/b6 handlers that own each source row: `ReindexVariantAsync` (variant create/edit/toggle),
`ReindexNurseAsync` (verification flip / suspend / accepting-toggle / rating recompute),
`FanOutServiceAreaAsync` + `RemoveServiceAreaRowsAsync` (area add/remove), and `RebuildAsync` (idempotent
full rebuild — the admin `POST admin_search/rebuild_index` job). It shares the request-scoped
`ApplicationDbContext`, so it only *stages* changes; the handler's commit flushes source + 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. Load-bearing rules:
- **`is_searchable = 1` only when** nurse `is_verified = 1` AND `nurse_verifications.status != 'suspended'`
AND `is_accepting_bookings = 1` AND variant `is_active = 1` — recomputed on every relevant source write.
An unverified/paused/suspended/deactivated nurse or variant must **never** surface.
- **`district_id = NULL` = 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 (whole-city) rows. Uniqueness
(`UNIQUE(variant_id, city_id, district_id) WHERE deleted_at IS NULL`) uses the filtered-index pair (the
`nurse_service_areas` trick) so NULL participates on SQL Server; the maintainer resurrects a soft-deleted
row on re-upsert so each (variant × area) has exactly one live row.
- **Incremental maintenance and full rebuild must converge** — the index is fully re-derivable from source.
**Booking requests — pre-payment intent (backend-phase-8).** A new **`booking` schema** holds the single
table `BookingRequests` — the **money-free** first half of the engagement lifecycle (`bookings` + money are
b9/b10). One customer requests one nurse for a patient/variant/address/date; the nurse accepts (opening a
30-minute payment window) or rejects before a frozen response deadline; unanswered/unpaid requests auto-expire.
Features under `Baya.Application/Features/Booking/{Commands|Queries}/`; config in
`Persistence/Configuration/BookingConfig/`; per-domain repo (`IBookingRequestRepository`) on `IUnitOfWork`;
the recurring expiry sweep is the `booking_request_expiry` `IRecurringJob` run by the scheduler (see
"Unattended operation" below — refinement-phase-7 re-homed it from a standalone hosted service). Load-bearing rules:
- **No money, ever, and no `bookings` row.** A request carries no price/total; accept only opens the payment
window. b9 consumes an `accepted_awaiting_payment` request → creates the booking → sets it `converted`.
- **Two-stage clinical disclosure (stage 1).** The nurse sees **only** the unencrypted, limited `customer_notes`
(never routed through `IFieldEncryptor`); the nurse view of a request **masks the full address** (line/postal/
recipient) to a coarse city/district. The encrypted `booking_care_instructions` are b9's stage 2.
- **Tenancy invariant.** patient + address ∈ the caller's `customer_id`; variant ∈ the requested `nurse_id`.
Resolved from `ICurrentUser`, never the body; a mismatch is a clean 404.
- **Same-gender match at request time.** `required_caregiver_gender` (`male`/`female`/`any`) is matched against
the nurse's `User.Gender`; required on create, never silently defaulted.
- **Deadlines frozen from config.** `nurse_response_deadline_at` = `now + nurse_response_deadline_hours` at
create; `payment_deadline_at` = `now + booking_payment_deadline_minutes` (30) at accept — both stored as
absolute UTC `datetime2` so a later config change can't move them. Stored as `DateTime` (not `DateTimeOffset`)
because they are compared/sorted in queries and the SQLite test provider can't translate `DateTimeOffset`.
- **Forward-only status guard** (`BookingRequestTransitions`) — every write is pre-checked; an illegal edge is a
409, terminal states have no outgoing edge; the expiry sweep's `WHERE status = …` predicate is the concurrency
guard (a row a racing accept/cancel moved is simply not reloaded). See CONVENTIONS §6.
**Bookings, sessions, EVV & cancellation (backend-phase-9).** The `booking` schema gains the five post-payment
tables — `Bookings`, `BookingSessions`, `BookingCareInstructions`, `VisitVerifications`, `CancellationPolicies`
(entities in `Domain/Entities/Booking/`, configs in `Persistence/Configuration/BookingConfig/`, one migration).
A `bookings` row exists **only** when the nurse accepted **and** payment was captured: `ConvertRequestToBooking`
reads an `accepted_awaiting_payment` request, confirms a capture, and creates the booking 1:1 (`pending_payment →
confirmed`), fanning out N `booking_sessions`. Features under `Baya.Application/Features/Bookings/{Commands|Queries}/`
(namespace **plural** `Bookings` — distinct from b8's singular `Booking`; the entity type `Booking` is aliased where
the two collide); per-domain repos `IBookingRepository` + `ICancellationPolicyRepository` on `IUnitOfWork`;
controllers `BookingsController` / `BookingSessionsController` / `AdminEvvController` / `AdminCancellationPoliciesController`.
Load-bearing rules:
- **Money is IRR `BIGINT`, three amounts reconcile.** `gross_price_irr = balinyaar_commission_irr + nurse_payout_amount`
(all ≥ 0) is a **DB CHECK** and handler invariant; commission = integer-round(`gross × platform_fee_rate`) with the
rate **snapshotted** onto the booking; `nurse_payout_amount` is derived, never free-entered. `Σ(visit_payout_amount)
= nurse_payout_amount` exactly (integer split, remainder on the last session — `BookingAmounts`). The
`payout_released` boolean was **cut** — paid-ness is derived later (b13). On the wire money is a **digit string**.
- **Snapshots freeze history.** `variant_snapshot_json` (via `IVariantSnapshotSerializer`), the **encrypted**
`address_snapshot_json`, `platform_fee_rate`, and the resolved cancellation `code` + `refund_percentage` are frozen
at their moment; later edits to the source variant/address/policy never mutate an existing booking.
- **Two-stage clinical disclosure (stage 2).** `booking_care_instructions` (all fields **encrypted** through
`IFieldEncryptor`) are readable **only post-confirmation** and **only** by the **assigned nurse + admin**
`GetCareInstructionsQuery` enforces it; the fields are never projected into a list or logged.
- **EVV is per session; mismatch is advisory.** `visit_verifications` FK is on `booking_session_id`. Check-in computes
the distance to the frozen booking address (reusing `IGeocoder` + `GeoDistance` haversine) against
`evv_location_tolerance_meters`; a mismatch raises a `location_mismatch` `support_alerts` + notifies **without
blocking**. GPS-denied still checks in (flagged null).
- **`SetDisputeWindow` is the only payout-eligibility trigger.** Booking completion (last check-out, or all sessions
settled) sets `dispute_window_ends_at = completed_at + config(dispute_window_hours, 72)` and each completed session's
`payout_eligible_at`; b13 gates payout on those, never on `completed` alone.
- **Cancellation snapshots the policy + refunds only un-started sessions.** The applicable `cancellation_policies` tier
is resolved by `(actor, lead-time bucket)` and its `code` + `refund_percentage` + computed refundable amount are
frozen onto the booking; only still-`scheduled` sessions are refundable; **no refund ledger is posted (b11)**.
- **`IPaymentCaptureSimulator`** (Application `Contracts/Common`; mock `MockPaymentCaptureSimulator` in CrossCutting,
registered in `AddCrossCuttingSeams`, config `Seams:PaymentCapture`) is the **temporary conversion trigger** — b10's
real card capture replaces it by calling `ConvertRequestToBooking` directly on a `succeeded` transaction. The no-show
sweep (`DetectNoShowSessions`) is admin/test-triggered; its recurring cron is DEFERRED (like b8's expiry sweep).
**Payments core — ledger, transactions, webhooks & card capture (backend-phase-10).** A new **`payments`
schema** holds the money core: `PaymentGateways` (config per PSP; **encrypted `config_json`**;
selection by `type`+`priority`), `PaymentTransactions` (every attempt; the **two filtered uniques**
`UNIQUE(gateway_reference_code) WHERE NOT NULL` and `UNIQUE(booking_id) WHERE status='succeeded'` — are the
anti-double-capture backstop), `PaymentWebhookEvents` (the idempotency store; **`UNIQUE(provider_code,
external_event_id)`**), and the **append-only** `LedgerEntries` (double-entry source of truth). Entities in
`Domain/Entities/Payments/` (+ `LedgerPosting` balanced-group builder, `LedgerAccountType`/`PaymentTransactionStatus`/
`WebhookProcessingStatus`/`PaymentGatewayType` code sets); configs in `Persistence/Configuration/PaymentsConfig/`;
one migration (`PaymentsCoreLedger`). Features under `Baya.Application/Features/Payments/{Commands|Queries}/`
(`InitiatePayment`, `HandlePaymentWebhook`, `ConfirmPaymentAndPostLedger`, `GetNursePayableBalance`);
`IPaymentRepository` on `IUnitOfWork`; controllers `PaymentsController` (`POST bookings/{id}/payments`),
`WebhooksController` (public `POST webhooks/payments/{provider}`), `NursePayableBalanceController`
(`GET nurses/{id}/payable_balance`). Load-bearing rules:
- **A `bookings` row exists only on capture (b9).** So a payment is initiated against the
`accepted_awaiting_payment` **request**; `payment_transactions.booking_id` is **nullable**, bound only when
the confirm creates/loads the booking. Confirm reuses b9 via the extracted **`BookingFactory`** (shared
conversion/amount logic) rather than re-implementing it — the mock `IPaymentCaptureSimulator` Convert path
stays for b9's own tests.
- **Idempotency ordering:** `HandlePaymentWebhook` **upserts the webhook event first** on `(provider,
external_event_id)` and **no-ops on a duplicate**; on a new success event it **re-verifies server-side**
(`IPaymentProvider.VerifyAsync`) then dispatches `ConfirmPaymentAndPostLedger`, all under
`IDistributedLock(booking-request:{id}:payment)`. A unique-violation on confirm is treated as an
**idempotent no-op success**, not an error.
- **The card-capture group is balanced:** `LedgerPosting.CardCapture` posts DEBIT `escrow_held` gross =
CREDIT `platform_revenue` commission + `nurse_payable` payout under one `transaction_group_id`
(Σdebit = Σcredit; throws if the three frozen amounts don't reconcile). `ledger_entries` is **append-only**
(implements `IEntity` only — no `ITimeModification`, so the audit interceptor never stamps it; no soft-delete).
- **Escrow IS the ledger.** `GetNursePayableBalance` is the **signed sum** over `nurse_payable` legs — never a
stored column. The lawful split is **تسهیم via `ISettlementSplitProvider`** to registered IBANs (the platform
never moves money).
- **Four money-path seams** in `Application/Contracts/Payments/``IPaymentProvider`,
`ISettlementSplitProvider`, `IWebhookVerifier`, `IDistributedLock` — with faithful mocks in
`CrossCutting/Seams/` (`MockPaymentProvider`, `MockSettlementSplitProvider`, `MockWebhookVerifier`,
`InProcessDistributedLock`), registered by `AddCrossCuttingSeams`. `payment_gateways.config_json` is
encrypted through the b0 `IFieldEncryptor` (converter wired in `ApplicationDbContext`).
**Refunds, clawbacks & invoices (backend-phase-11).** The `payments` schema gains three tables — `Refunds`,
`NurseClawbacks`, `Invoices` (+ the single-row `InvoiceNumberSequences` counter) — entities in
`Domain/Entities/Refunds/` + `…/Invoices/`, configs in `Persistence/Configuration/{RefundsConfig|InvoicesConfig}/`,
one migration (`RefundsClawbacksInvoices`). Features under `Baya.Application/Features/{Refunds|Invoices}/`;
per-domain repos `IRefundRepository` + `IInvoiceRepository` on `IUnitOfWork`; controllers `AdminRefundsController`
/ `AdminClawbacksController` / `AdminInvoicesController` (admin policy, rate-limited) + customer-facing
`RefundsController` (`refunds/{id}/status`) / `InvoicesController` (`invoices/{booking_id}`). Load-bearing rules:
- **A refund decomposes across both fee legs and reverses the ledger.** `CreateRefundCommand` (the whole
money-path under `lock(booking:{id}:refund)`) reads the booking's frozen split + b9 cancellation snapshot +
captured transaction (`IRefundRepository.GetRefundContextAsync`), splits `amount = platform_fee_refunded_irr +
nurse_payout_refunded_irr` pro-rata at the resolved %, enforces **`Σ refunded ≤ captured`** (handler backstop),
executes the channel behind its seam, and posts the balanced reversal via **b10's `LedgerPosting`** helper
(extended with `RefundReversalPrePayout` / `ClawbackReversalPostPayout` / `RefundPayableClearing` /
`ClawbackWriteOff`). The channel-execution/ledger "internal step" commands from the phase are cohesive private
steps in the handler (mirroring b10's `ConfirmPaymentAndPostLedger`) so they stay atomic.
- **Pre-payout reversal vs post-payout clawback fork.** `INursePayoutStatus` (Application `Contracts/Payments`;
DB-backed `NursePayoutStatusService` in `Persistence/Services/Payments`) answers "was the nurse already paid?"
— pre-payout debits `nurse_payable` (clean reversal); post-payout debits `nurse_clawback_receivable` **and**
opens a `pending` `nurse_clawbacks` row + raises a `nurse_clawback` support alert, because an Iranian IBAN
transfer is irreversible. Until b13 ships `nurse_payouts`, "paid?" is derived from the booking's
`dispute_window_ends_at` close (+ a `refund_assume_nurse_paid` config override); b13 swaps the registration.
Clawback **recovery/netting is b13** — this phase only opens the receivable + supports admin `write_off`.
- **Channel parity.** `psp_card` and `bnpl_revert` post the **same** reversal legs — only the channel, the
external reference (`gateway_refund_reference` vs `external_revert_reference`), and the ETA differ (card =
immediate `succeeded` + clearing posts now; BNPL = `processing` + `expected_customer_refund_eta` ≈ now + config
business days, clearing deferred to reconciliation). The `refund_payable ↔ escrow_held` clearing posts only
once the customer cash-back confirms — **reached (refinement-phase-6) by `ConfirmRefundSettlementCommand`**
(admin `POST admin_refunds/{id}/confirm_settlement` + the BNPL cash-back callback branch), which transitions
`processing → succeeded`, stamps the settled instant, and posts `LedgerPosting.RefundPayableClearing` in the
same commit (idempotent under `booking:{id}:refund`); `MarkRefundSettlementFailedCommand` (`.../mark_failed`)
is the counterpart. **The refund row is now persisted (approved) *before* the external channel call** — the
crash-window fix (claim-first / execute-second), matching the webhook handler.
- **Invoices: VAT on the commission line only, sequential number.** `IssueInvoiceCommand` computes
`vat_irr = round(platform_commission_irr × vat_rate)` (config `vat_rate`, default 0.10; `vat_rate = 0` ⇒ 0),
never on the nurse payout, and draws a gap-free `invoice_number` from the `InvoiceNumberSequences` counter row
(locked + committed with the invoice, portable across SQL Server/SQLite — no DB sequence). Idempotent per
booking (`UNIQUE(booking_id)`). `IMoadianClient` (introduced here; `MockMoadianClient` in CrossCutting) submits
to سامانه مودیان — mock leaves `moadian_status = pending` / no ref (config can force `registered`).
- **Forward-dep columns — FKs added in refinement-phase-6.** `refunds.ticket_id` (→ `messaging.Tickets`),
`nurse_clawbacks.original_payout_id` / `recovered_in_payout_id` (→ `payouts.NursePayouts`),
`invoices.partner_center_id` (→ `partner.PartnerCenters`, + index) now carry real FKs (`ON DELETE NO ACTION`;
all nullable) — b15 unconditionally auto-opens the refund ticket so `refunds.ticket_id` is always non-null, and
the orphaned `refund_ticket_required` config key was retired (its rule had no consumer left). The
data-model's `manual_bank` channel is stored/served as the canonical wire code **`manual`**. `IBnplProvider` is
introduced here as a **thin local stub** so the `bnpl_revert` path runs before b12 merges — **b12 owns the real
seam definition**.
**BNPL — provider-financed installments (backend-phase-12).** The `payments` schema gains one table —
`BnplTransactions` (entity in `Domain/Entities/Bnpl/`, config in `Persistence/Configuration/BnplConfig/`, one
migration `BnplTransactions`) — **1:1 with its `payment_transaction`** (`UNIQUE(payment_transaction_id)`).
A BNPL order is, in our books, **a card payment that lands net-of-fee**: there is no customer-installment
tracking (the provider owns the schedule + 100% default risk). Features under
`Baya.Application/Features/Bnpl/{Commands|Queries}/` (eligibility/initiate/verify/settle/revert/callback/status);
per-domain repo `IBnplRepository` on `IUnitOfWork`; controllers `CheckoutBnplController` (customer, rate-limited)
/ `WebhooksBnplController` (anonymous, signature-verified, rate-limited) / `AdminBnplController` (admin,
rate-limited). The b10 booking-conversion path was extracted to the shared **`Features/Bookings/BookingConversion`**
helper (used by both the card `ConfirmPaymentAndPostLedger` and the BNPL settle). Load-bearing rules:
- **Forward-only `BnplStatus` state machine** (`eligible → token_issued → verified → settled →
reverted/cancelled/failed`, `BnplTransitions`), mutated only through the entity's mark-* methods — the
idempotency spine. A replayed settle/revert that would re-drive a completed transition is an idempotent no-op.
- **Settle posts the net-of-fee group via `LedgerPosting.BnplSettle`** — the card-capture legs **plus** `DEBIT
bnpl_fee_expense / CREDIT escrow_held` for the provider commission, one balanced `transaction_group_id`, so
escrow reflects the **net** cash (`settled_amount_irr = order commission`). Settle confirms the parent
`payment_transaction` (which triggers the booking conversion) exactly like the 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 assumed instant; the commission is read from the
actual settlement, never hardcoded. **Currency is normalized to IRR at the provider boundary only**.
- **Revert reuses the b11 refund path** (`CreateRefundCommand` with `refund_channel='bnpl_revert'`) — money
flows customer ↔ provider ↔ Balinyaar only; the async ~710-business-day customer ETA is surfaced.
- **Two new seams** in `Application/Contracts/Payments/`: **`IBnplProvider`** (the full SnappPay-superset verb
set, superseding b11's revert-only stub; the b11 refund path still injects it) selected per `provider_code`
by **`IBnplProviderResolver`**, and **`ICurrencyNormalizer`** (Toman↔IRR at the boundary). Mocks
(`MockBnplProvider`/`MockBnplProviderResolver`/`MockCurrencyNormalizer`) in `CrossCutting/Seams/`, registered by
`AddCrossCuttingSeams`. `bnpl_settlement_entries` (tranched settlement) is **DEFERRED — modeled-but-not-built**.
**Weekly nurse payouts (backend-phase-13).** A new **`payouts` schema** holds the money-out engine: three tables
`NursePayoutBatches` (weekly aggregation, holiday-shifted `period_end`/`processing_date`), `NursePayouts`
(one row per nurse per batch; the `net = gross clawback` split as a DB CHECK; **encrypted `iban_snapshot`**
frozen from the verified primary account) and `NursePayoutBookingLinks` (**`UNIQUE(booking_id)` unconditional** —
the structural one-payout-per-booking-ever guard). Entities in `Domain/Entities/Payouts/`; configs in
`Persistence/Configuration/PayoutsConfig/`; one migration (`NursePayoutEngine`). Features under
`Baya.Application/Features/Payouts/{Commands|Queries}/` (compute-eligible / generate-batch / process / retry /
mark-failed + admin batch-detail/list + nurse history), with the shared **`PayoutSettlement`** step (payout
ledger post + clawback netting); per-domain repo `IPayoutRepository` on `IUnitOfWork`; controllers
`AdminPayoutsController` (admin, rate-limited) / `NursePayoutsController` (nurse, tenancy-scoped). Load-bearing rules:
- **Payout 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. There is
no `payout_released` boolean — paid-ness is derived from a `nurse_payout_booking_links` row + the ledger.
- **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 the backstop.
- **The payout drains `nurse_payable`.** `ExecutePayoutBatch` posts `DEBIT nurse_payable / CREDIT escrow_held` for
the paid net (b10's `LedgerPosting.NursePayout`); a netted clawback posts `DEBIT nurse_payable / CREDIT
nurse_clawback_receivable` (`LedgerPosting.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). Forward-only `PayoutStatus` machine + the ledger-exists
guard + a batch idempotency key make a retried process never double-send an irreversible transfer.
- **Holiday-aware.** `period_end`/`processing_date` shift off `is_bank_closed` days via **`IHolidayCalendar`**;
retry refuses on a bank-closed day. **First-payout gate:** only a `is_primary=1 AND is_verified=1 AND
matched_national_id=1` account is paid; a nurse without one is skipped with a recorded reason.
- **`IBankTransferProvider`** (new seam, `Contracts/Payments`; mock `MockBankTransferProvider` in `CrossCutting/Seams/`,
config `Seams:BankTransfer`) is the mocked PAYA/SATNA rail — PAYA vs SATNA chosen by the
`payout_satna_threshold_irr` config; a config switch forces whole-batch/single-row failures. b13 also swaps the
`INursePayoutStatus` registration to the authoritative **`NursePayoutLinkStatusService`** (a booking is paid iff
linked to a `paid` payout), superseding the b11 dispute-window derivation. The weekly **cron trigger is DEFERRED**
(batches are admin-triggered; cadence in `nurse_payout_interval_days`); the BNPL `settled_at` guard is the
default-off `require_bnpl_settlement_for_payout` config flag.
**Reviews, ratings & patient care records (backend-phase-14).** A new **`reviews` schema** holds four tables:
`Reviews` (one per completed booking — `UNIQUE(booking_id)`, `CHECK(rating 15)`, `moderation_status` code +
guarded moderation fields; `IAuditable` so the interceptor audits every transition), `ReviewTagsMaster` (seeded
tag vocabulary, `UNIQUE(code)`), `ReviewTagLinks` (N:N, `UNIQUE(review_id, review_tag_master_id)`), and
`PatientCareRecords` (nurse-authored, **encrypted, patient-scoped** clinical notes; `(patient_id, recorded_at)`
index). Entities in `Domain/Entities/Reviews/`; configs in `Persistence/Configuration/ReviewsConfig/`; per-domain
repos `IReviewRepository` + `IPatientCareRecordRepository` on `IUnitOfWork`; features under
`Baya.Application/Features/{Reviews|PatientCareRecords}/`; controllers `BookingReviewsController` (submit) /
`ReviewsController` (tags + moderate) / `AdminReviewsController` (queue) / `NursesController` (public reviews +
review_tags) / `PatientCareRecordsController`. Load-bearing rules:
- **Reviews are for completed/closed bookings only, owned by the caller, 1:1.** The `UNIQUE(booking_id)` is the
backstop; the handler pre-checks and returns a clean `OperationResult` (409 on a duplicate, not a raw DB error).
A cross-tenant booking is a 404, never a leak.
- **Recompute the nurse aggregate from source on EVERY transition — not a delta.** `RecomputeNurseRating`
(`Features/Reviews/`) reads `COUNT`/`SUM(rating)` over the nurse's currently-`published` reviews **excluding the
transitioning review**, folds in that review's *new* status in memory, sets `nurse_profiles.average_rating`/
`total_reviews` (guarded `NurseProfile.SetReviewAggregates`), and stages the b7 `ReindexNurseAsync` refresh — 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.
- **Publish gate — `pending_moderation` is never public.** `ListReviewsForNurse` and the aggregate count
`published` only, filtered at the query layer. The public aggregate read is cached (`ReviewCache`) and evicted on
every transition.
- **Low rating raises a `support_alert` reliably.** `rating <= min_rating_for_support_alert` (config, default 2)
`RaiseSupportAlert(low_rating)` in the same flow (after the main commit, never silently swallowed).
- **`patient_care_records` are patient-scoped (not booking-scoped) + encrypted + strict access.** `body_encrypted`
holds `IFieldEncryptor` ciphertext with **no EF value converter** — the handler encrypts on write and decrypts
only after the access check passes (owning customer / nurse with a confirmed booking / admin; anyone else 403).
- **`IReviewModerationService`** (new seam, `Contracts/Reviews`; mock `MockReviewModerationService` in CrossCutting,
config `Seams:ReviewModeration`) is the AI pre-screen; clean text stays pending by default (publish gate),
banned-word → auto-hidden. Decision authority stays with `ModerateReviewCommand` (human override).
**Messaging, partner centers & admin backoffice (backend-phase-15).** The final backend phase adds two schemas
and consolidates the admin surface. A new **`messaging` schema** holds `Tickets` / `TicketParticipants` /
`TicketMessages` (entities in `Domain/Entities/Messaging/` + `TicketStatus`/`TicketCategory`/`TicketParticipantRole`
codes) — the only sanctioned post-booking channel. A new **`partner` schema** holds `PartnerCenters` (entity in
`Domain/Entities/PartnerCenters/`, `IAuditable`; the licensed sponsor / merchant-of-record). Configs in
`Persistence/Configuration/{MessagingConfig|PartnerCentersConfig}/`; per-domain repos `ITicketRepository` +
`IPartnerCenterRepository` on `IUnitOfWork`; features under `Baya.Application/Features/{Messaging|PartnerCenters}/`;
controllers `TicketsController` / `AdminTicketsController` / `AdminPartnerCentersController` / `CentersController`
/ `InternalCentersController`; one migration (`MessagingAndPartnerCenters`, which also adds the
`nurse_profiles.partner_center_id` FK in place). Load-bearing rules:
- **`is_internal` is a HARD visibility boundary enforced at the QUERY layer.** `GetTicketThreadQuery` takes an
`AsAdmin` flag; the user view (`false`) strips every `is_internal` message in the repository projection
(`GetMessagesAsync(includeInternal:false)`), the admin view (`true`, staff only) returns them. A non-staff
caller can never *set* `is_internal` on `PostMessage` nor *read* one. Never enforced only in the UI.
- **No direct nurse↔customer channel.** All post-booking comms are ticket-mediated + admin-readable; participation
(via `TicketParticipant`, `UNIQUE(ticket_id, user_id)`, soft-remove via `removed_at`) plus staff is the auth
boundary. `reference_code` is minted once (collision-checked, UNIQUE) and stable. Both `booking_id`/`refund_id`
links are nullable — handle a ticket with neither. A coordination ticket is auto-created (idempotent, one per
booking) on confirmation via `AutoCreateCoordinationTicketCommand`, dispatched from the card confirm + BNPL
settle handlers. `LogEmergencyTicket` records the aftermath of an out-of-platform emergency call (+ optional
`support_alert`) — it exposes no phone number.
- **Merchant-of-record resolution follows `partner_centers`, not a hardcoded platform.**
`PartnerCenterRepository.ResolveCenterForBookingAsync` (surfaced by `GetCenterForBookingQuery`, endpoint
`GET /internal/bookings/{id}/center`) resolves booking → nurse → `partner_center_id`; the issuer/settlement
target is `partner_center` **only** when that center `is_merchant_of_record`, else `platform`. This is the
single resolver **b11's `IssueInvoice` now calls** to set `invoices.issuing_entity_type` + `partner_center_id`.
- **`partner_centers``organizations`.** The launch licensing *sponsor* (`partner_centers`) is distinct from
the future *employer* (`organizations`, DEFERRED). `settlement_iban` is encrypted at rest (converter in
`ApplicationDbContext`, `[AuditRedacted]`) and **masked** (last 4) in every read; `commission_rate` (the
center's cut) is separate from `platform_fee_rate`. The four DEFERRED tables (`organizations`,
`organization_nurses`, `fraud_flags`, `recurring_booking_schedules`) are **not** created.
- **Refund↔ticket link wired.** `CreateRefundCommand` (b11) now auto-opens a `category=refund` ticket via
`OpenTicketCommand` when the caller supplies none, so `refunds.ticket_id` is always non-null.
- **Backoffice consolidation surfaces, doesn't rebuild.** The support-alert worklist (`ISupportAlertService`
List/Assign/Resolve — `SupportAlertsController`) and the audit viewer (`GetAuditTrail``AuditController`)
already existed since b1 and are reused as-is; verification/refund/payout/moderation surfaces are their own
phases'. New seam **`ILicenseVerificationService`** (`Contracts/Common`; mock `MockLicenseVerificationService`
in CrossCutting, config `Seams:LicenseVerification`, `AutoApprove` toggle) is the eNamad / MoH permit check —
manual-approve at MVP; `VerifyPartnerCenter` records the human decision. There is **no** telephony/VoIP seam
(the emergency call is an out-of-platform `tel:` link by design). This is the last backend phase.
**Unattended operation — the recurring-job scheduler (refinement-phase-7).** A single in-process scheduler,
`Persistence/Services/Scheduling/RecurringJobSchedulerHostedService`, drives every registered `IRecurringJob`
(`Services/Scheduling/Jobs/`) on its own cadence — replacing the two stand-alone `PeriodicTimer` hosted services
and giving the previously admin-manual sweeps a schedule, **using no new infrastructure** (SQL Server stays the
only external dependency). Jobs, each reading its seeded `platform_configs` cadence key via `IPlatformConfig`:
`booking_request_expiry` (1 min const) · `notification_retention` (24 h const) · `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, refinement-phase-8) ·
`audit_log_retention` (`audit_retention_scan_cadence_hours`, refinement-phase-9). Load-bearing rules:
- **Add a cron = implement `IRecurringJob` + one `AddSingleton<IRecurringJob, …>()`** in `AddPersistenceServices`.
Phase 8 registers the Moadian reconciliation + refund-settlement poll exactly this way. 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/state-machines are the backstop. Each tick runs under
`IDistributedLock("scheduler:{name}")` — in-proc today, the **>1-instance scale-out gate** (swap the seam to
Redis to serialize ticks across nodes; 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 — `NursePayoutBatch.InitiatedByAdminId` is nullable = "no human initiator"); the irreversible
`process` step remains an explicit admin action. The command's `SystemInitiated` flag is scheduler-only —
`AdminPayoutsController` neutralizes any request-supplied value.
- **Admin manual triggers remain overrides** (the same idempotent commands). The scheduler is **dormant under the
`Testing` environment** so integration tests stay deterministic; each job/command is unit-tested directly.
- **Audit-log retention (refinement-phase-9 §9.4)** is an `IRecurringJob` (`AuditLogRetentionJob`) over the
append-only `ops.AuditLogs`: a **two-tier** sweep via `IAuditLogger.PurgeExpiredAsync` — financial/verification
entity types (`Refund`/`NurseClawback`/`NursePayout`/`NursePayoutBatch`/`NurseVerification`/`PlatformConfig`/
`PartnerCenter`) keep `audit_retention_financial_days` (default 2555 ≈ 7 yr); everyday rows
`audit_retention_general_days` (default 730 ≈ 2 yr). Oldest-first, capped, id-keyed delete; idempotent.
**Observability (refinement-phase-9).** One **OpenTelemetry** stack (`Baya.Infrastructure.Monitoring`,
`SetupOpenTelemetry`): metrics (runtime + ASP.NET Core + the `mediator_meter` histogram) scraped at `/metrics` via
the OTel Prometheus exporter, and **tracing** (ASP.NET Core + EF Core) sharing `service.name = Baya.Web.Api`. The
duplicate prometheus-net stack was removed. **OTLP export (traces + metrics) is opt-in** — wired only when
`OpenTelemetry:Otlp:Endpoint` is set, so an MVP with Prometheus alone runs unchanged. `ApiResult.RequestId` is the
W3C trace id (`Activity.Current.TraceId`, `Activity.DefaultIdFormat = W3C`), so a support ticket maps 1:1 to a
trace. **Health checks split** (`ConfigureHealthChecks`/`UseHealthChecks`): `/healthz/live` (process, dependency-
free), `/healthz/ready` (app DB + `logDb` [deployed only] + an `IObjectStorage` write-probe), `/HealthCheck`
(aggregate, kept for compat). **Logs:** deployed envs write **Information+** to `Baya_Logs` (framework categories
held at Warning); **no PII/secrets** — the mock SMS sender never logs the OTP code; clinical text/IBANs are
encrypted/masked. The dead Elasticsearch sink + package were removed (SQL sink is the deployed default; set the
OTLP collector to ship logs off-box). **gRPC reflection is Development-only** (`GrpcPluginStartup` gates
`AddGrpcReflection`/`MapGrpcReflectionService` on `IsDevelopment`); the plugin shares the mixed-protocol Kestrel
listener. **`TicketMessage.Body` is encrypted at rest** through `IFieldEncryptor` (converter in
`ApplicationDbContext`; column widened to `nvarchar(max)`; the 4000-char cap stays a boundary-validation rule) —
ticket bodies are the refund/dispute paper trail (phone numbers, addresses, clinical detail).
**Keeping the Project map current.** When a change touches the architecture — adds, removes, or
renames a project/assembly, a Clean-Architecture layer, or a major folder, or changes a cross-layer
dependency — you **must** update this Project map (and the dependency rule above, if affected) in the
**same** change. This is the server-specific form of the root "Keep docs honest" rule: the map is
only canonical if it stays accurate.
> `Features/Booking` (singular — the money-free pre-payment request) and `Features/Bookings` (plural — the
> post-payment engine) are **different areas, not a rename.** The entity type `Booking` is aliased where the
> namespaces collide.
---
## Startup wiring
## Where to read more
Service registration is composed from per-layer extension methods (each project's `ServiceConfiguration/`):
Open **one** of these for the area you are touching.
```
builder.ValidateRequiredSecrets() // refinement-phase-5: fail fast on missing/placeholder DB + crypto secrets
ConfigureHealthChecks() · SetupOpenTelemetry() // refinement-phase-9: live/ready health split + object-storage probe; one OTel stack (metrics + tracing, opt-in OTLP)
AddApplicationServices() // Mediator + pipeline behaviors (Logging → Metrics → Validate)
RegisterIdentityServices(…, requireHttpsMetadata) // Identity, JWT/JWE (RequireHttpsMetadata on outside Dev/Testing), ICurrentUser
AddPersistenceServices(...) // DbContext (+ AuditFieldInterceptor), UnitOfWork, repositories, the IRecurringJob crons + RecurringJobSchedulerHostedService (refinement-phase-7)
AddCrossCuttingSeams(config) // IDateTimeProvider, IFieldEncryptor, ICacheService, IObjectStorage, INotificationDispatcher (mocks)
AddWebFrameworkServices() // API versioning + snake_case routing
AddCorsPolicies(config) // browser CORS policy from Cors:AllowedOrigins (refinement-phase-0; default http://localhost:3000 in Dev)
AddForwardedHeadersConfiguration(config) // refinement-phase-5: trust ForwardedHeaders:KnownProxies/KnownNetworks so the rate limiter sees the real client IP behind a proxy
AddRateLimitingPolicies() // built-in rate limiter: per-resolved-IP global + named (otp/auth/sensitive/webhook)
AddSwagger("v1", "v1.1") · RegisterValidatorsAsServices() · AddMapster()
ConfigureGrpcPluginServices(builder.Environment) // refinement-phase-9: gRPC reflection registered only in Development
// Development-only: AddDevelopmentOtpCapture() (refinement-phase-0) decorates the registered ISmsSender to
// capture each OTP in-memory for the GET /api/v1/dev/last_otp/{phone} helper — never wired outside Development,
// and only for a capture-safe Seams:Sms:Provider (`mock` / unset, or the Development-only `telegram` relay).
// A real gateway (kavenegar) disables it, so the code only ever leaves the process over the SMS wire.
```
Pipeline order: **forwarded headers** → exception handler → Swagger → routing → **CORS → rate limiter →
authentication → authorization** → controllers → metrics → health checks → gRPC. `UseForwardedHeaders()`
(refinement-phase-5) is **first** so the resolved client IP (`X-Forwarded-For` from a trusted proxy) is in
place before the rate limiter partitions on it. `UseCors(...)` (refinement-phase-0) sits **after
`UseRouting()` and before `UseRateLimiter()`** so a pre-flight `OPTIONS` is answered before the limiter/auth
run; `UseRateLimiter()` is placed **before** `UseAuthentication()` so over-limit auth/OTP attempts are
rejected (`429`) before hitting the auth stack.
When adding new infrastructure, expose it as an extension method and call it from `Program.cs`
never inline registrations there directly.
---
## CQRS — how a feature is shaped
Features live under `Baya.Application/Features/<Area>/{Commands|Queries}/<Name>/`:
```
Features/<Area>/
├── Commands/<VerbNoun>Command/
│ ├── <VerbNoun>Command.cs record : IRequest<OperationResult<T>>
│ ├── <VerbNoun>Command.Handler.cs internal sealed class : IRequestHandler<...>
│ └── <VerbNoun>Command.Validator.cs
└── Queries/<VerbNoun>Query/
├── <VerbNoun>Query.cs
├── <VerbNoun>Query.Handler.cs
└── <VerbNoun>Query.Result.cs
```
A minimal live example shipped in backend-phase-0: `Features/System/Queries/Ping/` (query + handler +
result), surfaced by `Controllers/V1/PingController`.
Handlers are `internal sealed`. Requests are `record` types. Validators use FluentValidation and are
picked up automatically by the `ValidateCommandBehavior` pipeline behavior. Never throw for expected
failures — use `OperationResult` factory methods.
**To add a feature:** create the folder, implement request + handler + (optional) validator, add any
new contracts to `Application/Contracts/` and implement them in Infrastructure, then wire a controller
action to `sender.Send(...)`. Full conventions are in [CONVENTIONS.md](CONVENTIONS.md) §5.
---
## Persistence
- Access the DB through `IUnitOfWork` — not `ApplicationDbContext` directly outside Infrastructure.
- Commit once per command via `unitOfWork.CommitAsync()`.
- Use `AsNoTracking()` on all read-only queries.
- Always project to a DTO in queries — never return entity objects from handlers.
- Add entity config in `Persistence/Configuration/<Area>Config/` implementing `IEntityTypeConfiguration<T>`.
- Soft delete is enforced via a global query filter per entity (see [CONVENTIONS.md](CONVENTIONS.md) §6).
- **Development demo seeder (refinement-phase-1).** `Persistence/Services/Seeding/DemoWorldSeeder.cs`
(+ `DemoWorldDefinitions.cs`) idempotently populates a coherent demo marketplace on top of the reference
`HasData` seeds — 3 nurses (2 verified w/ variants + Tehran coverage + `approved` verification + credentials
+ a `matched_national_id` bank account, 1 unverified), 2 customers (patients + addresses), **2 phone-OTP
admins** (refinement-phase-2: a `super_admin` + a scoped `finance` operator, so the `/admin` console is
reachable through the normal phone-OTP login and `useAdminCapabilities` gating is demonstrable — admin
sub-roles are server-granted, never self-selectable), and one cross-category required demo option group
(شیفت / *Shift Type*). It writes through the real entities and
drives the search projection through `ISearchIndexMaintainer.RebuildAsync` (never hand-inserts index rows),
guarding each persona on its phone number so re-runs are a no-op. Invoked via `SeedDemoWorldAsync()`
**only under `IsDevelopment()`** — never in Production/Staging. The demo world (phones, which nurse is
verified) is in `dev/post-phase/refinement/RUNBOOK.md`.
- **Development lifecycle seeder (manual-testing bring-up).** `Persistence/Services/Seeding/DemoLifecycleSeeder.cs`
(+ `.Money.cs`/`.Social.cs` partials + `DemoLifecycleDefinitions.cs`) layers a full **lifecycle** world on the
demo personas so every flow is manually testable: booking requests in every status, 8 bookings across every
reachable state (upcoming w/ care instructions, a 5-session package mid-engagement with EVV, completed
inside/past the dispute window, BNPL-settled, cancelled-with-refund, clawed-back), the balanced payment
ledger behind each (via `LedgerPosting`), refunds on all three forks, a **paid** and a **draft** payout batch
(dispatched through the real `GeneratePayoutBatch`/`ExecutePayoutBatch` commands), moderated reviews +
recomputed nurse aggregates, tickets (incl. an `is_internal` note + coordination tickets via the real
command), notifications, patient care records, a merchant-of-record partner center (portal user
`09120000030`, linked to the second nurse), and a mid-pipeline verification case for the unverified nurse.
States are reached through the entities' guarded transition methods + `BookingFactory` (Application grants
`InternalsVisibleTo` to Persistence for this); business timestamps are backdated explicitly. Idempotent per
scenario on natural keys — **never guard on a Persian string**: the `ApplicationDbContext` save hook
normalizes Persian digits/ZWNJ in every stored string, so a Persian literal never round-trips equal.
Invoked via `SeedDemoLifecycleAsync()` after the demo-world + gateway seeds, Development-only. Scenario
table + testing plan: `dev/post-phase/manual-testing-plan.md`.
---
## Identity & auth
- JWT/JWE 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.
- **Phone-OTP is the public login** (backend-phase-2): `Controllers/V1/AuthController`
(`request_otp`/`verify_otp`/`refresh`/`logout`) + `MeController` (`/me`, `select_role`) drive the
`Features/Identity/` slices. OTP delivery goes through the **`ISmsSender`** seam (mock
`LoggingSmsSender` in CrossCutting logs the code; registered in `AddCrossCuttingSeams`).
- **Sessions & rotation:** every login creates a revocable `usr.UserSessions` row storing only the
refresh token's `IFieldEncryptor.Hash`. Refresh rotates (old session revoked, new pair issued);
a replayed/revoked token revokes **all** the user's sessions and returns 401. Logout revokes the
session **and** rotates the security stamp so outstanding access tokens fail the JWE
`OnTokenValidated` stamp check.
- **Encrypted PII:** `users.PhoneNumber/Email/NationalId` are encrypted at rest via an EF value
converter over `IFieldEncryptor` (wired in `ApplicationDbContext`; the encryptor must stay a
process-wide singleton because EF caches the model). 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`.
- **Roles:** full vocabulary in `Domain/Entities/User/RoleNames`; `SeedDataBase` always seeds the roles,
and seeds a **bootstrap admin only when `Seed:AdminUsername`/`Seed:AdminPassword` are configured**
(refinement-phase-5 — no more committed `admin`/`qw123321`; break-glass only, day-to-day admins come from
the phone-OTP demo seeds or are provisioned out-of-band). `customer`/`nurse` are self-selectable via
`POST me/select_role` (audited `granted_by`/`granted_at`, idempotent, both can be held); admin sub-roles are
internal-only and return 403 there. `user_roles.revoked_at` has a global query filter, so revoked grants
disappear from every role read automatically. Auth knobs (`auth_otp_resend_seconds`, `auth_otp_max_attempts`,
`auth_session_ttl_days`) are `platform_configs` rows read via `IPlatformConfig`.
- Dynamic permission system: `DynamicPermissionHandler` reads `[controller]` + `[action]` route
values and checks role claims. Always use `[controller]`/`[action]` tokens so the keys stay
consistent (see CONVENTIONS.md §1 Routing).
- Settings bound from `appsettings.json``IdentitySettings`. The base `appsettings.json` carries
`SET_VIA_USER_SECRETS_OR_ENV` placeholders that `StartupSecretsGuard` rejects; the real values live in the
environment-specific file (`appsettings.Development.json` holds the dev-only keys the demo deployment runs on). `RequireHttpsMetadata` is **on outside Dev/Testing**
(passed into `RegisterIdentityServices`), the access-token lifetime is `ExpirationMinutes: 60`, and
`Issuer`/`Audience` are real (`Balinyaar`/`BalinyaarClient`) — refinement-phase-5.
- Auth and OTP endpoints must be rate-limited (CONVENTIONS.md §11) — `request_otp`/`verify_otp` use
the `otp` policy, `refresh` the `auth` policy; plus a per-phone resend window via `ICacheService`. The two
PSP/BNPL webhooks share the single deliberate **`webhook`** policy (bursty-tolerant, partitioned per-provider);
behind a reverse proxy the limiter partitions on the forwarded client IP (see Startup wiring).
---
## Conventions — quick reference
Full rules in [CONVENTIONS.md](CONVENTIONS.md). The essentials:
- All URL segments are `snake_case` via `SnakeCaseParameterTransformer` — use `[controller]`/`[action]` tokens.
- Controllers are `sealed`, inherit `BaseController`, inject `ISender`, return `base.OperationResult(result)`.
Never call `Ok()` / `BadRequest()` / `NotFound()` directly.
- Handlers are `internal sealed`; never throw for expected failures — return `OperationResult`.
- `record` for requests/DTOs, `class` for entities (no public setters), `sealed class` for handlers/services.
- `async`/`await` all the way; pass `CancellationToken` through every async call; never `.Result`/`.Wait()`/`async void`.
- Mapster for mapping; FluentValidation for validation (validate at the boundary).
- Package versions live **only** in `Directory.Packages.props` — never `Version=` in a `.csproj`.
- No unused code (usings, locals, parameters, private fields/members) and no *what*-comments — explain *why*, prefer self-documenting names (§2).
- Architecture changes (a project/layer/major folder or a cross-layer dependency) must update the **Project map** in the same change.
- The `Baya.*` namespace is project naming — do not rename without explicit instruction.
---
## Known build warnings (pre-existing — do not fix unless tasked)
| Warning | Project | Note |
| ------- | ------- | ---- |
| `NU1510` on `Microsoft.Extensions.Logging.Debug` | `Baya.Web.Api` | Redundant transitive reference, harmless |
| `NETSDK1057` (preview SDK) | all | .NET 10 SDK is preview on this machine |
| Working on… | Read |
| --- | --- |
| Projects, layers, startup wiring, the seam catalogue, observability | [docs/rules/server/structure.md](../docs/rules/server/structure.md) |
| Adding a feature — command, query, handler, validator, controller | [docs/rules/server/cqrs.md](../docs/rules/server/cqrs.md) |
| EF Core, audit, state machines, uniqueness, snapshots, search, jobs, seeders | [docs/rules/server/persistence.md](../docs/rules/server/persistence.md) |
| **Anything on the money path** — ledger, refunds, BNPL, payouts, invoices | [docs/rules/server/money.md](../docs/rules/server/money.md) |
| Auth, JWE, sessions, field encryption, tenancy, disclosure, logging | [docs/rules/server/identity.md](../docs/rules/server/identity.md) |
| C# style, naming, async, error handling, tests, DI | [docs/rules/server/conventions.md](../docs/rules/server/conventions.md) |
| The wire contract — envelope, status codes, enums, pagination | [docs/integration/](../docs/integration/index.md) |
| What is built, what is mocked, what is next | [docs/status/](../docs/status/index.md) |
| Cross-project rules — naming, gates, code quality, config | [docs/rules/shared/](../docs/rules/shared/) |
-508
View File
@@ -1,508 +0,0 @@
# Server Coding Conventions
Rules enforced for all code in `server/`. These represent the standards expected from a **senior .NET engineer**. Read alongside [CLAUDE.md](CLAUDE.md).
When in doubt, ask: _would a senior engineer approve this diff without comment?_
---
## 1. Routing
### Rule: all URL segments must be `snake_case`
`SnakeCaseParameterTransformer` (`Baya.WebFramework/Routing/`) is registered globally via `RouteTokenTransformerConvention`. It converts `[controller]` and `[action]` tokens automatically.
```csharp
// ✅ transformer converts MyFeature → my_feature, GetBySlug → get_by_slug
[Route("api/v{version:apiVersion}/[controller]")]
public class MyFeatureController : BaseController
{
[HttpGet("[action]")]
public Task<IActionResult> GetBySlug(...) { }
}
// ❌ bypasses transformer — hardcoded segment escapes 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.
---
## 2. C# code quality
### 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` |
### Language features — use them
```csharp
// ✅ primary constructor (C# 12)
public sealed class OrderHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<...> { }
// ✅ switch expression over if/else chains
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"];
```
### Immutability & safety
- Mark fields `readonly` unless mutation is genuinely needed.
- Prefer `IReadOnlyList<T>` / `IReadOnlyCollection<T>` over `List<T>` in signatures unless the caller needs to mutate.
- Never expose public setters on entities — use methods or constructors.
- Avoid `static` mutable state.
### Null handling
- Enable `<Nullable>enable</Nullable>` in any new project you create.
- Use guard clauses at the entry point; don't scatter null checks throughout.
- Prefer returning `OperationResult.NotFoundResult(...)` over returning `null` from handlers.
- Never use `null!` (null-forgiving) unless you can prove the value cannot be null and the compiler cannot.
### Naming
| Kind | Convention | Example |
|---|---|---|
| Class, record, interface | PascalCase | `OrderHandler`, `IOrderRepository` |
| Method | PascalCase | `GetUserOrdersAsync` |
| Parameter, local variable | camelCase | `orderId`, `userEmail` |
| Private field | `_camelCase` | `_unitOfWork` |
| Constant | PascalCase | `MaxRetryCount` |
| Generic type param | `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`).
### No unused code
Leave nothing dead behind. Remove unused `using` directives, local variables, parameters, private fields, and private members rather than letting them accumulate.
- These already surface as compiler/analyzer signals — `CS0168` (variable declared, never used), `CS0219` (variable assigned, value never used), `CS0169` (private field never used), `IDE0005` (unnecessary `using`). The quality gate is **zero new warnings**, so treat unused code as a gate failure.
- **Delete it — don't silence it.** Do not add `#pragma warning disable`, throwaway discards, or `_ =` assignments just to quiet the analyzer.
- 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.
### Comments — explain *why*, never *what*
Code that needs a comment to be understood usually needs a better name instead. Prefer self-documenting names over prose.
- **Do not** write comments that restate what the code already says — no `// constructor`, `// loop over users`, or XML-doc that merely echoes the method name.
- **Do** add a comment only where a non-obvious decision, constraint, business rule, workaround, or trade-off is *not* evident from the code — explain the reasoning, not the mechanics.
- Keep any necessary comment tight, and delete comments that no longer match the code.
```csharp
// ❌ restates the obvious
// increment the retry counter
retryCount++;
// ✅ captures a non-obvious 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) ...
```
---
## 3. Async / await
```csharp
// ✅ always async all the way — no .Result or .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;
// ✅ pass CancellationToken through every async call
await _db.SaveChangesAsync(cancellationToken);
// ❌ fire and forget with no error handling
_ = DoSomethingAsync();
```
- Every public async method must accept `CancellationToken` and pass it downstream.
- Use `ValueTask<T>` for hot paths (handlers, repositories). Use `Task<T>` for rarely-called or always-async methods.
- Never use `async void` — it swallows exceptions. Use `async Task` even for event-like callbacks.
- Do not add `.ConfigureAwait(false)` in this ASP.NET Core app — it's unnecessary and adds noise.
---
## 4. Controllers
Every controller must follow 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));
}
```
Rules:
- `sealed` — controllers are not designed for inheritance beyond `BaseController`.
- Inject `ISender` via primary constructor — not `IMediator`.
- **Never call `Ok()`, `BadRequest()`, `NotFound()` directly** — always `base.OperationResult(result)`.
- Keep controller methods thin: one `Send`, one `OperationResult`. No business logic in controllers.
- Use `[Display(Description = "...")]` so NSwag generates meaningful Swagger tags.
- Pass `CancellationToken` from the action into `sender.Send(...)`.
### Authorization levels — use the narrowest that fits
| Attribute | When |
|---|---|
| _(none)_ | Truly public (health check, metrics) |
| `[Authorize]` | Any authenticated user |
| `[Authorize(ConstantPolicies.DynamicPermission)]` | Role/claim-gated admin action |
| `[RequireTokenWithoutAuthorization]` | Token must be present but may be expired (e.g. refresh) |
Apply at the **controller level** for uniform policy; override at the action level only for exceptions.
---
## 5. CQRS — feature structure
```
Features/<Area>/
├── Commands/<VerbNoun>Command/
│ ├── <Name>Command.cs record Command(…) : IRequest<OperationResult<T>>
│ ├── <Name>Command.Handler.cs internal sealed class Handler : IRequestHandler<…>
│ └── <Name>Command.Validator.cs AbstractValidator<Command> (omit if no validation needed)
└── Queries/<VerbNoun>Query/
├── <Name>Query.cs record Query(…) : IRequest<OperationResult<T>>
├── <Name>Query.Handler.cs internal sealed class Handler : IRequestHandler<…>
└── <Name>Query.Result.cs record Result(…) ← the DTO returned
```
- Request types are `record` — immutable.
- Handlers are `internal sealed` — they are never used outside the Application layer.
- **Handlers must not throw for expected failures.** Use `OperationResult` factory methods:
- `OperationResult<T>.SuccessResult(value)` — happy path
- `OperationResult<T>.FailureResult(errors)` — validation / business rule failure
- `OperationResult<T>.NotFoundResult(message)` — entity not found
- Only one handler per request type — no conditional dispatch.
- Contracts the handler depends on go in `Application/Contracts/` as interfaces; implementations live in Infrastructure.
---
## 6. Persistence — EF Core rules
```csharp
// ✅ project to DTO in the query — never load full entity for read operations
var dto = await _db.Orders
.AsNoTracking()
.Where(o => o.UserId == userId)
.Select(o => new OrderResult(o.Id, o.Status, o.CreatedAt))
.ToListAsync(ct);
// ❌ loads entire 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);
```
Rules:
- **Always use `AsNoTracking()`** on read-only queries.
- **Always project with `Select()`** in queries — never hydrate full entities just to map them.
- Never load more than you need. Pagination is mandatory for any unbounded list: `Skip` / `Take`.
- Use `Include` only in command handlers where you need to mutate the aggregate and need navigation properties loaded.
- Access the DB through `IUnitOfWork` in Application-layer handlers. `ApplicationDbContext` is only referenced directly inside Infrastructure.
- Commit once per command at the end: `await _unitOfWork.CommitAsync(ct)`.
- One `IEntityTypeConfiguration<T>` per entity, in `Persistence/Configuration/<Area>Config/`.
- Migrations command: `dotnet ef migrations add <Name> --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api`
### Soft delete
Every entity that supports soft delete **must** declare a global EF query filter in its `IEntityTypeConfiguration<T>`:
```csharp
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.HasQueryFilter(o => !o.IsDeleted);
}
```
Without this filter, soft-deleted records appear in every query that doesn't explicitly filter them — a silent data leak. Never add `Where(x => !x.IsDeleted)` in individual queries; the filter makes it automatic and auditable.
### Entity audit fields
When designing or extending an entity, include audit fields alongside timestamps:
| Field | Type | Set by |
|---|---|---|
| `CreatedAt` | `DateTimeOffset` | `SaveChangesAsync` override (on Add) |
| `ModifiedAt` | `DateTimeOffset` | `SaveChangesAsync` override (on Update) |
| `CreatedById` | `int?` | `SaveChangesAsync` override via `ICurrentUser` |
| `ModifiedById` | `int?` | `SaveChangesAsync` override via `ICurrentUser` |
Wire `ICurrentUser` (HTTP context accessor wrapped in an interface, registered Scoped) into `ApplicationDbContext` so the context can stamp who made the change without handlers needing to pass it explicitly. Audit fields cannot be backfilled retroactively — design them in from the start.
> **As built (backend-phase-0):** the audit base type is `BaseEntity`/`IAuditableEntity` in
> `Baya.Domain/Common/BaseEntity.cs` (`CreatedAt`/`ModifiedAt` as `DateTimeOffset`, `CreatedById`/
> `ModifiedById` as `int?`). Stamping is done by `AuditFieldInterceptor`
> (`Baya.Infrastructure.Persistence/Interceptors/`), a `SaveChangesInterceptor` that reads time from
> `IDateTimeProvider` and the user from `ICurrentUser` — not in the `DbContext` itself.
> **As built (backend-phase-1) — reusable patterns you should follow:**
> - **Config is rows, read at compute time.** Money-critical constants (commission %, VAT, deadlines,
> EVV tolerance, cancellation tiers) live in `platform_configs`, read via `IPlatformConfig.GetConfig<T>`
> (cached, parsed by the row's `data_type`) — **never hardcode**. Changing a rate must never
> retroactively alter an already-computed amount: later phases snapshot the rate onto the
> booking/invoice at compute time; do not live-re-read a rate for an already-priced row.
> - **Append-only audit trail.** `audit_logs` is immutable — there is **no** update/delete path in app
> code. Mark a compliance-sensitive entity with `IAuditable` (`Baya.Domain/Common`) and the
> `AuditFieldInterceptor` writes an old/new diff row per change in the same transaction; annotate any
> encrypted/PII property with `[AuditRedacted]` so it is redacted (never plaintext) in the diff.
> `platform_configs` is the first `IAuditable` entity.
> - **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 these tables directly. `IAnalyticsSink` is fire-and-forget
> (never fail the caller); `INotificationService`/notification endpoints are always tenant-scoped to
> `ICurrentUser`; `support_alerts` are admin-only and never appear on a user-facing route.
> - **Retention/scheduling seam.** Background jobs run behind the hosted-service seam
> (`NotificationRetentionHostedService`); real Hangfire/Quartz is deferred. The notification retention
> predicate is exactly `is_read = 1 AND age > 90d` — unread is never auto-deleted.
### Money is IRR `BIGINT` — integer-only, no floats
Every monetary value is **IRR Rials stored as `long` / `BIGINT`**. There is **no float/decimal path** on money — not in entities, DTOs, the API, or arithmetic. Toman is display-only and converts to/from Rials **only** inside a provider adapter at its boundary, never in domain or shared code. If a money value object is introduced later it must be integer-only. The three booking amounts always satisfy `gross = commission + payout`.
### Deterministic set-hash for multi-row uniqueness
When "no two rows may share the same *set* of child rows" must be enforced (e.g. a nurse can't list two
identical variants — same category + identical answered option-set), a plain composite unique index can't
express it because the set spans multiple rows. Reduce the set to a single comparable column with
**`Baya.Application.Common.OptionSetHash.Compute(pairs)`** (backend-phase-5): it sorts the `(long, long)`
pairs and SHA-256s them to a stable 64-char hex hash that is **order-independent** (identical sets always
collide). Persist it (`NVARCHAR(64)`) and back it with a **filtered unique index** (e.g.
`UNIQUE(nurse_id, service_category_id, option_set_hash) WHERE deleted_at IS NULL`) as the race-safe backstop,
with a handler pre-check for the friendly `409`. Reuse this helper for any future "same set of ids" guard;
do **not** reuse `IFieldEncryptor.Hash` (that is for PII-column equality lookups).
### Guarded cross-aggregate state flip (backend-phase-6)
When one write must atomically change a header row's state **and** a derived boolean on a *different*
aggregate (e.g. `nurse_verifications.status``nurse_profiles.is_verified`), do it in one transaction:
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. Two follow-on
rules this establishes:
- **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.
- **Persist a status enum as its stable snake_case code, not the member name.** Define the C# enum, then map
it with a `HasConversion(e => e.ToCode(), s => Parse(s))` value converter (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.
### Forward-only status machine (backend-phase-8)
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. The b8
pattern (reused by b9 for the `bookings` machine):
- **Statuses are `const string` codes** (`BookingRequestStatus`) persisted as the stable snake_case string —
no C# enum, no value converter needed. **Edges live in a static `CanTransition(from, to)`**
(`BookingRequestTransitions`) built from a `Dictionary<string, IReadOnlyCollection<string>>`; terminal
states map to an empty set.
- **The entity owns the transition.** `status` has a **private setter**; the only mutators are cohesive domain
methods (`Accept`/`Reject`/`Cancel…`) that call a private `Transition(target)` which asserts the edge is
legal (throws on an illegal edge — a programming error, since the handler pre-checks). Side-effect fields
(`payment_deadline_at`, `rejection_reason`) 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.
- **Time-sensitive commands self-guard** against a passed deadline via `IDateTimeProvider` rather than trusting
a sweep has run; the recurring expiry `BackgroundService` is bounded/paginated/idempotent, and its
`WHERE status = …` predicate (re-queried each tick) is the concurrency guard — a row a racing action moved is
simply not reloaded.
- **Deadline columns that are compared/sorted use `DateTime` (UTC `datetime2`), not `DateTimeOffset`** — the
SQLite test provider cannot translate `DateTimeOffset` comparison/`ORDER BY`. Order lists/sweeps by `Id`, not
the timestamp, for the same reason.
---
## 7. Validation
- All commands that accept user input need a `FluentValidation` validator. The `ValidateCommandBehavior` pipeline behavior runs it automatically before the handler.
- Validators are registered automatically via `RegisterValidatorsAsServices()` in `Program.cs`.
- Validate at the boundary (command/query), not deep in the domain or repositories.
```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);
});
}
}
```
---
## 8. Mapping — Mapster rules
- Use `IMapper` (injected via DI) for all entity↔DTO mapping in handlers.
- Register type adapter configs in `Program.cs` via `TypeAdapterConfig.GlobalSettings.Scan(...)`. Add new assemblies that contain mapping configs there.
- Never write manual mapping code when Mapster can infer it — only write custom `TypeAdapterConfig` when shapes diverge.
- Mapping happens **in the handler after the DB query**, not in the repository.
---
## 9. Error handling & logging
```csharp
// ✅ expected failure — use OperationResult, do not throw
if (user is null)
return OperationResult<T>.NotFoundResult("User not found.");
// ✅ unexpected failure — let it propagate; ExceptionHandler middleware catches it
// Log at the point you catch unexpected exceptions (ExceptionHandler logs automatically)
// ❌ swallowing exceptions
try { ... } catch { return OperationResult<T>.FailureResult(...); }
// ✅ structured logging — never interpolate sensitive data
_logger.LogInformation("Order {OrderId} created for user {UserId}", order.Id, userId);
// ❌ logs PII / secrets
_logger.LogInformation($"Token for {user.Email}: {token}");
```
- Log at the correct level: `Debug` for trace info, `Information` for meaningful events, `Warning` for recoverable issues, `Error` for unexpected failures.
- Never log passwords, tokens, secrets, or full PII (email is borderline — use `userId` in logs instead).
- The global `ExceptionHandler` middleware catches unhandled exceptions — do not add try/catch in handlers for unknown exceptions; let them propagate.
---
## 10. 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.
- Use `NSubstitute` for mocking: `Substitute.For<IUnitOfWork>()`.
- Integration tests use `Baya.Tests.Setup` which provides an in-memory SQLite context — prefer this over mocking the DB for persistence tests.
- Name tests: `{MethodUnderTest}_{Scenario}_{ExpectedOutcome}`.
- One assertion concept per test. Multiple `.Should()` calls are fine if they all verify the same outcome.
- Do not test EF internals (entity tracking, migrations) — test behavior through the handler.
### Integration tests — HTTP pipeline coverage
Handler tests verify business logic but leave the entire HTTP stack (routing, auth pipeline, middleware, `OperationResult → IActionResult` translation) untested. Each feature area must have at least one `WebApplicationFactory<Program>`-based test covering:
1. Happy path — authenticated request returns 200 with correct body shape.
2. Unauthenticated request 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);
}
}
```
Place these tests in a dedicated `Baya.Test.Api` project so they can run against the full `Program.cs` wiring.
---
## 11. Security rules
- **Never hardcode secrets in C#.** Keys, connection strings, and tokens come from `appsettings.*.json` or environment variables, bound to typed settings classes — never a literal in a handler or service. (`dotnet user-secrets` is not used; see [DEPLOY.md](../DEPLOY.md) for the configuration model.)
- `SecretKey` and `Encryptkey` (in `IdentitySettings`) belong in the environment-specific file, never in the base `appsettings.json`, which stays at its `StartupSecretsGuard`-rejected placeholder.
- Always validate all external input with FluentValidation before processing.
- EF Core parameterizes queries automatically — never concatenate raw SQL.
- If you must use raw SQL, use `FromSqlInterpolated` (parameterized), never `FromSqlRaw` with user data.
- Respect the principle of least privilege: grant `[Authorize(ConstantPolicies.DynamicPermission)]` to admin actions, not just `[Authorize]`.
- **Auth and OTP endpoints must be rate-limited.** Use ASP.NET Core's built-in `AddRateLimiter` (no extra NuGet package needed). Apply at minimum to: login, OTP request, and token refresh. A fixed window or token bucket policy per IP is the baseline. Register the limiter in a `ServiceConfiguration/` extension; add `app.UseRateLimiter()` before `app.UseAuthentication()` in `Program.cs`.
---
## 12. Service registration
- Every new infrastructure service gets an extension method in the project's `ServiceConfiguration/` folder.
- That extension is called from `Program.cs` — no inline DI registration in `Program.cs`.
- Register with the correct lifetime:
- **Singleton** — stateless, thread-safe services (e.g. `IHttpContextAccessor`)
- **Scoped** — per-request services (repositories, `DbContext`, handlers)
- **Transient** — lightweight, stateless (validators, transformers)
- All NuGet versions live in `Directory.Packages.props`. Never add `Version=` to a `<PackageReference>` in a `.csproj`.
---
## 13. Code organisation
- One type per file. File name matches the type name exactly.
- Handlers and validators go in the same feature folder — not in separate `Handlers/` or `Validators/` root folders.
- If a file exceeds ~150 lines, consider splitting it. Long files usually mean mixed concerns.
- Partial classes are only for generated code (source generators, EF scaffolding).
- Keep `Program.cs` as an orchestrator — extension method calls only, no logic.
+3 -2
View File
@@ -9,8 +9,9 @@ Backend API for the Balinyaar application. It is an **ASP.NET Core (.NET 10)** s
- A modular **gRPC plugin** mounted via Application Parts
- Observability out of the box: Serilog, OpenTelemetry, Prometheus metrics, health checks
> Looking for an architecture/file map to navigate the code? See [CLAUDE.md](CLAUDE.md) (agent guide)
> and [CONVENTIONS.md](CONVENTIONS.md) (coding rules).
> Looking for an architecture/file map to navigate the code? See [CLAUDE.md](CLAUDE.md) (the project map
> and the hard rules) and [../docs/rules/server/](../docs/rules/server/) (the coding rules, one file per
> area — `conventions.md` is the successor to the former `CONVENTIONS.md`).
## Requirements
+7 -3
View File
@@ -5,11 +5,15 @@
# seeds roles + an admin user + a sandbox gateway against this empty instance.
#
# The SA password below is a well-known DEV-ONLY value: it is NOT a secret, is used only on localhost,
# and never reaches a deployed environment. Point the API at this instance via `dotnet user-secrets`
# (see dev/post-phase/refinement/RUNBOOK.md) — never by editing a committed appsettings*.json.
# and never reaches a deployed environment.
#
# Point the API at this instance by setting `ConnectionStrings:SqlServer` in
# `src/API/Baya.Web.Api/appsettings.Development.json` (or as an environment variable).
# `dotnet user-secrets` is NOT used in this repo — the `<UserSecretsId>` was removed, so that store is
# never read. See docs/rules/shared/code-quality.md §6 for the configuration model.
#
# docker compose up -d
# # then set ConnectionStrings:SqlServer via user-secrets (see the runbook), then `dotnet run`
# # then set ConnectionStrings:SqlServer as above, then `dotnet run`
#
# The API itself runs on the host via `dotnet run` (not in a container) for the local-dev loop — this
# compose file intentionally provisions only the database.