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