Compare commits

...

23 Commits

Author SHA1 Message Date
hamid 5e4d19ac4f make builds sequential for better performing 2026-08-02 23:54:57 +03:30
hamid 71ca986dcd remove blocker phases after done 2026-08-02 23:42:39 +03:30
hamid 10d160358f blocker phase end 2026-08-02 23:42:12 +03:30
hamid 184b202f00 blocker phase 12 2026-08-02 23:24:28 +03:30
hamid 66a60ce874 3 blocker phases 2026-08-02 23:12:44 +03:30
hamid 90e0cdcc34 phase 7 blockers 2026-08-02 22:30:24 +03:30
hamid 9a55846df3 blocker phase 6, catalog admin 2026-08-02 22:09:45 +03:30
hamid e3c988e961 remove docs 2026-08-02 21:34:02 +03:30
hamid 340012b2f8 blocker phase 5 2026-08-02 21:33:10 +03:30
hamid 08949a24de blocker fix phase 4 2026-08-02 21:27:34 +03:30
hamid dd3e39dec5 blocker phase 3 addresses 2026-08-02 21:15:57 +03:30
hamid 2cd1075286 fix blocker 1 - super admin 2026-08-02 21:03:26 +03:30
hamid 42f38f5a72 blocker fix phases added 2026-08-02 20:37:14 +03:30
hamid fb58ca54e1 create mvp path 2026-08-02 20:01:31 +03:30
hamid 72ab290da1 cleanup phase 7 2026-08-02 18:58:46 +03:30
hamid 51e86a1e5f cleanup phases 6 2026-08-02 18:48:32 +03:30
hamid e2db97392a cleanup phase 5 2026-08-02 18:33:43 +03:30
hamid cd8144e653 cleanup docs phase 4 2026-08-02 18:08:40 +03:30
hamid b876490246 cleanup phases 3 2026-08-02 17:18:36 +03:30
hamid c841bded26 doc clean up phase 2 2026-07-30 12:49:46 +03:30
hamid c889c46110 cleanup phase 1 2026-07-30 02:26:52 +03:30
hamid d3ec723119 cleanup phases 0 done 2026-07-29 23:20:46 +03:30
hamid c99e3f4a6e making the mess clean plans added 2026-07-29 22:46:38 +03:30
427 changed files with 3913 additions and 69778 deletions
+167
View File
@@ -0,0 +1,167 @@
---
name: backend-feature
description: >-
Add a feature to the Balinyaar .NET server — a command, a query, or both — end to end: the Application
slice, the controller, EF configuration/migration if it touches a table, tests, and the doc updates it
triggers. Use when implementing a new endpoint or extending an existing one anywhere under server/.
---
# Balinyaar Backend Feature
The sequence for shipping one CQRS slice, from the Application layer to a green gate.
**Precedence.** This skill is the **procedure** — what order to do things in. The **rules** within each
step live in `docs/rules/server/` and this skill defers to them; it doesn't restate them.
| For | Read |
|-----|------|
| The dispatcher, folder shape, `OperationResult`, the controller skeleton, authorization | [docs/rules/server/cqrs.md](../../../archive/docs/rules/server/cqrs.md) |
| Projects, layers, the seam catalogue, startup wiring | [docs/rules/server/structure.md](../../../archive/docs/rules/server/structure.md) |
| EF Core, migrations, soft-delete, audit, config-as-rows, state machines, snapshots, uniqueness | [docs/rules/server/persistence.md](../../../archive/docs/rules/server/persistence.md) |
| Anything on the money path — ledger, refunds, BNPL, payouts, invoices | [docs/rules/server/money.md](../../../archive/docs/rules/server/money.md) |
| Auth, JWE, sessions, field encryption, tenancy | [docs/rules/server/identity.md](../../../archive/docs/rules/server/identity.md) |
| C# style, naming, async, testing | [docs/rules/server/conventions.md](../../../archive/docs/rules/server/conventions.md) |
| The gate, and what "done" means | [docs/rules/shared/git-and-gates.md](../../../archive/docs/rules/shared/git-and-gates.md) |
**Stack:** ASP.NET Core (.NET 10), Clean Architecture, CQRS on `martinothamar/Mediator` (a source generator —
**not MediatR**; there is no `IMediator` anywhere in this codebase), EF Core, FluentValidation, Mapster.
---
## 1. Scope it before writing anything
- **Which area?** The Application feature areas mirror the Domain entity folders — `Identity`, `Geography`,
`Catalog`, `Verification`, `Search`, `Booking` (singular, pre-payment) / `Bookings` (plural, post-payment),
`Payments`, `Refunds`, `Invoices`, `Bnpl`, `Payouts`, `Reviews`, `PatientCareRecords`, `Messaging`,
`PartnerCenters`, `Configuration`, `Audit`, `Analytics`, `Holidays`, `Notifications`, `SupportAlerts`. Full
list and the schema-per-area mapping: [structure.md](../../../archive/docs/rules/server/structure.md) §2.
- **Command or query, or both?** A command mutates; a query reads. Most features are a matched pair (create
+ get, or update + list).
- **Find a sibling to mirror.** Grep the area's existing folder —
`Features/<Area>/{Commands,Queries}/` — for a feature shaped like the one you're adding. Copying a live
pattern beats inventing a new one.
- **Does it touch money?** (ledger, refunds, invoices, BNPL, payouts) → read
[money.md](../../../archive/docs/rules/server/money.md) **first**. The invariants there (integer IRR, balanced
ledger postings, webhook idempotency, snapshot-at-compute-time) are not suggestions.
- **Does it add or change a table?** → read [persistence.md](../../../archive/docs/rules/server/persistence.md) §57
before modeling it (soft-delete filters, forward-only status machines, snapshot fields, uniqueness
patterns all have a house pattern — don't reinvent one).
- **Does it need a new external dependency** (a vendor, a rail)? It becomes an interface in
`Application/Contracts/`, mock in `CrossCutting/Seams/`, real in `CrossCutting/Seams/Real/`, selected by a
`Seams:<rail>:Provider` config key that **falls closed to the mock**. See
[structure.md](../../../archive/docs/rules/server/structure.md) §3.
---
## 2. The Application slice
```
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
```
1. Create the folder, one type per file, file name matching the type name.
2. The request is a `record`; the handler is `internal sealed`; return `OperationResult<T>` — never throw
for an expected failure (`SuccessResult`/`FailureResult`/`NotFoundResult`/`ConflictResult` map to
200/400/404/409). Let a genuinely unexpected exception propagate to the global `ExceptionHandler`.
3. Add a FluentValidation validator if the request takes input. **Never validate a route-supplied id in the
body command** — route values aren't bound into it.
4. Query: `AsNoTracking()` + `.Select()` straight to the DTO — never hydrate an entity graph to map it in
memory. Command: use `Include` only when you need navigation properties loaded to mutate the aggregate,
access the DB through `IUnitOfWork`, and `CommitAsync` once at the end.
Full rules and the validator/OperationResult examples: [cqrs.md](../../../archive/docs/rules/server/cqrs.md) §13.
---
## 3. Persistence — only if you added or changed a table
1. One `IEntityTypeConfiguration<T>` in `Persistence/Configuration/<Area>Config/`.
2. A soft-deletable entity **must** declare `HasQueryFilter(o => !o.IsDeleted)` — without it, deleted rows
leak into every query that doesn't explicitly exclude them.
3. A lifecycle `status` column is a forward-only machine: `const string` codes, a private setter, cohesive
transition methods, a static allowed-edges table. The handler pre-checks and returns a clean `409` — it
never throws for "already moved."
4. A row that represents a past agreement (a price, an address, a policy, a deadline) is a **snapshot**
frozen at compute time, never re-derived from a later edit to its source.
5. Money-critical constants (rates, deadlines, tolerances) are read via `IPlatformConfig.GetConfig<T>`
**never hardcoded**, and never re-read for an already-priced row.
```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
```
Full patterns, with the exact uniqueness/snapshot/state-machine tables:
[persistence.md](../../../archive/docs/rules/server/persistence.md).
---
## 4. The controller
```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
{
[HttpPost("[action]")]
[ProducesOkApiResponseType<MyCommandResult>]
public async Task<IActionResult> CreateSomething(MyCommand command, CancellationToken ct)
=> OperationResult(await sender.Send(command, ct));
}
```
- `sealed`, inject `ISender` via the primary constructor, always `base.OperationResult(result)` — never
`Ok()`/`BadRequest()`/`NotFound()` directly.
- Never hardcode a route string. If the method name doesn't read cleanly as the URL segment
`SnakeCaseParameterTransformer` will produce, rename the method instead.
- Pick the narrowest authorization that fits: none (truly public) → `[Authorize]` (any authenticated user) →
`[Authorize(ConstantPolicies.DynamicPermission)]` (role/claim-gated admin action). Table and rate-limiting
notes: [cqrs.md](../../../archive/docs/rules/server/cqrs.md) §4.
---
## 5. Tests
1. **Handler unit test** (xUnit + NSubstitute + FluentAssertions), Arrange-Act-Assert, named
`{MethodUnderTest}_{Scenario}_{ExpectedOutcome}`. Test the handler directly, not the controller.
2. **At least one `WebApplicationFactory<Program>` integration test** in `Baya.Test.Api` for the area,
covering: happy path → 200, unauthenticated → 401, validation failure → 400 with field detail.
3. The recurring-job scheduler is dormant under `Testing`, so a background tick can't make an integration
test flaky — you don't need to account for it.
Examples and the full testing convention: [conventions.md](../../../archive/docs/rules/server/conventions.md) §8.
---
## 6. Docs this feature triggers — in the same change
- **[`docs/integration/domains/<domain>.md`](../../../archive/docs/integration/domains/index.md)** — add the new
endpoint with its verdict (`wired`/`unwired`/`phantom`), matching the client `services/` domain it belongs
to.
- **The OpenAPI snapshot** — regenerate `docs/integration/openapi/swagger.v1.json` per
[openapi/README.md](../../../archive/docs/integration/openapi/README.md) and update its provenance table (date,
commit, path/operation counts) in the same change. A snapshot with stale provenance is what that
convention exists to prevent.
- **[`docs/status/backlog.md`](../../../archive/docs/status/backlog.md)** — tick the row if this closes a filed
item. Never delete a row; a ticked row is the record that it shipped.
- **A reference file in `docs/rules/server/`** — only if the feature introduces a genuinely new reusable
pattern, seam, or base class. Don't add prose for a feature that just follows the existing pattern.
---
## 7. Before you call it done
Run the server gate — `dotnet build Baya.sln` (**zero new warnings**) and `dotnet test Baya.sln` — and read
your diff as if reviewing the PR. Full "what done means" checklist:
[git-and-gates.md](../../../archive/docs/rules/shared/git-and-gates.md) §2.
+107
View File
@@ -0,0 +1,107 @@
---
name: flow-testing
description: >-
Boot both sides of Balinyaar locally and walk a real user journey end to end — the right seeded account,
the right flow doc, and knowing whether you just proved the real path or a mock answering. Use before
claiming a fix or feature works, or when asked to test, verify, or demo a flow.
---
# Balinyaar Flow Testing
Exercising a flow proves something only if you know which half of the stack actually answered. This is the
procedure; the facts it points at (ports, accounts, known failure modes) live in
[docs/flows/testing-setup.md](../../../archive/docs/flows/testing-setup.md) and are kept current there — don't copy
them here, they will drift.
---
## 1. Boot it
The five-minute path, verbatim from [testing-setup.md](../../../archive/docs/flows/testing-setup.md#the-five-minute-path):
```bash
# API — mock SMS or request_otp 500s
cd server
Seams__Sms__Provider=mock dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj
# client
cd client && npm install && npm run dev # http://localhost:3000/fa
# read the OTP — the console does NOT print it
curl http://localhost:5002/api/v1/dev/last_otp/09120000010
```
No database setup: the committed dev config points at an already-seeded remote SQL Server. If anything here
doesn't match reality when you run it, **testing-setup.md is wrong and needs a fix in the same change** — it
carries a `Last verified` stamp for exactly this reason.
---
## 2. Check the mock-vs-real map *before* you conclude anything
A flow "working" through a mocked domain proves the UI, not the server. Before testing:
1. Open [docs/integration/domains/index.md](../../../archive/docs/integration/domains/index.md) — the census table
names which of the 22 client `services/` domains are real vs **mock** (currently 15 real, 7 mock:
`admin`, `bnpl`, `partnerCenter`, `patientRecords`, `payouts`, `refunds`, `verification`).
2. A mocked domain is a `USE_<DOMAIN>_MOCK` flag in `client/src/services/<domain>/constants.ts` — check it
directly if you need certainty for the exact domain you're touching.
3. State your finding in terms of which one you exercised: "the booking flow works end-to-end against the
real server" is a different claim from "the admin console renders correctly against its mock" — never
report the second as if it were the first.
---
## 3. Pick the right seeded account
Demo accounts, their roles, and what each one demonstrates are tabulated in
[testing-setup.md](../../../archive/docs/flows/testing-setup.md#demo-accounts) — read it there rather than assuming
a phone number. One standing gap to route around: **the seeded admin accounts (`…020` `super_admin`,
`…021` `finance`) get 403 on every real admin endpoint** (a `DynamicPermission` / role-literal mismatch).
The admin backoffice is only testable against the client's mock; don't spend time trying to walk it against
the real API without first checking whether that gap has been closed.
---
## 4. Walk the flow
[docs/flows/index.md](../../../archive/docs/flows/index.md) is the atlas — one file per user-meaningful journey,
each answering exactly three questions: what it does, what's mocked *for that journey specifically*, and how
to test it. Open the one file that matches what you're testing rather than guessing the steps; it's the
one place gap numbers and REQ references for that journey are tracked.
---
## 5. Two things that will silently invalidate your test
- **The scheduler is live while you test.** `booking_request_expiry` runs every 60 seconds (hardcoded) and
flips an un-actioned request to `expired_no_response` / `payment_deadline_expired` out from under you. Act
on a request promptly, or create a fresh one rather than trying to reuse an old test artifact.
- **The OTP endpoints are rate-limited together.** `request_otp` and `verify_otp` share one bucket, 5 calls
per 60 s per IP — a login is 2 calls, so that's **two logins per minute, total**. Space scripted logins
≥ 40 s apart (see [testing-setup.md](../../../archive/docs/flows/testing-setup.md#scripting-logins) for a working
script) or you'll 429 and misread it as a bug.
---
## 6. When the seeded world has aged out
There is no in-app reseed — both seeders guard on natural keys, so re-running never refreshes stale dates.
If the scenario you need (an "upcoming" booking, an open dispute window, a pending request) no longer exists
because the world was seeded days ago:
- **Fastest fix:** create the scenario fresh yourself (customer → search → booking request → accept → pay) —
this is the intended way to exercise booking-request and checkout-and-payment anyway.
- **Full reseed:** only against a **local** database — `docker compose down -v && docker compose up -d` under
`server/`, then boot. **Never drop the shared remote database** casually; it backs the live demo deployment
and other people's sessions.
---
## 7. Report what you actually saw
Name the account you used, the domain's mock/real status, and the exact response (status code, error
message) rather than "it worked" — the troubleshooting table in
[testing-setup.md](../../../archive/docs/flows/testing-setup.md#troubleshooting) exists because several failure
modes here look identical to an unrelated bug (a rate limit looks like a crash; `/healthz/ready` failing on
Windows looks like the app is down). Check it before filing something as a new defect.
+97 -23
View File
@@ -12,9 +12,22 @@ description: >-
# Balinyaar Frontend Designer
Build UI that looks like Balinyaar and behaves correctly in both locales and both
color schemes on the first try. This skill is the design contract; the engineering
contract (providers, fetch, cookies, routing) lives in [client/CLAUDE.md](../../../client/CLAUDE.md) — read it
before touching layout/provider/data code, **don't restate it**, and never violate it.
color schemes on the first try.
**Precedence.** This skill is the **design** contract — brand, tone, and the visual
decisions. The **engineering** contract is [client/CLAUDE.md](../../../client/CLAUDE.md)
(hard rules) plus [docs/rules/client/](../../../archive/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](../../../archive/docs/rules/client/theme.md) |
| The `App*` library, shells, navigation, icons | [docs/rules/client/components.md](../../../archive/docs/rules/client/components.md) |
| Copy and Persian orthography | [docs/rules/client/i18n.md](../../../archive/docs/rules/client/i18n.md) |
| Forms | [docs/rules/client/forms.md](../../../archive/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](../../../archive/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](../../../archive/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](../../../archive/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](../../../archive/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](../../../archive/docs/rules/client/forms.md).
---
@@ -178,12 +220,19 @@ backdrop, at **every viewport**. A wider window gets more canvas, never a wider
design one set of states, verify one set of states. Do not add a `≥md` branch that widens
a shell, restores a sidebar, or lays a screen out in columns.
- `AppFrame` owns three structural guarantees, and is the only place any of them is
solved: the width cap; the **frame, not the document, owns the scroll** (header /
`<main>` / footer are flex siblings, so a top bar is `position: static` and no page
needs a top offset); and `overflowX: hidden` + `minWidth: 0`, so an over-wide child
clips rather than dragging the app sideways. Genuinely wide content (a data table)
scrolls **inside its own container** — see `AdminDataTable`'s `TableContainer`.
- `AppFrame` owns four structural guarantees, and is the only place any of them is
solved: the width cap; the **frame, not the document, owns the scroll** (a single
scrolling `<main>` fills the frame, with the bars pinned **`position: absolute`** over
it — never `fixed`, which would break out of the centered column — and `<main>`
reserving each bar's exact height as padding, so no page needs a top offset);
`overflowX: hidden` + `minWidth: 0`, so an over-wide child clips rather than dragging
the app sideways; and, above `sm`, the column **floats** as a rounded shadowed card
with a gutter all round (edge-to-edge on a phone). Genuinely wide content (a data
table) scrolls **inside its own container** — see `AdminDataTable`'s `TableContainer`.
- `AppFrame` also publishes **`--bal-chrome-top` / `--bal-chrome-bottom`** on the scroll
container (already including `env(safe-area-inset-*)`, and `0px` in a chrome-free
shell), so any `position: sticky` element can clear the bars without importing a
constant. `StickyActionBar` is the reference consumer — don't recompute an offset.
- **One authenticated shell**: `MobileShell` = `AppFrame` + a contextual `TopBar` (brand
lockup on a tab's own path, back chevron + `useRouteTitle()` on anything deeper) +
`BottomBar` + `ErrorBoundary` + `RouteFadeIn`. The four actor layouts (`CustomerLayout`
@@ -191,9 +240,17 @@ a shell, restores a sidebar, or lays a screen out in columns.
only `tabs` and `headerActions`. Add a destination by adding a tab or a hub row — never
by forking the shell.
- **The chrome is light, not structural.** The top bar is *not* an `AppBar` — no filled
surface, no rule, no elevation; it sits on the page background. The bottom bar *floats*:
inset from the frame edges, fully rounded (`--bal-radius-pill`), elevated. Neither should
read as a slab sealing off an edge of a 480px screen.
surface, no rule, no elevation of its own; `AppFrame` wraps both bars in the shared
`FLOATING_BAR_SX`, so the header is the bottom bar mirrored: inset from the frame edges,
fully rounded (`--bal-radius-pill`), elevated. Neither should read as a slab sealing off
an edge of a 480px screen. The bottom bar is **icon-only** (at five tabs the caption was
the widest thing in it and cost a whole line — the label survives as `aria-label`/
`title`), each tab a fixed 44px circle laid out `space-around`.
- **A stateful card carries its state in its content, not a stripe.** `AccentCard`'s
colored edge stripe was removed — a column of them read as a row of loose vertical rules
down the RTL edge of the screen. `tone` survives as the semantic label (reaching the DOM
as `data-accent-tone`); the `StatusChip`, icon and copy inside carry the state.
**Do not reintroduce the stripe.**
- **Navigation is the bottom bar. There is no drawer.** Tabs are `LinkToPage` arrays
(`@/utils`) built with `useTranslations('nav')`, 35 of them, and by convention the last
is a settings/«بیشتر» hub. Active state comes from the shared `matchActivePath`
@@ -254,7 +311,7 @@ Icons also default to `flexShrink: 0` — an icon squashed by a flex sibling was
layout bug this component kept quietly reintroducing on narrow rows.
**Directional icons mirror automatically.** Icons authored for LTR that must flip under
RTL (`back`, `chevron_start`, `chevron_end`) are registered in `AppIcon/config.ts`'s
RTL (`back`, `chevron_start`, `chevron_end`, `forward`, `send`) are registered in `AppIcon/config.ts`'s
`DIRECTIONAL_ICONS` set. `AppIcon` stamps `data-icon-directional` on those, and one CSS
rule (`app/globals.css`) does
`[dir='rtl'] [data-icon-directional] { transform: scaleX(-1); }`. Adding a new directional
@@ -284,11 +341,16 @@ Every screen/component you produce must satisfy **all** of these:
switches automatically. Verify on both schemes — never assume a light background.
4. **Tokens, not hexes.** No raw color literals in `sx`/`styled`/components (§2).
5. **Constants, not magic values.** Cookie names, routes, repeated dimensions, event
names → named constants (CLAUDE.md "Constants").
names → named constants ([components.md](../../../archive/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](../../../archive/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](../../../archive/docs/rules/client/structure.md)).
7. Data/fetch/auth/cookies/toasts → follow
[services.md](../../../archive/docs/rules/client/services.md) and
[auth.md](../../../archive/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/` |
-33
View File
@@ -1,33 +0,0 @@
# Git hooks
Repo-managed git hooks (they live in version control, unlike `.git/hooks`).
## Enable (once per clone)
```bash
git config core.hooksPath .githooks
```
## `pre-commit` — secret scan
A fast, dependency-free backstop against a credential leaking into a file that shouldn't hold one
(refinement-phase-5). 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).
**Declared config files.** The pre-launch demo deployment configures itself from committed files rather
than a secret store ([DEPLOY.md](../DEPLOY.md)), so a short allow-list — `appsettings.Development.json`,
`docker-compose.yml`, `telegram-otp-bot/.env.example`, `DEPLOY.md` — is exempt from the last two checks.
The list is maintained in the `declared_config` function in the hook and is the honest record of where the
repo's secrets are. **Shrink it, never grow it**: once real users exist, those values must be rotated and
moved out of git.
It scans only staged additions, so it is quick. It is **not** a replacement for a full scanner
(gitleaks / trufflehog) in CI — it is the local first line of defence.
Bypass a false positive with `git commit --no-verify` (use sparingly, and only when you are certain the
flagged line is not a secret).
-82
View File
@@ -1,82 +0,0 @@
#!/usr/bin/env bash
#
# Balinyaar secret-scanning pre-commit hook (refinement-phase-5).
# Blocks a commit that stages an obvious credential. This is a fast, dependency-free backstop for the
# root CLAUDE.md rule "Never commit secrets" — not a replacement for gitleaks/trufflehog in CI.
#
# Enable once per clone: git config core.hooksPath .githooks
# Bypass a false positive: git commit --no-verify (use sparingly, and only when you are certain)
#
set -euo pipefail
# Committed placeholders are allowed — real values are not. Keep in sync with StartupSecretsGuard.
PLACEHOLDER='SET_VIA_USER_SECRETS_OR_ENV'
# Files that deliberately carry live deployment credentials, because the pre-launch demo deployment
# configures itself from committed files rather than a secret store (see DEPLOY.md). They are exempt from
# the connection-string and known-host checks ONLY — the private-key and AWS-key checks still apply to
# them, and every other file in the repo is scanned exactly as strictly as before.
#
# This list is the honest record of where the repo's secrets are. Shrink it, never grow it: the moment
# real users exist, these values must be rotated and moved out of git.
declared_config() {
case "$1" in
server/src/API/Baya.Web.Api/appsettings.Development.json) return 0 ;;
docker-compose.yml) return 0 ;;
telegram-otp-bot/.env.example) return 0 ;;
DEPLOY.md) return 0 ;;
*) return 1 ;;
esac
}
# Only scan added/changed lines in text files that are staged.
staged=$(git diff --cached --name-only --diff-filter=ACM)
[ -z "$staged" ] && exit 0
violations=0
report() { printf ' ✖ %s\n' "$1"; violations=$((violations + 1)); }
while IFS= read -r file; do
# Skip this hook, lockfiles, and binaries.
case "$file" in
.githooks/*) continue ;;
*.png|*.jpg|*.jpeg|*.gif|*.ico|*.pdf|*.dll|*.exe|*.snk) continue ;;
esac
[ -f "$file" ] || continue
added=$(git diff --cached -U0 -- "$file" | grep '^+' | grep -v '^+++' || true)
[ -z "$added" ] && continue
# The retired hardcoded admin password. Applies everywhere, no exemptions.
echo "$added" | grep -Eq 'qw123321' && report "$file: hardcoded admin password 'qw123321'"
if ! declared_config "$file"; then
# The deployment's SQL Server host — outside the declared config files it is a leak.
echo "$added" | grep -Eq '87\.107\.152\.16' && report "$file: SQL Server host 87.107.152.16 outside the declared config files"
# A real (non-placeholder) connection-string password in a committed appsettings file.
case "$file" in
*appsettings*.json)
echo "$added" \
| grep -Ei 'Password=[^;"'"'"' ]+' \
| grep -viq "Password=${PLACEHOLDER}" \
&& report "$file: connection-string password must be '${PLACEHOLDER}' (see DEPLOY.md for where real values live)"
;;
esac
fi
# Private keys and common cloud tokens, anywhere.
echo "$added" | grep -Eq -- '-----BEGIN (RSA|EC|OPENSSH|PRIVATE) .*PRIVATE KEY-----' && report "$file: private key material"
echo "$added" | grep -Eq 'AKIA[0-9A-Z]{16}' && report "$file: AWS access key id"
done <<< "$staged"
if [ "$violations" -gt 0 ]; then
echo ""
echo "Commit blocked: $violations potential secret(s) staged. Real values belong in one of the declared"
echo "config files (see the 'declared_config' list in this hook, and DEPLOY.md); everything else commits"
echo "only the '${PLACEHOLDER}' placeholder."
echo "To override a false positive: git commit --no-verify"
exit 1
fi
exit 0
+6
View File
@@ -6,5 +6,11 @@ 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**. For current-state product truth — what to test, what's blocking launch,
what's missing — start at **[mvp/README.md](mvp/README.md)**. The engineering reasoning behind the hard
rules, and the full business-requirement docs, were archived on 2026-08-02 into
**[archive/docs/rules/](archive/docs/rules/index.md)** and **[archive/product/](archive/product/index.md)**
respectively — reference material, not actively maintained.
`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.
+100 -82
View File
@@ -1,127 +1,145 @@
# 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-08-02 against commit `51e86a1`.
---
## 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)):
**Start at [`mvp/README.md`](mvp/README.md) for the current state of the product**, in three short,
non-technical files: how to manually test any user journey, what's broken and blocking a real launch, and
what's missing that isn't clearly scheduled. That folder is the live, load-bearing answer to "what's next."
| 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/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/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) |
Deeper product/business knowledge — the full business-requirement write-ups, the ~54-table database model,
payments/BNPL research, market/legal research — was consolidated into [`archive/product/`](archive/product/index.md)
during the 2026-08-02 documentation cleanup. It is **reference material, not required reading**: correct as
of that date, but not actively maintained going forward. Read it when `mvp/` doesn't answer your question in
enough depth — e.g. designing a new table, or needing the full reasoning behind a business rule.
**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`.
**Never infer business rules from code alone** — the code is young. If `mvp/` and `archive/product/` both go
silent on a money, auth, tenancy, or clinical-data rule, say so rather than guessing.
---
## 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) |
| [`mvp/`](mvp/README.md) | **Current truth** — plain-language test flows, launch blockers, missing MVP features | Markdown | [mvp/README.md](mvp/README.md) |
| [`archive/`](archive/README.md) | Everything else: business docs, engineering rules/contracts/flow-atlas, and the executed build history. **Reference/history, not instruction** — nothing to build from it, and nothing here is kept current | Markdown | [archive/README.md](archive/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) |
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/` holds the executed build history, the former `docs/` (engineering rules, API contracts, the
per-flow test atlas, status/backlog) and the former `product/` (business requirements, data model, research)
— consolidated there on 2026-08-02 so the live tree stays focused on MVP work. **Anything in it is a record,
not an instruction.**
---
## 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** (archived) | [`archive/docs/rules/`](archive/docs/rules/index.md) | The *how* and the *why*, as of 2026-08-02 — 3 shared files, 8 client, 6 server, plus the documentation convention. Not actively maintained; read it on demand, don't expect it to track later changes |
| **Procedure** | `.claude/skills/` | Playbooks: **frontend-designer** (the design contract for `client/` UI), **backend-feature** (adding a server feature), **flow-testing** (walking a flow end to end) |
Start at [archive/docs/rules/index.md](archive/docs/rules/index.md) — it maps "working on X" to the one file
to open.
**Precedence when two sources disagree:** `archive/product/` (business truth) → the relevant `CLAUDE.md`
(engineering truth) → `archive/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`.
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.
- 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: [archive/docs/rules/shared/git-and-gates.md](archive/docs/rules/shared/git-and-gates.md).
4. **Read [`mvp/`](mvp/README.md) (and `archive/product/` for depth) 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 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
[archive/docs/rules/documentation.md](archive/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: [archive/docs/rules/shared/code-quality.md](archive/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 [`mvp/blockers.md`](mvp/blockers.md) (or `archive/docs/status/` for the full historical
ledger). 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: [archive/docs/rules/shared/naming.md](archive/docs/rules/shared/naming.md).
---
@@ -132,5 +150,5 @@ 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
```
+17 -3
View File
@@ -3,6 +3,13 @@
A first, shareable deployment of the whole stack under **balinyaar.ir**, in Docker, behind an existing
Caddy reverse proxy that terminates TLS.
> **This file is the deploy *procedure*.** The runtime dependency graph — every edge, what breaks when it is
> down, and where it is configured — is [archive/docs/integration/topology.md](archive/docs/integration/topology.md)
> (archived reference, not actively maintained), and
> every configuration key on both sides is
> [archive/docs/integration/config-matrix.md](archive/docs/integration/config-matrix.md). Read those to answer
> "what talks to what" or "where is this value set"; read this one to actually ship.
| Host | Serves | Container |
| --- | --- | --- |
| `balinyaar.ir`, `www.balinyaar.ir` | Next.js web client | `balinyaar-web:3000` |
@@ -28,9 +35,16 @@ machine is now inert and can be deleted. Every value lives in a file in the repo
| Client build-time config (API URL, site origin) | `client/.env.production` |
| Telegram relay config (bot token, chat ids, API key, proxy) | `docker-compose.yml``otp-relay.environment` |
The placeholder string `SET_VIA_USER_SECRETS_OR_ENV` in the base `appsettings.json` names that removed
store; the *name* is a historical artifact, kept only because it is the sentinel `StartupSecretsGuard`
rejects. **The mechanism is appsettings files and environment variables** — see
[archive/docs/integration/config-matrix.md](archive/docs/integration/config-matrix.md), which lists every key,
its default, and who reads it.
The API runs as **`ASPNETCORE_ENVIRONMENT=Development`**, so `appsettings.Development.json` is the file
that actually loads. An `appsettings.Production.json` would be ignored — put changes in the Development
file, or change the environment name first.
that actually loads. There is **no `appsettings.Production.json` in the repo at all**, and adding one would
be ignored until the environment name changes too — put changes in the Development file, or change the
environment name first.
The relay's shared secret appears twice and the two must match: `Seams:Sms:Telegram:ApiKey` in the
appsettings file and `API_KEY` in the compose file. It was rotated away from the value in
@@ -64,7 +78,7 @@ people you trust, and none of which are acceptable once strangers can reach the
### Going to Production later
1. Set `ASPNETCORE_ENVIRONMENT: Production` in `docker-compose.yml`.
2. Create `appsettings.Production.json` with the same content as the Development file, but with **real**
2. **Create** `appsettings.Production.json` (it does not exist) with the same content as the Development file, but with **real**
`IdentitySettings:SecretKey` / `Encryptkey``StartupSecretsGuard` rejects anything containing
`not-for-production` outside Development, so the current dev keys will refuse to boot (by design).
Keep `Seams:FieldEncryption` byte-identical.
View File
+1 -1
View File
@@ -23,6 +23,6 @@ NEXT_PUBLIC_API_URL = https://localhost:5002
# Neshan **web** key (client-embeddable maps/search/reverse-geocode) — get one from
# https://platform.neshan.org (a separate key from the server's NeshanGeocoder key, which lives in
# server appsettings/user-secrets, never here). Leave unset to keep the address map-pin picker's
# server appsettings, never here). Leave unset to keep the address map-pin picker's
# bounded-canvas grid fallback (dev/CI/jsdom all work without a key).
# NEXT_PUBLIC_NESHAN_KEY = your-neshan-web-key
+6 -3
View File
@@ -1,10 +1,13 @@
# 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.
- Current product truth (what to test, what's blocking, what's missing) → [../mvp/](../mvp/README.md)
- Reference rules, archived, read on demand per area → [../archive/docs/rules/client/](../archive/docs/rules/client/)
(structure · theme · components · forms · i18n · services · auth · testing)
- Repo-wide context → [../CLAUDE.md](../CLAUDE.md)
- Business rules in depth (archived) → [../archive/product/](../archive/product/index.md)
- Human setup/run instructions → [README.md](README.md)
- UI/design work → the **frontend-designer** skill
+142 -1062
View File
File diff suppressed because it is too large Load Diff
-116
View File
@@ -1,116 +0,0 @@
# Persian (fa) copy style guide
One page, binding for `messages/fa.json`. `scripts/check-copy.mjs` (`npm run lint:copy`, part of
`npm run check`) enforces the banned-variant rules below so these decisions cannot silently regress.
This file does not repeat `en.json` conventions beyond what's noted in §7 — the English catalog is
hand-written and reviewed for idiom, not linted.
## 1. Brand name
**«بالین‌یار» — ZWNJ (``) between بالین and یار, always.** Never a plain space («بالین یار»).
The brand name appears in money/trust copy (login, escrow, refunds) as often as anywhere else — an
unstable brand mark there is the worst place to be inconsistent.
## 2. تأیید — hamza, always
Write **تأیید** (with hamza) and its derived forms — **تأییدشده**, **تأییدیه**, **تأیید کردن** — every
time, never تایید/تاییدشده/تاییدیه (hamza-less). This is the single most frequent word in a
verification product; one spelling, no exceptions, in every namespace (booking, payment, auth,
verification, admin, payouts, bnpl, refunds, legal — all of them).
## 3. جستجو — one form
Standard form: **جستجو** (no ZWNJ, one word). Not «جست‌وجو» / «جست و جو». Applies to the noun and any
compound (`در جستجو`, `نتایج جستجو`).
## 4. ZWNJ (نیم‌فاصله) rules
Use ZWNJ (``) — never a plain space or no separator — in:
- **می‌ + verb stem**: می‌شود، می‌کند، می‌پردازید، می‌ماند (never میشود/می شود).
- **Plural ها**: مراقب‌ها-style compounds keep the ZWNJ before ها when the base ends in a consonant that
would otherwise misread (`شب‌ها` not `شبها`); a plain plural on a word already ending in a vowel/silent-h
takes the ZWNJ too (`بچه‌ها`).
- **Compound past-participle adjectives**: تأییدشده، لغوشده، ردشده، منتشرشده، پرداخت‌شده — the doer/state
compound is one ZWNJ-joined word, not two spaced words («تایید شده») and not fused with no separator.
- Brand name itself (§1) is the other load-bearing ZWNJ case.
## 5. Punctuation & quotes
- Persian text uses «...» guillemets for quoted terms/labels in prose (as this document does), and
Persian «،» / «؛» for commas/semicolons *inside translated sentences* where the surrounding punctuation
is itself Persian prose (most UI strings use plain Latin `,`/`;` today for simplicity in short labels —
don't retrofit existing short strings, but prefer «،»/«؛» in new multi-clause sentences).
- English (`en.json`) uses **straight** apostrophes (`don't`, `couldn't`) throughout — never curly
(``, ``). One admin-namespace holdout (curly `don't`/`couldn't`) is fixed by this phase; don't
reintroduce curly quotes when editing English copy.
## 6. Domain glossary
- **بیمار** — the care recipient, used consistently everywhere except one parenthetical. Do not adopt
«مددجو» — it appeared exactly once (`booking.patient_label`) and has been dropped in favor of the
99%-majority «بیمار».
- **پرستار** — the caregiver, always (never «مراقب» as a noun for the person — «مراقب» only survives as
an adjective/role qualifier, e.g. `booking.gender_label` "جنسیت مراقب" meaning "the caregiver's gender").
- **رزرو** — a confirmed, paid booking. **درخواست رزرو** — a pre-payment request. Never conflate the two;
a `booking_request` is never called «رزرو» before it converts.
- **ویزیت** — one scheduled visit/session within a booking.
- **شبا** — IBAN, always («شماره شبا» for the field label, «شبا» alone elsewhere).
## 7. Shell naming system
One metaphor per audience class, not four:
- **End-user shells** (family, nurse — the apps people book/work through day to day) → **«اپلیکیشن»**:
«اپلیکیشن خانواده», «اپلیکیشن پرستار».
- **Back-office shells** (staff consoles — admin, partner-center) → **«کنسول»**: «کنسول مدیریت»,
«کنسول همکار».
- Never «نما» (view) or «پرتال» (portal) for a whole shell name — those read as one-off inconsistent
metaphors. (`booking.evv_nurse_view` "نمای پرستار" is a different thing — a chip labeling *whose
perspective* a shared booking-detail screen is rendered from, not a shell name; it correctly keeps
«نما» in that narrower sense.)
## 8. Verification pipeline vs. the identity step
**«تأیید صلاحیت»** names the whole 7-step nurse trust pipeline (nav entry, the verification hub's title,
its start/progress/approved states, the admin queue). **«احراز هویت»** stays the name of the one KYC
step inside it (national-ID + civil-registry + liveness selfie) — both on the nurse side
(`verification.step_identity_kyc`) and the admin side (`admin.step_identity_kyc`). A nurse who passed the
KYC step but still sees a pipeline titled «احراز هویت» incomplete in the nav used to read as a
contradiction; they no longer share a name.
## 9. Status vocabulary — one nurse-facing form, one admin-facing form
For "this step/item was rejected/failed" style states that appear on both a nurse-facing screen and an
admin-facing screen for the *same underlying concept* (a verification step's outcome):
- **Nurse-facing**: «رد شد» (`verification.status_failed`) — a short declarative sentence-style status,
matching the register of its sibling `status_passed` ("تأییدشده")/`status_in_review` ("در حال بررسی").
- **Admin-facing**: «ردشده» (`admin.step_failed`, `admin.agg_rejected`, `admin.rstatus_rejected`,
`admin.mstatus_rejected`) — the compound-adjective state form, matching the admin namespace's own
`step_passed`/`agg_approved`/`center_state_verified` ("تأییدشده") pattern.
This does **not** extend to unrelated money-failure vocabulary (`payouts.pstatus_failed`,
`refunds.rstatus_failed`, `admin.batch_status_failed` all legitimately use «ناموفق» — a transfer/payment
*failing* is a different concept from a document being *rejected*, and conflating them would blur a real
distinction).
## 10. Digits policy
Persian digits (۰۱۲۳۴۵۶۷۸۹) everywhere on `/fa` — both hard-coded literals (`"۲۴ ساعت"`) and
interpolated numbers. For an interpolated `{count}`/`{hours}`/… inside an ICU message, use the ICU
`number` sub-format (`{count, number}`) or a plain `#` inside a `plural` block — next-intl formats both
through the active locale (`fa` → Persian digits) automatically. When a raw number is interpolated at a
call site instead of through ICU (e.g. built into a larger string in code, not a message placeholder),
route it through `formatNumber` (`@/utils`) — never template a raw JS number directly into Persian text.
## 11. Register
Formal شما throughout, with polite imperatives (کنید) for actions and instructions. Already consistent
across the whole catalog — this codifies it so a future addition can't drift into informal تو/imperative
stems (نکن, برو).
## 12. Policy numbers
Legally/financially sensitive numbers that the admin config panel can change (the dispute-window hours,
cancellation lead-time hours, refund ETA days) are **never hard-coded into a message string**. The
message key takes a parameter (`{hours}`, `{minDays}`/`{maxDays}`) and the call site interpolates from
`client/src/constants/policy.ts` (single-sourced, REQ-065 tracks the eventual public config-read that
replaces the constants file). A config edit must never again silently make the UI copy lie.
+51
View File
@@ -23,6 +23,7 @@
"payouts": "Payouts",
"reviews": "Reviews",
"config": "Configuration",
"catalog": "Catalog",
"holidays": "Holidays",
"alerts": "Alerts",
"audit": "Audit log",
@@ -309,6 +310,10 @@
"line_hint": "Building, street, unit — the detail a nurse needs to find the door.",
"line_required": "Enter the street address",
"city_required": "Select a city",
"recipient_name_label": "Recipient name",
"recipient_name_required": "Enter who the nurse should ask for",
"recipient_phone_label": "Recipient phone",
"recipient_phone_invalid": "Enter a valid Iranian mobile number",
"set_primary_toggle": "Set as primary address",
"map_hint": "Tap or drag the marker to the patient's exact location.",
"map_required": "Drop a pin on the map",
@@ -474,6 +479,7 @@
"unnamed_nurse": "Nurse",
"unnamed_service": "Service",
"completed_visits": "{count, number} successful visits",
"more_services_count": "+{count, plural, one {# more service} other {# more services}}",
"reviews_count": "({count, plural, =0 {no reviews} one {# review} other {# reviews}})",
"distance_km": "{km} km",
"price_from": "from",
@@ -773,6 +779,11 @@
"total_payable_label": "Total to pay",
"secure_gateway_notice": "Secure payment via bank gateway",
"cta_pay": "Continue to payment",
"gateway_title": "Redirecting to the payment gateway",
"gateway_hint": "This is a demo page standing in for the bank gateway.",
"gateway_reference_label": "Reference",
"gateway_pay_success": "Pay (demo)",
"gateway_pay_fail": "Cancel",
"bnpl_option": "Or pay in installments",
"state_initiating": "Starting payment…",
"state_redirecting": "Redirecting to the payment gateway…",
@@ -1683,6 +1694,45 @@
"hol_bank_hint": "When on, payouts falling on this day shift to the next business day.",
"hol_saved": "Holiday saved.",
"hol_year": "Year",
"status_active": "Active",
"status_inactive": "Inactive",
"cat_title": "Catalog",
"cat_subtitle": "Categories and the pricing dimensions nurses build their offerings from.",
"cat_empty": "No categories yet.",
"cat_col_name": "Category",
"cat_col_order": "Order",
"cat_col_status": "Status",
"cat_add": "Add category",
"cat_edit": "Edit",
"cat_activate": "Activate",
"cat_deactivate": "Deactivate",
"cat_activated": "Category activated.",
"cat_deactivated": "Category deactivated.",
"cat_saved": "Category saved.",
"cat_not_found": "This category could not be found.",
"cat_name_fa": "Name (Persian)",
"cat_name_en": "Name (English)",
"cat_description_fa": "Description (Persian)",
"cat_description_en": "Description (English)",
"cat_icon_key": "Icon key (optional)",
"cat_icon_hint": "Matches a name in the icon registry; an unknown or missing key falls back gracefully.",
"cat_sort_order": "Sort order",
"og_section_title": "Pricing options",
"og_empty": "No pricing dimensions yet.",
"og_add": "Add dimension",
"og_edit": "Edit",
"og_cross_category_badge": "All categories",
"og_required_badge": "Required",
"og_optional_badge": "Optional",
"og_scope_all": "Applies to all categories",
"og_scope_all_hint": "A cross-category dimension is offered for every category, not just this one.",
"og_scope_this_hint": "Offered only for this category.",
"og_is_required": "Must be answered before booking",
"og_saved": "Pricing dimension saved.",
"og_value_add": "Add value",
"og_value_edit": "Edit value",
"og_value_saved": "Value saved.",
"og_value_empty": "No values yet.",
"alert_title": "Support alerts",
"alert_subtitle": "Internal triage — never shown to customers or nurses.",
"alert_empty": "No open alerts.",
@@ -2100,6 +2150,7 @@
"admin_tickets_sub": "The global ticket queue",
"admin_alerts_sub": "The internal alert worklist",
"admin_config_sub": "Config keys and their change history",
"admin_catalog_sub": "Categories and their pricing dimensions",
"admin_holidays_sub": "The bank-holiday calendar",
"admin_audit_sub": "Read-only record of every change",
"admin_partners_sub": "Partner centers and sponsored nurses",
+51
View File
@@ -23,6 +23,7 @@
"payouts": "تسویه‌ها",
"reviews": "نظرات",
"config": "پیکربندی",
"catalog": "کاتالوگ",
"holidays": "تعطیلات",
"alerts": "هشدارها",
"audit": "گزارش ممیزی",
@@ -309,6 +310,10 @@
"line_hint": "پلاک، خیابان، واحد — جزئیاتی که پرستار برای یافتن درِ منزل نیاز دارد.",
"line_required": "نشانی کامل را وارد کنید",
"city_required": "شهر را انتخاب کنید",
"recipient_name_label": "نام تحویل‌گیرنده",
"recipient_name_required": "نام فردی که پرستار باید بپرسد را وارد کنید",
"recipient_phone_label": "شماره تماس تحویل‌گیرنده",
"recipient_phone_invalid": "یک شماره موبایل معتبر ایرانی وارد کنید",
"set_primary_toggle": "به‌عنوان آدرس اصلی تنظیم شود",
"map_hint": "برای تعیین محل دقیق بیمار، نشانگر را بکشید یا روی نقشه بزنید.",
"map_required": "روی نقشه یک پین بگذارید",
@@ -474,6 +479,7 @@
"unnamed_nurse": "پرستار",
"unnamed_service": "خدمت",
"completed_visits": "{count, number} ویزیت موفق",
"more_services_count": "+{count, plural, one {# خدمت دیگر} other {# خدمت دیگر}}",
"reviews_count": "({count, plural, =0 {بدون نظر} one {# نظر} other {# نظر}})",
"distance_km": "{km} کیلومتر",
"price_from": "از",
@@ -773,6 +779,11 @@
"total_payable_label": "مبلغ قابل پرداخت",
"secure_gateway_notice": "پرداخت امن از طریق درگاه بانکی",
"cta_pay": "ادامه پرداخت",
"gateway_title": "در حال انتقال به درگاه پرداخت",
"gateway_hint": "این صفحه نمایشی جایگزین درگاه بانکی است.",
"gateway_reference_label": "کد مرجع",
"gateway_pay_success": "پرداخت (نمایشی)",
"gateway_pay_fail": "انصراف",
"bnpl_option": "یا پرداخت اقساطی",
"state_initiating": "در حال آغاز پرداخت…",
"state_redirecting": "در حال انتقال به درگاه پرداخت…",
@@ -1683,6 +1694,45 @@
"hol_bank_hint": "در صورت فعال بودن، تسویه‌های این روز به روز کاری بعد منتقل می‌شوند.",
"hol_saved": "تعطیلی ذخیره شد.",
"hol_year": "سال",
"status_active": "فعال",
"status_inactive": "غیرفعال",
"cat_title": "کاتالوگ خدمات",
"cat_subtitle": "دسته‌بندی‌های خدمات و ابعاد قیمت‌گذاری که پرستاران خدمات خود را بر اساس آن‌ها می‌سازند.",
"cat_empty": "هنوز دسته‌بندی‌ای ثبت نشده است.",
"cat_col_name": "دسته‌بندی",
"cat_col_order": "ترتیب",
"cat_col_status": "وضعیت",
"cat_add": "افزودن دسته‌بندی",
"cat_edit": "ویرایش",
"cat_activate": "فعال‌سازی",
"cat_deactivate": "غیرفعال‌سازی",
"cat_activated": "دسته‌بندی فعال شد.",
"cat_deactivated": "دسته‌بندی غیرفعال شد.",
"cat_saved": "دسته‌بندی ذخیره شد.",
"cat_not_found": "این دسته‌بندی پیدا نشد.",
"cat_name_fa": "نام (فارسی)",
"cat_name_en": "نام (انگلیسی)",
"cat_description_fa": "توضیحات (فارسی)",
"cat_description_en": "توضیحات (انگلیسی)",
"cat_icon_key": "کلید آیکون (اختیاری)",
"cat_icon_hint": "باید با یکی از نام‌های ثبت‌شدهٔ آیکون مطابقت داشته باشد؛ کلید ناشناخته یا خالی به‌صورت خودکار جایگزین می‌شود.",
"cat_sort_order": "ترتیب نمایش",
"og_section_title": "ابعاد قیمت‌گذاری",
"og_empty": "هنوز بُعد قیمت‌گذاری‌ای ثبت نشده است.",
"og_add": "افزودن بُعد",
"og_edit": "ویرایش",
"og_cross_category_badge": "همهٔ دسته‌بندی‌ها",
"og_required_badge": "اجباری",
"og_optional_badge": "اختیاری",
"og_scope_all": "برای همهٔ دسته‌بندی‌ها اعمال شود",
"og_scope_all_hint": "یک بُعد بین‌دسته‌ای برای همهٔ دسته‌بندی‌ها نمایش داده می‌شود، نه فقط همین یکی.",
"og_scope_this_hint": "فقط برای همین دسته‌بندی نمایش داده می‌شود.",
"og_is_required": "پیش از رزرو باید پاسخ داده شود",
"og_saved": "بُعد قیمت‌گذاری ذخیره شد.",
"og_value_add": "افزودن مقدار",
"og_value_edit": "ویرایش مقدار",
"og_value_saved": "مقدار ذخیره شد.",
"og_value_empty": "هنوز مقداری ثبت نشده است.",
"alert_title": "هشدارهای پشتیبانی",
"alert_subtitle": "صف داخلی — هرگز به مشتری یا پرستار نمایش داده نمی‌شود.",
"alert_empty": "هشداری برای رسیدگی نیست.",
@@ -2100,6 +2150,7 @@
"admin_tickets_sub": "صف سراسری تیکت‌ها",
"admin_alerts_sub": "کارتابل داخلی هشدارها",
"admin_config_sub": "کلیدهای پیکربندی و تاریخچهٔ تغییرها",
"admin_catalog_sub": "دسته‌بندی‌های خدمات و ابعاد قیمت‌گذاری آن‌ها",
"admin_holidays_sub": "تقویم تعطیلات بانکی",
"admin_audit_sub": "گزارش تغییرها، فقط‌خواندنی",
"admin_partners_sub": "مراکز همکار و پرستاران تحت پوشش",
+4 -1
View File
@@ -1,8 +1,11 @@
#!/usr/bin/env node
/**
* Lints client/messages/fa.json against the banned-orthography-variant rules in
* client/messages/STYLE.md — enforces the phase-12 sweep so it cannot silently regress.
* docs/rules/client/i18n.md §4 — enforces the phase-12 copy sweep so it cannot silently regress.
* Exits non-zero (and prints every offending key) on any match.
*
* The rules below are the machine-checkable subset. The full Persian style guide (glossary, register,
* shell naming, the ZWNJ cases a grep can't express) lives in that doc; keep the two in step.
*/
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
@@ -172,6 +172,8 @@ export default function AddressesPage() {
latitude: editing.latitude,
longitude: editing.longitude,
isPrimary: editing.isPrimary,
recipientName: editing.recipientName,
recipientPhone: editing.recipientPhone,
}
: undefined
}
@@ -0,0 +1,71 @@
'use client';
import { Suspense } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import { Box, Stack, Typography } from '@mui/material';
import { AppButton, AppLoading, PaymentStateCard } from '@/components';
import { ROUTES } from '@/constants';
import {
CHECKOUT_QUERY_OUTCOME,
CHECKOUT_QUERY_REQUEST_ID,
CHECKOUT_QUERY_TRANSACTION_ID,
} from '@/services/payment/constants';
import type { GatewayReturnOutcome } from '@/services/payment/types';
/**
* Mock-gateway harness a **test harness, not a product feature**, mirroring the BNPL provider-handoff
* harness (`checkout/bnpl/gateway/page.tsx`). It stands in for the PSP so the initiate redirect return
* round-trip is exercisable without a real gateway: `MockPaymentProvider.InitPaymentAsync` points its
* `redirectUrl` here, and the pay/cancel buttons drive both outcome branches of the return surface (a real
* PSP redirects back after the cardholder pays or cancels). Deliberately reachable in every build, including
* production unlike the BNPL harness, this one is NOT env-gated: this deployment is a demo with no real
* gateway wired up (`mvp/blockers.md` §B.5), so the mock stays reachable until a real acquirer is switched
* on, at which point `redirectUrl` becomes the PSP's absolute URL and this page is never reached.
*/
export default function MockGatewayPage() {
return (
<Suspense fallback={<AppLoading />}>
<MockGatewayScreen />
</Suspense>
);
}
function MockGatewayScreen() {
const t = useTranslations('payment');
const locale = useLocale();
const router = useRouter();
const params = useSearchParams();
const requestId = params.get(CHECKOUT_QUERY_REQUEST_ID) ?? '';
const transactionId = params.get(CHECKOUT_QUERY_TRANSACTION_ID) ?? '';
const returnWith = (outcome: GatewayReturnOutcome) => {
const query = new URLSearchParams({
[CHECKOUT_QUERY_REQUEST_ID]: requestId,
[CHECKOUT_QUERY_TRANSACTION_ID]: transactionId,
[CHECKOUT_QUERY_OUTCOME]: outcome,
});
router.replace(`/${locale}${ROUTES.CHECKOUT_RETURN}?${query.toString()}`);
};
return (
<PaymentStateCard icon="payment" tone="var(--bal-secondary)" title={t('gateway_title')} body={t('gateway_hint')}>
{transactionId ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{t('gateway_reference_label')}:{' '}
<Box component="span" dir="ltr">
#{transactionId}
</Box>
</Typography>
) : null}
<Stack sx={{ gap: 1.5, alignItems: 'center', width: '100%' }}>
<AppButton color="secondary" variant="contained" size="large" onClick={() => returnWith('success')}>
{t('gateway_pay_success')}
</AppButton>
<AppButton variant="text" color="error" onClick={() => returnWith('failure')}>
{t('gateway_pay_fail')}
</AppButton>
</Stack>
</PaymentStateCard>
);
}
@@ -170,14 +170,19 @@ function EditableTabs({ patientId, tab, canEdit }: { patientId: number; tab: Car
}
const data = record.data;
// The real endpoint fully replaces all three lists from the request (no server-side merge), so every save
// must resend the whole current plan — not just the tab being edited — or the other two tabs get wiped.
const save = (patch: Partial<Pick<FamilyCareRecord, 'medications' | 'routine' | 'tasks'>>, onDone: () => void) => {
update.mutate(patch, {
onSuccess: () => {
enqueueSnackbar(t('saved'), { variant: 'success' });
onDone();
update.mutate(
{ medications: data.medications, routine: data.routine, tasks: data.tasks, ...patch },
{
onSuccess: () => {
enqueueSnackbar(t('saved'), { variant: 'success' });
onDone();
},
onError: () => enqueueSnackbar(t('save_error'), { variant: 'error' }),
},
onError: () => enqueueSnackbar(t('save_error'), { variant: 'error' }),
});
);
};
if (tab === 'medications') {
@@ -140,6 +140,9 @@ function ProfileHeader({ profile }: { profile: NurseProfile }) {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
// Reached by direct URL, not gated by the search-index verified-only invariant — unlike the result card
// (NurseResultCard), an unverified nurse's profile CAN be opened this way, so the badge must reflect
// `profile.isVerified` (already correctly fetched by `getNurseProfile`), never an assumed-verified literal.
return (
<Stack sx={{ gap: 1.5 }}>
@@ -170,7 +173,7 @@ function ProfileHeader({ profile }: { profile: NurseProfile }) {
</Stack>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
<TrustBadge state="verified" nurseId={profile.nurseId} />
<TrustBadge state={profile.isVerified ? 'verified' : 'unverified'} nurseId={profile.nurseId} />
{profile.inoMembership ? (
<Chip
icon={<AppIcon icon="license" size={16} color="var(--bal-primary)" />}
@@ -35,6 +35,7 @@ export default function AdminOverviewScreen() {
{ section: 'support', title: tNav('tickets'), subtitle: th('admin_tickets_sub'), path: ROUTES.ADMIN_TICKETS, icon: 'support', enabled: caps.canManageTickets },
{ section: 'support', title: tNav('alerts'), subtitle: th('admin_alerts_sub'), path: ROUTES.ADMIN_ALERTS, icon: 'alerts', enabled: caps.canManageAlerts },
{ section: 'system', title: tNav('config'), subtitle: th('admin_config_sub'), path: ROUTES.ADMIN_CONFIG, icon: 'config', enabled: caps.canConfig },
{ section: 'system', title: tNav('catalog'), subtitle: th('admin_catalog_sub'), path: ROUTES.ADMIN_CATALOG, icon: 'category', enabled: caps.canManageCatalog },
{ section: 'system', title: tNav('holidays'), subtitle: th('admin_holidays_sub'), path: ROUTES.ADMIN_HOLIDAYS, icon: 'calendar', enabled: caps.canConfig },
{ section: 'system', title: tNav('audit'), subtitle: th('admin_audit_sub'), path: ROUTES.ADMIN_AUDIT, icon: 'audit', enabled: caps.canViewAudit },
{ section: 'system', title: tNav('partners'), subtitle: th('admin_partners_sub'), path: ROUTES.ADMIN_PARTNERS, icon: 'partners', enabled: caps.canManagePartners },
@@ -0,0 +1,502 @@
'use client';
import { useState } from 'react';
import { useParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
import { useSnackbar } from 'notistack';
import {
Box,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
FormControlLabel,
Paper,
Skeleton,
Stack,
Switch,
Typography,
} from '@mui/material';
import { AppButton, PageHeader, RhfControlGroup, RhfTextField, StatusChip } from '@/components';
import { AdminEmptyState, AdminErrorState } from '@/components/admin';
import { useAdminCapabilities, useAdminBackToList } from '@/hooks';
import { ROUTES } from '@/constants';
import { ADMIN_CATEGORIES_PAGE_SIZE } from '@/services/catalog/constants';
import type {
CreateOptionValueInput,
OptionGroupInput,
ServiceOptionGroup,
ServiceOptionValue,
UpdateOptionValueInput,
} from '@/services/catalog/types';
import {
useAdminCategories,
useAdminOptionGroups,
useCreateOptionGroup,
useCreateOptionValue,
useSetCategoryActive,
useUpdateOptionGroup,
useUpdateOptionValue,
} from '@/services/catalog';
import { CategoryFormDialog } from '../page';
/**
* A category's pricing-options console (f6 blocker) the option groups (dimensions) and their values
* the nurse builder offers for this category, plus every cross-category group. Option groups have no
* active/inactive toggle in the contract (create/edit only); values do, exposed as a switch in the
* value edit dialog rather than a hard delete, matching the "reference data, never hard-deleted" rule
* that already governs categories and variants.
*/
export default function AdminCatalogCategoryPage() {
const t = useTranslations('admin');
const locale = useLocale();
const caps = useAdminCapabilities();
const { enqueueSnackbar } = useSnackbar();
const goBack = useAdminBackToList(`/${locale}${ROUTES.ADMIN_CATALOG}`);
const params = useParams<{ categoryId: string }>();
const parsed = Number(params?.categoryId);
const categoryId = Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
// No single-category admin read exists — categories are few, so the admin list (uncached) doubles
// as the detail source, exactly like the public reference-data reads do for the browse side.
const categories = useAdminCategories({ page: 1, pageSize: ADMIN_CATEGORIES_PAGE_SIZE });
const category = categories.data?.items.find((c) => c.id === categoryId) ?? null;
const groups = useAdminOptionGroups(categoryId || null);
const setActive = useSetCategoryActive();
const [editingCategory, setEditingCategory] = useState(false);
const [groupDialog, setGroupDialog] = useState<ServiceOptionGroup | 'new' | null>(null);
const [valueDialog, setValueDialog] = useState<{ groupId: number; value: ServiceOptionValue | 'new' } | null>(null);
const back = <PageHeader title={t('og_section_title')} onBack={goBack} backLabel={t('back')} />;
if (categories.isLoading) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{back}
<Skeleton variant="rounded" height={120} />
<Skeleton variant="rounded" height={220} />
</Box>
);
}
if (categories.isError) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{back}
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => categories.refetch()} />
</Box>
);
}
if (!category) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{back}
<AdminEmptyState icon="category" title={t('cat_not_found')} />
</Box>
);
}
const onToggleActive = () => {
setActive.mutate(
{ id: category.id, isActive: !category.isActive },
{ onSuccess: () => enqueueSnackbar(t(category.isActive ? 'cat_deactivated' : 'cat_activated'), { variant: 'success' }) },
);
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<PageHeader
title={category.nameFa}
subtitle={category.nameEn}
onBack={goBack}
backLabel={t('back')}
meta={<StatusChip status={category.isActive ? 'active' : 'neutral'} label={t(category.isActive ? 'status_active' : 'status_inactive')} />}
/>
{category.descriptionFa || category.descriptionEn ? (
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{category.descriptionFa || category.descriptionEn}
</Typography>
</Paper>
) : null}
{caps.canManageCatalog ? (
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
<AppButton variant="outlined" color="inherit" startIcon="edit" onClick={() => setEditingCategory(true)}>
{t('cat_edit')}
</AppButton>
<AppButton
variant="outlined"
color={category.isActive ? 'error' : 'primary'}
onClick={onToggleActive}
disabled={setActive.isPending}
>
{t(category.isActive ? 'cat_deactivate' : 'cat_activate')}
</AppButton>
</Stack>
) : null}
<Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('og_section_title')}
</Typography>
{caps.canManageCatalog ? (
<AppButton variant="text" color="primary" startIcon="add" onClick={() => setGroupDialog('new')}>
{t('og_add')}
</AppButton>
) : null}
</Stack>
{groups.isLoading ? (
<Skeleton variant="rounded" height={160} />
) : groups.isError ? (
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => groups.refetch()} />
) : (groups.data ?? []).length === 0 ? (
<AdminEmptyState icon="tune" title={t('og_empty')} />
) : (
(groups.data ?? []).map((group) => (
<Paper
key={group.id}
elevation={0}
sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}
>
<Stack sx={{ gap: 1.25 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'flex-start', gap: 1, flexWrap: 'wrap' }}>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{group.nameFa} · {group.nameEn}
</Typography>
<Stack direction="row" sx={{ gap: 0.5, mt: 0.5, flexWrap: 'wrap' }}>
{group.serviceCategoryId == null ? <Chip size="small" label={t('og_cross_category_badge')} /> : null}
<Chip size="small" variant="outlined" label={t(group.isRequired ? 'og_required_badge' : 'og_optional_badge')} />
</Stack>
</Box>
{caps.canManageCatalog ? (
<AppButton variant="text" color="primary" startIcon="edit" onClick={() => setGroupDialog(group)}>
{t('cat_edit')}
</AppButton>
) : null}
</Stack>
<Divider />
<Stack sx={{ gap: 1 }}>
{group.values.length === 0 ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('og_value_empty')}
</Typography>
) : (
group.values.map((value) => (
<Stack
key={value.id}
direction="row"
sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}
>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="body2">
{value.nameFa} · {value.nameEn}
</Typography>
<StatusChip status={value.isActive ? 'active' : 'neutral'} label={t(value.isActive ? 'status_active' : 'status_inactive')} />
</Stack>
{caps.canManageCatalog ? (
<AppButton variant="text" color="primary" onClick={() => setValueDialog({ groupId: group.id, value })}>
{t('cat_edit')}
</AppButton>
) : null}
</Stack>
))
)}
</Stack>
{caps.canManageCatalog ? (
<AppButton
variant="outlined"
color="primary"
startIcon="add"
onClick={() => setValueDialog({ groupId: group.id, value: 'new' })}
sx={{ alignSelf: 'flex-start' }}
>
{t('og_value_add')}
</AppButton>
) : null}
</Stack>
</Paper>
))
)}
</Stack>
{editingCategory ? <CategoryFormDialog category={category} onClose={() => setEditingCategory(false)} /> : null}
{groupDialog ? (
<OptionGroupFormDialog
categoryId={category.id}
group={groupDialog === 'new' ? null : groupDialog}
onClose={() => setGroupDialog(null)}
/>
) : null}
{valueDialog ? (
<OptionValueFormDialog
groupId={valueDialog.groupId}
value={valueDialog.value === 'new' ? null : valueDialog.value}
onClose={() => setValueDialog(null)}
/>
) : null}
</Box>
);
}
/** `'this'` maps to `serviceCategoryId: categoryId`; `'all'` maps to the cross-category `null`. */
interface GroupFormState {
scope: 'this' | 'all';
nameFa: string;
nameEn: string;
isRequired: boolean;
sortOrder: string;
}
function initialGroupForm(group: ServiceOptionGroup | null): GroupFormState {
return {
scope: group != null && group.serviceCategoryId == null ? 'all' : 'this',
nameFa: group?.nameFa ?? '',
nameEn: group?.nameEn ?? '',
isRequired: group?.isRequired ?? false,
sortOrder: group != null ? String(group.sortOrder) : '0',
};
}
/**
* The create/edit dialog for a pricing dimension (option group). Re-scoping an existing cross-category
* group to "this category only" removes it from every other category that currently sees it the hint
* text under the switch says so, matching what `UpdateServiceOptionGroupCommand` actually does server-side.
*/
function OptionGroupFormDialog({
categoryId,
group,
onClose,
}: {
categoryId: number;
group: ServiceOptionGroup | null;
onClose: () => void;
}) {
const t = useTranslations('admin');
const { enqueueSnackbar } = useSnackbar();
const isEdit = group != null;
const create = useCreateOptionGroup();
const update = useUpdateOptionGroup();
const isPending = create.isPending || update.isPending;
const form = useForm<GroupFormState>({ mode: 'onTouched', defaultValues: initialGroupForm(group) });
const { control, handleSubmit, formState } = form;
const scope = useWatch({ control, name: 'scope' });
const onSave = (values: GroupFormState) => {
const input: OptionGroupInput = {
serviceCategoryId: values.scope === 'all' ? null : categoryId,
nameFa: values.nameFa.trim(),
nameEn: values.nameEn.trim(),
isRequired: values.isRequired,
sortOrder: Number(values.sortOrder),
};
const onSuccess = () => {
enqueueSnackbar(t('og_saved'), { variant: 'success' });
onClose();
};
if (isEdit) update.mutate({ id: group.id, input }, { onSuccess });
else create.mutate(input, { onSuccess });
};
return (
<Dialog open onClose={isPending ? undefined : onClose} fullWidth maxWidth="xs">
<DialogTitle sx={{ fontWeight: 800 }}>{isEdit ? t('og_edit') : t('og_add')}</DialogTitle>
<FormProvider {...form}>
<DialogContent>
{/* A real <form> so Enter submits from any field; the footer button (outside DialogContent)
calls the same handler directly rather than relying on cross-element form association. */}
<Stack component="form" noValidate onSubmit={handleSubmit(onSave)} sx={{ gap: 2, mt: 1 }}>
<RhfTextField<GroupFormState>
name="nameFa"
label={t('cat_name_fa')}
rules={{ validate: (value) => String(value ?? '').trim() !== '' }}
/>
<RhfTextField<GroupFormState>
name="nameEn"
label={t('cat_name_en')}
rules={{ validate: (value) => String(value ?? '').trim() !== '' }}
/>
<Box>
<RhfControlGroup<GroupFormState> name="scope">
{({ field }) => (
<FormControlLabel
control={
<Switch
checked={field.value === 'all'}
onChange={(event) => field.onChange(event.target.checked ? 'all' : 'this')}
/>
}
label={t('og_scope_all')}
/>
)}
</RhfControlGroup>
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>
{scope === 'all' ? t('og_scope_all_hint') : t('og_scope_this_hint')}
</Typography>
</Box>
<RhfControlGroup<GroupFormState> name="isRequired">
{({ field }) => (
<FormControlLabel
control={<Switch checked={Boolean(field.value)} onChange={(event) => field.onChange(event.target.checked)} />}
label={t('og_is_required')}
/>
)}
</RhfControlGroup>
<RhfTextField<GroupFormState>
name="sortOrder"
type="number"
label={t('cat_sort_order')}
rules={{ validate: (value) => Number.isFinite(Number(value)) }}
slotProps={{ htmlInput: { step: 1 } }}
/>
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={onClose} disabled={isPending}>
{t('cancel')}
</AppButton>
<AppButton
variant="contained"
color="primary"
onClick={handleSubmit(onSave)}
disabled={!formState.isValid || isPending}
>
{isPending ? t('saving') : t('save')}
</AppButton>
</DialogActions>
</FormProvider>
</Dialog>
);
}
interface ValueFormState {
nameFa: string;
nameEn: string;
sortOrder: string;
isActive: boolean;
}
function initialValueForm(value: ServiceOptionValue | null): ValueFormState {
return {
nameFa: value?.nameFa ?? '',
nameEn: value?.nameEn ?? '',
sortOrder: value != null ? String(value.sortOrder) : '0',
isActive: value?.isActive ?? true,
};
}
/**
* The create/edit dialog for a concrete option value. Create has no active flag (the server always
* creates active); edit exposes it as a switch the only way to deactivate a value, since re-parenting
* to a different group is deliberately not supported (it would silently change variants that already
* answered with this value).
*/
function OptionValueFormDialog({
groupId,
value,
onClose,
}: {
groupId: number;
value: ServiceOptionValue | null;
onClose: () => void;
}) {
const t = useTranslations('admin');
const { enqueueSnackbar } = useSnackbar();
const isEdit = value != null;
const create = useCreateOptionValue();
const update = useUpdateOptionValue();
const isPending = create.isPending || update.isPending;
const form = useForm<ValueFormState>({ mode: 'onTouched', defaultValues: initialValueForm(value) });
const { handleSubmit, formState } = form;
const onSave = (values: ValueFormState) => {
const onSuccess = () => {
enqueueSnackbar(t('og_value_saved'), { variant: 'success' });
onClose();
};
if (isEdit) {
const input: UpdateOptionValueInput = {
nameFa: values.nameFa.trim(),
nameEn: values.nameEn.trim(),
sortOrder: Number(values.sortOrder),
isActive: values.isActive,
};
update.mutate({ id: value.id, input }, { onSuccess });
} else {
const input: CreateOptionValueInput = {
optionGroupId: groupId,
nameFa: values.nameFa.trim(),
nameEn: values.nameEn.trim(),
sortOrder: Number(values.sortOrder),
};
create.mutate(input, { onSuccess });
}
};
return (
<Dialog open onClose={isPending ? undefined : onClose} fullWidth maxWidth="xs">
<DialogTitle sx={{ fontWeight: 800 }}>{isEdit ? t('og_value_edit') : t('og_value_add')}</DialogTitle>
<FormProvider {...form}>
<DialogContent>
<Stack component="form" noValidate onSubmit={handleSubmit(onSave)} sx={{ gap: 2, mt: 1 }}>
<RhfTextField<ValueFormState>
name="nameFa"
label={t('cat_name_fa')}
rules={{ validate: (val) => String(val ?? '').trim() !== '' }}
/>
<RhfTextField<ValueFormState>
name="nameEn"
label={t('cat_name_en')}
rules={{ validate: (val) => String(val ?? '').trim() !== '' }}
/>
<RhfTextField<ValueFormState>
name="sortOrder"
type="number"
label={t('cat_sort_order')}
rules={{ validate: (val) => Number.isFinite(Number(val)) }}
slotProps={{ htmlInput: { step: 1 } }}
/>
{isEdit ? (
<RhfControlGroup<ValueFormState> name="isActive">
{({ field }) => (
<FormControlLabel
control={<Switch checked={Boolean(field.value)} onChange={(event) => field.onChange(event.target.checked)} />}
label={t(field.value ? 'status_active' : 'status_inactive')}
/>
)}
</RhfControlGroup>
) : null}
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={onClose} disabled={isPending}>
{t('cancel')}
</AppButton>
<AppButton
variant="contained"
color="primary"
onClick={handleSubmit(onSave)}
disabled={!formState.isValid || isPending}
>
{isPending ? t('saving') : t('save')}
</AppButton>
</DialogActions>
</FormProvider>
</Dialog>
);
}
@@ -0,0 +1,244 @@
'use client';
import { Suspense, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { FormProvider, useForm } from 'react-hook-form';
import { useSnackbar } from 'notistack';
import { Box, Dialog, DialogActions, DialogContent, DialogTitle, Skeleton, Stack } from '@mui/material';
import { AppButton, AppLoading, RhfTextField, StatusChip } from '@/components';
import { AdminDataTable, AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, type AdminTableColumn } from '@/components/admin';
import { useAdminCapabilities, useAdminListState } from '@/hooks';
import { adminCatalogCategoryPath } from '@/constants';
import { ADMIN_CATEGORIES_PAGE_SIZE } from '@/services/catalog/constants';
import type { CategoryInput, ServiceCategory } from '@/services/catalog/types';
import { useAdminCategories, useCreateCategory, useSetCategoryActive, useUpdateCategory } from '@/services/catalog';
/** No list-level filters today (the list is unfiltered) — `useAdminListState` still URL-syncs the page. */
type CategoryListFilters = Record<string, never>;
const EMPTY_FILTERS: CategoryListFilters = {};
/**
* Catalog admin the top-level category list (f6 blocker). Categories are seeded by migration, but
* pricing options are deliberately admin-authored data (never a migration), so outside a developer's
* own dev-seeded box there is nothing here until an admin creates it this console is the fix.
* A row opens the category's option-groups/values console; the create dialog here handles categories
* only. Categories are reference data deactivating (never a hard delete) hides a category from
* public browse and new bookings while leaving existing variants/bookings intact.
*/
export default function AdminCatalogPage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminCatalogPageInner />
</Suspense>
);
}
function AdminCatalogPageInner() {
const t = useTranslations('admin');
const locale = useLocale();
const router = useRouter();
const caps = useAdminCapabilities();
const { enqueueSnackbar } = useSnackbar();
const [creating, setCreating] = useState(false);
const setActive = useSetCategoryActive();
const listState = useAdminListState<CategoryListFilters>({
parse: () => EMPTY_FILTERS,
serialize: () => ({}),
empty: EMPTY_FILTERS,
});
const page = listState.page;
const categories = useAdminCategories({ page, pageSize: ADMIN_CATEGORIES_PAGE_SIZE });
const items = categories.data?.items ?? [];
const total = categories.data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / ADMIN_CATEGORIES_PAGE_SIZE));
const from = items.length === 0 ? 0 : (page - 1) * ADMIN_CATEGORIES_PAGE_SIZE + 1;
const to = (page - 1) * ADMIN_CATEGORIES_PAGE_SIZE + items.length;
const onToggleActive = (category: ServiceCategory) => {
setActive.mutate(
{ id: category.id, isActive: !category.isActive },
{ onSuccess: () => enqueueSnackbar(t(category.isActive ? 'cat_deactivated' : 'cat_activated'), { variant: 'success' }) },
);
};
const columns: AdminTableColumn<ServiceCategory>[] = [
{ key: 'name', header: t('cat_col_name'), render: (c) => `${c.nameFa} · ${c.nameEn}` },
{ key: 'order', header: t('cat_col_order'), render: (c) => c.sortOrder },
{
key: 'status',
header: t('cat_col_status'),
render: (c) => <StatusChip status={c.isActive ? 'active' : 'neutral'} label={t(c.isActive ? 'status_active' : 'status_inactive')} />,
},
...(caps.canManageCatalog
? [
{
key: 'actions',
header: '',
render: (c: ServiceCategory) => (
<Stack direction="row" sx={{ gap: 0.5, flexWrap: 'wrap' }}>
<AppButton
variant="text"
color={c.isActive ? 'error' : 'primary'}
onClick={(event) => {
event.stopPropagation();
onToggleActive(c);
}}
disabled={setActive.isPending}
>
{t(c.isActive ? 'cat_deactivate' : 'cat_activate')}
</AppButton>
</Stack>
),
} as AdminTableColumn<ServiceCategory>,
]
: []),
];
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader
title={t('cat_title')}
subtitle={t('cat_subtitle')}
actions={
caps.canManageCatalog ? (
<AppButton variant="contained" color="primary" startIcon="add" onClick={() => setCreating(true)}>
{t('cat_add')}
</AppButton>
) : undefined
}
/>
{categories.isLoading ? (
<Skeleton variant="rounded" height={220} />
) : categories.isError ? (
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => categories.refetch()} />
) : items.length === 0 ? (
<AdminEmptyState icon="category" title={t('cat_empty')} />
) : (
<AdminDataTable
columns={columns}
rows={items}
getRowKey={(c) => c.id}
ariaLabel={t('cat_title')}
onRowClick={(c) => router.push(`/${locale}${adminCatalogCategoryPath(c.id)}`)}
footer={total > 0 ? t('showing_range', { from, to, total }) : undefined}
/>
)}
<AdminPager
page={page}
pageCount={pageCount}
onPrev={() => listState.goToPage(Math.max(1, page - 1))}
onNext={() => listState.goToPage(Math.min(pageCount, page + 1))}
prevLabel={t('prev_page')}
nextLabel={t('next_page')}
indicator={t('page_indicator', { page, total: pageCount })}
/>
{creating ? <CategoryFormDialog category={null} onClose={() => setCreating(false)} /> : null}
</Box>
);
}
/** The editable slice of `CategoryInput`, kept as strings for controlled text/number inputs. */
interface CategoryFormState {
nameFa: string;
nameEn: string;
descriptionFa: string;
descriptionEn: string;
iconKey: string;
sortOrder: string;
}
function initialForm(category: ServiceCategory | null): CategoryFormState {
return {
nameFa: category?.nameFa ?? '',
nameEn: category?.nameEn ?? '',
descriptionFa: category?.descriptionFa ?? '',
descriptionEn: category?.descriptionEn ?? '',
iconKey: category?.iconKey ?? '',
sortOrder: category != null ? String(category.sortOrder) : '0',
};
}
/**
* The create/edit dialog for a category shared by the list (create, `category=null`) and the detail
* page (edit, `category` prefilled). Both labels are required; description/icon are optional.
*/
export function CategoryFormDialog({ category, onClose }: { category: ServiceCategory | null; onClose: () => void }) {
const t = useTranslations('admin');
const { enqueueSnackbar } = useSnackbar();
const isEdit = category != null;
const create = useCreateCategory();
const update = useUpdateCategory();
const form = useForm<CategoryFormState>({ mode: 'onTouched', defaultValues: initialForm(category) });
const { handleSubmit, formState } = form;
const isPending = create.isPending || update.isPending;
const onSave = (values: CategoryFormState) => {
const input: CategoryInput = {
nameFa: values.nameFa.trim(),
nameEn: values.nameEn.trim(),
descriptionFa: values.descriptionFa.trim() || null,
descriptionEn: values.descriptionEn.trim() || null,
iconKey: values.iconKey.trim() || null,
sortOrder: Number(values.sortOrder),
};
const onSuccess = () => {
enqueueSnackbar(t('cat_saved'), { variant: 'success' });
onClose();
};
if (isEdit) update.mutate({ id: category.id, input }, { onSuccess });
else create.mutate(input, { onSuccess });
};
return (
<Dialog open onClose={isPending ? undefined : onClose} fullWidth maxWidth="xs">
<DialogTitle sx={{ fontWeight: 800 }}>{isEdit ? t('cat_edit') : t('cat_add')}</DialogTitle>
<FormProvider {...form}>
<DialogContent>
{/* A real <form> so Enter submits from any field; the footer button (outside DialogContent)
calls the same handler directly rather than relying on cross-element form association. */}
<Stack component="form" noValidate onSubmit={handleSubmit(onSave)} sx={{ gap: 2, mt: 1 }}>
<RhfTextField<CategoryFormState>
name="nameFa"
label={t('cat_name_fa')}
rules={{ validate: (value) => String(value ?? '').trim() !== '' }}
/>
<RhfTextField<CategoryFormState>
name="nameEn"
label={t('cat_name_en')}
rules={{ validate: (value) => String(value ?? '').trim() !== '' }}
/>
<RhfTextField<CategoryFormState> name="descriptionFa" label={t('cat_description_fa')} multiline minRows={2} />
<RhfTextField<CategoryFormState> name="descriptionEn" label={t('cat_description_en')} multiline minRows={2} />
<RhfTextField<CategoryFormState> name="iconKey" label={t('cat_icon_key')} helperText={t('cat_icon_hint')} slotProps={{ htmlInput: { dir: 'ltr' } }} />
<RhfTextField<CategoryFormState>
name="sortOrder"
type="number"
label={t('cat_sort_order')}
rules={{ validate: (value) => Number.isFinite(Number(value)) }}
slotProps={{ htmlInput: { step: 1 } }}
/>
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={onClose} disabled={isPending}>
{t('cancel')}
</AppButton>
<AppButton
variant="contained"
color="primary"
onClick={handleSubmit(onSave)}
disabled={!formState.isValid || isPending}
>
{isPending ? t('saving') : t('save')}
</AppButton>
</DialogActions>
</FormProvider>
</Dialog>
);
}
@@ -34,6 +34,13 @@ export default function AdminSystemPage() {
path: ROUTES.ADMIN_CONFIG,
enabled: caps.canConfig,
},
{
title: tn('catalog'),
subtitle: t('admin_catalog_sub'),
icon: 'category',
path: ROUTES.ADMIN_CATALOG,
enabled: caps.canManageCatalog,
},
{
title: tn('holidays'),
subtitle: t('admin_holidays_sub'),
@@ -75,7 +75,7 @@ function AdminVerificationCaseScreen() {
const caps = useAdminCapabilities();
const { enqueueSnackbar } = useSnackbar();
const { data, isLoading, isError, refetch } = useVerificationCase(
const { data, isLoading, isError, isFetching, refetch } = useVerificationCase(
Number.isFinite(nurseVerificationId) ? nurseVerificationId : null,
);
const approve = useApproveVerification();
@@ -199,6 +199,8 @@ function AdminVerificationCaseScreen() {
step={step}
nurseVerificationId={nurseVerificationId}
canVerify={caps.canVerify}
onReloadDocuments={refetch}
reloadingDocuments={isFetching}
/>
))}
</Stack>
@@ -290,10 +292,14 @@ function StepCard({
step,
nurseVerificationId,
canVerify,
onReloadDocuments,
reloadingDocuments,
}: {
step: AdminVerificationStepDetail;
nurseVerificationId: number;
canVerify: boolean;
onReloadDocuments: () => void;
reloadingDocuments: boolean;
}) {
const t = useTranslations('admin');
const { enqueueSnackbar } = useSnackbar();
@@ -339,7 +345,12 @@ function StepCard({
{step.documents.length > 0 ? (
<Stack sx={{ gap: 1.5, mt: 1.5 }}>
{step.documents.map((doc) => (
<DocumentViewer key={doc.id} document={doc} />
<DocumentViewer
key={doc.id}
document={doc}
onReload={onReloadDocuments}
reloading={reloadingDocuments}
/>
))}
</Stack>
) : isManual ? (
@@ -24,7 +24,7 @@ const PREVIEW: CancellationPolicyPreview = {
bookingId: 5003,
cancellable: true,
cancellationPolicyCode: 'free_24h',
refundPercentageApplied: 1,
refundPercentageApplied: 100,
feePercentage: 0,
refundAmountIrr: '17600000',
feeAmountIrr: '0',
@@ -15,9 +15,10 @@ export interface CancellationPolicyDisclosureProps {
preview: CancellationPolicyPreview;
}
/** Percent (integer) from a 01 fraction — a small display number, never money, so JS math is safe. */
function toPercent(fraction: number): number {
return Math.round(fraction * 100);
/** Rounds an already-0100 percent value for display a small display number, never money, so JS math is
* safe. Never re-multiply by 100: the server (and the mock) already serve this scale. */
function toPercent(percent: number): number {
return Math.round(percent);
}
/**
@@ -25,6 +25,7 @@ const NURSE: NurseSearchResult = {
nurseId: 1,
variantId: 11,
serviceCategoryId: 1,
matchingServiceCount: 1,
nurseName: 'Maryam Rezaei',
avatarUrl: null,
isVerified: true,
@@ -112,6 +113,14 @@ describe('<NurseResultCard/> component', () => {
expect(screen.getByText(/منظم و دقیق/)).toBeInTheDocument();
});
it('discloses "+N more services" only when the nurse matched more than one variant', () => {
renderCard(NURSE);
expect(screen.queryByText(/more_services_count/)).not.toBeInTheDocument();
renderCard({ ...NURSE, matchingServiceCount: 3 });
expect(screen.getByText(/more_services_count:2/)).toBeInTheDocument();
});
it('falls back to a label when the name is missing (b7 join gap)', () => {
renderCard({ ...NURSE, nurseName: '' });
expect(screen.getByText('unnamed_nurse')).toBeInTheDocument();
@@ -84,11 +84,12 @@ const NurseResultCard = ({ nurse, serviceLabel, onSelect }: NurseResultCardProps
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{name}
</Typography>
<TrustBadge state="verified" nurseId={nurse.nurseId} />
<TrustBadge state={nurse.isVerified ? 'verified' : 'unverified'} nurseId={nurse.nurseId} />
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{serviceLabel}
{nurse.matchingServiceCount > 1 ? ` · ${t('more_services_count', { count: nurse.matchingServiceCount - 1 })}` : ''}
</Typography>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap', mt: 0.25 }}>
@@ -24,7 +24,7 @@ const base: RefundSummary = {
totalRefundedIrr: '45000000',
expectedCustomerRefundEta: null,
externalRevertReference: null,
refundPercentageApplied: 1,
refundPercentageApplied: 100,
cancellationPolicyCode: 'free_24h',
platformFeeRefundedIrr: '5400000',
nursePayoutRefundedIrr: '39600000',
@@ -3,9 +3,6 @@ import { ThemeProvider } from '../../theme';
jest.mock('next-intl', () => ({ useTranslations: () => (k: string) => k, useLocale: () => 'en' }));
const mockUseDocUrl = jest.fn();
jest.mock('@/services/verification', () => ({ useVerificationDocumentUrl: (...a: unknown[]) => mockUseDocUrl(...a) }));
import DocumentViewer from './DocumentViewer';
import type { VerificationDocument } from '@/services/verification/types';
@@ -14,35 +11,28 @@ const DOC: VerificationDocument = {
contentType: 'image/png',
fileSizeBytes: 2048,
originalFileName: 'license.png',
url: 'ignored-embedded-url',
url: 'https://signed/fresh.png',
};
describe('<DocumentViewer/>', () => {
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
afterEach(() => mockUseDocUrl.mockReset());
it('renders the loaded image from the on-demand signed url (never the embedded url)', () => {
mockUseDocUrl.mockReturnValue({ data: { url: 'https://signed/fresh.png', expiresInSeconds: 60 }, isLoading: false, isFetching: false, isError: false, refetch: jest.fn() });
const { container } = wrap(<DocumentViewer document={DOC} />);
it('renders the image from the case-embedded signed url', () => {
const { container } = wrap(<DocumentViewer document={DOC} onReload={jest.fn()} reloading={false} />);
const img = container.querySelector('img') as HTMLImageElement;
expect(img).toBeTruthy();
expect(img.src).toContain('signed/fresh.png');
expect(img.src).not.toContain('ignored-embedded-url');
});
it('offers a re-request affordance on error and calls refetch', () => {
const refetch = jest.fn();
mockUseDocUrl.mockReturnValue({ data: undefined, isLoading: false, isFetching: false, isError: true, refetch });
wrap(<DocumentViewer document={DOC} />);
expect(screen.getByText('doc_error')).toBeInTheDocument();
// Two re-request buttons (header + error panel); click the first.
fireEvent.click(screen.getAllByText('doc_reload')[0]);
expect(refetch).toHaveBeenCalled();
it('calls onReload — there is no per-document endpoint, so reload refetches the parent case', () => {
const onReload = jest.fn();
wrap(<DocumentViewer document={DOC} onReload={onReload} reloading={false} />);
fireEvent.click(screen.getByText('doc_reload'));
expect(onReload).toHaveBeenCalled();
});
it('shows a skeleton while the signed url is loading', () => {
mockUseDocUrl.mockReturnValue({ data: undefined, isLoading: true, isFetching: true, isError: false, refetch: jest.fn() });
const { container } = wrap(<DocumentViewer document={DOC} />);
it('shows a skeleton while the parent case is (re)loading', () => {
const { container } = wrap(<DocumentViewer document={DOC} onReload={jest.fn()} reloading />);
expect(container.querySelector('.MuiSkeleton-root')).toBeInTheDocument();
});
});
+17 -23
View File
@@ -3,12 +3,16 @@ import { FunctionComponent } from 'react';
import { useTranslations } from 'next-intl';
import { Box, Skeleton, Stack, Typography } from '@mui/material';
import type { VerificationDocument } from '@/services/verification/types';
import { useVerificationDocumentUrl } from '@/services/verification';
import AppButton from '../common/AppButton';
import AppIcon from '../common/AppIcon';
export interface DocumentViewerProps {
document: VerificationDocument;
/** Refetches the parent case there is no per-document re-sign route, so a reopen re-fetches the whole
* case to get a freshly-signed `document.url`. */
onReload: () => void;
/** True while the parent case is (re)loading — drives the skeleton. */
reloading: boolean;
}
/** Human-readable file size. */
@@ -19,16 +23,15 @@ function formatSize(bytes: number): string {
}
/**
* A verification-document viewer that fetches its **signed URL on demand** (never the embedded one those
* are short-lived) via `useVerificationDocumentUrl`. Handles the full lifecycle: **loading** the link,
* **error / expired re-request** (the URL is short-lived, so a manual re-request re-signs it), and the
* loaded state (inline image preview for images, otherwise an "open in a new tab" affordance). PII only
* the signed URL is ever surfaced, never a public asset (phase §5).
* A verification-document viewer. `document.url` is the **short-lived signed GET URL the case detail
* already carries** there is no separate per-document re-sign route (b6 gap), so "reload" refetches the
* whole case (`onReload`) rather than calling a document-specific endpoint. Handles the loading (parent
* case fetching) and loaded states (inline image preview for images, otherwise an "open in a new tab"
* affordance). PII only the signed URL is ever surfaced, never a public asset (phase §5).
* @component DocumentViewer
*/
const DocumentViewer: FunctionComponent<DocumentViewerProps> = ({ document }) => {
const DocumentViewer: FunctionComponent<DocumentViewerProps> = ({ document, onReload, reloading }) => {
const t = useTranslations('admin');
const signed = useVerificationDocumentUrl(document.id);
const isImage = document.contentType.startsWith('image/');
return (
@@ -45,36 +48,27 @@ const DocumentViewer: FunctionComponent<DocumentViewerProps> = ({ document }) =>
variant="text"
color="primary"
startIcon="refresh"
onClick={() => signed.refetch()}
disabled={signed.isFetching}
onClick={onReload}
disabled={reloading}
sx={{ minWidth: 0 }}
>
{t('doc_reload')}
</AppButton>
</Stack>
{signed.isLoading || signed.isFetching ? (
{reloading ? (
<Skeleton variant="rounded" height={isImage ? 180 : 44} />
) : signed.isError ? (
<Stack sx={{ gap: 1, alignItems: 'flex-start' }}>
<Typography variant="body2" sx={{ color: 'var(--bal-error)' }}>
{t('doc_error')}
</Typography>
<AppButton variant="outlined" color="primary" startIcon="refresh" onClick={() => signed.refetch()}>
{t('doc_reload')}
</AppButton>
</Stack>
) : signed.data?.url ? (
) : document.url ? (
isImage ? (
// Signed, short-lived, cross-host URL (not a static asset) — a plain <img> via Box, not next/image.
<Box
component="img"
src={signed.data.url}
src={document.url}
alt={document.originalFileName ?? String(document.id)}
sx={{ maxWidth: '100%', maxHeight: 320, borderRadius: 'var(--bal-radius-sm)', display: 'block' }}
/>
) : (
<AppButton variant="outlined" color="primary" endIcon="external" href={signed.data.url} openInNewTab>
<AppButton variant="outlined" color="primary" endIcon="external" href={document.url} openInNewTab>
{t('doc_open_new')}
</AppButton>
)
@@ -51,6 +51,8 @@ describe('<AddressForm/> component', () => {
latitude: 35.7,
longitude: 51.4,
isPrimary: false,
recipientName: 'Sara',
recipientPhone: '09120001234',
}}
onSubmit={onSubmit}
/>
@@ -67,6 +69,8 @@ describe('<AddressForm/> component', () => {
latitude: 35.7,
longitude: 51.4,
isPrimary: false,
recipientName: 'Sara',
recipientPhone: '09120001234',
});
});
});
@@ -7,6 +7,7 @@ import Stack from '@mui/material/Stack';
import Switch from '@mui/material/Switch';
import { AppButton } from '@/components/common';
import { RhfControlGroup, RhfTextField } from '@/components/common/form';
import PhoneNumberField, { isIranianMobile } from '@/components/PhoneNumberField';
import { cityCentroid } from '@/services/geography/constants';
import type { CreateAddressInput, LatLng } from '@/services/addresses/types';
import CascadingRegionSelect, { type CascadingRegionValue } from './CascadingRegionSelect';
@@ -22,6 +23,8 @@ export interface AddressFormInitial {
latitude?: number | null;
longitude?: number | null;
isPrimary?: boolean;
recipientName?: string | null;
recipientPhone?: string | null;
}
export interface AddressFormProps {
@@ -39,6 +42,8 @@ interface AddressFormValues {
pin: LatLng | null;
addressLine: string;
isPrimary: boolean;
recipientName: string;
recipientPhone: string;
}
// Only prefill the region when we have the province — a city id without its province can't drive
@@ -78,6 +83,8 @@ const AddressForm: FunctionComponent<AddressFormProps> = ({ initial, submitting
pin: initialPin(initial),
addressLine: initial?.addressLine ?? '',
isPrimary: initial?.isPrimary ?? false,
recipientName: initial?.recipientName ?? '',
recipientPhone: initial?.recipientPhone ?? '',
},
});
const { control, handleSubmit, formState } = form;
@@ -98,6 +105,8 @@ const AddressForm: FunctionComponent<AddressFormProps> = ({ initial, submitting
latitude: (values.pin as LatLng).latitude,
longitude: (values.pin as LatLng).longitude,
isPrimary: values.isPrimary,
recipientName: values.recipientName.trim(),
recipientPhone: values.recipientPhone,
});
};
@@ -153,6 +162,29 @@ const AddressForm: FunctionComponent<AddressFormProps> = ({ initial, submitting
fullWidth
/>
<RhfTextField<AddressFormValues>
name="recipientName"
label={t('recipient_name_label')}
rules={{ validate: (value) => String(value ?? '').trim().length > 0 || t('recipient_name_required') }}
fullWidth
/>
<RhfControlGroup<AddressFormValues>
name="recipientPhone"
rules={{ validate: (value) => isIranianMobile(String(value ?? '')) }}
>
{({ field, hasError }) => (
<PhoneNumberField
label={t('recipient_phone_label')}
value={(field.value as string) ?? ''}
onChange={field.onChange}
error={hasError}
helperText={hasError ? t('recipient_phone_invalid') : undefined}
fullWidth
/>
)}
</RhfControlGroup>
<RhfControlGroup<AddressFormValues> name="isPrimary">
{({ field }) => (
<FormControlLabel
+8
View File
@@ -27,6 +27,8 @@ export const ROUTES = {
BOOKING_REQUEST_STATUS: '/bookings/request',
// Checkout (f9) — C6 summary & pay; the C5 accept CTA hands off here with `?request_id=`.
CHECKOUT: '/bookings/checkout',
// Mock-gateway harness (test-only stand-in for the PSP redirect; the mock initiate points here).
CHECKOUT_GATEWAY: '/bookings/checkout/gateway',
// Return-from-gateway surface — pending-callback poll → succeeded/failed states.
CHECKOUT_RETURN: '/bookings/checkout/return',
// Post-payment success screen — links to the booking detail + invoice.
@@ -108,6 +110,8 @@ export const ROUTES = {
ADMIN_REVIEWS: '/admin/reviews',
// Platform config editor + change history.
ADMIN_CONFIG: '/admin/config',
// Catalog skeleton editor — categories + a category's option groups/values; append `/{categoryId}`.
ADMIN_CATALOG: '/admin/catalog',
// Iranian-holiday calendar manager (drives payout scheduling; is_bank_closed toggle).
ADMIN_HOLIDAYS: '/admin/holidays',
// Support-alert triage board — internal-only (assign/resolve).
@@ -144,6 +148,10 @@ export const adminPayoutBatchPath = (batchId: number | string): string =>
export const adminPartnerCenterPath = (centerId: number | string): string =>
`${ROUTES.ADMIN_PARTNERS}/${centerId}`;
/** The admin catalog category detail — its option groups/values, including inactive ones. */
export const adminCatalogCategoryPath = (categoryId: number | string): string =>
`${ROUTES.ADMIN_CATALOG}/${categoryId}`;
/** The partner-portal sponsored-booking detail (f15) — a bookings-list row deep-links here (read-only). */
export const partnerBookingDetailPath = (bookingId: number | string): string =>
`${ROUTES.PARTNER_BOOKINGS}/${bookingId}`;
+2
View File
@@ -23,6 +23,7 @@ export interface AdminCapabilities {
canPayout: boolean;
canModerate: boolean;
canConfig: boolean;
canManageCatalog: boolean;
canManageAlerts: boolean;
canManageTickets: boolean;
canManagePartners: boolean;
@@ -57,6 +58,7 @@ export function useAdminCapabilities(): AdminCapabilities {
canPayout: has(roles, 'super_admin', 'admin', 'finance'),
canModerate: has(roles, 'super_admin', 'admin', 'moderation'),
canConfig: has(roles, 'super_admin', 'admin', 'finance'),
canManageCatalog: has(roles, 'super_admin', 'admin'),
canManageAlerts: has(roles, 'super_admin', 'admin', 'support'),
canManageTickets: has(roles, 'super_admin', 'admin', 'support'),
canManagePartners: has(roles, 'super_admin', 'admin'),
+6 -12
View File
@@ -2,18 +2,12 @@
* When true, the BNPL domain is served by the in-memory mock (`apis/mockApi.ts`) behind the `BnplApi`
* seam.
*
* **Mock is primary this phase.** b12 ships the eligibility/initiate/webhook/settle endpoints server-side,
* but the D1D5 checkout cannot run real end-to-end from the client yet:
* - the accepted request being financed comes from the **mock-primary** `bookingRequests` store (f7), so a
* real `initiate` would reference an id that exists only in memory (same reason f9 payment is mock-primary);
* - the contract serves **no provider/plan options** (D1/D2), **no repayment schedule** (D4 the contract
* explicitly does not model the customer's repayment schedule), and **no provider-reported installment
* status** for the Wallet (D5) REQ-022/023/024;
* - nothing fires the provider webhook in dev, so a real order would never settle.
* The mock closes the loop: the settle (down-payment cleared) converts the f7 request, inserts a **confirmed**
* booking into the f8 store (the SAME bridge f9 uses a settled BNPL order is a card payment net-of-fee),
* and seeds a Wallet installment plan so C6 D1 D4 confirmation D5 demos end-to-end. Flip to
* `false` once the upstream domains are real and REQ-022/023/024 land no hook/component change.
* **Mock is still primary** (blocker-phase 05 landed Bug A the seeded BNPL gateway but not Bug B):
* the D1 wizard hard-gates on `getBnplOptions` (`GET checkout_bnpl/options/{id}`), and `getBnplSchedule` /
* `getWalletInstallments` have no server counterpart either `CheckoutBnplController` only ever grew
* `eligibility` / `initiate` / `GET {id}` / `by_request/{id}` (REQ-022/023/024 never landed). Flipping this
* flag today trades the old "mock store ages out" failure for an immediate `isError` on D1. Flip once those
* three endpoints exist server-side no hook/component change needed at that point.
*/
export const USE_BNPL_MOCK = true;
+78 -1
View File
@@ -1,17 +1,23 @@
import { clientFetch } from '@/lib/api/client';
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
import { CATEGORIES_PAGE_SIZE, MY_VARIANTS_PAGE_SIZE } from '../constants';
import { ADMIN_CATEGORIES_PAGE_SIZE, CATEGORIES_PAGE_SIZE, MY_VARIANTS_PAGE_SIZE } from '../constants';
import type {
CatalogApi,
CategoryInput,
CreateOptionValueInput,
CreateVariantInput,
NurseServiceVariant,
OptionGroupInput,
ServiceCategory,
ServiceOptionGroup,
ServiceOptionValue,
UpdateOptionValueInput,
UpdateVariantInput,
} from '../types';
const CATALOG_BASE = '/api/v1/catalog';
const VARIANTS_BASE = '/api/v1/nurse_variants';
const ADMIN_CATALOG_BASE = '/api/v1/admin_catalog';
/**
* Real HTTP implementation of the CatalogApi seam (b5 contract `dev/contracts/domains/catalog.md`).
@@ -73,4 +79,75 @@ export const catalogClientApi: CatalogApi = {
body: JSON.stringify({ isActive }),
});
},
adminListCategories: async (params) => {
const query = new URLSearchParams();
query.set('page', String(params?.page ?? 1));
query.set('pageSize', String(params?.pageSize ?? ADMIN_CATEGORIES_PAGE_SIZE));
return unwrap(
await clientFetch<ApiEnvelope<Paginated<ServiceCategory>>>(`${ADMIN_CATALOG_BASE}/list_categories?${query.toString()}`),
);
},
createCategory: async (input: CategoryInput) =>
unwrap(
await clientFetch<ApiEnvelope<ServiceCategory>>(`${ADMIN_CATALOG_BASE}/create_category`, {
method: 'POST',
body: JSON.stringify(input),
}),
),
updateCategory: async (id, input: CategoryInput) =>
unwrap(
await clientFetch<ApiEnvelope<ServiceCategory>>(`${ADMIN_CATALOG_BASE}/update_category/${id}`, {
method: 'POST',
body: JSON.stringify(input),
}),
),
setCategoryActive: async (id, isActive) => {
await clientFetch<ApiEnvelope<boolean>>(`${ADMIN_CATALOG_BASE}/set_category_active/${id}`, {
method: 'POST',
body: JSON.stringify({ isActive }),
});
},
adminListOptionGroups: async (categoryId) =>
unwrap(
await clientFetch<ApiEnvelope<ServiceOptionGroup[]>>(
`${ADMIN_CATALOG_BASE}/list_option_groups?category_id=${categoryId}`,
),
),
createOptionGroup: async (input: OptionGroupInput) =>
unwrap(
await clientFetch<ApiEnvelope<ServiceOptionGroup>>(`${ADMIN_CATALOG_BASE}/create_option_group`, {
method: 'POST',
body: JSON.stringify(input),
}),
),
updateOptionGroup: async (id, input: OptionGroupInput) =>
unwrap(
await clientFetch<ApiEnvelope<ServiceOptionGroup>>(`${ADMIN_CATALOG_BASE}/update_option_group/${id}`, {
method: 'POST',
body: JSON.stringify(input),
}),
),
createOptionValue: async (input: CreateOptionValueInput) =>
unwrap(
await clientFetch<ApiEnvelope<ServiceOptionValue>>(`${ADMIN_CATALOG_BASE}/create_option_value`, {
method: 'POST',
body: JSON.stringify(input),
}),
),
updateOptionValue: async (id, input: UpdateOptionValueInput) =>
unwrap(
await clientFetch<ApiEnvelope<ServiceOptionValue>>(`${ADMIN_CATALOG_BASE}/update_option_value/${id}`, {
method: 'POST',
body: JSON.stringify(input),
}),
),
};
+104 -6
View File
@@ -1,16 +1,20 @@
import { sleep } from '@/utils';
import { ApiError } from '@/lib/api/errors';
import type { PageParams, Paginated } from '@/lib/api/types';
import { CATEGORIES_PAGE_SIZE, MY_VARIANTS_PAGE_SIZE } from '../constants';
import { ADMIN_CATEGORIES_PAGE_SIZE, CATEGORIES_PAGE_SIZE, MY_VARIANTS_PAGE_SIZE } from '../constants';
import {
isGroupApplicable,
optionSetSignature,
type CatalogApi,
type CategoryInput,
type CreateOptionValueInput,
type CreateVariantInput,
type NurseServiceVariant,
type OptionGroupInput,
type ServiceCategory,
type ServiceOptionGroup,
type ServiceOptionValue,
type UpdateOptionValueInput,
type UpdateVariantInput,
type VariantOption,
type VariantOptionSelection,
@@ -27,6 +31,14 @@ const bySortOrder = <T extends { sortOrder: number }>(a: T, b: T) => a.sortOrder
let store: NurseServiceVariant[] = [];
let nextVariantId = 1;
// The admin-managed skeleton — seeded as a mutable copy so a create/update/deactivate in the admin
// console is reflected back through the public read methods below, exactly like the real DB.
let categoryStore: ServiceCategory[] = SEED_CATEGORIES.map((category) => ({ ...category }));
let groupStore: ServiceOptionGroup[] = SEED_OPTION_GROUPS.map((group) => ({ ...group, values: [...group.values] }));
let nextCategoryId = Math.max(0, ...categoryStore.map((c) => c.id)) + 1;
let nextGroupId = Math.max(0, ...groupStore.map((g) => g.id)) + 1;
let nextValueId = Math.max(0, ...groupStore.flatMap((g) => g.values.map((v) => v.id))) + 1;
/** Active-first (contract list order), then most-recent within each group. */
const orderedVariants = () =>
[...store].sort((a, b) => Number(b.isActive) - Number(a.isActive) || b.id - a.id);
@@ -39,9 +51,7 @@ function paginate<T>(all: T[], params?: PageParams, defaultSize = 50): Paginated
}
function applicableGroups(categoryId: number): ServiceOptionGroup[] {
return SEED_OPTION_GROUPS.filter((group) => group.isActive && isGroupApplicable(group, categoryId)).sort(
bySortOrder,
);
return groupStore.filter((group) => group.isActive && isGroupApplicable(group, categoryId)).sort(bySortOrder);
}
function findValue(group: ServiceOptionGroup, valueId: number): ServiceOptionValue | undefined {
@@ -128,7 +138,7 @@ function assertValidCreate(category: ServiceCategory, groups: ServiceOptionGroup
export const catalogMockApi: CatalogApi = {
listCategories: async (params) => {
await sleep(MOCK_LATENCY_MS);
const active = SEED_CATEGORIES.filter((category) => category.isActive).sort(bySortOrder);
const active = categoryStore.filter((category) => category.isActive).sort(bySortOrder);
return paginate(active, params, CATEGORIES_PAGE_SIZE);
},
@@ -155,7 +165,7 @@ export const catalogMockApi: CatalogApi = {
createVariant: async (input) => {
await sleep(MOCK_LATENCY_MS);
const category = SEED_CATEGORIES.find((candidate) => candidate.isActive && candidate.id === input.serviceCategoryId);
const category = categoryStore.find((candidate) => candidate.isActive && candidate.id === input.serviceCategoryId);
if (!category) throw new ApiError(400, 'Missing or inactive category', 'invalid_category');
const groups = applicableGroups(category.id);
@@ -205,4 +215,92 @@ export const catalogMockApi: CatalogApi = {
if (!existing) throw new ApiError(404, 'Variant not found', 'not_found');
store = store.map((candidate) => (candidate.id === id ? { ...candidate, isActive } : candidate));
},
adminListCategories: async (params) => {
await sleep(MOCK_LATENCY_MS);
return paginate([...categoryStore].sort(bySortOrder), params, ADMIN_CATEGORIES_PAGE_SIZE);
},
createCategory: async (input: CategoryInput) => {
await sleep(MOCK_LATENCY_MS);
const category: ServiceCategory = { id: nextCategoryId++, ...input, isActive: true };
categoryStore = [...categoryStore, category];
return category;
},
updateCategory: async (id, input: CategoryInput) => {
await sleep(MOCK_LATENCY_MS);
const existing = categoryStore.find((candidate) => candidate.id === id);
if (!existing) throw new ApiError(404, 'Category not found', 'not_found');
const updated: ServiceCategory = { ...existing, ...input };
categoryStore = categoryStore.map((candidate) => (candidate.id === id ? updated : candidate));
return updated;
},
setCategoryActive: async (id, isActive) => {
await sleep(MOCK_LATENCY_MS);
const existing = categoryStore.find((candidate) => candidate.id === id);
if (!existing) throw new ApiError(404, 'Category not found', 'not_found');
categoryStore = categoryStore.map((candidate) => (candidate.id === id ? { ...candidate, isActive } : candidate));
},
adminListOptionGroups: async (categoryId) => {
await sleep(MOCK_LATENCY_MS);
return groupStore
.filter((group) => isGroupApplicable(group, categoryId))
.sort(bySortOrder)
.map((group) => ({ ...group, values: [...group.values].sort(bySortOrder) }));
},
createOptionGroup: async (input: OptionGroupInput) => {
await sleep(MOCK_LATENCY_MS);
if (input.serviceCategoryId != null && !categoryStore.some((c) => c.id === input.serviceCategoryId)) {
throw new ApiError(400, 'Category not found', 'invalid_category');
}
const group: ServiceOptionGroup = { id: nextGroupId++, ...input, isActive: true, values: [] };
groupStore = [...groupStore, group];
return group;
},
updateOptionGroup: async (id, input: OptionGroupInput) => {
await sleep(MOCK_LATENCY_MS);
const existing = groupStore.find((candidate) => candidate.id === id);
if (!existing) throw new ApiError(404, 'Option group not found', 'not_found');
if (input.serviceCategoryId != null && !categoryStore.some((c) => c.id === input.serviceCategoryId)) {
throw new ApiError(400, 'Category not found', 'invalid_category');
}
const updated: ServiceOptionGroup = { ...existing, ...input };
groupStore = groupStore.map((candidate) => (candidate.id === id ? updated : candidate));
return updated;
},
createOptionValue: async (input: CreateOptionValueInput) => {
await sleep(MOCK_LATENCY_MS);
const group = groupStore.find((candidate) => candidate.id === input.optionGroupId);
if (!group) throw new ApiError(400, 'Option group not found', 'invalid_group');
const value: ServiceOptionValue = {
id: nextValueId++,
nameFa: input.nameFa,
nameEn: input.nameEn,
sortOrder: input.sortOrder,
isActive: true,
};
groupStore = groupStore.map((candidate) =>
candidate.id === group.id ? { ...candidate, values: [...candidate.values, value] } : candidate,
);
return value;
},
updateOptionValue: async (id, input: UpdateOptionValueInput) => {
await sleep(MOCK_LATENCY_MS);
const group = groupStore.find((candidate) => candidate.values.some((value) => value.id === id));
if (!group) throw new ApiError(404, 'Option value not found', 'not_found');
const updated: ServiceOptionValue = { id, ...input };
groupStore = groupStore.map((candidate) =>
candidate.id === group.id
? { ...candidate, values: candidate.values.map((value) => (value.id === id ? updated : value)) }
: candidate,
);
return updated;
},
};
+7
View File
@@ -22,3 +22,10 @@ export const MY_VARIANTS_STALE_TIME = 60_000;
/** api-conventions default/max page sizes. A nurse has a handful of offerings; categories are few. */
export const CATEGORIES_PAGE_SIZE = 50;
export const MY_VARIANTS_PAGE_SIZE = 50;
/**
* The admin catalog-management reads (categories incl. inactive; a category's groups/values incl.
* inactive) are deliberately uncached (`staleTime: 0`) an admin editing the skeleton expects every
* write reflected immediately, and this console's traffic is negligible next to the public browse.
*/
export const ADMIN_CATEGORIES_PAGE_SIZE = 50;
@@ -0,0 +1,17 @@
import { useQuery } from '@tanstack/react-query';
import type { PageParams } from '@/lib/api/types';
import { catalogApi } from '../apis';
import { catalogKeys } from '../keys';
/**
* Every category regardless of active state, for the admin catalog console unlike the public,
* session-cached `useServiceCategories`, this always refetches (`staleTime: 0`) so a deactivate/
* reactivate or edit is visible immediately without a stale cached page.
*/
export function useAdminCategories(params?: PageParams) {
return useQuery({
queryKey: catalogKeys.adminCategories(params),
queryFn: () => catalogApi.adminListCategories(params),
staleTime: 0,
});
}
@@ -0,0 +1,17 @@
import { useQuery } from '@tanstack/react-query';
import { catalogApi } from '../apis';
import { catalogKeys } from '../keys';
/**
* A category's applicable option groups **including inactive groups, each with every value
* including inactive ones** the admin console's read of the skeleton it manages. Always
* refetches (`staleTime: 0`); disabled until a category is chosen.
*/
export function useAdminOptionGroups(categoryId: number | null | undefined) {
return useQuery({
queryKey: catalogKeys.adminOptionGroups(categoryId ?? 0),
queryFn: () => catalogApi.adminListOptionGroups(categoryId as number),
enabled: categoryId != null,
staleTime: 0,
});
}
@@ -0,0 +1,16 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { catalogApi } from '../apis';
import { catalogKeys } from '../keys';
import type { CategoryInput } from '../types';
/** Adds a top-level category, then invalidates the admin category list. */
export function useCreateCategory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CategoryInput) => catalogApi.createCategory(input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: catalogKeys.adminCategoryLists() });
queryClient.invalidateQueries({ queryKey: catalogKeys.categories() });
},
});
}
@@ -0,0 +1,19 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { catalogApi } from '../apis';
import { catalogKeys } from '../keys';
import type { OptionGroupInput } from '../types';
/**
* Adds a pricing dimension invalidates every cached category's option-groups (admin + public), not
* just the current one, because a cross-category group (`serviceCategoryId: null`) affects all of them.
*/
export function useCreateOptionGroup() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: OptionGroupInput) => catalogApi.createOptionGroup(input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: catalogKeys.adminOptionGroupsAll() });
queryClient.invalidateQueries({ queryKey: catalogKeys.categoryOptionGroupsAll() });
},
});
}
@@ -0,0 +1,16 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { catalogApi } from '../apis';
import { catalogKeys } from '../keys';
import type { CreateOptionValueInput } from '../types';
/** Adds a concrete choice to an option group — invalidates every cached category's option-groups. */
export function useCreateOptionValue() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateOptionValueInput) => catalogApi.createOptionValue(input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: catalogKeys.adminOptionGroupsAll() });
queryClient.invalidateQueries({ queryKey: catalogKeys.categoryOptionGroupsAll() });
},
});
}
@@ -0,0 +1,19 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { catalogApi } from '../apis';
import { catalogKeys } from '../keys';
/**
* Soft deactivates or reactivates a category **never a hard delete**. Deactivating hides it from
* public browse and new variant creation; existing variants in it are left intact. Drives both the
* deactivate-with-confirm action and the reactivate affordance on an inactive row.
*/
export function useSetCategoryActive() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, isActive }: { id: number; isActive: boolean }) => catalogApi.setCategoryActive(id, isActive),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: catalogKeys.adminCategoryLists() });
queryClient.invalidateQueries({ queryKey: catalogKeys.categories() });
},
});
}
@@ -0,0 +1,16 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { catalogApi } from '../apis';
import { catalogKeys } from '../keys';
import type { CategoryInput } from '../types';
/** Edits a category's labels/description/icon/order, then invalidates the admin + public lists. */
export function useUpdateCategory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, input }: { id: number; input: CategoryInput }) => catalogApi.updateCategory(id, input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: catalogKeys.adminCategoryLists() });
queryClient.invalidateQueries({ queryKey: catalogKeys.categories() });
},
});
}
@@ -0,0 +1,19 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { catalogApi } from '../apis';
import { catalogKeys } from '../keys';
import type { OptionGroupInput } from '../types';
/**
* Edits a group's category scope/labels/required flag/order — invalidates every cached category's
* option-groups, since re-scoping to/from cross-category changes what more than one category sees.
*/
export function useUpdateOptionGroup() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, input }: { id: number; input: OptionGroupInput }) => catalogApi.updateOptionGroup(id, input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: catalogKeys.adminOptionGroupsAll() });
queryClient.invalidateQueries({ queryKey: catalogKeys.categoryOptionGroupsAll() });
},
});
}
@@ -0,0 +1,19 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { catalogApi } from '../apis';
import { catalogKeys } from '../keys';
import type { UpdateOptionValueInput } from '../types';
/**
* Edits a value's labels/order and activates/deactivates it (never a hard delete re-parenting to a
* different group is not supported). Invalidates every cached category's option-groups.
*/
export function useUpdateOptionValue() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, input }: { id: number; input: UpdateOptionValueInput }) => catalogApi.updateOptionValue(id, input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: catalogKeys.adminOptionGroupsAll() });
queryClient.invalidateQueries({ queryKey: catalogKeys.categoryOptionGroupsAll() });
},
});
}
+9
View File
@@ -4,3 +4,12 @@ export { useMyVariants } from './hooks/useMyVariants';
export { useCreateVariant } from './hooks/useCreateVariant';
export { useUpdateVariant } from './hooks/useUpdateVariant';
export { useSetVariantActive } from './hooks/useSetVariantActive';
export { useAdminCategories } from './hooks/useAdminCategories';
export { useCreateCategory } from './hooks/useCreateCategory';
export { useUpdateCategory } from './hooks/useUpdateCategory';
export { useSetCategoryActive } from './hooks/useSetCategoryActive';
export { useAdminOptionGroups } from './hooks/useAdminOptionGroups';
export { useCreateOptionGroup } from './hooks/useCreateOptionGroup';
export { useUpdateOptionGroup } from './hooks/useUpdateOptionGroup';
export { useCreateOptionValue } from './hooks/useCreateOptionValue';
export { useUpdateOptionValue } from './hooks/useUpdateOptionValue';
+15 -1
View File
@@ -11,12 +11,26 @@ export const catalogKeys = {
// Reference data — cached for the whole session, never invalidated by this phase.
categories: () => [...catalogKeys.all, 'categories'] as const,
/** Prefix shared by every `categoryOptionGroups(id)` key invalidate this to catch every cached
* category when a group/value edit is cross-category (its `serviceCategoryId` is null) or its exact
* category set isn't known to the caller. */
categoryOptionGroupsAll: () => [...catalogKeys.all, 'option-groups'] as const,
categoryOptionGroups: (categoryId?: number | null) =>
[...catalogKeys.all, 'option-groups', categoryId ?? null] as const,
[...catalogKeys.categoryOptionGroupsAll(), categoryId ?? null] as const,
// The nurse's offerings — mutable; mutations invalidate the `myVariantsLists()` prefix.
variants: () => [...catalogKeys.all, 'variants'] as const,
myVariantsLists: () => [...catalogKeys.variants(), 'mine'] as const,
myVariants: (params?: PageParams) => [...catalogKeys.myVariantsLists(), params ?? {}] as const,
variant: (id: number) => [...catalogKeys.variants(), 'detail', id] as const,
// Admin catalog management — separate from the public/cached reference-data keys above so a mutation
// invalidating one never stales the other's cache entry.
admin: () => [...catalogKeys.all, 'admin'] as const,
adminCategoryLists: () => [...catalogKeys.admin(), 'categories'] as const,
adminCategories: (params?: PageParams) => [...catalogKeys.adminCategoryLists(), params ?? {}] as const,
/** Prefix shared by every `adminOptionGroups(id)` key see `categoryOptionGroupsAll`'s doc for why a
* group/value mutation invalidates this prefix instead of a single category id. */
adminOptionGroupsAll: () => [...catalogKeys.admin(), 'option-groups'] as const,
adminOptionGroups: (categoryId: number) => [...catalogKeys.adminOptionGroupsAll(), categoryId] as const,
};
+56
View File
@@ -121,6 +121,47 @@ export interface UpdateVariantInput {
displayName?: string | null;
}
// ── Admin write models (AdminCatalogController — categories/option groups/values management) ───────────
/** `CreateServiceCategoryCommand`/`UpdateServiceCategoryCommand` body. */
export interface CategoryInput {
nameFa: string;
nameEn: string;
descriptionFa: string | null;
descriptionEn: string | null;
iconKey: string | null;
sortOrder: number;
}
/** `CreateServiceOptionGroupCommand`/`UpdateServiceOptionGroupCommand` body `serviceCategoryId = null`
* makes the group cross-category (applies to every category), exactly like the read side. */
export interface OptionGroupInput {
serviceCategoryId: number | null;
nameFa: string;
nameEn: string;
isRequired: boolean;
sortOrder: number;
}
/** `CreateServiceOptionValueCommand` body. */
export interface CreateOptionValueInput {
optionGroupId: number;
nameFa: string;
nameEn: string;
sortOrder: number;
}
/**
* `UpdateServiceOptionValueCommand` body. Re-parenting to a different group is deliberately not
* supported server-side it would silently change the meaning of variants that already answered
* with this value so there is no `optionGroupId` here.
*/
export interface UpdateOptionValueInput {
nameFa: string;
nameEn: string;
sortOrder: number;
isActive: boolean;
}
/**
* The catalog domain's API seam the real HTTP client and the in-memory mock both implement this
* interface; selection is by config (`USE_CATALOG_MOCK`), never scattered `if (mock)` checks.
@@ -137,6 +178,21 @@ export interface CatalogApi {
updateVariant(id: number, input: UpdateVariantInput): Promise<NurseServiceVariant>;
/** Soft deactivate/reactivate — never a hard delete. */
setVariantActive(id: number, isActive: boolean): Promise<void>;
// Admin — every category regardless of active state, so a deactivated one stays reachable to reactivate.
adminListCategories(params?: PageParams): Promise<Paginated<ServiceCategory>>;
createCategory(input: CategoryInput): Promise<ServiceCategory>;
updateCategory(id: number, input: CategoryInput): Promise<ServiceCategory>;
/** Soft deactivate/reactivate — categories/groups/values are reference data, never hard-deleted. */
setCategoryActive(id: number, isActive: boolean): Promise<void>;
// Admin — a category's groups including inactive ones, each with every value including inactive.
adminListOptionGroups(categoryId: number): Promise<ServiceOptionGroup[]>;
createOptionGroup(input: OptionGroupInput): Promise<ServiceOptionGroup>;
updateOptionGroup(id: number, input: OptionGroupInput): Promise<ServiceOptionGroup>;
/** There is no group-level active toggle in the contract — a group can only be created/edited. */
createOptionValue(input: CreateOptionValueInput): Promise<ServiceOptionValue>;
updateOptionValue(id: number, input: UpdateOptionValueInput): Promise<ServiceOptionValue>;
}
/**
@@ -23,6 +23,7 @@ interface CareRecordWire {
nurseProfileId: number;
nurseName: string | null;
body: string;
taskResults: TaskResult[];
recordedAt: string;
}
@@ -33,42 +34,27 @@ function toVisitNote(w: CareRecordWire): VisitNote {
nurseProfileId: w.nurseProfileId,
nurseDisplayName: w.nurseName,
body: w.body,
// The wire body carries only free text; the structured checklist is composed into it on write (see below).
taskResults: [],
taskResults: w.taskResults,
recordedAt: w.recordedAt,
};
}
/**
* Folds the nurse's ticked task checklist into the free-text note body, because the wire
* `WriteCareRecordBody` has only `{ bookingId?, body }` there is no structured task field (REQ-027 would
* add one). The mock keeps `taskResults` structured; the real path serialises them as a leading summary line.
*/
export function composeVisitNoteBody(body: string, taskResults: TaskResult[] | undefined): string {
const trimmed = body.trim();
if (!taskResults || taskResults.length === 0) return trimmed;
const summary = taskResults.map((t) => `${t.done ? '✓' : '✗'} ${t.label}`).join(' · ');
return trimmed ? `${summary}\n\n${trimmed}` : summary;
}
/**
* Real HTTP implementation of the `PatientRecordsApi` seam (b14 contract). Two methods map **published**
* b14 routes:
* Real HTTP implementation of the `PatientRecordsApi` seam (b14 contract). Every method maps a real route:
* - `getPatientHistory` `GET patients/{id}/care_records` (the patient-scoped, newest-first note history;
* a `403` from the envelope surfaces as an `ApiError` the E2 screen renders as access-denied).
* - `createVisitNote` `POST patients/{id}/care_records` (a nurse appends one encrypted note).
*
* The family-record + access methods target contract gaps the frontend filed (**REQ-027**) no wire
* endpoint exists which is why the domain stays mock-primary (see `constants.ts`).
* - `getRecordAccess` `GET patients/{id}/record_access`.
* - `getFamilyRecord`/`updateFamilyRecord` `GET`/`PUT patients/{id}/care_record` the routes exist, but
* `FamilyCareRecord`'s medication/routine fields are richer than the wire's (see `constants.ts`'s care-plan
* schema note), so these two are **not safe to use as written** until that's resolved.
*
* NOT the primary implementation this phase (`USE_PATIENT_RECORDS_MOCK = true`).
*/
export const patientRecordsClientApi: PatientRecordsApi = {
// REQ-027: proposed owner/nurse-scoped read of the family-owned record (no wire endpoint yet).
getFamilyRecord: async (patientId: number): Promise<FamilyCareRecord> =>
unwrap(await clientFetch<ApiEnvelope<FamilyCareRecord>>(`${API}/patients/${patientId}/care_record`)),
// REQ-027: proposed access check. On the real path the 403 on the history read is the true access signal.
getRecordAccess: async (patientId: number): Promise<RecordAccess> =>
unwrap(await clientFetch<ApiEnvelope<RecordAccess>>(`${API}/patients/${patientId}/record_access`)),
@@ -84,7 +70,6 @@ export const patientRecordsClientApi: PatientRecordsApi = {
return { ...page, items: page.items.map(toVisitNote) };
},
// REQ-027: proposed customer edit of the family record.
updateFamilyRecord: async (patientId: number, body: UpdateFamilyRecordRequest): Promise<FamilyCareRecord> =>
unwrap(
await clientFetch<ApiEnvelope<FamilyCareRecord>>(`${API}/patients/${patientId}/care_record`, {
@@ -99,7 +84,8 @@ export const patientRecordsClientApi: PatientRecordsApi = {
method: 'POST',
body: JSON.stringify({
bookingId: body.bookingId ?? null,
body: composeVisitNoteBody(body.body, body.taskResults),
body: body.body.trim(),
taskResults: body.taskResults ?? [],
}),
}),
),
@@ -15,9 +15,9 @@ import type {
} from '../types';
/**
* In-memory `PatientRecordsApi` **the primary implementation this phase** (the nurse-authored visit-note
* history/append are real b14, but the family-owned medications/routine/tasks record + the access check are
* REQ-027 gaps see `constants.ts`).
* In-memory `PatientRecordsApi` **the primary implementation this phase**. Every route this seam needs
* exists on the server; the domain stays mock-primary because the family-owned medications/routine/tasks
* shape doesn't match yet (an unresolved product decision see `constants.ts`).
*
* The store is **patient-scoped** and lazily seeds a coherent default the first time any patient is read, so
* every E2 record viewer has content and every state is demoable:
@@ -149,7 +149,7 @@ function paginate<T>(all: T[], params: PageParams): Paginated<T> {
function assertAccess(patientId: number): void {
// The real 403 comes from the server clinical-access check; the mock denies a designated foreign patient.
if (patientId === MOCK_FOREIGN_PATIENT_ID) {
throw new ApiError(403, 'No clinical access to this patient', 'no_access');
throw new ApiError(403, 'No clinical access to this patient', 'not_authorized');
}
}
@@ -163,7 +163,7 @@ export const patientRecordsMockApi: PatientRecordsApi = {
getRecordAccess: async (patientId: number): Promise<RecordAccess> => {
await sleep(MOCK_LATENCY_MS);
if (patientId === MOCK_FOREIGN_PATIENT_ID) {
return { canView: false, canEdit: false, canAppendNote: false, deniedReason: 'no_access' };
return { canView: false, canEdit: false, canAppendNote: false, deniedReason: 'not_authorized' };
}
// In the single-session mock, an authorized viewer can do everything; the SCREEN (customer vs nurse
// shell) decides which affordances to render — the nurse view never wires the edit path (append-only).
@@ -2,13 +2,14 @@
* When true, the patient-records domain is served by the in-memory mock (`apis/mockApi.ts`) behind the
* `PatientRecordsApi` seam.
*
* **Mock is primary this phase.** b14 serves the nurse-authored **visit-note history** (`care_records`
* GET/POST) those two methods are real but the **family-owned editable record** (medications/routine/
* tasks) and the **access check** have **no backend at all** (neither the contract nor the data model has
* them; **REQ-027**). The mock seeds a default family record + a multi-nurse continuity history per patient,
* enforces a foreign-patient **access-denied** (403) path, and lets the nurse append notes that appear in the
* history. Flip to `false` once REQ-027 lands only `clientApi.ts`'s family-record/access methods flip; the
* history/append methods already map the real routes.
* **Mock is primary the backend is fully built, but the shapes don't match yet.** `PatientCareRecordsController`
* implements every route this seam needs: `care_records` GET/POST (visit-note history/append), `care_record`
* GET/PUT (the family care plan), and `record_access` GET. The blocker is a genuine schema mismatch, not a
* missing endpoint see `mvp/fix-plan.md`'s "Patient records" follow-up: the client's medication/routine
* shape (structured dose amount/unit, frequency preset codes, a multi-select `timeOfDay`) was built after the
* server shipped a simpler one (one free-text dose, one required frequency string, no `timeOfDay` at all),
* and which side to change is a product decision, not something to guess in code. Flip to `false` once that's
* resolved and `clientApi.ts`'s family-record methods are updated to match.
*/
export const USE_PATIENT_RECORDS_MOCK = true;
+20 -20
View File
@@ -8,12 +8,12 @@ import type { PageParams, Paginated } from '@/lib/api/types';
* GET/POST). Encrypted at rest, returned decrypted only after the clinical-access check passes; it is
* **patient-scoped, not booking-scoped**, so a new nurse taking over reads the whole history. A nurse
* with a qualifying booking may **append** a note; nobody edits it.
* 2. **The family-owned editable record** (داروها/روتین/وظایف medications/routine/tasks) **NO backend
* exists** (neither the b14 contract nor the data model has it; **REQ-027**). The customer maintains it;
* it is mocked behind this seam. The domain is therefore **mock-primary** (see `constants.ts`).
* ui-phase-9 added the structured shape (dose amount/unit, frequency preset codes, time-of-day codes)
* that replaced free-text dose/frequency/routine-time editing recorded as a REQ-027 addendum, not a
* new REQ number, in `requests/for-backend.md`.
* 2. **The family-owned editable record** (داروها/روتین/وظایف medications/routine/tasks) the backend
* route exists (`GET`/`PUT patients/{id}/care_record`) but its medication/routine shape is simpler than
* what ui-phase-9 shipped (structured dose amount/unit, frequency preset codes, multi-select `timeOfDay`
* vs. one free-text dose, one required frequency string, no `timeOfDay`) a product decision on which
* side to change, not yet made (see `mvp/fix-plan.md`). The customer maintains it; it is mocked behind
* this seam until that's resolved. The domain is therefore **mock-primary** (see `constants.ts`).
*
* Load-bearing rules (contract + phase §5):
* - **Family-owned & patient-scoped.** The customer owns/edits medications/routine/tasks; the record
@@ -112,9 +112,10 @@ export interface VisitNote {
recordedAt: string;
}
// ── Access (REQ-027 — no wire endpoint; derived from the 403 on a read / the caller role) ─────────────────
// ── Access (`GET record_access` exists; matches this shape exactly — mocked only because the domain switches
// as one seam, and the care-plan schema decision below still gates the flip) ───────────────────────────────
export type RecordAccessDeniedReason = 'no_access' | 'not_found';
export type RecordAccessDeniedReason = 'not_authorized' | 'not_found';
/**
* Who may do what with this patient's record. `canEdit` is the owning customer only; `canAppendNote` is a
@@ -127,17 +128,15 @@ export interface RecordAccess {
deniedReason?: RecordAccessDeniedReason;
}
/** The customer edit body (REQ-027) — replaces the provided sections of the family record. */
/** The customer edit body replaces the provided sections of the family record (maps to `UpsertCarePlanBody`,
* pending the care-plan schema decision see `constants.ts`). */
export interface UpdateFamilyRecordRequest {
medications?: Medication[];
routine?: RoutineItem[];
tasks?: CareTask[];
}
/**
* The nurse append body. Maps to the wire `WriteCareRecordBody` (`{ bookingId?, body }`): on the real path
* `taskResults` is composed into `body` (the wire has no structured task field); the mock keeps it structured.
*/
/** The nurse append body. Maps 1:1 to the wire `WriteCareRecordBody` (`{ bookingId?, body, taskResults? }`). */
export interface CreateVisitNoteRequest {
bookingId?: number | null;
body: string;
@@ -153,18 +152,19 @@ export interface WriteVisitNoteResult {
/**
* The patient-records API seam the real HTTP client and the in-memory mock both implement this; selection
* is by config (`USE_PATIENT_RECORDS_MOCK`), never scattered `if (mock)` checks. `getPatientHistory` +
* `createVisitNote` map real b14 routes; the family-record + access methods are REQ-027 gaps (mocked).
* is by config (`USE_PATIENT_RECORDS_MOCK`), never scattered `if (mock)` checks. Every method maps a real
* route; the whole domain still runs on the mock because the family-record methods need the care-plan schema
* decision resolved first (see `constants.ts`) before `clientApi.ts` can implement them correctly.
*/
export interface PatientRecordsApi {
/** REQ-027 — the family-owned medications/routine/tasks (customer-maintained). */
/** `GET care_record` exists; blocked on the care-plan schema decision. */
getFamilyRecord(patientId: number): Promise<FamilyCareRecord>;
/** REQ-027 — who may view/edit/append for this patient (derived from the 403 on a read + the caller role). */
/** `GET record_access` — who may view/edit/append for this patient. */
getRecordAccess(patientId: number): Promise<RecordAccess>;
/** REAL — the patient-scoped longitudinal visit-note history, newest-first, paged. */
/** `GET care_records` — the patient-scoped longitudinal visit-note history, newest-first, paged. */
getPatientHistory(patientId: number, params: PageParams): Promise<Paginated<VisitNote>>;
/** REQ-027 — the customer replaces sections of the family record. */
/** `PUT care_record` exists; blocked on the care-plan schema decision. */
updateFamilyRecord(patientId: number, body: UpdateFamilyRecordRequest): Promise<FamilyCareRecord>;
/** REAL — a nurse appends a visit note (append-only; never edits the record). */
/** `POST care_records` — a nurse appends a visit note (append-only; never edits the record). */
createVisitNote(patientId: number, body: CreateVisitNoteRequest): Promise<WriteVisitNoteResult>;
}
+33 -17
View File
@@ -32,6 +32,19 @@ interface RefundStatusWire {
reference: string | null;
}
/**
* `CancellationPolicyPreviewDto` everything `CancellationPolicyPreview` has except `refundableSessionIds`,
* which the server doesn't serve as its own field (derived below from `sessions` instead).
*/
type CancellationPolicyPreviewWire = Omit<CancellationPolicyPreview, 'refundableSessionIds'>;
function toPreview(wire: CancellationPolicyPreviewWire): CancellationPolicyPreview {
return {
...wire,
refundableSessionIds: wire.sessions.filter((s) => s.refundable).map((s) => s.bookingSessionId),
};
}
function toSummary(wire: RefundStatusWire): RefundSummary {
return {
id: wire.id,
@@ -53,23 +66,20 @@ function toSummary(wire: RefundStatusWire): RefundSummary {
}
/**
* Real HTTP implementation of the `RefundsApi` seam. Only `getRefund` maps a **published** b11 route
* (`GET refunds/{id}/status`, tenancy-scoped); the other three target contract gaps the frontend filed
* (which is why the domain stays mock-primary see `constants.ts`):
* - `resolveCancellationPolicy` REQ-020 (`GET bookings/{id}/cancellation_policy`): b9 snapshots the
* policy only *after* a cancel; there is no pre-cancel preview resolving the tier by current lead time
* + per-session refundability.
* - `cancelBooking` REQ-019 (`POST bookings/{id}/cancel`): b11 refunds are admin-only, no customer path.
* - `getRefundByBooking` REQ-021 (`GET refunds/by_booking/{id}`): the customer cannot obtain a refund
* id from the admin-only worklist, so it needs to reach its refund from the booking. `404` = no refund.
*
* NOT the primary implementation this phase (`USE_REFUNDS_MOCK = true`).
* Real HTTP implementation of the `RefundsApi` seam. The customer half (`resolveCancellationPolicy`/
* `cancelBooking`/`getRefundByBooking`/`getRefund`/`getMyRefunds`) is the primary implementation as of
* phase 08 (REQ-019/020/021 routes are live: `BookingsController.CancellationPolicy`/`.Cancel`,
* `RefundsController.Status`/`.ByBooking`). The admin console methods below (`getRefundPreview`/
* `approveRefund`/`rejectRefund`) still target proposed routes (REQ-035) `AdminRefundsController` only
* has create-and-execute so `USE_ADMIN_REFUNDS_MOCK` keeps them on the mock (see `constants.ts`).
*/
export const refundsClientApi: RefundsApi = {
resolveCancellationPolicy: async (bookingId: number) =>
unwrap(
await clientFetch<ApiEnvelope<CancellationPolicyPreview>>(
`${BOOKINGS}/${bookingId}/cancellation_policy`,
toPreview(
unwrap(
await clientFetch<ApiEnvelope<CancellationPolicyPreviewWire>>(
`${BOOKINGS}/${bookingId}/cancellation_policy`,
),
),
),
@@ -99,10 +109,16 @@ export const refundsClientApi: RefundsApi = {
toSummary(unwrap(await clientFetch<ApiEnvelope<RefundStatusWire>>(`${REFUNDS}/${refundId}/status`))),
// REQ-048 proposed slug — no "all my refunds" list exists yet (only by-booking/by-id reads); 404s
// until delivered (the wallet «استردادها» tab renders its empty state until then).
// until delivered, so the wallet «استردادها» tab renders its empty state until then rather than an
// error (a 404 here means "not built yet", not "something went wrong").
getMyRefunds: async () => {
const wire = unwrap(await clientFetch<ApiEnvelope<RefundStatusWire[]>>(`${REFUNDS}/my`));
return wire.map(toSummary);
try {
const wire = unwrap(await clientFetch<ApiEnvelope<RefundStatusWire[]>>(`${REFUNDS}/my`));
return wire.map(toSummary);
} catch (error) {
if (error instanceof ApiError && error.status === 404) return [];
throw error;
}
},
// REQ-035: refund preview endpoint. b11 computes the fee-leg decomposition only *on create* (there is no
+20 -4
View File
@@ -1,10 +1,26 @@
import { USE_REFUNDS_MOCK } from '../constants';
import { USE_ADMIN_REFUNDS_MOCK, USE_CUSTOMER_REFUNDS_MOCK } from '../constants';
import type { RefundsApi } from '../types';
import { refundsClientApi } from './clientApi';
import { refundsMockApi } from './mockApi';
const customer = USE_CUSTOMER_REFUNDS_MOCK ? refundsMockApi : refundsClientApi;
const admin = USE_ADMIN_REFUNDS_MOCK ? refundsMockApi : refundsClientApi;
/**
* The selected `RefundsApi` implementation the single seam the hooks import. Selection is by config
* (`USE_REFUNDS_MOCK`), never by scattered `if (mock)` checks.
* The selected `RefundsApi` implementation the single seam the hooks import. The customer surface and
* the admin console are two independently-selected halves (`USE_CUSTOMER_REFUNDS_MOCK` /
* `USE_ADMIN_REFUNDS_MOCK`), composed into one object here rather than scattered `if (mock)` checks
* see the constants for why the admin group must never be split across real and mock.
*/
export const refundsApi: RefundsApi = USE_REFUNDS_MOCK ? refundsMockApi : refundsClientApi;
export const refundsApi: RefundsApi = {
resolveCancellationPolicy: customer.resolveCancellationPolicy,
cancelBooking: customer.cancelBooking,
getRefundByBooking: customer.getRefundByBooking,
getRefund: customer.getRefund,
getMyRefunds: customer.getMyRefunds,
getRefundPreview: admin.getRefundPreview,
initiateRefund: admin.initiateRefund,
approveRefund: admin.approveRefund,
rejectRefund: admin.rejectRefund,
};
+13 -10
View File
@@ -124,8 +124,10 @@ function computePreview(bookingId: number): CancellationPolicyPreview {
bookingId,
cancellable,
cancellationPolicyCode: policyCode,
refundPercentageApplied: tier.refundFraction,
feePercentage: Math.round((1 - tier.refundFraction) * 100) / 100,
// 0100 (matches the real server's decimal `RefundPercentageApplied`/`FeePercentage`), not the 01
// fraction `tier.refundFraction` uses internally for the BigInt math above.
refundPercentageApplied: tier.refundFraction * 100,
feePercentage: (1 - tier.refundFraction) * 100,
refundAmountIrr: refundAmount.toString(),
feeAmountIrr: feeAmount.toString(),
refundableAmountIrr: refundableGross.toString(),
@@ -158,7 +160,7 @@ refundsByBooking[5004] = {
totalRefundedIrr: '6000000',
expectedCustomerRefundEta: null,
externalRevertReference: maskedReference(5004),
refundPercentageApplied: 0.5,
refundPercentageApplied: 50,
cancellationPolicyCode: 'partial_under_24h',
platformFeeRefundedIrr: '720000',
nursePayoutRefundedIrr: '5280000',
@@ -282,13 +284,14 @@ const adminRefundsById: Record<number, AdminRefundResult> = {};
const adminInitiateAttempts: Record<number, number> = {};
/**
* In-memory mock behind the `RefundsApi` seam the whole customer cancel + refund surface b11 doesn't
* serve (admin-only refunds; no cancel command / policy preview / refund-by-booking / decomposition on the
* customer status REQ-019/020/021). It reads the shared f8 bookings store to resolve the tier + per-
* session refundability, flips the booking to `cancelled` on confirm (so the booking-detail cache reflects
* it after invalidation), enforces the outside-policy `409`, and drives the refund through the customer
* steps (card immediate `succeeded`; BNPL `processing` with an ETA that reconciles over polls). Swap to the
* real `clientApi` once REQ-019/020/021 land (`USE_REFUNDS_MOCK = false`).
* In-memory mock behind the `RefundsApi` seam. The customer half (cancel/preview/status) is a config-
* selectable fallback now that REQ-019/020/021 are real (`USE_CUSTOMER_REFUNDS_MOCK = false` by default)
* kept for local demo/dev without a backend. It reads the shared f8 bookings store to resolve the tier +
* per-session refundability, flips the booking to `cancelled` on confirm (so the booking-detail cache
* reflects it after invalidation), enforces the outside-policy `409`, and drives the refund through the
* customer steps (card immediate `succeeded`; BNPL `processing` with an ETA that reconciles over polls).
* The admin half below (preview/initiate/approve/reject) is the **primary** implementation
* (`USE_ADMIN_REFUNDS_MOCK = true`) REQ-035's real endpoints don't exist yet.
*/
export const refundsMockApi: RefundsApi = {
resolveCancellationPolicy: async (bookingId) => {
+16 -12
View File
@@ -2,20 +2,24 @@ import { REFUND_ETA_MAX_BUSINESS_DAYS } from '@/constants';
import type { CancellationPolicyCode } from './types';
/**
* When true, the refunds domain is served by the in-memory mock (`apis/mockApi.ts`) behind the
* `RefundsApi` seam.
* When true, the **customer-facing** refund surface (`resolveCancellationPolicy`/`cancelBooking`/
* `getRefundByBooking`/`getRefund`/`getMyRefunds`) is served by the in-memory mock (`apis/mockApi.ts`).
*
* **Mock is primary this phase.** b11 shipped the refund lifecycle **admin-only**: the only
* customer-visible surface is `GET refunds/{id}/status` (thin: status/channel/amount/ETA/masked ref).
* There is **no** customer cancel command, **no** cancellation-policy preview, **no** refund-by-booking
* lookup, and the customer status carries **no** fee-leg decomposition all filed as REQ-019/020/021.
* So the whole cancel + policy-disclosure + fee-split surface is mocked behind this seam. The mock reads
* the shared f8 bookings store (lead time + per-session refundability), flips the booking to `cancelled`
* on confirm (so the booking-detail cache reflects it), and drives a refund through
* `submitted → on_its_way → completed` (card immediate; BNPL processing with an ETA). Flip to `false`
* once REQ-019/020/021 land no hook/component change.
* **Real as of phase 08 (blocker-phases/08-refunds-demock.md).** REQ-019/020/021's routes are live and
* shape-matched (`BookingsController.CancellationPolicy`/`.Cancel`, `RefundsController.Status`/`.ByBooking`)
* the mock stays only as a config-selectable fallback for local demo/dev without a backend.
*/
export const USE_REFUNDS_MOCK = true;
export const USE_CUSTOMER_REFUNDS_MOCK = false;
/**
* When true, the **admin refund console** (`getRefundPreview`/`initiateRefund`/`approveRefund`/
* `rejectRefund`) is served by the mock. `AdminRefundsController` only implements create-and-execute
* (`initiateRefund`'s real endpoint) there is no real read-only preview, no retry/approve, and no
* reject route (REQ-035). Mixing a real `initiateRefund` with a mocked preview would let an admin approve
* against numbers that don't match what actually executes, so the whole admin group stays on the mock
* together until all four land. Flip once REQ-035 ships.
*/
export const USE_ADMIN_REFUNDS_MOCK = true;
/**
* The cancellation preview depends on `now` vs the booking start (the resolved tier moves as the visit
+5 -3
View File
@@ -106,9 +106,9 @@ export interface CancellationPolicyPreview {
/** `false` when nothing is refundable (already cancelled/completed, or no un-started sessions). */
cancellable: boolean;
cancellationPolicyCode: CancellationPolicyCode;
/** 01 fraction of the refundable amount returned to the customer. */
/** 0100 percent of the refundable amount returned to the customer (matches the server's decimal). */
refundPercentageApplied: number;
/** 01 fraction retained as the cancellation fee/penalty (`= 1 - refundPercentageApplied`). */
/** 0100 percent retained as the cancellation fee/penalty (`= 100 - refundPercentageApplied`). */
feePercentage: number;
/** IRR digit-string — the amount refunded to the customer. */
refundAmountIrr: string;
@@ -125,7 +125,8 @@ export interface CancellationPolicyPreview {
refundChannel: RefundChannel;
/** Populated only for `bnpl_revert` (the ~710 business-day window); a date `YYYY-MM-DD`. */
expectedCustomerRefundEta: string | null;
/** The refundable session ids the confirm submits (all un-started sessions). */
/** The refundable session ids the confirm submits (all un-started sessions); derived from `sessions` on
* the real path (the server doesn't serve it as its own field). */
refundableSessionIds: number[];
sessions: CancellationSessionPreview[];
}
@@ -164,6 +165,7 @@ export interface RefundSummary {
/** Opaque, **masked** (last 4 only) external reference — never parse it. */
externalRevertReference: string | null;
/** --- Fee-leg decomposition + policy snapshot (REQ-021: `null` on the real path until served). --- */
/** 0100 percent, matching `CancellationPolicyPreview.refundPercentageApplied`'s scale. */
refundPercentageApplied: number | null;
cancellationPolicyCode: string | null;
platformFeeRefundedIrr: string | null;
@@ -32,6 +32,8 @@ interface NurseSearchResultDto {
nurseName: string | null;
avatarUrl: string | null;
distanceKm: number | null;
/** Phase 10: how many of the nurse's variants matched this query (server-side dedup). */
matchingServiceCount: number;
/** REQ-040 (proposed) — not yet served; absent until the backend lands it. */
topReviewTag?: string | null;
}
@@ -94,6 +96,7 @@ export const searchClientApi: SearchApi = {
nurseId: dto.nurseId,
variantId: dto.variantId,
serviceCategoryId: dto.serviceCategoryId,
matchingServiceCount: dto.matchingServiceCount,
// REQ-012 — identity denormalized onto the index row; card falls back to a label only when null.
nurseName: dto.nurseName ?? '',
avatarUrl: dto.avatarUrl,
+37 -18
View File
@@ -34,11 +34,12 @@ function withinPrice(priceIrr: string, min?: string, max?: string): boolean {
return true;
}
function toResult(nurse: SeedNurse, variant: SeedVariant): NurseSearchResult {
function toResult(nurse: SeedNurse, variant: SeedVariant, matchingServiceCount: number): NurseSearchResult {
return {
nurseId: nurse.nurseId,
variantId: variant.variantId,
serviceCategoryId: variant.serviceCategoryId,
matchingServiceCount,
nurseName: nurse.nurseName,
avatarUrl: nurse.avatarUrl,
isVerified: true,
@@ -54,6 +55,25 @@ function toResult(nurse: SeedNurse, variant: SeedVariant): NurseSearchResult {
};
}
/** One card per nurse (phase 10): collapse the matched variant rows down to her cheapest matching
* variant, counting how many others matched. Mirrors the real `SqlNurseSearch` grouping. */
function dedupeByNurse(rows: { nurse: SeedNurse; variant: SeedVariant }[]): NurseSearchResult[] {
const byNurse = new Map<number, { nurse: SeedNurse; cheapest: SeedVariant; matchCount: number }>();
for (const { nurse, variant } of rows) {
const existing = byNurse.get(nurse.nurseId);
if (!existing) {
byNurse.set(nurse.nurseId, { nurse, cheapest: variant, matchCount: 1 });
continue;
}
existing.matchCount += 1;
const isCheaper =
BigInt(variant.priceIrr) < BigInt(existing.cheapest.priceIrr) ||
(BigInt(variant.priceIrr) === BigInt(existing.cheapest.priceIrr) && variant.variantId < existing.cheapest.variantId);
if (isCheaper) existing.cheapest = variant;
}
return Array.from(byNurse.values()).map(({ nurse, cheapest, matchCount }) => toResult(nurse, cheapest, matchCount));
}
/**
* In-memory mock behind the `SearchApi` seam. Reproduces the b7 filter + geography + rating-sort
* semantics over verified-only fixtures, so C1/C2/C3 (incl. the empty state and the caching revert)
@@ -71,25 +91,24 @@ export const searchMockApi: SearchApi = {
throw new ApiError(400, 'min_price must not exceed max_price', 'invalid_price_range');
}
const matched = allRows()
.filter(({ nurse, variant }) => {
if (variant.serviceCategoryId !== filters.serviceCategoryId) return false;
if (variant.cityId !== filters.cityId) return false;
if (!matchesDistrict(variant.districtId, filters.districtId)) return false;
if (filters.nurseGender && nurse.gender !== filters.nurseGender) return false;
if (filters.priceUnit && variant.priceUnit !== filters.priceUnit) return false;
if (!withinPrice(variant.priceIrr, filters.priceMin, filters.priceMax)) return false;
return true;
})
// Rating desc, tiebroken by review count then ids so paging is deterministic (contract order).
const matchedRows = allRows().filter(({ nurse, variant }) => {
if (variant.serviceCategoryId !== filters.serviceCategoryId) return false;
if (variant.cityId !== filters.cityId) return false;
if (!matchesDistrict(variant.districtId, filters.districtId)) return false;
if (filters.nurseGender && nurse.gender !== filters.nurseGender) return false;
if (filters.priceUnit && variant.priceUnit !== filters.priceUnit) return false;
if (!withinPrice(variant.priceIrr, filters.priceMin, filters.priceMax)) return false;
return true;
});
const matched = dedupeByNurse(matchedRows)
// Rating desc, tiebroken by review count then nurse id so paging is deterministic (contract order).
.sort(
(a, b) =>
b.nurse.averageRating - a.nurse.averageRating ||
b.nurse.totalReviews - a.nurse.totalReviews ||
a.nurse.nurseId - b.nurse.nurseId ||
a.variant.variantId - b.variant.variantId,
)
.map(({ nurse, variant }) => toResult(nurse, variant));
b.averageRating - a.averageRating ||
b.totalReviews - a.totalReviews ||
a.nurseId - b.nurseId,
);
const pageSize = filters.pageSize || SEARCH_PAGE_SIZE;
const page = filters.page || 1;
+9 -3
View File
@@ -11,8 +11,10 @@ import type { PriceUnit } from '@/services/catalog/types';
* - **Every returned row is already bookable.** The `nurse_search_index` invariant guarantees a hit
* only when the nurse is verified + not suspended + accepting + the variant is active. The UI must
* **never** re-filter for verification, and never surface an unverified/paused nurse.
* - **The result unit is the variant, not the nurse** a nurse with several variants/areas can appear
* as several hits.
* - **The result unit is the nurse, one card per nurse** (phase 10 previously the variant, so a nurse
* with several matching variants/areas surfaced as several hits). The server groups the underlying
* per-variant index rows and picks the cheapest matching variant as the card's representative;
* `matchingServiceCount` says how many of her variants matched.
* - **`districtId = null` whole city**, both directions; the client omits `districtId` for a
* whole-city search rather than sending a bogus value.
* - **Same-gender is first-class** `nurseGender` is an up-front filter, never silently defaulted or
@@ -52,11 +54,15 @@ export interface NurseSearchFilters {
pageSize: number;
}
/** A single C2 result card row (one bookable variant matched in a covered area). */
/** A single C2 result card one nurse (phase 10: server-deduplicated; was previously one bookable
* variant matched in a covered area, so the same nurse could repeat across several cards). */
export interface NurseSearchResult {
nurseId: number;
/** The nurse's cheapest matching variant — the card's "from X" price and the profile deep-link target. */
variantId: number;
serviceCategoryId: number;
/** How many of the nurse's variants matched this query (>= 1); the card discloses "+N more" when > 1. */
matchingServiceCount: number;
/** Display name (mock/future-backend; the real b7 row omits it — card falls back to a label). */
nurseName: string;
avatarUrl: string | null;
@@ -15,7 +15,6 @@ import type {
IdentityKycInput,
NurseCredential,
RunStepResult,
SignedDocumentUrl,
TrustBadge,
UploadUrlResult,
VerificationAggregateStatus,
@@ -249,12 +248,6 @@ export const verificationClientApi: VerificationApi = {
};
},
// REQ-034: b6 has no per-document signed-URL route (documents already carry a short-lived signed `url` on
// the case detail). This targets a proposed `GET admin_verifications/documents/{documentId}/url` for an
// on-demand re-sign; until it ships, callers can re-fetch the case to get a fresh document `url`.
getDocumentSignedUrl: async (documentId: number): Promise<SignedDocumentUrl> =>
unwrap(await clientFetch<ApiEnvelope<SignedDocumentUrl>>(`${ADMIN_BASE}/documents/${documentId}/url`)),
decideStep: async (stepId: number, input: DecideStepInput): Promise<DecideStepResult> =>
unwrap(
await clientFetch<ApiEnvelope<DecideStepResult>>(`${ADMIN_BASE}/steps/${stepId}/decide`, {
@@ -263,9 +256,9 @@ export const verificationClientApi: VerificationApi = {
}),
),
// REQ-034: b6 has no whole-verification approve/reject route — approval emerges from the final step
// `decide` re-aggregating `is_verified`. These target proposed `POST admin_verifications/{id}/approve` and
// `/reject` for an explicit admin action (until they ship, approve by deciding the last pending step).
// Phase 09: explicit whole-verification approve/reject actions (AdminVerificationsController.Approve/
// Reject). Approve re-confirms what Finalize already flipped once every step passed; reject is a distinct
// admin override, not a per-step decision.
approveVerification: async (nurseVerificationId: number): Promise<void> => {
await clientFetch<ApiEnvelope<boolean>>(`${ADMIN_BASE}/${nurseVerificationId}/approve`, { method: 'POST' });
},
@@ -143,7 +143,8 @@ function mkDoc(id: number, originalFileName: string): VerificationDocument {
contentType: 'application/pdf',
fileSizeBytes: 482_000,
originalFileName,
// A short-lived signed GET URL; the on-demand `getDocumentSignedUrl` re-signs it fresh each open.
// A short-lived signed GET URL, embedded on the case detail — re-opening the viewer refetches the
// case (there is no separate per-document re-sign route) to get a fresh one.
url: `https://mock.balinyaar.local/docs/${id}`,
};
}
@@ -414,20 +415,6 @@ export const verificationMockApi: VerificationApi = {
return toCaseView(record);
},
getDocumentSignedUrl: async (documentId) => {
await sleep(MOCK_LATENCY_MS);
// Sentinel for the viewer's error/re-request path: this document can never be signed.
if (documentId === 9999) {
throw new ApiError(404, 'Document not found', 'document_not_found');
}
// A FRESH short-lived URL each call — the signature + timestamp differ so it is never re-used from cache.
const sig = Math.random().toString(36).slice(2, 12);
return {
url: `https://mock.balinyaar.local/docs/${documentId}?sig=${sig}&t=${Date.now()}`,
expiresInSeconds: 60,
};
},
decideStep: async (stepId, input) => {
await sleep(MOCK_LATENCY_MS);
const found = findCaseByStepId(stepId);
+8 -14
View File
@@ -1,12 +1,14 @@
/**
* When true, the verification domain is served by the in-memory mock (apis/mockApi.ts) behind the
* VerificationApi seam. The b6 routes exist server-side, but like `catalog` the mock lets the full
* nurse flow (checklist identity run credential upload under-review admin-approval verified
* badge + publish gate) demo standalone before the backend is reachable in this environment. Flip to
* false to hit the live endpoints no hook/component changes (see
* dev/shared-working-context/reports/mocks-registry.md).
* VerificationApi seam.
*
* **Real as of phase 09 (blocker-phases/09-nurse-verification-badge.md).** The b6 routes are live and
* shape-matched; the two admin gaps the mock papered over (whole-verification approve/reject, and the
* per-document signed-URL re-sign) shipped in the same change see `AdminVerificationsController`'s
* `Approve`/`Reject` actions and `DocumentViewer`'s refetch-the-case rewire. The mock stays only as a
* config-selectable fallback for local demo/dev without a backend.
*/
export const USE_VERIFICATION_MOCK = true;
export const USE_VERIFICATION_MOCK = false;
/**
* The checklist is **moderately fresh** submitting a step changes it, and every mutation invalidates
@@ -35,11 +37,3 @@ export const ADMIN_QUEUE_PAGE_SIZE = 20;
/** A single admin case — same freshness as the queue; invalidated on every decide / approve / reject. */
export const ADMIN_CASE_STALE_TIME = 20_000;
/**
* A document's **signed GET URL is short-lived** (server issues ~60 s URLs). Fetch it on demand and keep it
* out of long-term cache: a short `staleTime` re-fetches a fresh URL on reopen; a short `gcTime` drops the
* stale URL soon after the viewer closes (never retry a failed/expired sign is surfaced, not re-hammered).
*/
export const SIGNED_DOCUMENT_URL_STALE_TIME = 30_000;
export const SIGNED_DOCUMENT_URL_GC_TIME = 60_000;
@@ -1,21 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { verificationApi } from '../apis';
import { verificationKeys } from '../keys';
import { SIGNED_DOCUMENT_URL_GC_TIME, SIGNED_DOCUMENT_URL_STALE_TIME } from '../constants';
/**
* A document's **short-lived signed GET URL**, fetched on demand when the viewer opens a document (pass
* `null` while none is open). Short `staleTime` + short `gcTime` keep the URL out of long-term cache a
* reopen re-signs a fresh URL rather than reusing an expired one. `retry: false`: a failed/expired sign is
* surfaced to the viewer's error/re-request path, not silently re-hammered.
*/
export function useVerificationDocumentUrl(documentId: number | null) {
return useQuery({
queryKey: verificationKeys.adminDocumentUrl(documentId ?? -1),
queryFn: () => verificationApi.getDocumentSignedUrl(documentId as number),
enabled: documentId != null,
staleTime: SIGNED_DOCUMENT_URL_STALE_TIME,
gcTime: SIGNED_DOCUMENT_URL_GC_TIME,
retry: false,
});
}
@@ -9,7 +9,6 @@ export { useNurseTrustBadge } from './hooks/useNurseTrustBadge';
// Admin review queue (b6 AdminVerificationsController)
export { useVerificationQueue } from './hooks/useVerificationQueue';
export { useVerificationCase } from './hooks/useVerificationCase';
export { useVerificationDocumentUrl } from './hooks/useVerificationDocumentUrl';
export { useDecideStep } from './hooks/useDecideStep';
export { useApproveVerification } from './hooks/useApproveVerification';
export { useRejectVerification } from './hooks/useRejectVerification';
+3 -7
View File
@@ -7,9 +7,9 @@ import type { PageParams } from '@/lib/api/types';
* submit/upload/run mutation invalidates `status()` so the checklist re-renders from cache with no
* manual refetch. The public `badge(nurseId)` is longer-lived and reused by search/f6.
*
* The admin subtree (`admin()` queue / case / document-url) mirrors the same hierarchy: each queue
* variant (filters+params) and each case keys independently, and the `adminQueues()` / `adminCases()`
* prefixes let a decision invalidate every queue page and a single case in one call.
* The admin subtree (`admin()` queue / case) mirrors the same hierarchy: each queue variant
* (filters+params) and each case keys independently, and the `adminQueues()` / `adminCases()` prefixes let
* a decision invalidate every queue page and a single case in one call.
*/
export const verificationKeys = {
all: ['verification'] as const,
@@ -35,8 +35,4 @@ export const verificationKeys = {
// A single nurse's full case — invalidated on every decide / approve / reject.
adminCases: () => [...verificationKeys.admin(), 'case'] as const,
adminCase: (nurseVerificationId: number) => [...verificationKeys.adminCases(), nurseVerificationId] as const,
// A document's short-lived signed URL — keyed per document; kept out of long-term cache (fetched on demand).
adminDocumentUrls: () => [...verificationKeys.admin(), 'document_url'] as const,
adminDocumentUrl: (documentId: number) => [...verificationKeys.adminDocumentUrls(), documentId] as const,
};
+5 -9
View File
@@ -260,12 +260,6 @@ export interface DecideStepResult {
credentialId: number | null;
}
/** A freshly-signed, short-lived GET URL for a document — fetched on demand (never long-cached). */
export interface SignedDocumentUrl {
url: string;
expiresInSeconds: number;
}
/**
* The verification domain's API seam the real HTTP client and the in-memory mock both implement
* this interface; selection is by config (`USE_VERIFICATION_MOCK`), never scattered `if (mock)` checks.
@@ -294,10 +288,12 @@ export interface VerificationApi {
// --- Admin review queue (b6 AdminVerificationsController) ---
/** The review queue, folded to one item per nurse. `status`/`search` filter (status default `in_review`); paginated. */
listVerificationQueue(filters: AdminVerificationQueueFilters, params: PageParams): Promise<AdminVerificationQueuePage>;
/** The full admin case for one nurse — steps + documents + credentials + the identity name for cross-check. */
/**
* The full admin case for one nurse steps + documents + credentials + the identity name for cross-check.
* Each document's `url` is already a short-lived signed GET URL; re-opening the viewer refetches this
* case (there is no separate per-document re-sign route) to get a fresh one.
*/
getVerificationCase(nurseVerificationId: number): Promise<AdminVerificationCase>;
/** A freshly-signed, short-lived GET URL for a document — fetched on demand (URLs expire; never long-cached). */
getDocumentSignedUrl(documentId: number): Promise<SignedDocumentUrl>;
/** Approve or reject a manual step; on a credential-bearing step, records the (encrypted) credential. Re-aggregates. */
decideStep(stepId: number, input: DecideStepInput): Promise<DecideStepResult>;
/** Approve the whole verification (all required steps pass → `approved`), removing it from the queue. */
-21
View File
@@ -1,21 +0,0 @@
# Balinyaar — the block to add to your EXISTING Caddyfile (the Caddy container that owns caddy_net).
#
# This is not loaded by anything in this repo; it is a copy of what DEPLOY.md tells you to paste, kept
# here so the reverse-proxy contract lives next to the compose file that depends on it.
#
# Both upstreams are plain HTTP on the container network — Caddy is the only TLS terminator, and it
# obtains/renews the certificates for both hostnames automatically.
balinyaar.ir, www.balinyaar.ir {
encode zstd gzip
reverse_proxy balinyaar-web:3000
}
api.balinyaar.ir {
encode zstd gzip
# The API partitions its rate limiter on the client IP resolved from X-Forwarded-For, and trusts the
# docker bridge ranges listed under ForwardedHeaders:KnownNetworks. Caddy sets X-Forwarded-For and
# X-Forwarded-Proto by default, so no extra header directives are needed here.
reverse_proxy balinyaar-api:8080
}
-31
View File
@@ -1,31 +0,0 @@
# `dev/` — the Balinyaar build workspace
This folder is the **plan for building Balinyaar**, not application code. It takes the repo from its
current *starter + auth* baseline to the MVP described in [`product/`](../product/), as a chain of
agent-runnable prompt files split into two parallel tracks.
| Folder | What it is |
| --- | --- |
| [`phases/`](phases/README.md) | The prompt chain — `backend/` (b0b15) and `frontend/` (f0f15), plus the shared rules/template in `phases/_shared/`. **Start at [`phases/README.md`](phases/README.md).** |
| [`contracts/`](contracts/README.md) | The shared API/flow contract between the two independent projects. Backend writes, frontend reads. |
| [`shared-working-context/`](shared-working-context/README.md) | The parallel-agent handoff + per-phase reports + the mock registry. Each lane writes only its own files. |
| [`post-phase/`](post-phase/refinement/README.md) | Follow-up chains run after the 16+16 phases: the server audit ([`server/`](post-phase/server/README.md)), the integration/production [`refinement/`](post-phase/refinement/README.md) chain (complete), and the [`ui/`](post-phase/ui/README.md) design chain (UI phases 013 + the design audit that produced them). |
## How to use it
1. Read [`phases/README.md`](phases/README.md) — the roadmap and dependency graph.
2. To run a phase, point a fresh agent at one phase file (e.g. *"Execute `dev/phases/backend/backend-phase-2.md`"*).
The phase file tells it what to read, what to build, and how to close out.
3. Run the two tracks **in parallel** with two agents if you like: a frontend phase named
`frontend-phase-N-bM.md` only needs **backend phase bM** merged first; everything else about the two
tracks is decoupled through `contracts/` and `shared-working-context/` (which are designed so the two
agents never touch the same files).
## Non-negotiables (every phase enforces them)
- Follow the project rules in the relevant `CLAUDE.md` / `CONVENTIONS.md` and the
[shared operating rules](phases/_shared/agent-operating-rules.md).
- Mock external services **behind DI seams** and record them in
[`shared-working-context/reports/mocks-registry.md`](shared-working-context/reports/mocks-registry.md).
- Finish each phase with: updated docs, a written contract (backend), a handoff note, a phase report,
and saved memory.
-45
View File
@@ -1,45 +0,0 @@
# Contracts — the shared interface between `client/` and `server/`
The two projects are independent (no shared build). This folder is their **single shared source of
truth** for everything that crosses the wire: API routes, request/response shapes, status codes, enums,
shared flows, and money/format conventions. It lets a frontend agent build against a stable contract
**before, during, and after** the matching backend phase, and it lets the two run in parallel.
## Ownership (this is what makes parallel work safe)
- **Backend owns and writes** `contracts/domains/*` and `contracts/openapi/*`. A backend phase that
ships an API writes/updates the contract in the **same** change.
- **Frontend reads** contracts and derives its TypeScript types from them. The frontend never edits
files here. If a contract is missing or wrong, the frontend appends a request to
[`../shared-working-context/frontend/requests/for-backend.md`](../shared-working-context/frontend/requests/for-backend.md);
the backend delivers the fix in a later change.
## What's here
| Path | What it is |
| --- | --- |
| `conventions/api-conventions.md` | The envelope, routing, pagination, errors, auth, locale — read first. |
| `conventions/money-and-types.md` | How money, dates, enums, gender, IDs are represented on the wire. |
| `domains/_TEMPLATE.md` | The shape every per-domain contract doc follows. |
| `domains/<domain>.md` | One file per domain (identity, catalog, booking, payments, …), added by the backend phase that ships it. |
| `openapi/` | The published `swagger.json` snapshot(s) — the machine-readable contract for type generation. |
## How a contract is produced (backend)
1. Build the endpoints following `server/CONVENTIONS.md`.
2. Write/extend `domains/<domain>.md` from `domains/_TEMPLATE.md`: every route, its method + snake_case
path, auth/policy, request and response JSON (with a real example), the enums it uses, and the error
cases. Reference, don't restate, `conventions/*`.
3. Publish the OpenAPI snapshot per `openapi/README.md`.
4. Note in your handoff (`shared-working-context/backend/handoff/after-backend-phase-N.md`) that the
contract is live.
## How a contract is consumed (frontend)
1. Read `domains/<domain>.md` + `conventions/*`. Derive types in `src/services/{domain}/types.ts`
(or generate from `openapi/swagger.json`) — keep names aligned with the contract.
2. If something is missing/ambiguous, request it (don't guess) and mock behind the `services/{domain}`
seam meanwhile.
> Keep contracts **versioned by being honest**: when a shipped shape changes, update its `domains/*` doc
> and the OpenAPI snapshot in the same change, and call it out in the handoff so the frontend re-syncs.
@@ -1,50 +0,0 @@
# API conventions (read before writing or consuming any contract)
These hold for **every** Balinyaar endpoint. Per-domain contract docs assume them and don't restate them.
## Base & versioning
- Base URL from `NEXT_PUBLIC_API_URL` (client) / `https://localhost:5002` (server default).
- Versioned routes: `api/v{version}/...` (Asp.Versioning). Default `v1`.
- **All URL segments are `snake_case`** (server `SnakeCaseParameterTransformer`). Controllers use
`[controller]`/`[action]` tokens, so `GetNurseProfile``.../get_nurse_profile`.
## Response envelope (`OperationResult``ApiResult`)
Every response is the server's standard envelope, not a bare body. Success and failure share the shape;
the frontend's `clientFetch`/`serverFetch` already unwrap it and throw `ApiError` on failure. Document
each endpoint's **payload** (the `data`/result) and its failure cases. The envelope carries at least:
a success flag, an HTTP-aligned status, a user-safe message, and the typed `data` (or validation errors).
The canonical shape is defined by `Baya.Application/Models/ApiResult` + `OperationResult<T>` on the
server — mirror that, don't invent a new envelope.
## Status codes
- `200` success (payload in `data`).
- `400` validation/business-rule failure (field-level errors included).
- `401` unauthenticated (missing/expired token) · `403` unauthorized (lacks permission).
- `404` not found.
- `409` conflict (idempotency / duplicate / state-machine violation) where applicable.
- `5xx` unexpected (generic safe message; details only in server logs).
## Auth
- Bearer JWE in `Authorization: Bearer <token>` (access token, ~15 min). Refresh via the refresh
endpoint; rotation + reuse-detection apply. The client attaches the header automatically in
`clientFetch`. State which policy/role each endpoint needs.
## Localisation
- The client sends the active locale (`Accept-Language` / `x-app-locale`, `fa` default). Server-produced
user-facing messages should honour it. Reference data that has `name_fa`/`name_en` returns both;
the client picks by locale.
## Pagination (mandatory on lists)
- Query params: `page` (1-based) + `pageSize` (cap it server-side, e.g. ≤100). Response payload carries
`items` + `total` (+ `page`/`pageSize`). Document the default and max `pageSize` per endpoint.
## Idempotency (money & side-effecting POSTs)
- Where stated, the client sends an idempotency key (header or body field) and the server dedups. Webhook
endpoints dedup on the provider's `external_event_id`. Document which endpoints require a key.
## Naming
- JSON properties are the server's serialized casing (follow what NSwag/Swagger emits — typically
`snake_case` to match routing, or the project's configured policy; **derive the exact casing from the
published `swagger.json`, don't assume**). The frontend types match the wire exactly.
> When in doubt about an envelope/casing detail, the published `openapi/swagger.json` is authoritative.
@@ -1,39 +0,0 @@
# Money & shared types on the wire
## Money — IRR Rials, integer, no floats
- All monetary values are **IRR Rials as integers** (`BIGINT` server-side). There are **no floats** on
the money path anywhere — not in the DB, not in the API, not in the client.
- Because IRR amounts exceed JS's safe integer range in some aggregates and to avoid float coercion,
represent money on the wire as a **string of digits** (e.g. `"23300000"`) unless the published
`swagger.json` shows otherwise; the client parses with `BigInt`/integer-safe helpers and formats for
display. **Toman is display-only** and is converted to/from Rials **only** inside a provider adapter
at its boundary — never in shared contracts or the client's own math.
- The three booking amounts always satisfy `gross_price_irr = balinyaar_commission_irr + nurse_payout_amount`.
## Dates & times
- Timestamps are **UTC ISO-8601** (`DATETIME2(7)` server-side). Persian-calendar (Shamsi) display is a
**client** concern. The exception is bank-closure scheduling, which the server resolves via the
holiday calendar — the client never computes payout dates.
- `day_of_week` for availability uses the **Shamsi week (0 = Saturday … 6 = Friday)**, not ISO Monday-start.
## Enums (string-valued, stable codes)
Enums cross the wire as their stable string code (e.g. `male`/`female`/`any`, `per_hour`/`per_session`/
`per_half_day`/`per_day`/`per_24h`, booking/verification/payment statuses, `refund_channel` =
`psp_card`/`bnpl_revert`/`manual`). Each domain contract doc lists the exact set it uses. The frontend
mirrors them as string-literal union types and **never** hardcodes a display label off the code — labels
are i18n keys.
## Identifiers
- Entity IDs are integers/`BIGINT` (serialized per the published schema). Human-facing references
(`reference_code` on tickets, `invoice_number`) are strings shown to users — treat as opaque.
## PII & sensitive fields
- Encrypted-at-rest fields (phone, national_id, IBAN, addresses, clinical notes) are returned **only**
to authorized callers and often **masked** (e.g. last 4 of an IBAN). Contracts must state when a field
is masked vs. full, and the two-stage clinical-disclosure rule (full care instructions only after a
booking is confirmed, to the assigned nurse + admin) applies to the relevant payloads.
## Gender (load-bearing)
- `gender` (`male`/`female`) drives **same-gender caregiver matching** — a near-hard requirement. It is
present on users/patients and on the booking request as `required_caregiver_gender`
(`male`/`female`/`any`). Never default or drop it silently.
-36
View File
@@ -1,36 +0,0 @@
# Contract — <Domain> (backend phase bN)
> One-line: what this domain's API covers. Assumes
> [`../conventions/api-conventions.md`](../conventions/api-conventions.md) +
> [`../conventions/money-and-types.md`](../conventions/money-and-types.md). Source of truth for the
> machine schema: [`../openapi/`](../openapi/README.md).
**Status:** live as of backend-phase-bN · **Frontend consumer:** frontend-phase-fM
## Enums used
- `<enum_name>`: `value_a` | `value_b` | … — meaning of each.
## Endpoints
### `<HTTP> api/v1/<controller>/<action>`
- **Purpose:**
- **Auth:** none | authenticated | policy/role … · **Rate-limited:** yes/no · **Idempotency key:** yes/no
- **Path/query params:** `name` (type) — meaning; pagination `page`/`pageSize` (default/max) for lists.
- **Request body:**
```json
{ "field": "example" }
```
- **Success `200` payload (`data`):**
```json
{ "field": "example" }
```
- **Failure cases:** `400` …, `401` …, `403` …, `404` …, `409` … (when/why each).
- **Notes:** masking, two-stage disclosure, tenancy, side effects (notifications/ledger/audit), etc.
_(repeat per endpoint)_
## Shared shapes
- `<DtoName>`: field-by-field (name, type, nullable, masked?, meaning).
## Changelog
- bN — initial contract.
-164
View File
@@ -1,164 +0,0 @@
# Contract — BNPL provider-financed installments (backend phase b12)
> One-line: the "pay with installments" checkout alternative. A family checks eligibility, starts a BNPL order
> and is handed off to the provider; the provider callback (or an admin) verifies + settles it — which, in our
> books, is **a card payment that lands net-of-fee** (the provider pays the full booking amount in one lump minus
> its merchant commission and owns 100% of the customer's installments + default risk). Admins can revert. Assumes
> [`../conventions/api-conventions.md`](../conventions/api-conventions.md) +
> [`../conventions/money-and-types.md`](../conventions/money-and-types.md). Machine schema:
> [`../openapi/swagger.v1.json`](../openapi/README.md).
**Status:** live as of backend-phase-b12 · **Frontend consumer:** frontend-phase-f11-b12
All money is **IRR Rials, integer, on the wire as a string of digits** (`"10000000"`). We do **not** model the
customer's repayment schedule — `installment_count` is informational (default 4). Timestamps are UTC ISO-8601;
`settled_at` is **nullable** (settlement is contract-defined and not instant); `expected_customer_refund_eta` is a
**date** (`"2026-08-24"`). Internal ledger `account_type`s are never exposed.
## Enums used
- `bnpl_status` (`bnpl_transactions.status`): `eligible` | `token_issued` | `verified` | `settled` | `reverted` |
`cancelled` | `failed`. **Forward-only** (`eligible → token_issued → verified → settled → reverted`); a replayed
callback that would re-drive a completed transition is an idempotent no-op.
- `bnpl_eligibility_status` (`bnpl_transactions.eligibility_status`): `eligible` | `not_eligible` |
`ceiling_exceeded`. On anything but `eligible` the client falls back to card.
- `provider_code`: `snapppay` | `digipay` | `tara` | `torobpay` — selects the provider adapter.
- `refund_channel` (on the revert's refund): always `bnpl_revert` here (see
[`refunds-invoices.md`](refunds-invoices.md)).
## Endpoints
### `POST api/v1/checkout_bnpl/eligibility`
- **Purpose:** check whether the caller can finance an `accepted_awaiting_payment` booking request with a
provider, and record the outcome on a created/updated `bnpl_transactions` row (status `eligible`).
- **Auth:** authenticated (customer, tenancy-scoped) · **Rate-limited:** yes (sensitive) · **Idempotency key:** no
- **Request body:**
```json
{ "bookingRequestId": 42, "providerCode": "snapppay" }
```
- **Success `200` payload (`data`):**
```json
{
"eligibilityStatus": "eligible",
"isEligible": true,
"installmentCount": 4,
"planSummary": "4 interest-free installments, 0% interest, provider-financed.",
"creditCeilingIrr": "2000000000"
}
```
- **Failure cases:** `400` invalid `provider_code` / non-positive id; `401` unauthenticated; `404` request not
found **or not owned by the caller** (tenancy — a cross-customer request is indistinguishable from missing);
`409` already paid / not awaiting payment.
- **Notes:** the order amount is the request's frozen gross (variant price × session count), never client-supplied.
### `POST api/v1/checkout_bnpl/initiate`
- **Purpose:** start the BNPL order — issue the provider payment token + redirect and walk `eligible →
token_issued`.
- **Auth:** authenticated (customer, tenancy-scoped) · **Rate-limited:** yes (sensitive) · **Idempotency key:**
`Idempotency-Key` header (a retried initiate reuses the same token).
- **Request body:**
```json
{ "bookingRequestId": 42, "providerCode": "snapppay" }
```
- **Success `200` payload (`data`):**
```json
{
"bnplTransactionId": 7,
"paymentTransactionId": 15,
"status": "token_issued",
"externalPaymentToken": "mock-bnpl-token-10000000-bnpl-br-42",
"redirectUrl": "https://provider.example/checkout/…"
}
```
- **Failure cases:** `400` invalid input; `401` unauthenticated; `404` request not found / not owned; `409` already
paid / not awaiting payment / payment window lapsed / order no longer startable; `400` provider declined the
order.
- **Notes:** the row is **1:1** with a `payment_transaction` (`UNIQUE(payment_transaction_id)`); a second initiate
reuses the same row. Runs under `lock(booking-request:{id}:payment)`.
### `GET api/v1/checkout_bnpl/{id}`
- **Purpose:** the customer reads **their own** BNPL order.
- **Auth:** authenticated (tenancy-scoped) · **Rate-limited:** yes (sensitive)
- **Success `200`:** the `BnplOrderStatus` shape (below).
- **Failure cases:** `401`; `404` not found **or another customer's** order (clean not-found).
### `POST api/v1/webhooks_bnpl/{provider}`
- **Purpose:** inbound provider callback — verify/settle/revert an order by event type.
- **Auth:** anonymous, **signature-authenticated** · **Rate-limited:** yes (per-IP) · **Idempotency key:**
`(provider_code, external_event_id)` deduped in `payment_webhook_events` before any money moves.
- **Request body:** raw provider payload; the mock verifier reads
`{ "external_event_id": "...", "event_type": "order.settled", "gateway_reference_code": "<token>" }`. Event type
routes: contains `verif` → verify, `settl` → settle, `revert`/`refund` → revert.
- **Success `200` payload (`data`):**
```json
{ "processingStatus": "processed", "duplicate": false }
```
- **Notes:** always `200` (at-least-once tolerant). A bad signature is stored `ignored`; a duplicate is a no-op
(`duplicate: true`); an unknown token is `failed` (retryable). A replayed settle never double-posts the ledger
(webhook dedup + the forward-only state guard).
### `POST api/v1/admin_bnpl/{id}/verify` · `POST api/v1/admin_bnpl/{id}/settle`
- **Purpose:** manually drive verify / settle (also driven by the callback).
- **Auth:** admin (dynamic-permission) · **Rate-limited:** yes (sensitive)
- **Success `200`:** `true`.
- **Failure cases:** `401`/`403`; `404` order not found; `409` wrong state (e.g. settle before verify); `400`
provider declined / settlement does not reconcile.
- **Settle side effects:** records `settled_amount_irr` = `order commission`, `bnpl_commission_irr`, `settled_at`
(nullable — read from the settlement); posts the **net-of-fee ledger group** (card-capture legs **plus** `DEBIT
bnpl_fee_expense / CREDIT escrow_held`, one balanced group, so escrow reflects the **net** cash); confirms the
parent `payment_transaction`**converts the booking**. The nurse's `nurse_payable` accrual equals the
card-path amount (payout **invariant to payment method**). Runs under `lock(bnpl:{id}:settle)`.
### `POST api/v1/admin_bnpl/{id}/revert`
- **Purpose:** reverse a settled BNPL order through the provider.
- **Auth:** admin (dynamic-permission) · **Rate-limited:** yes (sensitive)
- **Request body:** (all optional; omit `refund_percentage` for a full revert)
```json
{ "refundPercentage": 1.0, "ticketId": null, "reasonNotes": "customer cancelled" }
```
- **Success `200` payload (`data`):**
```json
{
"bnplTransactionId": 7,
"refundId": 3,
"status": "reverted",
"revertTransactionId": "…",
"revertedAmountIrr": "8000000",
"expectedCustomerRefundEta": "2026-08-24"
}
```
- **Failure cases:** `401`/`403`; `404` not found; `409` not settled / already reverted; `400` provider refused.
- **Notes:** creates a `refunds` row with `refund_channel='bnpl_revert'` and posts the reversal ledger via the b11
refund path (fee + payout legs; a clawback if the nurse was already paid). Money flows **customer ↔ provider ↔
Balinyaar** only; the customer cash-back is async ~710 business days (`expected_customer_refund_eta`). A
partial (`refund_percentage < 1`) maps to the provider's update-to-strictly-lower verb.
### `GET api/v1/admin_bnpl/{id}`
- **Purpose:** admin reads any BNPL order.
- **Auth:** admin (dynamic-permission) · **Rate-limited:** yes (sensitive)
- **Success `200`:** the `BnplOrderStatus` shape (below).
## Shared shapes
- `BnplOrderStatus``id` (long), `paymentTransactionId` (long), `bookingId` (long?, set at settle),
`providerCode` (string), `status` (`bnpl_status`), `eligibilityStatus` (`bnpl_eligibility_status`?),
`orderAmountIrr` (digit string), `settledAmountIrr` (digit string?), `bnplCommissionIrr` (digit string?),
`currency` (string, `IRR`), `installmentCount` (int, informational), `settledAt` (datetime?, **nullable —
not instant**), `revertTransactionId` (string?), `revertedAmountIrr` (digit string?), `revertedAt` (datetime?),
`providerCommissionReversedAmount` (digit string?), `refundChannel` (string?, `bnpl_revert`),
`expectedCustomerRefundEta` (date?), `createdAt` (datetime).
## Changelog
- b12 — initial contract (eligibility, initiate, customer/admin status, webhook, admin verify/settle/revert).
---
## Refinement phase 3 additions (REQ-022/023/024)
- `balinyaar` added to the `provider_code` enum (in-house plan; identical net-of-fee mechanics, resolves to the
same adapter). The set is now `snapppay|digipay|tara|torobpay|balinyaar`.
- `POST checkout_bnpl/eligibility` accepts optional `{ nationalId, mobile, consent }` (consent required when the
KYC inputs are present; a supplied mobile drives the provider inquiry, else the account mobile).
- `GET api/v1/checkout_bnpl/by_request/{bookingRequestId}` (owner-scoped) → `BnplOrderStatusDto`; `bookingId` on
the settled order was already present on the DTO.
- **DEFERRED:** `checkout_bnpl/options/{id}` + `schedule` + `wallet_installments` — b12 deliberately does not model
the customer repayment schedule / per-installment status, and there is no installment ledger to serve them from.
Keep the D1/D2/D4/D5 plan visualization mocked until a provider-schedule integration (or a schedule table) lands.
-166
View File
@@ -1,166 +0,0 @@
# Contract — Booking requests (backend phase b8)
> The **pre-payment intent** layer of the engagement lifecycle: a customer requests a nurse; the nurse
> accepts/rejects before a config-driven response deadline; on accept a config-driven **30-minute payment
> window** opens. **No money and no `bookings` row exist here** — accept only opens the window (conversion is
> b9/b10). Assumes [`../conventions/api-conventions.md`](../conventions/api-conventions.md) +
> [`../conventions/money-and-types.md`](../conventions/money-and-types.md). Machine schema:
> [`../openapi/swagger.v1.json`](../openapi/README.md) (refreshed for b8).
**Status:** live as of backend-phase-b8 · **Frontend consumer:** frontend-phase-f7-b8
> **Routing note.** Routes are **action-style** (`[controller]/[action]`, snake_cased). All responses use the
> standard `{ succeeded, statusCode, data }` envelope; `data` shapes are below. Request bodies are camelCase.
## Key semantics (read first)
- **Two-table split, no money on a request.** A `booking_requests` row never carries a price/total and never
creates a booking. Accept moves it to `accepted_awaiting_payment` and opens the payment window; b9 later
consumes that and creates the `bookings` row (+ money), setting the request to `converted`.
- **Two-stage clinical disclosure (stage 1).** The nurse sees **only** the unencrypted, limited
`customer_notes` — never a full clinical/care instruction (those are b9's encrypted, post-confirmation
`booking_care_instructions`). The **nurse view of a request masks the full address** (address line / postal
code / recipient) and shows only a coarse city/district location; the **customer/admin view** returns the
full address.
- **Tenancy invariant (enforced at create).** The `patient` and `customer_address` must belong to the caller's
customer; the `variant` must belong to the requested `nurse_id`. A mismatch is a clean `404` — never a leak.
- **Same-gender match is first-class.** `required_caregiver_gender` (`male`/`female`/`any`) is matched against
the nurse's gender at request time. `male`/`female` must equal the nurse's gender; `any` matches either. A
mismatch is a `400`. It is required on create and never silently defaulted.
- **Deadlines are frozen from config.** `nurse_response_deadline_at` = create-time `now +
nurse_response_deadline_hours` (default 24h); `payment_deadline_at` = accept-time `now +
booking_payment_deadline_minutes` (**30**). Both are absolute UTC timestamps stored on the row — a later
config change never moves an existing request's deadlines.
- **Forward-only status machine.** Illegal transitions return `409`. Terminal states
(`converted`/`rejected_by_nurse`/`expired_no_response`/`payment_deadline_expired`/`cancelled_by_customer`)
have no outgoing edges. An accept after the response deadline, or on a non-pending request, is `409`.
- **Auto-expiry.** A background sweep (also an admin manual trigger) moves `pending_nurse_response →
expired_no_response` past the response deadline and `accepted_awaiting_payment → payment_deadline_expired`
past the payment window, and notifies the customer.
- **Timestamps** are UTC ISO-8601. **Ids** are `BIGINT`. **There is no money field anywhere in this domain.**
## Enums used
- `booking_request_status`: `pending_nurse_response` | `accepted_awaiting_payment` | `converted` |
`rejected_by_nurse` | `expired_no_response` | `payment_deadline_expired` | `cancelled_by_customer`.
- `required_caregiver_gender`: `male` | `female` | `any`.
## Endpoints
### `POST api/v1/booking_requests/create`
- **Purpose:** a customer requests a nurse for a patient/variant/address/date.
- **Auth:** authenticated **customer (owner)** · **Rate-limited:** no · **Idempotency key:** no
- **Request body:**
```json
{
"nurseId": 42,
"variantId": 8,
"patientId": 5,
"customerAddressId": 6,
"requestedDate": "2026-08-01",
"requestedTimeStart": "09:00:00",
"requestedTimeEnd": "13:00:00",
"requiredCaregiverGender": "female",
"customerNotes": "Careful with the IV line."
}
```
- **Success `200` payload (`data`):** a `BookingRequestDto` (customer view — full address), status
`pending_nurse_response`, `nurseResponseDeadlineAt` set, `paymentDeadlineAt` null.
- **Failure cases:** `400` validation (missing/invalid gender, `requestedTimeEnd ≤ requestedTimeStart`,
past date, notes > 1000, inactive variant, nurse not verified/accepting, **same-gender mismatch**);
`401` unauthenticated; `403` not a customer / no customer profile; `404` patient/address not owned or
variant not the nurse's or nurse absent.
- **Side effects:** in-app `booking_request_received` notification to the nurse (`data_json`:
`booking_request_id`, `patient_display_name`, `requested_date`).
### `POST api/v1/booking_requests/accept/{id}`
- **Purpose:** the assigned nurse accepts a pending request, opening the 30-minute payment window.
- **Auth:** authenticated **nurse (assigned)** · path param `id` (BIGINT).
- **Request body:** none.
- **Success `200` payload (`data`):** `BookingRequestDto` (nurse view — masked address), status
`accepted_awaiting_payment`, `paymentDeadlineAt = now + booking_payment_deadline_minutes` (30 min).
- **Failure cases:** `401`; `403` not a nurse; `404` not the caller's request; `409` not pending / already
past the response deadline.
- **Side effects:** in-app `booking_request_accepted` notification to the customer (`data_json`:
`booking_request_id`, `payment_deadline_at`). **No `bookings` row and no money are created.**
### `POST api/v1/booking_requests/reject/{id}`
- **Purpose:** the assigned nurse declines a pending request with a reason.
- **Auth:** authenticated **nurse (assigned)** · path param `id`.
- **Request body:** `{ "reason": "Fully booked that week." }` (required, ≤ 500 chars).
- **Success `200` payload (`data`):** `BookingRequestDto`, status `rejected_by_nurse`, `nurseRejectionReason` set.
- **Failure cases:** `400` empty/too-long reason; `401`; `403`; `404`; `409` not pending.
- **Side effects:** in-app `booking_request_rejected` notification to the customer.
### `POST api/v1/booking_requests/cancel/{id}`
- **Purpose:** the customer withdraws a request that is still `pending_nurse_response` or
`accepted_awaiting_payment` (before paying).
- **Auth:** authenticated **customer (owner)** · path param `id`.
- **Request body:** none.
- **Success `200` payload (`data`):** `BookingRequestDto` (customer view), status `cancelled_by_customer`.
- **Failure cases:** `401`; `403`; `404` not owned; `409` request is terminal (converted/rejected/expired).
### `GET api/v1/booking_requests/list`
- **Purpose:** the role-scoped inbox (paginated).
- **Auth:** authenticated (customer **or** nurse).
- **Query params:** `status` (optional enum filter); `role` (`customer`|`nurse`, optional — disambiguates a
user who holds both roles; inferred from the caller's profile when omitted); `page`/`pageSize` (default
1 / 50, max 100).
- **Success `200` payload (`data`):** `PagedResult<BookingRequestListItemDto>` (`items`, `total`, `page`,
`pageSize`). Actionable rows sort first. The **customer** inbox sets `counterpartyName` = nurse name +
`nurseRating`; the **nurse** inbox sets `counterpartyName` = patient name + `customerNotes` (stage-1 only).
- **Failure cases:** `400` holds both roles and no `role` given; `401`. A caller with neither profile gets an
empty page.
### `GET api/v1/booking_requests/get/{id}`
- **Purpose:** a single request.
- **Auth:** its **customer** (full address), its **nurse** (masked address), or an **admin** (full).
- **Success `200` payload (`data`):** `BookingRequestDto`.
- **Failure cases:** `401`; `404` absent **or** caller is neither party nor admin (existence not leaked).
### `POST api/v1/admin_booking_requests/expire`
- **Purpose:** admin/test manual trigger of the expiry sweep (the same command the recurring job runs).
- **Auth:** **admin** (`DynamicPermission`) · **Rate-limited:** no.
- **Request body:** none.
- **Success `200` payload (`data`):** `{ "expiredNoResponse": 0, "paymentDeadlineExpired": 0 }` — counts moved
this run (idempotent; re-running on a drained set returns zeros).
- **Failure cases:** `401`; `403` non-admin.
## Shared shapes
- `BookingRequestDto` (full single-request view):
`id` (long), `status` (enum), `nurseId` (long), `nurseName` (string), `nurseRating` (decimal),
`nurseTotalReviews` (int), `patientId` (long), `patientName` (string), `variantId` (long),
`variantLabel` (string), `variantPriceUnit` (string), `customerAddressId` (long), `addressTitle` (string),
`cityId` (long), `cityNameFa`/`cityNameEn` (string), `districtId` (long?, null = whole city),
`districtNameFa`/`districtNameEn` (string?), `addressLine`/`postalCode`/`recipientName`/`recipientPhone`
(string?, **null in the nurse view** — masked), `requiredCaregiverGender` (enum?), `requestedDate` (date),
`requestedTimeStart`/`requestedTimeEnd` (time), `customerNotes` (string?, stage-1 plaintext),
`nurseResponseDeadlineAt` (UTC datetime), `paymentDeadlineAt` (UTC datetime?, null until accept),
`nurseRejectionReason` (string?), `createdAt` (UTC datetime).
- `BookingRequestListItemDto` (inbox row):
`id`, `status`, `counterpartyName` (nurse name for customer view / patient name for nurse view),
`nurseRating` (decimal?, customer view only), `requiredCaregiverGender` (enum?), `requestedDate`,
`requestedTimeStart`, `requestedTimeEnd`, `nurseResponseDeadlineAt`, `paymentDeadlineAt`,
`customerNotes` (string?, **nurse view only**).
- `ExpireBookingRequestsResult`: `expiredNoResponse` (int), `paymentDeadlineExpired` (int).
## Changelog
- b8 — initial contract (create/accept/reject/cancel + role-scoped list + single get + admin expire).
---
## Refinement phase 3 additions (REQ-013/014/016/017)
- **`BookingRequestDto`** gains `variantPrice` (IRR digit-string — the chosen variant's *display rate*, not
an engagement total; the request stays money-free), `nurseAvatarUrl` (nullable), and `bookingId`
(nullable — the booking created once the request is `converted`, for the confirmation deep-link).
- **`BookingRequestListItemDto`** gains `variantLabel` (self-describing inbox row) and `patientAge`
(nullable coarse triage age).
- **`GET api/v1/booking_requests/checkout_summary/{id}`** (owner-scoped) — the C6 money breakdown:
`{ bookingRequestId, requestStatus, nurseName, patientName, variantLabel, variantPriceUnit, sessionCount,
requestedDate, requestedTimeStart, requestedTimeEnd, paymentDeadlineAt, serviceCostIrr, commissionIrr,
vatIrr, vatRate, totalIrr, grossPriceIrr, balinyaarCommissionIrr, nursePayoutAmount }`. All IRR
digit-strings, computed server-side. **Canonical rates:** `platform_fee_rate = 0.15`, `vat_rate = 0.10`.
VAT is **carved out of the commission** so `serviceCostIrr + commissionIrr + vatIrr = totalIrr = gross`
(the captured amount); `commissionIrr` is the commission **net of VAT**, and the raw b10 amounts
(`grossPriceIrr = balinyaarCommissionIrr + nursePayoutAmount`) are surfaced alongside.
-132
View File
@@ -1,132 +0,0 @@
# Contract — Bookings, Sessions, EVV & Cancellation (backend phase b9)
> One-line: the post-payment engagement — convert a paid request into a booking + N sessions, the two-stage
> care-instructions boundary, per-session EVV check-in/out, dispute-window gating, and cancellation. Assumes
> [`../conventions/api-conventions.md`](../conventions/api-conventions.md) +
> [`../conventions/money-and-types.md`](../conventions/money-and-types.md). Machine schema:
> [`../openapi/swagger.v1.json`](../openapi/README.md).
**Status:** live as of backend-phase-b9 · **Frontend consumer:** frontend-phase-f8-b9
All money is **IRR Rials, integer, on the wire as a string of digits** (`"15000000"`). The three booking
amounts always satisfy `gross_price_irr = balinyaar_commission_irr + nurse_payout_amount`. Timestamps are UTC
ISO-8601. Enums cross as their stable string codes.
## Enums used
- `BookingStatus`: `pending_payment` | `confirmed` | `in_progress` | `completed` | `disputed` | `closed` | `cancelled`.
- `BookingSessionStatus`: `scheduled` | `in_progress` | `completed` | `missed` | `cancelled`.
- `VisitVerificationStatus`: `pending` | `checked_in` | `completed`.
- `CancellationActor` (`applies_to` / `cancelled_by`): `customer` | `nurse` | `admin`.
## Endpoints
### `POST api/v1/bookings/convert`
- **Purpose:** The (mock) payment-capture conversion — creates the booking 1:1 from an
`accepted_awaiting_payment` request. In b10 the real card capture calls this path directly.
- **Auth:** authenticated (owning customer or admin) · **Rate-limited:** yes (sensitive) · **Idempotent:** yes
(replay returns the existing booking).
- **Request body:** `{ "bookingRequestId": 123 }`
- **Success `200` (`data`):** the `Booking` shape (below), `status = confirmed`.
- **Failure:** `400` bad id, `401` unauth, `404` request not found / not the caller's, `409` request not
awaiting payment / no longer convertible, `400` capture failed (no booking created).
- **Notes:** computes the three amounts (commission = round(`gross × platform_fee_rate`), rate snapshotted),
writes `variant_snapshot_json` + **encrypted** `address_snapshot_json`, generates ≥ 1 session with
`Σ visit_payout_amount = nurse_payout_amount`, flips the request → `converted`, notifies both parties.
### `GET api/v1/bookings/get/{id}`
- **Purpose:** Booking header + money summary + sessions + timeline. **Auth:** authenticated, tenancy-scoped
(customer own / nurse assigned / admin all). The **nurse** view omits `addressSnapshotJson`. Never includes
care-instruction clinical fields. **Failure:** `401`, `404` (not found / not a party → no leak).
### `GET api/v1/bookings/list?role=customer|nurse|all&status=&page=&pageSize=`
- **Purpose:** role-scoped "My bookings" (paginated, projected). `role=all` is admin-only (`403` otherwise).
**Success:** `PagedResult<BookingListItem>`.
### `POST api/v1/bookings/transition/{id}`
- **Purpose:** admin/dispute status move. **Auth:** admin (`403` otherwise). **Body:**
`{ "targetStatus": "disputed", "reason": "…" }`. **Failure:** `409` (illegal edge, or contradicts EVV —
e.g. `in_progress` with no session checked in, `completed` with a live session), `400` (use `cancel` for
cancellation). Completing here also opens the dispute window.
### `POST api/v1/bookings/cancel/{id}`
- **Purpose:** cancel a whole booking. **Auth:** owning customer / assigned nurse / admin · **Rate-limited:** yes.
**Body:** `{ "reason": "…" }`. **Success:** `CancellationResult`. Resolves + **snapshots** the policy
(`code` + `refund_percentage`) onto the booking, cancels only un-started (`scheduled`) sessions, computes the
refundable amount. **No refund ledger is posted (b11).** **Failure:** `400` no reason, `409` not cancellable.
### `POST api/v1/bookings/submit_care_instructions/{id}`
- **Purpose:** write/update the encrypted stage-2 `booking_care_instructions`. **Auth:** owning customer or
admin, booking must be `confirmed`+. **Body:** `CareInstructions` (all optional strings). **Failure:** `409`
not confirmed, `404` not found.
### `GET api/v1/bookings/care_instructions/{id}`
- **Purpose:** the **gated** stage-2 read. **Auth:** **assigned nurse or admin only, post-confirmation.** Any
other caller (customer, unassigned nurse, pre-confirmation) → `404` (never leaks). Returns decrypted
`CareInstructions`. **This is the two-stage disclosure boundary.**
### `POST api/v1/booking_sessions/check_in/{id}`
- **Purpose:** assigned nurse clocks in. **Auth:** assigned nurse. **Body:** `{ "latitude": 35.6892,
"longitude": 51.389 }` (both nullable — GPS-denied still checks in, flagged). Moves the session + booking to
`in_progress`; computes the **advisory** address match against `evv_location_tolerance_meters`. A mismatch
raises a `location_mismatch` support alert + notifies **without blocking**. **Success:** `VisitVerification`.
**Failure:** `401`, `403` (not a nurse), `404` (not the nurse's session), `409` (not startable).
### `POST api/v1/booking_sessions/check_out/{id}`
- **Purpose:** assigned nurse clocks out — must follow an open check-in. Completes the session's EVV, sets its
`payout_eligible_at`, and — when all sessions are settled — completes the booking + sets
`dispute_window_ends_at`. **Failure:** `400` no open check-in, `409` not checkout-able.
### `GET api/v1/booking_sessions/today?date=&page=&pageSize=`
- **Purpose:** the nurse's sessions for a day (default all), with check-in/out CTA state. **Auth:** nurse,
tenancy-scoped. **Success:** `PagedResult<BookingSessionListItem>`.
### `GET api/v1/booking_sessions/evv/{id}`
- **Purpose:** per-session EVV detail. **Auth:** owning nurse + admin only (raw GPS gated); others → `404`.
### `POST api/v1/booking_sessions/cancel/{id}`
- **Purpose:** cancel a single un-started session · **Rate-limited:** yes. Snapshots the policy + computes the
session's refundable share. **Failure:** `409` if the session already started.
### `GET api/v1/admin_evv/list?type=mismatch|no_show&page=&pageSize=`
- **Purpose:** admin EVV-review queue. **Auth:** admin policy · **Rate-limited:** yes. **Success:**
`PagedResult<AdminEvvItem>`.
### `POST api/v1/admin_evv/detect_no_shows`
- **Purpose:** the manual no-show sweep trigger (the recurring cron is DEFERRED). **Auth:** admin. Marks
overdue scheduled sessions `missed`, raises `no_show` alerts + notifies. **Success:** `{ "missed": N }`.
### `POST api/v1/admin_cancellation_policies/upsert` · `GET api/v1/admin_cancellation_policies/list`
- **Purpose:** admin CRUD of cancellation tiers (keyed by unique `code`). **Auth:** admin policy. Editing a
policy never mutates an already-snapshotted cancellation. **Failure:** `400` (percentage not 0100, bad
actor, min ≥ max).
## Shared shapes
- **`Booking`** (`bookings/get`, `convert`, `transition`): `id`, `bookingRequestId`, `status` (`BookingStatus`),
`nurseId`, `nurseName`, `patientId`, `patientName`, `variantId`, `variantSnapshotJson` (string),
`customerAddressId`, `addressSnapshotJson` (string, **null for the nurse view**), `grossPriceIrr`,
`balinyaarCommissionIrr`, `nursePayoutAmount`, `pspFeeAmount` (money strings; psp nullable),
`platformFeeRate` (decimal), `sessionCount` (int), `scheduledDate`/`scheduledTimeStart`/`scheduledTimeEnd`,
`confirmedAt`/`completedAt`/`cancelledAt` (nullable), `cancelledBy`/`cancellationReason`/
`cancellationPolicyCode` (nullable), `cancellationRefundPercentage` (nullable decimal), `refundableAmountIrr`
(nullable money string), `disputeWindowEndsAt` (nullable), `createdAt`, `sessions[]`.
- **`BookingSessionSummary`** (embedded): `id`, `sessionIndex`, schedule, `status` (`BookingSessionStatus`),
`visitPayoutAmount` (money string), `payoutEligibleAt` (nullable), `evvStatus` (`VisitVerificationStatus`),
`checkInAt`/`checkOutAt` (nullable), `checkInAddressMatch` (nullable bool).
- **`BookingListItem`**: `id`, `status`, `counterpartyName`, `scheduledDate`, `sessionCount`, `amountIrr`
(gross for customer / payout for nurse), `disputeWindowEndsAt`, `createdAt`.
- **`BookingSessionListItem`**: `sessionId`, `bookingId`, `sessionIndex`, `patientName`, schedule, `status`,
`evvStatus`.
- **`CareInstructions`**: `bookingId` + `currentConditions`/`medications`/`allergies`/`specialInstructions`/
`emergencyContactName`/`emergencyContactPhone` (all nullable). **Encrypted at rest; gated read.**
- **`VisitVerification`**: `id`, `bookingSessionId`, `status`, `checkInAt`/`checkInLat`/`checkInLng`,
`checkOutAt`/`checkOutLat`/`checkOutLng`, `checkInAddressMatch`, `checkInDistanceMeters` (raw GPS gated).
- **`AdminEvvItem`**: `sessionId`, `bookingId`, `nurseId`, `sessionStatus`, `scheduledDate`,
`scheduledTimeStart`, `checkInAt`, `checkInAddressMatch`, `checkInDistanceMeters`.
- **`CancellationResult`**: `bookingId`, `sessionId` (nullable), `bookingStatus`, `policyCode`,
`refundPercentage`, `refundableAmountIrr` (money string).
- **`CancellationPolicy`**: `id`, `code`, `appliesTo`, `hoursBeforeStartMin`/`Max` (nullable), `refundPercentage`,
`feeAmountIrr` (money string), `feeRate` (nullable), `isActive`.
## Changelog
- b9 — initial contract (bookings + sessions + care instructions + EVV + cancellation; capture mocked via
`IPaymentCaptureSimulator`, real trigger arrives with b10).
-142
View File
@@ -1,142 +0,0 @@
# Contract — Service catalog & nurse pricing variants (backend phase b5)
> The admin catalog skeleton (categories → option groups → option values) and the nurse pricing layer
> (variants — the atomic bookable unit). Assumes
> [`../conventions/api-conventions.md`](../conventions/api-conventions.md) +
> [`../conventions/money-and-types.md`](../conventions/money-and-types.md). Machine schema:
> [`../openapi/swagger.v1.json`](../openapi/README.md) (refreshed for b5).
**Status:** live as of backend-phase-b5 · **Frontend consumer:** frontend-phase-f4-b5
> **Routing note.** Routes are **action-style** (`[controller]/[action]`, snake_cased) to match the
> codebase convention and the dynamic-permission key scheme — e.g. create-a-category is
> `POST api/v1/admin_catalog/create_category`, not `POST api/v1/admin/catalog/categories`. Mutations use
> **POST**; ids for edit/toggle come from the **route**, never the body. All responses use the standard
> `{ succeeded, statusCode, data }` envelope; `data` shapes are below. JSON bodies/fields are **camelCase**.
## Enums used
- `price_unit`: `per_hour` | `per_session` | `per_half_day` | `per_day` | `per_24h` — the unit a variant's
`price` is quoted in. `per_24h` (شبانه‌روزی / live-in) and `per_day` are first-class. Stable string codes;
the client maps them to i18n labels, never derives a label from the code.
## Key semantics (read first)
- **The bookable unit is the VARIANT, not the nurse.** A nurse with no active variant is not bookable.
Search (b7) and booking (b8) operate on a variant.
- **`price` is IRR Rials, integer, on the wire as a string of digits** (e.g. `"8000000"`). No floats, no
Toman. The engagement **total is `price` + `price_unit` + `session_count`** — never derive a total from
`price` alone.
- **A NULL-category option group is cross-category** — it applies to *every* category. The applicable
groups for a category = its own groups **plus** every cross-category group.
- **All required dimensions must be answered** on variant create (including required cross-category ones);
**one value per dimension**; a value must belong to its group and be active.
- **Duplicate identical listings are rejected** — same nurse + same category + identical answered
option-set → **409**.
- **`display_name` auto-generates** from the category + chosen value labels but is nurse-editable.
- **Deactivate, never delete.** Categories/groups/values/variants soft-deactivate; a deactivated variant is
unbookable and drops out of the public view.
- **Every catalog row carries `nameFa` (primary) + `nameEn`.** The client picks by locale.
## Public catalog browse — `CatalogController` (no auth)
### `GET api/v1/catalog/categories?page=&pageSize=`
- Active categories ordered by `sortOrder`, **paginated** (default `pageSize` 50, max 100). Cached. `data`:
`PagedResult<ServiceCategoryDto>`.
### `GET api/v1/catalog/option_groups?category_id={id}`
- A category's **applicable** option groups — its own active groups **plus** every cross-category (NULL)
active group — each with its active values, ordered by `sortOrder`. Cached. **Empty list is valid**
(no dimensions defined yet). `data`: `OptionGroupDto[]`.
## Admin catalog curation — `AdminCatalogController` (admin / dynamic-permission)
Every write **invalidates the catalog cache**. Both labels required (`nameFa`/`nameEn`). No hard delete.
| Route | Body | Result |
| --- | --- | --- |
| `POST admin_catalog/create_category` | `{ nameFa, nameEn, descriptionFa?, descriptionEn?, iconKey?, sortOrder }` | `ServiceCategoryDto` |
| `POST admin_catalog/update_category/{id}` | `{ nameFa, nameEn, descriptionFa?, descriptionEn?, iconKey?, sortOrder }` | `ServiceCategoryDto` |
| `POST admin_catalog/set_category_active/{id}` | `{ isActive }` | `true` |
| `POST admin_catalog/create_option_group` | `{ serviceCategoryId?, nameFa, nameEn, isRequired, sortOrder }` | `OptionGroupDto` |
| `POST admin_catalog/update_option_group/{id}` | `{ serviceCategoryId?, nameFa, nameEn, isRequired, sortOrder }` | `OptionGroupDto` |
| `POST admin_catalog/create_option_value` | `{ optionGroupId, nameFa, nameEn, sortOrder }` | `OptionValueDto` |
| `POST admin_catalog/update_option_value/{id}` | `{ nameFa, nameEn, sortOrder, isActive }` | `OptionValueDto` |
- **`serviceCategoryId = null` on a group = cross-category** (applies to every category).
- `update_option_value` intentionally does **not** re-parent a value to another group (it would change the
meaning of variants that already answered with it).
- **Failure cases:** `400` empty labels / unknown parent (`serviceCategoryId`/`optionGroupId`); `401`
unauthenticated; `403` non-admin; `404` unknown id on update/toggle.
## Nurse variants — `NurseVariantsController` (authenticated; nurse-owner-scoped in handler)
### `POST api/v1/nurse_variants/create`
- **Body:** `{ serviceCategoryId, options: [{ optionGroupId, optionValueId }], price, priceUnit, sessionCount?, displayName? }`
`price` is a string of digits; `options` answers the dimensions (one value per group). Omit
`displayName` to auto-generate it.
- **`data`:** `VariantDto` (`isActive: true`, `displayName` auto-generated from labels unless overridden).
- **Failure cases:** `400` invalid price (non-digits/≤0)/`priceUnit`/`sessionCount`; a **missing required
dimension** (names it); a value not belonging to its group; the same group answered twice; an unknown/
inapplicable group or value; missing/inactive category. `401` unauthenticated; `403` caller is not a
nurse (or has no nurse profile). **`409`** a duplicate identical listing (same category + option-set) —
never a `500`.
- **Tenancy/side effects:** the nurse is derived from the caller, never the body. (Deferred: this is the
trigger point for the b7 `nurse_search_index` fan-out.)
### `POST api/v1/nurse_variants/update/{id}`
- **Body:** `{ price, priceUnit, sessionCount?, displayName? }` — edits price/unit/session/display only. The
**option-set is immutable** here (change dimensions = create-new + deactivate-old). A blank `displayName`
leaves the current one unchanged. `data`: `VariantDto`. `404` if not owned/absent (existence not leaked).
### `POST api/v1/nurse_variants/set_active/{id}`
- **Body:** `{ isActive }`. Deactivate/reactivate — **never hard-delete**. `data`: `true`. `404` if not owned.
### `GET api/v1/nurse_variants/list?page=&pageSize=`
- The nurse's own offerings — **active and inactive**, active-first, paginated. `data`:
`PagedResult<VariantDto>`.
### `GET api/v1/nurse_variants/get/{id}`
- **Auth:** none required (owner/admin get the full view; any other caller gets the **public** projection).
- The owning nurse and an admin see the variant in any state; anyone else sees it only when **active**.
`data`: `VariantDto`. `404` when absent, or inactive to a non-owner.
## Shared shapes
- `ServiceCategoryDto`: `id` (long), `nameFa`, `nameEn`, `descriptionFa` (string?), `descriptionEn`
(string?), `iconKey` (string?), `sortOrder` (int), `isActive` (bool).
- `OptionValueDto`: `id`, `nameFa`, `nameEn`, `sortOrder`, `isActive`.
- `OptionGroupDto`: `id`, `serviceCategoryId` (long?, **null = cross-category**), `nameFa`, `nameEn`,
`isRequired` (bool), `sortOrder`, `isActive`, `values` (`OptionValueDto[]`).
- `VariantOptionDto`: `optionGroupId`, `groupNameFa`, `groupNameEn`, `optionValueId`, `valueNameFa`,
`valueNameEn`.
- `VariantDto`: `id`, `serviceCategoryId`, `categoryNameFa`, `categoryNameEn`, `price` (**string of IRR
digits**), `priceUnit` (enum), `sessionCount` (int?), `displayName`, `isActive` (bool), `options`
(`VariantOptionDto[]`).
- `PagedResult<T>`: `items` (`T[]`), `total` (int), `page` (int), `pageSize` (int).
## Seed (available on a fresh DB)
Five categories, ordered by `sortOrder`, `nameFa` + `nameEn`: Elderly Care (id 1, مراقبت از سالمند),
Post-Surgery Recovery (2, مراقبت پس از جراحی), Infant Care (3, مراقبت از نوزاد), Chronic Illness
Management (4, مدیریت بیماری مزمن), Companionship (5, همراهی و مراقبت روزمره). **Option groups/values are
not seeded** — an admin authors them per category (EAV; no migration needed).
## Example — build a variant
```
# 1) admin defines a dimension for Elderly Care
POST /api/v1/admin_catalog/create_option_group
{ "serviceCategoryId": 1, "nameFa": "نوع شیفت", "nameEn": "Shift type", "isRequired": true, "sortOrder": 1 }
-> data.id = 11
POST /api/v1/admin_catalog/create_option_value
{ "optionGroupId": 11, "nameFa": "شبانه‌روزی", "nameEn": "Live-in", "sortOrder": 1 } -> data.id = 101
# 2) nurse builds a priced variant
POST /api/v1/nurse_variants/create
{ "serviceCategoryId": 1, "options": [{ "optionGroupId": 11, "optionValueId": 101 }],
"price": "8000000", "priceUnit": "per_24h" }
-> 200 { id, isActive: true, price: "8000000", displayName: "مراقبت از سالمند · شبانه‌روزی", options: [...] }
# 3) repeating the exact same create -> 409 (duplicate identical listing)
# 4) omitting the required shift-type value -> 400 (missing required dimension)
```
## Changelog
- b5 — initial contract: public catalog browse (categories + applicable option groups), admin catalog CRUD
+ set-active, nurse variant create/update/set-active/list/get; `price_unit` enum; IRR-string money;
`409` duplicate listing / `400` missing required dimension.
-113
View File
@@ -1,113 +0,0 @@
# Contract — Config, Reference & Platform Signals (backend phase b1)
> Admin config/holiday/audit/support-alert endpoints + the current-user notification endpoints. Assumes
> [`../conventions/api-conventions.md`](../conventions/api-conventions.md) +
> [`../conventions/money-and-types.md`](../conventions/money-and-types.md). Machine schema (authoritative
> for exact field/param casing): [`../openapi/swagger.v1.json`](../openapi/README.md).
**Status:** live as of backend-phase-1 · **Frontend consumer:** frontend-phase-f14 (notification center) / frontend-phase-f15 (admin config/holidays/audit/alerts)
All responses are the standard `OperationResult``ApiResult` envelope (camelCase body, snake_case URLs).
Lists carry `{ items, total, page, pageSize }`. Pagination inputs are `page` (1-based) + `pageSize`
(default 50, max 100) — bound from the query string; derive exact casing from `swagger.v1.json`.
## Enums used
- **config `data_type`**: `decimal` | `int` | `bool` | `string` | `json` — how to parse a config `value`.
- **holiday `type`**: `official` | `religious` | `national`.
- **support-alert `type`**: `low_rating` | `evv_no_show` | `evv_location_mismatch` | `verification_expired` | `payment_anomaly` | `fraud_signal`.
- **support-alert `severity`**: `low` | `medium` | `high`.
- **support-alert `status`**: `open` | `assigned` | `resolved` (forward-only).
- **notification `type`**: open string code the front-end renders/deep-links on (e.g. `booking_confirmed`); its shape is the `data_json` contract (below), versioned per type.
---
## Admin — Platform config (`platform_config` controller, `[Authorize(DynamicPermission)]`)
### `GET api/v1/platform_config/get_platform_configs`
- **Purpose:** list config rows. **Auth:** admin (DynamicPermission). **Rate-limited:** no.
- **Query:** `page`, `pageSize`.
- **200 `data`:** `PagedResult<PlatformConfigDto>``{ items:[{ key, value, dataType, description }], total, page, pageSize }`.
### `POST api/v1/platform_config/update_platform_config`
- **Purpose:** update one existing config row; writes an `audit_logs` entry in the same transaction and evicts the cache. **Auth:** admin.
- **Body:** `{ "key": "platform_fee_rate", "value": "0.18" }`.
- **200 `data`:** `true` (empty-body success).
- **Failures:** `400` validation (empty key); `404` key does not exist. **Notes:** value is the raw string parsed per the row's `data_type`; changing a rate never retroactively re-prices already-computed rows.
### `GET api/v1/platform_config/get_config_change_history`
- **Purpose:** the audited change history for one key (from the append-only trail). **Auth:** admin.
- **Query:** `key` (required), `page`, `pageSize`.
- **200 `data`:** `PagedResult<ConfigChangeDto>``{ items:[{ id, action, changedFieldsJson, actorUserId, occurredAt }], … }`, newest first.
---
## Admin — Holidays (`holidays` controller, `[Authorize(DynamicPermission)]`)
### `GET api/v1/holidays/get_holidays`
- **Query:** `from` (date, optional), `to` (date, optional), `page`, `pageSize`.
- **200 `data`:** `PagedResult<HolidayDto>``{ items:[{ id, holidayDate, nameFa, type, isBankClosed }], … }`, by date.
### `POST api/v1/holidays/upsert_holiday`
- **Body:** `{ "holidayDate": "2026-03-21", "nameFa": "نوروز", "type": "national", "isBankClosed": true }`.
- **200 `data`:** `true`. **Failures:** `400` (bad `type`, empty `nameFa`, default date). **Notes:** upsert keyed on `holidayDate`.
### `POST api/v1/holidays/delete_holiday`
- **Body:** `{ "holidayDate": "2026-03-21" }`. **200:** `true`; **404** if no holiday on that date.
---
## Admin — Audit (`audit` controller, `[Authorize(DynamicPermission)]`)
### `GET api/v1/audit/get_audit_trail`
- **Purpose:** the immutable trail for one entity. **Auth:** admin.
- **Query:** `entity_type` (e.g. `PlatformConfig`), `entity_id` (string), `page`, `pageSize`.
- **200 `data`:** `PagedResult<AuditLogDto>``{ items:[{ id, entityType, entityId, action, changedFieldsJson, actorUserId, occurredAt }], … }`, newest first. **Notes:** read-only; there is no write/update/delete endpoint for audit rows.
---
## Admin — Support alerts (`support_alerts` controller, `[Authorize(DynamicPermission)]`, never user-facing)
### `GET api/v1/support_alerts/get_support_alerts`
- **Query:** `type?`, `status?`, `owner_user_id?`, `page`, `pageSize`.
- **200 `data`:** `PagedResult<SupportAlertDto>``{ items:[{ id, type, severity, status, entityType, entityId, bookingId, reviewId, ownerUserId, resolutionNote, resolvedAt, createdAt }], … }`.
### `POST api/v1/support_alerts/assign_support_alert`
- **Body:** `{ "alertId": 42, "ownerUserId": 7 }`. **200:** `true` (open → assigned); **404** if missing or already resolved.
### `POST api/v1/support_alerts/resolve_support_alert`
- **Body:** `{ "alertId": 42, "note": "handled" }`. **200:** `true` (→ resolved); **404** if missing or already resolved.
---
## Current user — Notifications (`notifications` controller, `[Authorize]`, tenant-scoped)
Every endpoint is scoped to the signed-in caller (`ICurrentUser`) — never a body-supplied user id.
### `GET api/v1/notifications/get_notifications`
- **Query:** `page`, `pageSize`.
- **200 `data`:** `PagedResult<NotificationDto>``{ items:[{ id, type, title, body, dataJson, isRead, readAt, createdAt }], … }`, **unread-first** then newest-first.
### `GET api/v1/notifications/get_unread_count`
- **200 `data`:** `{ count }` — cheap index-backed count for the polling bell.
### `POST api/v1/notifications/mark_notification_read`
- **Body:** `{ "notificationId": 100 }`. **200:** `true`; **404** if it isn't the caller's or doesn't exist.
### `POST api/v1/notifications/mark_all_read`
- **No body. 200:** `true`.
> **Not exposed via REST** (internal contracts other backend domains call): `CreateNotification` (via
> `INotificationDispatcher.DispatchAsync`), `RaiseSupportAlert` (`ISupportAlertService.RaiseAsync`),
> `EmitSystemEvent` (`IAnalyticsSink.EmitAsync`), `WriteAuditLog` (`IAuditLogger.WriteAsync`). The
> notification retention purge runs on a background hosted service, not an endpoint.
## Shared shapes
- **`PlatformConfigDto`**: `key` (string), `value` (string, raw — parse per `dataType`), `dataType` (enum), `description` (string, nullable).
- **`ConfigChangeDto`**: `id` (long), `action` (`created`/`updated`/`deleted`), `changedFieldsJson` (string, nullable — `{ "Field": { "old": …, "new": … } }`; encrypted/PII fields redacted as `"<redacted>"`), `actorUserId` (int, nullable), `occurredAt` (UTC ISO-8601).
- **`HolidayDto`**: `id` (long), `holidayDate` (date), `nameFa` (string), `type` (enum), `isBankClosed` (bool).
- **`AuditLogDto`**: `id` (long), `entityType` (string), `entityId` (string), `action`, `changedFieldsJson` (nullable), `actorUserId` (nullable), `occurredAt`.
- **`NotificationDto`**: `id` (long), `type` (string code), `title` (string), `body` (string, nullable), `dataJson` (string, nullable — a **typed, versioned deep-link payload**; shape depends on `type`, e.g. `{"booking_id": 1}`), `isRead` (bool), `readAt` (UTC, nullable), `createdAt` (UTC).
- **`SupportAlertDto`**: `id`, `type`, `severity`, `status`, `entityType` (string), `entityId` (string), `bookingId` (long, nullable), `reviewId` (long, nullable), `ownerUserId` (int, nullable), `resolutionNote` (string, nullable), `resolvedAt` (UTC, nullable), `createdAt` (UTC).
## Changelog
- b1 — initial contract (config, holidays, audit, support alerts, notifications).
@@ -1,135 +0,0 @@
# Contract — Geography, addresses & nurse service areas (backend phase b4)
> The province→city→district reference hierarchy (public cascading dropdowns + admin curation), a nurse's
> declared service areas, and a customer's saved (encrypted, geocoded) addresses. Assumes
> [`../conventions/api-conventions.md`](../conventions/api-conventions.md) +
> [`../conventions/money-and-types.md`](../conventions/money-and-types.md). Machine schema:
> [`../openapi/swagger.v1.json`](../openapi/README.md) (refreshed for b4).
**Status:** live as of backend-phase-b4 · **Frontend consumer:** frontend-phase-f3-b4
> **Routing note.** Routes are **action-style** (`[controller]/[action]`, snake_cased) to match the
> codebase convention and the dynamic-permission key scheme — e.g. create-a-city is
> `POST api/v1/admin_geo/create_city`, not `POST api/v1/admin_geo/cities`. Ids for edit/toggle/remove come
> from the **route**, never the body. All responses use the standard `{ succeeded, statusCode, data }`
> envelope; `data` shapes are below.
## Key semantics (read first)
- **`districtId = null` ⇒ whole city.** For a nurse service area it is a real coverage choice ("I cover
the entire city"), not missing data. Search (b7) treats a whole-city row as matching every district in
that city. A city with **no districts** (e.g. Mashhad) is whole-city-only and its district list is a
valid **empty** result.
- **Coverage is named districts, never a GPS radius.** Address coordinates exist only for the later EVV
distance check (b9), not for matching.
- **`is_active` hides, never deletes.** A deactivated province/city/district disappears from the public
dropdowns (parent-active is honoured on the join) without deleting the region or orphaning rows.
- **Exactly one primary address** per customer; the first address is primary by default.
- **Address PII is encrypted at rest** and decrypted only in the owner's own read.
## Public geo lookups — `GeoController` (no auth)
### `GET api/v1/geo/provinces`
- Active provinces, ordered by `sortOrder`. Cached. `data`: `ProvinceDto[]`.
### `GET api/v1/geo/cities?province_id={id}`
- Active cities under an (active) province, ordered. Empty if the province is inactive/absent. `data`: `CityDto[]`.
### `GET api/v1/geo/districts?city_id={id}`
- Active districts under an (active) city, ordered. **Empty list is valid** (whole-city-only city). `data`: `DistrictDto[]`.
### `GET api/v1/geo/tree`
- The full active province→city→district tree in one cached payload. `data`: `ProvinceTreeDto[]`.
## Admin geo curation — `AdminGeoController` (admin / dynamic-permission)
Every write **invalidates the geo cache**. Names required (`nameFa`/`nameEn`); `409` is not used here.
| Route | Body | Result |
| --- | --- | --- |
| `POST admin_geo/create_province` | `{ nameFa, nameEn, sortOrder }` | `ProvinceDto` |
| `POST admin_geo/update_province/{id}` | `{ nameFa, nameEn, sortOrder }` | `ProvinceDto` |
| `POST admin_geo/set_province_active/{id}` | `{ isActive }` | `true` |
| `POST admin_geo/create_city` | `{ provinceId, nameFa, nameEn, sortOrder }` | `CityDto` |
| `POST admin_geo/update_city/{id}` | `{ nameFa, nameEn, sortOrder }` | `CityDto` |
| `POST admin_geo/set_city_active/{id}` | `{ isActive }` | `true` |
| `POST admin_geo/create_district` | `{ cityId, nameFa, nameEn, sortOrder }` | `DistrictDto` |
| `POST admin_geo/update_district/{id}` | `{ nameFa, nameEn, sortOrder }` | `DistrictDto` |
| `POST admin_geo/set_district_active/{id}` | `{ isActive }` | `true` |
- **Failure cases:** `400` invalid names / unknown parent (`provinceId`/`cityId`); `401` unauthenticated;
`403` non-admin; `404` unknown id on update/toggle.
## Nurse service areas — `NurseServiceAreasController` (authenticated; nurse-scoped in handler)
### `POST api/v1/nurse_service_areas/add`
- **Body:** `{ cityId, districtId? }` — omit/`null` `districtId` = whole city.
- **`data`:** `NurseServiceAreaDto`.
- **Failure cases:** `400` invalid/inactive city, or district not in the (active) city; `401`
unauthenticated; `403` caller is not a nurse; **`409`** the nurse already declared this exact coverage
(including a duplicate **whole-city** row) — never a `500`.
- **Tenancy/side effects:** `nurseId` from the caller, never the body. (Deferred: this is the trigger
point for the b7 `nurse_search_index` fan-out.)
### `DELETE api/v1/nurse_service_areas/remove/{id}`
- Soft-removes the nurse's own area. `data`: `true`. `404` if not owned/absent (existence not leaked).
### `GET api/v1/nurse_service_areas/list?page=&pageSize=`
- The nurse's own areas, whole-city first, paginated. `data`: `PagedResult<NurseServiceAreaDto>`.
## Customer addresses — `CustomerAddressesController` (authenticated; customer-scoped in handler)
### `POST api/v1/customer_addresses/create`
- **Body:** `{ title, cityId, districtId?, addressLine, postalCode?, recipientName?, recipientPhone?, isPrimary? }`.
- **`data`:** `CustomerAddressDto` (with `latitude`/`longitude` set from the geocoder, or `null` if
unresolved).
- **Behaviour:** encrypts the PII columns; geocodes via `IGeocoder`; the first address (or `isPrimary:true`)
becomes the single primary (prior primary cleared in the same unit of work). A thin customer profile is
auto-provisioned on first address if needed.
- **Failure cases:** `400` empty `title`/`addressLine`, invalid/inactive city, district not in the city,
bad postal-code format; `401`; `403` caller is not a customer.
### `POST api/v1/customer_addresses/update/{id}`
- Edits an owned address; re-geocodes when `addressLine`/`cityId`/`districtId` changes; re-encrypts PII.
`data`: `CustomerAddressDto`. `404` if not owned.
### `POST api/v1/customer_addresses/set_primary/{id}`
- Atomically makes the owned address primary and clears the previous. `data`: `true`. `404` if not owned.
### `DELETE api/v1/customer_addresses/delete/{id}`
- Soft-deletes the owned address. `data`: `true`. `404` if not owned.
### `GET api/v1/customer_addresses/list?page=&pageSize=`
- The customer's own addresses, **primary first**, paginated, with PII **decrypted for the owner**. `data`:
`PagedResult<CustomerAddressDto>`.
## Shared shapes
- `ProvinceDto`: `id` (long), `nameFa` (string), `nameEn` (string), `sortOrder` (int).
- `CityDto`: `id`, `provinceId`, `nameFa`, `nameEn`, `sortOrder`.
- `DistrictDto`: `id`, `cityId`, `nameFa`, `nameEn`, `sortOrder`.
- `CityTreeDto`: `id`, `nameFa`, `nameEn`, `sortOrder`, `districts` (`DistrictDto[]`).
- `ProvinceTreeDto`: `id`, `nameFa`, `nameEn`, `sortOrder`, `cities` (`CityTreeDto[]`).
- `NurseServiceAreaDto`: `id`, `cityId`, `cityNameFa`, `cityNameEn`, `districtId` (long?, null = whole
city), `districtNameFa` (null when whole city), `districtNameEn` (null when whole city), `isWholeCity`
(bool), `isActive` (bool).
- `CustomerAddressDto`: `id`, `title`, `cityId`, `cityNameFa`, `cityNameEn`, `districtId` (long?),
`districtNameFa` (null?), `districtNameEn` (null?), `addressLine` (decrypted, owner-only), `postalCode`
(decrypted, owner-only, null?), `latitude` (decimal?, null when ungeocoded), `longitude` (decimal?),
`isPrimary` (bool), `recipientName` (decrypted, null?), `recipientPhone` (decrypted, null?).
## Seed (available on a fresh DB)
31 provinces (Tehran first, `sortOrder` deterministic), each province's capital city (covers Tehran,
Karaj, Mashhad, Isfahan, Shiraz, Tabriz, Ahvaz, Qom), and Tehran's 22 مناطق. Tehran province id `1`,
Tehran city id `101`, Tehran districts `1001…1022`; other cities have no districts at seed time.
## Changelog
- b4 — initial contract: public geo lookups, admin geo CRUD + set_active, nurse service areas, customer
addresses; `IGeocoder` seam; `409` conflict added to the envelope.
---
## Refinement phase 3 additions (REQ-008/009)
- **`CustomerAddressDto`** gains `provinceId` (joined from `cities.province_id`) so the edit form can
prefill the province → city cascade from a server-loaded address.
- **`customer_addresses/create` + `update/{id}`** now accept optional `latitude`/`longitude` (both or
neither). When present, the user's dropped pin is stored (`geocode_source = user_pin`, preferred for the
EVV distance check); when absent the server geocodes as before (`geocode_source = geocoder`).
-141
View File
@@ -1,141 +0,0 @@
# Contract — Identity & Auth (backend phase b2)
> One-line: phone-OTP login, revocable refresh-token sessions with rotation + reuse detection, the
> current-user profile (`/me`) and public role selection. Assumes
> [`../conventions/api-conventions.md`](../conventions/api-conventions.md) +
> [`../conventions/money-and-types.md`](../conventions/money-and-types.md). Source of truth for the
> machine schema: [`../openapi/`](../openapi/README.md) (`swagger.v1.json`, refreshed for b2).
**Status:** live as of backend-phase-2 · **Frontend consumer:** frontend-phase-f1-b2
> **Exact paths** (snake_case transformer output — differs from early sketches that showed
> `otp/request` / `me/role`): `auth/request_otp`, `auth/verify_otp`, `auth/refresh`, `auth/logout`,
> `me`, `me/select_role`. JSON bodies are **camelCase** (confirmed against the live envelope).
## Enums used
- `role` (self-selectable): `customer` | `nurse` — a user may hold **both**. Admin sub-roles
(`admin`, `support`, `finance`, `moderation`, `super_admin`) exist but are **internal-only**;
sending one to `me/select_role` returns `403`.
- `gender`: `male` | `female` — load-bearing for same-gender matching; **null until the profile flow
(b3) sets it**; never defaulted.
- `nurseVerificationStatus`: `not_started` until the b6 verification pipeline exists.
## Endpoints
### `POST api/v1/auth/request_otp`
- **Purpose:** send a one-time login code to an Iranian mobile; silently creates an
inactive-until-verified account for a new phone.
- **Auth:** none · **Rate-limited:** yes (`otp` per-IP policy → `429`) · **Idempotency key:** no
- **Request body:**
```json
{ "phone": "09121112233" }
```
Accepts `+98…`/`0098…`/Persian digits; normalized server-side to `09xxxxxxxxx`.
- **Success `200` payload (`data`):**
```json
{ "otpSent": true, "resendAvailableInSeconds": 120 }
```
Inside the per-phone resend window the same shape returns with `otpSent: false` and the remaining
seconds. **The shape never reveals whether the phone already had an account** (no enumeration).
- **Failure cases:** `400` invalid phone; `429` over the per-IP OTP limit.
- **Notes:** in the mock environment the code is written to the server log (`ISmsSender` mock); the
OTP itself expires on the TOTP provider's window (~3 min).
### `POST api/v1/auth/verify_otp`
- **Purpose:** verify the code, activate the account, mint the token pair + a revocable session.
- **Auth:** none · **Rate-limited:** yes (`otp` policy) · **Idempotency key:** no
- **Request body:**
```json
{ "phone": "09121112233", "code": "466036", "deviceInfo": "iPhone 15 / app 1.0 (optional)" }
```
- **Success `200` payload (`data`):** `AuthTokensResult` (below). `isNewUser: true` on the first
successful verify; `roles` is empty for a fresh user — **route them to role selection**.
- **Failure cases:** `400` wrong/expired code (same safe message whether the phone exists or the
code is wrong — no enumeration); `400` "too many failed attempts" after `auth_otp_max_attempts`
wrong codes (request a new OTP to reset); `429` over limit.
### `POST api/v1/auth/refresh`
- **Purpose:** rotate the refresh token: the presented session is revoked, a new pair is issued.
- **Auth:** none required (`RequireTokenWithoutAuthorization` — the refresh token is the
credential) · **Rate-limited:** yes (`auth` policy) · **Idempotency key:** no
- **Request body:**
```json
{ "refreshToken": "<64-hex refresh token>", "deviceInfo": "optional" }
```
- **Success `200` payload (`data`):** `AuthTokensResult` (`isNewUser` always `false`).
- **Failure cases:** `401` unknown token; `401` expired session; **`401` reuse-detection** — a token
that hashes to an *already-revoked* session is treated as stolen: **all** of that user's sessions
are revoked (logout-everywhere) and the client must sign in again.
### `POST api/v1/auth/logout`
- **Purpose:** revoke the session server-side and kill outstanding access tokens.
- **Auth:** authenticated (Bearer) · **Rate-limited:** no
- **Request body:** (send `{}` at minimum)
```json
{ "refreshToken": "optional — revoke just this session", "everywhere": false }
```
With `everywhere: true` **or no `refreshToken`**, every active session is revoked.
- **Success `200`:** empty envelope (no `data`).
- **Failure cases:** `401` unauthenticated.
- **Notes:** the security stamp rotates on every logout, so **all** of the user's outstanding access
tokens fail immediately (other devices recover by refreshing their still-valid refresh tokens).
### `GET api/v1/me`
- **Purpose:** the signed-in user's identity, roles and onboarding state (drives the role router).
- **Auth:** authenticated · **Rate-limited:** no
- **Success `200` payload (`data`):** `MeResult` (below).
- **Failure cases:** `401` missing/expired/stamp-invalidated token.
### `POST api/v1/me/select_role`
- **Purpose:** self-assign a public actor role. Idempotent; `customer` and `nurse` can coexist.
- **Auth:** authenticated · **Rate-limited:** no
- **Request body:**
```json
{ "role": "customer" }
```
- **Success `200` payload (`data`):** the updated `MeResult`.
- **Failure cases:** **`403` for any non-public role** (`super_admin`, `support`, …); `400` empty
role; `401` unauthenticated.
- **Notes:** role claims live inside the (JWE) access token — after selecting a role, **refresh the
token pair** so subsequent role-gated calls carry the new claim. `/me` reads roles from the DB and
reflects the change immediately.
## Shared shapes
- `AuthTokensResult`:
| field | type | notes |
|---|---|---|
| `accessToken` | string | JWE bearer token (send as `Authorization: Bearer …`) |
| `refreshToken` | string | 64-hex opaque token; **store securely**, only its hash exists server-side |
| `accessExpiresAt` | ISO-8601 | absolute access-token expiry |
| `refreshExpiresAt` | ISO-8601 | session expiry (`auth_session_ttl_days`, default 30d) |
| `isNewUser` | bool | `true` only on the first successful verify for the phone |
| `roles` | string[] | active roles; empty ⇒ send the user to role selection |
- `MeResult`:
| field | type | notes |
|---|---|---|
| `id` | int | user id |
| `phone` | string | **always masked** (`0912*****33`) — full phone is never returned |
| `firstName` / `lastName` | string \| null | null until profile (b3) |
| `gender` | `male`/`female` \| null | null until profile (b3); never defaulted |
| `isActive` | bool | phone-verified account |
| `roles` | string[] | active roles (revoked grants excluded) |
| `hasCustomerProfile` / `hasNurseProfile` | bool | `false` until b3 populates the profile tables |
| `nurseVerificationStatus` | string | `not_started` until b6 |
- `RequestOtpResult`: `otpSent` (bool) + `resendAvailableInSeconds` (int).
## Changelog
- b2 — initial contract (phone-OTP auth, sessions, `/me`, role selection).
---
## Refinement phase 3 additions (REQ-002/003)
- **`RequestOtpResult`** gains `codeLength` (6) and `expiresInSeconds` (60) so the OTP box count + expiry
hint are contract-driven.
- **`verify_otp` failures** now carry a stable machine `code` on the envelope: `otp_invalid` (wrong **or**
expired — collapsed for anti-enumeration) and `otp_locked` with `data: { retryAfterSeconds }` on lockout.
The coded-error envelope is `{ isSuccess: false, statusCode: 400, message, code, data? }` (the optional
`code` is omitted from every other response).
-100
View File
@@ -1,100 +0,0 @@
# Contract — Identity profiles, patients & nurse bank accounts (backend phase b3)
> Role-attached identity data on top of the b2 auth spine: the nurse seller profile, the customer payer
> profile, the customer's patients, and the nurse's payout bank accounts. Assumes
> [`../conventions/api-conventions.md`](../conventions/api-conventions.md) +
> [`../conventions/money-and-types.md`](../conventions/money-and-types.md). Machine schema:
> [`../openapi/swagger.v1.json`](../openapi/README.md).
**Status:** live as of backend-phase-b3 · **Frontend consumer:** frontend-phase-f2-b3
All endpoints require a **Bearer access token** (`[Authorize]`); unauthenticated calls return `401`.
Role scoping is enforced in the handler and returns `403` when the caller lacks the required role — and
**role claims are baked into the access token at mint time**, so a client must refresh (or re-login)
after `me/select_role` before these endpoints see the new role. Request bodies are camelCase JSON; URL
segments are snake_case; responses use the standard `OperationResult``ApiResult` envelope (payload in
`data`).
## Enums used
- `gender`: `male` | `female` — load-bearing for same-gender caregiver matching; required on a patient.
- `blood_type`: free-form short string (e.g. `O+`, `AB-`), nullable — not a fixed enum at MVP.
## Shared shapes
- `NurseProfileDto`: `id` (int64), `bio` (string), `yearsOfExperience` (int), `educationLevel` (string),
`educationField` (string), `specializationsJson` (string — raw JSON array), `isVerified` (bool,
**read-only** — always false until b6 verification), `isAcceptingBookings` (bool),
`averageRating` (decimal), `totalReviews` (int), `totalCompletedBookings` (int) — the last three are
**read-only aggregates**, 0 until reviews/bookings phases.
- `CustomerProfileDto`: `id` (int64), `defaultEmergencyContactName` (string), `defaultEmergencyContactPhone`
(string) — decrypted and returned **in full** to the owning customer (self).
- `PatientDto`: `id` (int64), `displayName`, `firstName`, `lastName` (strings), `birthDate` (date
`YYYY-MM-DD`), `gender` (`male`/`female`), `bloodType` (string, nullable), `initialMedicalNotes`
(string — decrypted, owner-only), `isActive` (bool).
- `NurseBankAccountDto`: `id` (int64), `bankName` (string), `ibanMasked` (string — **last 4 only**, e.g.
`••••3456`; the full IBAN is never returned), `isPrimary` (bool), `isVerified` (bool),
`matchedNationalId` (bool **nullable** — null until the ownership inquiry runs).
## Endpoints
### Nurse profile — role `nurse`
- `POST api/v1/nurse_profiles/upsert` — create/update own profile. Body: `{ bio, yearsOfExperience,
educationLevel, educationField, specializationsJson }`. Returns `NurseProfileDto`. **Never accepts
`isVerified` or the aggregates.** `400` if `yearsOfExperience` ∉ [0,80]; `403` non-nurse.
- `POST api/v1/nurse_profiles/set_accepting_bookings` — body `{ accepting: bool }`. Empty `200` on
success; `404` if no profile yet. Never touches `isVerified`.
- `GET api/v1/nurse_profiles/me` — returns `NurseProfileDto`; `404` if none.
### Customer profile — role `customer`
- `POST api/v1/customer_profiles/upsert` — body `{ defaultEmergencyContactName, defaultEmergencyContactPhone }`
(phone stored **encrypted**). Returns `CustomerProfileDto`. `400` invalid phone / empty name; `403` non-customer.
- `GET api/v1/customer_profiles/me` — returns `CustomerProfileDto`; `404` if none.
### Patients — role `customer` (tenancy-scoped to the caller)
- `POST api/v1/patients/create` — body `{ displayName, firstName, lastName, birthDate, gender, bloodType,
initialMedicalNotes }`. `customerId` is derived from the caller (a thin customer profile is
auto-provisioned on first patient) — **never taken from the body**. Returns `PatientDto`. `400` missing/invalid
`gender` or future `birthDate`.
- `GET api/v1/patients/list?page=&pageSize=` — paginated (`page` 1-based, `pageSize` ≤100, default 50).
Returns `PagedResult<PatientDto>` (`items`, `total`, `page`, `pageSize`) of the caller's **own** patients only.
- `GET api/v1/patients/get/{id}` — returns `PatientDto`; `404` if not owned (existence not leaked).
- `POST api/v1/patients/update/{id}` — body as create (id from the route). Returns `PatientDto`; `404` if not owned.
- `POST api/v1/patients/archive/{id}` — soft-archive (`isActive=false`, not a delete). Empty `200`; `404` if not owned.
### Nurse bank accounts — role `nurse` (tenancy-scoped)
- `POST api/v1/nurse_bank_accounts/add`**rate-limited**. Body `{ bankName, accountHolderName, iban }`
(IBAN `IR`+24 digits; stored encrypted). Runs the استعلام شبا ownership inquiry and returns
`NurseBankAccountDto` with `matchedNationalId` set. Becomes primary if it is the nurse's first account.
`400` invalid IBAN, **duplicate IBAN** (via `iban_hash` uniqueness — a clean failure, not a 500), or no nurse profile.
- `POST api/v1/nurse_bank_accounts/set_primary/{id}` — makes the account primary and clears the prior
primary atomically (the filtered single-primary index never trips). Empty `200`; `404` if not owned.
- `GET api/v1/nurse_bank_accounts/list` — returns `NurseBankAccountDto[]` with **masked** IBANs.
- `POST api/v1/nurse_bank_accounts/verify_ownership/{id}`**rate-limited**. Re-runs the ownership
inquiry (idempotent: same input → same vendor ref). Returns the updated `NurseBankAccountDto`; `404` if not owned.
## Side effects & rules the API enforces
- **Guarded `isVerified`** — there is no field or endpoint to set it; a nurse profile is created
unverified and stays so until the b6 verification-confirm transaction.
- **Tenancy** — a customer only ever sees/mutates their own patients; a nurse only their own bank
accounts. Cross-tenant access returns `404` (never leaks existence).
- **IBAN masking** — the full IBAN is never returned; lists/DTOs carry last-4 only. The full value is
encrypted at rest.
- **`matchedNationalId` gates the first payout (b13)** — set here by the (mocked)
`IBankAccountOwnershipVerifier`, not by admin eyeballing; `null` until the inquiry has run.
- **Deferred:** saved service addresses & nurse coverage areas (b4); customer national-ID KYC (not
collected, never gates browsing/booking).
## Changelog
- b3 — initial contract (nurse/customer profiles, patients, nurse bank accounts + ownership inquiry).
---
## Refinement phase 3 additions (REQ-005/006/007)
- **`PatientDto` + create/update** gain `relation` (`parent|spouse|child|self`, nullable) and `conditions`
(`string[]` of stable codes; empty, never null). Stored as a nullable code + a JSON array column.
- **`NurseProfileDto`** and **`CustomerProfileDto`** gain `avatarUrl` (nullable). `CustomerProfileDto` also
gains `preferredLanguage` (nullable); the customer `upsert` body now accepts `firstName`/`lastName`
(persisted on the base `users` row) and `preferredLanguage`.
- **Avatar upload (multipart):** `POST api/v1/nurse_profiles/avatar` and
`POST api/v1/customer_profiles/avatar``multipart/form-data` field `file` (JPEG/PNG/WebP, ≤ 5 MB),
stored via `IObjectStorage`, returns `{ url }` and persists it on the profile.
@@ -1,195 +0,0 @@
# Contract — Messaging (tickets), partner centers & admin backoffice (backend phase b15)
> One-line: the ticket system (the only sanctioned post-booking channel, admin-readable, with a hard
> `is_internal` boundary), the licensed **partner centers** (sponsor / merchant-of-record → invoice issuer +
> settlement target), and the consolidated admin backoffice (support-alert worklist + audit viewer + the
> verify/refund/payout/moderation surfaces built in prior phases). Assumes
> [`../conventions/api-conventions.md`](../conventions/api-conventions.md) +
> [`../conventions/money-and-types.md`](../conventions/money-and-types.md). Machine schema:
> [`../openapi/swagger.v1.json`](../openapi/README.md).
**Status:** live as of backend-phase-b15 · **Frontend consumers:** frontend-phase-14-b15 (messaging/notifications),
frontend-phase-15-b15 (admin + partner consoles)
Timestamps are UTC ISO-8601. IDs are numbers. Pagination is `page` / `pageSize` (default 50, max 100), response
`{ items, total, page, pageSize }`. All settlement money is IRR `BIGINT`; the `settlement_iban` is **never**
returned in plaintext — only a masked last-4 (`"••••0001"`).
---
## Critical rules the frontend must respect
- **`is_internal` is a hard boundary enforced at the query layer.** `GET /tickets/{id}` (the **user** view)
never contains an internal message; `GET /admin/tickets/{id}` (the **admin** view, staff only) contains them.
A non-staff caller cannot set `is_internal` on a message (→ `403`) and can never read one. Do not rely on the
UI to hide internal notes — the backend already strips them from the user payload.
- **No direct nurse↔customer channel.** All post-booking communication is ticket-mediated. Never surface a
phone number. The emergency flow (`POST /tickets/emergency`) records the *aftermath* of an out-of-platform
call; it exposes no contact.
- **Ticket ↔ booking/refund links are optional.** `bookingId` and `refundId` are both nullable — a pure support
ticket has neither.
- **`referenceCode` is stable + unique** (`"TKT-9F3K2A7Q"`), quoted to users; never mutated.
- **Merchant-of-record follows `partner_centers`.** `GET /internal/bookings/{bookingId}/center` returns
`issuingEntityType = partner_center` (+ the center id) only when the booking's nurse is sponsored by a
merchant-of-record center, else `platform`. Invoices + settlement follow this, not a hardcoded platform.
- **Admin endpoints are internal-only + RBAC-gated + audited.** Every admin state change writes an append-only
`audit_logs` row (never mutate prior rows). `support_alerts` are internal-only — never in a user response.
## Enums
- `ticket.status`: `open` | `closed`.
- `ticket.category`: `coordination` | `support` | `refund` | `emergency`.
- `ticket_participant.role_on_ticket`: `customer` | `nurse` | `admin` (display label, not an auth source).
- `support_alert.status`: `open` | `assigned` | `resolved` (forward-only).
- `support_alert.type`: `low_rating` | `evv_no_show` | `evv_location_mismatch` | `verification_expired` |
`shared_sim` | `payment_anomaly` | `fraud_signal` | `nurse_clawback` | `emergency`.
- `invoice.issuing_entity_type` (resolver output): `platform` | `partner_center`.
---
## Tickets — authenticated (participant-scoped)
| Verb & route | Maps to | Auth |
| --- | --- | --- |
| `POST /api/v1/tickets` | open a ticket | authenticated |
| `POST /api/v1/tickets/emergency` | log an emergency ticket (+ optional alert) | assigned nurse / staff |
| `POST /api/v1/tickets/{id}/messages` | post a message | participant (staff may set `isInternal`) |
| `POST /api/v1/tickets/{id}/participants` | add a participant | staff / ticket owner |
| `DELETE /api/v1/tickets/{id}/participants/{userId}` | soft-remove a participant | staff / ticket owner |
| `POST /api/v1/tickets/{id}/close` · `/reopen` | status transitions | participant / staff |
| `GET /api/v1/tickets` | my tickets (paginated) | authenticated (own) |
| `GET /api/v1/tickets/{id}` | thread — **user view, internal stripped** | participant / staff |
### `POST /api/v1/tickets`
Request:
```json
{ "category": "support", "subject": "Reschedule", "body": "Can we move to 5pm?", "bookingId": 42, "refundId": null }
```
`bookingId`/`refundId` optional. A booking link requires the caller to be a party to the booking (staff bypass);
a refund link is staff-only. Response `data`:
```json
{ "ticketId": 12, "referenceCode": "TKT-9F3K2A7Q", "status": "open", "category": "support" }
```
### `POST /api/v1/tickets/{id}/messages`
```json
{ "body": "internal note", "isInternal": true }
```
`isInternal` defaults `false`; a non-staff caller sending `true``403`; posting to a closed ticket as a
non-staff caller → `403`. Response `data`: `{ "messageId", "ticketId", "sentAt" }`.
### `POST /api/v1/tickets/emergency`
```json
{ "bookingId": 42, "body": "Called 115; patient stable.", "raiseAlert": true }
```
Only the assigned nurse (or staff). Response is the same shape as opening a ticket (`category: "emergency"`).
### `GET /api/v1/tickets/{id}` (user) / `GET /api/v1/admin/tickets/{id}` (admin)
Response `data` (admin view shown; the user view omits internal messages):
```json
{
"id": 12, "referenceCode": "TKT-9F3K2A7Q", "subject": "Reschedule",
"status": "open", "category": "support", "bookingId": 42, "refundId": null,
"openedById": 7, "closedAt": null,
"participants": [ { "userId": 7, "roleOnTicket": "customer" }, { "userId": 3, "roleOnTicket": "admin" } ],
"messages": [ { "id": 1, "senderId": 7, "body": "…", "isInternal": false, "sentAt": "2026-07-10T…Z" } ]
}
```
A duplicate `POST …/participants` returns **409** (backed by `UNIQUE(ticket_id, user_id)`), never a 500.
## Tickets — admin (`support`/`admin`)
| Verb & route | Maps to |
| --- | --- |
| `GET /api/v1/admin/tickets` | global queue (filter `status`/`category`, search `referenceCode`, `bookingId`/`refundId`) |
| `GET /api/v1/admin/tickets/{id}` | thread — **admin view, internal included** |
---
## Partner centers — admin (`admin`/`super_admin`)
| Verb & route | Maps to |
| --- | --- |
| `POST /api/v1/admin/partner-centers` | create (inactive until verified) |
| `PATCH /api/v1/admin/partner-centers/{id}` | update (replace semantics) |
| `POST /api/v1/admin/partner-centers/{id}/verify` | record licensing approval + activate |
| `POST /api/v1/admin/partner-centers/{id}/sponsor-nurse` | set/clear `nurse_profiles.partner_center_id` |
| `GET /api/v1/admin/partner-centers` | list (no IBAN, sponsored-nurse counts) |
| `GET /api/v1/admin/partner-centers/{id}` | detail (**IBAN masked**) |
### `POST /api/v1/admin/partner-centers`
```json
{
"name": "Asanism Center", "legalEntityType": "llc", "mohEstablishmentPermitNo": "MOH-12345",
"technicalDirectorNurseUserId": null, "technicalDirectorLicenseNo": null, "enamadCode": "EN-999",
"settlementIban": "IR062960000000100324200001", "isMerchantOfRecord": true,
"commissionRate": 0.05, "adminUserId": 8
}
```
Validation: `commissionRate ∈ [0, 1)`; `settlementIban` required when `isMerchantOfRecord=true`;
`mohEstablishmentPermitNo` non-empty. Response `data` (detail):
```json
{
"id": 1, "name": "Asanism Center", "legalEntityType": "llc", "mohEstablishmentPermitNo": "MOH-12345",
"technicalDirectorNurseUserId": null, "technicalDirectorLicenseNo": null, "enamadCode": "EN-999",
"settlementIbanMasked": "••••0001", "isMerchantOfRecord": true, "commissionRate": 0.05,
"adminUserId": 8, "isActive": false, "verifiedAt": null, "sponsoredNurseCount": 0, "createdAt": "…Z"
}
```
### `POST /api/v1/admin/partner-centers/{id}/sponsor-nurse`
```json
{ "nurseProfileId": 15, "unlink": false }
```
Staff, or the center's own `adminUserId`, may sponsor within that center. `unlink: true` clears the link.
## Partner center — portal + internal resolver
| Verb & route | Maps to | Auth |
| --- | --- | --- |
| `GET /api/v1/centers/{id}/dashboard` | sponsored nurses + booking/invoice counts + masked settlement | center `adminUserId` / staff |
| `GET /api/v1/internal/bookings/{bookingId}/center` | issuer/settlement resolution | internal / admin |
`GET /internal/bookings/{bookingId}/center` response `data`:
```json
{ "bookingId": 42, "issuingEntityType": "partner_center", "partnerCenterId": 1, "partnerCenterName": "Asanism Center", "isMerchantOfRecord": true }
```
For an unsponsored / non-merchant-of-record nurse: `{ "issuingEntityType": "platform", "partnerCenterId": null, … }`.
---
## Admin backoffice (surfaced, built in prior phases)
The support-alert worklist and audit viewer existed since b1; b15 confirms them as the backoffice surface (no
rebuild). All are `[Authorize(DynamicPermission)]` (admin role passes; other staff scopes via seeded claims).
| Verb & route | Maps to | Scope |
| --- | --- | --- |
| `GET /api/v1/support_alerts/get_support_alerts` | list (filter `type`/`status`/`ownerUserId`) | `support`/`admin` |
| `POST /api/v1/support_alerts/assign_support_alert` | set owner | `support`/`admin` |
| `POST /api/v1/support_alerts/resolve_support_alert` | resolve + note | `support`/`admin` |
| `GET /api/v1/audit/get_audit_trail` | append-only audit log (filter entity/actor/date) | `super_admin`/`admin` |
| Verification queue / refunds / payouts / moderation / config / holidays | their own phase routes | b6/b11/b13/b14/b1 |
`support_alerts` are internal-only and must never appear in a user-facing response or join.
---
## Refinement phase 3 additions (REQ-029/030/031/032/033/034/035/036/037)
**Delivered:**
- **REQ-029** `PlatformConfigDto` gains `updatedAt` + `updatedBy` (from the entity audit fields).
- **REQ-030** `GET audit/get_audit_trail` filters also by `actorId`, `action`, `from`, `to` (all optional; `entityType`/
`entityId` now optional too). **Query params bind camelCase** (`actorId`/`from`/`to`), not `actor_id`.
- **REQ-037** `tagCodes: string[]` on `ModerationQueueItemDto`. **REQ-033** `totalIrr` on `InvoiceDto`
(= platform commission + BNPL commission + VAT).
- **REQ-032** activate/suspend toggle `POST admin/partner-centers/{id}/set-active { isActive }`. **Route casing
pinned:** the admin partner-center routes are **kebab-case** (`admin/partner-centers`, `.../set-active`) — an
intentional b15 divergence from the `snake_case` convention; the frontend's kebab-case client is CORRECT.
**Deferred (admin-console polish, documented in the tracker):** REQ-031 (RBAC `admin_roles/list|grant|revoke`),
REQ-032 `centers/me` + split portal reads (need the user↔center admin association REQ-038 deferred), REQ-033
center-scoped invoice list, REQ-034 verification nurse-queue/signed-url/whole-approve, REQ-035 refund admin
preview+approve/reject (the customer preview REQ-020 IS delivered), REQ-036 payout admin preview/holidayShifted/
transfer-reference.
-13
View File
@@ -1,13 +0,0 @@
---
## Refinement phase 3 additions (REQ-028)
- **`TicketSummaryDto`** gains `lastMessageAt` (last non-internal activity) + `unreadCount` (the caller's unread
non-internal messages from others; 0 on the admin queue). Unread is computed against the participant's
`last_read_at`, **stamped when the participant fetches the user-facing thread**.
- **`GET /tickets`** gains a `bookingId` query filter (jump to a booking's coordination ticket).
- **`POST /tickets/{id}/messages`** accepts an optional `clientMessageId` — a retried send with the same key is
deduplicated (returns the original) and the key is echoed on `PostMessageResult`.
- **Message author = role label, not a name** (confirmed intentional, privacy): the DTO carries `senderId`; the
client derives the author label from the participant role. No raw identity/name is exposed.
-79
View File
@@ -1,79 +0,0 @@
# Contract — Payments core: ledger, transactions, webhooks & card capture (backend phase b10)
> One-line: the inbound money rail — start a card payment against an accepted request, a PSP webhook confirms
> it, the balanced card-capture ledger group posts, and the booking confirms. Assumes
> [`../conventions/api-conventions.md`](../conventions/api-conventions.md) +
> [`../conventions/money-and-types.md`](../conventions/money-and-types.md). Machine schema:
> [`../openapi/swagger.v1.json`](../openapi/README.md).
**Status:** live as of backend-phase-b10 · **Frontend consumer:** frontend-phase-f9-b10
All money is **IRR Rials, integer, on the wire as a string of digits** (`"23300000"`). The card-capture ledger
group is always **balanced** (Σ debit = Σ credit). **Internal `account_type`s are never exposed to the
customer** — the checkout UI shows gross + the commission/VAT breakdown only. Timestamps are UTC ISO-8601.
## Enums used
- `payment` status (`payment_transactions.status`): `pending` | `succeeded` | `failed`.
- `payment_gateways.type`: `standard` (card IPG) | `bnpl`.
- `payment_webhook_events.processing_status`: `received` | `processed` | `failed` | `ignored`.
- `account_type` (internal, never on the customer wire): `escrow_held` | `platform_revenue` | `nurse_payable`
| `refund_payable` | `bnpl_fee_expense` | `psp_fee_expense` | `nurse_clawback_receivable` | `bad_debt`.
b10 posts only the first three (card capture); the rest are reserved for b11/b12/b13.
## Endpoints
### `POST api/v1/bookings/{bookingRequestId}/payments`
- **Purpose:** start a card payment for an `accepted_awaiting_payment` booking request owned by the caller.
A `bookings` row exists only on capture (b9), so payment is initiated against the **request**; the amount
charged is the request's **frozen gross** (variant price × session count), never client-supplied.
- **Auth:** authenticated (the owning customer) · **Rate-limited:** yes (sensitive) · **Idempotency:** send an
**`Idempotency-Key`** header — a retried start reuses the same attempt/reference.
- **Request body:** none (the id is in the route; the key is a header).
- **Success `200` (`data`):** `{ "transactionId": 42, "redirectUrl": "https://…", "gatewayReferenceCode": "…" }`.
No ledger rows yet; the booking is not created yet.
- **Failure:** `400` bad id, `401` unauth, `404` request not found / not the caller's, `409` already paid /
not awaiting payment / payment window lapsed, `400` no active gateway configured.
### `POST api/v1/webhooks/payments/{provider}`
- **Purpose:** the inbound PSP/BNPL callback — verify-then-dedup-then-mutate.
- **Auth:** **none (signature-authenticated)**, anonymous to the auth pipeline · **Rate-limited:** yes (global
per-IP) · **Idempotent:** yes, at-least-once tolerant.
- **Request:** the raw provider callback body (stored verbatim in `payload_json`); signature material in headers.
- **Behaviour:** upserts `payment_webhook_events` **first** on `(provider, external_event_id)` and **no-ops on
a duplicate**; an **invalid signature** is stored `ignored` and mutates nothing; on a **new success event**
it re-verifies server-side (never trusts the callback alone), then captures — posts the balanced card-capture
group and creates/confirms the booking — all under a `lock(booking:{id}:payment)` with the DB uniques as the
authoritative backstop.
- **Success `200` (`data`):** `{ "processingStatus": "processed" | "ignored" | "failed", "duplicate": false }`
(`duplicate: true` on a replayed event).
### `GET api/v1/nurses/{nurseId}/payable_balance`
- **Purpose:** the IRR balance currently owed a nurse — the **signed sum** over `nurse_payable` ledger legs
(credit adds, debit subtracts), **derived, never a stored column**. This is what b13 payouts read.
- **Auth:** authenticated — the **nurse themself or an admin/finance role** (`403` otherwise).
- **Success `200` (`data`):** `{ "nurseId": 7, "balanceIrr": "19805000" }`.
## The card-capture ledger group (posted on webhook confirm)
One `transaction_group_id`, `amount_irr` positive with `direction` carrying the sign, Σdebit = Σcredit:
```
DEBIT escrow_held gross_price_irr (e.g. 23300000)
CREDIT platform_revenue balinyaar_commission_irr (e.g. 3495000)
CREDIT nurse_payable nurse_payout_amount (e.g. 19805000, nurse_id set)
```
## Load-bearing rules the client must honour
- **Money is IRR integer, on the wire as a digit-string.** Never coerce to a JS number for math.
- **A booking is created & confirmed on capture** (the webhook), not on initiate — after `initiate` the
redirect is shown; the booking appears once the PSP callback confirms.
- **The checkout shows gross + commission/VAT breakdown only** — never the internal `account_type`s.
- **Payment is idempotent end-to-end**: a retried `initiate` (same `Idempotency-Key`) reuses the attempt; a
replayed webhook is a no-op; a repeat `initiate` after capture is a `409`.
---
## Refinement phase 3 additions (REQ-018)
- **Invoice auto-issue on capture/settle:** the commission invoice is now issued automatically when a card
capture (`ConfirmPaymentAndPostLedger`) or a BNPL settle first creates the booking — idempotent per
booking — so a paying customer's `GET api/v1/invoices/{bookingId}` resolves right away (was admin-only).
-112
View File
@@ -1,112 +0,0 @@
# Contract — Payouts (backend phase b13)
> The weekly nurse-payout engine: an admin previews eligible earnings, opens a draft batch, submits it to the
> (mocked) PAYA/SATNA bank rail, retries/marks failed payouts, and reads batches; a nurse reads their own payout
> history. Assumes [`../conventions/api-conventions.md`](../conventions/api-conventions.md) +
> [`../conventions/money-and-types.md`](../conventions/money-and-types.md). Machine schema:
> [`../openapi/swagger.v1.json`](../openapi/swagger.v1.json).
**Status:** live as of backend-phase-b13 · **Frontend consumer:** frontend-phase-f12-b13
All money is IRR `BIGINT` and crosses the wire as a **digit string** (`"8500000"`). Dates are `yyyy-MM-dd`.
List query params are **camelCase** (`page`, `pageSize`, `status`, `periodStart`, `periodEnd`) — not snake_case.
The response envelope is the standard `{ data, … }`; the shapes below are the `data`.
## Enums used
- `PayoutBatchStatus`: `draft` | `processing` | `partially_failed` | `completed` | `failed` — the batch lifecycle.
A draft is materialized but unsubmitted; `partially_failed` has some paid + some failed (retryable).
- `PayoutStatus`: `pending` | `submitted` | `paid` | `failed` — the per-payout lifecycle (forward-only; `paid` is an
irreversible transfer with no outgoing edge; `failed` re-submits on retry).
## Endpoints
### `GET api/v1/admin_payouts/eligible`
- **Purpose:** Preview the payout-eligible, unpaid earnings for a window, grouped by nurse (the dry-run before a batch).
- **Auth:** admin (dynamic-permission policy) · **Rate-limited:** yes · **Idempotency key:** n/a (read).
- **Query params:** `periodStart` (date, required), `periodEnd` (date, required, ≤ today, ≥ periodStart), `page` (default 1), `pageSize` (default 20, max 100).
- **Success `200` (`data`):** `PagedResult<EligibleNurseEarningsDto>`.
- **Failure cases:** `400` periodStart > periodEnd or periodEnd in the future; `401` unauthenticated; `403` non-admin.
- **Notes:** Eligible = booking `status='completed'` AND `dispute_window_ends_at < now` AND no active refund AND not already paid. The `periodEnd` is holiday-shifted the same way a generate would shift it. A nurse without a verified primary IBAN is **flagged** (`hasVerifiedPrimaryIban=false`), not dropped. Pending clawbacks are netted into the preview.
### `POST api/v1/admin_payouts/batches`
- **Purpose:** Open a `draft` batch: select eligible bookings, materialize one payout per nurse (net of clawbacks), link each booking under the UNIQUE guard, snapshot the verified primary IBAN. **No money moves.**
- **Auth:** admin · **Rate-limited:** yes · **Idempotency:** the `booking_id` UNIQUE link makes a re-run over an overlapping window unable to re-select an already-paid booking.
- **Request body:** `{ "periodStart": "2026-06-01", "periodEnd": "2026-06-30" }`
- **Success `200` (`data`):** `GeneratePayoutBatchResult` — the draft batch, its materialized payouts, and the nurses skipped (with reasons).
- **Failure cases:** `400` invalid period; `401`/`403`; a plain failure when **no eligible bookings** in the window or **no eligible nurse has a verified primary IBAN**; `409` a concurrent run already claimed one of the bookings (the UNIQUE backstop).
- **Notes:** `period_end`/`processing_date` are shifted off bank-closed days via `IHolidayCalendar`. `total_amount = Σ net_amount_irr`, `payout_count = COUNT(payouts)`.
### `POST api/v1/admin_payouts/batches/{id}/process`
- **Purpose:** Submit a draft (or partially-failed) batch to the bank rail — the one irreversible money-out step.
- **Auth:** admin · **Rate-limited:** yes · **Idempotency key:** yes (`payout-batch:{id}`; a retried process never re-sends a paid payout or re-posts the ledger).
- **Path params:** `id` (long) — the batch id. **Body:** none.
- **Success `200` (`data`):** `ExecutePayoutBatchResult`.
- **Failure cases:** `401`/`403`; `404` batch not found; `409` the batch already `failed` (open a new one). A re-process of a `completed` batch is an idempotent `200`.
- **Notes:** Per accepted transfer it posts `DEBIT nurse_payable / CREDIT escrow_held` (paid net) and, for a netted clawback, `DEBIT nurse_payable / CREDIT nurse_clawback_receivable` + marks the `nurse_clawbacks` row `recovered`. Batch ends `completed` (all paid) or `partially_failed` (some failed). PAYA vs SATNA is chosen by `payout_satna_threshold_irr`.
### `POST api/v1/admin_payouts/{payoutId}/retry`
- **Purpose:** Re-submit a single `failed` payout (holiday-aware).
- **Auth:** admin · **Rate-limited:** yes · **Idempotency key:** yes (`payout:{id}:retry`).
- **Path params:** `payoutId` (long). **Body:** none.
- **Success `200` (`data`):** `true`.
- **Failure cases:** `400` a `processing_date` failure when banks are closed today, or a `channel` failure when the rail declines again; `401`/`403`; `404` payout not found; `409` the payout is not `failed`. An already-`paid` payout returns an idempotent `200`.
- **Notes:** On success it posts the ledger + nets clawbacks like the first process and re-settles the batch (`partially_failed → completed` when it was the last failure).
### `POST api/v1/admin_payouts/{payoutId}/mark_failed`
- **Purpose:** Record a reconciled bank rejection on a payout — no ledger movement (no money left).
- **Auth:** admin · **Rate-limited:** yes.
- **Path params:** `payoutId` (long). **Request body:** `{ "failureReason": "invalid_sheba" }`
- **Success `200` (`data`):** `true`.
- **Failure cases:** `400` empty reason; `401`/`403`; `404` not found; `409` the payout is `paid` (a confirmed transfer can't be failed). An already-`failed` payout is an idempotent `200`.
### `GET api/v1/admin_payouts/batches/{id}`
- **Purpose:** Batch header + its paginated payouts (status, net, masked IBAN, transfer reference) + the bookings each covers.
- **Auth:** admin · **Rate-limited:** yes.
- **Path params:** `id` (long). **Query:** `page` (default 1), `pageSize` (default 50, max 200).
- **Success `200` (`data`):** `PayoutBatchDetailDto`.
- **Failure cases:** `401`/`403`; `404` not found.
### `GET api/v1/admin_payouts/batches`
- **Purpose:** Admin reconciliation list of batches.
- **Auth:** admin · **Rate-limited:** yes.
- **Query:** `status` (optional `PayoutBatchStatus`), `page` (default 1), `pageSize` (default 20, max 100).
- **Success `200` (`data`):** `PagedResult<PayoutBatchDto>`.
### `GET api/v1/nurse_payouts/history`
- **Purpose:** The signed-in nurse's own payouts (tenancy-scoped) — status, net, masked IBAN + transfer reference, clawback applied, the batch window.
- **Auth:** authenticated (nurse) · **Rate-limited:** no.
- **Query:** `page` (default 1), `pageSize` (default 20, max 100).
- **Success `200` (`data`):** `PagedResult<NursePayoutHistoryDto>`.
- **Failure cases:** `401` unauthenticated. A caller who is not a nurse gets an empty page (never another nurse's data).
## Shared shapes
- `EligibleNurseEarningsDto`: `nurseId` (long), `nurseName` (string?), `bookingCount` (int), `grossEarningsIrr` (string), `clawbackAppliedIrr` (string), `netAmountIrr` (string), `hasVerifiedPrimaryIban` (bool).
- `PayoutBatchDto`: `id` (long), `periodStart`/`periodEnd`/`processingDate` (date), `totalAmount` (string), `payoutCount` (int), `status` (`PayoutBatchStatus`), `initiatedByAdminId` (int?, **null = system-initiated / scheduled batch** — refinement-phase-7), `processedAt` (datetime?), `failureNotes` (string?), `createdAt` (datetime).
- `PayoutDto`: `id` (long), `nurseId` (long), `nurseName` (string?), `maskedIban` (string, last-4 only), `grossEarningsIrr`/`clawbackAppliedIrr`/`netAmountIrr`/`amount` (string), `bookingCount` (int), `status` (`PayoutStatus`), `transferReference` (string?), `paidAt` (datetime?), `failureReason` (string?), `bookings` (`PayoutBookingLinkDto[]`).
- `PayoutBookingLinkDto`: `bookingId` (long), `sessionId` (long?), `payoutAmountIrr` (string).
- `PayoutBatchDetailDto`: `batch` (`PayoutBatchDto`), `payouts` (`PayoutDto[]`), `total` (int), `page` (int), `pageSize` (int).
- `SkippedNurseDto`: `nurseId` (long), `nurseName` (string?), `grossEarningsIrr` (string), `reason` (string, e.g. `no_verified_primary_iban`).
- `GeneratePayoutBatchResult`: `batch` (`PayoutBatchDto`), `payouts` (`PayoutDto[]`), `skipped` (`SkippedNurseDto[]`).
- `ExecutePayoutBatchResult`: `batchId` (long), `status` (`PayoutBatchStatus`), `paidCount` (int), `failedCount` (int), `totalPaid` (string).
- `NursePayoutHistoryDto`: `id` (long), `batchId` (long), `status` (`PayoutStatus`), `grossEarningsIrr`/`clawbackAppliedIrr`/`netAmountIrr` (string), `maskedIban` (string), `transferReference` (string?), `paidAt` (datetime?), `periodStart`/`periodEnd` (date).
## Side effects
- **Ledger:** process/retry post balanced groups out of `nurse_payable` (payout + clawback-recovery). Never a `payout_released` boolean — paid-ness derives from a link row + the ledger.
- **One payout per booking, forever** via the `nurse_payout_booking_links.booking_id` UNIQUE.
- **Bank rail** is mocked behind `IBankTransferProvider` (PAYA/SATNA) — no real transfer.
## Changelog
- b13 — initial contract.
---
## Refinement phase 3 additions (REQ-025 — nurse earnings)
- **`GET api/v1/nurse_payouts/earnings_balance`** → `{ pendingTotalIrr, eligibleTotalIrr, paidTotalIrr,
clawbackOutstandingIrr, netPayableBalanceIrr }`. `netPayableBalanceIrr` is the **ledger-derived, SIGNED**
nurse_payable balance (may be negative = "owed back"; never clamped); `paidTotalIrr` is lifetime, not in the net.
- **`GET api/v1/nurse_payouts/earnings?state=&page=&pageSize=`** → `PagedResult<NurseEarningsItem>`; `state`
(`pending|eligible|paid|clawback_applied`) is **derived server-side** from `bookings.status` +
`dispute_window_ends_at < now` + the payout link + any clawback. Filterable by `state`.
- **`GET api/v1/nurse_payouts/{id}`** → nurse-scoped payout detail (batch window + covered bookings).
- **`NursePayoutHistoryDto`** gains `failureReason`.
-172
View File
@@ -1,172 +0,0 @@
# Contract — Refunds, clawbacks & invoices (backend phase b11)
> One-line: the outbound money leg — an admin reverses a captured booking payment across both fee legs (posting
> the balanced ledger reversal, forking on whether the nurse was already paid), and issues the commission
> invoice with VAT. Customers can only **read** their refund status + invoice. Assumes
> [`../conventions/api-conventions.md`](../conventions/api-conventions.md) +
> [`../conventions/money-and-types.md`](../conventions/money-and-types.md). Machine schema:
> [`../openapi/swagger.v1.json`](../openapi/README.md).
**Status:** live as of backend-phase-b11 · **Frontend consumer:** frontend-phase-f10-b11
All money is **IRR Rials, integer, on the wire as a string of digits** (`"10000000"`). Refunds are **admin-only**
— there is no customer refund-initiation path. Internal `account_type`s are never exposed. Timestamps are UTC
ISO-8601; `expected_customer_refund_eta` is a **date** (`"2026-08-24"`).
## Enums used
- `refund_status` (`refunds.status`): `requested` | `approved` | `processing` | `succeeded` | `failed` | `rejected`.
A card refund goes `approved → succeeded` immediately; a BNPL/manual refund sits in `processing` until the
async customer cash-back reconciles. Forward-only.
- `refund_channel` (`refunds.refund_channel`): `psp_card` | `bnpl_revert` | `manual`. (The data-model's
`manual_bank` is stored/served as the canonical **`manual`**.)
- `clawback_status` (`nurse_clawbacks.status`): `pending` | `recovered` | `written_off`. This phase only ever
creates `pending` and supports `written_off`; `recovered` is set by b13 payout netting.
- `moadian_status` (`invoices.moadian_status`): `pending` | `submitted` | `registered` | `failed`. Mock leaves a
new invoice `pending`.
## Endpoints
### `POST api/v1/admin_refunds`
- **Purpose:** create (and immediately execute) a refund on a booking with a captured payment.
- **Auth:** admin (dynamic-permission) · **Rate-limited:** yes (sensitive) · **Idempotency key:** internal
(booking + transaction + cumulative amount) — a retried channel call never double-refunds.
- **Request body:**
```json
{
"bookingId": 42,
"ticketId": null,
"refundPercentage": 1.0,
"platformFeeRefundedIrr": null,
"nursePayoutRefundedIrr": null,
"reasonCategory": "customer_request",
"reasonNotes": "shortened visit",
"adminNotes": null,
"manualBankReference": null
}
```
Supply **either** `refundPercentage` (01 fraction, pro-rata across the booking's commission/payout legs) **or**
the explicit `platformFeeRefundedIrr` + `nursePayoutRefundedIrr` legs (both together). If neither is given the
booking's b9 cancellation snapshot percentage is used. `manualBankReference` forces the `manual` channel.
- **Success `200` payload (`data`):**
```json
{
"refundId": 7,
"bookingId": 42,
"status": "succeeded",
"refundChannel": "psp_card",
"amount": "10000000",
"platformFeeRefundedIrr": "1500000",
"nursePayoutRefundedIrr": "8500000",
"expectedCustomerRefundEta": null,
"clawbackId": null
}
```
For BNPL: `status: "processing"`, `refundChannel: "bnpl_revert"`, `expectedCustomerRefundEta: "2026-08-24"`.
Post-payout: `clawbackId` is set (a `pending` `nurse_clawbacks` row + a support alert were created).
- **Failure cases:** `400` invalid amount/legs or missing percentage · `401` unauth · `403` non-admin ·
`404` no captured payment for the booking · `409` **`Σ refunded > captured`** (over-refund) · `400` channel refused.
- **Notes:** whole money-path runs under `lock(booking:{id}:refund)`; the refund row is persisted (`approved`)
**before** the external channel executes (crash-window fix), then the balanced ledger reversal posts via b10's
helper; the `refund_payable ↔ escrow_held` clearing posts immediately for a succeeded card refund and is
**deferred to reconciliation for BNPL/manual** (settled later via `confirm_settlement`, below). `ticketId` is
optional — one is auto-opened when omitted (b15), so a refund is always ticket-anchored. Notifies the customer.
### `POST api/v1/admin_refunds/{id}/confirm_settlement`
- **Purpose:** reconciliation confirmed the customer cash-back for a `processing` BNPL/manual refund — transitions
it `processing → succeeded`, stamps the settled instant, and posts the deferred `refund_payable ↔ escrow_held`
clearing in the same commit. (Also reached automatically by the BNPL provider cash-back callback.)
- **Auth:** admin · **Rate-limited:** yes (sensitive) · **Idempotent:** a replay against an already-`succeeded`
refund is a no-op success (the clearing never posts twice).
- **Request body:** none (id in the route).
- **Success `200` (`data`):** `RefundSettlement` — `{ "refundId": 7, "bookingId": 42, "status": "succeeded",
"completedAt": "2026-08-12T10:00:00Z" }`.
- **Failure:** `404` refund not found · `409` refund not in `processing` (e.g. still `approved`, already `failed`).
### `POST api/v1/admin_refunds/{id}/mark_failed`
- **Purpose:** reconciliation reported the BNPL/manual customer cash-back did **not** land — transitions the
`processing` refund to `failed`. No ledger moves (the clearing was never posted for a processing refund).
- **Auth:** admin · **Rate-limited:** yes · **Idempotent:** a replay against an already-`failed` refund is a no-op.
- **Request body:** `{ "reason": "bank_rejected" }` (optional).
- **Success `200` (`data`):** `RefundSettlement` (as above, `status: "failed"`). **Failure:** `404` · `409` not `processing`.
### `GET api/v1/admin_refunds?booking_id=&status=&page=&pageSize=`
- **Purpose:** admin refund worklist — projected + paginated (`page` default 1, `pageSize` default 20 / max 100).
- **Auth:** admin · **Success `200` (`data`):** `PagedResult<RefundListItem>` (see shapes) — channel, decomposed
legs, status, `expectedCustomerRefundEta`, the policy snapshot.
### `POST api/v1/admin_clawbacks/{id}/write_off`
- **Purpose:** mark a `pending` nurse clawback uncollectable; posts the balancing `DEBIT bad_debt / CREDIT
nurse_clawback_receivable` correction and sets `resolved_at`.
- **Auth:** admin · **Rate-limited:** yes · **Request body:** `{ "reason": "uncollectable" }`
- **Success `200` (`data`):** `true`. **Failure:** `404` not found · `409` not pending.
### `POST api/v1/admin_invoices`
- **Purpose:** issue the booking's official commission invoice. Idempotent per booking (re-issue returns the same).
- **Auth:** admin · **Rate-limited:** yes · **Request body:** `{ "bookingId": 42 }`
- **Success `200` (`data`):** `Invoice` (see shapes) — sequential `invoiceNumber`, `vatIrr = round(commission ×
vat_rate)` on the **commission line only**, `moadianStatus: "pending"`, `moadianReferenceNumber: null`.
- **Failure:** `404` booking not found.
### `GET api/v1/refunds/{id}/status` *(customer-visible)*
- **Purpose:** the customer-facing status of **their own** refund.
- **Auth:** authenticated; **tenancy-scoped** to the booking's customer — another customer's refund is a clean `404`.
- **Success `200` (`data`):**
```json
{ "id": 7, "bookingId": 42, "status": "processing", "refundChannel": "bnpl_revert",
"amount": "10000000", "expectedCustomerRefundEta": "2026-08-24", "reference": "••••••ab12" }
```
The external reference is **masked** (last 4 only).
- **Failure:** `401` unauth · `404` not found / not the caller's.
### `GET api/v1/invoices/{booking_id}` *(customer/admin)*
- **Purpose:** the booking's invoice. **Auth:** authenticated — the owning customer or an admin (else `404`).
- **Success `200` (`data`):** `Invoice` (see shapes), with `pdfUrl` when a PDF is stored.
## Shared shapes
- `RefundListItem`: `id` (int), `bookingId` (int), `paymentTransactionId` (int), `amount` / `platformFeeRefundedIrr`
/ `nursePayoutRefundedIrr` (IRR digit-strings), `refundChannel` (enum), `status` (enum), `refundPercentage`
(decimal), `reasonCategory` (string?), `cancellationPolicyCode` (string?), `refundPercentageApplied` (decimal?),
`expectedCustomerRefundEta` (date?), `gatewayRefundReference` (string?), `externalRevertReference` (string?),
`processedAt` (datetime?), `createdAt` (datetime).
- `Invoice`: `id` (int), `bookingId` (int), `invoiceNumber` (string, unique/sequential), `issuingEntityType`
(`platform`|`partner_center`), `grossIrr` / `platformCommissionIrr` (IRR digit-strings), `bnplCommissionIrr`
(digit-string?), `vatRate` (decimal), `vatIrr` (IRR digit-string), `moadianReferenceNumber` (string?),
`moadianStatus` (enum?), `pdfUrl` (string?), `issuedAt` (datetime).
## Load-bearing rules the client must honour
- **Money is IRR integer, on the wire as a digit-string.** Never coerce to a JS number for math.
- **Refunds are admin-only.** The only customer-visible surface is `refunds/{id}/status` — there is no
self-service refund initiation.
- **A card refund is immediate** (`succeeded`, no ETA); **a BNPL refund is `processing`** with an
`expectedCustomerRefundEta` ~710 business days out — surface it as "on its way, ~N days".
- **VAT is on the platform commission only** — never the nurse payout.
- **External references are masked** in the customer status view.
## Changelog
- b11 — initial contract (create refund, list refunds, write-off clawback, issue invoice, refund status, get invoice).
- refinement-phase-6 — added `POST admin_refunds/{id}/confirm_settlement` + `.../mark_failed` (the BNPL/manual
`processing → succeeded/failed` settlement, `RefundSettlement` shape), so the deferred `refund_payable ↔
escrow_held` clearing is now reachable. Refunds are persisted before the channel call (crash-window fix). The
`refund_ticket_required` gate was retired (a refund ticket is always auto-opened). Forward-dep FKs added on
`refunds.ticket_id`, `nurse_clawbacks.original_payout_id`/`recovered_in_payout_id`, `invoices.partner_center_id`.
---
## Refinement phase 3 additions (REQ-019/020/021 — customer refunds)
- **`POST api/v1/bookings/{id}/cancel`** (customer) — cancels the booking (freezing the policy snapshot) AND
opens its refund in one call → `RefundStatusDto`. Body `{ reasonCategory, reasonNotes?, sessionIds? }`
(MVP cancels all un-started sessions; `sessionIds` is accepted for forward-compat).
- **`GET api/v1/bookings/{id}/cancellation_policy`** (customer) — pre-cancel disclosure: resolves the
applicable policy by **current** lead time + per-session refundability →
`{ bookingId, cancellable, cancellationPolicyCode, refundPercentageApplied, feePercentage, refundAmountIrr,
feeAmountIrr, refundableAmountIrr, platformFeeRefundedIrr, nursePayoutRefundedIrr, appliesTo, leadTimeLabel,
refundChannel, expectedCustomerRefundEta (null in preview), sessions: [{ bookingSessionId, sessionIndex,
scheduledDate, refundable, reasonCode }] }`. `refundAmountIrr + feeAmountIrr = refundableAmountIrr`.
- **`GET api/v1/refunds/by_booking/{bookingId}`** (customer) — the booking's latest refund status (404 if none).
- **`RefundStatusDto`** gains `platformFeeRefundedIrr`, `nursePayoutRefundedIrr`, `refundPercentageApplied`,
`cancellationPolicyCode`, `createdAt`, `completedAt` (the fee-leg transparency split).
- **Canonical `cancellation_policy_code` set** (seeded, stable — the frontend's `free_24h`/`partial_under_24h`/
`customer_no_show` were invented): **`standard_24h`** (customer ≥24h → full refund), **`standard_inside_24h`**
(customer <24h → partial), **`nurse_no_show`** (nurse-initiated → full refund + penalty), **`admin_cancellation`**
(admin → full refund). Per-session `reasonCode`: **`un_started`** when refundable, else the blocking session status.
-137
View File
@@ -1,137 +0,0 @@
# Contract — Reviews & Patient Care Records (backend phase b14)
> The trust-and-continuity surface: a customer leaves **one moderated review per completed booking**; an
> admin/moderator transitions it (recomputing the nurse's public rating from source on every transition); the
> public reads only ever see published reviews + the aggregate; and nurses author **encrypted, patient-scoped**
> clinical notes readable only under a strict clinical-access rule. Assumes
> [`../conventions/api-conventions.md`](../conventions/api-conventions.md) +
> [`../conventions/money-and-types.md`](../conventions/money-and-types.md). Machine schema:
> [`../openapi/swagger.v1.json`](../openapi/swagger.v1.json).
**Status:** live as of backend-phase-b14 · **Frontend consumer:** frontend-phase-f13-b14
There is **no money** in this domain. Ratings are integers 15; the aggregate `averageRating` is a decimal
(2-dp, e.g. `4.5`). Timestamps are ISO-8601. List query params are **camelCase** (`page`, `pageSize`,
`status`). The response envelope is the standard `{ data, … }`; the shapes below are the `data`.
## Enums used
- `moderationStatus`: `pending_moderation` | `published` | `hidden` | `rejected`. A review is born
`pending_moderation` and is **never public / never counted** until `published`. Only `published` reviews are
returned by any public read and only `published` reviews feed the aggregate.
- Moderation **action** (the `PATCH` body): `publish` | `hide` | `reject` | `unpublish`. `hide`/`reject`
require a `reason`; `unpublish` returns a published review to `pending_moderation`.
- Review **tag codes** (seeded vocabulary): `punctual` | `professional` | `clean` | `kind` | `communicative`.
## Reviews
### `POST api/v1/bookings/{bookingId}/review`
- **Purpose:** The customer submits the one review for a completed booking.
- **Auth:** authenticated customer who **owns** the booking (tenancy enforced in the handler).
- **Path params:** `bookingId` (long).
- **Request body:** `{ "rating": 5, "body": "great care", "tagCodes": ["punctual","kind"] }``rating` 15
required; `body` optional (≤ 2000); `tagCodes` optional (validated against the active vocabulary).
- **Success `200` (`data`):** `SubmitReviewResult``{ id, moderationStatus, lowRatingAlertRaised }`. The status
is `pending_moderation` by default (the AI pre-screen keeps clean text pending; a banned-word hit auto-hides).
- **Failure cases:** `400` rating out of 15 / unknown tag code; `401` unauthenticated; `403` caller is not a
customer; `404` booking not found **or not owned** (a cross-tenant booking is a not-found, never a leak); a
plain failure when the booking is **not completed/closed**; `409` the booking is **already reviewed** (1:1).
- **Notes:** A rating **`min_rating_for_support_alert`** (config, default 2) raises an internal `low_rating`
`support_alert` (never surfaced on any user response). The new review does **not** appear in the public list
until published.
### `POST api/v1/reviews/{reviewId}/tags`
- **Purpose:** Replace a review's standardized tags with exactly the requested set.
- **Auth:** the review's **author** or a moderator (admin/super_admin/moderation) — enforced in the handler.
- **Path params:** `reviewId` (long). **Request body:** `{ "tagCodes": ["punctual","professional"] }`.
- **Success `200` (`data`):** `ReviewTagsResult``{ reviewId, tagCodes }`.
- **Failure cases:** `400` unknown tag code; `401`; `403` not the author and not a moderator; `404` review not
found. The `UNIQUE(reviewId, tagCode)` forbids a duplicate tag (the set is de-duplicated server-side).
### `PATCH api/v1/reviews/{reviewId}/status`
- **Purpose:** Admin/moderator moderation transition (the human decision authority; always overrides the AI).
- **Auth:** admin / moderator (dynamic-permission policy).
- **Path params:** `reviewId` (long). **Request body:** `{ "action": "publish", "reason": null }``hide`/`reject`
require a non-empty `reason` (≤ 500).
- **Success `200` (`data`):** `ModerateReviewResult``{ id, moderationStatus, averageRating, totalReviews }`
(the recomputed-from-source nurse aggregate).
- **Failure cases:** `400` unknown action / missing reason on hide|reject; `401`; `403` non-admin; `404` review
not found.
- **Notes:** **Every** transition recomputes `nurse_profiles.averageRating`/`totalReviews` from the nurse's
currently-`published` reviews (not an incremental delta) and refreshes the search index — in the **same
transaction** as the status change — so hiding a low rating lowers the count and re-derives the average.
### `GET api/v1/nurses/{nurseProfileId}/reviews` — public
- **Purpose:** The public reviews for a nurse: the rating aggregate + a page of **published** reviews.
- **Auth:** anonymous. **Path params:** `nurseProfileId` (long). **Query:** `page` (default 1), `pageSize`
(default 20, max 100).
- **Success `200` (`data`):** `NurseReviewsResult` — `{ aggregate: { averageRating, publishedCount },
reviews: PagedResult<ReviewListItemDto> }`. `ReviewListItemDto` = `{ id, rating, body, tagCodes[], createdAt }`
**never** carries moderation internals. An unknown nurse returns a zero aggregate + empty page (`200`).
- **Notes:** The publish gate is enforced at the query layer — a `pending_moderation`/`hidden`/`rejected` review
is never returned and never counted. The aggregate is cached and invalidated on every transition.
### `GET api/v1/nurses/{nurseProfileId}/review_tags` — public
- **Purpose:** The per-nurse tag rollup ("% punctual") over published reviews.
- **Auth:** anonymous. **Path params:** `nurseProfileId` (long).
- **Success `200` (`data`):** `NurseTagAggregatesResult``{ publishedReviewCount, tags: TagAggregateDto[] }`
where `TagAggregateDto` = `{ code, labelFa, labelEn, count, percentage }` (percentage of published reviews, 1-dp).
The active seeded vocabulary is always returned (zero counts for a nurse with no published reviews).
### `GET api/v1/admin/reviews/moderation_queue` — admin
- **Purpose:** The moderation worklist.
- **Auth:** admin / moderator. **Query:** `status` (default `pending_moderation`; any `moderationStatus`),
`page`, `pageSize`.
- **Success `200` (`data`):** `PagedResult<ModerationQueueItemDto>` — `{ id, bookingId, nurseProfileId,
customerProfileId, rating, body, moderationStatus, moderationReason, lowRatingAlertId, createdAt }`. The
`lowRatingAlertId` is the linked internal alert (id only — support alerts stay internal).
- **Failure cases:** `400` unknown status; `401`; `403` non-admin.
## Patient care records
Clinical bodies are **encrypted at rest** and returned **decrypted only after the access check passes**. The
access rule is enforced in the handler, not just the route policy.
**Access matrix** (both endpoints):
| Caller | Write | Read |
| --- | --- | --- |
| Nurse with a **confirmed** (or in-progress/completed/disputed/closed) booking for the patient | ✅ | ✅ |
| Nurse **without** such a booking | ❌ `403` | ❌ `403` |
| The patient's **owning customer** | ❌ (only nurses author) | ✅ |
| Admin / super_admin | ❌ (only nurses author) | ✅ |
| Anyone else | ❌ | ❌ `403` |
### `POST api/v1/patients/{patientId}/care_records`
- **Purpose:** A nurse authors a clinical note for a patient (patient-scoped; the note is encrypted before persist).
- **Auth:** authenticated nurse with a qualifying booking for the patient.
- **Path params:** `patientId` (long). **Request body:** `{ "bookingId": 123, "body": "…" }``bookingId`
optional provenance (which visit produced the note); `body` required (≤ 8000).
- **Success `200` (`data`):** `WriteCareRecordResult``{ id, patientId, recordedAt }`.
- **Failure cases:** `401`; `403` caller is not a nurse **or** has no qualifying booking for the patient; `404`
patient not found; `400` empty body.
### `GET api/v1/patients/{patientId}/care_records`
- **Purpose:** The patient-scoped longitudinal history, newest first.
- **Auth:** owning customer / nurse with a qualifying booking / admin (see the matrix).
- **Path params:** `patientId` (long). **Query:** `page`, `pageSize`.
- **Success `200` (`data`):** `PagedResult<CareRecordDto>``CareRecordDto` = `{ id, patientId, bookingId,
nurseProfileId, nurseName, body, recordedAt }` (`body` decrypted). Ordered by `recordedAt DESC`.
- **Failure cases:** `401`; `403` no clinical access; `404` patient not found.
- **Notes:** The record is **patient-scoped, not booking-scoped** — a new nurse taking over reads the whole
history (not just their own booking's notes).
---
## Refinement phase 3 additions (REQ-026/027)
- **`GET api/v1/bookings/{bookingId}/review_eligibility`** → `{ canReview, reason?:
not_completed|already_reviewed|not_owner|not_found }`.
- **`GET api/v1/bookings/{bookingId}/my_review`** → `{ moderationStatus:
pending_moderation|published|hidden|rejected|none, rating?, body?, tagCodes[], createdAt? }`. Masked-author
omission on the public list is **intentional** (privacy).
- **Family-owned care plan (new entity `usr.PatientCarePlans`):** `GET/PUT api/v1/patients/{patientId}/care_record`
→ `{ patientId, medications:[{id,name,dosage?,frequency,timingNote?}], routine:[{id,label,timeOfDay?,note?}],
tasks:[{id,label,done}] }`. Read = owner/nurse-with-booking/admin; write = owning customer only.
- **`GET api/v1/patients/{patientId}/record_access`** → `{ canView, canEdit, canAppendNote, deniedReason? }`
(always 200; non-leaking `not_found`/`not_authorized`).
- **Structured `taskResults`** (`[{ label, done }]`) added to the visit-note write body + the history DTO.
-117
View File
@@ -1,117 +0,0 @@
# Contract — Nurse search & matching (backend phase b7)
> The single public nurse-discovery endpoint (category + city/district geo, same-gender filter, price range,
> rating sort, paginated) plus the admin search-index rebuild. Reads a denormalized, maintained-on-write
> projection and returns **only searchable (verified + accepting + not-suspended + active) nurses**. Assumes
> [`../conventions/api-conventions.md`](../conventions/api-conventions.md) +
> [`../conventions/money-and-types.md`](../conventions/money-and-types.md). Machine schema:
> [`../openapi/swagger.v1.json`](../openapi/README.md) (refreshed for b7).
**Status:** live as of backend-phase-b7 · **Frontend consumer:** frontend-phase-f6-b7
> **Routing note.** Routes are **action-style** (`[controller]/[action]`, snake_cased). All responses use the
> standard `{ succeeded, statusCode, data }` envelope; `data` shapes are below. Query parameters are
> **snake_case** (`service_category_id`, `city_id`, …).
## Key semantics (read first)
- **Only `is_searchable = 1` rows are ever returned.** A row is searchable **only** when the nurse is
`is_verified` AND not suspended AND `is_accepting_bookings` AND the variant `is_active`. An unverified,
paused, suspended, or deactivated nurse/variant never appears — this is the phase's highest-stakes rule.
- **The result unit is the variant, not the nurse.** Each hit is a bookable `nurse_service_variant` matched
in a covered area; a nurse with multiple variants/areas can appear as multiple hits.
- **`district_id = null` ⇒ whole city**, both directions:
- A **city-only** search (no `district_id`) matches every row in the city — both the whole-city (NULL) rows
and every district row.
- A **district** search matches that district's rows **plus** the whole-city (NULL) rows (a whole-city
nurse covers every district).
- **Same-gender matching is a first-class facet.** `nurse_gender` (`male`/`female`) is an up-front filter;
it is never silently defaulted or dropped. (Carrying the chosen gender *into* the booking request —
`booking_requests.required_caregiver_gender` — lands in b8.)
- **Money is IRR `BIGINT`.** `price` in results is a **digit string** (`"500000"`); `min_price`/`max_price`
filters are integers. No floats anywhere.
- **Rating sort only (MVP).** Results are ordered by `averageRating` desc, tiebroken by `totalReviews` desc
then `nurseId`/`variantId` so paging is deterministic.
- **Availability is not a filter.** Availability slots are soft guidance; they never hard-filter search (b7).
## Enums used
- `nurse_gender`: `male` | `female`.
- `price_unit`: `per_hour` | `per_session` | `per_half_day` | `per_day` | `per_24h` (copied from the variant).
## Endpoints
### `GET api/v1/search/nurses`
- **Purpose:** the single family-facing discovery query over the maintained search index.
- **Auth:** none (public, pre-auth discovery) · **Rate-limited:** yes (per-IP global limiter) · **Idempotency key:** no
- **Query params:**
- `service_category_id` (long, **required**) — the primary search dimension.
- `city_id` (long, **required**).
- `district_id` (long, optional) — omit for a whole-city search; see geography rule above.
- `nurse_gender` (`male`|`female`, optional) — the same-gender facet.
- `min_price` / `max_price` (long IRR, optional) — inclusive range over the copied `price`.
- `price_unit` (enum, optional) — compare like-for-like listings (e.g. only `per_day`).
- `page` (int, default 1), `pageSize` (int, default 50, max 100).
- **Success `200` payload (`data` = `PagedResultOfNurseSearchResultDto`):**
```json
{
"items": [
{
"variantId": 12,
"nurseId": 5,
"serviceCategoryId": 1,
"price": "8000000",
"priceUnit": "per_24h",
"nurseGender": "female",
"averageRating": 4.8,
"totalReviews": 9,
"totalCompletedBookings": 12,
"cityId": 101,
"districtId": 1003
}
],
"total": 1,
"page": 1,
"pageSize": 50
}
```
- **Failure cases:** `400``service_category_id`/`city_id` missing or ≤ 0, `nurse_gender` not `male`/`female`,
`min_price > max_price`, invalid `price_unit`, or `pageSize > 100`.
- **Notes:** returns only `is_searchable = 1` rows. `districtId = null` in a result row means the nurse covers
the whole city. No entity is hydrated — the read is a projected, paginated, `AsNoTracking` index scan.
### `POST api/v1/admin_search/rebuild_index`
- **Purpose:** idempotent full rebuild of the search index from source — the convergence/reconciliation path
(first-launch / nightly / after a bulk data fix).
- **Auth:** admin (dynamic-permission policy) · **Rate-limited:** yes (`sensitive`) · **Idempotency key:** no
- **Request body:** none.
- **Success `200` payload (`data` = `SearchIndexRebuildResult`):**
```json
{ "nursesProcessed": 128, "rowsWritten": 342 }
```
- **Failure cases:** `401` unauthenticated · `403` non-admin.
- **Notes:** truncates and repopulates the whole index in nurse-batches; the rebuilt index's live/searchable
rows must match the incrementally-maintained state (no duplicate variant×area rows). Writes an audit-log row.
## Shared shapes
- `NurseSearchResultDto`: `variantId` (long), `nurseId` (long), `serviceCategoryId` (long),
`price` (string, IRR digits), `priceUnit` (enum), `nurseGender` (`male`/`female`), `averageRating` (decimal),
`totalReviews` (int), `totalCompletedBookings` (int), `cityId` (long), `districtId` (long?, null = whole city).
- `SearchIndexRebuildResult`: `nursesProcessed` (int), `rowsWritten` (int).
## Backend seam (not a wire shape)
- **`INurseSearch`** — the search-service seam. MVP impl `SqlNurseSearch` (real, over `nurse_search_index`).
Config key `Search:Backend` (default `sql`); a later `ElasticNurseSearch` is a config-selected drop-in.
## Changelog
- b7 — initial contract: public `search/nurses`, admin `admin_search/rebuild_index`.
---
## Refinement phase 3 additions (REQ-012)
- **`NurseSearchResultDto`** gains `nurseName` + `avatarUrl` (denormalized onto `nurse_search_index`, so no
per-row join) and `distanceKm` (nullable — the covering index carries no coordinate, so it is null today).
- **`GET api/v1/nurses/{id}/profile`** (public) — the aggregated discovery detail:
`{ nurseId, nurseName, avatarUrl, bio, yearsExperience, averageRating, totalReviews,
totalCompletedBookings, isVerified, inoMembership, attributeChips[], services: [{ variantId, displayName,
priceIrr, priceUnit, sessionCount? }], latestReview?: { rating, body, authorMasked (null by design),
createdAt } }`. No encrypted credential number is ever exposed.
-269
View File
@@ -1,269 +0,0 @@
# Contract — Nurse verification & credentials (backend phase b6)
> The trust engine: a data-driven verification pipeline (checklist of steps), the admin review queue, the
> structured credential registry, the transactional `nurse_profiles.is_verified` flip, the admin-triggered
> credential-expiry scan, and the public trust badge. Assumes
> [`../conventions/api-conventions.md`](../conventions/api-conventions.md) +
> [`../conventions/money-and-types.md`](../conventions/money-and-types.md). Machine schema:
> [`../openapi/swagger.v1.json`](../openapi/README.md) (refreshed for b6 — all 15 endpoints).
**Status:** live as of backend-phase-b6 · **Frontend consumer:** frontend-phase-f5-b6 (public trust badge → f6)
> **Routing note.** Routes are **action-style** (`[controller]/[action]`, snake_cased) to match the codebase
> convention and the dynamic-permission key scheme — e.g. `POST api/v1/nurse_verification/submit`,
> `POST api/v1/admin_verifications/steps/{stepId}/decide`. Mutations use **POST**; ids come from the
> **route**, never the body. All responses use the standard `ApiResult<T>` envelope (payload under `data`);
> JSON bodies/fields are **camelCase**.
## Enums used
- `verification_status` (`nurse_verifications.status` — the aggregate): `not_started` | `pending` |
`in_review` | `approved` | `rejected` | `suspended`. **This is the single source of verification truth.**
- `verification_step_status` (`verification_steps.status`): `not_started` | `pending` | `in_review` |
`passed` | `failed` | `expired`.
- `step_type_code` (the six seeded, **stable** codes): `identity_kyc` | `shahkar_match` |
`moh_competency_license` | `ino_membership` | `criminal_record` | `bank_account_verification`.
- `credential_type`: `moh_competency_license` | `ino_membership` | `criminal_record` — the three
credential-bearing steps that record a `nurse_credentials` row on approval.
- `verification_method` (how a credential was verified): `manual` | `portal` | `api`. Today every real
credential resolves `manual` (admin review); `api` is reserved for a future MoH/INO portal lookup.
## Key semantics (read first)
- **`nurse_verifications.status` is the SINGLE source of verification truth.** `nurse_profiles.is_verified`
is the **only derived boolean** and is flipped **only inside the finalize transaction** (a re-aggregate
after a step decision) or reversed inside a suspend/expiry transaction. The client never sets or infers
`is_verified` — it reads `isBookable` / `isVerified` off the API.
- **A nurse becomes bookable only when the aggregate reaches `approved`.** `VerificationStatusDto.isBookable`
is the flag to gate the nurse UI on; `blockingSteps` names what still stands in the way.
- **Steps are automated or manual.** Automated steps (`isAutomated:true``identity_kyc`, `shahkar_match`,
`bank_account_verification`) run via a `/run` endpoint against a mocked vendor seam. Manual steps
(`moh_competency_license`, `ino_membership`, `criminal_record`) take a document upload and wait for an
admin decision.
- **Credentials never leak their number.** `nurse_credentials.credential_number` is **encrypted at rest and
NEVER serialized** on any DTO. The public **trust badge exposes credential TYPES only**, never numbers.
- **Identity is cross-checked.** A credential's `holderName` is checked against the verified identity name;
a mismatch → **400** and **no credential is recorded**. Bank verification enforces a **money-mule guard**:
the IBAN holder's national id must equal the verified nurse national id.
- **Deactivate step-types, never delete.** `DELETE` on a step-type sets `is_active=false`; a step-type
`code` is **immutable once in use**; a duplicate `code`**409**.
- **Expiry is admin-triggered for now.** Time-limited steps (e.g. `criminal_record`) lapse to `expired`;
`scan_expiring` is the manual entry point that reverts them and re-gates bookability. The scheduled cron is
**deferred** (config key `verification_expiry_scan_cadence_hours`, int, default `24`).
- **All vendor/money calls are mocked** behind DI seams (deterministic) — no real KYC, Shahkar, credential,
or bank call happens. See [Mocks](#mocks).
## Nurse verification — `NurseVerificationController` (`[Authorize]`; nurse role enforced in handler; tenancy-scoped to the signed-in nurse)
### `POST api/v1/nurse_verification/submit`
- **Purpose:** open (or re-open) the nurse's verification and seed the checklist.
- **Body:** none.
- **`data`:** `VerificationStatusDto`.
- **Notes:** upserts the `nurse_verifications` header and **seeds one `verification_step` per active required
step-type** (snapshotting `is_automated` at seed time). **Idempotent** — never duplicates a step; adds only
newly-required ones on a re-submit. `400` if the caller has no nurse profile; `401` unauthenticated;
`403` non-nurse.
### `GET api/v1/nurse_verification`
- **Purpose:** the nurse's own checklist + aggregate status + blocking summary.
- **`data`:** `VerificationStatusDto``status`, `isBookable`, `blockingSteps` (step codes still blocking),
`steps[]`. Returns a **`not_started` empty checklist** if the nurse never submitted (not a 404).
### `POST api/v1/nurse_verification/steps/{stepId}/upload_url`
- **Purpose:** get a signed PUT URL for a manual step's document.
- **Body:** `{ contentType, fileName? }`.
- **`data`:** `UploadUrlResult` (`objectStorageKey`, `uploadUrl`). **Manual (non-automated) steps only.**
Echo `objectStorageKey` back on confirm. `400` on an automated step / bad content type; `404` if the step
isn't the caller's.
### `POST api/v1/nurse_verification/steps/{stepId}/documents`
- **Purpose:** confirm an uploaded document and move the manual step to `in_review`.
- **Body:** `{ objectStorageKey, integrityHash, contentType, fileSizeBytes, originalFileName? }`.
- **`data`:** `DocumentConfirmedResult` (`documentId`, `stepStatus`).
- **Notes:** persists a `verification_documents` **metadata row only** (bytes never touch the DB) and moves
the manual step to `in_review`. `404` if the step isn't the caller's.
### `POST api/v1/nurse_verification/steps/identity_kyc/run`
- **Purpose:** run the automated national-ID + liveness check.
- **Body:** `{ nationalId (10 digits), livenessPayload? }`.
- **`data`:** `RunStepResult` (`stepId`, `stepStatus`, `failureReason?`).
- **Side effects:** on **pass** populates `users.national_id` + `users.national_id_verified_at`. `400` on a
malformed national id; a vendor fail comes back as `stepStatus:"failed"` + `failureReason` (still `200`).
### `POST api/v1/nurse_verification/steps/shahkar_match/run`
- **Purpose:** run the phone↔national-id Shahkar match.
- **Body:** none.
- **`data`:** `RunStepResult`.
- **Notes:** **requires identity KYC passed** (a verified national id must be present) → else `400`.
**Shared-SIM is an explicit handled failure** — it fails the step and raises a `shared_sim` **support
alert**. On **pass** sets `users.shahkar_verified_at`.
### `POST api/v1/nurse_verification/steps/bank_account_verification/run`
- **Purpose:** run the استعلام شبا IBAN-owner ↔ national-id match (money-mule guard).
- **Body:** none.
- **`data`:** `RunStepResult`.
- **Notes:** requires KYC passed **and** a **primary `nurse_bank_accounts` row** → else `400`. Reuses the b3
`IBankAccountOwnershipVerifier`; the **holder national id must equal the verified nurse national id**. On
match sets the account's `matched_national_id=1` (the b13 first-payout gate).
## Admin step-type catalog — `AdminVerificationStepTypesController` (`[Authorize(DynamicPermission)]`, rate-limited `sensitive`)
### `GET api/v1/admin_verification_step_types?includeInactive={bool}`
- **`data`:** `IReadOnlyList<VerificationStepTypeDto>`. **Cached** (generation-token; any write invalidates).
`includeInactive=false` (default) hides deactivated step-types.
### `POST api/v1/admin_verification_step_types`
- **Purpose:** create or update a step-type (upsert on `id`).
- **Body:** `{ id?, code, displayName, description?, isRequired, isAutomated, automationProvider?, sortOrder, isActive }`.
`id` **null → create**; else update.
- **`data`:** `VerificationStepTypeDto`.
- **Notes:** `code` must be **snake_case** (`[a-z][a-z0-9_]*`) and is **immutable once the step-type is in
use**. `400` invalid code/labels; `404` unknown `id` on update; **`409`** duplicate `code`.
### `DELETE api/v1/admin_verification_step_types/{id}`
- **Purpose:** **deactivate** a step-type (`is_active=false`) — **never a hard delete**.
- **`data`:** `bool` (success). `404` unknown id.
## Admin review queue — `AdminVerificationsController` (`[Authorize(DynamicPermission)]`, rate-limited `sensitive`)
### `GET api/v1/admin_verifications?status=&page=&pageSize=`
- **Purpose:** the review queue — one row per step awaiting attention.
- **Params:** `status` (default `in_review`) + pagination `page`/`pageSize`.
- **`data`:** `PagedResult<AdminPendingStepDto>`. Documents carry **signed GET URLs**.
### `GET api/v1/admin_verifications/{nurseVerificationId}`
- **Purpose:** the full case for a nurse.
- **`data`:** `AdminVerificationDetailDto` — all steps + their documents (signed URLs) + credentials + the
**identity name** for cross-check. `404` if the verification doesn't exist.
### `POST api/v1/admin_verifications/steps/{stepId}/decide`
- **Purpose:** approve or reject a manual step (and, on a credential-bearing step, record the credential).
- **Body:** `{ approve, rejectionReason?, credentialNumber?, holderName?, issuingAuthority?, issuedAt?, expiresAt?, verificationSource? }`
`rejectionReason` **required when `approve=false`**.
- **`data`:** `ReviewStepResult` (`stepId`, `stepStatus`, `credentialId?`).
- **Notes:** **manual steps only.** On **approving a credential-bearing step**
(`moh_competency_license` / `ino_membership` / `criminal_record`) it records a `nurse_credentials` row —
`credential_number` **ENCRYPTED** (never serialized); `holderName` **cross-checked** against the verified
identity name (**mismatch → 400, no credential recorded**); **`criminal_record` requires `expiresAt`**.
Writes an `audit_logs` decision record, then **re-aggregates** the verification (**may flip
`is_verified`**). `400` missing `rejectionReason` / holder-name mismatch / missing required `expiresAt`;
`404` step not found.
### `POST api/v1/admin_verifications/{nurseVerificationId}/suspend`
- **Purpose:** suspend a verified nurse.
- **Body:** `{ reason }`.
- **`data`:** `bool`.
- **Notes:** sets `status=suspended` and **reverses `is_verified=0` in the same transaction**. Writes an
`audit_logs` record. `404` unknown verification.
### `POST api/v1/admin_verifications/scan_expiring`
- **Purpose:** the admin-triggered credential-expiry scan (the cron entry point until the scheduler ships).
- **Body:** `{ page?, pageSize? }`.
- **`data`:** `ScanExpiringResult` (`scannedSteps`, `revertedNurses`).
- **Notes:** reverts lapsed time-limited steps to `expired`, raises a `verification_expired` **support
alert** + a `verification_expiry_prompt` **notification**, and **re-gates bookability**. The scheduled cron
is **deferred** (config `verification_expiry_scan_cadence_hours`, int, default `24`).
## Public trust badge — `NursesController` (`[AllowAnonymous]`)
### `GET api/v1/nurses/{nurseId}/trust_badge`
- **Purpose:** the public trust signal for a nurse.
- **`data`:** `TrustBadgeDto``isVerified`, `approvedAt?`, and the **credential TYPES held** (never the
numbers). **Cached** (short TTL; evicted on suspension / expiry / a step decision). `404` for an unknown
nurse.
## Shared shapes
_(records; camelCase on the wire; `?` = nullable; `credential_number` is never present)_
- `VerificationStepTypeDto`: `id` (long), `code` (string), `displayName` (string), `description` (string?),
`isRequired` (bool), `isAutomated` (bool), `automationProvider` (string?), `sortOrder` (int),
`isActive` (bool).
- `VerificationStepDto`: `id` (long), `code` (string), `displayName` (string), `status` (enum), `isAutomated`
(bool), `expiresAt` (datetime?), `failureReason` (string?).
- `VerificationStatusDto`: `status` (enum), `isBookable` (bool), `blockingSteps` (string[] — step codes),
`steps` (`VerificationStepDto[]`).
- `VerificationDocumentDto`: `id` (long), `contentType` (string), `fileSizeBytes` (long), `originalFileName`
(string?), `url` (string — a **short-lived signed** URL).
- `NurseCredentialDto`: `id` (long), `credentialType` (enum), `holderNameSnapshot` (string),
`issuingAuthority` (string), `issuedAt` (date?), `expiresAt` (date?), `verificationMethod` (enum).
**`credential_number` is NEVER serialized.**
- `TrustBadgeDto`: `nurseId` (long), `isVerified` (bool), `approvedAt` (datetime?), `credentialTypes`
(string[] — credential **types** only).
- `AdminPendingStepDto`: `nurseVerificationId` (long), `nurseId` (long), `nurseName` (string), `stepId`
(long), `stepCode` (string), `stepDisplayName` (string), `status` (enum), `submittedAt` (datetime?),
`documents` (`VerificationDocumentDto[]`).
- `AdminStepDetailDto`: `stepId` (long), `code` (string), `displayName` (string), `status` (enum),
`isAutomated` (bool), `expiresAt` (datetime?), `failureReason` (string?), `documents`
(`VerificationDocumentDto[]`).
- `AdminVerificationDetailDto`: `nurseVerificationId` (long), `nurseId` (long), `identityName` (string),
`status` (enum), `steps` (`AdminStepDetailDto[]`), `credentials` (`NurseCredentialDto[]`).
- `UploadUrlResult`: `objectStorageKey` (string), `uploadUrl` (string).
- `DocumentConfirmedResult`: `documentId` (long), `stepStatus` (enum).
- `RunStepResult`: `stepId` (long), `stepStatus` (enum), `failureReason` (string?).
- `ReviewStepResult`: `stepId` (long), `stepStatus` (enum), `credentialId` (long?).
- `ScanExpiringResult`: `scannedSteps` (int), `revertedNurses` (int).
- `PagedResult<T>`: `items` (`T[]`), `total` (int), `page` (int), `pageSize` (int).
## Side effects to know
The finalize/reverse of `nurse_profiles.is_verified` (one transaction) · `users.national_id` population ·
`users.shahkar_verified_at` · `nurse_bank_accounts.matched_national_id` · `support_alerts` (`shared_sim`,
`verification_expired`) · `notifications` (`verification_expiry_prompt`) · `audit_logs` decision records.
## Mocks
All vendor/money calls are **mocked behind DI seams** (deterministic — use the test values below). See
[`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md).
- `IShahkarVerifier``MockShahkarVerifier`: **pass** unless the configured shared-SIM phone `09120000000`
(→ shared-SIM handled failure) or the mismatch national id `1111111111`.
- `IIdentityKycProvider``MockIdentityKycProvider`: passes any well-formed 10-digit national id **except**
the configured fail id `0000000000`.
- `ICredentialVerifier``MockCredentialVerifier`: the manual-admin default — always `RequiresManualReview`
/ `verification_method=manual`.
- **Reused:** `IBankAccountOwnershipVerifier` (b3; mismatch IBAN `IR000000000000000000000000` → mismatch),
`IObjectStorage` (b0; local-disk, signed PUT/GET URLs), `IFieldEncryptor` (b0; encrypts
`credential_number`).
## Example — a nurse gets verified
```
# 1) open the checklist
POST /api/v1/nurse_verification/submit -> data.steps seeded (one per active required step-type)
GET /api/v1/nurse_verification -> { status: "pending", isBookable: false, blockingSteps: [...] }
# 2) automated identity + shahkar
POST /api/v1/nurse_verification/steps/identity_kyc/run { "nationalId": "1234567891" } -> stepStatus "passed"
POST /api/v1/nurse_verification/steps/shahkar_match/run -> stepStatus "passed"
# (a 09120000000 SIM would come back "failed" + raise a shared_sim support alert)
# 3) manual credential (e.g. MoH license): upload then wait for admin
POST /api/v1/nurse_verification/steps/{stepId}/upload_url { "contentType": "application/pdf" }
-> { objectStorageKey, uploadUrl } # PUT the bytes to uploadUrl
POST /api/v1/nurse_verification/steps/{stepId}/documents { objectStorageKey, integrityHash, contentType, fileSizeBytes }
-> step -> in_review
# 4) admin decides -> records the (encrypted) credential, re-aggregates, may flip is_verified
POST /api/v1/admin_verifications/steps/{stepId}/decide
{ "approve": true, "credentialNumber": "…", "holderName": "<verified identity name>", "issuingAuthority": "MoH", "issuedAt": "2026-01-01" }
-> { credentialId }
# holderName != verified identity -> 400 (no credential recorded)
# 5) public badge (types only, never numbers)
GET /api/v1/nurses/{nurseId}/trust_badge -> { isVerified: true, approvedAt, credentialTypes: ["moh_competency_license"] }
```
## Changelog
- b6 — initial contract: nurse verification checklist (submit/get/upload/confirm/run), automated
identity-KYC / Shahkar / bank-ownership runs, admin step-type catalog (CRUD + deactivate),
admin review queue (list/detail/decide/suspend/scan-expiring), public trust badge;
`verification_status` / `verification_step_status` / `credential_type` / `verification_method` enums;
transactional `is_verified` flip; encrypted-never-serialized `credential_number`; three new mocked vendor
seams (`IShahkarVerifier`, `IIdentityKycProvider`, `ICredentialVerifier`). Scheduled expiry cron deferred.
---
## Refinement phase 3 additions (REQ-011)
- **`VerificationStepDto`** gains `isRequired` (mirrors the step-type catalog; an optional step never blocks
bookability).
- **`POST api/v1/nurse_verification/credential_details`** (nurse) — captures the structured credential
fields collected with the uploads: `{ inoNumber (required), specialties: string[], licenseNumber?,
issuingAuthority?, holderName?, issuedAt?, expiresAt? }` → `VerificationStatusDto`. Upserts an
`ino_membership` (and, if a license number is sent, `moh_competency_license`) `nurse_credentials` row
(unverified — admin still decides) and persists `specialties` on the profile. The INO number is encrypted.
-22
View File
@@ -1,22 +0,0 @@
# OpenAPI snapshots
The server already generates OpenAPI via **NSwag** (Swagger UI at `/swagger`, documents `v1`, `v1.1`).
This folder holds the **published `swagger.json` snapshot(s)** so the frontend can generate/verify types
without running the backend.
## Backend: publish on every API-shipping phase
After adding/changing endpoints and confirming the build, export the OpenAPI document and commit it here
as `swagger.v1.json` (overwrite — git history is the version trail). Typical options:
- Run the API and save `GET /swagger/v1/swagger.json` to `dev/contracts/openapi/swagger.v1.json`, **or**
- Use the NSwag CLI / build target the server already wires to emit the document.
Record in your handoff that the snapshot was refreshed. Keep it in sync with `../domains/*.md` — the
markdown is the human contract, this JSON is the machine contract; they must agree.
## Frontend: consume
Generate types from `swagger.v1.json` (e.g. an `openapi-typescript`-style step) **or** hand-write
`src/services/{domain}/types.ts` to match it. Either way, the wire shapes come from here — not from
guessing. Casing/format questions are resolved by this file.
> Until the first API-shipping backend phase runs, this folder is empty by design.
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Some files were not shown because too many files have changed in this diff Show More