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 # Balinyaar Frontend Designer
Build UI that looks like Balinyaar and behaves correctly in both locales and both 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 color schemes on the first try.
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. **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`) · **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 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 emphasis** — `--bal-money-emphasis`, an AA-contrast-safe color for emphasized
money text. `--bal-secondary` (terracotta) fails AA contrast at small sizes on light money text. `--bal-secondary` (terracotta) fails AA contrast at small sizes on light
backgrounds — never use it for money text, use this token instead. 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` | | `AppIconButton` | icon-only actions | takes an icon `name`, `title`, `to`/`onClick` |
| `AppIcon` | any icon | `icon="home"` by registered name (§6); `size`, `color` props | | `AppIcon` | any icon | `icon="home"` by registered name (§6); `size`, `color` props |
| `AppLink` | internal/external links | locale-aware Next navigation; default underline `hover` | | `AppLink` | internal/external links | locale-aware Next navigation; default underline `hover` |
| `AppAlert` | inline alerts | default `severity="error"`, `variant="filled"` | | `AppAlert` | inline alerts | defaults to a calm `severity="info"`, `variant="standard"` — pass `severity="error"` explicitly when it really is an error |
| `AppImage` | images | wrapper around next/image conventions |
| `AppLoading` | loading state | default circular, `primary`, `3rem` | | `AppLoading` | loading state | default circular, `primary`, `3rem` |
| `ErrorBoundary` | wrapping fault-prone subtrees | already wraps page content in the shell | | `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 | | `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`, 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/ `APP_ICON_SIZE = 24`, `APP_ICON_STROKE_WIDTH = 1.75`, `APP_BUTTON_ICON_SIZE = 20`,
loading defaults). Change a default there, not per-call-site. `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`, For layout/spacing use MUI primitives directly: `Box`, `Stack`, `Container`, `Grid`,
`Paper`, `Card`. Use the `spacing`/`sx` system (theme spacing unit = 8px) — never inline `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 **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`, `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 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 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. 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 - `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** (header / solved: the width cap; the **frame, not the document, owns the scroll** (a single
`<main>` / footer are flex siblings, so a top bar is `position: static` and no page scrolling `<main>` fills the frame, with the bars pinned **`position: absolute`** over
needs a top offset); and `overflowX: hidden` + `minWidth: 0`, so an over-wide child it — never `fixed`, which would break out of the centered column — and `<main>`
clips rather than dragging the app sideways. Genuinely wide content (a data table) reserving each bar's exact height as padding, so no page needs a top offset);
scrolls **inside its own container** — see `AdminDataTable`'s `TableContainer`. `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 - **One authenticated shell**: `MobileShell` = `AppFrame` + a contextual `TopBar` (brand
lockup on a tab's own path, back chevron + `useRouteTitle()` on anything deeper) + lockup on a tab's own path, back chevron + `useRouteTitle()` on anything deeper) +
`BottomBar` + `ErrorBoundary` + `RouteFadeIn`. The four actor layouts (`CustomerLayout` `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 only `tabs` and `headerActions`. Add a destination by adding a tab or a hub row — never
by forking the shell. by forking the shell.
- **The chrome is light, not structural.** The top bar is *not* an `AppBar` — no filled - **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*: surface, no rule, no elevation of its own; `AppFrame` wraps both bars in the shared
inset from the frame edges, fully rounded (`--bal-radius-pill`), elevated. Neither should `FLOATING_BAR_SX`, so the header is the bottom bar mirrored: inset from the frame edges,
read as a slab sealing off an edge of a 480px screen. 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 - **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 (`@/utils`) built with `useTranslations('nav')`, 35 of them, and by convention the last
is a settings/«بیشتر» hub. Active state comes from the shared `matchActivePath` 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. layout bug this component kept quietly reintroducing on narrow rows.
**Directional icons mirror automatically.** Icons authored for LTR that must flip under **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 `DIRECTIONAL_ICONS` set. `AppIcon` stamps `data-icon-directional` on those, and one CSS
rule (`app/globals.css`) does rule (`app/globals.css`) does
`[dir='rtl'] [data-icon-directional] { transform: scaleX(-1); }`. Adding a new directional `[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. switches automatically. Verify on both schemes — never assume a light background.
4. **Tokens, not hexes.** No raw color literals in `sx`/`styled`/components (§2). 4. **Tokens, not hexes.** No raw color literals in `sx`/`styled`/components (§2).
5. **Constants, not magic values.** Cookie names, routes, repeated dimensions, event 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. 6. **Use the wrappers** (§4) and the **icon registry** (§6) before bare MUI.
7. **Shared component ⇒ co-located test** (§4). 7. **Shared component ⇒ co-located test** (§4).
8. **MUI v9 API only.** No v5/v6-era props (e.g. `Stack` `useFlexGap`, `storageWindow`). 8. **MUI v9 API only.** No v5/v6-era props (e.g. `Stack` `useFlexGap`, `storageWindow`).
Avoid deprecated APIs that throw. 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 5. **Verify the four axes:** `/fa` (RTL) and `/en` (LTR) × light and dark. The default
route is `/fa` — start there. route is `/fa` — start there.
6. **Tests** for any new shared component; **never** add a layout above `[locale]` 6. **Tests** for any new shared component; **never** add a layout above `[locale]`
(breaks locale/dir — see CLAUDE.md). (breaks locale/dir — see [structure.md](../../../archive/docs/rules/client/structure.md)).
7. Data/fetch/auth/cookies/toasts → follow CLAUDE.md (`serverFetch`/`clientFetch`, 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. `@/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. - Hard-coded hex/rgb in components → use palette keys or `--bal-*` tokens.
- MUI default success/error colors for feedback → use `--bal-*` semantic 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`. - 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. - New shared component without a `.test.tsx`, or mocking MUI in tests.
- Re-introducing `src/app/layout.tsx` / any layout above `[locale]`. - 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 shells | `client/src/layout/` |
| Layout dimensions | `client/src/layout/config.ts` | | Layout dimensions | `client/src/layout/config.ts` |
| Messages (i18n) | `client/messages/{en,fa}.json` | | 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) - Frontend → [client/CLAUDE.md](client/CLAUDE.md)
- Backend → [server/CLAUDE.md](server/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 `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. just pointers so the convention is discoverable under either name.
+100 -82
View File
@@ -1,127 +1,145 @@
# Balinyaar — Repository Guide (root) # Balinyaar — Repository Guide (root)
This is the **shared, repo-wide** guide for AI coding agents. It is intentionally short. The **shared, repo-wide** guide for AI coding agents. It is intentionally short. Everything specific to one
Everything specific to one side of the stack lives in that project's own `CLAUDE.md`. 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.** > **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 `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. > 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. > Last verified: 2026-08-02 against commit `51e86a1`.
> `CLAUDE.md` is the single source of truth at every level.
--- ---
## What Balinyaar is ## What Balinyaar is
Balinyaar is a **trust-first home-nursing marketplace in Iran**. Independent nurses (and Balinyaar is a **trust-first home-nursing marketplace in Iran**. Independent nurses (and nursing-company
nursing-company employees) list configurable services; families search, book, pay, and review. employees) list configurable services; families search, book, pay, and review. The platform holds funds in an
The platform holds funds in an escrow-style ledger and pays nurses out weekly after a confirmed escrow-style ledger and pays nurses out weekly after a confirmed check-out.
check-out.
Product/domain knowledge — business rules, the database model, payments/BNPL, escrow, the **Start at [`mvp/README.md`](mvp/README.md) for the current state of the product**, in three short,
verification pipeline — is **not** in the code. It lives in [`product/`](product/), organized as a non-technical files: how to manually test any user journey, what's broken and blocking a real launch, and
**structured docs tree** (one topic per file; start at [product/index.md](product/index.md) or its what's missing that isn't clearly scheduled. That folder is the live, load-bearing answer to "what's next."
[README](product/README.md)):
| Folder | What it covers | 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)
| [product/overview/](product/overview/platform-summary.md) | What Balinyaar is, the four cross-cutting ground truths, Persian glossary. **Read first.** | during the 2026-08-02 documentation cleanup. It is **reference material, not required reading**: correct as
| [product/business/](product/business/index.md) | The 14 functional/business requirement areas, one file each | of that date, but not actively maintained going forward. Read it when `mvp/` doesn't answer your question in
| [product/data-model/](product/data-model/index.md) | The ~54-table SQL Server schema across 13 domains + [diagrams](product/data-model/diagrams.md) | enough depth — e.g. designing a new table, or needing the full reasoning behind a business rule.
| [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) |
**Read the relevant `product/` doc before designing any schema, API, or feature.** Don't infer **Never infer business rules from code alone** — the code is young. If `mvp/` and `archive/product/` both go
business rules from code — the code is young and the docs are the source of truth. silent on a money, auth, tenancy, or clinical-data rule, say so rather than guessing.
> **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`.
--- ---
## Repository layout ## Repository layout
This is **two independent projects in one repo**. There is no root-level build, package, or This is **two independent projects in one repo**, plus their documentation. There is no root-level build,
solution — each project is built, linted, and run on its own. 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) | | [`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) | | [`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) | | [`mvp/`](mvp/README.md) | **Current truth** — plain-language test flows, launch blockers, missing MVP features | Markdown | [mvp/README.md](mvp/README.md) |
| [`dev/`](dev/) | Build plan (not app code) | Markdown | [dev/README.md](dev/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) | Node 18+, zero deps | [telegram-otp-bot/README.md](telegram-otp-bot/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) | | [`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 `AGENTS.md` files in this repo are thin pointers to the `CLAUDE.md` in the same folder. **`CLAUDE.md` is the
`NEXT_PUBLIC_API_URL`; the server listens on `https://localhost:5002` by default. single source of truth at every level.**
**Deployment** is three Docker containers — one `Dockerfile` per project directory, orchestrated by the The two projects communicate over **HTTP/JSON** (optionally gRPC). The client reads the API base URL from
root [`docker-compose.yml`](docker-compose.yml) — behind an existing Caddy reverse proxy on the external `NEXT_PUBLIC_API_URL`; the server listens on `http://localhost:5002` by default.
`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).
[`dev/`](dev/README.md) holds the **phased build plan** that takes the repo from its current baseline to **Deployment** is three Docker containers — one `Dockerfile` per project directory, orchestrated by the root
the MVP: a chain of agent-runnable prompt files split into a `backend/` and a `frontend/` track [`docker-compose.yml`](docker-compose.yml) — behind an existing Caddy reverse proxy on the external `caddy_net`
([dev/phases/](dev/phases/README.md)), the cross-project API [`contracts/`](dev/contracts/README.md), and network, serving `balinyaar.ir` (client) and `api.balinyaar.ir` (server). The database is **not**
a [`shared-working-context/`](dev/shared-working-context/README.md) that lets a backend agent and a containerised; it is a remote SQL Server. Full runbook: [DEPLOY.md](DEPLOY.md).
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. `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) ## Working agreements (apply to both projects)
1. **Stay within one project per change** unless the task explicitly spans both. 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 2. **Match the surrounding style.** Mirror existing patterns; don't introduce new ones. Each project documents
project documents its conventions in its own `CLAUDE.md`. its conventions in its own `CLAUDE.md`.
3. **Run that project's own checks before declaring work done:** 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. - client: `cd client && npm run check` (type + lint + copy), plus `npm run test:ci` if you touched a tested
- server: `dotnet build Baya.sln` and `dotnet test Baya.sln`. component.
4. **Read the product docs before changing behavior.** Business rules are decisions, not guesses. - server: `cd server && dotnet build Baya.sln` (**zero new warnings**) and `dotnet test Baya.sln`.
5. **Don't reintroduce template/starter scaffolding.** Both projects were derived from open-source - What "done" means in full: [archive/docs/rules/shared/git-and-gates.md](archive/docs/rules/shared/git-and-gates.md).
starters; their branding, demo/showcase pages, and `_TITLE_`/`_DESCRIPTION_` placeholders were 4. **Read [`mvp/`](mvp/README.md) (and `archive/product/` for depth) before changing behavior.** Business rules are decisions, not guesses.
intentionally removed. Don't add them back. 5. **Don't reintroduce template/starter scaffolding.** Both projects were derived from open-source starters;
6. **Configuration lives in files, not in a secret store.** `dotnet user-secrets` is **not** used — the their branding, demo/showcase pages, and `_TITLE_`/`_DESCRIPTION_` placeholders were intentionally removed.
`<UserSecretsId>` was removed from `Baya.Web.Api.csproj`, so that store isn't even read. Server config Don't add them back.
(including keys) lives in `appsettings.*.json`; client config in `.env.development` / `.env.production`; 6. **Configuration lives in files, not a secret store.** `dotnet user-secrets` is **not used** — the
the deployment's container-specific overrides in `docker-compose.yml`. This is a deliberate pre-launch `<UserSecretsId>` was removed from `Baya.Web.Api.csproj`, so that store **is not even read**. Any
trade for a demo deployment — **the repo therefore contains live credentials**. Before onboarding real instruction anywhere to set a value with it is stale. Server config (including keys) lives in
users, rotate them and move the secret half out of git (see [DEPLOY.md](DEPLOY.md) "Going to Production"). `appsettings.*.json`; client config in `.env.development` / `.env.production`; the deployment's
One value is load-bearing and must never change: `Seams:FieldEncryption:Key`/`:HashKey` decrypt all container-specific overrides in `docker-compose.yml`.
existing PII and derive the phone-lookup hash. This is a deliberate pre-launch trade for a demo deployment — **the repo therefore contains live
7. **Keep docs honest, and keep the architecture map current.** If you change how something works, credentials.** Before onboarding real users, rotate them and move the secret half out of git (see
update the `CLAUDE.md` that describes it in the same change. Each level documents its architecture [DEPLOY.md](DEPLOY.md) "Going to Production"). **One value is load-bearing and must never change:**
in one canonical place — **this file's "Repository layout"** (repo), **client/CLAUDE.md "Project `Seams:FieldEncryption:Key` / `:HashKey` decrypt all existing PII and derive the phone-lookup hash.
Structure"** (frontend), **server/CLAUDE.md "Project map"** (backend). When a change alters that 7. **Keep docs honest, and keep the architecture map current.** If you change how something works, update the
structure — adds, removes, or renames a project, layer, route group, provider, or major folder, or doc that describes it in the **same** change. Each level documents its architecture in one canonical place —
changes a cross-project / cross-layer boundary — update the matching architecture section in the **this file's "Repository layout"** (repo), **client/CLAUDE.md "Project structure"** (frontend),
same change. Stale instructions are worse than none. **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.** 8. **Write clean, self-documenting code.**
- **No dead code.** Remove unused variables, imports/usings, parameters, and private members — - **No dead code.** Remove unused variables, imports/usings, parameters, and private members — don't leave
don't leave them behind and don't suppress the warning. The client enforces this with ESLint 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 (`@typescript-eslint/no-unused-vars` as an *error*); on the server they are build warnings and the gate is
the gate is zero new warnings. Per-project specifics live in each project's `CLAUDE.md` / zero new warnings.
`CONVENTIONS.md`. - **Comment the *why*, not the *what*.** Don't write verbose comments that restate what the code already
- **Comment the *why*, not the *what*.** Don't write verbose comments that restate what the code says. Add a comment only where a non-obvious decision, constraint, business rule, or trade-off isn't
already says. Add a comment only where a non-obvious decision, constraint, business rule, or evident from the code itself. Prefer a clearer name over a comment.
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 ## Naming
- The **server**'s C# namespaces, projects, and solution all use the `Baya*` prefix - The **server**'s C# namespaces, projects, and solution all use the `Baya*` prefix (`Baya.Web.Api`,
(`Baya.Web.Api`, `Baya.sln`). Keep new server code under the `Baya.*` convention. `Baya.sln`). Keep new server code under the `Baya.*` convention.
- The **client** package is `balinyaar-client`; the `@/*` import alias maps to `client/src/*`. - 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 — The product/brand name is **Balinyaar** — «بالین‌یار» in Persian copy, with a ZWNJ, always. The server's
do not rename it without explicit instruction. `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 cd client && npm install && npm run dev # http://localhost:3000
# Backend # 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 A first, shareable deployment of the whole stack under **balinyaar.ir**, in Docker, behind an existing
Caddy reverse proxy that terminates TLS. 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 | | Host | Serves | Container |
| --- | --- | --- | | --- | --- | --- |
| `balinyaar.ir`, `www.balinyaar.ir` | Next.js web client | `balinyaar-web:3000` | | `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` | | 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` | | 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 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 that actually loads. There is **no `appsettings.Production.json` in the repo at all**, and adding one would
file, or change the environment name first. 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 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 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 ### Going to Production later
1. Set `ASPNETCORE_ENVIRONMENT: Production` in `docker-compose.yml`. 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 `IdentitySettings:SecretKey` / `Encryptkey``StartupSecretsGuard` rejects anything containing
`not-for-production` outside Development, so the current dev keys will refuse to boot (by design). `not-for-production` outside Development, so the current dev keys will refuse to boot (by design).
Keep `Seams:FieldEncryption` byte-identical. 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 # 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 # 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). # bounded-canvas grid fallback (dev/CI/jsdom all work without a key).
# NEXT_PUBLIC_NESHAN_KEY = your-neshan-web-key # NEXT_PUBLIC_NESHAN_KEY = your-neshan-web-key
+6 -3
View File
@@ -1,10 +1,13 @@
# AGENTS.md — Balinyaar Web Client # AGENTS.md — Balinyaar Web Client
The canonical agent guide for the frontend is **[CLAUDE.md](CLAUDE.md)** (same folder). It is the The canonical agent guide for the frontend is **[CLAUDE.md](CLAUDE.md)** (same folder): stack,
engineering contract: stack, commands, lint/type gates, routing, providers, data fetching, theming, commands, the quality gates, the project structure, and the hard rules every change must follow.
i18n, cookies, and the 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) - 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) - Human setup/run instructions → [README.md](README.md)
- UI/design work → the **frontend-designer** skill - 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", "payouts": "Payouts",
"reviews": "Reviews", "reviews": "Reviews",
"config": "Configuration", "config": "Configuration",
"catalog": "Catalog",
"holidays": "Holidays", "holidays": "Holidays",
"alerts": "Alerts", "alerts": "Alerts",
"audit": "Audit log", "audit": "Audit log",
@@ -309,6 +310,10 @@
"line_hint": "Building, street, unit — the detail a nurse needs to find the door.", "line_hint": "Building, street, unit — the detail a nurse needs to find the door.",
"line_required": "Enter the street address", "line_required": "Enter the street address",
"city_required": "Select a city", "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", "set_primary_toggle": "Set as primary address",
"map_hint": "Tap or drag the marker to the patient's exact location.", "map_hint": "Tap or drag the marker to the patient's exact location.",
"map_required": "Drop a pin on the map", "map_required": "Drop a pin on the map",
@@ -474,6 +479,7 @@
"unnamed_nurse": "Nurse", "unnamed_nurse": "Nurse",
"unnamed_service": "Service", "unnamed_service": "Service",
"completed_visits": "{count, number} successful visits", "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}})", "reviews_count": "({count, plural, =0 {no reviews} one {# review} other {# reviews}})",
"distance_km": "{km} km", "distance_km": "{km} km",
"price_from": "from", "price_from": "from",
@@ -773,6 +779,11 @@
"total_payable_label": "Total to pay", "total_payable_label": "Total to pay",
"secure_gateway_notice": "Secure payment via bank gateway", "secure_gateway_notice": "Secure payment via bank gateway",
"cta_pay": "Continue to payment", "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", "bnpl_option": "Or pay in installments",
"state_initiating": "Starting payment…", "state_initiating": "Starting payment…",
"state_redirecting": "Redirecting to the payment gateway…", "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_bank_hint": "When on, payouts falling on this day shift to the next business day.",
"hol_saved": "Holiday saved.", "hol_saved": "Holiday saved.",
"hol_year": "Year", "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_title": "Support alerts",
"alert_subtitle": "Internal triage — never shown to customers or nurses.", "alert_subtitle": "Internal triage — never shown to customers or nurses.",
"alert_empty": "No open alerts.", "alert_empty": "No open alerts.",
@@ -2100,6 +2150,7 @@
"admin_tickets_sub": "The global ticket queue", "admin_tickets_sub": "The global ticket queue",
"admin_alerts_sub": "The internal alert worklist", "admin_alerts_sub": "The internal alert worklist",
"admin_config_sub": "Config keys and their change history", "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_holidays_sub": "The bank-holiday calendar",
"admin_audit_sub": "Read-only record of every change", "admin_audit_sub": "Read-only record of every change",
"admin_partners_sub": "Partner centers and sponsored nurses", "admin_partners_sub": "Partner centers and sponsored nurses",
+51
View File
@@ -23,6 +23,7 @@
"payouts": "تسویه‌ها", "payouts": "تسویه‌ها",
"reviews": "نظرات", "reviews": "نظرات",
"config": "پیکربندی", "config": "پیکربندی",
"catalog": "کاتالوگ",
"holidays": "تعطیلات", "holidays": "تعطیلات",
"alerts": "هشدارها", "alerts": "هشدارها",
"audit": "گزارش ممیزی", "audit": "گزارش ممیزی",
@@ -309,6 +310,10 @@
"line_hint": "پلاک، خیابان، واحد — جزئیاتی که پرستار برای یافتن درِ منزل نیاز دارد.", "line_hint": "پلاک، خیابان، واحد — جزئیاتی که پرستار برای یافتن درِ منزل نیاز دارد.",
"line_required": "نشانی کامل را وارد کنید", "line_required": "نشانی کامل را وارد کنید",
"city_required": "شهر را انتخاب کنید", "city_required": "شهر را انتخاب کنید",
"recipient_name_label": "نام تحویل‌گیرنده",
"recipient_name_required": "نام فردی که پرستار باید بپرسد را وارد کنید",
"recipient_phone_label": "شماره تماس تحویل‌گیرنده",
"recipient_phone_invalid": "یک شماره موبایل معتبر ایرانی وارد کنید",
"set_primary_toggle": "به‌عنوان آدرس اصلی تنظیم شود", "set_primary_toggle": "به‌عنوان آدرس اصلی تنظیم شود",
"map_hint": "برای تعیین محل دقیق بیمار، نشانگر را بکشید یا روی نقشه بزنید.", "map_hint": "برای تعیین محل دقیق بیمار، نشانگر را بکشید یا روی نقشه بزنید.",
"map_required": "روی نقشه یک پین بگذارید", "map_required": "روی نقشه یک پین بگذارید",
@@ -474,6 +479,7 @@
"unnamed_nurse": "پرستار", "unnamed_nurse": "پرستار",
"unnamed_service": "خدمت", "unnamed_service": "خدمت",
"completed_visits": "{count, number} ویزیت موفق", "completed_visits": "{count, number} ویزیت موفق",
"more_services_count": "+{count, plural, one {# خدمت دیگر} other {# خدمت دیگر}}",
"reviews_count": "({count, plural, =0 {بدون نظر} one {# نظر} other {# نظر}})", "reviews_count": "({count, plural, =0 {بدون نظر} one {# نظر} other {# نظر}})",
"distance_km": "{km} کیلومتر", "distance_km": "{km} کیلومتر",
"price_from": "از", "price_from": "از",
@@ -773,6 +779,11 @@
"total_payable_label": "مبلغ قابل پرداخت", "total_payable_label": "مبلغ قابل پرداخت",
"secure_gateway_notice": "پرداخت امن از طریق درگاه بانکی", "secure_gateway_notice": "پرداخت امن از طریق درگاه بانکی",
"cta_pay": "ادامه پرداخت", "cta_pay": "ادامه پرداخت",
"gateway_title": "در حال انتقال به درگاه پرداخت",
"gateway_hint": "این صفحه نمایشی جایگزین درگاه بانکی است.",
"gateway_reference_label": "کد مرجع",
"gateway_pay_success": "پرداخت (نمایشی)",
"gateway_pay_fail": "انصراف",
"bnpl_option": "یا پرداخت اقساطی", "bnpl_option": "یا پرداخت اقساطی",
"state_initiating": "در حال آغاز پرداخت…", "state_initiating": "در حال آغاز پرداخت…",
"state_redirecting": "در حال انتقال به درگاه پرداخت…", "state_redirecting": "در حال انتقال به درگاه پرداخت…",
@@ -1683,6 +1694,45 @@
"hol_bank_hint": "در صورت فعال بودن، تسویه‌های این روز به روز کاری بعد منتقل می‌شوند.", "hol_bank_hint": "در صورت فعال بودن، تسویه‌های این روز به روز کاری بعد منتقل می‌شوند.",
"hol_saved": "تعطیلی ذخیره شد.", "hol_saved": "تعطیلی ذخیره شد.",
"hol_year": "سال", "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_title": "هشدارهای پشتیبانی",
"alert_subtitle": "صف داخلی — هرگز به مشتری یا پرستار نمایش داده نمی‌شود.", "alert_subtitle": "صف داخلی — هرگز به مشتری یا پرستار نمایش داده نمی‌شود.",
"alert_empty": "هشداری برای رسیدگی نیست.", "alert_empty": "هشداری برای رسیدگی نیست.",
@@ -2100,6 +2150,7 @@
"admin_tickets_sub": "صف سراسری تیکت‌ها", "admin_tickets_sub": "صف سراسری تیکت‌ها",
"admin_alerts_sub": "کارتابل داخلی هشدارها", "admin_alerts_sub": "کارتابل داخلی هشدارها",
"admin_config_sub": "کلیدهای پیکربندی و تاریخچهٔ تغییرها", "admin_config_sub": "کلیدهای پیکربندی و تاریخچهٔ تغییرها",
"admin_catalog_sub": "دسته‌بندی‌های خدمات و ابعاد قیمت‌گذاری آن‌ها",
"admin_holidays_sub": "تقویم تعطیلات بانکی", "admin_holidays_sub": "تقویم تعطیلات بانکی",
"admin_audit_sub": "گزارش تغییرها، فقط‌خواندنی", "admin_audit_sub": "گزارش تغییرها، فقط‌خواندنی",
"admin_partners_sub": "مراکز همکار و پرستاران تحت پوشش", "admin_partners_sub": "مراکز همکار و پرستاران تحت پوشش",
+4 -1
View File
@@ -1,8 +1,11 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* Lints client/messages/fa.json against the banned-orthography-variant rules in * 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. * 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 { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@@ -172,6 +172,8 @@ export default function AddressesPage() {
latitude: editing.latitude, latitude: editing.latitude,
longitude: editing.longitude, longitude: editing.longitude,
isPrimary: editing.isPrimary, isPrimary: editing.isPrimary,
recipientName: editing.recipientName,
recipientPhone: editing.recipientPhone,
} }
: undefined : 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; 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) => { const save = (patch: Partial<Pick<FamilyCareRecord, 'medications' | 'routine' | 'tasks'>>, onDone: () => void) => {
update.mutate(patch, { update.mutate(
onSuccess: () => { { medications: data.medications, routine: data.routine, tasks: data.tasks, ...patch },
enqueueSnackbar(t('saved'), { variant: 'success' }); {
onDone(); onSuccess: () => {
enqueueSnackbar(t('saved'), { variant: 'success' });
onDone();
},
onError: () => enqueueSnackbar(t('save_error'), { variant: 'error' }),
}, },
onError: () => enqueueSnackbar(t('save_error'), { variant: 'error' }), );
});
}; };
if (tab === 'medications') { if (tab === 'medications') {
@@ -140,6 +140,9 @@ function ProfileHeader({ profile }: { profile: NurseProfile }) {
minimumFractionDigits: 1, minimumFractionDigits: 1,
maximumFractionDigits: 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 ( return (
<Stack sx={{ gap: 1.5 }}> <Stack sx={{ gap: 1.5 }}>
@@ -170,7 +173,7 @@ function ProfileHeader({ profile }: { profile: NurseProfile }) {
</Stack> </Stack>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}> <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 ? ( {profile.inoMembership ? (
<Chip <Chip
icon={<AppIcon icon="license" size={16} color="var(--bal-primary)" />} 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('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: '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('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('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('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 }, { 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, path: ROUTES.ADMIN_CONFIG,
enabled: caps.canConfig, enabled: caps.canConfig,
}, },
{
title: tn('catalog'),
subtitle: t('admin_catalog_sub'),
icon: 'category',
path: ROUTES.ADMIN_CATALOG,
enabled: caps.canManageCatalog,
},
{ {
title: tn('holidays'), title: tn('holidays'),
subtitle: t('admin_holidays_sub'), subtitle: t('admin_holidays_sub'),
@@ -75,7 +75,7 @@ function AdminVerificationCaseScreen() {
const caps = useAdminCapabilities(); const caps = useAdminCapabilities();
const { enqueueSnackbar } = useSnackbar(); const { enqueueSnackbar } = useSnackbar();
const { data, isLoading, isError, refetch } = useVerificationCase( const { data, isLoading, isError, isFetching, refetch } = useVerificationCase(
Number.isFinite(nurseVerificationId) ? nurseVerificationId : null, Number.isFinite(nurseVerificationId) ? nurseVerificationId : null,
); );
const approve = useApproveVerification(); const approve = useApproveVerification();
@@ -199,6 +199,8 @@ function AdminVerificationCaseScreen() {
step={step} step={step}
nurseVerificationId={nurseVerificationId} nurseVerificationId={nurseVerificationId}
canVerify={caps.canVerify} canVerify={caps.canVerify}
onReloadDocuments={refetch}
reloadingDocuments={isFetching}
/> />
))} ))}
</Stack> </Stack>
@@ -290,10 +292,14 @@ function StepCard({
step, step,
nurseVerificationId, nurseVerificationId,
canVerify, canVerify,
onReloadDocuments,
reloadingDocuments,
}: { }: {
step: AdminVerificationStepDetail; step: AdminVerificationStepDetail;
nurseVerificationId: number; nurseVerificationId: number;
canVerify: boolean; canVerify: boolean;
onReloadDocuments: () => void;
reloadingDocuments: boolean;
}) { }) {
const t = useTranslations('admin'); const t = useTranslations('admin');
const { enqueueSnackbar } = useSnackbar(); const { enqueueSnackbar } = useSnackbar();
@@ -339,7 +345,12 @@ function StepCard({
{step.documents.length > 0 ? ( {step.documents.length > 0 ? (
<Stack sx={{ gap: 1.5, mt: 1.5 }}> <Stack sx={{ gap: 1.5, mt: 1.5 }}>
{step.documents.map((doc) => ( {step.documents.map((doc) => (
<DocumentViewer key={doc.id} document={doc} /> <DocumentViewer
key={doc.id}
document={doc}
onReload={onReloadDocuments}
reloading={reloadingDocuments}
/>
))} ))}
</Stack> </Stack>
) : isManual ? ( ) : isManual ? (
@@ -24,7 +24,7 @@ const PREVIEW: CancellationPolicyPreview = {
bookingId: 5003, bookingId: 5003,
cancellable: true, cancellable: true,
cancellationPolicyCode: 'free_24h', cancellationPolicyCode: 'free_24h',
refundPercentageApplied: 1, refundPercentageApplied: 100,
feePercentage: 0, feePercentage: 0,
refundAmountIrr: '17600000', refundAmountIrr: '17600000',
feeAmountIrr: '0', feeAmountIrr: '0',
@@ -15,9 +15,10 @@ export interface CancellationPolicyDisclosureProps {
preview: CancellationPolicyPreview; preview: CancellationPolicyPreview;
} }
/** Percent (integer) from a 01 fraction — a small display number, never money, so JS math is safe. */ /** Rounds an already-0100 percent value for display a small display number, never money, so JS math is
function toPercent(fraction: number): number { * safe. Never re-multiply by 100: the server (and the mock) already serve this scale. */
return Math.round(fraction * 100); function toPercent(percent: number): number {
return Math.round(percent);
} }
/** /**
@@ -25,6 +25,7 @@ const NURSE: NurseSearchResult = {
nurseId: 1, nurseId: 1,
variantId: 11, variantId: 11,
serviceCategoryId: 1, serviceCategoryId: 1,
matchingServiceCount: 1,
nurseName: 'Maryam Rezaei', nurseName: 'Maryam Rezaei',
avatarUrl: null, avatarUrl: null,
isVerified: true, isVerified: true,
@@ -112,6 +113,14 @@ describe('<NurseResultCard/> component', () => {
expect(screen.getByText(/منظم و دقیق/)).toBeInTheDocument(); 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)', () => { it('falls back to a label when the name is missing (b7 join gap)', () => {
renderCard({ ...NURSE, nurseName: '' }); renderCard({ ...NURSE, nurseName: '' });
expect(screen.getByText('unnamed_nurse')).toBeInTheDocument(); expect(screen.getByText('unnamed_nurse')).toBeInTheDocument();
@@ -84,11 +84,12 @@ const NurseResultCard = ({ nurse, serviceLabel, onSelect }: NurseResultCardProps
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}> <Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{name} {name}
</Typography> </Typography>
<TrustBadge state="verified" nurseId={nurse.nurseId} /> <TrustBadge state={nurse.isVerified ? 'verified' : 'unverified'} nurseId={nurse.nurseId} />
</Stack> </Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}> <Typography variant="body2" sx={{ color: 'text.secondary' }}>
{serviceLabel} {serviceLabel}
{nurse.matchingServiceCount > 1 ? ` · ${t('more_services_count', { count: nurse.matchingServiceCount - 1 })}` : ''}
</Typography> </Typography>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap', mt: 0.25 }}> <Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap', mt: 0.25 }}>
@@ -24,7 +24,7 @@ const base: RefundSummary = {
totalRefundedIrr: '45000000', totalRefundedIrr: '45000000',
expectedCustomerRefundEta: null, expectedCustomerRefundEta: null,
externalRevertReference: null, externalRevertReference: null,
refundPercentageApplied: 1, refundPercentageApplied: 100,
cancellationPolicyCode: 'free_24h', cancellationPolicyCode: 'free_24h',
platformFeeRefundedIrr: '5400000', platformFeeRefundedIrr: '5400000',
nursePayoutRefundedIrr: '39600000', nursePayoutRefundedIrr: '39600000',
@@ -3,9 +3,6 @@ import { ThemeProvider } from '../../theme';
jest.mock('next-intl', () => ({ useTranslations: () => (k: string) => k, useLocale: () => 'en' })); 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 DocumentViewer from './DocumentViewer';
import type { VerificationDocument } from '@/services/verification/types'; import type { VerificationDocument } from '@/services/verification/types';
@@ -14,35 +11,28 @@ const DOC: VerificationDocument = {
contentType: 'image/png', contentType: 'image/png',
fileSizeBytes: 2048, fileSizeBytes: 2048,
originalFileName: 'license.png', originalFileName: 'license.png',
url: 'ignored-embedded-url', url: 'https://signed/fresh.png',
}; };
describe('<DocumentViewer/>', () => { describe('<DocumentViewer/>', () => {
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>); 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)', () => { it('renders the image from the case-embedded signed 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} onReload={jest.fn()} reloading={false} />);
const { container } = wrap(<DocumentViewer document={DOC} />);
const img = container.querySelector('img') as HTMLImageElement; const img = container.querySelector('img') as HTMLImageElement;
expect(img).toBeTruthy(); expect(img).toBeTruthy();
expect(img.src).toContain('signed/fresh.png'); 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', () => { it('calls onReload — there is no per-document endpoint, so reload refetches the parent case', () => {
const refetch = jest.fn(); const onReload = jest.fn();
mockUseDocUrl.mockReturnValue({ data: undefined, isLoading: false, isFetching: false, isError: true, refetch }); wrap(<DocumentViewer document={DOC} onReload={onReload} reloading={false} />);
wrap(<DocumentViewer document={DOC} />); fireEvent.click(screen.getByText('doc_reload'));
expect(screen.getByText('doc_error')).toBeInTheDocument(); expect(onReload).toHaveBeenCalled();
// Two re-request buttons (header + error panel); click the first.
fireEvent.click(screen.getAllByText('doc_reload')[0]);
expect(refetch).toHaveBeenCalled();
}); });
it('shows a skeleton while the signed url is loading', () => { it('shows a skeleton while the parent case is (re)loading', () => {
mockUseDocUrl.mockReturnValue({ data: undefined, isLoading: true, isFetching: true, isError: false, refetch: jest.fn() }); const { container } = wrap(<DocumentViewer document={DOC} onReload={jest.fn()} reloading />);
const { container } = wrap(<DocumentViewer document={DOC} />);
expect(container.querySelector('.MuiSkeleton-root')).toBeInTheDocument(); expect(container.querySelector('.MuiSkeleton-root')).toBeInTheDocument();
}); });
}); });
+17 -23
View File
@@ -3,12 +3,16 @@ import { FunctionComponent } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Box, Skeleton, Stack, Typography } from '@mui/material'; import { Box, Skeleton, Stack, Typography } from '@mui/material';
import type { VerificationDocument } from '@/services/verification/types'; import type { VerificationDocument } from '@/services/verification/types';
import { useVerificationDocumentUrl } from '@/services/verification';
import AppButton from '../common/AppButton'; import AppButton from '../common/AppButton';
import AppIcon from '../common/AppIcon'; import AppIcon from '../common/AppIcon';
export interface DocumentViewerProps { export interface DocumentViewerProps {
document: VerificationDocument; 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. */ /** 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 * A verification-document viewer. `document.url` is the **short-lived signed GET URL the case detail
* are short-lived) via `useVerificationDocumentUrl`. Handles the full lifecycle: **loading** the link, * already carries** there is no separate per-document re-sign route (b6 gap), so "reload" refetches the
* **error / expired re-request** (the URL is short-lived, so a manual re-request re-signs it), and the * whole case (`onReload`) rather than calling a document-specific endpoint. Handles the loading (parent
* loaded state (inline image preview for images, otherwise an "open in a new tab" affordance). PII only * case fetching) and loaded states (inline image preview for images, otherwise an "open in a new tab"
* the signed URL is ever surfaced, never a public asset (phase §5). * affordance). PII only the signed URL is ever surfaced, never a public asset (phase §5).
* @component DocumentViewer * @component DocumentViewer
*/ */
const DocumentViewer: FunctionComponent<DocumentViewerProps> = ({ document }) => { const DocumentViewer: FunctionComponent<DocumentViewerProps> = ({ document, onReload, reloading }) => {
const t = useTranslations('admin'); const t = useTranslations('admin');
const signed = useVerificationDocumentUrl(document.id);
const isImage = document.contentType.startsWith('image/'); const isImage = document.contentType.startsWith('image/');
return ( return (
@@ -45,36 +48,27 @@ const DocumentViewer: FunctionComponent<DocumentViewerProps> = ({ document }) =>
variant="text" variant="text"
color="primary" color="primary"
startIcon="refresh" startIcon="refresh"
onClick={() => signed.refetch()} onClick={onReload}
disabled={signed.isFetching} disabled={reloading}
sx={{ minWidth: 0 }} sx={{ minWidth: 0 }}
> >
{t('doc_reload')} {t('doc_reload')}
</AppButton> </AppButton>
</Stack> </Stack>
{signed.isLoading || signed.isFetching ? ( {reloading ? (
<Skeleton variant="rounded" height={isImage ? 180 : 44} /> <Skeleton variant="rounded" height={isImage ? 180 : 44} />
) : signed.isError ? ( ) : document.url ? (
<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 ? (
isImage ? ( isImage ? (
// Signed, short-lived, cross-host URL (not a static asset) — a plain <img> via Box, not next/image. // Signed, short-lived, cross-host URL (not a static asset) — a plain <img> via Box, not next/image.
<Box <Box
component="img" component="img"
src={signed.data.url} src={document.url}
alt={document.originalFileName ?? String(document.id)} alt={document.originalFileName ?? String(document.id)}
sx={{ maxWidth: '100%', maxHeight: 320, borderRadius: 'var(--bal-radius-sm)', display: 'block' }} 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')} {t('doc_open_new')}
</AppButton> </AppButton>
) )
@@ -51,6 +51,8 @@ describe('<AddressForm/> component', () => {
latitude: 35.7, latitude: 35.7,
longitude: 51.4, longitude: 51.4,
isPrimary: false, isPrimary: false,
recipientName: 'Sara',
recipientPhone: '09120001234',
}} }}
onSubmit={onSubmit} onSubmit={onSubmit}
/> />
@@ -67,6 +69,8 @@ describe('<AddressForm/> component', () => {
latitude: 35.7, latitude: 35.7,
longitude: 51.4, longitude: 51.4,
isPrimary: false, isPrimary: false,
recipientName: 'Sara',
recipientPhone: '09120001234',
}); });
}); });
}); });
@@ -7,6 +7,7 @@ import Stack from '@mui/material/Stack';
import Switch from '@mui/material/Switch'; import Switch from '@mui/material/Switch';
import { AppButton } from '@/components/common'; import { AppButton } from '@/components/common';
import { RhfControlGroup, RhfTextField } from '@/components/common/form'; import { RhfControlGroup, RhfTextField } from '@/components/common/form';
import PhoneNumberField, { isIranianMobile } from '@/components/PhoneNumberField';
import { cityCentroid } from '@/services/geography/constants'; import { cityCentroid } from '@/services/geography/constants';
import type { CreateAddressInput, LatLng } from '@/services/addresses/types'; import type { CreateAddressInput, LatLng } from '@/services/addresses/types';
import CascadingRegionSelect, { type CascadingRegionValue } from './CascadingRegionSelect'; import CascadingRegionSelect, { type CascadingRegionValue } from './CascadingRegionSelect';
@@ -22,6 +23,8 @@ export interface AddressFormInitial {
latitude?: number | null; latitude?: number | null;
longitude?: number | null; longitude?: number | null;
isPrimary?: boolean; isPrimary?: boolean;
recipientName?: string | null;
recipientPhone?: string | null;
} }
export interface AddressFormProps { export interface AddressFormProps {
@@ -39,6 +42,8 @@ interface AddressFormValues {
pin: LatLng | null; pin: LatLng | null;
addressLine: string; addressLine: string;
isPrimary: boolean; isPrimary: boolean;
recipientName: string;
recipientPhone: string;
} }
// Only prefill the region when we have the province — a city id without its province can't drive // 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), pin: initialPin(initial),
addressLine: initial?.addressLine ?? '', addressLine: initial?.addressLine ?? '',
isPrimary: initial?.isPrimary ?? false, isPrimary: initial?.isPrimary ?? false,
recipientName: initial?.recipientName ?? '',
recipientPhone: initial?.recipientPhone ?? '',
}, },
}); });
const { control, handleSubmit, formState } = form; const { control, handleSubmit, formState } = form;
@@ -98,6 +105,8 @@ const AddressForm: FunctionComponent<AddressFormProps> = ({ initial, submitting
latitude: (values.pin as LatLng).latitude, latitude: (values.pin as LatLng).latitude,
longitude: (values.pin as LatLng).longitude, longitude: (values.pin as LatLng).longitude,
isPrimary: values.isPrimary, isPrimary: values.isPrimary,
recipientName: values.recipientName.trim(),
recipientPhone: values.recipientPhone,
}); });
}; };
@@ -153,6 +162,29 @@ const AddressForm: FunctionComponent<AddressFormProps> = ({ initial, submitting
fullWidth 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"> <RhfControlGroup<AddressFormValues> name="isPrimary">
{({ field }) => ( {({ field }) => (
<FormControlLabel <FormControlLabel
+8
View File
@@ -27,6 +27,8 @@ export const ROUTES = {
BOOKING_REQUEST_STATUS: '/bookings/request', BOOKING_REQUEST_STATUS: '/bookings/request',
// Checkout (f9) — C6 summary & pay; the C5 accept CTA hands off here with `?request_id=`. // Checkout (f9) — C6 summary & pay; the C5 accept CTA hands off here with `?request_id=`.
CHECKOUT: '/bookings/checkout', 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. // Return-from-gateway surface — pending-callback poll → succeeded/failed states.
CHECKOUT_RETURN: '/bookings/checkout/return', CHECKOUT_RETURN: '/bookings/checkout/return',
// Post-payment success screen — links to the booking detail + invoice. // Post-payment success screen — links to the booking detail + invoice.
@@ -108,6 +110,8 @@ export const ROUTES = {
ADMIN_REVIEWS: '/admin/reviews', ADMIN_REVIEWS: '/admin/reviews',
// Platform config editor + change history. // Platform config editor + change history.
ADMIN_CONFIG: '/admin/config', 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). // Iranian-holiday calendar manager (drives payout scheduling; is_bank_closed toggle).
ADMIN_HOLIDAYS: '/admin/holidays', ADMIN_HOLIDAYS: '/admin/holidays',
// Support-alert triage board — internal-only (assign/resolve). // 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 => export const adminPartnerCenterPath = (centerId: number | string): string =>
`${ROUTES.ADMIN_PARTNERS}/${centerId}`; `${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). */ /** The partner-portal sponsored-booking detail (f15) — a bookings-list row deep-links here (read-only). */
export const partnerBookingDetailPath = (bookingId: number | string): string => export const partnerBookingDetailPath = (bookingId: number | string): string =>
`${ROUTES.PARTNER_BOOKINGS}/${bookingId}`; `${ROUTES.PARTNER_BOOKINGS}/${bookingId}`;
+2
View File
@@ -23,6 +23,7 @@ export interface AdminCapabilities {
canPayout: boolean; canPayout: boolean;
canModerate: boolean; canModerate: boolean;
canConfig: boolean; canConfig: boolean;
canManageCatalog: boolean;
canManageAlerts: boolean; canManageAlerts: boolean;
canManageTickets: boolean; canManageTickets: boolean;
canManagePartners: boolean; canManagePartners: boolean;
@@ -57,6 +58,7 @@ export function useAdminCapabilities(): AdminCapabilities {
canPayout: has(roles, 'super_admin', 'admin', 'finance'), canPayout: has(roles, 'super_admin', 'admin', 'finance'),
canModerate: has(roles, 'super_admin', 'admin', 'moderation'), canModerate: has(roles, 'super_admin', 'admin', 'moderation'),
canConfig: has(roles, 'super_admin', 'admin', 'finance'), canConfig: has(roles, 'super_admin', 'admin', 'finance'),
canManageCatalog: has(roles, 'super_admin', 'admin'),
canManageAlerts: has(roles, 'super_admin', 'admin', 'support'), canManageAlerts: has(roles, 'super_admin', 'admin', 'support'),
canManageTickets: has(roles, 'super_admin', 'admin', 'support'), canManageTickets: has(roles, 'super_admin', 'admin', 'support'),
canManagePartners: has(roles, 'super_admin', 'admin'), 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` * When true, the BNPL domain is served by the in-memory mock (`apis/mockApi.ts`) behind the `BnplApi`
* seam. * seam.
* *
* **Mock is primary this phase.** b12 ships the eligibility/initiate/webhook/settle endpoints server-side, * **Mock is still primary** (blocker-phase 05 landed Bug A the seeded BNPL gateway but not Bug B):
* but the D1D5 checkout cannot run real end-to-end from the client yet: * the D1 wizard hard-gates on `getBnplOptions` (`GET checkout_bnpl/options/{id}`), and `getBnplSchedule` /
* - the accepted request being financed comes from the **mock-primary** `bookingRequests` store (f7), so a * `getWalletInstallments` have no server counterpart either `CheckoutBnplController` only ever grew
* real `initiate` would reference an id that exists only in memory (same reason f9 payment is mock-primary); * `eligibility` / `initiate` / `GET {id}` / `by_request/{id}` (REQ-022/023/024 never landed). Flipping this
* - the contract serves **no provider/plan options** (D1/D2), **no repayment schedule** (D4 the contract * flag today trades the old "mock store ages out" failure for an immediate `isError` on D1. Flip once those
* explicitly does not model the customer's repayment schedule), and **no provider-reported installment * three endpoints exist server-side no hook/component change needed at that point.
* 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.
*/ */
export const USE_BNPL_MOCK = true; export const USE_BNPL_MOCK = true;
+78 -1
View File
@@ -1,17 +1,23 @@
import { clientFetch } from '@/lib/api/client'; import { clientFetch } from '@/lib/api/client';
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types'; 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 { import type {
CatalogApi, CatalogApi,
CategoryInput,
CreateOptionValueInput,
CreateVariantInput, CreateVariantInput,
NurseServiceVariant, NurseServiceVariant,
OptionGroupInput,
ServiceCategory, ServiceCategory,
ServiceOptionGroup, ServiceOptionGroup,
ServiceOptionValue,
UpdateOptionValueInput,
UpdateVariantInput, UpdateVariantInput,
} from '../types'; } from '../types';
const CATALOG_BASE = '/api/v1/catalog'; const CATALOG_BASE = '/api/v1/catalog';
const VARIANTS_BASE = '/api/v1/nurse_variants'; 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`). * 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 }), 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 { sleep } from '@/utils';
import { ApiError } from '@/lib/api/errors'; import { ApiError } from '@/lib/api/errors';
import type { PageParams, Paginated } from '@/lib/api/types'; 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 { import {
isGroupApplicable, isGroupApplicable,
optionSetSignature, optionSetSignature,
type CatalogApi, type CatalogApi,
type CategoryInput,
type CreateOptionValueInput,
type CreateVariantInput, type CreateVariantInput,
type NurseServiceVariant, type NurseServiceVariant,
type OptionGroupInput,
type ServiceCategory, type ServiceCategory,
type ServiceOptionGroup, type ServiceOptionGroup,
type ServiceOptionValue, type ServiceOptionValue,
type UpdateOptionValueInput,
type UpdateVariantInput, type UpdateVariantInput,
type VariantOption, type VariantOption,
type VariantOptionSelection, type VariantOptionSelection,
@@ -27,6 +31,14 @@ const bySortOrder = <T extends { sortOrder: number }>(a: T, b: T) => a.sortOrder
let store: NurseServiceVariant[] = []; let store: NurseServiceVariant[] = [];
let nextVariantId = 1; 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. */ /** Active-first (contract list order), then most-recent within each group. */
const orderedVariants = () => const orderedVariants = () =>
[...store].sort((a, b) => Number(b.isActive) - Number(a.isActive) || b.id - a.id); [...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[] { function applicableGroups(categoryId: number): ServiceOptionGroup[] {
return SEED_OPTION_GROUPS.filter((group) => group.isActive && isGroupApplicable(group, categoryId)).sort( return groupStore.filter((group) => group.isActive && isGroupApplicable(group, categoryId)).sort(bySortOrder);
bySortOrder,
);
} }
function findValue(group: ServiceOptionGroup, valueId: number): ServiceOptionValue | undefined { function findValue(group: ServiceOptionGroup, valueId: number): ServiceOptionValue | undefined {
@@ -128,7 +138,7 @@ function assertValidCreate(category: ServiceCategory, groups: ServiceOptionGroup
export const catalogMockApi: CatalogApi = { export const catalogMockApi: CatalogApi = {
listCategories: async (params) => { listCategories: async (params) => {
await sleep(MOCK_LATENCY_MS); 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); return paginate(active, params, CATEGORIES_PAGE_SIZE);
}, },
@@ -155,7 +165,7 @@ export const catalogMockApi: CatalogApi = {
createVariant: async (input) => { createVariant: async (input) => {
await sleep(MOCK_LATENCY_MS); 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'); if (!category) throw new ApiError(400, 'Missing or inactive category', 'invalid_category');
const groups = applicableGroups(category.id); const groups = applicableGroups(category.id);
@@ -205,4 +215,92 @@ export const catalogMockApi: CatalogApi = {
if (!existing) throw new ApiError(404, 'Variant not found', 'not_found'); if (!existing) throw new ApiError(404, 'Variant not found', 'not_found');
store = store.map((candidate) => (candidate.id === id ? { ...candidate, isActive } : candidate)); 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. */ /** api-conventions default/max page sizes. A nurse has a handful of offerings; categories are few. */
export const CATEGORIES_PAGE_SIZE = 50; export const CATEGORIES_PAGE_SIZE = 50;
export const MY_VARIANTS_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 { useCreateVariant } from './hooks/useCreateVariant';
export { useUpdateVariant } from './hooks/useUpdateVariant'; export { useUpdateVariant } from './hooks/useUpdateVariant';
export { useSetVariantActive } from './hooks/useSetVariantActive'; 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. // Reference data — cached for the whole session, never invalidated by this phase.
categories: () => [...catalogKeys.all, 'categories'] as const, 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) => 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. // The nurse's offerings — mutable; mutations invalidate the `myVariantsLists()` prefix.
variants: () => [...catalogKeys.all, 'variants'] as const, variants: () => [...catalogKeys.all, 'variants'] as const,
myVariantsLists: () => [...catalogKeys.variants(), 'mine'] as const, myVariantsLists: () => [...catalogKeys.variants(), 'mine'] as const,
myVariants: (params?: PageParams) => [...catalogKeys.myVariantsLists(), params ?? {}] as const, myVariants: (params?: PageParams) => [...catalogKeys.myVariantsLists(), params ?? {}] as const,
variant: (id: number) => [...catalogKeys.variants(), 'detail', id] 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; 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 * 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. * 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>; updateVariant(id: number, input: UpdateVariantInput): Promise<NurseServiceVariant>;
/** Soft deactivate/reactivate — never a hard delete. */ /** Soft deactivate/reactivate — never a hard delete. */
setVariantActive(id: number, isActive: boolean): Promise<void>; 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; nurseProfileId: number;
nurseName: string | null; nurseName: string | null;
body: string; body: string;
taskResults: TaskResult[];
recordedAt: string; recordedAt: string;
} }
@@ -33,42 +34,27 @@ function toVisitNote(w: CareRecordWire): VisitNote {
nurseProfileId: w.nurseProfileId, nurseProfileId: w.nurseProfileId,
nurseDisplayName: w.nurseName, nurseDisplayName: w.nurseName,
body: w.body, body: w.body,
// The wire body carries only free text; the structured checklist is composed into it on write (see below). taskResults: w.taskResults,
taskResults: [],
recordedAt: w.recordedAt, recordedAt: w.recordedAt,
}; };
} }
/** /**
* Folds the nurse's ticked task checklist into the free-text note body, because the wire * Real HTTP implementation of the `PatientRecordsApi` seam (b14 contract). Every method maps a real route:
* `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:
* - `getPatientHistory` `GET patients/{id}/care_records` (the patient-scoped, newest-first note history; * - `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). * 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). * - `createVisitNote` `POST patients/{id}/care_records` (a nurse appends one encrypted note).
* * - `getRecordAccess` `GET patients/{id}/record_access`.
* The family-record + access methods target contract gaps the frontend filed (**REQ-027**) no wire * - `getFamilyRecord`/`updateFamilyRecord` `GET`/`PUT patients/{id}/care_record` the routes exist, but
* endpoint exists which is why the domain stays mock-primary (see `constants.ts`). * `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`). * NOT the primary implementation this phase (`USE_PATIENT_RECORDS_MOCK = true`).
*/ */
export const patientRecordsClientApi: PatientRecordsApi = { 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> => getFamilyRecord: async (patientId: number): Promise<FamilyCareRecord> =>
unwrap(await clientFetch<ApiEnvelope<FamilyCareRecord>>(`${API}/patients/${patientId}/care_record`)), 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> => getRecordAccess: async (patientId: number): Promise<RecordAccess> =>
unwrap(await clientFetch<ApiEnvelope<RecordAccess>>(`${API}/patients/${patientId}/record_access`)), 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) }; return { ...page, items: page.items.map(toVisitNote) };
}, },
// REQ-027: proposed customer edit of the family record.
updateFamilyRecord: async (patientId: number, body: UpdateFamilyRecordRequest): Promise<FamilyCareRecord> => updateFamilyRecord: async (patientId: number, body: UpdateFamilyRecordRequest): Promise<FamilyCareRecord> =>
unwrap( unwrap(
await clientFetch<ApiEnvelope<FamilyCareRecord>>(`${API}/patients/${patientId}/care_record`, { await clientFetch<ApiEnvelope<FamilyCareRecord>>(`${API}/patients/${patientId}/care_record`, {
@@ -99,7 +84,8 @@ export const patientRecordsClientApi: PatientRecordsApi = {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
bookingId: body.bookingId ?? null, 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'; } from '../types';
/** /**
* In-memory `PatientRecordsApi` **the primary implementation this phase** (the nurse-authored visit-note * In-memory `PatientRecordsApi` **the primary implementation this phase**. Every route this seam needs
* history/append are real b14, but the family-owned medications/routine/tasks record + the access check are * exists on the server; the domain stays mock-primary because the family-owned medications/routine/tasks
* REQ-027 gaps see `constants.ts`). * 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 * 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: * 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 { function assertAccess(patientId: number): void {
// The real 403 comes from the server clinical-access check; the mock denies a designated foreign patient. // The real 403 comes from the server clinical-access check; the mock denies a designated foreign patient.
if (patientId === MOCK_FOREIGN_PATIENT_ID) { 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> => { getRecordAccess: async (patientId: number): Promise<RecordAccess> => {
await sleep(MOCK_LATENCY_MS); await sleep(MOCK_LATENCY_MS);
if (patientId === MOCK_FOREIGN_PATIENT_ID) { 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 // 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). // 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 * When true, the patient-records domain is served by the in-memory mock (`apis/mockApi.ts`) behind the
* `PatientRecordsApi` seam. * `PatientRecordsApi` seam.
* *
* **Mock is primary this phase.** b14 serves the nurse-authored **visit-note history** (`care_records` * **Mock is primary the backend is fully built, but the shapes don't match yet.** `PatientCareRecordsController`
* GET/POST) those two methods are real but the **family-owned editable record** (medications/routine/ * implements every route this seam needs: `care_records` GET/POST (visit-note history/append), `care_record`
* tasks) and the **access check** have **no backend at all** (neither the contract nor the data model has * GET/PUT (the family care plan), and `record_access` GET. The blocker is a genuine schema mismatch, not a
* them; **REQ-027**). The mock seeds a default family record + a multi-nurse continuity history per patient, * missing endpoint see `mvp/fix-plan.md`'s "Patient records" follow-up: the client's medication/routine
* enforces a foreign-patient **access-denied** (403) path, and lets the nurse append notes that appear in the * shape (structured dose amount/unit, frequency preset codes, a multi-select `timeOfDay`) was built after the
* history. Flip to `false` once REQ-027 lands only `clientApi.ts`'s family-record/access methods flip; the * server shipped a simpler one (one free-text dose, one required frequency string, no `timeOfDay` at all),
* history/append methods already map the real routes. * 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; 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 * 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 * **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. * with a qualifying booking may **append** a note; nobody edits it.
* 2. **The family-owned editable record** (داروها/روتین/وظایف medications/routine/tasks) **NO backend * 2. **The family-owned editable record** (داروها/روتین/وظایف medications/routine/tasks) the backend
* exists** (neither the b14 contract nor the data model has it; **REQ-027**). The customer maintains it; * route exists (`GET`/`PUT patients/{id}/care_record`) but its medication/routine shape is simpler than
* it is mocked behind this seam. The domain is therefore **mock-primary** (see `constants.ts`). * what ui-phase-9 shipped (structured dose amount/unit, frequency preset codes, multi-select `timeOfDay`
* ui-phase-9 added the structured shape (dose amount/unit, frequency preset codes, time-of-day codes) * vs. one free-text dose, one required frequency string, no `timeOfDay`) a product decision on which
* that replaced free-text dose/frequency/routine-time editing recorded as a REQ-027 addendum, not a * side to change, not yet made (see `mvp/fix-plan.md`). The customer maintains it; it is mocked behind
* new REQ number, in `requests/for-backend.md`. * this seam until that's resolved. The domain is therefore **mock-primary** (see `constants.ts`).
* *
* Load-bearing rules (contract + phase §5): * Load-bearing rules (contract + phase §5):
* - **Family-owned & patient-scoped.** The customer owns/edits medications/routine/tasks; the record * - **Family-owned & patient-scoped.** The customer owns/edits medications/routine/tasks; the record
@@ -112,9 +112,10 @@ export interface VisitNote {
recordedAt: string; 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 * 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; 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 { export interface UpdateFamilyRecordRequest {
medications?: Medication[]; medications?: Medication[];
routine?: RoutineItem[]; routine?: RoutineItem[];
tasks?: CareTask[]; tasks?: CareTask[];
} }
/** /** The nurse append body. Maps 1:1 to the wire `WriteCareRecordBody` (`{ bookingId?, body, taskResults? }`). */
* 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.
*/
export interface CreateVisitNoteRequest { export interface CreateVisitNoteRequest {
bookingId?: number | null; bookingId?: number | null;
body: string; 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 * 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` + * is by config (`USE_PATIENT_RECORDS_MOCK`), never scattered `if (mock)` checks. Every method maps a real
* `createVisitNote` map real b14 routes; the family-record + access methods are REQ-027 gaps (mocked). * 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 { 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>; 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>; 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>>; 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>; 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>; createVisitNote(patientId: number, body: CreateVisitNoteRequest): Promise<WriteVisitNoteResult>;
} }
+33 -17
View File
@@ -32,6 +32,19 @@ interface RefundStatusWire {
reference: string | null; 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 { function toSummary(wire: RefundStatusWire): RefundSummary {
return { return {
id: wire.id, 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 * Real HTTP implementation of the `RefundsApi` seam. The customer half (`resolveCancellationPolicy`/
* (`GET refunds/{id}/status`, tenancy-scoped); the other three target contract gaps the frontend filed * `cancelBooking`/`getRefundByBooking`/`getRefund`/`getMyRefunds`) is the primary implementation as of
* (which is why the domain stays mock-primary see `constants.ts`): * phase 08 (REQ-019/020/021 routes are live: `BookingsController.CancellationPolicy`/`.Cancel`,
* - `resolveCancellationPolicy` REQ-020 (`GET bookings/{id}/cancellation_policy`): b9 snapshots the * `RefundsController.Status`/`.ByBooking`). The admin console methods below (`getRefundPreview`/
* policy only *after* a cancel; there is no pre-cancel preview resolving the tier by current lead time * `approveRefund`/`rejectRefund`) still target proposed routes (REQ-035) `AdminRefundsController` only
* + per-session refundability. * has create-and-execute so `USE_ADMIN_REFUNDS_MOCK` keeps them on the mock (see `constants.ts`).
* - `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`).
*/ */
export const refundsClientApi: RefundsApi = { export const refundsClientApi: RefundsApi = {
resolveCancellationPolicy: async (bookingId: number) => resolveCancellationPolicy: async (bookingId: number) =>
unwrap( toPreview(
await clientFetch<ApiEnvelope<CancellationPolicyPreview>>( unwrap(
`${BOOKINGS}/${bookingId}/cancellation_policy`, 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`))), 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 // 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 () => { getMyRefunds: async () => {
const wire = unwrap(await clientFetch<ApiEnvelope<RefundStatusWire[]>>(`${REFUNDS}/my`)); try {
return wire.map(toSummary); 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 // 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 type { RefundsApi } from '../types';
import { refundsClientApi } from './clientApi'; import { refundsClientApi } from './clientApi';
import { refundsMockApi } from './mockApi'; 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 * The selected `RefundsApi` implementation the single seam the hooks import. The customer surface and
* (`USE_REFUNDS_MOCK`), never by scattered `if (mock)` checks. * 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, bookingId,
cancellable, cancellable,
cancellationPolicyCode: policyCode, cancellationPolicyCode: policyCode,
refundPercentageApplied: tier.refundFraction, // 0100 (matches the real server's decimal `RefundPercentageApplied`/`FeePercentage`), not the 01
feePercentage: Math.round((1 - tier.refundFraction) * 100) / 100, // fraction `tier.refundFraction` uses internally for the BigInt math above.
refundPercentageApplied: tier.refundFraction * 100,
feePercentage: (1 - tier.refundFraction) * 100,
refundAmountIrr: refundAmount.toString(), refundAmountIrr: refundAmount.toString(),
feeAmountIrr: feeAmount.toString(), feeAmountIrr: feeAmount.toString(),
refundableAmountIrr: refundableGross.toString(), refundableAmountIrr: refundableGross.toString(),
@@ -158,7 +160,7 @@ refundsByBooking[5004] = {
totalRefundedIrr: '6000000', totalRefundedIrr: '6000000',
expectedCustomerRefundEta: null, expectedCustomerRefundEta: null,
externalRevertReference: maskedReference(5004), externalRevertReference: maskedReference(5004),
refundPercentageApplied: 0.5, refundPercentageApplied: 50,
cancellationPolicyCode: 'partial_under_24h', cancellationPolicyCode: 'partial_under_24h',
platformFeeRefundedIrr: '720000', platformFeeRefundedIrr: '720000',
nursePayoutRefundedIrr: '5280000', nursePayoutRefundedIrr: '5280000',
@@ -282,13 +284,14 @@ const adminRefundsById: Record<number, AdminRefundResult> = {};
const adminInitiateAttempts: Record<number, number> = {}; const adminInitiateAttempts: Record<number, number> = {};
/** /**
* In-memory mock behind the `RefundsApi` seam the whole customer cancel + refund surface b11 doesn't * In-memory mock behind the `RefundsApi` seam. The customer half (cancel/preview/status) is a config-
* serve (admin-only refunds; no cancel command / policy preview / refund-by-booking / decomposition on the * selectable fallback now that REQ-019/020/021 are real (`USE_CUSTOMER_REFUNDS_MOCK = false` by default)
* customer status REQ-019/020/021). It reads the shared f8 bookings store to resolve the tier + per- * kept for local demo/dev without a backend. It reads the shared f8 bookings store to resolve the tier +
* session refundability, flips the booking to `cancelled` on confirm (so the booking-detail cache reflects * per-session refundability, flips the booking to `cancelled` on confirm (so the booking-detail cache
* it after invalidation), enforces the outside-policy `409`, and drives the refund through the customer * reflects it after invalidation), enforces the outside-policy `409`, and drives the refund through the
* steps (card immediate `succeeded`; BNPL `processing` with an ETA that reconciles over polls). Swap to the * customer steps (card immediate `succeeded`; BNPL `processing` with an ETA that reconciles over polls).
* real `clientApi` once REQ-019/020/021 land (`USE_REFUNDS_MOCK = false`). * 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 = { export const refundsMockApi: RefundsApi = {
resolveCancellationPolicy: async (bookingId) => { resolveCancellationPolicy: async (bookingId) => {
+16 -12
View File
@@ -2,20 +2,24 @@ import { REFUND_ETA_MAX_BUSINESS_DAYS } from '@/constants';
import type { CancellationPolicyCode } from './types'; import type { CancellationPolicyCode } from './types';
/** /**
* When true, the refunds domain is served by the in-memory mock (`apis/mockApi.ts`) behind the * When true, the **customer-facing** refund surface (`resolveCancellationPolicy`/`cancelBooking`/
* `RefundsApi` seam. * `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 * **Real as of phase 08 (blocker-phases/08-refunds-demock.md).** REQ-019/020/021's routes are live and
* customer-visible surface is `GET refunds/{id}/status` (thin: status/channel/amount/ETA/masked ref). * shape-matched (`BookingsController.CancellationPolicy`/`.Cancel`, `RefundsController.Status`/`.ByBooking`)
* There is **no** customer cancel command, **no** cancellation-policy preview, **no** refund-by-booking * the mock stays only as a config-selectable fallback for local demo/dev without a backend.
* 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.
*/ */
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 * 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). */ /** `false` when nothing is refundable (already cancelled/completed, or no un-started sessions). */
cancellable: boolean; cancellable: boolean;
cancellationPolicyCode: CancellationPolicyCode; 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; refundPercentageApplied: number;
/** 01 fraction retained as the cancellation fee/penalty (`= 1 - refundPercentageApplied`). */ /** 0100 percent retained as the cancellation fee/penalty (`= 100 - refundPercentageApplied`). */
feePercentage: number; feePercentage: number;
/** IRR digit-string — the amount refunded to the customer. */ /** IRR digit-string — the amount refunded to the customer. */
refundAmountIrr: string; refundAmountIrr: string;
@@ -125,7 +125,8 @@ export interface CancellationPolicyPreview {
refundChannel: RefundChannel; refundChannel: RefundChannel;
/** Populated only for `bnpl_revert` (the ~710 business-day window); a date `YYYY-MM-DD`. */ /** Populated only for `bnpl_revert` (the ~710 business-day window); a date `YYYY-MM-DD`. */
expectedCustomerRefundEta: string | null; 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[]; refundableSessionIds: number[];
sessions: CancellationSessionPreview[]; sessions: CancellationSessionPreview[];
} }
@@ -164,6 +165,7 @@ export interface RefundSummary {
/** Opaque, **masked** (last 4 only) external reference — never parse it. */ /** Opaque, **masked** (last 4 only) external reference — never parse it. */
externalRevertReference: string | null; externalRevertReference: string | null;
/** --- Fee-leg decomposition + policy snapshot (REQ-021: `null` on the real path until served). --- */ /** --- Fee-leg decomposition + policy snapshot (REQ-021: `null` on the real path until served). --- */
/** 0100 percent, matching `CancellationPolicyPreview.refundPercentageApplied`'s scale. */
refundPercentageApplied: number | null; refundPercentageApplied: number | null;
cancellationPolicyCode: string | null; cancellationPolicyCode: string | null;
platformFeeRefundedIrr: string | null; platformFeeRefundedIrr: string | null;
@@ -32,6 +32,8 @@ interface NurseSearchResultDto {
nurseName: string | null; nurseName: string | null;
avatarUrl: string | null; avatarUrl: string | null;
distanceKm: number | 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. */ /** REQ-040 (proposed) — not yet served; absent until the backend lands it. */
topReviewTag?: string | null; topReviewTag?: string | null;
} }
@@ -94,6 +96,7 @@ export const searchClientApi: SearchApi = {
nurseId: dto.nurseId, nurseId: dto.nurseId,
variantId: dto.variantId, variantId: dto.variantId,
serviceCategoryId: dto.serviceCategoryId, serviceCategoryId: dto.serviceCategoryId,
matchingServiceCount: dto.matchingServiceCount,
// REQ-012 — identity denormalized onto the index row; card falls back to a label only when null. // REQ-012 — identity denormalized onto the index row; card falls back to a label only when null.
nurseName: dto.nurseName ?? '', nurseName: dto.nurseName ?? '',
avatarUrl: dto.avatarUrl, avatarUrl: dto.avatarUrl,
+37 -18
View File
@@ -34,11 +34,12 @@ function withinPrice(priceIrr: string, min?: string, max?: string): boolean {
return true; return true;
} }
function toResult(nurse: SeedNurse, variant: SeedVariant): NurseSearchResult { function toResult(nurse: SeedNurse, variant: SeedVariant, matchingServiceCount: number): NurseSearchResult {
return { return {
nurseId: nurse.nurseId, nurseId: nurse.nurseId,
variantId: variant.variantId, variantId: variant.variantId,
serviceCategoryId: variant.serviceCategoryId, serviceCategoryId: variant.serviceCategoryId,
matchingServiceCount,
nurseName: nurse.nurseName, nurseName: nurse.nurseName,
avatarUrl: nurse.avatarUrl, avatarUrl: nurse.avatarUrl,
isVerified: true, 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 * 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) * 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'); throw new ApiError(400, 'min_price must not exceed max_price', 'invalid_price_range');
} }
const matched = allRows() const matchedRows = allRows().filter(({ nurse, variant }) => {
.filter(({ nurse, variant }) => { if (variant.serviceCategoryId !== filters.serviceCategoryId) return false;
if (variant.serviceCategoryId !== filters.serviceCategoryId) return false; if (variant.cityId !== filters.cityId) return false;
if (variant.cityId !== filters.cityId) return false; if (!matchesDistrict(variant.districtId, filters.districtId)) return false;
if (!matchesDistrict(variant.districtId, filters.districtId)) return false; if (filters.nurseGender && nurse.gender !== filters.nurseGender) return false;
if (filters.nurseGender && nurse.gender !== filters.nurseGender) return false; if (filters.priceUnit && variant.priceUnit !== filters.priceUnit) return false;
if (filters.priceUnit && variant.priceUnit !== filters.priceUnit) return false; if (!withinPrice(variant.priceIrr, filters.priceMin, filters.priceMax)) return false;
if (!withinPrice(variant.priceIrr, filters.priceMin, filters.priceMax)) return false; return true;
return true; });
})
// Rating desc, tiebroken by review count then ids so paging is deterministic (contract order). const matched = dedupeByNurse(matchedRows)
// Rating desc, tiebroken by review count then nurse id so paging is deterministic (contract order).
.sort( .sort(
(a, b) => (a, b) =>
b.nurse.averageRating - a.nurse.averageRating || b.averageRating - a.averageRating ||
b.nurse.totalReviews - a.nurse.totalReviews || b.totalReviews - a.totalReviews ||
a.nurse.nurseId - b.nurse.nurseId || a.nurseId - b.nurseId,
a.variant.variantId - b.variant.variantId, );
)
.map(({ nurse, variant }) => toResult(nurse, variant));
const pageSize = filters.pageSize || SEARCH_PAGE_SIZE; const pageSize = filters.pageSize || SEARCH_PAGE_SIZE;
const page = filters.page || 1; 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 * - **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 * 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. * **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 * - **The result unit is the nurse, one card per nurse** (phase 10 previously the variant, so a nurse
* as several hits. * 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 * - **`districtId = null` whole city**, both directions; the client omits `districtId` for a
* whole-city search rather than sending a bogus value. * whole-city search rather than sending a bogus value.
* - **Same-gender is first-class** `nurseGender` is an up-front filter, never silently defaulted or * - **Same-gender is first-class** `nurseGender` is an up-front filter, never silently defaulted or
@@ -52,11 +54,15 @@ export interface NurseSearchFilters {
pageSize: number; 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 { export interface NurseSearchResult {
nurseId: number; nurseId: number;
/** The nurse's cheapest matching variant — the card's "from X" price and the profile deep-link target. */
variantId: number; variantId: number;
serviceCategoryId: 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). */ /** Display name (mock/future-backend; the real b7 row omits it — card falls back to a label). */
nurseName: string; nurseName: string;
avatarUrl: string | null; avatarUrl: string | null;
@@ -15,7 +15,6 @@ import type {
IdentityKycInput, IdentityKycInput,
NurseCredential, NurseCredential,
RunStepResult, RunStepResult,
SignedDocumentUrl,
TrustBadge, TrustBadge,
UploadUrlResult, UploadUrlResult,
VerificationAggregateStatus, 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> => decideStep: async (stepId: number, input: DecideStepInput): Promise<DecideStepResult> =>
unwrap( unwrap(
await clientFetch<ApiEnvelope<DecideStepResult>>(`${ADMIN_BASE}/steps/${stepId}/decide`, { 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 // Phase 09: explicit whole-verification approve/reject actions (AdminVerificationsController.Approve/
// `decide` re-aggregating `is_verified`. These target proposed `POST admin_verifications/{id}/approve` and // Reject). Approve re-confirms what Finalize already flipped once every step passed; reject is a distinct
// `/reject` for an explicit admin action (until they ship, approve by deciding the last pending step). // admin override, not a per-step decision.
approveVerification: async (nurseVerificationId: number): Promise<void> => { approveVerification: async (nurseVerificationId: number): Promise<void> => {
await clientFetch<ApiEnvelope<boolean>>(`${ADMIN_BASE}/${nurseVerificationId}/approve`, { method: 'POST' }); await clientFetch<ApiEnvelope<boolean>>(`${ADMIN_BASE}/${nurseVerificationId}/approve`, { method: 'POST' });
}, },
@@ -143,7 +143,8 @@ function mkDoc(id: number, originalFileName: string): VerificationDocument {
contentType: 'application/pdf', contentType: 'application/pdf',
fileSizeBytes: 482_000, fileSizeBytes: 482_000,
originalFileName, 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}`, url: `https://mock.balinyaar.local/docs/${id}`,
}; };
} }
@@ -414,20 +415,6 @@ export const verificationMockApi: VerificationApi = {
return toCaseView(record); 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) => { decideStep: async (stepId, input) => {
await sleep(MOCK_LATENCY_MS); await sleep(MOCK_LATENCY_MS);
const found = findCaseByStepId(stepId); 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 * 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 * VerificationApi seam.
* 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 * **Real as of phase 09 (blocker-phases/09-nurse-verification-badge.md).** The b6 routes are live and
* false to hit the live endpoints no hook/component changes (see * shape-matched; the two admin gaps the mock papered over (whole-verification approve/reject, and the
* dev/shared-working-context/reports/mocks-registry.md). * 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 * 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. */ /** A single admin case — same freshness as the queue; invalidated on every decide / approve / reject. */
export const ADMIN_CASE_STALE_TIME = 20_000; 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) // Admin review queue (b6 AdminVerificationsController)
export { useVerificationQueue } from './hooks/useVerificationQueue'; export { useVerificationQueue } from './hooks/useVerificationQueue';
export { useVerificationCase } from './hooks/useVerificationCase'; export { useVerificationCase } from './hooks/useVerificationCase';
export { useVerificationDocumentUrl } from './hooks/useVerificationDocumentUrl';
export { useDecideStep } from './hooks/useDecideStep'; export { useDecideStep } from './hooks/useDecideStep';
export { useApproveVerification } from './hooks/useApproveVerification'; export { useApproveVerification } from './hooks/useApproveVerification';
export { useRejectVerification } from './hooks/useRejectVerification'; 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 * 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. * 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 * The admin subtree (`admin()` queue / case) mirrors the same hierarchy: each queue variant
* variant (filters+params) and each case keys independently, and the `adminQueues()` / `adminCases()` * (filters+params) and each case keys independently, and the `adminQueues()` / `adminCases()` prefixes let
* prefixes let a decision invalidate every queue page and a single case in one call. * a decision invalidate every queue page and a single case in one call.
*/ */
export const verificationKeys = { export const verificationKeys = {
all: ['verification'] as const, all: ['verification'] as const,
@@ -35,8 +35,4 @@ export const verificationKeys = {
// A single nurse's full case — invalidated on every decide / approve / reject. // A single nurse's full case — invalidated on every decide / approve / reject.
adminCases: () => [...verificationKeys.admin(), 'case'] as const, adminCases: () => [...verificationKeys.admin(), 'case'] as const,
adminCase: (nurseVerificationId: number) => [...verificationKeys.adminCases(), nurseVerificationId] 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; 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 * 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. * 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) --- // --- Admin review queue (b6 AdminVerificationsController) ---
/** The review queue, folded to one item per nurse. `status`/`search` filter (status default `in_review`); paginated. */ /** The review queue, folded to one item per nurse. `status`/`search` filter (status default `in_review`); paginated. */
listVerificationQueue(filters: AdminVerificationQueueFilters, params: PageParams): Promise<AdminVerificationQueuePage>; 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>; 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. */ /** Approve or reject a manual step; on a credential-bearing step, records the (encrypted) credential. Re-aggregates. */
decideStep(stepId: number, input: DecideStepInput): Promise<DecideStepResult>; decideStep(stepId: number, input: DecideStepInput): Promise<DecideStepResult>;
/** Approve the whole verification (all required steps pass → `approved`), removing it from the queue. */ /** 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