From c889c46110ffa9f4a1220eec738fd05a7a39be33 Mon Sep 17 00:00:00 2001 From: hamid Date: Thu, 30 Jul 2026 02:26:52 +0330 Subject: [PATCH] cleanup phase 1 --- .claude/skills/frontend-designer/SKILL.md | 120 +- AGENTS.md | 4 + CLAUDE.md | 176 +-- client/.env.sample | 2 +- client/AGENTS.md | 8 +- client/CLAUDE.md | 1203 +++------------------ client/messages/STYLE.md | 116 -- client/scripts/check-copy.mjs | 5 +- docs/README.md | 6 +- docs/_plan/open-contradictions.md | 31 +- docs/_plan/phase-1-rules-consolidation.md | 70 +- docs/rules/client/auth.md | 184 ++++ docs/rules/client/components.md | 266 +++++ docs/rules/client/forms.md | 116 ++ docs/rules/client/i18n.md | 215 ++++ docs/rules/client/services.md | 221 ++++ docs/rules/client/structure.md | 185 ++++ docs/rules/client/testing.md | 120 ++ docs/rules/client/theme.md | 268 +++++ docs/rules/documentation.md | 131 +++ docs/rules/index.md | 104 +- docs/rules/server/conventions.md | 269 +++++ docs/rules/server/cqrs.md | 149 +++ docs/rules/server/identity.md | 218 ++++ docs/rules/server/money.md | 244 +++++ docs/rules/server/persistence.md | 382 +++++++ docs/rules/server/structure.md | 209 ++++ docs/rules/shared/code-quality.md | 159 +++ docs/rules/shared/git-and-gates.md | 131 +++ docs/rules/shared/naming.md | 94 ++ server/.dockerignore | 1 - server/AGENTS.md | 11 +- server/CLAUDE.md | 862 +++------------ server/CONVENTIONS.md | 508 --------- server/README.md | 5 +- server/docker-compose.yml | 10 +- 36 files changed, 4251 insertions(+), 2552 deletions(-) delete mode 100644 client/messages/STYLE.md create mode 100644 docs/rules/client/auth.md create mode 100644 docs/rules/client/components.md create mode 100644 docs/rules/client/forms.md create mode 100644 docs/rules/client/i18n.md create mode 100644 docs/rules/client/services.md create mode 100644 docs/rules/client/structure.md create mode 100644 docs/rules/client/testing.md create mode 100644 docs/rules/client/theme.md create mode 100644 docs/rules/documentation.md create mode 100644 docs/rules/server/conventions.md create mode 100644 docs/rules/server/cqrs.md create mode 100644 docs/rules/server/identity.md create mode 100644 docs/rules/server/money.md create mode 100644 docs/rules/server/persistence.md create mode 100644 docs/rules/server/structure.md create mode 100644 docs/rules/shared/code-quality.md create mode 100644 docs/rules/shared/git-and-gates.md create mode 100644 docs/rules/shared/naming.md delete mode 100644 server/CONVENTIONS.md diff --git a/.claude/skills/frontend-designer/SKILL.md b/.claude/skills/frontend-designer/SKILL.md index 905a991..f142761 100644 --- a/.claude/skills/frontend-designer/SKILL.md +++ b/.claude/skills/frontend-designer/SKILL.md @@ -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//.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 ``, never mock MUI). +[docs/rules/client/testing.md](../../../docs/rules/client/testing.md); wrap with +``, 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 / - `
` / 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 `
` fills the frame, with the bars pinned **`position: absolute`** over + it — never `fixed`, which would break out of the centered column — and `
` + 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')`, 3–5 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/` | diff --git a/AGENTS.md b/AGENTS.md index 4c62336..e940e0f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index 23eff8b..2901b34 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 - `` 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 + `` 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 ``` diff --git a/client/.env.sample b/client/.env.sample index e962339..04e9e10 100644 --- a/client/.env.sample +++ b/client/.env.sample @@ -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 \ No newline at end of file diff --git a/client/AGENTS.md b/client/AGENTS.md index efa8c38..ceea709 100644 --- a/client/AGENTS.md +++ b/client/AGENTS.md @@ -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 diff --git a/client/CLAUDE.md b/client/CLAUDE.md index 7e1e4c5..a88e2bc 100644 --- a/client/CLAUDE.md +++ b/client/CLAUDE.md @@ -1,36 +1,30 @@ -# Balinyaar Client — Claude Code Guidelines +# Balinyaar Client -The web frontend of **Balinyaar**, a trust-first home-nursing marketplace in Iran. This file is the -**engineering contract** for everything under `client/`: providers, routing, data fetching, theming, -i18n, cookies, and the rules every change must follow. +The web frontend of **Balinyaar**, a trust-first home-nursing marketplace in Iran. Families search for, +book, pay for and review home nursing; nurses list configurable services and run their day from the same +app. Four actors share one mobile shell: family, nurse, admin, partner centre. -- Repo-wide context and the backend → root [CLAUDE.md](../CLAUDE.md). -- Product/domain rules (what to build) → [`product/`](../product/) — read the relevant doc before - designing a feature; don't infer business rules from code. -- Visual/design work (brand palette, tokens, component look-and-feel) → the **frontend-designer** - skill. It is the *design* contract and defers to this file for *engineering* rules. Don't restate - this file there. +> Last verified: 2026-07-30 against commit `d3ec723`. + +- Repo-wide context and the backend → root [CLAUDE.md](../CLAUDE.md) +- Business rules (what to build) → [`product/`](../product/index.md). **Read the relevant doc before + designing a feature** — don't infer a business rule from code. +- Visual/design work → the **frontend-designer** skill. It is the *design* contract and defers to this file + and [`docs/rules/client/`](../docs/rules/client/) for engineering rules. + +--- ## Stack -- **Next.js 16** — App Router, Turbopack, React Server Components. **Not a static export** — the app - relies on server components, middleware, and server-side cookies. (`next.config.mjs` only wires the - next-intl plugin + `reactStrictMode`.) -- **React 19** + **TypeScript** (`strict`). -- **MUI v9** (`@mui/material`) for components and theming; **Emotion** underneath (RTL via - `stylis-plugin-rtl`). -- **Lucide** (`lucide-react`) for icons, behind the `AppIcon` name registry. - `@mui/icons-material` was removed — never reintroduce it. -- **next-intl v4** for i18n — locales `fa` (default, RTL) and `en`. -- **TanStack Query v5** for server state; a small **AuthContext** (React context + reducer, - `src/context/auth/`, seeded with server-read auth state) for auth/session state. -- **react-hook-form v7** for every form with more than one field — uncontrolled fields + per-field - subscriptions, so a keystroke re-renders one input rather than the whole screen. Never used through - the raw `useController`/`register` API at a call site: bind through the `components/common/form` - wrappers (`RhfTextField`, `RhfChipSelect`, `RhfJalaliDateField`, `RhfControlGroup`). See **Forms** below. -- **notistack** for toasts; **js-cookie** (wrapped) for client cookies. -- **Jest** + **Testing Library** for unit tests. -- Quality gates: **tsc**, **ESLint 9** (flat config), **Prettier**. +- **Next.js 16** — App Router, Turbopack, React Server Components. **Not a static export.** +- **React 19** + **TypeScript** (`strict`) +- **MUI v9** (`@mui/material`) with **Emotion** underneath; RTL via `stylis-plugin-rtl` +- **Lucide** (`lucide-react`) for icons, behind the `AppIcon` name registry +- **next-intl v4** — locales `fa` (default, RTL) and `en` +- **TanStack Query v5** for server state; a small **AuthContext** (`src/context/auth/`) for session state +- **react-hook-form v7** for every form with more than one field +- **notistack** for toasts; **js-cookie** (wrapped) for client cookies +- **Jest** + **Testing Library**; **ESLint 9** (flat config) + **Prettier** ## Commands @@ -38,1061 +32,146 @@ i18n, cookies, and the rules every change must follow. | --- | --- | | Dev server | `npm run dev` | | Production build | `npm run build` | -| Type-check | `npm run type` | -| Lint | `npm run lint` | -| Lint + autofix | `npm run lint:fix` | -| **Type + lint (the gate)** | `npm run check` | -| Format (Prettier) | `npm run format` | -| Test (watch) | `npm test` | -| Test (CI, once) | `npm run test:ci` | +| **Type + lint + copy (the gate)** | `npm run check` | +| Type-check only | `npm run type` | +| Lint only / autofix | `npm run lint` · `npm run lint:fix` | +| Persian copy lint | `npm run lint:copy` | +| Format | `npm run format` | +| Test (watch / CI) | `npm test` · `npm run test:ci` | -**Always run `npm run check` before declaring work done.** Run `npm run test:ci` as well when you -touch a component that has a co-located `*.test.tsx`. +## Quality gates -## Quality gates: lint & type (how they work) +``` +npm run check # tsc --noEmit → eslint . → scripts/check-copy.mjs +npm run test:ci # also required when you touched a component with a co-located *.test.tsx +``` -Both gates are plain CLI tools. **There is no `next lint`** — it was removed in Next 16; calling it -silently does nothing. +Both must be green, and `en.json`/`fa.json` must be in sync, before work is done. There is **no +`next lint`** — it was removed in Next 16 and calling it silently does nothing. -- `npm run type` → `tsc --noEmit`. Config in `tsconfig.json`: `strict` on, `noEmit`, `@/*` → `src/*`. -- `npm run lint` → `eslint .` driven by **flat config** in `eslint.config.mjs`. That config 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. -- `npm run check` runs type then lint. Keep it green. +--- -Rules for this project: -- **This project is flat-config only.** Do not add `.eslintrc*` files — put any rule changes 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`** (in `eslint.config.mjs`), so dead code fails - `npm run check`. Delete unused code rather than disabling the rule; prefix a deliberately-unused - binding with `_` (e.g. `_event`, `catch (_err)`) to opt out. -- **Prefer fixing code over silencing the linter.** When a disable is genuinely correct — e.g. a - deliberate browser-only read after mount that trips `react-hooks/set-state-in-effect` — use a - scoped `// eslint-disable-next-line ` with a one-line reason, never a file-wide disable. -- **Pin to ESLint 9.** ESLint 10 currently crashes with this Next 16 toolchain - (`scopeManager.addGlobals is not a function`). `import/no-cycle` is also disabled — its TS resolver - has an interface mismatch here (see the note in `eslint.config.mjs`). +## Hard rules -## Golden rules (the short list) - -A change is "done" only if it respects all of these — each has a full section below. - -1. **Never add a layout above `[locale]`.** `src/app/[locale]/layout.tsx` is the root layout (it - renders ``/``). A layout above it freezes `lang`/`dir`/messages on the default locale. +1. **Never add a layout above `[locale]`.** `src/app/[locale]/layout.tsx` **is** the root layout — it renders + ``/``. A layout above it freezes `lang`/`dir`/messages on the default locale for every route. 2. **Respect the server/client boundary.** Never import `next/headers`, `next-intl/server`, or `@/lib/cookies/server` from a client component; never import `@/lib/cookies/client` from an RSC. -3. **No hard-coded UI strings.** Every user-visible string is a key in **both** `messages/en.json` - and `messages/fa.json`. -4. **Fetch only through `clientFetch`/`serverFetch`** (`@/lib/api`) — never raw `fetch()`. Domain - calls live in `src/services/{domain}/apis/`. -5. **Cookies only through the cookie manager** (`@/lib/cookies/*`) — never `document.cookie`, - `js-cookie`, `localStorage`, or `sessionStorage` for app/auth state. -6. **Colors come from `tokens.css`** (`var(--…)`), never hard-coded in `sx`. Use the pre-built - `APP_THEME_LTR`/`APP_THEME_RTL`; never call `createTheme()` in a component. -7. **MUI v9 API only.** Use `sx={{ mb: 4 }}`, not `mb={4}` as a direct prop. No MUI-v5/v6-only props - (`useFlexGap`, `flexWrap` on `Stack`, `storageWindow`, `InitColorSchemeScript`, …). -8. **Shared components get a co-located `*.test.tsx`.** (A component imported from >1 place.) -9. **Magic strings become named constants** (`src/constants/` or a co-located `constants.ts`). -10. **`npm run check` is green** and translations stay in sync before you finish. -11. **No dead code; comment the *why*, not the *what*.** Unused vars/imports are lint errors — remove - them. Don't add comments that restate the code; comment only a non-obvious decision, constraint, or - trade-off. See **Comments & dead code** below. +3. **No hard-coded UI strings.** Every user-visible string is a key in **both** `messages/en.json` and + `messages/fa.json`. The one exception is `app/global-error.tsx`, which cannot use next-intl. +4. **Fetch only through `clientFetch`/`serverFetch`** (`@/lib/api`) — never raw `fetch()`. Domain calls live in + `src/services/{domain}/apis/`. +5. **Cookies only through the cookie manager** (`@/lib/cookies/*`) — never `document.cookie`, `js-cookie` + directly, `localStorage`, or `sessionStorage` for app or auth state. +6. **Colors come from `tokens.css` (`var(--bal-*)`) or MUI palette keys** — never a hex or rgb literal in `sx` + or `styled`. Use the pre-built `APP_THEME_LTR`/`APP_THEME_RTL`; **never call `createTheme()` in a + component.** +7. **MUI v9 API only.** `sx={{ mb: 4 }}`, not `mb={4}`. No v5/v6-only props (`useFlexGap`, `flexWrap` on + `Stack`, `storageWindow`, `InitColorSchemeScript`). +8. **RTL-safe.** Never `marginLeft`, `left:`, or `textAlign: 'left'` for layout flow — use logical or + MUI-flipped properties. `fa` is the default locale and it is RTL. +9. **Never add a pre-paint color-scheme script.** The no-flash boot is CSS-only; extend the `tokens.css` + media-query fallback instead. +10. **`prefers-reduced-motion` has exactly one gate**, in `src/app/globals.css`. Never add a second, + component-local branch. +11. **One layout: a phone.** `AppFrame` caps every screen at 480px at every viewport. Never add a `≥md` branch + that widens a shell, restores a sidebar, or goes multi-column. Wide content scrolls inside its own + container. +12. **Navigation is the bottom bar; there is no drawer.** Add a destination by adding a tab or a hub row, never + by forking `MobileShell`. Active state comes from the shared `matchActivePath`, never a hand-rolled + `pathname.startsWith`. +13. **All chrome navigation goes through `@/i18n/navigation`** — never a raw `next/link`, never a manual + `` `/${locale}` `` prefix. +14. **Icons come from the `AppIcon` registry by lowercase name.** `@mui/icons-material` was removed — never + reintroduce it, and never pass a raw icon component where a name is expected. +15. **Any form with more than one field uses react-hook-form**, bound through the + `@/components/common/form` wrappers. Never call `register`/`useController` at a call site. +16. **Never append `ROUTES.HOME` (`'/'`) to `PUBLIC_PATHS`** — it is matched with `startsWith`, so that would + silently make every route public. +17. **The middleware auth check is UX-only, not a security boundary** — it does not verify the JWT signature. + Never gate real authorization on it, on `isTokenAlive`, or on `useAdminCapabilities`. +18. **The client displays money; it never computes it.** IRR digit strings parsed with integer-safe `BigInt` + helpers, never a float. Never compute a rate, an aggregate, a payout date, or a holiday shift. A + server-frozen deadline is rendered, never recomputed. A signed balance is never clamped. +19. **Never leak clinical data.** The customer never fires the care-instructions query; the nurse's care-record + access is append-only; access-denied is gated *before* any clinical fetch; `is_internal` is never modelled + in user-app types. Clinical text is never logged, stored in `localStorage`, or put in a query string. +20. **Every shared component has a co-located `*.test.tsx`** (shared = imported from more than one place). + Don't mock MUI — test the rendered DOM. +21. **No dead code.** `@typescript-eslint/no-unused-vars` is an **error**, so it fails the gate. Delete it; + prefix a deliberately-unused binding with `_`. Comment the *why*, never the *what*. +22. **Every mutation needs an `onError` toast** unless the failure is already surfaced inline. But never toast + 401/403/5xx in a hook — `clientFetch` already does. +23. **Magic strings become named constants** (`src/constants/`, or a co-located `constants.ts`). +24. **Don't reintroduce starter scaffolding** or `_TITLE_`/`_DESCRIPTION_` placeholders. +25. **When you change the structure, update "Project structure" below in the same change.** -## Project Structure +--- -**This section is the canonical description of the client's architecture.** When a change adds, removes, -or renames a route group, provider, or top-level `src/` folder, update this tree in the same change -(root `CLAUDE.md` working agreement #7). +## Project structure + +The canonical map of the frontend's architecture. Expanded, with the reasoning, in +[`docs/rules/client/structure.md`](../docs/rules/client/structure.md). ``` client/ -├── messages/ # Translation files (add keys to BOTH files) -│ ├── en.json -│ └── fa.json -├── middleware.ts # next-intl routing middleware (locale detection + redirect) + (ui-phase-13) the guest front-door: after the i18n 307/308 early-return, an UNAUTHENTICATED exact-match on '/' is `NextResponse.rewrite()`d to `/{locale}/welcome` (never a redirect, so the URL/SEO canonical stays '/'); an AUTHENTICATED hit on `/welcome` redirects to '/'. The bare-root matcher entry (`'/'`, alongside the existing catch-all regex) is load-bearing — this Next 16/Turbopack build did not reliably invoke middleware for the literal root through the negative-lookahead pattern alone (verified in dev; a production `next build && next start` confirmed the intended behavior end-to-end, so this is a dev-server-only quirk, not a logic bug) -├── next.config.mjs # createNextIntlPlugin wires i18n into Next.js +├── messages/{en,fa}.json translations — add every key to BOTH +├── middleware.ts i18n routing → guest front door → auth gate +├── next.config.mjs next-intl plugin + reactStrictMode, nothing else └── src/ - ├── app/ - │ ├── globals.css - │ ├── fonts/ # Local font files (woff2) — Mikhak for fa - │ ├── global-error.tsx # Special file above [locale] — replaces the root layout on a root-level crash; renders its own , so it CANNOT use next-intl. The one sanctioned static-string exception (minimal, bilingual fa+en). - │ ├── robots.ts # (ui-phase-13) replaces the old static `public/robots.txt` (deleted) — allows the public surface, disallows every private route root for every locale, points at sitemap.xml - │ ├── sitemap.ts # (ui-phase-13) public routes only ('/', /login, /terms, /privacy) × both locales, with hreflang alternates + ├── app/ the App Router tree + │ ├── global-error.tsx above [locale]: renders its own , cannot use next-intl + │ ├── robots.ts · sitemap.ts the public surface, both locales + │ ├── fonts/ Mikhak woff2 (next/font/local resolves relative to the caller) │ └── [locale]/ - │ ├── layout.tsx # ROOT RSC: renders + fonts + setRequestLocale + NextIntlClientProvider + ThemeProvider + AuthProvider (seeded via getServerAuthState) + generateMetadata (the '%s | برند' title template) - │ ├── error.tsx # Branded, localized error boundary for the whole [locale] segment — reset() retries, a "go home" link escapes - │ ├── not-found.tsx # Branded, localized 404 (RSC) — reached via the [...rest] catch-all below - │ ├── [...rest]/page.tsx # Catch-all — calls notFound() so any unmatched path under a locale renders not-found.tsx (next-intl's recommended 404 pattern) - │ ├── (private-routes)/ - │ │ ├── layout.tsx # 'use client' — wraps PrivateLayout; mounts useSessionRoleSync (hydrates AuthContext roles from /me) - │ │ ├── _chrome/ShellContentSkeleton.tsx # Private (`_`-prefixed, not a route) shared loading.tsx skeleton for nurse/admin/partner — shapes the content area only; MobileShell chrome is already rendered by the enclosing layout - │ │ ├── select-role/ # /select-role — first-use role picker (no public role yet); role router lands here. Its own layout.tsx wraps FocusedLayout: outside every actor group (no role to tab for) but still inside the phone-width frame - │ │ ├── (customer)/ # Customer (family) app — mobile-first, bottom-tab nav; no URL segment - │ │ │ ├── layout.tsx # 'use client' — RoleGuard(expected=customer) → CustomerLayout - │ │ │ ├── loading.tsx # Route-group loading skeleton (header + search bar + category-tile row + card stack) - │ │ │ ├── page.tsx # Thin RSC — generateMetadata (shell.customer_app) + renders HomeScreen - │ │ │ ├── HomeScreen.tsx # 'use client' — ui-phase-4: A5 home body — tappable search entry (routes to C1; the free-text field never worked, upgrade path noted for REQ-041's `q` param), a quiet TrustStrip, the data-driven category grid, a completeness-gated + session-dismissible patient-record nudge, and a rebook shortcut row (useBookingList + per-card useBookingDetail → deep-links to the nurse's C3 profile) - │ │ │ ├── search/ # /search — f6 discovery, ui-phase-4 redesign: C1 filter screen (page.tsx: reused category grid + f3 region picker + the shared GenderToggle `allowAny` + a Jalali day-chip date-intent strip (JalaliDatePicker `chips` variant + a full-grid popover) + Toman price + a sticky live-count CTA (StickyActionBar) that turns into a non-CTA "no matches" message at zero results; useSearchFilters colocated controller) → results/ (C2) → nurse/[nurseId]/ (C3) - │ │ │ │ ├── page.tsx # Thin RSC — generateMetadata (search.title) + renders SearchScreen - │ │ │ │ ├── SearchScreen.tsx # 'use client' — C1 search & filter body; hydrates from the FULL carried URL (searchParamsToFilters + a client-only `province_id` convenience param for the cascading-select prefill), not just `category_id`; pushes the filter set + `province_id`/`date` to C2 as URL query params - │ │ │ │ ├── useSearchFilters.ts # C1 colocated filter controller — seeds every field from the initial URL (category/region/gender/price/date), not just category; derives the canonical NurseSearchFilters (debounced Toman price → IRR) - │ │ │ │ ├── results/page.tsx # C2 results, ui-phase-4 redesign — a tappable filter-recap chip row (category/region/gender/price, each deep-linking back to C1 with the ENTIRE carried query string) + a static "مرتب‌شده بر اساس امتیاز" caption (the dead one-option sort dropdown is gone) + NurseResultCard.Skeleton twins; all four states (skeleton/empty-relax/error/populated); load-more; filters live in the URL (the cache key) - │ │ │ │ └── nurse/[nurseId]/page.tsx # C3 nurse profile, ui-phase-4 dossier redesign — header now shows completed-visits count; a tappable TrustBadge (nurseId prop) + the shared VerificationPanel section (fed by useNurseTrustBadge) + attribute chips; a f13 tab strip: «خدمات» (ServicePriceRow list + an optional latest-review snippet) / «نظرات» (ReviewsPanel — published-only fractional aggregate+count + infinite list via services/reviews); a sticky bottom CTA bar (StickyActionBar, price-from beside "درخواست رزرو") survives the infinite reviews list and hands off to /bookings/request (f7); no gender chip (the public profile DTO doesn't serve `nurseGender` yet — REQ-042, never render the client's placeholder stub) - │ │ │ ├── bookings/ # ui-phase-5 lifecycle redesign — the /bookings tabs are now the lifecycle home (no request is orphaned once left) - │ │ │ │ ├── page.tsx # Thin RSC — generateMetadata (booking.list_title) + renders BookingsScreen - │ │ │ │ ├── BookingsScreen.tsx # 'use client' — three tabs («در انتظار پاسخ»/«فعال»/«گذشته»): pending wires useCustomerRequests (mini-countdown per row, deep-links to C5); active/past split useBookingList('customer') client-side by status over one growing-pageSize "load more" query (the C2 pattern); AccentCard rows (status-tone borderInlineStart) are fully tappable (role=button+keyboard); a completed row without a review shows a compact star-strip CTA (useReviewEligibility, gated to completed rows) - │ │ │ │ ├── [id]/page.tsx # /bookings/[id] — f8 customer booking detail (BookingDetailView viewerRole="customer") + f10 cancel/refund entry (CustomerBookingActions) + f13 review entry (LeaveReviewCta: on a completed/closed booking, «ثبت نظر» → review page, flips to a passive "under review" affordance once reviewed — reuses the cached booking + my-review query) - │ │ │ │ ├── request/page.tsx # /bookings/request — C4 request form, ui-phase-5 redesign: sticky nurse-identity bar (avatar/name/rating/TrustBadge/gender) + a «چه اتفاقی می‌افتد؟» 3-step strip (reuses C5's StepperHeader); JalaliDateIntentPicker date + tappable morning/afternoon/evening/custom time-window chips (kills the end≤start error class); touched-on-blur field errors + a disabled-CTA "what's missing" caption (the old submit-gated `attempted` dead code is gone); a compact address row with a «تغییر» toggle back to the select (no more fake-map preview); accepts `patient_id`/`address_id` recovery params from C5's re-request handoff - │ │ │ │ ├── request/[id]/page.tsx # /bookings/request/[id] — C5 tracker, ui-phase-5 redesign: CountdownTimer progress ring (`windowStart=createdAt`) + humanized «حدود N ساعت/دقیقه» framing above the coarse threshold; the cancel-request dialog is now `ConfirmDialog` with the destructive/dismiss labels fixed («نه، نگه دار» vs «بله، انصراف از درخواست» — the old defect had them swapped); rejected/expired terminal cards offer «درخواست دوباره با زمان دیگر» (reopens C4 prefilled) + «پرستاران مشابه» (region+gender-carried search), gated by a keyword heuristic over the freeform `nurseRejectionReason` (REQ-044 proposes a real code); converted → booking deep-link (bookingId, REQ-017) - │ │ │ │ ├── [id]/invoice/page.tsx # /bookings/[id]/invoice — f9 commission invoice (b11), ui-phase-6 fiscal-grade pass: number + Shamsi date + buyer/service/visit-date recap (client-joined off useCustomerProfile + useBookingDetail) + payment method/transaction reference + seller fiscal-identity block (all REQ-049), reconciling lines with the VAT-on-commission line, read-only مودیان state; A4 `@page` print stylesheet + print-only footer; pdfUrl download or window.print receipt - │ │ │ │ ├── [id]/cancel/page.tsx # /bookings/[id]/cancel — f10 cancellation flow, ui-phase-5 added off-ramps above the disclosure («تغییر زمان»/«گفتگو با پشتیبانی» → ContactSupportDialog, category coordination/support) + a one-line nurse-impact note; the reason select no longer pre-defaults to 'changed_mind' (empty until chosen, confirm gated); CancellationPolicyDisclosure unchanged - │ │ │ │ ├── [id]/refund_status/page.tsx # /bookings/[id]/refund_status — f10 customer refund status (RefundStatusCard): pending → on-its-way → completed, BNPL ~7–10-day ETA, failed=contact-support; polls only while non-terminal - │ │ │ │ ├── [id]/review/page.tsx # /bookings/[id]/review — f13 leave-a-review (b14), ui-phase-5 added a context recap (service/Shamsi date/nurse avatar off the cached booking) + the moderation-expectation note up front (not only post-submit); `useMyReviewForBooking` now gated `{ enabled: reviewable }` like the detail page; RatingInput + body + ReviewTagSelector; gated on completed/closed + server can_review + 1:1; on submit → persistent "under review" (pending_moderation, never public here); already-reviewed shows the review state, never a 2nd form (services/reviews) - │ │ │ │ └── checkout/ # f9 checkout flow (C5 accept CTA lands on page.tsx with ?request_id=), ui-phase-6 trust-forward redesign - │ │ │ │ ├── page.tsx # C6 خلاصه و پرداخت — identity moment (nurse avatar+TrustBadge, REQ-046), prominent `` total, served reconciling breakdown (PriceBreakdown), EscrowExplainer, safe-area-aware sticky pay bar (StickyActionBar: total+CTA+secure-gateway trust line), both CTAs disable during initiate, explicit «بازگشت به درخواست» text link, un-baked continue arrow (endIcon="forward") - │ │ │ │ ├── return/page.tsx # return-from-gateway — confirm return → staged 2-node pending progress (StatusTimeline «بازگشت از درگاه ✓ → در انتظار تایید بانک» + duration hint, replacing the old spinner+chip+title stack) → succeeded (invalidate + hand off) / failed retry / window-expired, all via the shared PaymentStateCard (on the real path the PSP redirectUrl is absolute) - │ │ │ │ ├── confirmation/page.tsx # payment success rebuilt as a receipt (ui-phase-6): copyable LTR کد پیگیری + copy-to-clipboard, Shamsi paid-at, method, booking reference (all REQ-046, hidden gracefully on the real path), EscrowExplainer, a "what happens next" 2-step StatusTimeline, real loading/error states (never a silently missing amount); «مشاهده رزرو» + «دانلود فاکتور»; REUSED by f11 (?method=bnpl reads the settled BnplOrderStatus instead of the payment outcome) - │ │ │ │ └── bnpl/ # f11 BNPL installment checkout (the alternate branch off C6, reached with ?request_id=), ui-phase-6 honesty + polish pass - │ │ │ │ ├── page.tsx # D1→D4 stateful wizard (StepperHeader): D1 method/provider · D2 plan · D3 eligibility · D4 schedule+contract → provider handoff; card fall-back → C6 everywhere; terminal cards via the shared PaymentStateCard - │ │ │ │ ├── MethodStep.tsx # D1 روش پرداخت — payable amount + full-card option + provider option cards (from useBnplOptions, never hardcoded); provider mark via BnplProviderLogo (replaces the two-letter glyph stand-in) - │ │ │ │ ├── PlanStep.tsx # D2 انتخاب طرح — single-select BnplPlanCard group; the «مبلغ کل» header only renders once a plan is selected and **names** it («مبلغ کل با طرح {plan}») + the fee delta, never a silent plans[0] default - │ │ │ │ ├── EligibilityStep.tsx # D3 اعتبارسنجی — کد ملی + prefilled موبایل (readOnly presentation, not disabled) + consent gate → useCheckEligibility (in-progress spinner + «در حال استعلام اعتبار…» label) → approved(ceiling)/declined(+card) - │ │ │ │ ├── ScheduleStep.tsx # D4 تایید طرح و قرارداد — served repayment rows (InstallmentScheduleRow) + ownership note + contract-consent gate → useIssueBnplToken handoff - │ │ │ │ ├── gateway/page.tsx # dev provider-handoff harness (TEST HARNESS; mock redirectUrl points here) → return; env-gated `notFound()` outside `development` (ui-phase-6 — was reachable in production builds) - │ │ │ │ └── return/page.tsx # settle (useAcceptBnplSchedule) → invalidate → reused confirmation (?method=bnpl) / retry / card; the invalid-link CTA label now matches its destination (ui-phase-6 fix) - │ │ │ ├── patients/page.tsx # /patients — the E1 «حلقهٔ مراقبت» (care-circle) list/CRUD, ui-phase-9 redesign: copy-level rename from «بیماران» (route/service/i18n key names unchanged), `FormDialogShell` (full-screen-on-mobile) replacing the `maxWidth="sm"` dialog, `PatientForm`'s `onDirtyChange` wired to its discard-confirm; error/empty/loading states unchanged (already fixed pre-phase-9); tapping a `PatientCard` (now a pressable surface with a chevron, not an invisible button) opens the E2 record (f13) - │ │ │ ├── patients/[id]/record/page.tsx # /patients/[id]/record — f13 E2 care-record viewer (b14), ui-phase-9 rebuild: reused PatientHeader (now with an avatar) + ownership banner + 4 tabs (داروها/روتین/سوابق/وظایف). The whole-list free-text edit mode is gone — each medication/routine/task row opens a per-item responsive sheet (`Drawer anchor="bottom"` mobile / `Dialog` desktop, its own dirty-gated discard-confirm) with structured dose amount+unit/frequency-preset+time-of-day-chip fields (REQ-027 addendum); سوابق (read-only nurse visit-note history, VisitNoteCard) is now a Shamsi month-grouped timeline with a booking link + task-done summary when derivable; access-denied is a first-class non-leaking state gated BEFORE any clinical fetch (services/patientRecords) — preserved verbatim - │ │ │ ├── addresses/page.tsx # /addresses — F3 address book, ui-phase-9 pass: `isError`→`ErrorState` (was silently absent), `FormDialogShell` replacing the cramped dialog, `AddressCard`'s new pin-quality cue (`hasPin`/pin_set/pin_missing) - │ │ │ ├── wallet/ # /wallet — ui-phase-6 rebuilt into the customer money hub (page.tsx → WalletScreen.tsx: 4 Tabs, one shared `CONTENT_MAX_WIDTH`, no local width override): «پرداخت‌ها» (WalletPaymentHistory — card `usePaymentHistory` (REQ-047) + BNPL down-payment rows merged via the co-located `useWalletHistoryRows`), «اقساط» (WalletInstallments — the unchanged f11 D5 provider-reported outstanding balance + due list + early-pay hand-off, now section-only, no own heading/width), «استردادها» (WalletRefunds — `useMyRefunds`, REQ-048, one RefundStatusCard per refund), «رسیدها» (WalletReceipts — client-derived invoice deep-links off the same merged history rows, no endpoint) - │ │ │ ├── profile/page.tsx # /profile — ui-phase-9 rebuild into the customer's account hub (still the single route — sub-sections are `FormDialogShell` sheets, not sub-routes): `ProfileSummary` identity header (avatar/initials + name + server-masked phone), grouped tappable rows (اطلاعات شخصی/نشانی‌ها/زبان/اعلان‌ها/پشتیبانی/خروج — the زبان row owns the server-stored `preferredLanguage` and hosts the phase-2 `LocaleSwitcher`, never a second locale mechanism), an emergency-contact status card (tel:-only link when complete, a warm nudge otherwise), `ActorSwitcher` preserved; sign-out goes through `useLogout()` only. No national-ID. - │ │ │ ├── support/tickets/ # /support/tickets — f14 "My Tickets" inbox (TicketInboxScreen role="customer") ↔ support/tickets/[id]/page.tsx thread (TicketThreadScreen); thin role-passing wrappers over @/components/messaging - │ │ │ └── notifications/page.tsx # /notifications — f14 notification center (NotificationCenter role="customer"); the TopBar bell deep-links here - │ │ ├── nurse/ # Nurse app (/nurse/…) — 4-tab bottom nav, one tab per group root - │ │ │ ├── layout.tsx # 'use client' — RoleGuard(expected=nurse) → NurseLayout - │ │ │ ├── loading.tsx # → ../_chrome/ShellContentSkeleton - │ │ │ ├── page.tsx # /nurse (the «امروز» tab) — thin RSC (generateMetadata nav.dashboard) rendering NurseDashboardScreen.tsx - │ │ │ ├── practice/page.tsx # /nurse/practice — «حرفهٔ من» group root (NursePracticeScreen): a listing-status card (own TrustBadge + the real accepting-bookings state) over links to profile/services/coverage/verification, each with a count read off its already-cached query (omitted, never faked, while one is in flight) - │ │ │ ├── finance/page.tsx # /nurse/finance — «مالی» group root (NurseFinanceScreen): the SIGNED net payable balance (an owed-back reads as an error tone, never clamped) over links to earnings/payout history/bank - │ │ │ ├── more/page.tsx # /nurse/more — «بیشتر» group root (NurseMoreScreen): ProfileSummary + ActorSwitcher, support/notifications links with unread badges, SettingsPanel, SignOutRow. Everything the sidebar drawer header and footer used to carry - │ │ │ ├── NurseDashboardScreen.tsx # ui-phase-7 — the «امروز» operational home replacing the old PlaceholderScreen: NextVisitCard (useTodaySessions), RequestsStrip (useNurseRequestInbox, the most time-critical widget — sorts above earnings), EarningsSnapshotCard (useNurseEarningsBalance, signed net + eligible), DashboardActivationSlot, NotificationsEntryRow (useUnreadCount) — every widget is a read of an already-cached query, four-state pattern throughout. iteration-2 removed the greeting+TrustBadge strip (it spent the screen's most valuable row restating the signed-in name and duplicating the activation tracker's badge) and replaced it with a plain `PageHeader` — load-bearing now that the nav is icon-only, since the page title is the only place the current section is named - │ │ │ ├── DashboardActivationSlot.tsx # ui-phase-8 — the named composition point from ui-phase-7's hand-off, now filled with the shared `ActivationChecklist` (same component as `/nurse/services`) instead of the old single-row verification banner - │ │ │ ├── requests/ # /nurse/requests — f7 incoming booking-requests inbox, ui-phase-7 redesign: page.tsx = decision-first cards (service+price headline when served — REQ-050, mock-tolerant) + an urgency-tinted CountdownTimer pill (teal/amber/terracotta tiers) + three tabs (در انتظار/پاسخ‌داده merged-client-side page-1-only/منقضی, shared Pager) ↔ requests/[id]/page.tsx detail (only customerNotes + masked city/district; accept now behind a ConfirmDialog + a post-accept payment-window countdown; reject-with-reason invalidate inbox+detail) - │ │ │ ├── profile/ # /nurse/profile — B7 profile bootstrap, ui-phase-8 pass: page.tsx adds editable education level/field (select + "سایر" free-text fallback) + specializations (chips, shared `SPECIALTY_PRESETS` vocab), a beforeunload guard on a staged-but-unsaved avatar, and a link into preview/ ↔ preview/page.tsx «نمایهٔ عمومی من» — the C3 trust-dossier pieces (TrustBadge/VerificationPanel/ServicePriceRow) composed entirely from the nurse's OWN cached data (own profile + useMyVariants + useServiceAreas + own badge), so it renders truthfully pre-publish. iteration-2: rebuilt on react-hook-form + three `FormSection`s (معرفی / تجربه و تحصیلات / تخصص‌ها), with the blocked-until-verified banner as an `AccentCard` and the trust badge in `PageHeader`'s `meta` slot - │ │ │ ├── services/ # /nurse/services — B7 services half: offerings list ↔ variant builder (page.tsx switches mode; MyServicesList + VariantBuilder + PublishGate co-located). ui-phase-8: MyServicesList mounts the shared `ActivationChecklist` + a link to the profile preview above the list; PublishGate rewritten as the REAL `set_accepting_bookings` toggle (state-driven guidance/start/pause — no more no-op snackbar); VariantBuilder's step-3 renders the actual `VariantCard` as a live listing preview, option groups are chips (not a wrapped ToggleButtonGroup), and a 409 duplicate offers "edit the existing listing" (resolved via `optionSetSignature` against the cached `useMyVariants` list). iteration-2 made the create flow deterministic: the step list is DERIVED from the loaded option groups (a category with none skips straight to pricing instead of showing an empty middle step), Next is disabled with the unanswered required groups NAMED under it (was: always enabled, error only after the tap), and the price step recaps the chosen category+options as chips so it doubles as a review; RHF + `FormSection` throughout, sticky step footer - │ │ │ ├── coverage/page.tsx # /nurse/coverage — F3 coverage-area editor. ui-phase-8: the separate whole-city/districts scope toggle is gone — `CascadingRegionSelect`'s own district level (its «کل شهر» empty option) is the ONLY control for the whole-city choice; `removeArea` now has an onError toast - │ │ │ ├── bank/page.tsx # /nurse/bank — payout IBAN + ownership states (pending/verified/mismatch; `BankStatusPanel` unchanged). ui-phase-8: restructured as an accounts section — a persistent «افزودن حساب دیگر» CTA once ≥1 account exists (never dead-ends a nurse switching banks), an explicit pending-inquiry copy, and a real error state (never the empty-state form) on a failed query - │ │ │ ├── verification/ # /nurse/verification — f5 trust flow: ONE cached VerificationStatus query, four views. ui-phase-8: rebuilt as a single vertical journey — one progress metaphor everywhere (the old flat "X از Y" meter + the B4/B5/B6 3-step `StepperHeader` are both gone). iteration-2: B4 and B5 are RHF forms grouped into `FormSection`s — B4's three asks (national id / card photo / selfie) each become a section with the card marked optional and the selfie marked required (the submit gate is a real form field, not a caption near the bottom); B5's four (شماره نظام / مدارک with an «n از m» count / تخصص‌ها / جزئیات مدرک, the last two marked optional) mount only once the status query resolves, so the server read-back IS `defaultValues` - │ │ │ │ ├── page.tsx # B3 hub — grouped step cards (هویت/مدارک حرفه‌ای/بانک, `VerificationChecklist`) + `TrustBadgePreviewPanel` («این نشان را خانواده‌ها می‌بینند», fills per group) + single continue CTA + not_started/approved states; dev-only mock admin-decision sim - │ │ │ │ ├── identity/page.tsx # B4 — national-ID (checksum) + card/selfie local capture → automated KYC + chained Shahkar; a cheap CSS `CaptureGuideFrame` (viewfinder corners / oval) + static hint per capture, `VerificationJourneyHeader` replaces the old StepperHeader - │ │ │ │ ├── credentials/page.tsx # B5 — hydrates INO/specialties/registry fields from `status.credentialSubmission` (REQ-056, mock-tolerant) so a returning nurse sees a submitted summary, never blank fields; the INO number locks into a "شمارهٔ نظام ثبت شد" row (never re-prompted, never re-sent blank — the raw value is never read back by design); Jalali `JalaliDateField`s replace the native `type="date"` issue/expiry inputs; the submit gate considers server-side document state too, so a returning nurse is never dead-ended on a disabled button with no explanation - │ │ │ │ ├── review/page.tsx # B6 — under-review; a Shamsi submitted timestamp (REQ-055, mock-tolerant) + a `StatusTimeline` what-happens-next (بررسی توسط کارشناس → نتیجه در ۲۴–۴۸ ساعت → فعال‌سازی نشان) + `VerificationJourneyHeader` - │ │ │ │ ├── VerificationChecklist.tsx # B3 body: grouped step cards over `groupedDisplaySteps` (co-located, page-only) - │ │ │ │ ├── TrustBadgePreviewPanel.tsx # B3's payoff — live TrustBadge + a per-group fill indicator, never fakes a state beyond `ownBadgeState` - │ │ │ │ ├── VerificationJourneyHeader.tsx # the ONE progress header B4/B5/B6 share — group name + «بازگشت به مسیر تأیید» (reused across 3 sibling pages) - │ │ │ │ └── verificationSteps.ts # step→label/chip/route helpers + synthetic mobile step + ui-phase-8's group→steps folding (`groupedDisplaySteps`/`groupStatus`/`GROUP_ORDER`) — keeps rendering data-driven - │ │ │ ├── visits/ # /nurse/visits — f8 EVV, ui-phase-7 day-surface + detail pass: page.tsx = Shamsi «امروز، …» date anchor + ویزیت امروز today-sessions feed (per-session check-in/out via useEvvController + advisory EvvStatusBanner; 60s refetchInterval; SessionCard's EVV CTA is now the full-width hero action + a confirm step on check-out) ↔ visits/[id]/page.tsx nurse booking detail (BookingDetailView viewerRole="nurse": address card + geo: map link, an in-visit «در حال ویزیت» banner promoting check-out, EVV controls + gated care card) + f13 NurseVisitNotesPanel.tsx (co-located, BELOW the EVV banner: today's task checklist + free-text note composer + read-only continuity history — APPEND-ONLY, never wires useUpdateCareRecord; services/patientRecords) - │ │ │ ├── earnings/ # /nurse/earnings — f12 nurse earnings (read-only), ui-phase-7 pass: page.tsx = EarningsBalanceHeader (net payable balance + 4 buckets, negative "owed back") + a «برداشت بعدی» ForecastLine (server-served only) + an accessible ButtonBase ExplainerCard (aria-expanded, registered `expand` chevron) + state-segmented EarningsRow list (deep-links to /nurse/visits/[id], shared Pager) ↔ payouts/page.tsx (PayoutHistoryRow list) → payouts/[id]/page.tsx (payout/batch reconciliation detail: money decomposition + masked IBAN + booking links); failed-payout reasons now map through `services/payouts/failureReasons.ts` (mapped label headline, raw code demoted to a secondary LTR caption) - │ │ │ ├── support/tickets/ # /nurse/support/tickets — f14 nurse "My Tickets" (same TicketInboxScreen/TicketThreadScreen, role="nurse") ↔ support/tickets/[id]/page.tsx - │ │ │ └── notifications/page.tsx # /nurse/notifications — f14 notification center (role="nurse"); the nurse-shell bell deep-links here - │ │ ├── admin/ # Admin/backoffice (/admin/…) — 5-tab bottom nav over four group roots (f15). Every screen is role-gated via useAdminCapabilities(); the sidebar hides a console the current admin role can't act on (server still enforces). ui-phase-11: every queue page adopts `useAdminListState` (URL-synced filters+page, `@/hooks`) behind a `` wrapper. - │ │ │ ├── layout.tsx # 'use client' — RoleGuard(expected=admin) → AdminLayout (capability-gated nav) - │ │ │ ├── loading.tsx # → ../_chrome/ShellContentSkeleton - │ │ │ ├── page.tsx # Thin RSC — generateMetadata (admin.overview_title) + renders AdminOverviewScreen - │ │ │ ├── AdminOverviewScreen.tsx # 'use client' — f15 overview landing: every permitted console as a NavHubList, grouped by the same four sections the bottom nav carries (the old 3-column card grid collapsed to one column of icon-and-a-word tiles inside the frame) - │ │ │ ├── _hub/AdminGroupHub.tsx # Private (`_`-prefixed, not a route) body every admin group root shares: header + capability-filtered NavHubList + an optional tail; an all-denied group renders an explicit no-access state, never an empty card - │ │ │ ├── trust/page.tsx # /admin/trust — «اعتماد» group root (verification queue + review moderation) - │ │ │ ├── finance/page.tsx # /admin/finance — «مالی» group root (the weekly payout run) - │ │ │ ├── support/page.tsx # /admin/support — «پشتیبانی» group root (ticket queue + internal alerts) - │ │ │ ├── system/page.tsx # /admin/system — «سیستم» group root (config/holidays/audit/partners/users/roles) plus the admin identity chip, SettingsPanel and SignOutRow that used to live in the top bar. Always reachable: it is the only way out of the app - │ │ │ ├── verification/ # /admin/verification — ui-phase-11 rebuild: page.tsx = status **Tabs** with server counts (when served, REQ-062) + a name/phone search behind the draft-vs-applied Apply/Clear pattern + a client-computed SLA-colored waiting-time column (`WAITING_TIME_WARNING_HOURS`/`_ALARM_HOURS`) ↔ [nurseId]/page.tsx per-nurse case (unchanged DocumentViewer signed-URL docs, pass/reject+reason per step, structured credential entry with `JalaliDateField` issued/expires, Approve enabled only when all steps pass) + «پرونده بعدی/قبلی» next/prev case nav (re-derives the queue's cached page via `queueFilters.ts`) + arrow-key bindings — client never writes is_verified - │ │ │ ├── tickets/ # /admin/tickets — ui-phase-11: page.tsx adds an activity column + a results footer ↔ [id]/page.tsx admin thread gains close/reopen/assign-to-me (`useCloseTicket`/`useReopenTicket`/`useAssignTicket`, gated behind `TICKET_LIFECYCLE_ENABLED` + `canManageTickets` — REQ-063, no live route yet), opens scrolled to the newest message (`useThreadScroll`), and the composer turns amber (`--bal-warning-soft`) + relabels its send button in internal-note mode so a note can't be posted publicly by mistake; AdminMessageBubble still renders isInternal notes distinctly; RefundPanel opens from a refund ticket - │ │ │ ├── payouts/ # /admin/payouts — ui-phase-11: page.tsx fixes the local-midnight UTC off-by-one on the window default, adopts `JalaliDateField` for the period inputs, and the run-confirm shows the batch total/count/date (from the already-fetched preview) behind `ConfirmDialog`'s new typed-confirmation gate (type «تایید» or the amount) ↔ [batchId]/page.tsx per-nurse rows + failed-payout retry + transfer-reference reconcile, unified onto `PageHeader` - │ │ │ ├── reviews/page.tsx # /admin/reviews — f15 moderation queue: publish/hide/reject (reason on hide/reject); low-rating flag; client never computes the aggregate; ui-phase-11 adopts `useAdminListState` - │ │ │ ├── config/page.tsx # /admin/config — f15 config editor: typed input by data_type + 0–1 rate validation + audited-save dialog + change-history drawer; ui-phase-11 gives both the list and the history drawer real page state (was hard-wired to page 1) and drops the dead `dataType==='int'||'decimal'?'text':'text'` ternary - │ │ │ ├── holidays/page.tsx # /admin/holidays — f15 Iranian-holiday manager (is_bank_closed toggle; client never computes the payout shift); ui-phase-11 adopts `JalaliDateField` for the holiday date, seeds new-holiday date from a real local-date helper (was a lying `TODAY_ISO = ''`), and real page state - │ │ │ ├── alerts/page.tsx # /admin/alerts — f15 internal support-alert worklist (assign/resolve); NEVER surfaced to a non-admin; ui-phase-11 fixes assign-to-self's `?? 1` fallback — the button disables with a "loading your account" tooltip until the real id hydrates — and adopts `useAdminListState` - │ │ │ ├── audit/page.tsx # /admin/audit — f15 append-only audit viewer (filtered, paginated, expandable changedFields diff; no edit/delete); ui-phase-11 adopts `useAdminListState` + `JalaliDateField` for from/to + batch-resolves actor names (`useUserLookup`) for `AuditLogRow`'s new `actorLabel` prop (falls back to `#id`) - │ │ │ ├── partners/ # /admin/partners — f15 partner-center management (page.tsx: list + create) ↔ [id]/page.tsx detail (verify/activate/suspend + edit + sponsored-nurse roster + assign-nurse; IBAN write-then-masked); ui-phase-11 replaces both the admin-user and sponsored-nurse raw-id `TextField`s with `UserPicker`/`NursePicker` (name+masked-phone+id search, §3.2) and unifies the detail header onto `PageHeader` - │ │ │ ├── roles/page.tsx # /admin/roles — f15 RBAC grant/revoke grid (DEFERRED-IF-MISSING — mock-backed until the b15 role endpoints land); ui-phase-11 replaces the raw numeric-id grant `TextField` with `UserPicker` — the confirm/revoke copy now names the resolved person, never `#42` - │ │ │ ├── users/page.tsx # /admin/users — ui-phase-11: a real read-first directory (was a `PlaceholderScreen`) — search by name/phone over the same admin user-directory seam `UserPicker` uses (REQ-061, mock-backed), role chips, a per-row link into the audit log filtered to that user - │ │ │ └── notifications/page.tsx # /admin/notifications — still a placeholder; NOT in `AdminLayout`'s nav (no real feed yet, ui-phase-10/11) so it satisfies "no placeholder reachable from admin nav" - │ │ └── partner/ # Partner-center portal (/partner/…) — a SEPARATE authz scope (f15). A center admin is not a Balinyaar admin; each page resolves the caller's OWN center (useMyPartnerCenter → access-denied on 403/404). - │ │ ├── layout.tsx # 'use client' — RoleGuard (no expected role — hydration-only) → PartnerLayout (own partner nav; self-gates via useMyPartnerCenter) - │ │ ├── loading.tsx # → ../_chrome/ShellContentSkeleton - │ │ ├── page.tsx # Thin RSC — generateMetadata (partner.home_title) + renders PartnerHomeScreen - │ │ ├── PartnerHomeScreen.tsx # 'use client' — center home: onboarding/verification state banner + license fields + is_merchant_of_record indicator - │ │ ├── nurses/page.tsx # /partner/nurses — the center's sponsored nurses (verification badge) - │ │ ├── bookings/ # /partner/bookings — ui-phase-11: page.tsx localizes the 7 booking-status codes onto `StatusChip` (was raw English wire codes, e.g. `pending_payment`) in both the table and filter, adopts `useAdminListState`, and rows link to ↔ [id]/page.tsx (new) — a scoped read-only detail (dates, status timeline via the shared `StatusTimeline`, patient display name only — no clinical data; REQ-064, mock-backed) - │ │ ├── more/page.tsx # /partner/more — «بیشتر» group root: the center identity + merchant-of-record status (previously squeezed into the top bar and hidden below `sm`), SettingsPanel, SignOutRow - │ │ └── settlement/page.tsx # /partner/settlement — rendered ONLY when is_merchant_of_record: per-booking commission invoices (commission/VAT decomposition via PartnerSettlementRow, signed-URL PDF, masked IBAN); non-MoR shows the "settlement via Balinyaar" state; ui-phase-11 adds a client-side «خروجی CSV» export (`utils/toCsv.ts`, UTF-8 BOM + CRLF for Excel) of the current result set - │ ├── (customer-focused)/ # ui-phase-3 — chrome-free counterpart to (customer) for can't-tab-away flows; same URL space (route groups add no segment) - │ │ ├── layout.tsx # 'use client' — RoleGuard(expected=customer) → FocusedLayout (no BottomBar/bell/sidebar) - │ │ └── onboarding/ # /onboarding — moved here from (customer) so the A3→A4 wizard can't be tabbed away from mid-setup - │ │ ├── page.tsx # Thin RSC — generateMetadata (onboarding.welcome_title) + renders OnboardingScreen - │ │ └── OnboardingScreen.tsx # 'use client' — welcome moment (not a stepper step) → relation (4 distinct icons: elderly/favorite/infant/account) → patient (StepperHeader 2 steps) - │ └── (public-routes)/ - │ ├── layout.tsx # 'use client' — wraps PublicLayout - │ ├── loading.tsx # Auth-card-shaped skeleton (brand mark + a card-sized block) - │ ├── login/ # /login — phone-OTP login (A1/A2 customer, B1/B2 nurse switch) - │ │ ├── page.tsx # Thin RSC — generateMetadata (auth.customer_title) + renders LoginScreen - │ │ └── LoginScreen.tsx # 'use client' — the actual LoginFlow body - │ ├── terms/page.tsx # /terms — draft Terms of Service (ui-phase-3; DRAFT COPY, needs human/legal review before launch) - │ ├── privacy/page.tsx # /privacy — draft Privacy Policy (ui-phase-3; DRAFT COPY, needs human/legal review before launch) - │ └── welcome/ # /welcome — ui-phase-13 public front door; middleware REWRITES an unauthenticated '/' here (URL stays '/'), so this is what a guest, and every crawler, actually sees at the root - │ ├── page.tsx # Thin RSC — generateMetadata (description/OG/`alternates.canonical` pointing at `/{locale}`; no `title` override, so it inherits the root layout's default "بالین‌یار"/"Balinyaar" — that's how it's told apart from the customer home's own `shell.customer_app` title in view-source) + renders WelcomeScreen - │ ├── WelcomeScreen.tsx # Async Server Component (no 'use client') — hero (BrandMark) + static category grid (CategoryTile via its new `href` prop, §3.1 tier a+b — NOT the live catalog) + how-it-works (step 2 reuses EscrowNotice verbatim) + a static trust/verification explainer + nurse-recruitment CTA + footer; zero query hooks anywhere in the tree — first paint is the whole page - │ └── opengraph-image.tsx # `next/og` ImageResponse — a static brand-mark composition (Latin-only; Persian would need an embedded Mikhak font buffer, left for a follow-up) on the brand teal/cream, served as the page's absolute `og:image` - ├── components/ # Shared UI components (each with .test.tsx if imported >1 place) - │ ├── common/ # Foundational primitives (import from @/components or @/components/common) - │ │ ├── AppButton/, AppIconButton/, AppIcon/, AppLink/, AppAlert/, AppLoading/ # house-default MUI wrappers (see frontend-designer skill §4) - │ │ ├── ErrorBoundary.tsx # class component wrapping page content in the shell; PRESENTATIONAL — no next-intl import, caller passes title/body/retryLabel (see "Presentational purity" below) - │ │ ├── EmptyState/ # icon+title+body+action — the one "nothing here" pattern (replaces hand-rolled dashed-border Paper blocks) - │ │ ├── ErrorState/ # message+retryLabel(required)+onRetry — the one "this query failed" pattern; PRESENTATIONAL, no next-intl import (same reason as ErrorBoundary) - │ │ ├── QueryStateGate/ # wraps a query's skeleton/error/empty/children branching in the fixed skeleton→error→empty→children order; requires retryLabel - │ │ ├── PageHeader/ # title+subtitle+actions+optional back button (backTo/backLabel); ui-phase-11 added `meta` (a chip-row slot below the title, distinct from the button-oriented `actions`) and `onBack` (a callback alternative to `backTo` — pairs with `useAdminBackToList` for `router.back()`-with-fallback semantics; takes precedence over `backTo` when both are given) - │ │ ├── ConfirmDialog/ # promoted from admin/ — required-reason gating + busy-disable, now usable by any actor; ui-phase-11 added `requireTypedConfirmation`/`typedConfirmationLabel`/`typedConfirmationPlaceholder` — confirm stays disabled until the typed value matches one of the given strings, the guard for an irreversible money-moving action (the admin payout run confirm) - │ │ ├── SurfaceCard/ # flat Paper wrapper, padding: 'sm'|'md'|'lg' - │ │ ├── AccentCard/ # SurfaceCard + a semantic `tone` for a STATEFUL panel. iteration-2: the colored edge stripe is GONE — a column of cards read as a row of loose vertical rules down the RTL side of the screen. `tone` survives as the semantic label (reaches the DOM as `data-accent-tone`); state is carried by the StatusChip/icon/copy inside the card. Do not reintroduce the stripe - │ │ ├── form/ # (iteration-2) the react-hook-form seam — `FormSection` (a titled/described group of fields, with an optional/status marker) + `RhfTextField` (`transform` normalizes keystrokes into form state; rule message replaces the helper text) / `RhfChipSelect` (multi or single; `allowCustomValues` renders a stored code that isn't in the option list) / `RhfJalaliDateField` / `RhfControlGroup` (label+hint+error shell around ANY non-input control — GenderToggle, RatingInput, a map picker). Every wrapper falls back to the enclosing `FormProvider`'s control, so a form wires it once (all tested) - │ │ ├── Money/ # — the one money-rendering primitive (wraps utils/money.ts); size gained `xl` (h4) in ui-phase-6 for the checkout/confirmation prominent-total hero; imports next-intl (see jest.config.ts transformIgnorePatterns note below) - │ │ ├── StatusTimeline/ # ordered TimelineNode[] (completed/current/pending/failed) with animated pulse on current (respects prefers-reduced-motion) - │ │ ├── JalaliDatePicker/ # calendarEngine.ts (jalaali-js-backed Jalali↔Gregorian) + grid/chips variants (ui-phase-4: chips variant takes optional `todayLabel`/`tomorrowLabel` overrides — the C1 «امروز»/«فردا» date-intent strip), RTL-aware keyboard nav - │ │ ├── JalaliDateField/ # read-only TextField + Popover wrapping JalaliDatePicker - │ │ ├── JalaliDateIntentPicker/ # ui-phase-5 — extracted from C1's local date-intent widget (near-day chip strip + a calendar-icon Popover entry into the full grid) so C4's real required date field reuses it too, not just C1's intent-only one; caller-owned today/tomorrow/pick-other labels (tested) - │ │ ├── StickyActionBar/ # ui-phase-4 — a `position:sticky` bottom-pinned action-bar shell for a scrolling screen's primary CTA (C1's live-count CTA, C3's booking CTA). iteration-2: offsets off `--bal-chrome-bottom` (published by AppFrame) so it clears the pinned nav; the property already carries `env(safe-area-inset-bottom)` and resolves to `0px` in a chrome-free shell - │ │ ├── LocaleSwitcher/ # ui-2 fa/en toggle preserving the current route (`router.replace(pathname, {locale})` via `@/i18n/navigation`); sidebar footers, the customer profile hub, the public shell (tested) - │ │ ├── Pager/ # ui-phase-7 — the shared prev/next "page X of Y" control (common namespace i18n) replacing the near-identical inline pagers hand-rolled per list screen (nurse inbox tabs, payout history/earnings) (tested) - │ │ ├── InitialsAvatar/ # ui-phase-9 — warm auto-colored initials for a person with no photo: deterministic name-hash → one of 6 `--bal-avatar-*` token pairs (tokens.css, both scheme blocks); `aria-hidden` (decorative next to a visible name); used by `PatientHeader` and (via `ProfileSummary`'s new `initialsFallback` prop) the customer account hub (tested) - │ │ ├── FormDialogShell/ # ui-phase-9 — full-screen-below-`sm` form dialog (app-bar header + close) with a dirty-gated discard-confirm on close/backdrop/escape; the shared primitive behind the patient/address add-edit dialogs (the hosted form reports `dirty` via an `onDirtyChange` prop) (tested) - │ │ ├── NavHubList/ # the grouped list of destinations a group-root («hub») page is built from — the surface that replaced the sidebar: icon chip + title + one line of orientation + optional badge/meta + chevron, navigating through the locale-aware `@/i18n/navigation` Link (tested) - │ │ ├── RouteFadeIn/ # ui-phase-12 — the one route-content fade/slide primitive (motion pass): wraps `{children}`, keyed on the locale-stripped pathname so it remounts (replays the CSS `bal-fade-in` keyframe, globals.css) on navigation but never on an in-place re-render; mounted inside the `ErrorBoundary` in all five shells (tested) - │ │ └── index.tsx # barrel — keep next-intl-importing primitives (Money) below the presentational ones so the poisoning risk stays visible in review - │ ├── admin/ # f15 backoffice + partner composites (import from `@/components/admin`): AdminDataTable (v2, ui-phase-11 — optional per-column server-param `sort`/`sortable` + `TableSortLabel`, `stickyHeader` scroll viewport, `minWidth`, a `footer` line), AdminPager (page/pageCount; the admin `page_indicator` i18n key regained its `{total}`), AdminPageHeader/AdminEmptyState/AdminErrorState, ConfirmDialog (thin alias), ConfigRow/AuditLogRow (ui-phase-11 — expand chevron rotates + `aria-expanded` + button semantics, new `actorLabel` prop resolved via a batch id→name lookup)/SupportAlertCard (ui-phase-11 — `assignSelfDisabled`/`assignSelfDisabledTitle` so "assign to me" never falls back to a guessed user)/PartnerSettlementRow, DocumentViewer, RefundPanel, AdminMessageBubble, and (ui-phase-11) `UserPicker`/`NursePicker` — async name/phone `Autocomplete` over the admin user directory (REQ-061, mock-backed), replacing every raw numeric-id `TextField` on an audited action; each option renders name+masked-phone+id, never a bare id - │ ├── PlaceholderScreen/ # Empty-state scaffold for not-yet-built screens - │ ├── OtpInput/ # OTP code input (auto-advance, paste, RTL-safe) - │ ├── PhoneNumberField/ # Iranian mobile field (digit-normalizing, LTR-in-RTL, maskIranMobile) - │ ├── StepperHeader/ # Progress header for onboarding/verification flows - │ ├── StatusChip/ # Semantic status chip (verified/pending/rejected/…) off --bal-* tokens - │ ├── GenderToggle/ # Required male/female toggle (never defaulted) — drives same-gender matching; ui-phase-4 added an opt-in `allowAny` mode (discriminated-union props) adding a third «فرقی ندارد» option for C1's search facet — the default booking-context contract is unchanged - │ ├── ConditionChips/ # Multi-select patient-condition chips (stable codes, translated labels) - │ ├── RelationSelect/ # Single-select relation radio cards (parent/spouse/child/self) - │ ├── PatientForm/ # A4 patient form (first/last name, age/gender/conditions/relation) — reused create+edit; ui-phase-9 split the single full-name field into first/last (lastName falls back to firstName when blank — the wire requires it) and added an `onDirtyChange` prop for `FormDialogShell`'s discard-confirm - │ ├── PatientCard/ # E1 care-circle summary card (composes the shared PatientHeader, now with its avatar) + edit/archive actions; ui-phase-9 replaced the invisible tap target with a pressable `ButtonBase` surface (hover/press + a trailing chevron) and added an optional `lastVisitLabel` teaser (never populated without a `patientId` on the cached bookings list — REQ-057) - │ ├── BankStatusPanel/ # Nurse bank-account ownership state (pending/verified/mismatch), masked IBAN - │ ├── CategoryTile/ # f4 tappable service-category tile (icon+label; `selected` state for the builder) — Home grid + builder step 1; ui-phase-13 added an `href` mode (renders as the anchor itself via `ButtonBase`'s `component` swap to `AppLink` — never a button nested inside a link) for the public landing's static category grid (tested) - │ ├── PriceDisplay/ # f4 price renderer: money-util Toman + i18n unit label + unit-aware estimated total (never a total from price alone) (tested) - │ ├── VariantCard/ # f4 nurse offering card: display_name, PriceDisplay, active/deactivated distinction, edit/deactivate (no delete); ui-phase-8 added `interactive={false}` for a read-only preview use (the builder's live listing preview + the profile preview) (tested) - │ ├── ActivationChecklist/ # ui-phase-8 — the unified go-live tracker (`useActivationChecklist` hook, self-fetching): five already-cached queries folded into rows — two-tier honesty (identity/profile/services/coverage drive search visibility; bank drives "getting paid", labelled separately and never gates search) — collapses to a compact «فعال در جستجو» state once everything passes AND accepting-bookings is on; mounted on `/nurse/services` and the dashboard's `DashboardActivationSlot`, one shared component; `useActivationChecklist` is also consumed directly by `PublishGate` so the go-live gate and the checklist never compute the conditions twice (tested) - │ ├── TrustBadge/ # f5 public trust signal (verified/unverified/expired) off --bal-* tokens — nurse profile + reused by f6 search/public profile; ui-phase-4 added an opt-in `nurseId` prop that makes the badge tappable, opening a bottom-sheet/dialog explainer (the shared VerificationPanel, fed by a LAZY useNurseTrustBadge(nurseId) fetch) — split into an inner `InteractiveTrustBadge` so the default (no `nurseId`) mode calls no query hook at all and needs no QueryClientProvider in its callers' tests (tested) - │ ├── DocumentUpload/ # f5 reusable doc uploader: client type/size validation, progress %, success/retry, re-upload on reject; server-metadata truth (local-capture mode too) (tested) - │ ├── VerificationPanel/ # ui-phase-4 — shared "what Balinyaar verified" explainer (`src/components/VerificationPanel/`): one row per TrustBadge.credentialTypes[] (i18n off the verification namespace's step_* codes) + the approval date, fed by useNurseTrustBadge; used standalone on the C3 profile AND inside TrustBadge's tap-to-explain dialog; reused unchanged by phase 8's public-profile preview (tested) - │ ├── NurseResultCard/ # f6 C2 result card, ui-phase-4 v2 anatomy: avatar+name, tappable verified TrustBadge, a service/variant label (category name until REQ-040's `variantDisplayName` lands), a quiet nurse-gender chip + completed-visits count, rating+review count, optional distance chip, an optional one-line top-review tag (REQ-040), and "from X تومان/unit" via PriceDisplay; presentational + memoized (tested) - │ ├── ServicePriceRow/ # f6 C3 service line: localised name + PriceDisplay (money util + i18n unit label); reused by the booking summary later (tested) - │ ├── CountdownTimer/ # f7 pure presentational countdown to a server-frozen UTC deadline; owns its own 1s tick (only it re-renders), stops + shows elapsed text at zero, locale digits LTR; v2 (ui-phase-1) progress ring (`windowStart`) + urgency tiers + humanized coarse mode — C5's response countdown (ui-phase-5) is the ring's first live consumer (`windowStart=createdAt`) (tested) - │ ├── BookingRequestSummaryCard/ # f7 engagement summary (nurse+rating, patient, priced service, address, Shamsi time) — shared by C5 + nurse detail + later f8 booking detail; ui-phase-5 bidi-isolated the date·time-range label (`dir="ltr"` span, matching SessionCard's precedent) (tested) - │ ├── PriceBreakdown/ # f9 reconciling money breakdown (rows + bold total, all IRR digit-strings via the money util; dev-guard console.errors when rows ≠ total) — C6 + invoice now, f10/f11 refund/BNPL later; ui-phase-6 switched the row amounts to `` too (every row now carries «تومان», not just the total) (tested) - │ ├── EscrowNotice/ # f9 product-mandated escrow trust callout (verbatim fa copy, --bal-info tone, lock icon) — the untouchable inner sentence; C6/confirmation now wrap it in `EscrowExplainer` (tested) - │ ├── EscrowExplainer/ # ui-phase-6 — wraps `EscrowNotice` with an optional «چطور کار می‌کند؟» expander: a 3-step visual (پرداخت ← امانت نزد بالین‌یار ← آزادسازی) grounded in product/payments/escrow-ledger.md + the cancellation/refund implication; checkout + confirmation (tested) - │ ├── PaymentStateCard/ # ui-phase-6 — the one terminal/wait-state card (icon/tone/title/body/actions) replacing the four copy-pasted private `MessageCard`/`StateCard` functions across the card + BNPL checkout/return flows (tested) - │ ├── BnplProviderLogo/ # ui-phase-6 — providerCode → bundled SVG (none licensed yet) falling back to a designed tinted-monogram roundel, replacing the two-letter text-glyph stand-in (`DG`/`SP`/…) in D1's MethodStep (tested) - │ ├── PaymentStatusBadge/ # f9 b10 payment status (pending/succeeded/failed) → StatusChip kind + payment.pstatus_* label (tested) - │ ├── CancellationPolicyDisclosure/ # f10 pre-confirm cancel disclosure: policy-tier label (off cancellation_policy_code) + refund %/fee % + PriceBreakdown refund-vs-fee split (reconciles) + multi-session refundable/locked breakdown + admin-approval explainer + RefundEtaBanner (tested) - │ ├── RefundStatusCard/ # f10 customer refund view: 3-step stepper (submitted→on-its-way→completed) + refunded amount + optional fee-leg split + masked ref + failed=contact-support (no retry); reused on booking detail + refund-status page (tested) - │ ├── RefundEtaBanner/ # f10 per-channel refund ETA — bnpl_revert surfaces the ~7–10 business-day window honestly (never instant), psp_card/manual wording; one branch on refund_channel (tested) - │ ├── BnplPlanCard/ # f11 D2 installment-plan option card (terracotta): term/installments + served monthly amount, a plain پیش‌پرداخت amount row, and مجموع بازپرداخت with the fee delta vs. paying in full spelled out in Toman (ui-phase-6 replaced the percent-only label + `LinearProgress` bar — a static fact must not look like loading); single-select (tested) - │ ├── InstallmentScheduleRow/ # f11 repayment row: down-payment(«امروز»)/installment + Shamsi due date + served amount (every row carries «تومان» since ui-phase-6) + optional provider-reported status chip; reused by D4 schedule + D5 wallet due list (tested) - │ ├── EarningsBalanceHeader/ # f12 nurse net payable balance + 4-bucket breakdown (pending/eligible/paid/clawback off --bal-{warning,info,success,error}); renders a negative net as an explicit "owed back" state (magnitude only, never a bare minus) (tested) - │ ├── EarningsRow/ # f12 one earnings item: three-amount «gross − commission = your payout» breakdown via PriceBreakdown + one of four visually-distinct state chips + state affordance (pending→display-only dispute-window CountdownTimer, eligible→awaiting-batch, paid→paid_at+ref+payout link, clawback_applied→net explanation); deep-links to /nurse/visits/[id] (tested) - │ ├── PayoutHistoryRow/ # f12 one nurse_payouts row: net transferred + payout-status chip (pending/submitted/paid/failed) + period + masked IBAN (last-4, dir=ltr) + transfer ref + read-only failure banner (no nurse retry) (tested) - │ ├── RatingInput/ # f13 1–5 star input/display (custom, on AppIcon "star"; filled=var(--bal-warning), empty=var(--bal-divider)); interactive=radiogroup of radios, readOnly=static role="img"; used by the review form + review/my-review display (tested) - │ ├── ReviewTagSelector/ # f13 multi-select review-tag chip group (selected=MUI palette primary, unselected=outlined); i18n-free (caller passes labelFor(code)); codes stay keyed off the stable vocabulary, never off the wire (tested) - │ ├── VisitNoteCard/ # f13 one read-only nurse visit note: nurse name + Shamsi date + body + done/not-done task-result chips; presentational (caller formats the date); reused by the E2 سوابق tab + the nurse continuity view (tested) - │ ├── PatientHeader/ # f13 patient identity block (avatar + name + relation chip + "age · gender" meta + condition chips) extracted from PatientCard so the E1 card and the E2 record viewer share one header; tolerates null relation / empty conditions; ui-phase-9 added an `InitialsAvatar` slot (tested) - │ ├── ProfileSummary/ # ui-2 the one identity card for chrome: avatar+name+masked phone+role label+optional TrustBadge, vertical (nurse sidebar) or `compact` horizontal chip (admin/partner TopBar); presentational — callers source data from useMe/profiles; replaces the starter UserInfo; ui-phase-9 added `initialsFallback` (renders `InitialsAvatar` instead of MUI's generic silhouette when there's no `avatarUrl`) — the customer account hub's identity header (tested) - │ ├── booking/ # f8 post-payment engagement composites (import from @/components/booking). BookingDetailView (both-roles smart container; ui-phase-5 hero: next-upcoming-session headline off the embedded sessions, a nurse-identity row, a client-only `.ics` add-to-calendar download (ics.ts, no backend seam), an EVV "پرستار در محل است" presence headline while checked in, role-conditioned EVV+gated care; ui-phase-7 added a standalone AddressCard below the hero — a `geo:`/Neshan-web map deep-link when the frozen snapshot carries lat/lng, a quiet nurse-only fallback note otherwise (REQ-051) — and a nurse-only in-visit «در حال ویزیت» banner promoting the check-out action), BookingStatusTimeline (server-truth 7-status timeline over the shared vertical StatusTimeline — the ui-phase-1 swap off StepperHeader), SessionList→SessionCard (per-session schedule/status/EVV CTA; ui-phase-5 aligned its card shell to SurfaceCard; ui-phase-7 made the EVV CTA the full-width hero action + an optional `serviceLabel` line, REQ-052), EvvStatusBanner (advisory in/out-of-range/no-gps), CareInstructionsCard (decrypted clinical read), CheckOutConfirmButton (ui-phase-7 — the shared check-out action + lightweight confirm dialog, used by both SessionCard and BookingDetailView's in-visit banner so "check-out ends the visit and starts the payout clock" always gets one confirm step), BookingMoneySummary (gross/commission/payout display-only); useEvvController (GPS-capture + check-in/out orchestration), format.ts + statusKind.ts + ics.ts helpers (kept internal — not in the barrel). Each composite tested; the BookingDetailView test proves the customer never fires the care query (two-stage-disclosure gate) - │ ├── geography/ # F3 geo composites: CascadingRegionSelect, AddressForm, AddressCard (ui-phase-9 added a pin-quality cue — hasPin/pinSetLabel/pinMissingLabel), and the map-pin picker boundary — `AddressMapPicker` now branches on `NESHAN_WEB_KEY` (`@/config`): real Neshan tiles via `NeshanMap` (Leaflet, dynamically imported `ssr:false`; search box + locate-me + draggable pin + reverse-geocoded preview via `services/geography/neshan.ts`'s direct third-party fetch client) when set, else the original bounded-canvas grid stand-in (kept, not deleted, for dev/CI/jsdom) — `{ latitude, longitude }` in/out is identical either way so `AddressForm` never changed (each tested; `NeshanMap` itself isn't unit-tested — jsdom+Leaflet integration — and is unreachable in tests since `NEXT_PUBLIC_NESHAN_KEY` is unset in CI) - │ ├── messaging/ # f14 tickets composites (import from @/components/messaging), ui-phase-10 messaging-app rebuild. Screens shared by the customer+nurse pages (role decides chrome): TicketInboxScreen (status filter chips + load-more, EmergencyPlaybookRow instead of a permanent banner), TicketThreadScreen (+ TicketConversationPanel, key={ticketId} — owns the one usePostMessage/draft both TicketMessageList and MessageComposer share, so retry/discard and the composer are one pipeline), TicketMessageList (date separators/author-grouped bubbles/centered system events via useThreadScroll — opens at the newest message, "new message" pill), ContactSupportDialog (new-ticket → shows referenceCode), MessageComposer (controlled; pointer-aware Enter semantics, attachment affordance gated behind `TICKETS_ATTACHMENTS_ENABLED`), BookingSupportEntry (page-local glue on f8 booking detail — reuses the cached booking + care query, no refetch; still mounts the full alarm-red EmergencyBanner, untouched). Pure/tested: MessageBubble (mine/theirs, RTL-mirrored, hh:mm-only, failed-send retry-in-place + discard, `role="alert"` on failure), TicketListCard (prominent referenceCode + unread pill + last-message preview + relative time, mock-tolerant when the enrichment fields are absent), EmergencyBanner (nurse post-confirmation tel: playbook only), EmergencyPlaybookRow (the inbox's compact neutral emergency row). Helpers: statusKind.ts, authorLabel.ts, clientMessageId.ts, useThreadScroll (reusable scroll-orchestration hook, exported for phase 11's admin thread) - │ ├── notifications/ # f14 notification composites (import from @/components/notifications), ui-phase-10 pass. NotificationBell (chrome container — subscribes to the polling count so only it re-renders; opens NotificationBellPopover on the nurse desktop shell instead of navigating, everywhere else still navigates) → NotificationBellView (pure, tested, ref-forwarding so the container can anchor the popover), NotificationBellPopover (5-recent preview, fetches on open, exported for phase 11's admin shell once it has a feed), NotificationRow (pure, tested: per-kind tinted icon container, navigable rows get a trailing chevron, non-navigable rows render as a plain non-rippling surface), NotificationCenter (shared page body: unread-first, day-grouped امروز/دیروز/این‌هفته with relative timestamps, mark-read-on-open + mark-all, deep-links via notificationDeepLink). Helper: notificationIcon.ts (+ notificationTint). Admin's bell entry is hidden (no real feed yet, `AdminLayout.tsx`) until phase 11 ships one. - │ ├── settings/ # The app's one appearance-and-language surface (import from @/components/settings): SettingsPanel (language row + the appearance block, tested), ThemeModeSetting (a three-way روشن/تیره/سیستم segmented control, and the ONLY subscriber to useColorScheme() left in the app — an on/off Switch could not express `system`, which is the app's actual default), SignOutRow (always through useLogout()). Mounted in each actor's settings hub — /nurse/more, /admin/system, /partner/more and the customer profile hub — and nowhere else - │ └── auth/ # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard (brand mark → step form → divider → TrustBullets on ONE surface; the facts used to float under the card, and the desktop side-illustration went away with the frame), BrandMark, AuthSplash, RoleGuard (role-aware shell guard, tested — also what makes the locale root role-aware), AuthAccountError (/me-failed recovery), useCountdown, useWebOtp (ui-phase-3 WebOTP autofill seam), TrustBullets - ├── i18n/ - │ ├── routing.ts # defineRouting — locales: ['en', 'fa'], defaultLocale: 'fa' - │ ├── request.ts # getRequestConfig — loads messages/${locale}.json - │ └── navigation.ts # ui-2 createNavigation(routing) — Link/usePathname/useRouter/redirect/getPathname. ALL chrome navigation goes through this: usePathname is locale-stripped (so unprefixed ROUTES.* compare directly) and Link/router add the locale automatically — no manual `/${locale}` prefixing, no middleware redirect hop - ├── layout/ # the one mobile app shell — per-actor tabs over a shared phone-width frame - │ ├── AppFrame.tsx # 'use client' — THE device frame every shell renders inside: a centered `APP_FRAME_MAX_WIDTH` column on a `--bal-frame-canvas` backdrop, `overflowX: hidden` + `minWidth: 0` so an over-wide child clips instead of dragging the app sideways. iteration-2: above `sm` the column FLOATS as a rounded, shadowed card with a gutter all round (edge-to-edge on a phone); header and footer are pinned OVER the single scrolling `
` with `position: absolute` — never `fixed`, which would break out of the centered column — and `
` reserves their exact height as padding. It also publishes `--bal-chrome-top`/`--bal-chrome-bottom` (0px in a chrome-free shell) so any `position: sticky` element in the tree can clear the bars without importing a constant. No shell computes a top offset any more (tested) - │ ├── MobileShell.tsx # 'use client' — the ONE authenticated shell behind all four actor apps: AppFrame + a contextual TopBar (brand lockup on a tab's own path, back chevron + route title on anything deeper) + BottomBar + ErrorBoundary + RouteFadeIn + PageTitleProvider. Actors supply only `tabs` + `headerActions`. No identity affordance in the header — that lives in each actor's «بیشتر»/account hub, one tap away on the nav - │ ├── PrivateLayout.tsx # authenticated wrapper (passthrough today); actor chrome lives in the shells below - │ ├── CustomerLayout.tsx # 'use client' — customer tabs: خانه (+/search) · رزروها · حلقهٔ مراقبت · کیف‌پول · پروفایل (+/addresses, /support, /notifications — the account hub that owns appearance/language) - │ ├── NurseLayout.tsx # 'use client' — nurse tabs: امروز (/nurse) · درخواست‌ها (/nurse/requests, pending-count badge off the already-cached `useNurseRequestInbox`) · حرفهٔ من (/nurse/practice) · مالی (/nurse/finance) · بیشتر (/nurse/more, `useSupportUnreadTotal` badge). درخواست‌ها earned a tab in iteration-2: it is the one nurse screen with a deadline on it (a pending request expires unanswered) and was previously reachable only from a strip on the dashboard. Each group root is a real page; `matchPaths` keeps the historical destination URLs lighting up their group - │ ├── AdminLayout.tsx # 'use client' — admin tabs: نمای کلی · اعتماد · مالی · پشتیبانی · سیستم, each a group root. A group tab hides when `useAdminCapabilities` permits nothing inside it (gating unchanged and still per-console; the server still enforces); سیستم is always present because it carries settings + sign-out - │ ├── PartnerLayout.tsx # 'use client' — partner tabs: مرکز · پرستاران · رزروها · تسویه · بیشتر. The center identity + merchant-of-record indicator moved from the top bar into /partner/more - │ ├── PublicLayout.tsx # unauthenticated shell — the frame and NOTHING else: no top bar, so the login card's own BrandMark is the only mark on screen and the locale/theme toggles that used to sit up here live in settings - │ ├── FocusedLayout.tsx # ui-phase-3 — chrome-free framed shell for can't-tab-away flows (onboarding, and /select-role via its own layout.tsx): a slim logo strip + content, no bottom nav; the route group above it still applies RoleGuard - │ ├── routeTitle.tsx # ui-2 static route→title map (longest-prefix over ROUTES.*, off the `nav` namespace) + `PageTitleProvider`/`usePageTitleOverride` per-page dynamic-title slot + `useRouteTitle` - │ ├── matchActivePath.ts # ui-2 shared longest-prefix, winner-takes-all active-path matcher (tested) — BottomBar runs it over each tab's own path PLUS its `matchPaths` claims, so a nested route still lights up its parent tab, never a sibling - │ ├── config.ts # APP_FRAME_MAX_WIDTH (480 — mirrored by components/config.ts's CONTENT_MAX_WIDTH) + TOP_BAR_HEIGHT + TOP_CHROME_HEIGHT/BOTTOM_NAV_HEIGHT (the space each floating bar occupies, which AppFrame reserves as `
` padding — keep in sync with the bars) + FLOATING_BAR_SX (the ONE definition of the two bars' shared shape, so header and footer cannot drift apart) - │ ├── index.ts - │ └── components/ - │ ├── TopBar.tsx # NOT an AppBar — no elevation or surface of its own: `AppFrame` wraps it in the floating-pill container (`FLOATING_BAR_SX`), so it is the bottom nav mirrored. Content: title | titleNode override, align ('start' breadcrumb-style | 'center') - │ ├── BottomBar.tsx # the app's only navigation surface (tested) — a FLOATING pill bar (inset from the frame edges, `FLOATING_BAR_SX`) rather than an edge-to-edge slab sealing off the bottom; pinned over the scrolling main by AppFrame, which reserves `BOTTOM_NAV_HEIGHT` of padding so nothing is ever hidden under it. ICON-ONLY (iteration-2: the caption was the widest thing in the bar at five tabs and cost a whole line; the label survives as `aria-label`/`title`), each tab a fixed 44px CIRCLE that is simultaneously the target, the hover/press tint and the active fill (`--bal-motion-fast`, so the app-wide reduced-motion gate already covers it), laid out `space-around` so the target keeps one size at any tab count; `LinkToPage.badgeCount` badges and `matchPaths`-aware active matching - │ ├── BrandLockup.tsx # ui-2 compact horizontal logo+wordmark — the TopBar title on a shell's root tabs - │ ├── ActorSwitcher.tsx # ui-2 dual customer+nurse session switcher (renders nothing for a single-role session); nurse «بیشتر» hub + customer profile hub (tested) - │ └── index.tsx - ├── lib/ - │ ├── api/ - │ │ ├── client.ts # clientFetch — throws ApiError on error; use in hooks/client components; silent-refreshes + retries once on 401 - │ │ ├── server.ts # serverFetch — throws ApiError on error; use in RSCs/Server Actions - │ │ ├── types.ts # ApiEnvelope + unwrap(), Paginated, PageParams — shared wire types - │ │ ├── refresh.ts # attemptTokenRefresh — single-flight silent refresh used by clientFetch's 401 branch - │ │ └── errors.ts # ApiError class (status, message, code) - │ ├── auth/ - │ │ ├── token.ts # decodeJwtPayload / isTokenAlive — edge-safe, shared with middleware (no next/headers) - │ │ ├── session.ts # persistAuthTokens / clearAuthTokens — client token-cookie writers (shared by auth hooks + fetch refresh) - │ │ └── server.ts # getServerAuthState — access-token cookie → AuthState for AuthProvider - │ ├── query/ - │ │ ├── queryClient.ts # makeQueryClient factory + getQueryClient() SSR-safe singleton - │ │ └── QueryProvider.tsx # 'use client' — QueryClientProvider + ReactQueryDevtools - │ └── cookies/ # Cookie manager — strict server/client separation - │ ├── constants.ts # COOKIE_NAMES, CookieOptions, AUTH_*_COOKIE_OPTIONS - │ ├── server.ts # getServerCookie, getThemeMode, setServerCookie - │ ├── client.ts # getClientCookie, setClientCookie, deleteClientCookie - │ └── index.ts # Re-exports constants ONLY (never server/client) - ├── services/ # Domain services — no top-level barrel; import directly from the file - │ ├── auth/ # Phone-OTP auth: requestOtp/verifyOtp/refresh/logout/me/selectRole + role router (routing.ts: resolveRoleDestination + ui-phase-3's resolvePostLoginDestination for the validated `?next=` returnUrl) + useSessionRoleSync + useRoleHydration (resolved-vs-pending role state for RoleGuard) - │ ├── patients/ # Care-recipient CRUD (b3 PatientDto + client-augmented relation/conditions), soft-archive; age.ts helper - │ ├── profiles/ # Customer + nurse profile get/upsert + avatar (behind the ProfilesApi seam). ui-phase-8 added `setAcceptingBookings` (`useSetAcceptingBookings`) — the real, previously-unwired `POST nurse_profiles/set_accepting_bookings` go-live switch (mock mirrors the flip; real+mock both invalidate `profileKeys.nurse()`) - │ ├── nurse/ # Nurse payout bank accounts + IBAN(Sheba) util (iban.ts) + ownership-inquiry states - │ ├── geography/ # F3 cached province→city→district reference lookups (Infinity staleTime, shared geographyKeys; reused by addresses, coverage & later search) - │ ├── addresses/ # F3 customer address book CRUD + set-primary (single-primary invariant; invalidate-on-mutation) - │ ├── serviceAreas/ # F3 nurse coverage areas add/remove (areaExists dup-guard; districtId=null = whole city) - │ ├── catalog/ # F4 catalog skeleton + nurse pricing variants (b5). Reference data (categories, category option groups) cached session-long like geography (Infinity staleTime); myVariants invalidated on mutation. useServiceCategories/useCategoryOptionGroups/useMyVariants/useCreateVariant/useUpdateVariant/useSetVariantActive; seam+mock+client; names.ts locale-label helper - │ ├── search/ # F6 family discovery (b7). The **filter object IS the query key** (searchKeys.results + canonicalizeSearchFilters): identical/reverted filters reuse cache with zero network (keepPreviousData avoids flashing). useNurseSearch/useNurseProfile/useDebouncedValue; filterParams.ts = the shared C1↔C2 URL (de)serializer; seam+mock(PRIMARY)+client. Mock supplies name/avatar/distance/profile/reviews that b7's index row + b5/b6 reads don't yet expose (gap filed in for-backend.md). Every returned row is verified-by-invariant — the UI never re-filters - │ ├── verification/ # F5 nurse trust flow (b6). ONE cached status() query drives B3+B6; every mutation invalidates it. useVerificationStatus/useStartVerification/useSubmitIdentity/useRunBankVerification/useUploadVerificationDocument/useSubmitCredentials/useNurseTrustBadge; seam+mock(primary)+client; validation.ts (national-ID checksum); types export ownBadgeState/publicBadgeState/isApproved - │ ├── bookingRequests/ # F7 pre-payment request lifecycle (b8). Money-free create→accept/reject/cancel + role-scoped inbox + single get. useCreateBookingRequest/useBookingRequest(polls until terminal)/useNurseRequestInbox/useCustomerRequests/useAccept/useReject/useCancel; seam+mock(PRIMARY, shared in-memory state machine — customer create ↔ nurse inbox ↔ accept flips C5; lazy expiry sweep)+client. Server-frozen UTC deadlines rendered by CountdownTimer (never recomputed); two-stage disclosure (nurse `get(id,'nurse')` masks address); variantPrice client-augmented (REQ-013). Contract-live but mock-primary because inputs (search/patients/addresses) are mock-primary - │ ├── bookings/ # F8 post-payment engagement (b9) — the SIBLING of bookingRequests, NOT a rename. useBookingDetail/useBookingSessions(select over detail — sessions are embedded)/useBookingList/useTodaySessions/useSessionEvv/useCareInstructions(enabled-gated)/useCheckInVisit/useCheckOutVisit; seam+mock(PRIMARY, seeded confirmed bookings + sessions + care + EVV state machine)+client(1:1 b9)+serverApi(RSC-prefetch seam, real-path). evv/locationProvider.ts = the ILocationProvider GPS seam (real navigator.geolocation vs mock coords by NEXT_PUBLIC_EVV_MOCK_GPS in_range|out_of_range|denied). Money display-only (gross=commission+payout server-side); timeline=server truth; care read gated to assigned nurse; EVV mismatch/denial advisory (never blocks); EVV mutations invalidate detail+session+today+list - │ ├── payment/ # F9 checkout & card capture (b10) + customer invoice read (b11). useCheckoutSummary/useInitiatePayment(caller owns the per-ATTEMPT Idempotency-Key)/useConfirmGatewayReturn/usePaymentOutcome(backoff poll, stops on terminal + bounded attempts)/useInvoice(immutable, long staleTime, 404=not-issued not error)/usePaymentHistory(ui-phase-6, wallet «پرداخت‌ها», REQ-047); invalidations.ts = the one post-capture cache transition (request detail/lists + bookings lists/detail + summary/outcome — never a blanket refetch); seam+mock(PRIMARY — the conversion trigger bridging the f7↔f8 mock stores: capture converts the request, inserts a confirmed booking, issues the b11-shaped invoice)+client (initiate/invoice = real b10/b11 contract; summary = REQ-016 proposed route; outcome = mapped booking_requests/get, REQ-017). Money = served IRR digit-strings; rows reconcile by construction; a 409 on the money path is benign convergence, never a toast. ui-phase-6 added `nurseAvatarUrl`/`nurseVerified` on `CheckoutSummaryDto` + `trackingCode`/`paidAt` on `PaymentOutcomeDto` (REQ-046) and `paymentMethod`/`transactionReference`/`sellerFiscalIdentity` on `InvoiceDto` (REQ-049) — mock-populated, `null` on the real path until served - │ ├── refunds/ # F10 customer cancellation + refund status (b11). resolveCancellationPolicy/cancelBooking/getRefundByBooking/getRefund/getMyRefunds(ui-phase-6, wallet «استردادها», REQ-048). useCancellationPolicyPreview/useCancelBooking/useRefundStatus(polls only while non-terminal)/useMyRefunds; invalidations.ts primes the fresh refund + invalidates booking detail/lists on cancel; seam+mock(PRIMARY — reads the f8 bookings store to resolve tier+per-session refundability, flips the booking cancelled, drives card-immediate/BNPL-processing refunds)+client. Contract is admin-only (REQ-019/020/021 fill the customer cancel command, policy preview, refund-by-booking + decomposition). Money = IRR digit-strings, BigInt; refund %+fee disclosed before confirm; refunds never self-issued - │ ├── bnpl/ # F11 BNPL installment checkout (b12) — the alternate branch off C6. useBnplOptions/useCheckEligibility/useBnplSchedule/useIssueBnplToken/useAcceptBnplSchedule(invalidates booking+checkout+wallet)/useBnplOrder(bounded backoff poll)/useWalletInstallments; invalidations.ts reuses f9 invalidateAfterPaymentSuccess + the wallet key; seam+mock(PRIMARY)+client. Mock = the settle bridge: reuses the f9 conversion (mockInsertConvertedBooking + mockMarkBookingRequestConverted) — a settled BNPL order is a card payment net-of-fee — and seeds a provider-reported Wallet plan (D5). Contract serves only eligibility/initiate/status; options/schedule/wallet-installments/D3-KYC/customer-bookingId are REQ-022/023/024 gaps mocked behind the seam. Money = served IRR digit-strings (the mock computes plan/schedule with BigInt; components only format). D5 is provider-reported status, NOT a Balinyaar ledger; early-pay hands off to the provider. ui-phase-6: no API-shape change, UI-only honesty/polish pass (BnplPlanCard Toman rows + fee delta, PlanStep names the selected plan, BnplProviderLogo, eligibility progress feedback, gateway harness env-gated) - │ ├── payouts/ # F12 nurse earnings & payout history (b13) — read-only, no mutations. useNurseEarningsBalance/useNurseEarnings(state,page)/useNursePayoutHistory(page)/useNursePayoutDetail(id); the state-filter + page are part of the query key (tabs/pages cache separately, keepPreviousData); seam+mock(PRIMARY)+client. b13 serves only GET nurse_payouts/history; the four-bucket earnings summary, per-booking earnings list + money-state, and nurse-readable payout detail (batch context + booking links + failureReason) are REQ-025 gaps mocked behind the seam. EarningsState (pending|eligible|paid|clawback_applied) is a client display model derived server-side; PayoutStatus is the contract's pending|submitted|paid|failed. Money = IRR digit-strings (gross=commission+payout; net=gross−clawback; Σ booking-links=grossEarnings); the net payable balance is SIGNED (may be negative "owed back", never clamped); eligibility/dates/amounts are server truth (never computed client-side); the BNPL provider commission never appears (payment-method-invariant). MOCK_SCENARIO toggles the negative-balance demo - │ ├── reviews/ # F13 moderated reviews (b14). useNurseReviews(infinite, published-only aggregate+list)/useReviewEligibility(bookingId)/useMyReviewForBooking(bookingId)/useCreateReview(invalidates eligibility+myReview, NEVER the public list); seam+mock(PRIMARY)+client. b14 serves submit + GET nurses/{id}/reviews (both mapped 1:1); review-eligibility + my-review-for-booking are REQ-026 gaps and moderation is admin-only (f15), so the mock reads a booking from the shared f8 bookings store (mockGetBookingForReview) to gate on a completed booking, tracks the submission for the persistent "under review" state, seeds a per-nurse published list, and recomputes the aggregate from published (never a stored sum). A pending_moderation review is NEVER injected into a public list/aggregate. Dev-only __mockPublishSubmittedReview stands in for the f15 admin queue. Tag chip labels are i18n keys off REVIEW_TAG_CODES, never off the wire - │ ├── patientRecords/ # F13 continuity-of-care (b14) — patient-scoped, NOT booking-scoped. usePatientCareRecord(family record)/useRecordAccess(gates before any clinical fetch)/usePatientHistory(paged visit-note history)/useUpdateCareRecord(CUSTOMER-only edit → setQueryData)/useCreateVisitNote(NURSE-only append → invalidates history). seam+mock(PRIMARY)+client. The nurse-authored visit-note history/append (getPatientHistory/createVisitNote) are REAL b14 (GET/POST patients/{id}/care_records, mapped 1:1; the append folds the ticked task checklist into the note body); the family-owned editable record (medications/routine/tasks) + the access check have NO backend (REQ-027) and are mocked. Nurse is APPEND-ONLY (never wires useUpdateCareRecord). Access-denied (canView=false / 403) is a first-class non-leaking state; MOCK_FOREIGN_PATIENT_ID=8888 exercises it. Clinical text is never logged/localStorage/query-string - │ ├── tickets/ # F14 tickets — the ONLY sanctioned post-booking channel (b15). useMyTickets/useTicket+useTicketThread(select over detail; both poll every TICKET_THREAD_REFETCH_INTERVAL while a thread is mounted, ui-phase-10)/useOpenTicket(invalidates lists)/usePostMessage(OPTIMISTIC + retry-in-place, ui-phase-10: onMutate is idempotent on clientMessageId — a retry flips an existing `failed` bubble back to `sending` instead of appending a duplicate; onError no longer rolls back, it flips the bubble to `sendStatus:'failed'` in place so the text + a retry/discard affordance survive; onSettled also invalidates the unread-total badge)/useDiscardFailedMessage(non-mutation cache removal for the composer's discard-and-retype)/useSupportUnreadTotal(chrome badge, §3.1 — mock sums unread, real returns null until REQ-059). seam+mock(PRIMARY)+client(maps b15 1:1). **is_internal NEVER modelled in the user-app types** — both mappers DROP any internal message (server-strip mimic); no internal affordance anywhere. Mock stores an internal note it never returns (no-leak demo), seeds a booking-linked coordination ticket (idempotent for coordination+bookingId → "jump to existing"), tracks the last viewer so an optimistic message reconciles as mine, MOCK_SEND_FAIL_SENTINEL='/fail' drives the failure→retry path. `unreadCount`/`lastMessageAt` are REAL (REQ-028, delivered); `lastMessagePreview`/`lastAuthorRole` are the newer REQ-059 gap → mock-only, card degrades gracefully without them - │ ├── notifications/ # F14 in-app notification center (b1) — polled, no push. useNotifications(unread-first, growing limit, now takes an optional `{enabled}` so the ui-phase-10 bell popover can fetch only on open)/useUnreadCount(the POLLING bell: refetchInterval 60s + staleTime 45s + refetchOnFocus, auth-gated — count only, list never polled)/useMarkNotificationRead+useMarkAllRead(OPTIMISTIC setQueryData flips isRead + decrements/zeros the cached count, rollback on error, invalidate on settle). seam+mock(PRIMARY)+client(maps b1 1:1). data_json is a TYPED contract: parseNotificationData(type,dataJson)→discriminated NotificationData union (snake/camel tolerant, degrades to {kind:'none'} on malformed/unknown/missing id — never trusts a blob); notificationDeepLink(n,role) centralises the role-aware route (null when nothing to open). Mock seeds every deep-link class + __mockPushNotification for the bell-increment demo - │ ├── admin/ # F15 backoffice-owned data (b1 + b15): config, holidays, audit, support-alerts, RBAC, and (ui-phase-11) a user directory. usePlatformConfigs/useUpdatePlatformConfig/useConfigChangeHistory/useHolidays/useUpsertHoliday/useAuditLogs/useSupportAlerts/useAssignSupportAlert/useResolveSupportAlert/useAdminRoles/useGrantRole/useRevokeRole/useUserSearch/useUserLookup (ui-phase-11 — `searchUsers`/`lookupUsers` on `AdminApi`, backing `UserPicker`/`NursePicker` + `AuditLogRow`'s actor-name resolve); seam+mock(PRIMARY)+client. Filters+page in each key (worklist filters cache separately). Mock-primary: config updatedAt/updatedBy + rich audit filters + the whole RBAC surface + the user directory are gaps (REQ-029/030/031/061). support_alerts are internal-only — never rendered outside an admin route - │ ├── partnerCenter/ # F15 partner centers (b15): admin management + the center-scoped portal. usePartnerCenters/usePartnerCenter/useCenterSponsoredNurses/useCreate/useUpdate/useVerify/useSetActive/useAssignNurse (admin) + useMyPartnerCenter/useMySponsoredNurses/useMySponsoredBookings/useMySettlement (portal); seam+mock(PRIMARY)+client. settlement_iban masked last-4 (write-then-masked); merchant-of-record gates the settlement view; VAT on the commission line only (config vat_rate); deriveCenterState(isActive,verifiedAt). Mock-primary: portal split reads + activate/suspend + invoice total are gaps (REQ-032/033) - │ │ # Admin-endpoint ADDITIONS to existing domains (the staff lens — NOT new domains): - │ │ # verification → useVerificationQueue/useVerificationCase/useVerificationDocumentUrl(on-demand signed URL)/useDecideStep/useApproveVerification/useRejectVerification (b6; REQ-034) - │ │ # refunds → useRefundPreview/useInitiateRefund/useApproveRefund/useRejectRefund (b11, ticket-linked; REQ-035) - │ │ # payouts → usePayoutBatches/usePayoutBatchDetail/usePreviewPayoutBatch/useRunPayoutBatch(idempotency-keyed)/useRetryPayout/useRecordTransferReference (b13; REQ-036) - │ │ # reviews → useModerationQueue/useModerateReview (b14; REQ-037) - │ │ # tickets → useAdminTickets/useAdminTicket/useAdminTicketThread/usePostAdminMessage (b15; the ADMIN ticket types carry isInternal — the user-app types deliberately do NOT) - │ └── {domain}/ - │ ├── types.ts # Request/response types + the domain's Api interface (the seam) - │ ├── keys.ts # React Query key factory (hierarchical) - │ ├── constants.ts # Mock toggle + staleTime (when the domain has a mock) - │ ├── apis/ - │ │ ├── clientApi.ts # Real impl wrapping clientFetch (unwraps ApiEnvelope via unwrap()) - │ │ ├── mockApi.ts # In-memory impl behind the same interface (until the endpoint lands) - │ │ ├── serverApi.ts # serverFetch calls (only when an RSC needs it) - │ │ └── index.ts # Selects real vs mock by config — the seam hooks import - │ └── hooks/ - │ └── use{Action}.ts # One hook per file — useQuery (deliberate staleTime) or useMutation (invalidates) - ├── context/ # React context providers - │ └── auth/ # AuthContext — AuthProvider (server-seeded) + reducer + useAuth - ├── theme/ - │ ├── ThemeProvider.tsx # MuiThemeProvider wrapper (RTL cache) + ColorSchemeCookieSync - │ ├── colors.ts # BRAND, LIGHT_PALETTE, DARK_PALETTE (incl. success/error/warning/info) - │ ├── direction.ts # getDirection(locale) → 'ltr' | 'rtl' - │ ├── theme.ts # APP_THEME_LTR / APP_THEME_RTL (static, created once) — the `components` brand pass + teal-tinted `shadows` array + responsiveFontSizes() - │ ├── tokens.css # CSS custom properties — [data-mui-color-scheme] selectors + the dark @media fallback (no-flash boot, no script — see "Theme System" below) - │ ├── typography.ts # TYPOGRAPHY_LTR (Space Grotesk) / TYPOGRAPHY_RTL (Mikhak) — shared size scale, 500/700 weight system - │ └── index.ts # Public re-exports (ThemeProvider, getDirection, APP_THEME_*) - ├── constants/ # App-wide constants (routes.ts w/ actor paths, roles.ts, headers.ts, policy.ts — ui-phase-12's single-sourced trust-copy numbers pending REQ-065) - ├── hooks/ # incl. auth.ts → useIsAuthenticated / useActorRole (role-aware chrome); ui-phase-11 added `useAdminListState.ts` — URL-synced worklist state (`useSearchParams`-based; callers need a `` boundary) mirroring **applied** filters+page into the URL (draft-vs-applied; `apply`/`applyFilters`/`clear`/`goToPage`) + `useAdminBackToList` (a real `router.back()` with a list-route fallback), adopted by every admin/partner queue page - ├── utils/ # incl. money.ts (IRR/Toman, integer-safe) + date.ts (Shamsi display) + number.ts (localeTag/formatNumber/formatRelativeTime/formatClock — the one home for locale-ternary formatting) + toEnglishDigits + toCsv.ts (ui-phase-11 — dependency-free CSV serializer, CRLF + comma/quote escaping; backs the partner settlement CSV export, UTF-8-BOM-prefixed for Excel) - └── config.ts + │ ├── layout.tsx THE ROOT LAYOUT — , fonts, setRequestLocale, + │ │ providers, generateMetadata + metadataBase + │ ├── error.tsx · not-found.tsx · [...rest]/page.tsx + │ ├── (private-routes)/ layout mounts useSessionRoleSync + │ │ ├── _chrome/ shared content skeleton (private, not a route) + │ │ ├── select-role/ first-use role picker, in FocusedLayout + │ │ ├── (customer)/ the family app — no URL segment + │ │ ├── (customer-focused)/ chrome-free, same URL space (onboarding) + │ │ ├── nurse/ the nurse app + │ │ ├── admin/ the backoffice (+ _hub/ shared group-root body) + │ │ └── partner/ the partner-centre portal — a SEPARATE authz scope + │ └── (public-routes)/ login · terms · privacy · welcome + ├── components/ common/ primitives + one folder per domain composite family + ├── constants/ routes.ts · roles.ts · headers.ts · policy.ts + ├── context/auth/ AuthContext (provider + reducer + useAuth) + ├── hooks/ auth.ts · capabilities.ts · layout.ts · useAdminListState.ts + ├── i18n/ routing.ts · request.ts · navigation.ts + ├── layout/ AppFrame · MobileShell · the 4 actor layouts · chrome · config.ts + ├── lib/ api/ · auth/ · cookies/ · query/ · toast/ + ├── services/ 22 domain services — the data layer + ├── theme/ colors.ts · tokens.css · typography.ts · theme.ts · ThemeProvider.tsx + ├── utils/ money · date · number · text · toCsv · navigation + └── config.ts API_URL · SITE_URL · NESHAN_WEB_KEY · IS_DEBUG ``` ---- +Route groups (parenthesised) add no URL segment; `_`-prefixed folders are private, not routes. Each private +group's layout is `'use client'` and wraps `RoleGuard` → that actor's layout. -## Server / Client Component Boundaries - -**There is NO `src/app/layout.tsx`.** `src/app/[locale]/layout.tsx` is the application's **root layout** — it renders `` and ``. This is intentional and load-bearing (see below); do not re-introduce a layout above the `[locale]` segment. - -**Root / locale layout** (`src/app/[locale]/layout.tsx`) is an RSC that owns the document shell, all i18n, and theme context. It: -- Sources the locale from the **URL param** (`params.locale`), validated against `routing.locales` (falls back to `defaultLocale`). No header reads. -- Renders `` (`dir` from `getDirection(locale)`) plus `data-mui-color-scheme` from `getThemeMode()`. -- Loads the Mikhak font and attaches its CSS-variable class to `` **only for `fa`** (see Fonts). -- Calls `setRequestLocale(locale)` so server components deeper in the tree can call `getLocale()` / `getTranslations()` reliably. -- Calls `getMessages({ locale })` with the locale passed **explicitly** so `getRequestConfig` receives it via `Promise.resolve(locale)` (not through the React.cache read), avoiding any cache-ordering race. -- Wraps children with `NextIntlClientProvider`, `AuthProvider` (seeded with server-read auth state), and `ThemeProvider`. -- Exports `generateStaticParams` so Next.js can enumerate locale routes at build time. - -**WHY `` MUST live in `[locale]/layout.tsx` and not a layout above it**: a layout above the `[locale]` segment 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 (the segment doesn't change). Its `lang`/`dir`/messages 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 where `` reliably tracks the active locale. - -**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** import from `next/headers`, `next-intl/server`, or `@/lib/cookies/server` in a client component. The build will fail. +**Every screen is a thin RSC `page.tsx` (exporting `generateMetadata`) plus a co-located `'use client'` +`Screen.tsx`.** `page.tsx` never renders `` and never touches `document.title`. --- -## Per-page metadata (the client-page pattern) +## Where to read more -The root layout (`src/app/[locale]/layout.tsx`) exports a locale-aware `generateMetadata` that sets a -title template — `'%s | بالین‌یار'` (fa) / `'%s | Balinyaar'` (en) — plus a default title and description. -Any route that wants its own tab title supplies the `%s`: make `page.tsx` a thin RSC (no `'use client'`) -that exports `generateMetadata` (or a static `metadata` when the title needs no translation lookup) and -renders a co-located `'use client'` body component holding all the page's logic/JSX, named -`<PageName>Screen.tsx` (e.g. `HomeScreen.tsx`, `SearchScreen.tsx`) in the same folder — so its existing -relative imports keep working unchanged. The screen's returned title composes automatically into the -root template; `page.tsx` itself never renders `<title>` or touches `document.title`. Only the 7 landing -pages (customer home, `/login`, `/search`, `/bookings`, `/nurse`, `/admin`, `/partner`) have adopted this -so far — the rest is deferred to the area phases (3–11). +Open **one** of these for the area you are touching. -**`metadataBase` (ui-phase-13):** the root layout's `generateMetadata` sets `metadataBase: new URL(SITE_URL)` -(`SITE_URL` — `src/config.ts`, sourced from `NEXT_PUBLIC_SITE_URL`, never hard-coded) so every child -page's relative OG/canonical URLs resolve to an absolute one. The public `/welcome` landing is the first -consumer: its `generateMetadata` sets `alternates.canonical` + `openGraph`, and a co-located -`opengraph-image.tsx` (`next/og`'s `ImageResponse`) supplies `og:image`. - -```tsx -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 />; -} -``` - ---- - -## i18n (next-intl v4) - -**Adding translations:** -1. Add the key to `messages/en.json` AND `messages/fa.json`. Both files must always be in sync. -2. Top-level keys are namespaces: `"nav"`, `"common"`, etc. - -**Using translations in client components:** -```tsx -import { useTranslations } from 'next-intl'; - -function MyComponent() { - const t = useTranslations('nav'); // namespace - return <span>{t('home')}</span>; // key -} -``` - -**Using translations in Server Components:** -```tsx -import { getTranslations } from 'next-intl/server'; - -async function MyServerComponent() { - const t = await getTranslations('nav'); - return <span>{t('home')}</span>; -} -``` - -**Established namespaces and where they're used:** -- `'nav'` — the actor shells (`CustomerLayout`/`NurseLayout`/`AdminLayout`) build their nav from here -- `'common'` — `DarkModeButton.tsx` (dark/light labels), shared words (loading, retry, currency_toman, …) -- `'shell'` — actor-shell titles + the not-yet-built placeholder body -- `'patients'` — the E1 patient list/CRUD (list, card, add/edit dialog, archive) -- `'onboarding'` — the A3→A4 wizard (ui-phase-3 added `welcome_*` for the pre-wizard welcome moment) + the shared enum labels (relation/condition/gender codes → labels) -- `'home'` — the A5 family home (greeting + avatar, search bar, category grid, record/profile nudges) -- `'profile'` — the customer profile + emergency contact -- `'nurseProfile'` — the nurse B7 profile bootstrap (photo/bio/years + unverified placeholder); ui-phase-8 added editable education level/field + specialties (`education_*`, `specializations_label`), the public-profile preview (`preview_*`), and the avatar/save error copy -- `'activation'` — ui-phase-8 — the shared `ActivationChecklist` row labels (`row_identity`/`row_profile`/`row_services`/`row_coverage`/`row_bank` + the "getting paid, not search-visibility" hint) and the collapsed «فعال در جستجو» state; consumed by `ActivationChecklist` wherever it's mounted (services page, dashboard slot) -- `'bank'` — the nurse payout bank settings (IBAN form + the three ownership states); ui-phase-8 added the accounts-section copy (`add_another`, `load_error`) and an explicit pending-inquiry duration hint -- `'geo'` — the shared cascading province→city→district dropdowns (`CascadingRegionSelect`: level labels, "whole city", cascade hints) -- `'address'` — the customer address book + add/edit form (title/street, map-pin helper, set-primary, empty/delete states) + the profile-hub link -- `'coverage'` — the nurse coverage-area editor (chips, duplicate + "won't appear in search" warnings); ui-phase-8 dropped the now-unused `scope_*`/`district_required` keys once the separate scope toggle was removed, added `remove_error` -- `'catalog'` — **shared** catalog vocabulary: the five `price_unit` labels + count nouns + the estimated-total label (read by `PriceDisplay`; f6 reuses it customer-side) -- `'services'` — the f4 nurse Services & prices surface (offerings list, the variant builder steps/fields/validation, the duplicate-listing warning, deactivate confirm); ui-phase-8 added the live-preview (`preview_heading`/`preview_untitled`) and 409-recovery (`duplicate_edit_existing`) copy, replaced `publish_*` with the state-driven go-live copy (`publish_start_accepting`/`publish_pause_accepting`/`publish_live_*`/`publish_unmet_intro` — the old no-op `publish_done`/`publish_cta` are gone) -- `'search'` — the f6 discovery flow (C1/C2/C3): filter section labels, the same-gender facet + hint, sort/count (ICU plural), all four result states + "relax filters" suggestions, card labels (rating/distance/from-price), profile badges (تاییدشده/نظام پرستاری)/attribute chips/specialty codes/services/latest review, and the "درخواست رزرو" CTA -- `'booking'` — the f7 booking-request flow (C4 form fields/validation, C5 tracker steps + dual-countdown + terminal-state copy, the nurse inbox + detail, gender labels, per-status labels, summary-card captions) **and the f8 post-payment engagement** (booking-status timeline labels `bstatus_*`, session-status labels `sstatus_*`, the EVV banner variants `evv_banner_{in_range,out_of_range,no_gps}` + check-in/out CTAs + GPS-acquiring copy, the care-instructions section labels `care_*` + the customer "visible to your nurse only" copy, the money summary `money_*`, the dispute-window note, the bookings list `list_*`); consumed by the C4/C5 pages, the nurse requests pages, the f8 booking-detail/EVV pages, and the shared `BookingRequestSummaryCard` + `booking/` composites -- `'verification'` — the f5 nurse trust flow: B3/B4/B5/B6 copy, per-step labels + status labels (keyed off code, never derived), the DocumentUpload state chrome, TrustBadge labels, the honesty-sensitive manual-vs-auto copy, shared-SIM/mismatch messages; ui-phase-8 replaced `journey_identity`/`journey_credentials`/`journey_review` with the journey-group labels (`group_identity`/`group_credentials`/`group_bank`/`group_review`, shared by `VerificationJourneyHeader`), added the payoff panel copy (`payoff_*`), the B4 capture hints (`capture_hint_card`/`capture_hint_selfie`), the B5 hydration copy (`ino_number_submitted`/`ino_number_change`/`credentials_needs_document`), and the B6 timeline labels (`review_timeline_*`) -- `'payment'` — the f9 checkout & invoice surface: C6 labels (breakdown rows هزینه خدمت/کارمزد بالین‌یار/مالیات/مبلغ کل, the **verbatim escrow copy** `escrow_notice`, «ادامه پرداخت ←», the BNPL seam), the card-flow states (initiating/redirecting/pending/failed/expired/already-paid), the confirmation + invoice screens (VAT-on-commission line, مودیان `moadian_*` states), `pstatus_*` transaction-status labels, and the dev mock-gateway harness copy; consumed by the checkout pages, the invoice page, `EscrowNotice`, and `PaymentStatusBadge` -- `'refunds'` — the f10 customer cancellation + refund-status surface: policy-tier labels keyed off `cancellation_policy_code` (`policy_*`), the lead-time + refund %/fee % disclosure, the refund-vs-fee breakdown rows, the multi-session refundable/locked reasons (`reason_*`), the admin-approval explainer, the three refund-status step + chip labels (`step_*`/`rstatus_*`), the per-channel ETA copy (`eta_*` — `bnpl_revert` 7–10-business-day window / `psp_card` / `manual`), and the failed/contact-support copy; consumed by the cancel + refund-status pages and `CancellationPolicyDisclosure`/`RefundStatusCard`/`RefundEtaBanner` -- `'bnpl'` — the f11 BNPL installment checkout (D1–D5): the ownership-truth copy (`ownership_note`/`contract_note`/`provider_owned_note`/`paid_via_installments` — the agreement is customer↔provider, provider-financed, Balinyaar paid in full), provider names/taglines keyed off `provider_{code}`, the method/plan/eligibility/schedule labels, ICU-`number` plan params (`plan_term_months`/`plan_installments`/`plan_fee`/`down_payment_percent`/`installment_n` — Persian digits on `fa`), the declined/error copy + card fall-back, the D5 wallet outstanding-balance/due-list/`status_*` labels, and the handoff/settle states; consumed by the D1–D4 wizard + gateway/return pages, `WalletInstallments`, the reused confirmation, and `BnplPlanCard`/`InstallmentScheduleRow` -- `'payouts'` — the f12 nurse earnings & payout-history surface: the balance header (`balance_net_*`/`balance_owed_*` — the negative "owed back" state + hint) + four buckets (`bucket_*`), the cadence/dispute-window explainer (`explainer_*` — weekly batches, EVV+72h gate, method-invariant), the state tabs + earnings-state chip labels (`tab_*`/`estate_*` for pending/eligible/paid/clawback_applied), the nurse-framed three-amount breakdown (`amount_gross`/`amount_commission`/`amount_your_payout`) + clawback net explanation (`clawback_*`), the per-state affordances (`pending_affordance`/`dispute_window_*`/`eligible_affordance`/`paid_on`), the payout-status labels (`pstatus_*` for pending/submitted/paid/failed) + batch-status labels (`bstatus_*`), the read-only failure banner (`failure_*`), and the detail money decomposition + booking-links copy (`detail_*`/`gross_earnings_label`/`net_amount_label`); consumed by the `/nurse/earnings` pages and `EarningsBalanceHeader`/`EarningsRow`/`PayoutHistoryRow` -- `'reviews'` — the f13 leave-a-review flow + the C3 reviews tab: the form labels (`title`/`rating_label`/`body_label`/`tags_label`/`submit`), the review-tag labels keyed off the code (`tag_{punctual,professional,clean,kind,communicative}` — never off the wire), the not-eligible reasons (`reason_*`), the moderation-status labels (`status_pending_moderation`/`status_published`/`status_hidden`/`status_rejected`), the persistent "under review" + my-review copy, the booking-detail CTA (`cta_leave`/`cta_under_review`/`cta_view_review`), the aggregate count (`count` ICU plural), the masked author fallback (`author_masked`), and the list empty/error/load-more; consumed by the review page, the C3 `ReviewsPanel`, and the `LeaveReviewCta` -- `'records'` — the f13 E2 care-record viewer + the nurse visit-note panel: the ownership banner, the four tab labels (`tab_{medications,routine,history,tasks}`), the access-denied + not-found cards, the editable-record field labels (`med_*`/`routine_*`/`task_*`) + empty states, the paged-history controls (`prev`/`next`/`page_of`) + visit-note author fallback, and the nurse composer copy (`notes_title`/`tasks_checklist_title`/`note_*`/`continuity_title`); shared enum labels (relation/gender/condition) are REUSED from `onboarding`/`patients`, never re-keyed; consumed by the E2 record page + `NurseVisitNotesPanel` + `VisitNoteCard` -- `'tickets'` — the f14 messaging surface (tickets are the only post-booking channel): the inbox (`title`/`contact_support`/`empty_*`/`error_body`/`filter_all`/`load_more`), the category + status labels keyed off the code (`category_{support,coordination,refund,emergency}`/`status_{open,closed}`), the linked-entity hints (`linked_booking`/`linked_refund` with `{id}`), `ref_code_label`, the new-ticket dialog (`new_ticket_title`/`category_label`/`subject_label`/`message_label`/`submit`/`created_*`/`view_thread`), the thread (`back_to_tickets`/`thread_*`/`closed_notice`/`day_today`/`day_yesterday`/`new_message_pill`), the composer (`sending`/`send`/`send_failed`/`composer_placeholder`/`discard_failed`/`attach_photo` — the last gated off by `TICKETS_ATTACHMENTS_ENABLED`), the author-role labels (`author_{customer,nurse,support,system}` — `admin`→support), and both emergency surfaces: the nurse post-confirmation **playbook** (`emergency_title`/`emergency_body`/`emergency_call {name}`/`emergency_call_generic`/`emergency_open_ticket`) + `open_from_booking`, and the ui-phase-10 inbox **compact row** (`emergency_row_title`/`emergency_row_body` — rewritten to never instruct calling a number the inbox can't show); consumed by the ticket screens, `MessageBubble`/`TicketListCard`/`EmergencyBanner`/`EmergencyPlaybookRow`/`ContactSupportDialog`/`MessageComposer`/`BookingSupportEntry` -- `'notifications'` — the f14 notification center + bell: `title`, `empty_*`, `error_body`, `retry`, `mark_all_read`, `load_more`, the day-group headers (`group_today`/`group_yesterday`/`group_this_week`, ui-phase-10), `view_all` (the bell popover's link to the full center), and the polled-bell aria (`bell_aria` with `{count, number}`); the row `title`/`body` are **server-rendered** copy, not keys. Consumed by `NotificationCenter` + `NotificationBell` + `NotificationBellPopover` -- `'auth'` — the phone-OTP login flow, role router, RoleGuard (loading/`account_error_*`/`guard_denied`), and SelectRole screen (`common.brand`/`brand_tagline` for the wordmark); ui-phase-3 added the login-hero `trust_*` bullets, the consent line (`consent_line`, `t.rich` with `<terms>`/`<privacy>` tags), and select-role's `role_add_later_note` -- `'legal'` — ui-phase-3's `/terms`/`/privacy` static pages: `terms_title`/`privacy_title`, `draft_banner` (the human/legal-review flag shown on-page), `terms_intro`/`privacy_intro`, and `terms_sections`/`privacy_sections` (arrays of `{title, body}` read via `t.raw`, not flat keys — the one namespace with structured JSON values). Consumed only by the two legal pages -- `'admin'` — the f15 backoffice consoles: verification queue/case, refund panel, payout dashboard/detail, review moderation, config editor + change-history, holiday manager, support-alert board, audit viewer, admin ticket queue/thread, RBAC grid, and admin-side partner management. Includes the **Persian legal terms** (پروانه تأسیس / مسئول فنی / نماد اعتماد الکترونیکی) and the enum-label prefixes keyed off the stable code (`step_*`/`agg_*`/`atype_*`/`astatus_*`/`sev_*`/`htype_*`/`dtype_*`/`batch_status_*`/`pstatus_*`/`channel_*`/`rstatus_*`/`mstatus_*`/`center_state_*`/`role_*`/`tcat_*`/`tstatus_*`). Consumed by the `/admin/*` screens + the `@/components/admin` composites -- `'partner'` — the f15 partner-center portal (a separate authz scope): center home/onboarding-state, sponsored nurses/bookings, and the merchant-of-record settlement/invoice view (سامانه مودیان, commission/VAT decomposition). Consumed by the `/partner/*` screens + `PartnerSettlementRow` -- `'welcome'` — the ui-phase-13 public landing: hero subtitle/CTA, the five static category-grid labels (`category_*`, distinct from the live `catalog`-namespace category names — this grid is marketing copy, not the catalog API), the how-it-works steps, the trust/verification explainer (`trust_*`, a static counterpart to `VerificationPanel`'s live-badge version), the nurse-recruitment CTA, and the footer (`footer_*`, incl. a plain `{year, number}`-formatted copyright line). Consumed only by `WelcomeScreen.tsx` - -**Namespace conventions for the phases to come** (seed each when its feature lands, in both locale -files): none — **MVP namespaces complete** (f15 seeded `admin` + `partner`; ui-phase-13 seeded `welcome` -post-MVP). Keep top-level keys as namespaces and both files in sync. - -**Never hard-code UI strings in English.** Any user-visible text must have a translation key in both locale files. - -**Persian orthography is a checked-in style guide, not tribal knowledge.** `client/messages/STYLE.md` -(ui-phase-12) is the binding decision record for brand spelling (ZWNJ: «بالین‌یار»), تأیید's hamza form, -the one `جستجو` spelling, ZWNJ rules, the domain glossary, the shell-naming system, the -verification-pipeline-vs-KYC-step naming split, and the digits/policy-number-interpolation rules. -`npm run lint:copy` (`client/scripts/check-copy.mjs`, part of `npm run check`) greps `fa.json` for the -banned variants on every run — a regression fails the gate immediately, it doesn't need re-discovering. - ---- - -## Cookie Manager - -The cookie manager in `src/lib/cookies/` is split into three files to prevent cross-environment bundling: - -| File | Use from | Purpose | -|------|----------|---------| -| `constants.ts` | anywhere | `COOKIE_NAMES`, `CookieOptions`, `COLOR_SCHEME_COOKIE_OPTIONS` | -| `server.ts` | Server Components, Server Actions, Route Handlers only | `getServerCookie`, `getThemeMode`, `setServerCookie` | -| `client.ts` | client components / `useEffect` only | `getClientCookie`, `setClientCookie`, `deleteClientCookie` | -| `index.ts` | anywhere | Re-exports `constants.ts` only — safe barrel | - -**Rules:** -- Import constants via the barrel: `import { COOKIE_NAMES } from '@/lib/cookies'` -- Import server utils directly: `import { getThemeMode } from '@/lib/cookies/server'` -- Import client utils directly: `import { setClientCookie } from '@/lib/cookies/client'` -- Never import `server.ts` in a client component; never import `client.ts` in an RSC. -- `COOKIE_NAMES.COLOR_SCHEME = 'color-scheme'` — the single source of truth for the theme cookie name. Do not redeclare it anywhere. - ---- - -## Constants - -**Rule: every magic string or configurable value must be a named constant — never inline.** - -A value is "magic" if its meaning isn't obvious from the literal alone: cookie names, event names, localStorage keys, route paths, query-param names, numeric timeouts, API endpoint slugs. - -Where to define: -- **Cookie names / options**: `src/lib/cookies/constants.ts` -- **Feature-scope constants**: co-locate in a `constants.ts` next to that feature's files -- **App-wide constants** (used across multiple features): `src/constants/` — one file per concern (`routes.ts`, `events.ts`, etc.). `constants/policy.ts` (ui-phase-12) is this 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) — real server config with no public/authenticated read yet (REQ-065), single-sourced here and fed into message keys as ICU params (`{hours}`, `{minDays}`/`{maxDays}`) instead of being baked into the string. - -Rules: -1. Import the constant; never copy-paste the string value. -2. When renaming, update the constant definition — the rest of the codebase follows automatically. - ---- - -## Forms - -**Rule: 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 — it 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, which meant every keystroke re-rendered the whole screen — -including 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. - -### 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`/`useController` at a call site. -3. Put the rule on the field it governs (`rules={{ validate: … }}`), returning the **translated - message**. Cross-field rules read the second `validate` 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. - -### Wrappers - -| Wrapper | For | +| Working on… | Read | | --- | --- | -| `RhfTextField` | any `TextField`, incl. `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. | - -### 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`.** Two cases exist - and both are commented at the call site: the variant builder's display-name (stored = the override - only, blank ⇒ 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). - -### 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. Applies to the nurse profile, the verification -identity + credentials screens, and the variant builder. 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. - ---- - -## Theme System - -### How it works (end-to-end, no-flash — CSS only, no boot script) - -The no-flash mechanism is **pure CSS**, matching how every other color decision in this -app is made — no inline `<script>`, no `Storage.prototype` patching. Two visitor cases: - -**Returning visitor (cookie present):** -1. `getThemeMode()` (`lib/cookies/server.ts`) reads the `'color-scheme'` cookie → returns - `{ colorScheme: 'light'|'dark', defaultMode: colorScheme }`. -2. 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 yet):** -1. `getThemeMode()` returns `{ colorScheme: undefined, defaultMode: 'system' }`. -2. Root layout renders `<html>` **without** the `data-mui-color-scheme` attribute at all - (`data-mui-color-scheme={undefined}` — React omits it). -3. `tokens.css` has a `@media (prefers-color-scheme: dark)` block scoped to - `:root:not([data-mui-color-scheme])` — it only applies 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 CSS values already match what was - painted, so there is nothing to visibly flip. -5. `ColorSchemeCookieSync` in `ThemeProvider.tsx` writes the cookie via - `useColorScheme().colorScheme` on mount, so the next visit is a "returning visitor". - -**Trade-off, by design:** this covers the dominant visual surface — every `--bal-*` token -(page/paper background, text, dividers, all brand colors) — because that's what -`tokens.css`'s media-query fallback drives. MUI's own generated `--mui-palette-*` -variables (consumed by a bare `color="primary"` fill, e.g. a contained Button, or the -default `MuiTabs` indicator) do **not** get the same free fallback — MUI's -`colorSchemeSelector` supports either 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 (self-corrects same frame; -`disableTransitionOnChange` means it snaps, never animates). 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. - -### Critical MUI v9 rules - -**`colorSchemeSelector` must be the explicit attribute name:** -```ts -// theme.ts -cssVariables: { - colorSchemeSelector: 'data-mui-color-scheme', // CORRECT - // colorSchemeSelector: 'data', // WRONG — produces boolean data-dark/data-light -}, -``` -The shorthand `'data'` in MUI v9 generates `[data-%s]` → `data-dark=""` / `data-light=""` (boolean attributes). Our `tokens.css` uses `[data-mui-color-scheme="dark"]` which never matches boolean attributes. Always use the explicit attribute name. - -**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`. This prop is a no-op in browsers. The `Storage.prototype` patch in `ColorSchemeScript` is the correct intercept. - -**Never use MUI's `InitColorSchemeScript`:** -It reads from localStorage, which diverges from our cookie (especially in 'system' mode), -and it's a script — this app's no-flash boot is CSS-only (see "How it works" above). Don't -add any pre-paint script for color scheme; extend the `tokens.css` media-query fallback -instead if a new token needs the same first-visit treatment. - -**MUI v9 localStorage key defaults (different 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 all of these via `colorSchemeSelector: 'data-mui-color-scheme'` in the theme and the Storage.prototype patch. - -### Color tokens - -All theme-aware colors live in `src/theme/tokens.css` under `[data-mui-color-scheme]` selectors. Do not add color values to inline `sx` props or component styles — add a CSS variable to `tokens.css` and reference it via `var(--my-token)`. - -This includes feedback colors: `--bal-success`, `--bal-error`, `--bal-warning`, `--bal-info` (each with a `*-contrast` text token). These drive the toast variants (see Toast Notifications) and are the place to source any success/error/warning/info color — the MUI palette does **not** define semantic colors, so prefer these tokens over MUI's defaults for brand consistency. - -### Pre-built theme objects - -`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`. - -### Motion & the reduced-motion gate - -Durations/easing live as tokens in `tokens.css` (`--bal-motion-fast/-base/-slow`, `--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. `RouteFadeIn` -(`components/common/RouteFadeIn/`) is the one route-content fade/slide primitive, mounted inside the -`ErrorBoundary` in all five shells — new pages get the motion for free, 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, not a token-only zeroing (`tokens.css` also -zeroes the duration tokens, but that alone wouldn't reach MUI's own JS-driven Dialog/Drawer/Menu/Collapse -transitions, which don't read the 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. - -### Toggle components - -`ThemeModeSetting` in `src/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. When the user picks a mode: -1. `setMode('dark')` is called -2. `Storage.prototype.setItem` intercept fires → writes `'color-scheme'='dark'` cookie synchronously -3. MUI sets `data-mui-color-scheme="dark"` on `<html>` -4. CSS variables resolve → browser repaints. No React re-render above the control. - -**It is a three-way segmented control (light / dark / system), never a boolean switch.** `system` is -the app's real default (`ThemeProvider`'s `defaultMode` on a cookie-less first visit), so an on/off -control cannot represent the current state and would silently misreport it. - -Use `colorScheme` (not `mode`) for an `isDark` 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 all agree, so there is no hydration mismatch and no flash of "nothing selected". - ---- - -## Direction (RTL / LTR) - -Derived from locale via `getDirection(locale)` in `src/theme/direction.ts`: -- RTL locales: `fa`, `ar`, `he`, `ur` -- All others: `ltr` - -`ThemeProvider` accepts a `dir` prop and selects the matching pre-built theme (`APP_THEME_RTL` for RTL). The RTL Emotion cache uses `stylis-plugin-rtl` to mirror all generated CSS. - -`src/app/[locale]/layout.tsx` sets `dir={dir}` on `<html>` and passes `dir` to `ThemeProvider`. Because that layout is keyed on the `[locale]` URL param, changing locale re-renders it with a fresh `dir` — on both hard and soft navigation, no client-side state. **Do not** move the `<html dir>` render to a layout above `[locale]`; such a layout is shared across locales, gets statically cached with the default locale, and `dir` freezes on 'rtl' for `/en`. - -**Default locale is `fa` (RTL).** The middleware redirects bare `/` to `/fa/`. English is explicitly accessed at `/en/`. - ---- - -## Fonts - -Fonts are loaded **per locale** — the Persian face is never shipped to English pages: - -| Locale | Font | CSS variable | Source | Loaded when | -|--------|------|--------------|--------|-------------| -| `fa` (RTL) | **Mikhak** | `--font-mikhak` | `next/font/local` — woff2 files in `src/app/fonts/` | only on `fa` routes | -| `en` (LTR) | **Space Grotesk** | `--font-space-grotesk` | `next/font/google` — self-hosted at build time, `preload: false` | only on `en` routes | - -**Typography exports:** -- `TYPOGRAPHY_LTR` — Space Grotesk headings, system font body (used by `APP_THEME_LTR`) -- `TYPOGRAPHY_RTL` — Mikhak for all text including body (used by `APP_THEME_RTL`, ensures full Persian glyph coverage) -- Both share one size/line-height scale (`SIZE_SCALE` in `typography.ts`), wrapped in - `responsiveFontSizes()` (`theme.ts`) for per-breakpoint heading scaling. Weight system: - **700** for headings + buttons, **500** for in-text emphasis (subtitles, labels), **400** - body — never `600`, neither font loads that weight (see `typography.ts`'s header comment). -- There is no `TYPOGRAPHY` alias anymore — import `TYPOGRAPHY_LTR`/`TYPOGRAPHY_RTL` explicitly. - -**Rules:** -- Both fonts are declared with `preload: false`, and each `.variable` class is attached to `<html>` **only for its own locale** (Mikhak on `fa`, Space Grotesk on `en`) — never both, never neither. A `next/font` loader called in the root layout would otherwise preload on every route, and `preload: false` ensures the font file only downloads 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 (`src/app/[locale]/layout.tsx`) at build time. Space Grotesk needs no local files — next/font/google fetches + self-hosts it at build time. -- Never load fonts inside components — all font loading lives in `src/app/[locale]/layout.tsx`. -- To add a new local font, add woff2 files to `src/app/fonts/`, declare via `localFont` in `src/app/[locale]/layout.tsx`, attach its `.variable` class conditionally on the matching locale, and update the `BRAND_FONT_VARIABLE_*` constants in `typography.ts`. - ---- - -## Unit Testing - -**Rule: every shared component must have a co-located test file.** - -A component is "shared" if it is imported from more than one place (page, layout, or other component). - -Coverage baseline for shared components: -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. - -Test location: `src/components/ComponentName/ComponentName.test.tsx` next to the component. -Test wrapper: wrap with `<ThemeProvider>` if the component uses MUI theming. -Do NOT mock MUI components — test against the rendered DOM. - -Enforcement: before removing or renaming a shared component, check whether `src/**/*.test.{ts,tsx}` files import it. If so, update or delete those tests too. - -### Presentational purity in `components/common` - -`next-intl` (and its `use-intl` dependency) ship ESM-only builds. `jest.config.ts` widens -`next/jest`'s default `transformIgnorePatterns` (which otherwise treats *all* of -`node_modules` as untransformed CommonJS) to allow `next-intl`/`use-intl`/`@formatjs`/ -`intl-messageformat` through — but that only fixes real, unmocked 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**, even 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 so they 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 (widen the Jest -transform) rather than stripping `next-intl` from it. When adding a new `components/common` -primitive: prefer the caller-owned-copy pattern by default, and only reach for -`useTranslations` inside it if the component is genuinely leaf-level (nothing else in the -barrel needs to stay import-safe around it). - ---- - -## Comments & dead code - -- **No dead code.** Unused variables, imports, parameters, and private members are lint errors - (`@typescript-eslint/no-unused-vars`, raised to `error` — see *Quality gates*). Delete them; don't - comment them out and don't silence the rule. Prefix a deliberately-unused binding with `_` to opt out. -- **Comment the *why*, never the *what*.** Code should read for itself — a comment that restates what - the code already says is noise. Don't write `// set the access token` above `setClientCookie(...)`, or - JSDoc that just echoes a function's name. -- **Do** add a tight comment when a decision is genuinely non-obvious from the code: a workaround for a - framework quirk, a business rule, an ordering or security constraint, a deliberate deviation. Explain - *why it is this way*. The comments in `src/app/[locale]/layout.tsx` (why `<html>` lives in the - `[locale]` layout) and `src/lib/auth/token.ts` (why the JWT `exp` check is UX-only, never a security - boundary) are the model to follow. -- Prefer a clearer name or a small helper over a comment whenever that removes the need for it. - -## Anti-patterns (do not do these) - -- **Do not** read `localStorage` or `document.cookie` in render functions — use `useEffect` or server-side `cookies()` from `next/headers`. -- **Do not** call `createTheme()` inside a component or hook — use `APP_THEME_LTR` / `APP_THEME_RTL`. -- **Do not** use `storageWindow={null}` on `MuiThemeProvider` — it is silently ignored in MUI v9. -- **Do not** use `InitColorSchemeScript` from MUI — use `ColorSchemeScript` from `@/theme`. -- **Do not** set `colorSchemeSelector: 'data'` — use `'data-mui-color-scheme'`. -- **Do not** check `mode === 'dark'` for "is dark active" — use `colorScheme === 'dark'`. -- **Do not** hard-code UI strings — add translation keys to both `messages/en.json` and `messages/fa.json`. -- **Do not** add a `src/app/layout.tsx` or any layout above the `[locale]` segment. Such a layout is shared across locales, gets statically cached at build time with `defaultLocale` ('fa'), and never re-renders on a locale switch — so `<html lang/dir>`, messages, providers, and fonts placed there freeze on 'fa'/'rtl' for `/en`. `src/app/[locale]/layout.tsx` is the root layout (it renders `<html>`/`<body>`) precisely because it is the lowest boundary keyed on the locale param. -- **Do not** call `getMessages()` without passing `{ locale }` explicitly — `getMessages({ locale })` passes the locale directly to `getRequestConfig` via `Promise.resolve(locale)`, bypassing potential React.cache ordering issues. -- **Do not** remove `setRequestLocale(locale)` from `src/app/[locale]/layout.tsx` — without it, `getLocale()` called by deeper server components always returns `defaultLocale`. -- **Do not** add `notFound()` to `src/app/[locale]/layout.tsx` — unknown locale URLs are handled by middleware (redirect to defaultLocale); a hard 404 here breaks fallback behavior. -- **Do not** import `TYPOGRAPHY` — use `TYPOGRAPHY_LTR` or `TYPOGRAPHY_RTL` explicitly. -- **Do not** load fonts inside components or pages — all next/font declarations belong in `src/app/[locale]/layout.tsx`, with the `.variable` class attached conditionally per locale (Mikhak only for `fa`). -- **Do not** import `@/lib/cookies/server` in client components or `@/lib/cookies/client` in RSCs. -- **Do not** call `fetch()` directly in components or services — use `serverFetch` (RSC/Server Actions) or `clientFetch` (hooks/Client Components) from `@/lib/api`. -- **Do not** create a top-level barrel at `src/services/index.ts` — imports should make the domain origin clear (e.g. `import { useLogin } from '@/services/auth'`, not `import { useLogin } from '@/services'`). -- Each domain **does** have an `index.ts` that re-exports its hooks (e.g. `src/services/auth/index.ts`). Do not export `types`, `keys`, or `apis/*` from this barrel — only hooks. -- **Do not** mix `clientFetch` and `serverFetch` in the same file — keep `clientApi.ts` and `serverApi.ts` separate; Next.js enforces the environment boundary at build time. -- **Do not** toast inside hooks for 401/403/5xx — those are already toasted by `clientFetch`. Only toast in `onError` for domain-specific 4xx messages. -- **Do not** call `js-cookie` (`Cookies.*`) directly — use the central client cookie manager (`@/lib/cookies/client`). -- **Do not** read or write `document.cookie` directly — use the central client cookie manager. -- **Do not** store auth tokens in `sessionStorage` or `localStorage` — use cookies via `@/lib/cookies/client`. -- **Do not** pass `flexWrap` or `useFlexGap` as direct props to MUI `Stack` — these are not valid Stack props in MUI v9 and cause a TypeScript overload error. Use `sx={{ flexWrap: 'wrap' }}` instead. `useFlexGap` was a MUI v5 opt-in and does not exist in v9. -- **Do not** use mui old api which cause errors ---- - -## API Fetch Services - -Central fetch primitives live in `src/lib/api/`: - -| File | Use from | Purpose | -|------|----------|---------| -| `client.ts` | hooks, client components | `clientFetch<T>` — throws `ApiError` on error | -| `server.ts` | RSCs, Server Actions only | `serverFetch<T>` — throws `ApiError` on error | -| `errors.ts` | anywhere | `ApiError` class (`status`, `message`, `code`) | - -**Error contract — `clientFetch`:** -- **401** — toast "session expired", clear cookies, redirect to login (no throw; 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` - -**Error contract — `serverFetch`:** -- All errors throw `ApiError` (no toast — server can't fire browser events) -- RSC callers decide whether to `notFound()`, `redirect()`, or let the error propagate to an error boundary - -**Domain API calls** live in `src/services/{domain}/apis/clientApi.ts` (or `serverApi.ts`). Never call raw `fetch()` directly. - -### The `services/{domain}` reference pattern (copy `auth` / `patients`) - -Every domain follows the same shape: `types.ts` (wire types + the domain's `Api` interface), `keys.ts` -(hierarchical React Query key factory), `apis/` (implementations + a selecting `index.ts`), `hooks/` -(one hook per file), and a barrel `index.ts` that re-exports **hooks only** (never `types`/`keys`/`apis`). - -- **Caching is deliberate:** 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 (the geo province→city→district - hierarchy) use an **Infinite `staleTime`** + a shared, hierarchical key factory (`geographyKeys`) so each - level is fetched **once** and served from cache across every consumer (the address form, the coverage editor, - and later search) — never refetched on a dropdown open. Contrast with mutable lists (addresses, coverage - areas) which invalidate on every mutation. See `services/geography/*`. Reuse this pattern for future - reference data; do not reinvent per-consumer fetching. **`services/catalog` (f4) is the second long-lived - cached reference domain:** admin-seeded categories + a category's option groups/values use the same Infinite - `staleTime`/`gcTime` (`CATALOG_REFERENCE_*`) so the Home grid and every builder step read them from cache; - the nurse's own **variant list** is the mutable side — mutations invalidate `catalogKeys.myVariantsLists()`. -- **Mock behind a seam:** when the 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. Record every mock in - `dev/shared-working-context/reports/mocks-registry.md`. -- **De-mock status (refinement-phase-4):** **14 domains are now REAL** (`USE_*_MOCK = false`): `auth`, - `geography`, `patients`, `profiles`, `nurse` (bank), `addresses`, `serviceAreas`, `catalog`, `search`, - `bookingRequests`, `bookings`, `payment`, `reviews`, `notifications`, `tickets`. Flipping them required - updating each `clientApi.ts` to **consume the fields Phase-3 delivered** (search name/avatar/distance + - `nurses/{id}/profile`; patient relation/conditions; address `provinceId`; booking-request - `variantPrice`/`bookingId`; ticket `unreadCount`/`lastMessageAt`/`clientMessageId`; review `my_review` - mapper; profile `avatarUrl`/`preferredLanguage` + a **multipart avatar upload** now that `clientFetch` - passes `FormData` bodies through). **7 domains stay mocked** because a precondition REQ is deferred/unsafe: - `verification` (REQ-034 admin queue), `refunds` (REQ-035 admin preview), `payouts` (REQ-036 admin preview), - `admin` (REQ-031 RBAC roles), `bnpl` (REQ-022/024 options/schedule/wallet), `partnerCenter` (REQ-032/033/038 - portal reads + `/me` signal), `patientRecords` (REQ-027 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). Note: - the `EVV_GPS_MODE` seam auto-selects `off` (real `navigator.geolocation`) once `USE_BOOKINGS_MOCK=false`. -- **The wire envelope:** the server wraps responses in `ApiEnvelope<T>` (`{ isSuccess, statusCode, - message, requestId, data }`, camelCase — see `lib/api/types.ts`). `clientFetch` returns the raw body, so - a real `clientApi` reads the payload via `unwrap()`. Types are derived from `dev/contracts/` + - `dev/contracts/openapi/swagger.v1.json`, mirroring the wire exactly. -- **Money & dates:** format via `@/utils` — `formatIrrToToman`/`formatIrr`/`parseIrr` (IRR strings, integer-safe - BigInt) and `formatShamsiDate`/`formatShamsiDateTime` (UTC ISO → Persian calendar). Money is never a float. - ---- - -## Auth Cookies & session state - -| Cookie | Constant | TTL | Set by | -|--------|----------|-----|--------| -| `access_token` | `COOKIE_NAMES.ACCESS_TOKEN` | 15 min | `persistAuthTokens` (`src/lib/auth/session.ts`) — via `useVerifyOtp`, `useRefresh`, `useSelectRole`, and the fetch-layer silent refresh | -| `refresh_token` | `COOKIE_NAMES.REFRESH_TOKEN` | 7 days | same as above | - -**The credential is phone-OTP** — there is no username/password anywhere; email is never a login key. -The login flow lives in `src/components/auth/` (`LoginFlow` → `PhoneStep`/`OtpStep`) at `/login`, over the -`services/auth` domain (`requestOtp`/`verifyOtp`/`refresh`/`logout`/`getMe`/`selectRole`). - -**Role router:** after a successful verify, `RoleRouter` (`src/components/auth/`) reads `/me` and navigates — -customer→family app, nurse→nurse app, empty roles→`/select-role`, admin→admin console — showing the branded -splash while `/me` loads so the wrong shell never flashes. The routing decision is the **pure** -`resolveRoleDestination(me, intendedRole)` in `src/services/auth/routing.ts` (unit-tested). The middleware -still owns the auth gate; the router only decides *which app*. - -**Role-aware shell guard (resolved-vs-pending hydration).** Every private shell — `(customer)`, `nurse`, -`admin`, `partner` — wraps its layout in **`RoleGuard`** (`src/components/auth/RoleGuard.tsx`). This exists -because the *core* role bug is conflating **"`/me` hasn't resolved yet"** with **"the user has no -nurse/admin role"**: a fresh `/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`) and: -- **loading** → a neutral brand splash (never the customer shell as a stand-in); -- **error** (`/me` failed, e.g. API down) → `AuthAccountError` with retry (never a silent customer fallback — - a transient error must not downgrade a nurse/admin); -- **role mismatch** → redirect to the caller's real app via `resolveRoleDestination` (the single "which app" - source) with a `guard_denied` toast, instead of rendering a shell they lack the role for. - -A shell passes `expected={APP_ROLES.*}`; the partner portal passes **no** `expected` (it isn't an `AppRole` -— it self-gates on `useMyPartnerCenter`, so `RoleGuard` there only hardens hydration). The guard is **UX/chrome, -not security** — the server authorizes every endpoint; a dual customer+nurse session holds both roles and moves -freely between the family and nurse apps. `useActorRole()`'s `DEFAULT_ROLE` fallback is now only a last resort -(the guard ensures roles are hydrated before a shell renders), never the loading state. - -**Session state lives in `AuthContext`** (`src/context/auth/`), now carrying `SessionUser { id?, phone, -roles: AppRole[] }`. The root layout resolves the session on the server with `getServerAuthState()` -(`src/lib/auth/server.ts`) — which reads the `access_token` cookie and checks the JWT `exp` via the shared -`isTokenAlive` (`src/lib/auth/token.ts`) — and passes it to `<AuthProvider initialState={…}>`, so the first -render already knows whether the user is authenticated. **Roles are not derivable from the opaque JWE token -server-side**, so the server seeds `isAuthenticated` only; `useSessionRoleSync()` (mounted in the -private-routes layout) hydrates `currentUser.roles` from `/me` — the single source the shells read via -`useActorRole()`. `invalidateQueries(authKeys.me())` runs on login; `removeQueries(authKeys.all)` on logout. - -**Lifecycle:** -- Written by `persistAuthTokens` after verify/refresh/select-role, which also dispatch `LOG_IN` to keep - `AuthContext` in sync without a reload. -- Deleted by `useLogout()` (`src/services/auth/hooks/useLogout.ts`) — the single logout path: revoke the - server session, clear both cookies, `LOG_OUT`, drop the `/me` cache, redirect — and by `clientFetch` when a - 401 can't be recovered by a refresh. -- Read on the server by `serverFetch` / `getServerAuthState` via `getServerCookie`. -- Read on the client by `clientFetch` via `getClientCookie` (to attach `Authorization: Bearer`). - -**Silent refresh:** `clientFetch` attempts one single-flight `attemptTokenRefresh` (`src/lib/api/refresh.ts`) -on a 401 and retries the request once; a failed refresh (unknown/expired/reused token → the server revokes -the session) clears tokens and redirects to `/login`. The refresh/OTP endpoints are excluded from this retry. - -**Middleware** (`middleware.ts`) gates private routes with the same `isTokenAlive` helper before render. On -redirect it appends the attempted locale-stripped path + query as `?next=` (`RETURN_URL_PARAM`) so a deep -link survives the round trip; `LoginFlow` reads it and `RoleRouter` resolves it via -`resolvePostLoginDestination` (`services/auth/routing.ts`) — same-origin-relative + role-permitting only, -else it falls back to `resolveRoleDestination` (never an open redirect). - -**Security posture — current limits and best-practice follow-ups.** The flow above is the intended -client design, but some hardening needs *server* coordination — don't silently "fix" it client-only: -- **Tokens are non-httpOnly cookies** (JS-readable) so `clientFetch` can attach the bearer header — this - trades XSS-hardening for the bearer pattern. Real hardening (httpOnly cookies set by the server + a - same-origin proxy) spans the server. -- **The middleware check is UX-only, not a security boundary:** it decodes the JWT and checks `exp` but - does **not** verify the signature. The API is the only authority; never gate real authorization on the - middleware or `isTokenAlive`. -- **Role gating is coarse for chrome, fine for the backoffice:** the shells pick chrome from the collapsed - `currentUser.roles` (`useActorRole`). **f15 adds `useAdminCapabilities()` (`@/hooks`)** — a memoized selector - over the session's **fine-grained** `roleCodes` (hydrated from `/me` by `useSessionRoleSync`; `super_admin`/ - `admin`/`support`/`finance`/`moderation`) that returns per-console booleans (`canVerify`/`canRefund`/ - `canPayout`/`canModerate`/`canConfig`/`canManageAlerts`/`canManageTickets`/`canManagePartners`/`canViewAudit`/ - `canManageRoles`). The `AdminLayout` nav and every admin action **hide/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 still isn't hard-guarded client-side; add route guards when a - phase needs them. The **partner portal is a separate scope** — its pages resolve the caller's own center via - `useMyPartnerCenter()` (a 403/404 renders a non-leaking access-denied state), never a raw id. -- **Signed URLs are fetched on demand, never cached long-lived (f15):** verification documents load via a - short-lived signed URL fetched by `useVerificationDocumentUrl(documentId)` (short `staleTime`, `retry:false`) — - `DocumentViewer` re-requests it on expiry/error rather than reading the embedded URL from the long-lived case - query. Reuse this pattern for any short-lived signed asset (invoice PDFs, etc.). -- **Refresh-token rotation is wired** client-side (fetch-layer silent refresh + `useRefresh`), matching the - server's rotation + reuse-detection. The `refresh_token` cookie TTL (7d) is shorter than the server session - default (30d) — a follow-up can align the cookie `maxAge` to `refreshExpiresAt`. - ---- - -## Toast Notifications (notistack) - -`<SnackbarProvider>` wraps all children inside `ThemeProvider` in `src/app/[locale]/layout.tsx`. - -**In React components/hooks** — use notistack directly: -```tsx -import { useSnackbar } from 'notistack' -const { enqueueSnackbar } = useSnackbar() -enqueueSnackbar('Saved!', { variant: 'success' }) -``` - -**Outside React** (plain functions, fetch services) — use the event bridge: -```ts -import { dispatchToast } from '@/lib/toast' -dispatchToast('Something went wrong', 'error') -``` -`dispatchToast` fires a `window` CustomEvent (`app:toast`). `ToastBridge` (a zero-UI `'use client'` component inside `SnackbarProvider`) listens and calls `enqueueSnackbar`. - -`ToastBridge` is already rendered in `[locale]/layout.tsx` — do not add another instance. - -**Toast colors follow the theme.** `NotistackProvider` maps every notistack variant to a `styled(MaterialDesignContent)` whose `backgroundColor`/`color` come from the `--bal-{success,error,warning,info}` (+ `*-contrast`) tokens in `tokens.css`. 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 instead. - -**Direction is inherited, not passed.** notistack's Portal mounts under `<body>`, so it inherits `dir` from `<html dir>` (set per-locale in the root layout). Do **not** pass a `dir` prop to `SnackbarProvider` — it is not a valid prop (TS error) and is unnecessary: -```tsx -<NotistackProvider>{children}</NotistackProvider> -``` - -**Every mutation needs an `onError` toast.** Every mutation whose failure is not already surfaced inline or by the fetch layer (401/403/5xx are auto-toasted by `clientFetch`) must have an `onError` toast — a mutation that only handles `onSuccess` is a defect. - ---- - -## Route Constants - -Named path constants live in `src/constants/routes.ts`: -```ts -ROUTES.LOGIN = '/login' -ROUTES.HOME = '/' -PUBLIC_PATHS = [ROUTES.LOGIN, ...] // paths that bypass middleware auth check -``` -Import from the barrel: `import { ROUTES, PUBLIC_PATHS } from '@/constants'`. - -To add a new public route, append it to `PUBLIC_PATHS` — the middleware picks it up automatically. -**Never append `ROUTES.HOME` ('/') itself** — `PUBLIC_PATHS` is matched with `startsWith`, so `'/'` -would silently make every route public. The guest-facing root (ui-phase-13) is handled by an -exact-match `NextResponse.rewrite()` in `middleware.ts` instead, to `ROUTES.WELCOME` ('/welcome'). - ---- - -## Client Cookie Manager (js-cookie) - -`src/lib/cookies/client.ts` uses `js-cookie` internally. The exported API is unchanged: - -| Function | Purpose | -|----------|---------| -| `getClientCookie(name)` | Read a cookie by name | -| `setClientCookie(name, value, options?)` | Write a cookie; `options` is `CookieOptions` with `maxAge` in **seconds** | -| `deleteClientCookie(name, path?)` | Delete a cookie | -| `getColorSchemeCookie()` | Typed helper for the theme cookie | - -`CookieOptions` type is defined in `src/lib/cookies/constants.ts` — `maxAge` is in seconds (converted to `expires: Date` internally when calling js-cookie). -- **Do not** `document.title = title` in the render body of any component — it causes `ReferenceError: document is not defined` during build-time prerendering. +| Routes, layouts, the RSC boundary, page metadata | [docs/rules/client/structure.md](../docs/rules/client/structure.md) | +| Colors, tokens, dark mode, RTL, fonts, motion | [docs/rules/client/theme.md](../docs/rules/client/theme.md) | +| The `App*` library, shells, navigation, icons, constants | [docs/rules/client/components.md](../docs/rules/client/components.md) | +| Any form | [docs/rules/client/forms.md](../docs/rules/client/forms.md) | +| Copy, translations, Persian orthography | [docs/rules/client/i18n.md](../docs/rules/client/i18n.md) | +| Fetching, TanStack Query, `services/{domain}`, money display, cookies | [docs/rules/client/services.md](../docs/rules/client/services.md) | +| Sessions, refresh, `RoleGuard`, middleware, security posture | [docs/rules/client/auth.md](../docs/rules/client/auth.md) | +| Tests, ESLint, the type gate | [docs/rules/client/testing.md](../docs/rules/client/testing.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) | +| Brand, look and feel, turning a design into a screen | the **frontend-designer** skill | +| Cross-project rules — naming, gates, code quality | [docs/rules/shared/](../docs/rules/shared/) | diff --git a/client/messages/STYLE.md b/client/messages/STYLE.md deleted file mode 100644 index 96c8d65..0000000 --- a/client/messages/STYLE.md +++ /dev/null @@ -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. diff --git a/client/scripts/check-copy.mjs b/client/scripts/check-copy.mjs index 3643dde..ccfa9f1 100644 --- a/client/scripts/check-copy.mjs +++ b/client/scripts/check-copy.mjs @@ -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'; diff --git a/docs/README.md b/docs/README.md index 9d4a589..3c82df2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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). diff --git a/docs/_plan/open-contradictions.md b/docs/_plan/open-contradictions.md index 345926f..29cc89a 100644 --- a/docs/_plan/open-contradictions.md +++ b/docs/_plan/open-contradictions.md @@ -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), [:35–48](../../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 §§1–7](../../.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), [:22–23](../../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:181–186` | +| 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`**. diff --git a/docs/_plan/phase-1-rules-consolidation.md b/docs/_plan/phase-1-rules-consolidation.md index 4bfcc66..35396e8 100644 --- a/docs/_plan/phase-1-rules-consolidation.md +++ b/docs/_plan/phase-1-rules-consolidation.md @@ -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:134–135` assigns them owner phase 1. The phase file (lines 38–39) 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. diff --git a/docs/rules/client/auth.md b/docs/rules/client/auth.md new file mode 100644 index 0000000..2260e3e --- /dev/null +++ b/docs/rules/client/auth.md @@ -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. diff --git a/docs/rules/client/components.md b/docs/rules/client/components.md new file mode 100644 index 0000000..2406412 --- /dev/null +++ b/docs/rules/client/components.md @@ -0,0 +1,266 @@ +# Client components, shells and icons + +What to reach for before writing something new, and the layout system every screen lives in. + +> Last verified: 2026-07-30 against commit `d3ec723`. + +--- + +## 1. There is one layout: a phone + +`AppFrame` (`src/layout/AppFrame.tsx`) renders **every** screen inside a centered +`APP_FRAME_MAX_WIDTH` (480px) column on a `--bal-frame-canvas` backdrop, at **every viewport**. A wider +window gets more canvas, never a wider app — so you design one set of states and verify one set of states. + +**Do not add a `≥md` branch that widens a shell, restores a sidebar, or lays a screen out in columns.** + +`AppFrame` owns four structural guarantees, and is the only place any of them is solved: + +1. **The width cap.** No shell stretches a header, a nav bar, or a content column across a monitor. +2. **The frame, not the document, owns the scroll.** A single scrolling `<main>` fills the frame; header and + footer are pinned over it and reserve their own space through `<main>`'s padding, so **no page needs a + top offset of its own**. +3. **Horizontal scroll is structurally impossible.** `overflowX: hidden` + `minWidth: 0` on the column mean + an over-wide child clips instead of dragging the whole app sideways. Genuinely wide content (a data + table) scrolls **inside its own container** — see `AdminDataTable`'s `TableContainer`. +4. **Above `sm` the column floats** as a rounded, shadowed card with a gutter all round. On a phone it fills + the viewport edge to edge — there is no canvas to float on. + +Two mechanics that follow from (2) and are easy to get wrong: + +- **The chrome is `position: absolute` against the frame, never `fixed`.** A viewport-fixed bar would break + out of the centered column and span the whole window. The frame itself never scrolls (only `<main>` does), + so on a phone — where the frame *is* the viewport — the two are visually identical. +- **`AppFrame` publishes `--bal-chrome-top` and `--bal-chrome-bottom`** on the scroll container, so any + `position: sticky` element anywhere in the tree can clear the bars without importing a constant or knowing + which shell it is in. Both already include `env(safe-area-inset-*)`, and both resolve to `0px` in a + chrome-free shell — which is why a sticky consumer can read them unconditionally. `StickyActionBar` is the + reference consumer. + +### Shell dimensions are constants + +`src/layout/config.ts` holds them, and they are measured rather than guessed: + +| Constant | Value | Note | +| --- | --- | --- | +| `APP_FRAME_MAX_WIDTH` | 480 | Mirrored by `components/config.ts`'s `CONTENT_MAX_WIDTH` — a page column can never be wider than the frame containing it | +| `TOP_BAR_HEIGHT` | 56 | One height at every viewport; the frame never changes width, so the old mobile/desktop split had nothing to switch on | +| `TOP_CHROME_HEIGHT` | 72 | Total space the floating header occupies. Deliberately equal to `BOTTOM_NAV_HEIGHT` — the two bars are the same object mirrored | +| `BOTTOM_NAV_HEIGHT` | 72 | Total space the floating nav occupies. `AppFrame` reserves exactly this as `<main>` padding, so nothing hides behind the bar. **Keep in sync with `BottomBar`** | +| `FLOATING_BAR_SX` | — | The ONE definition of the two bars' shared shape, so header and footer cannot drift apart | + +--- + +## 2. The shells + +**One authenticated shell.** `MobileShell` = `AppFrame` + a contextual `TopBar` + `BottomBar` + +`ErrorBoundary` + `RouteFadeIn` + `PageTitleProvider`. The four actor layouts — `CustomerLayout`, +`NurseLayout`, `AdminLayout`, `PartnerLayout`, each wrapped in `RoleGuard` — supply only `tabs` and +`headerActions`. + +**Add a destination by adding a tab or a hub row — never by forking the shell.** + +| Shell | For | +| --- | --- | +| `MobileShell` (via the four actor layouts) | Every authenticated screen | +| `PublicLayout` | Unauthenticated: the frame and **nothing else**, no top bar — so the login card's own `BrandMark` is the only mark on screen | +| `FocusedLayout` | Framed but chrome-free, for flows a user must not tab away from mid-setup: onboarding, `/select-role`. A slim logo strip and content, no bottom nav. The route group above it still applies `RoleGuard` | +| `PrivateLayout` | An authenticated passthrough wrapper; actor chrome lives in the shells above | + +### Navigation is the bottom bar. There is no drawer. + +- Tabs are `LinkToPage` arrays built with `useTranslations('nav')`, **3–5 of them**, and by convention the + last is a settings/«بیشتر» hub. +- Active state comes from the shared **`matchActivePath`** (longest-prefix, winner-takes-all) run over each + tab's own path **plus its `matchPaths` claims**. Use `matchPaths` when a tab owns a destination outside + its own URL subtree — `/nurse/finance` owning `/nurse/earnings`. **Never hand-roll + `pathname.startsWith`**, which lights up a sibling tab as often as the right one. +- `BottomBar` **floats**: inset from the frame edges, fully rounded (`--bal-radius-pill`), elevated — not an + edge-to-edge slab sealing off the bottom of a 480px screen. It is **icon-only**: at five tabs the caption + was the widest thing in the bar and cost a whole line, so the label survives as `aria-label`/`title`. Each + tab is a fixed 44px circle that is simultaneously the target, the hover/press tint and the active fill, + laid out `space-around` so the target keeps one size at any tab count. +- `TopBar` is **not** an `AppBar` — no filled surface, no rule, no elevation of its own. `AppFrame` wraps it + in `FLOATING_BAR_SX`, so it is the bottom bar mirrored. It shows a brand lockup on a tab's own path and a + back chevron + `useRouteTitle()` on anything deeper. + +### Group roots are real pages + +A nav group's root is a page, not a drawer section: a short summary of that domain — read **only off +queries that already answer it, never a fabricated figure** — over a `NavHubList` of its destinations. +`/nurse/practice`, `/nurse/finance`, `/admin/trust`, `/admin/system` are the references. A count that is +still in flight is **omitted, never faked**. + +### Chrome carries no preferences, and no identity + +Language and appearance live in `SettingsPanel` (`@/components/settings`), mounted in each actor's settings +hub **and nowhere else**. Identity lives in each actor's «بیشتر»/account hub, one tap away on the nav. The +top bar is for the page title and at most a notification bell. + +`/admin/system` is always present in the admin nav even when every console inside it is denied, because it +is the only route out of the app (settings + sign-out). + +### Navigation goes through `@/i18n/navigation` + +**All chrome navigation** uses `Link` / `usePathname` / `useRouter` from `@/i18n/navigation` +(`createNavigation(routing)`). `usePathname` is locale-stripped, so unprefixed `ROUTES.*` compare directly, +and `Link`/`router` add the locale automatically — **no manual `` `/${locale}` `` prefixing, and no +middleware redirect hop.** Never a raw `next/link` for chrome. + +Inside a *page*, `AppLink`/`AppButton`'s `to` is a plain `next/link` and still needs the prefix. + +Prefer MUI breakpoints in `sx` for the little responsive branching that remains, over `useIsMobile()` +(`@/hooks`) — the hook is JS/post-hydration and caused a real SSR flash. Reach for it only for genuinely +non-structural, JS-only behaviour. + +--- + +## 3. Reach for these before raw MUI + +Shared primitives live in `src/components/`, barrel `@/components`. Prefer the `App*` wrapper over the bare +MUI component — the wrappers carry the house defaults. + +| Component | Use for | Notes | +| --- | --- | --- | +| `AppButton` | all buttons and button-links | default `variant="contained"`; pass `to`/`href` to render as a link; `startIcon`/`endIcon` accept an **icon name string** or a node | +| `AppIconButton` | icon-only actions | takes an icon name, `title`, `to`/`onClick` | +| `AppIcon` | any icon | `icon="home"` by registered name (§4); `size`, `color` | +| `AppLink` | internal/external links | locale-aware; default underline `hover` | +| `AppAlert` | inline alerts | defaults to a calm `severity="info"`, `variant="standard"` — a genuinely error-severity call site passes `severity="error"` explicitly | +| `AppLoading` | loading state | circular, `primary`, `3rem` | + +Defaults live in `src/components/config.ts` — `APP_BUTTON_VARIANT`, `APP_ICON_SIZE` (24), +`APP_ICON_STROKE_WIDTH` (1.75), `APP_BUTTON_ICON_SIZE` (20), `CONTENT_MAX_WIDTH` (**480**), +`CONTENT_MIN_WIDTH` (320), the alert/link defaults. **Change a default there, not per call site.** + +**Concrete MUI primitives stay MUI.** Use `Button`, `Avatar`, `Paper`, `TextField`, `Box`, `Stack`, +`Container`, `Grid`, `Card` directly (or the existing `App*` wrappers) — never invent a new root-level +Button or Avatar. Use the `spacing`/`sx` system (theme unit = 8px); never inline pixel margins for rhythm. + +**Composite, shareable components** built from primitives and reused in more than one place belong at the +right *shared* level (`src/components/…`), not inline in a page and not buried in a leaf. Page-only, +never-reused composition can stay in the page. + +### The state kit — one pattern per state, and they are not optional + +| Primitive | The one pattern for | +| --- | --- | +| `EmptyState` | "nothing here" — icon + title + body + action. Replaces every hand-rolled dashed-border `Paper` | +| `ErrorState` | "this query failed" — `message` + a **required** `retryLabel` + `onRetry` | +| `QueryStateGate` | A query's branching, in the fixed **skeleton → error → empty → children** order. Also requires `retryLabel` | +| `PageHeader` | title + subtitle + `actions` (buttons) + `meta` (a chip row) + a back affordance (`backTo`, or `onBack` which takes precedence and pairs with `useAdminBackToList` for `router.back()`-with-fallback) | +| `ConfirmDialog` | Any destructive confirm. Required-reason gating, busy-disable, and `requireTypedConfirmation` (confirm stays disabled until the typed value matches) — the guard for an irreversible money-moving action, e.g. the admin payout run | +| `SurfaceCard` | A flat `Paper` wrapper; `padding: 'sm' \| 'md' \| 'lg'` | +| `AccentCard` | `SurfaceCard` + a semantic `tone` for a **stateful** panel | +| `Money` | The one money-rendering primitive (`amountIrr`, `size` incl. `xl`, `tone`, `deduction`, `hideUnit`, `strikethrough`) | +| `StatusTimeline` | An ordered `TimelineNode[]` (completed/current/pending/failed) with an animated pulse on `current` | +| `JalaliDatePicker` / `JalaliDateField` / `JalaliDateIntentPicker` | Any Persian-calendar date input. Never a native `type="date"` | +| `StickyActionBar` | A scrolling screen's primary CTA, offset off `--bal-chrome-bottom` | +| `Pager` | The shared prev/next "page X of Y" control. Never a per-screen inline pager | +| `NavHubList` | The grouped destination list a group-root page is built from | +| `InitialsAvatar` | A person with no photo — deterministic name hash → one of six `--bal-avatar-*` pairs, `aria-hidden` beside a visible name | +| `FormDialogShell` | A form dialog: full-screen below `sm`, with a dirty-gated discard confirm | +| `RouteFadeIn` | Route-content motion. Already mounted in all five shells | + +**An error state is never an empty state.** A failed query renders `ErrorState`; a successful query with +no rows renders `EmptyState`. Collapsing the two hides outages. + +Two `AccentCard` details worth knowing: its colored **edge stripe is gone** — a column of striped cards read +as a row of loose vertical rules down the RTL side of the screen. `tone` survives as the semantic label +(reaching the DOM as `data-accent-tone`), and state is carried by the `StatusChip`, icon and copy inside the +card. **Do not reintroduce the stripe.** + +### Presentational purity in `components/common` + +`next-intl` (and its `use-intl` dependency) ship ESM-only builds. `jest.config.ts` widens `next/jest`'s +`transformIgnorePatterns` to let them through, but that only fixes real imports — it doesn't make the +dependency free. **Any component at the top of the `@/components/common` barrel that imports `next-intl` at +module scope forces every test file that transitively imports the barrel to deal with it**, including tests +that never touch translations. + +So `ErrorBoundary` and `ErrorState` are deliberately **caller-owned**: they take `title`/`body`/`retryLabel`/ +`message` as required string props instead of calling `useTranslations` internally, specifically to stay +import-safe at the top of the barrel. `QueryStateGate` inherits the same `retryLabel` requirement by +composition. `Money` is the sanctioned exception — it already had 30+ call sites depending on its +locale-aware API before this was noticed, so the fix went the other way. + +When adding a new `common` primitive: **prefer the caller-owned-copy pattern by default**, and reach for +`useTranslations` inside it only if the component is genuinely leaf-level. Keep next-intl-importing +primitives *below* the presentational ones in the barrel so the poisoning risk stays visible in review. + +### New shared component + +`src/components/<Name>/<Name>.tsx` + an `index.tsx` barrel + a **co-located `<Name>.test.tsx`** (mandatory +for anything imported in more than one place — see [testing.md](testing.md)). Follow the `App*` +prop-spreading and JSDoc style of `AppButton.tsx`. + +--- + +## 4. Icons are a name registry + +`src/components/common/AppIcon/config.ts` maps **lowercase** names → components. Render with +`<AppIcon icon="home" />`, or pass the name to `AppButton`/`AppIconButton` (`startIcon="search"`). + +**One visual family: Lucide.** Every registered icon comes from `lucide-react` — a contemporary outline +family on a 24px grid with round caps and joins, which reads far lighter than filled glyphs at the small +sizes a phone-width app actually uses. **`@mui/icons-material` is no longer a dependency; never reintroduce +it.** The house stroke weight is `APP_ICON_STROKE_WIDTH` (1.75 — Lucide ships at 2, which competes with +Mikhak's lighter Persian strokes). + +**The mapping is semantic, not incidental.** A name describes the domain concept ("verification", +"earnings", "coverage") and the glyph depicts *that*, so swapping the underlying glyph never leaks into call +sites. Related concepts share a visual root on purpose: trust names are shields, money names are coins or +cards, clinical names are a pulse or a cross. Around 110 names are registered — read `AppIcon/config.ts` +rather than duplicating the list. + +- **`size` drives real `width`/`height`** (Lucide sizes off SVG attributes), so `size={48}` is 48px with no + `fontSize`/`1em` indirection. Icons default to `flexShrink: 0` — an icon squashed by a flex sibling was + the one layout bug this component kept quietly reintroducing on narrow rows. +- **Directional icons mirror automatically.** Names authored for LTR that must flip under RTL are listed in + `DIRECTIONAL_ICONS` (`back`, `chevron_start`, `chevron_end`, `forward`, `send`). `AppIcon` stamps + `data-icon-directional`, and one CSS rule in `globals.css` does + `[dir='rtl'] [data-icon-directional] { transform: scaleX(-1); }`. Adding a directional icon is a one-line + registry addition — **never hand-roll a per-component flip.** +- **A new icon** is an import from `lucide-react` into `config.ts` plus a lowercase `ICONS` key. Custom SVGs + (the brand mark) go in `AppIcon/icons/` and must accept the same `size`/`color`/`strokeWidth` contract + (`AppIcon/utils.ts`'s `IconProps`). An unregistered name logs a dev-only warning and falls back to + `default`. **Never pass a raw icon component where a name is expected.** + +--- + +## 5. Constants, not magic values + +Every magic string or configurable value is a named constant. A value is "magic" if its meaning isn't +obvious from the literal alone: cookie names, event names, route paths, query-param names, numeric +timeouts, API slugs, repeated dimensions. + +| Kind | Home | +| --- | --- | +| Cookie names and options | `src/lib/cookies/constants.ts` | +| Feature-scope | a `constants.ts` co-located with that feature | +| App-wide | `src/constants/<concern>.ts` — `routes.ts`, `roles.ts`, `headers.ts`, `policy.ts` | +| Shell dimensions | `src/layout/config.ts` | +| Component defaults | `src/components/config.ts` | + +`constants/policy.ts` is the pattern applied to legally-sensitive numbers that trust-critical copy states +in plain language — the payout dispute-window hours, the cancellation lead-time hours, the refund ETA day +range. They are real server config with no public read yet, single-sourced here and fed into message keys +as ICU params rather than baked into a string. See [i18n.md](i18n.md). + +Import the constant; **never copy-paste the literal.** When renaming, change the definition and the rest +follows. + +--- + +## 6. Toasts + +| From | Use | +| --- | --- | +| A component or hook | `useSnackbar()` → `enqueueSnackbar('…', { variant: 'success' })` | +| Outside React (a plain function, the fetch layer) | `dispatchToast('…', 'error')` from `@/lib/toast` — it fires an `app:toast` window CustomEvent that `ToastBridge` picks up | + +`ToastBridge` is already rendered in the root layout. **Do not add another instance.** + +**Every mutation whose failure isn't already surfaced inline or by the fetch layer needs an `onError` +toast.** A mutation that only handles `onSuccess` is a defect. But don't toast 401/403/5xx in a hook — +`clientFetch` already does. See [services.md](services.md). diff --git a/docs/rules/client/forms.md b/docs/rules/client/forms.md new file mode 100644 index 0000000..eb47fff --- /dev/null +++ b/docs/rules/client/forms.md @@ -0,0 +1,116 @@ +# Client forms + +Every form with more than one field is a react-hook-form form. This is how you build one. + +> Last verified: 2026-07-30 against commit `d3ec723`. + +--- + +## 1. The rule, and why it exists + +**Any form with more than one field uses react-hook-form.** A single-field control — a search box, a filter +select, a message composer — does not: that is state, not a form. + +This is not a style preference. The pattern it replaced was one `useState` per input **plus** a parallel +`useState` per error flag. That meant every keystroke re-rendered the whole screen — including the +query-backed cards, price previews and uploaders sitting beside the field — and left "is this form valid?" +spread across ad-hoc `if` blocks at the top of each submit handler. + +react-hook-form gives uncontrolled fields plus per-field subscriptions, so a keystroke re-renders one +input. + +28 files currently import it. Any multi-field form that still holds its state in `useState` is a defect to +be migrated when that screen is next substantially touched. + +--- + +## 2. How to build one + +1. **`useForm<Values>({ mode: 'onTouched', defaultValues })`.** `onTouched` is the house default: an error + appears once a field has been visited, never while it is first being typed into. + +2. **Wrap the subtree in `<FormProvider {...form}>` and bind fields with the `@/components/common/form` + wrappers.** They read `control` off the provider, so it is threaded once. + **Never call `register` or `useController` at a call site.** + +3. **Put the rule on the field it governs** — `rules={{ validate: … }}` — returning the **translated** + message. Cross-field rules read `validate`'s second argument (all values); that is how the C4 request + form's past-date guard reads the chosen start time. + +4. **Render `<Stack component="form" noValidate onSubmit={handleSubmit(submit)}>`** and make the primary + button `type="submit"`. Enter-to-submit then works for free. + +5. **Async defaults come from a mounted-when-ready child, not an effect.** When the initial values depend on + a query — the verification credentials read-back, the C4 variant/address defaults — keep the loading + branch in the *parent* and mount the form component only once the data has resolved, so `defaultValues` + **is** the server state instead of being copied into it later. + +Point 5 is the one that gets skipped and then costs an afternoon: seeding a form from an effect means the +form has two sources of truth for a moment, and a user who types during that moment loses the keystroke. + +--- + +## 3. The wrappers + +| Wrapper | For | +| --- | --- | +| `RhfTextField` | any `TextField`, including `select`. `transform` normalizes keystrokes **into form state** (digit-stripping, max length) so the *stored* value is canonical, not just the displayed one. A rule message replaces `helperText` | +| `RhfChipSelect` | a chip group over stable codes — `string[]` (multi) or `string \| null` (single). `allowCustomValues` keeps a stored code that isn't in the option list visible | +| `RhfJalaliDateField` | a Jalali date field; stores the wire ISO (Gregorian) string, or `null` | +| `RhfControlGroup` | **any** non-input control — `GenderToggle`, `RatingInput`, `CascadingRegionSelect`, the map-pin picker, a `Switch`, a `Checkbox`. Gives it the same label/hint/error shell the text fields get | + +Every wrapper falls back to the enclosing `FormProvider`'s `control`, so a form wires it once. All four are +tested. + +### Two conventions worth knowing + +- **A control that renders its own error text gets a message-less rule** — `validate: (v) => cond`, no + string. `RhfControlGroup` then flags the field without printing a second identical line. `AddressForm`'s + region and pin fields are the reference. + +- **When the displayed value isn't the stored value, drop to a bare `Controller`.** Exactly two cases exist + and both are commented at the call site: the variant builder's display-name (stored = the override only; + blank means the server names it — shown = the live auto-generated name) and the admin refund channel + (stored = `""` until explicitly overridden; shown = the server's resolved channel). + +--- + +## 4. Structure: `FormSection` + +A long form is grouped into `FormSection`s — a heading, a one-line statement of *why* the group is being +asked for, and an optional/status marker. + +The point is that **an optional group reads as skippable and a blocked submit has somewhere to attribute +itself.** A flat run of ten `TextField`s makes everything look equally mandatory, which is how a nurse ends +up abandoning a verification form over a field that was never required. + +Applies to the nurse profile (معرفی / تجربه و تحصیلات / تخصص‌ها), the verification identity and credentials +screens, and the variant builder. + +--- + +## 5. Making a gate honest + +Five habits that came out of the verification and variant-builder rebuilds. They are what separates a form +that *validates* from a form a user can actually finish. + +- **The submit gate is a real form field, not a caption near the bottom.** Verification B4's three asks + (national id / card photo / selfie) each became a `FormSection` with the card marked optional and the + selfie marked required — so the requirement is attached to the thing that satisfies it. +- **Disable Next with the unanswered required groups *named* under it.** Not "always enabled, error after + the tap". +- **Derive a wizard's step list from the loaded data.** A category with no option groups skips straight to + pricing rather than showing an empty middle step. +- **Recap the chosen values on the final step**, so the last step doubles as a review. +- **Never dead-end a returning user on a disabled button with no explanation.** If server-side state (an + already-uploaded document, an already-submitted registry number) satisfies part of the gate, the gate must + consider it — and a value the server won't read back by design should lock into a "recorded" row rather + than re-prompting for it blank. + +## 6. Unsaved work + +- A form hosted in `FormDialogShell` reports `dirty` via an **`onDirtyChange`** prop, which drives the + shell's discard-confirm on close, backdrop and escape. +- A **staged-but-unsaved upload** gets a `beforeunload` guard — the nurse-profile avatar is the reference. +- A destructive confirm goes through `ConfirmDialog`, whose destructive and dismiss labels must not be + swapped. (They were, once, on the cancel-request dialog; check yours reads correctly out loud.) diff --git a/docs/rules/client/i18n.md b/docs/rules/client/i18n.md new file mode 100644 index 0000000..a132d8e --- /dev/null +++ b/docs/rules/client/i18n.md @@ -0,0 +1,215 @@ +# Client i18n and Persian copy + +next-intl v4 mechanics, the namespace map, and the binding Persian style guide — the last of which is +enforced by `npm run lint:copy`. + +> Last verified: 2026-07-30 against commit `d3ec723`. + +--- + +## 1. The rule + +**No hard-coded user-facing strings.** Every user-visible string — label, placeholder, `aria-label`, +button text, error message — is a key in **both** `messages/en.json` and `messages/fa.json`, and the two +files stay in sync. + +The one sanctioned exception is `app/global-error.tsx`, which replaces the root layout on a root-level crash +and therefore renders its own `<html>` and cannot use next-intl. Keep it minimal and bilingual. + +Locales: **`fa` (default, RTL)** and `en`. `/en` is explicitly accessed; a bare `/` normalizes to the +default locale. + +| Context | API | +| --- | --- | +| Client component | `const t = useTranslations('nav'); t('home')` | +| Server component | `const t = await getTranslations('nav'); t('home')` | +| Structured (array/object) values | `t.raw('terms_sections')` | +| Rich text with tags | `t.rich('consent_line', { terms: …, privacy: … })` | + +Top-level keys are namespaces. Adding a translation means adding the key to both files — never one. + +--- + +## 2. The namespace map + +MVP namespaces are complete. Add a key to an existing namespace where it fits; seed a new namespace only +with a genuinely new surface, and seed it in both files at once. + +| Namespace | Owns | +| --- | --- | +| `common` | Shared words — loading, retry, `currency_toman`, the brand wordmark | +| `nav` | The actor shells' tab labels — every shell builds its nav from here | +| `shell` | Actor-shell titles | +| `auth` | Phone-OTP login, the role router, `RoleGuard` states, select-role, the login-hero trust bullets, the consent line | +| `legal` | `/terms` and `/privacy`. **The one namespace with structured JSON values** — `terms_sections`/`privacy_sections` are arrays of `{title, body}` read via `t.raw` | +| `welcome` | The public landing. Its `category_*` labels are **marketing copy, deliberately distinct from the live `catalog` category names** | +| `onboarding` | The A3→A4 wizard, plus the shared enum labels (relation / condition / gender codes → labels) | +| `home` | The family home — greeting, search entry, category grid, nudges | +| `profile` | Customer profile and emergency contact | +| `patients` | The care-circle list and CRUD | +| `records` | The care-record viewer and the nurse visit-note panel. Reuses `onboarding`/`patients` enum labels — never re-keyed | +| `geo` · `address` · `coverage` | The cascading region select · the customer address book · the nurse coverage editor | +| `catalog` | **Shared** catalog vocabulary — the five `price_unit` labels, count nouns, the estimated-total label. Read by `PriceDisplay` on both sides | +| `services` | The nurse services surface and the variant builder | +| `nurseProfile` | The nurse profile bootstrap and the public-profile preview | +| `activation` | The shared `ActivationChecklist` rows and its collapsed live state | +| `bank` | Nurse payout bank settings and the three ownership states | +| `verification` | The nurse trust flow — per-step and per-status labels keyed off the code, the honesty-sensitive manual-vs-auto copy, the journey group labels | +| `search` | Discovery C1/C2/C3 — filters, the same-gender facet, all four result states, card and profile labels | +| `booking` | The booking-request flow **and** post-payment engagement — `bstatus_*`, `sstatus_*`, EVV banners, care-instruction labels, `money_*`, the bookings list | +| `payment` | Checkout and invoice — the breakdown rows, the **verbatim escrow copy** (`escrow_notice`), the card-flow states, the confirmation and invoice screens, `pstatus_*`, مودیان states | +| `refunds` | Cancellation and refund status — policy tiers keyed off `cancellation_policy_code`, the refund-vs-fee breakdown, `step_*`/`rstatus_*`, per-channel ETA copy | +| `bnpl` | Installment checkout D1–D5 — the ownership-truth copy, provider names keyed off `provider_{code}`, the plan/eligibility/schedule labels, the wallet due list | +| `payouts` | Nurse earnings and payout history — the balance header incl. the negative "owed back" state, the four buckets, `estate_*`/`pstatus_*`/`bstatus_*`, the cadence explainer | +| `reviews` | The review form, tag labels keyed off the code, moderation-status labels, the aggregate count | +| `tickets` | The messaging surface — inbox, thread, composer, author-role labels, and both emergency surfaces | +| `notifications` | The notification center and bell. Row `title`/`body` are **server-rendered copy, not keys** | +| `admin` | Every backoffice console, the Persian legal terms (پروانه تأسیس / مسئول فنی / نماد اعتماد الکترونیکی), and the enum-label prefixes | +| `partner` | The partner-centre portal — a separate authz scope | + +### Enum labels + +**A label is keyed off the stable code, never derived from the wire value.** `status_pending_moderation`, +`pstatus_failed`, `tag_punctual` — the code is the key suffix, and the vocabulary of codes is a client +constant, not something read off a response. Shared enum labels are **reused** across namespaces, never +re-keyed. + +This is what lets the server rename a display string without a client deploy, and lets the client show a +Persian label for a code it has never seen without falling back to raw English. + +--- + +## 3. Numbers and interpolation + +Persian digits (۰۱۲۳۴۵۶۷۸۹) everywhere on `/fa` — both hard-coded literals (`"۲۴ ساعت"`) and interpolated +numbers. + +| Case | Do | +| --- | --- | +| A number inside an ICU message | Use the `number` sub-format — `{count, number}` — or a plain `#` inside a `plural` block. next-intl formats both through the active locale, so `fa` gets Persian digits automatically | +| A raw number built into a string in code | Route it through `formatNumber` (`@/utils`). **Never template a raw JS number into Persian text** | +| A date | `formatShamsiDate` / `formatShamsiDateTime` (`@/utils`) — UTC ISO in, Persian calendar out | +| Money | `<Money>` or the `@/utils` money helpers. Never a float, never a raw digit run | + +### Policy numbers are never hard-coded into a string + +Legally or financially sensitive numbers that the admin config panel can change — the dispute-window hours, +the cancellation lead-time hours, the refund ETA day range — **never** go into a message string. The message +key takes a parameter (`{hours}`, `{minDays}`/`{maxDays}`) and the call site interpolates from +`src/constants/policy.ts`, which single-sources them. + +A config edit must never again silently make the UI copy lie. (These are real server config with no public +read yet; the constants file is the interim single source.) + +--- + +## 4. Persian style — binding for `fa.json` + +`npm run lint:copy` (`client/scripts/check-copy.mjs`, part of `npm run check`) greps every leaf string in +`fa.json` for the banned variants marked **linted** below, on every run. A regression fails the gate +immediately rather than needing to be re-discovered by a human. + +`en.json` is hand-written and reviewed for idiom, not linted. + +### 4.1 Brand name — **linted** + +**«بالین‌یار» — ZWNJ (`‌`) between بالین and یار, always.** Never a plain space («بالین یار»). The brand name +appears in money and trust copy — login, escrow, refunds — as often as anywhere else, and that is the worst +place to be inconsistent. + +### 4.2 تأیید — hamza, always — **linted** + +Write **تأیید** and its derived forms — **تأییدشده**, **تأییدیه**, **تأیید کردن** — every time. Never +تایید/تاییدشده/تاییدیه. This is the single most frequent word in a verification product: one spelling, no +exceptions, in every namespace. + +### 4.3 جستجو — one form — **linted** + +Standard form: **جستجو** (no ZWNJ, one word). Not «جست‌وجو», not «جست و جو». Applies to the noun and any +compound (`در جستجو`, `نتایج جستجو`). + +### 4.4 ZWNJ (نیم‌فاصله) + +Use ZWNJ — never a plain space, never nothing — in: + +- **می‌ + verb stem** — می‌شود، می‌کند، می‌پردازید، می‌ماند. Never میشود or می شود. +- **Plural ها** — keep the ZWNJ before ها when the base ends in a consonant that would otherwise misread + (`شب‌ها`, not `شبها`); a word already ending in a vowel or silent-h takes it too (`بچه‌ها`). +- **Compound past-participle adjectives** — تأییدشده، لغوشده، ردشده، منتشرشده، پرداخت‌شده. One ZWNJ-joined + word: not two spaced words («تایید شده»), not fused with no separator. +- The brand name (§4.1). + +### 4.5 Two other linted rules + +- **The archaic passive می‌گردد is banned** — use می‌شود. (The check anchors on a leading space so the + entirely legitimate «برمی‌گردد», which fuses «بر» directly on, is never flagged.) +- **«بازی » is banned** — it catches an indefinite «ی» misattached to the wrong word. + +### 4.6 Punctuation and quotes + +- Persian prose uses **«…» guillemets** for quoted terms and labels. Prefer Persian «،» / «؛» inside new + multi-clause translated sentences; most existing short labels use plain Latin `,`/`;` — **don't retrofit + those**, just don't add more. +- English uses **straight** apostrophes (`don't`, `couldn't`) throughout — never curly (`’`). Don't + reintroduce curly quotes when editing English copy. + +### 4.7 Domain glossary + +| Term | Means | Never | +| --- | --- | --- | +| **بیمار** | the care recipient | «مددجو» — it appeared once and was dropped for the 99%-majority form | +| **پرستار** | the caregiver | «مراقب» as a noun for the person. «مراقب» survives only as an adjective/role qualifier — "جنسیت مراقب" = the caregiver's gender | +| **رزرو** | a confirmed, **paid** booking | calling a `booking_request` «رزرو» before it converts | +| **درخواست رزرو** | a pre-payment request | conflating it with رزرو | +| **ویزیت** | one scheduled visit/session within a booking | — | +| **شبا** | IBAN | «شماره شبا» for the field label, «شبا» alone elsewhere | + +The رزرو / درخواست رزرو split mirrors the code's `bookings` vs `bookingRequests` and the server's +`Bookings` vs `Booking` areas. It is a money boundary, not a synonym. + +### 4.8 Shell naming — one metaphor per audience class + +- **End-user shells** (family, nurse — the apps people book or work through day to day) → **«اپلیکیشن»**: + «اپلیکیشن خانواده», «اپلیکیشن پرستار». +- **Back-office shells** (admin, partner-centre) → **«کنسول»**: «کنسول مدیریت», «کنسول همکار». +- Never «نما» (view) or «پرتال» (portal) for a whole shell name. + +(`booking.evv_nurse_view` "نمای پرستار" is a different thing — a chip labelling *whose perspective* a shared +screen is rendered from, not a shell name. It correctly keeps «نما» in that narrower sense.) + +### 4.9 Verification pipeline vs. the identity step + +**«تأیید صلاحیت»** names the whole 7-step nurse trust pipeline — the nav entry, the hub title, its +start/progress/approved states, the admin queue. **«احراز هویت»** stays the name of the *one* KYC step inside +it (national ID + civil registry + liveness selfie), on both the nurse side +(`verification.step_identity_kyc`) and the admin side (`admin.step_identity_kyc`). + +A nurse who passed the KYC step but still saw a pipeline titled «احراز هویت» marked incomplete in the nav +read that as a contradiction. They no longer share a name — keep it that way. + +### 4.10 Status vocabulary — one nurse-facing form, one admin-facing form + +For "this step/item was rejected" states that appear on **both** a nurse-facing and an admin-facing screen +for the *same underlying concept* (a verification step's outcome): + +| Audience | Form | Matches its siblings | +| --- | --- | --- | +| Nurse-facing | **«رد شد»** (`verification.status_failed`) | the declarative sentence register of `status_passed` («تأییدشده») / `status_in_review` («در حال بررسی») | +| Admin-facing | **«ردشده»** (`admin.step_failed`, `agg_rejected`, `rstatus_rejected`, `mstatus_rejected`) | the admin namespace's compound-adjective pattern — `step_passed`/`agg_approved`/`center_state_verified` | + +This does **not** extend to money-failure vocabulary. `payouts.pstatus_failed`, `refunds.rstatus_failed` and +`admin.batch_status_failed` all legitimately use «ناموفق»: a transfer *failing* is a different concept from a +document being *rejected*, and conflating them would blur a real distinction. + +### 4.11 Register + +Formal شما throughout, with polite imperatives (کنید) for actions and instructions. Never informal تو or bare +imperative stems (نکن, برو). + +--- + +## 5. Reference data with two names + +Server reference data that carries `name_fa`/`name_en` returns **both**, and the client picks by locale. +Don't ask the server for a locale-specific name, and don't translate a data row into a message key — +categories, provinces and cities are rows an admin can add, not vocabulary. diff --git a/docs/rules/client/services.md b/docs/rules/client/services.md new file mode 100644 index 0000000..d7c4d9c --- /dev/null +++ b/docs/rules/client/services.md @@ -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. diff --git a/docs/rules/client/structure.md b/docs/rules/client/structure.md new file mode 100644 index 0000000..68612e3 --- /dev/null +++ b/docs/rules/client/structure.md @@ -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. diff --git a/docs/rules/client/testing.md b/docs/rules/client/testing.md new file mode 100644 index 0000000..d4c88a1 --- /dev/null +++ b/docs/rules/client/testing.md @@ -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. diff --git a/docs/rules/client/theme.md b/docs/rules/client/theme.md new file mode 100644 index 0000000..ec46d31 --- /dev/null +++ b/docs/rules/client/theme.md @@ -0,0 +1,268 @@ +# Client theme + +Colors, tokens, dark mode, direction, fonts, motion. The brand's *look* is the +[frontend-designer](../../../.claude/skills/frontend-designer/SKILL.md) skill's job; this file is the +mechanism it runs on. + +> Last verified: 2026-07-30 against commit `d3ec723`. + +--- + +## 1. Brand identity + +Balinyaar is a trust-first home-nursing marketplace in Iran. The tone is calm, warm, +clinical-but-human — not a cold medical dashboard. The default audience is Persian (RTL); English is +secondary. + +| Role | Light | Dark | +| --- | --- | --- | +| Primary — deep teal | `#1d4a40` | `#6fc0ac` (lifted, readable on dark) | +| Secondary — terracotta | `#d98c6a` | `#e6a98a` | +| Page surface | `#faf9f5` cream | `#0f1c19` deep teal | +| Paper / card | `#ffffff` | `#16302a` teal surface | +| Text primary | `#1b2521` ink | `#f3efe9` cream | + +**Teal ground, cream glyph, terracotta accent** is the whole identity. Use terracotta sparingly as the +single accent; teal carries everything else. + +The logo mark is a deep-teal rounded square, a cream lowercase "b" built from a stem plus a ring bowl, and +one terracotta dot. Two SVGs under `components/common/AppIcon/icons/`: `LogoMark.tsx` (monochrome +`currentColor` glyph only, registered as `ICONS.logo`) and `LogoLockup.tsx` (full colour, token-driven so +it tracks the scheme, used by `BrandMark`). **The wordmark beside it stays a real, translated +`<Typography>`** — never bake locale text into an SVG. The favicon and `public/img/favicon/*.png` are +rasterized from the same construction with fixed brand hex, which is the one place a literal hex is +correct; regenerate with a `sharp`-based script rather than hand-editing the binaries. + +--- + +## 2. Two mirrored color homes + +Colors exist in two places that must stay in sync. Pick the right one. + +| Home | File | Reach it via | Use for | +| --- | --- | --- | --- | +| **MUI palette** | `theme/colors.ts` (`BRAND`, `LIGHT_PALETTE`, `DARK_PALETTE`) | `color="primary"`, `sx={{ color: 'text.secondary', bgcolor: 'background.paper' }}` | **The default** for styling a MUI component | +| **`--bal-*` CSS variables** | `theme/tokens.css`, under `[data-mui-color-scheme='light'\|'dark']` | `var(--bal-primary)` | Custom CSS outside MUI's palette, and every semantic feedback color | + +- Styling a MUI component → palette keys. +- Need success / error / warning / info → **`--bal-*`, not MUI's defaults.** The MUI palette defines no + semantic colors, and these tokens are brand-harmonized. +- Need a custom color in raw CSS → add a `--bal-*` token **in both scheme blocks**, then `var(--…)`. +- **Never hard-code a hex or rgb** in `sx`, `styled`, or a component. +- Adding or changing a color means editing `tokens.css` **and** `colors.ts` together. Both file headers + call out the sync requirement. + +### The token catalogue + +Every token is defined under both `[data-mui-color-scheme]` blocks unless noted. + +| Group | Tokens | Notes | +| --- | --- | --- | +| Brand | `--bal-primary`, `-light`, `-dark`, `-contrast`, `-soft`; same five for `--bal-secondary` | | +| Surfaces | `--bal-bg-default`, `--bal-bg-paper`, `--bal-frame-canvas` | `frame-canvas` is the backdrop `AppFrame` paints *outside* the phone-width column — **never a surface a component draws on** | +| Text | `--bal-text-primary`, `--bal-text-secondary`, `--bal-divider` | | +| Semantic | `--bal-{success,error,warning,info}` + each `-contrast` + each `-soft` | `-contrast` is the text color on that fill; `-soft` is the tinted background variant | +| Elevation | `--bal-shadow-1/2/3` | Teal-tinted (black-teal in dark). They back `theme.ts`'s `shadows` array, so every MUI elevation resolves through them — never MUI's grey stack | +| Radius | `--bal-radius-sm` 6px (controls), `-md` 8px (cards/paper, `= theme.shape.borderRadius`), `-lg` 12px (dialogs), `-pill` 999px | Reference the token, **never a numeric `sx={{ borderRadius: n }}`** — that multiplies the shape unit, which is how the login card once became a 30px pill. `MuiPaper` pins the `md` step so a Paper can't drift past it. `-pill` is for shapes that genuinely *are* pills (the floating nav, a segmented control's active chip), never a card | +| Motion | `--bal-motion-fast/base/slow` (120/200/300ms), `--bal-easing-standard` | `theme.ts` points `MuiDialog`/`MuiDrawer`/`MuiPopover`/`MuiMenu`'s `defaultProps.transitionDuration` at the same numbers, in one place, instead of MUI's per-variant defaults | +| Focus | `--bal-focus-ring` | The 2px ring `MuiCssBaseline`'s global `:focus-visible` override uses. **Don't hand-roll a focus style** — it is already uniform everywhere | +| Rating | `--bal-rating`, `--bal-rating-empty` | `RatingInput` uses these, **not** `--bal-warning` | +| Trust | `--bal-trust`, `--bal-trust-soft` | A distinct identity for verified marks — not primary, not success. `TrustBadge` and any future verification UI | +| Money | `--bal-money-emphasis` | AA-contrast-safe emphasized money text. `--bal-secondary` (terracotta) **fails AA at small sizes on light backgrounds** — never use it for money text | +| Avatar | `--bal-avatar-1..6` + each `-contrast` | Six warm pairs `InitialsAvatar` picks from by a deterministic name hash | +| Map | `--bal-pin-shadow` | The address-picker pin | + +`--bal-chrome-top` / `--bal-chrome-bottom` are **not** in `tokens.css` — `AppFrame` publishes them at +runtime on its scroll container. See [components.md](components.md). + +--- + +## 3. Dark mode, and the no-flash boot + +The mechanism is **pure CSS. There is no boot script**, no inline `<script>`, and no +`Storage.prototype` patching — matching how every other color decision in this app is made. + +**Returning visitor (cookie present)** + +1. `getThemeMode()` (`lib/cookies/server.ts`) reads the `'color-scheme'` cookie → `{ colorScheme, defaultMode: colorScheme }`. +2. The root layout sets `data-mui-color-scheme={colorScheme}` on `<html>`, server-side. +3. `tokens.css`'s explicit `[data-mui-color-scheme='light'|'dark']` blocks match immediately — correct on + the very first paint, before any JS runs. + +**First-ever visitor (no cookie)** + +1. `getThemeMode()` returns `{ colorScheme: undefined, defaultMode: 'system' }`. +2. The root layout renders `<html>` **without** the attribute at all (React omits `undefined`). +3. `tokens.css` has a `@media (prefers-color-scheme: dark)` block scoped to + `:root:not([data-mui-color-scheme])` — it applies only while the attribute is absent, and paints the + OS-preferred scheme immediately with zero JS. +4. Once React hydrates, `<MuiThemeProvider defaultMode="system">` resolves the *same* media query and + stamps the attribute itself. The painted values already match, so nothing visibly flips. +5. `ColorSchemeCookieSync` (inside `ThemeProvider.tsx`) writes the cookie from + `useColorScheme().colorScheme` in an effect, so the next visit is a "returning visitor" — even before + the user ever touches the control. + +### The known gap, by design + +This covers the dominant visual surface — every `--bal-*` token — because that is what the media-query +fallback drives. MUI's own generated `--mui-palette-*` variables (consumed by a bare `color="primary"` +fill: a contained Button, the default `MuiTabs` indicator) do **not** get the same free fallback: MUI's +`colorSchemeSelector` supports attribute-based *or* `'media'`-based generation, not both at once. So on a +cookie-less first visit with OS dark on, a raw MUI-primary fill can very briefly show the light value +until hydration. It self-corrects in the same frame, and `disableTransitionOnChange` means it snaps rather +than animating. + +**Prefer sourcing colors from `var(--bal-*)` over `theme.vars.palette.*` in new `styleOverrides`** — most +of `theme.ts`'s `components` block already does — to keep this gap as small as possible. + +### MUI v9 traps in this area + +**`colorSchemeSelector` must be the explicit attribute name.** + +```ts +// theme.ts +cssVariables: { + colorSchemeSelector: 'data-mui-color-scheme', // CORRECT + // colorSchemeSelector: 'data', // WRONG +} +``` + +The shorthand `'data'` generates `[data-%s]` → boolean `data-dark=""` / `data-light=""` attributes. Our +`tokens.css` selects on `[data-mui-color-scheme="dark"]`, which never matches a boolean attribute, so the +whole token layer silently stops switching. + +**Never use MUI's `InitColorSchemeScript`.** It reads localStorage, which diverges from our cookie +(especially in `system` mode), and it is a script — this app's no-flash boot is CSS-only. Don't add *any* +pre-paint color-scheme script; if a new token needs the same first-visit treatment, extend the `tokens.css` +media-query fallback instead. + +**Never use `storageWindow={null}`.** In MUI v9's `localStorageManager` the check is +`if (!storageWindow && typeof window !== 'undefined')` — `null` is falsy, so it silently overrides to +`window`. The prop is a no-op in browsers. + +**MUI v9's localStorage key defaults differ from v5/v6** — mode key `'mode'` (was `'mui-mode'`), color +scheme key `'color-scheme'` (was `'mui-color-scheme'`), HTML attribute `'data-color-scheme'` (was +`'data-mui-color-scheme'`). We override the attribute via `colorSchemeSelector`; the cookie is ours and is +named by `COOKIE_NAMES.COLOR_SCHEME`. + +### `mode` vs `colorScheme` + +Use **`colorScheme`** for an "is dark active" check. `mode` can be `'system'` even when dark is active. + +The one exception is the control itself, which must read `mode`: that is the user's *choice*, while +`colorScheme` is only the resolved result. `mode` is `undefined` until MUI mounts, so default it +(`mode ?? 'system'`) rather than rendering an unselected control — server, first client render, and +pre-mount state then agree, so there is no hydration mismatch and no flash of "nothing selected". + +### The one appearance control + +`components/settings/ThemeModeSetting.tsx` is the **only** component that subscribes to `useColorScheme()` +and the app's only appearance control. It lives in each actor's settings hub (`/nurse/more`, +`/admin/system`, `/partner/more`, the customer profile hub) **and nowhere else** — the old top-bar toggle +spent a permanent slot of chrome in three shells on a preference set once. + +It is a **three-way segmented control (light / dark / system), never a boolean switch.** `system` is the +app's real default on a cookie-less first visit, so an on/off control cannot represent the current state +and would silently misreport it. + +The write path: `setMode('dark')` → `ColorSchemeCookieSync`'s effect writes the `'color-scheme'` cookie → +MUI sets `data-mui-color-scheme` on `<html>` → CSS variables resolve → the browser repaints. No React +re-render above the control. + +### Pre-built themes + +`APP_THEME_LTR` and `APP_THEME_RTL` are created once at module load. **Never call `createTheme()` inside a +component or hook** — pass the appropriate pre-built theme to `MuiThemeProvider`. + +--- + +## 4. Direction + +`getDirection(locale)` (`theme/direction.ts`) returns `'rtl'` for `fa`, `ar`, `he`, `ur`; `'ltr'` for +everything else. `ThemeProvider` takes a `dir` prop and selects the matching pre-built theme; the RTL +Emotion cache uses `stylis-plugin-rtl` to mirror all generated CSS. + +The root layout sets `dir` on `<html>` and passes it to `ThemeProvider`. Because that layout is keyed on +the `[locale]` URL param, a locale change re-renders it with a fresh `dir` on both hard and soft +navigation, with no client state. Do **not** move the `<html dir>` render above `[locale]` — see +[structure.md](structure.md) §2. + +**RTL correctness is a rule, not a nicety.** Never use directional hard-coding for layout flow — +`marginLeft`, `left:`, `textAlign: 'left'`. Use logical or MUI-flipped properties: `ml` (MUI flips it), +`marginInlineStart`, `insetInline`, `start`/`end`. Verify the layout visually at `/fa`, then `/en`. + +Bidi text needs explicit isolation: a Latin-digit code, an IBAN, or a date·time range inside Persian prose +goes in a `dir="ltr"` span. `SessionCard` and `BookingRequestSummaryCard` are the references. + +--- + +## 5. Fonts + +Loaded **per locale**, so the Persian face is never shipped to English pages. + +| Locale | Font | CSS variable | Source | +| --- | --- | --- | --- | +| `fa` (RTL) | **Mikhak** | `--font-mikhak` | `next/font/local` — woff2 in `src/app/fonts/` | +| `en` (LTR) | **Space Grotesk** | `--font-space-grotesk` | `next/font/google` — self-hosted at build time | + +Rules: + +- Both are declared with `preload: false`, and each `.variable` class is attached to `<html>` **only for + its own locale** — never both, never neither. A `next/font` loader called unconditionally would preload + on every route; `preload: false` ensures the file downloads only when its locale actually renders. +- Mikhak's woff2 files live in `src/app/fonts/`, **not** `public/` — `next/font/local` resolves paths + relative to the calling file at build time. Space Grotesk needs no local files. +- **Never load a font inside a component or page.** All font loading lives in + `src/app/[locale]/layout.tsx`. +- To add a local font: add the woff2 files, declare via `localFont` in the root layout, attach its + `.variable` class conditionally on the matching locale, and update the `BRAND_FONT_VARIABLE_*` constants + in `typography.ts`. + +### Typography + +`TYPOGRAPHY_LTR` (Space Grotesk headings, system-stack body) and `TYPOGRAPHY_RTL` (Mikhak for *all* text, +for full Persian glyph coverage) share one size/line-height scale (`SIZE_SCALE`), wrapped in +`responsiveFontSizes()` in `theme.ts` for per-breakpoint heading scaling. **There is no `TYPOGRAPHY` +alias** — import `TYPOGRAPHY_LTR`/`TYPOGRAPHY_RTL` explicitly, and not into components: use +`<Typography variant=…>` and let the theme apply the direction-aware family. + +**Never write `fontWeight: 600`.** Neither face loads a 600 weight, so a requested 600 silently renders +full Bold. The system is **700** for headings (`h1`–`h6`), buttons and strong emphasis, **500** for lighter +in-text emphasis (subtitles, row labels, chip text), **400** body. It is enforced globally in +`typography.ts`; match it in any new `sx`. + +Buttons are `textTransform: 'none'` at weight 700, set globally — never re-uppercase button text. + +The Persian scale sets `letterSpacing: 0` on **every** variant (Persian is a joined script; tracking breaks +glyph connections), body line-height ≥1.7, heading line-height ~1.4–1.5 for ascender/descender room. Don't +hand-roll per-breakpoint `fontSize` overrides — `responsiveFontSizes()` already wraps both themes. + +--- + +## 6. Motion and the reduced-motion gate + +`RouteFadeIn` (`components/common/RouteFadeIn/`) is the one route-content fade/slide primitive. It wraps +`{children}`, keyed on the locale-stripped pathname so it remounts (and replays the CSS `bal-fade-in` +keyframe from `globals.css`) on navigation but never on an in-place re-render. It is mounted inside the +`ErrorBoundary` in **all five shells**, so a new page gets the motion for free with no per-page wiring. + +**`prefers-reduced-motion: reduce` has exactly one gate**, in `src/app/globals.css`: a universal +`*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; … }` +media-query block. + +This is deliberately a global CSS reset rather than token-only zeroing. `tokens.css` *also* zeroes the +duration tokens, but that alone would not reach MUI's own JS-driven Dialog / Drawer / Menu / Collapse +transitions, which don't read CSS custom properties. **Never add a second, component-local reduced-motion +branch** — extend this one rule if a new motion primitive needs the same treatment. + +--- + +## 7. Toast colors + +`NotistackProvider` maps every notistack variant to a `styled(MaterialDesignContent)` whose +`backgroundColor`/`color` come from the `--bal-{success,error,warning,info}` (+ `-contrast`) tokens. Because +those tokens are defined on `<html>`, they cascade into notistack's Portal and switch with the color scheme +automatically. **Never hard-code a toast color** — adjust the tokens. + +Direction is inherited too: the Portal mounts under `<body>` and picks up `dir` from `<html dir>`. Do +**not** pass a `dir` prop to `SnackbarProvider` — it is not a valid prop (TS error) and is unnecessary. diff --git a/docs/rules/documentation.md b/docs/rules/documentation.md new file mode 100644 index 0000000..834c235 --- /dev/null +++ b/docs/rules/documentation.md @@ -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 | **15–25 numbered items** | The weakest items aren't hard rules. Cut them. | +| A `docs/rules/**` reference file | **400 lines** | Split by sub-topic, or you are listing where you should be describing. | +| A `docs/flows/<flow>.md` | **200 lines** | It is covering two journeys. | + +The rule behind the numbers: **loading a rule must not cost 40k tokens.** That is what the old +1,098-line `client/CLAUDE.md` did to every frontend change, and it is why nobody read past the top. + +--- + +## 6. Style + +- **English throughout**, including in files that describe Persian UI copy. Quote the Persian, explain in + English. +- Prose over bullet soup for reasoning; tables for anything with more than three parallel cases. +- Link, don't restate. Two copies of a rule drift; one copy and a link cannot. +- Write the *why* down when it isn't obvious from the rule. A rule whose reason is recorded survives + contact with a case it didn't anticipate; a bare prohibition gets worked around. diff --git a/docs/rules/index.md b/docs/rules/index.md index 679d314..602b47e 100644 --- a/docs/rules/index.md +++ b/docs/rules/index.md @@ -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. diff --git a/docs/rules/server/conventions.md b/docs/rules/server/conventions.md new file mode 100644 index 0000000..d2cb603 --- /dev/null +++ b/docs/rules/server/conventions.md @@ -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. diff --git a/docs/rules/server/cqrs.md b/docs/rules/server/cqrs.md new file mode 100644 index 0000000..aa5ae8a --- /dev/null +++ b/docs/rules/server/cqrs.md @@ -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. diff --git a/docs/rules/server/identity.md b/docs/rules/server/identity.md new file mode 100644 index 0000000..73a4fcb --- /dev/null +++ b/docs/rules/server/identity.md @@ -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. diff --git a/docs/rules/server/money.md b/docs/rules/server/money.md new file mode 100644 index 0000000..3c462a4 --- /dev/null +++ b/docs/rules/server/money.md @@ -0,0 +1,244 @@ +# Server money path + +IRR integers, the append-only ledger, idempotency, and the invariants of refunds, BNPL, payouts and invoices. + +> Last verified: 2026-07-30 against commit `d3ec723`. + +Read this before touching anything under `Features/{Payments,Refunds,Invoices,Bnpl,Payouts}` or the +`payments` / `payouts` schemas. Every rule here is enforced in code **and** by a database constraint, and the +constraint is the authority. + +--- + +## 1. Money is IRR `BIGINT`, integer-only + +**Every monetary value is IRR Rials stored as `long` / `BIGINT`.** There is **no float or decimal path on +money** — not in entities, not in DTOs, not in the API, not in arithmetic. If a money value object is ever +introduced it must be integer-only. + +- **Toman is display-only**, and converts to/from Rials **only inside a provider adapter at its boundary** — + never in domain or shared code. +- On the wire, money is a **digit string** (IRR aggregates exceed JS's safe integer range). +- Currency is normalized to IRR **at the provider boundary only**, via `ICurrencyNormalizer`. + +### The three booking amounts always reconcile + +``` +gross_price_irr = balinyaar_commission_irr + nurse_payout_amount (all ≥ 0) +``` + +This is a **DB CHECK** *and* a handler invariant. Commission is `integer-round(gross × platform_fee_rate)` +with the rate **snapshotted onto the booking**; the payout is *derived*, never free-entered. + +And per session: **`Σ(visit_payout_amount) = nurse_payout_amount` exactly** — an integer split with the +remainder on the last session (`BookingAmounts`). + +### A rate change is never retroactive + +Money-critical constants — commission percentage, VAT rate, deadlines, cancellation tiers — live in +`ops.PlatformConfigs` and are read via `IPlatformConfig.GetConfig<T>`. **Never hardcode one.** + +> **Changing a rate must never retroactively alter an already-computed amount.** The rate is snapshotted at +> compute time. Do not live-re-read a rate for an already-priced row. + +--- + +## 2. The ledger is the source of truth + +`payments.LedgerEntries` is **append-only**: it implements `IEntity` only, with **no `ITimeModification`** (so +the audit interceptor never stamps it) and **no soft delete**. There is no update or delete path. + +Every posting group is **balanced** — Σdebit = Σcredit per `transaction_group_id` — and built through +**`LedgerPosting`**, which throws if the frozen amounts don't reconcile. Never hand-write a leg. + +| Posting group | Legs | +| --- | --- | +| `CardCapture` | DEBIT `escrow_held` gross = CREDIT `platform_revenue` commission + `nurse_payable` payout | +| `BnplSettle` | The card-capture legs **plus** DEBIT `bnpl_fee_expense` / CREDIT `escrow_held` for the provider commission | +| `RefundReversalPrePayout` | DEBIT `nurse_payable` — a clean reversal | +| `ClawbackReversalPostPayout` | DEBIT `nurse_clawback_receivable` — the nurse was already paid | +| `RefundPayableClearing` | Posted only once the customer cash-back confirms | +| `ClawbackWriteOff` | An admin write-off | +| `NursePayout` | DEBIT `nurse_payable` / CREDIT `escrow_held` for the paid net | +| `ClawbackRecovery` | DEBIT `nurse_payable` / CREDIT `nurse_clawback_receivable` | + +**Escrow IS the ledger.** `GetNursePayableBalance` is the **signed sum** over `nurse_payable` legs — never a +stored column. There is no `payout_released` boolean anywhere: paid-ness is *derived* from a +`nurse_payout_booking_links` row plus the ledger. + +The lawful split is **تسهیم via `ISettlementSplitProvider`** to registered IBANs. **The platform never moves +money itself.** + +--- + +## 3. Idempotency + +Three patterns, all mandatory on this path. + +**Upsert the webhook event first.** `HandlePaymentWebhook` upserts on `(provider_code, external_event_id)` and +**no-ops on a duplicate** before doing anything else. On a *new* success event it **re-verifies server-side** +(`IPaymentProvider.VerifyAsync`) — never trusting the payload — then dispatches +`ConfirmPaymentAndPostLedger`, all under `IDistributedLock("booking-request:{id}:payment")`. +**A unique-violation on confirm is an idempotent no-op success, not an error.** + +**Claim first, execute second.** Persist the state claim *before* the external call. The refund row is +persisted (approved) before the channel call for exactly this reason — it is the crash-window fix, and it +matches the webhook handler's shape. A crash between claim and execute leaves a recoverable record; a crash +between execute and claim leaves money moved with nothing recording it. + +**The DB constraint is the authoritative backstop** behind every friendly pre-check. The two filtered uniques +on `payment_transactions` — `UNIQUE(gateway_reference_code) WHERE NOT NULL` and +`UNIQUE(booking_id) WHERE status='succeeded'` — are the anti-double-capture guard, not the handler's `if`. + +A **forward-only status machine** is the idempotency spine of each money entity: a replayed transition that +would re-drive a completed edge is an idempotent no-op. See [persistence.md](persistence.md) §5. + +--- + +## 4. Capture and conversion + +- A `bookings` row exists **only** when the nurse accepted **and** payment was captured. So a payment is + initiated against the `accepted_awaiting_payment` **request**, and `payment_transactions.booking_id` is + **nullable**, bound only when the confirm creates or loads the booking. +- A booking request carries **no money and no `bookings` row**. Accept only opens the payment window. +- Conversion goes through the shared **`BookingFactory` / `Features/Bookings/BookingConversion`** helper. The + card confirm and the BNPL settle both call it rather than re-implementing the split. +- `IPaymentCaptureSimulator` is **out of the production registration** — production gets the fail-closed + `DisabledPaymentCaptureSimulator`, and the `bookings/convert` path is a Dev/Testing affordance. Production + converts through the webhook confirm. + +--- + +## 5. Refunds and clawbacks + +A refund **decomposes across both fee legs and reverses the ledger.** `CreateRefundCommand` runs the whole +money path under `lock(booking:{id}:refund)`: it reads the booking's frozen split, the cancellation snapshot +and the captured transaction, splits `amount = platform_fee_refunded_irr + nurse_payout_refunded_irr` +**pro-rata at the resolved percentage**, enforces **`Σ refunded ≤ captured`** as a handler backstop, executes +the channel behind its seam, and posts the balanced reversal through `LedgerPosting`. + +The channel-execution and ledger steps are cohesive **private** steps inside the handler, so they stay atomic. + +### The pre-payout / post-payout fork + +`INursePayoutStatus` answers *"was the nurse already paid?"* + +| Answer | What the reversal debits | Plus | +| --- | --- | --- | +| Not yet paid | `nurse_payable` — a clean reversal | — | +| Already paid | `nurse_clawback_receivable` | Opens a `pending` `nurse_clawbacks` row **and** raises a `nurse_clawback` support alert | + +The fork exists because **an Iranian IBAN transfer is irreversible.** Once money has left, the platform holds a +receivable, not a reversal. + +The authoritative implementation is `NursePayoutLinkStatusService` — a booking is paid iff it is linked to a +`paid` payout. + +### Channel parity + +`psp_card` and `bnpl_revert` post the **same** reversal legs. Only three things differ: + +| | `psp_card` | `bnpl_revert` | +| --- | --- | --- | +| Initial status | immediate `succeeded` | `processing` | +| Clearing | posts now | deferred to reconciliation | +| Customer ETA | immediate | `expected_customer_refund_eta` ≈ now + config **business** days (~7–10) | + +The `refund_payable ↔ escrow_held` clearing posts **only once the customer cash-back confirms** — reached by +`ConfirmRefundSettlementCommand` (admin `POST admin_refunds/{id}/confirm_settlement`, or the BNPL cash-back +callback branch), which transitions `processing → succeeded`, stamps the settled instant, and posts +`RefundPayableClearing` in the same commit, idempotently under the refund lock. +`MarkRefundSettlementFailedCommand` is the counterpart. + +The canonical wire code for the manual channel is **`manual`** (the data model calls it `manual_bank`). + +**Clawback recovery is the payout engine's job** (§7), not the refund's. A refund only opens the receivable and +supports an admin `write_off`. + +`refunds.ticket_id` is always non-null — `CreateRefundCommand` auto-opens a `category=refund` ticket when the +caller supplies none. + +--- + +## 6. BNPL — provider-financed installments + +**In our books, a BNPL order is a card payment that lands net-of-fee.** There is no customer-installment +tracking on our side: the provider owns the schedule and **100% of the default risk**. + +- `BnplTransactions` is **1:1 with its `payment_transaction`** (`UNIQUE(payment_transaction_id)`). +- The forward-only machine is `eligible → token_issued → verified → settled → reverted/cancelled/failed` + (`BnplTransitions`), mutated only through the entity's `mark-*` methods. +- **Settle** posts the net-of-fee group (§2) so escrow reflects the **net** cash + (`settled_amount_irr = order − commission`), and confirms the parent `payment_transaction` — which triggers + the booking conversion — exactly like a card capture. +- **The nurse's payout is invariant to payment method.** `nurse_payable` comes from the booking split + (`gross − commission`), **never** from `settled_amount_irr`. **The BNPL commission is a platform expense.** +- **`settled_at` is per-transaction and nullable** — never assume it is instant. The commission is read from + the **actual settlement**, never hardcoded. +- **Revert reuses the refund path** with `refund_channel='bnpl_revert'`. Money flows + customer ↔ provider ↔ Balinyaar only. +- `IBnplProvider` is selected per `provider_code` by `IBnplProviderResolver`. **`balinyaar` is the in-house + provider** and resolves to the net-of-fee model with no external API. +- `bnpl_settlement_entries` (tranched settlement) is **deferred — modelled but not built.** Do not create it. + +--- + +## 7. Weekly payouts + +- **Eligibility ≠ completed.** A booking enters a batch only when `status='completed'` **AND** + `dispute_window_ends_at < now` **AND** it has no active refund **AND** it isn't already in a link row. + `SetDisputeWindow` is the only eligibility trigger: + `dispute_window_ends_at = completed_at + config(dispute_window_hours, 72)`. +- **One payout per booking, forever.** `nurse_payout_booking_links.booking_id` is an **unconditional** UNIQUE — + *not* filtered on soft-delete. The "not already linked" filter is the fast first line; the UNIQUE is the + backstop. +- **The payout drains `nurse_payable`.** A netted clawback posts `ClawbackRecovery` and marks the + `nurse_clawbacks` row `recovered` (`recovered_in_payout_id` + `resolved_at`). **Netting recovers WHOLE + pending clawbacks up to earnings** — never a negative net, never a partial single-clawback recovery. +- **`net = gross − clawback`** is a DB CHECK on `NursePayouts`. `iban_snapshot` is **encrypted** and + `[AuditRedacted]`, frozen from the verified primary account. +- **Holiday-aware.** `period_end` and `processing_date` shift off `is_bank_closed` days via + `IHolidayCalendar`; a retry **refuses on a bank-closed day**. +- **First-payout gate.** Only an account with `is_primary=1 AND is_verified=1 AND matched_national_id=1` is + paid. A nurse without one is **skipped with a recorded reason**, never silently. +- **A retried process never double-sends an irreversible transfer**: the forward-only `PayoutStatus` machine, + the ledger-exists guard, and a batch idempotency key together. +- `IBankTransferProvider` is the PAYA/SATNA rail; PAYA vs SATNA is chosen by the `payout_satna_threshold_irr` + config. The real Jibit adapter is **async**: it accepts as `submitted`, and the HMAC-verified callback + `POST webhooks/payouts/{provider}` → `ReconcilePayoutBatchCommand` flips `submitted → paid/failed`. +- The BNPL `settled_at` guard is the default-off `require_bnpl_settlement_for_payout` flag. + +### Money movement stays human-approved + +The `weekly_payout_generation` job schedules **generation only** — a `draft` batch, recorded system-initiated +(`NursePayoutBatch.InitiatedByAdminId` nullable = "no human initiator"). **The irreversible `process` step +remains an explicit admin action**, and `AdminPayoutsController` **neutralizes any request-supplied +`SystemInitiated` value** — that flag is scheduler-only. + +--- + +## 8. Invoices + +- **VAT is on the commission line only**: `vat_irr = round(platform_commission_irr × vat_rate)` (config + `vat_rate`, default 0.10; `vat_rate = 0` ⇒ 0). **Never on the nurse payout.** +- **The invoice number is gap-free and sequential**, drawn from the single-row `InvoiceNumberSequences` counter, + locked and committed with the invoice — **portable across SQL Server and SQLite, so no DB sequence.** +- **Idempotent per booking** (`UNIQUE(booking_id)`). +- The issuing entity follows the **merchant-of-record resolver**: booking → nurse → `partner_center_id`, and the + target is the partner centre **only** when it `is_merchant_of_record`, else `platform`. Never a hardcoded + platform. +- `IMoadianClient` submits to سامانه مودیان; the mock leaves `moadian_status = pending` with no reference. A + `MoadianReconciliationJob` walks `pending/submitted → registered` every 6 hours. + +--- + +## 9. Cancellation + +The applicable `cancellation_policies` tier is resolved by **`(actor, lead-time bucket)`**, and its `code` + +`refund_percentage` + the computed refundable amount are **frozen onto the booking**. + +**Only still-`scheduled` sessions are refundable.** A session already started or completed is not, and the +per-session split is what makes a partial refund on a multi-session package correct. + +Cancellation itself **posts no refund ledger** — it snapshots the policy and computes the refundable amount. +The reversal is the refund path's job (§5). diff --git a/docs/rules/server/persistence.md b/docs/rules/server/persistence.md new file mode 100644 index 0000000..128adcd --- /dev/null +++ b/docs/rules/server/persistence.md @@ -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. diff --git a/docs/rules/server/structure.md b/docs/rules/server/structure.md new file mode 100644 index 0000000..fcb2ed0 --- /dev/null +++ b/docs/rules/server/structure.md @@ -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. diff --git a/docs/rules/shared/code-quality.md b/docs/rules/shared/code-quality.md new file mode 100644 index 0000000..88770c0 --- /dev/null +++ b/docs/rules/shared/code-quality.md @@ -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. diff --git a/docs/rules/shared/git-and-gates.md b/docs/rules/shared/git-and-gates.md new file mode 100644 index 0000000..91a9c6b --- /dev/null +++ b/docs/rules/shared/git-and-gates.md @@ -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). diff --git a/docs/rules/shared/naming.md b/docs/rules/shared/naming.md new file mode 100644 index 0000000..4a29624 --- /dev/null +++ b/docs/rules/shared/naming.md @@ -0,0 +1,94 @@ +# Naming + +The names that are load-bearing across both projects, and the ones that are only conventions. + +> Last verified: 2026-07-30 against commit `d3ec723`. + +--- + +## The two names, and why there are two + +The product and brand are **Balinyaar** (Persian: «بالین‌یار»). The server's code namespace is **`Baya*`** +— a legacy prefix from before the name settled. + +| Layer | Name | Rule | +| --- | --- | --- | +| Server namespaces, projects, solution | `Baya.*` / `Baya.sln` | Keep it. **Do not rename without explicit instruction** — it touches 14 `.csproj` files, every namespace, and the solution. | +| Client package | `balinyaar-client` | — | +| Client import alias | `@/*` → `client/src/*` | Defined in `client/tsconfig.json`. Use it; don't write deep relative paths across folders. | +| User-facing copy | «بالین‌یار» / "Balinyaar" | Never `Baya`. See [client/i18n.md](../client/i18n.md) for the ZWNJ rule — it is linted. | + +So `Baya.Application` is correct in C# and wrong in a UI string, and «بالین‌یار» is correct in a UI string +and would be wrong as a namespace. That is the whole split. + +--- + +## Agent-facing docs + +`CLAUDE.md` is the single source of truth at every level of the repo. `AGENTS.md` files exist only so the +convention is discoverable under that name too — they are **thin pointers**, never content. If you find +yourself writing a rule into an `AGENTS.md`, it belongs in the `CLAUDE.md` beside it. + +Three `AGENTS.md` files exist: repo root, `client/`, `server/`. + +--- + +## Server naming + +Full C# conventions in [server/conventions.md](../server/conventions.md). The names that matter beyond +style: + +| Kind | Convention | Example | +| --- | --- | --- | +| Command | `{Verb}{Noun}Command` | `CreateOrderCommand` | +| Query | `{Verb}{Noun}Query` | `GetUserOrdersQuery` | +| Handler | `{RequestName}Handler` | `CreateOrderCommandHandler` | +| Result DTO | `{RequestName}Result` | `CreateOrderCommandResult` | +| Feature folder | `Features/<Area>/{Commands\|Queries}/<VerbNoun>/` | `Features/Payments/Commands/InitiatePayment/` | +| EF config folder | `Persistence/Configuration/<Area>Config/` | `PaymentsConfig/` | +| Seam interface | `I{Capability}` in `Application/Contracts/` | `IBankTransferProvider` | +| Real adapter | `{Vendor}{Capability}` in `Seams/Real/` | `JibitBankTransferProvider` | +| Mock adapter | `Mock{Capability}` in `Seams/` | `MockBankTransferProvider` | + +**Controller and action names become URLs.** All URL segments are `snake_case`, produced automatically +from `[controller]`/`[action]` tokens by `SnakeCaseParameterTransformer`. So `GetBySlug` becomes +`get_by_slug`. If a method name doesn't read cleanly as a URL, **rename the method** — never hardcode the +route string, which bypasses the transformer. + +One type per file, and the file name matches the type name exactly. + +--- + +## Client naming + +| Kind | Convention | Example | +| --- | --- | --- | +| Shared component | `src/components/<Name>/<Name>.tsx` + `index.tsx` barrel | `components/TrustBadge/TrustBadge.tsx` | +| Its test | co-located `<Name>.test.tsx` | `components/TrustBadge/TrustBadge.test.tsx` | +| Page body | `<PageName>Screen.tsx`, co-located with `page.tsx` | `HomeScreen.tsx`, `SearchScreen.tsx` | +| Private (non-route) folder under `app/` | `_`-prefixed | `_chrome/`, `_hub/` | +| Route group (adds no URL segment) | parenthesised | `(customer)`, `(public-routes)` | +| Service domain | `src/services/{domain}/` | `services/bookingRequests/` | +| Query hook | one per file, `hooks/use{Action}.ts` | `hooks/useBookingDetail.ts` | +| Icon registry key | **lowercase**, semantic | `icon="verification"`, not `icon="ShieldCheck"` | +| i18n namespace | a top-level key in both message files | `booking`, `payouts` | +| Constant | `SCREAMING_SNAKE` in a `constants.ts` | `APP_FRAME_MAX_WIDTH` | + +`bookings` and `bookingRequests` are **siblings, not a rename** — a booking request is the money-free +pre-payment intent, a booking exists only after capture. The same distinction is load-bearing in Persian +copy («درخواست رزرو» vs «رزرو») and in the server's singular `Booking` vs plural `Bookings` feature areas. + +--- + +## Directory conventions that carry meaning + +| Path | Meaning | +| --- | --- | +| `client/src/components/common/` | Foundational primitives, imported via `@/components` | +| `client/src/components/<domain>/` | Domain composites (`booking/`, `messaging/`, `admin/`, `geography/`, `notifications/`, `settings/`, `auth/`) | +| `client/src/services/{domain}/apis/` | The seam: `clientApi.ts` (real), `mockApi.ts`, `serverApi.ts`, `index.ts` (selects) | +| `server/src/Core/` | Domain + Application — no outward dependencies | +| `server/src/Infrastructure/` | Implementations of Application contracts | +| `server/src/API/` | Controllers, framework, plugins | +| `dev/` | The finished build-plan chain. History, not a project — nothing to build in it | +| `product/` | Business truth. Markdown canonical, HTML generated | diff --git a/server/.dockerignore b/server/.dockerignore index a2ba7b2..9207e0d 100644 --- a/server/.dockerignore +++ b/server/.dockerignore @@ -12,7 +12,6 @@ Dockerfile .dockerignore docker-compose.yml CLAUDE.md -CONVENTIONS.md AGENTS.md README.md LICENSE.md diff --git a/server/AGENTS.md b/server/AGENTS.md index f6cc971..0ec33e6 100644 --- a/server/AGENTS.md +++ b/server/AGENTS.md @@ -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 diff --git a/server/CLAUDE.md b/server/CLAUDE.md index d167554..fb5f35b 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -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 ~7–10-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 1–5)`, `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/) | diff --git a/server/CONVENTIONS.md b/server/CONVENTIONS.md deleted file mode 100644 index 9feccc0..0000000 --- a/server/CONVENTIONS.md +++ /dev/null @@ -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. diff --git a/server/README.md b/server/README.md index 9ec18e4..5691ca8 100644 --- a/server/README.md +++ b/server/README.md @@ -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 diff --git a/server/docker-compose.yml b/server/docker-compose.yml index 96ba404..0e7bcb0 100644 --- a/server/docker-compose.yml +++ b/server/docker-compose.yml @@ -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.