Compare commits
23 Commits
96b57eb1b8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e4d19ac4f | |||
| 71ca986dcd | |||
| 10d160358f | |||
| 184b202f00 | |||
| 66a60ce874 | |||
| 90e0cdcc34 | |||
| 9a55846df3 | |||
| e3c988e961 | |||
| 340012b2f8 | |||
| 08949a24de | |||
| dd3e39dec5 | |||
| 2cd1075286 | |||
| 42f38f5a72 | |||
| fb58ca54e1 | |||
| 72ab290da1 | |||
| 51e86a1e5f | |||
| e2db97392a | |||
| cd8144e653 | |||
| b876490246 | |||
| c841bded26 | |||
| c889c46110 | |||
| d3ec723119 | |||
| c99e3f4a6e |
@@ -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) §5–7
|
||||
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) §1–3.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
@@ -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.
|
||||
@@ -12,9 +12,22 @@ description: >-
|
||||
# Balinyaar Frontend Designer
|
||||
|
||||
Build UI that looks like Balinyaar and behaves correctly in both locales and both
|
||||
color schemes on the first try. This skill is the design contract; the engineering
|
||||
contract (providers, fetch, cookies, routing) lives in [client/CLAUDE.md](../../../client/CLAUDE.md) — read it
|
||||
before touching layout/provider/data code, **don't restate it**, and never violate it.
|
||||
color schemes on the first try.
|
||||
|
||||
**Precedence.** This skill is the **design** contract — brand, tone, and the visual
|
||||
decisions. The **engineering** contract is [client/CLAUDE.md](../../../client/CLAUDE.md)
|
||||
(hard rules) plus [docs/rules/client/](../../../archive/docs/rules/client/) (one reference file
|
||||
per area). Where the two overlap — tokens, typography, the component library, shells,
|
||||
icons — **`docs/rules/client/` is authoritative and this skill defers to it.** Read the
|
||||
relevant one before touching layout, provider, or data code; don't restate it here, and
|
||||
never violate it.
|
||||
|
||||
| For | Read |
|
||||
|-----|------|
|
||||
| Tokens, palette, dark mode, RTL, fonts, motion | [docs/rules/client/theme.md](../../../archive/docs/rules/client/theme.md) |
|
||||
| The `App*` library, shells, navigation, icons | [docs/rules/client/components.md](../../../archive/docs/rules/client/components.md) |
|
||||
| Copy and Persian orthography | [docs/rules/client/i18n.md](../../../archive/docs/rules/client/i18n.md) |
|
||||
| Forms | [docs/rules/client/forms.md](../../../archive/docs/rules/client/forms.md) |
|
||||
|
||||
**Stack:** Next.js 16 (App Router, Turbopack) · React 19 · MUI v9 (`@mui/material`) ·
|
||||
Emotion (RTL via `stylis-plugin-rtl`) · next-intl v4 · notistack. Everything below
|
||||
@@ -106,6 +119,16 @@ these; they're define-only in CSS):
|
||||
- **Money emphasis** — `--bal-money-emphasis`, an AA-contrast-safe color for emphasized
|
||||
money text. `--bal-secondary` (terracotta) fails AA contrast at small sizes on light
|
||||
backgrounds — never use it for money text, use this token instead.
|
||||
- **Soft fills** — every brand and semantic color has a `-soft` variant
|
||||
(`--bal-primary-soft`, `--bal-warning-soft`, …) for a tinted background. Reach for it
|
||||
before hand-mixing an alpha over a surface.
|
||||
- **Avatar** — `--bal-avatar-1..6` (+ each `-contrast`), the six warm pairs
|
||||
`InitialsAvatar` picks from by a deterministic name hash. Add a seventh to **both**
|
||||
scheme blocks or don't add one.
|
||||
- **Map** — `--bal-pin-shadow`, the address-picker pin.
|
||||
|
||||
Full catalogue, with what each group backs:
|
||||
[docs/rules/client/theme.md](../../../archive/docs/rules/client/theme.md) §2.
|
||||
|
||||
---
|
||||
|
||||
@@ -149,15 +172,23 @@ wrapper over the bare MUI component — the wrappers carry the house defaults.
|
||||
| `AppIconButton` | icon-only actions | takes an icon `name`, `title`, `to`/`onClick` |
|
||||
| `AppIcon` | any icon | `icon="home"` by registered name (§6); `size`, `color` props |
|
||||
| `AppLink` | internal/external links | locale-aware Next navigation; default underline `hover` |
|
||||
| `AppAlert` | inline alerts | default `severity="error"`, `variant="filled"` |
|
||||
| `AppImage` | images | wrapper around next/image conventions |
|
||||
| `AppAlert` | inline alerts | defaults to a calm `severity="info"`, `variant="standard"` — pass `severity="error"` explicitly when it really is an error |
|
||||
| `AppLoading` | loading state | default circular, `primary`, `3rem` |
|
||||
| `ErrorBoundary` | wrapping fault-prone subtrees | already wraps page content in the shell |
|
||||
| `ProfileSummary` | the identity card in chrome | avatar+name+masked phone+role label+optional `TrustBadge`; vertical or `compact` horizontal chip |
|
||||
|
||||
Defaults for these live in `src/components/config.ts` (`APP_BUTTON_VARIANT`,
|
||||
`APP_ICON_SIZE = 24`, `CONTENT_MAX_WIDTH = 800`, `CONTENT_MIN_WIDTH = 320`, alert/link/
|
||||
loading defaults). Change a default there, not per-call-site.
|
||||
`APP_ICON_SIZE = 24`, `APP_ICON_STROKE_WIDTH = 1.75`, `APP_BUTTON_ICON_SIZE = 20`,
|
||||
`CONTENT_MAX_WIDTH = 480`, `CONTENT_MIN_WIDTH = 320`, alert/link/loading defaults).
|
||||
Change a default there, not per-call-site.
|
||||
|
||||
Beyond the `App*` wrappers there is a **state kit** — `EmptyState`, `ErrorState`,
|
||||
`QueryStateGate`, `PageHeader`, `ConfirmDialog`, `SurfaceCard`, `AccentCard`, `Money`,
|
||||
`StatusTimeline`, the `Jalali*` date inputs, `StickyActionBar`, `Pager`, `NavHubList`,
|
||||
`InitialsAvatar`, `FormDialogShell` — with **one pattern per state**. Never hand-roll a
|
||||
dashed-border "nothing here" block or a per-screen pager; and **an error state is never an
|
||||
empty state.** Catalogue in
|
||||
[docs/rules/client/components.md](../../../archive/docs/rules/client/components.md).
|
||||
|
||||
For layout/spacing use MUI primitives directly: `Box`, `Stack`, `Container`, `Grid`,
|
||||
`Paper`, `Card`. Use the `spacing`/`sx` system (theme spacing unit = 8px) — never inline
|
||||
@@ -166,7 +197,18 @@ pixel margins for rhythm.
|
||||
**New shared component?** Put it in `src/components/<Name>/<Name>.tsx` with an
|
||||
`index.tsx` barrel, follow the `App*` prop-spreading + JSDoc style of `AppButton.tsx`,
|
||||
and add a co-located `.test.tsx` (mandatory for anything imported in >1 place — see
|
||||
CLAUDE.md "Unit Testing"; wrap with `<ThemeProvider>`, never mock MUI).
|
||||
[docs/rules/client/testing.md](../../../archive/docs/rules/client/testing.md); wrap with
|
||||
`<ThemeProvider>`, never mock MUI). If it goes at the top of the `@/components/common`
|
||||
barrel, prefer **caller-owned copy** (required `title`/`body`/`retryLabel` string props)
|
||||
over calling `useTranslations` inside it — `next-intl` is ESM-only and poisons every test
|
||||
that transitively imports the barrel. `ErrorBoundary`/`ErrorState` are the model;
|
||||
[components.md](../../../archive/docs/rules/client/components.md) has the why.
|
||||
|
||||
**Any form with more than one field is a react-hook-form form**, bound through the
|
||||
`@/components/common/form` wrappers (`RhfTextField`, `RhfChipSelect`,
|
||||
`RhfJalaliDateField`, `RhfControlGroup`) and grouped into `FormSection`s. A single-field
|
||||
control is state, not a form. Full pattern:
|
||||
[docs/rules/client/forms.md](../../../archive/docs/rules/client/forms.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -178,12 +220,19 @@ backdrop, at **every viewport**. A wider window gets more canvas, never a wider
|
||||
design one set of states, verify one set of states. Do not add a `≥md` branch that widens
|
||||
a shell, restores a sidebar, or lays a screen out in columns.
|
||||
|
||||
- `AppFrame` owns three structural guarantees, and is the only place any of them is
|
||||
solved: the width cap; the **frame, not the document, owns the scroll** (header /
|
||||
`<main>` / footer are flex siblings, so a top bar is `position: static` and no page
|
||||
needs a top offset); and `overflowX: hidden` + `minWidth: 0`, so an over-wide child
|
||||
clips rather than dragging the app sideways. Genuinely wide content (a data table)
|
||||
scrolls **inside its own container** — see `AdminDataTable`'s `TableContainer`.
|
||||
- `AppFrame` owns four structural guarantees, and is the only place any of them is
|
||||
solved: the width cap; the **frame, not the document, owns the scroll** (a single
|
||||
scrolling `<main>` fills the frame, with the bars pinned **`position: absolute`** over
|
||||
it — never `fixed`, which would break out of the centered column — and `<main>`
|
||||
reserving each bar's exact height as padding, so no page needs a top offset);
|
||||
`overflowX: hidden` + `minWidth: 0`, so an over-wide child clips rather than dragging
|
||||
the app sideways; and, above `sm`, the column **floats** as a rounded shadowed card
|
||||
with a gutter all round (edge-to-edge on a phone). Genuinely wide content (a data
|
||||
table) scrolls **inside its own container** — see `AdminDataTable`'s `TableContainer`.
|
||||
- `AppFrame` also publishes **`--bal-chrome-top` / `--bal-chrome-bottom`** on the scroll
|
||||
container (already including `env(safe-area-inset-*)`, and `0px` in a chrome-free
|
||||
shell), so any `position: sticky` element can clear the bars without importing a
|
||||
constant. `StickyActionBar` is the reference consumer — don't recompute an offset.
|
||||
- **One authenticated shell**: `MobileShell` = `AppFrame` + a contextual `TopBar` (brand
|
||||
lockup on a tab's own path, back chevron + `useRouteTitle()` on anything deeper) +
|
||||
`BottomBar` + `ErrorBoundary` + `RouteFadeIn`. The four actor layouts (`CustomerLayout`
|
||||
@@ -191,9 +240,17 @@ a shell, restores a sidebar, or lays a screen out in columns.
|
||||
only `tabs` and `headerActions`. Add a destination by adding a tab or a hub row — never
|
||||
by forking the shell.
|
||||
- **The chrome is light, not structural.** The top bar is *not* an `AppBar` — no filled
|
||||
surface, no rule, no elevation; it sits on the page background. The bottom bar *floats*:
|
||||
inset from the frame edges, fully rounded (`--bal-radius-pill`), elevated. Neither should
|
||||
read as a slab sealing off an edge of a 480px screen.
|
||||
surface, no rule, no elevation of its own; `AppFrame` wraps both bars in the shared
|
||||
`FLOATING_BAR_SX`, so the header is the bottom bar mirrored: inset from the frame edges,
|
||||
fully rounded (`--bal-radius-pill`), elevated. Neither should read as a slab sealing off
|
||||
an edge of a 480px screen. The bottom bar is **icon-only** (at five tabs the caption was
|
||||
the widest thing in it and cost a whole line — the label survives as `aria-label`/
|
||||
`title`), each tab a fixed 44px circle laid out `space-around`.
|
||||
- **A stateful card carries its state in its content, not a stripe.** `AccentCard`'s
|
||||
colored edge stripe was removed — a column of them read as a row of loose vertical rules
|
||||
down the RTL edge of the screen. `tone` survives as the semantic label (reaching the DOM
|
||||
as `data-accent-tone`); the `StatusChip`, icon and copy inside carry the state.
|
||||
**Do not reintroduce the stripe.**
|
||||
- **Navigation is the bottom bar. There is no drawer.** Tabs are `LinkToPage` arrays
|
||||
(`@/utils`) built with `useTranslations('nav')`, 3–5 of them, and by convention the last
|
||||
is a settings/«بیشتر» hub. Active state comes from the shared `matchActivePath`
|
||||
@@ -254,7 +311,7 @@ Icons also default to `flexShrink: 0` — an icon squashed by a flex sibling was
|
||||
layout bug this component kept quietly reintroducing on narrow rows.
|
||||
|
||||
**Directional icons mirror automatically.** Icons authored for LTR that must flip under
|
||||
RTL (`back`, `chevron_start`, `chevron_end`) are registered in `AppIcon/config.ts`'s
|
||||
RTL (`back`, `chevron_start`, `chevron_end`, `forward`, `send`) are registered in `AppIcon/config.ts`'s
|
||||
`DIRECTIONAL_ICONS` set. `AppIcon` stamps `data-icon-directional` on those, and one CSS
|
||||
rule (`app/globals.css`) does
|
||||
`[dir='rtl'] [data-icon-directional] { transform: scaleX(-1); }`. Adding a new directional
|
||||
@@ -284,11 +341,16 @@ Every screen/component you produce must satisfy **all** of these:
|
||||
switches automatically. Verify on both schemes — never assume a light background.
|
||||
4. **Tokens, not hexes.** No raw color literals in `sx`/`styled`/components (§2).
|
||||
5. **Constants, not magic values.** Cookie names, routes, repeated dimensions, event
|
||||
names → named constants (CLAUDE.md "Constants").
|
||||
names → named constants ([components.md](../../../archive/docs/rules/client/components.md) §5).
|
||||
6. **Use the wrappers** (§4) and the **icon registry** (§6) before bare MUI.
|
||||
7. **Shared component ⇒ co-located test** (§4).
|
||||
8. **MUI v9 API only.** No v5/v6-era props (e.g. `Stack` `useFlexGap`, `storageWindow`).
|
||||
Avoid deprecated APIs that throw.
|
||||
9. **Persian copy follows the style guide** — «بالینیار» with a ZWNJ, تأیید with a hamza,
|
||||
جستجو in one form, formal شما. `npm run lint:copy` fails the gate on a banned variant.
|
||||
Glossary and the full rules: [i18n.md](../../../archive/docs/rules/client/i18n.md) §4.
|
||||
10. **A screen never fabricates a figure.** A summary reads only off a query that already
|
||||
answers it; a count still in flight is omitted, never faked or defaulted.
|
||||
|
||||
---
|
||||
|
||||
@@ -304,13 +366,15 @@ Every screen/component you produce must satisfy **all** of these:
|
||||
5. **Verify the four axes:** `/fa` (RTL) and `/en` (LTR) × light and dark. The default
|
||||
route is `/fa` — start there.
|
||||
6. **Tests** for any new shared component; **never** add a layout above `[locale]`
|
||||
(breaks locale/dir — see CLAUDE.md).
|
||||
7. Data/fetch/auth/cookies/toasts → follow CLAUDE.md (`serverFetch`/`clientFetch`,
|
||||
(breaks locale/dir — see [structure.md](../../../archive/docs/rules/client/structure.md)).
|
||||
7. Data/fetch/auth/cookies/toasts → follow
|
||||
[services.md](../../../archive/docs/rules/client/services.md) and
|
||||
[auth.md](../../../archive/docs/rules/client/auth.md) (`serverFetch`/`clientFetch`,
|
||||
`@/lib/cookies/*`, `dispatchToast`/`useSnackbar`). Don't reinvent these.
|
||||
|
||||
---
|
||||
|
||||
## 9. Anti-patterns (design-specific — CLAUDE.md has the full engineering list)
|
||||
## 9. Anti-patterns (design-specific — `docs/rules/client/` has the full engineering list)
|
||||
|
||||
- Hard-coded hex/rgb in components → use palette keys or `--bal-*` tokens.
|
||||
- MUI default success/error colors for feedback → use `--bal-*` semantic tokens.
|
||||
@@ -321,6 +385,14 @@ Every screen/component you produce must satisfy **all** of these:
|
||||
- Raw MUI icon where a registry name is expected → register it in `AppIcon/config.ts`.
|
||||
- New shared component without a `.test.tsx`, or mocking MUI in tests.
|
||||
- Re-introducing `src/app/layout.tsx` / any layout above `[locale]`.
|
||||
- A `≥md` branch that widens a shell, restores a sidebar, or goes multi-column → there is
|
||||
one layout, and it is a phone (§5).
|
||||
- A numeric `sx={{ borderRadius: n }}` → it multiplies the shape unit; use the radius token.
|
||||
- `fontWeight: 600` → neither face loads it, so it silently renders full Bold. 700/500/400.
|
||||
- Reintroducing `AccentCard`'s edge stripe, a drawer, a top-bar theme/locale toggle, or a
|
||||
caption under a bottom-nav icon → each was deliberately removed.
|
||||
- A hand-rolled empty/error/loading block, or a per-screen pager → use the state kit (§4).
|
||||
- A second `prefers-reduced-motion` branch → there is exactly one, in `globals.css`.
|
||||
|
||||
---
|
||||
|
||||
@@ -351,4 +423,6 @@ pushing code back into Figma.
|
||||
| Layout shells | `client/src/layout/` |
|
||||
| Layout dimensions | `client/src/layout/config.ts` |
|
||||
| Messages (i18n) | `client/messages/{en,fa}.json` |
|
||||
| Engineering contract | `client/CLAUDE.md` |
|
||||
| Persian copy lint | `client/scripts/check-copy.mjs` |
|
||||
| Engineering hard rules | `client/CLAUDE.md` |
|
||||
| Engineering reference (per area) | `docs/rules/client/` |
|
||||
|
||||
@@ -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).
|
||||
@@ -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,5 +6,11 @@ The canonical guidance for AI coding agents in this repository lives in **[CLAUD
|
||||
- Frontend → [client/CLAUDE.md](client/CLAUDE.md)
|
||||
- Backend → [server/CLAUDE.md](server/CLAUDE.md)
|
||||
|
||||
Those hold the **hard rules**. For current-state product truth — what to test, what's blocking launch,
|
||||
what's missing — start at **[mvp/README.md](mvp/README.md)**. The engineering reasoning behind the hard
|
||||
rules, and the full business-requirement docs, were archived on 2026-08-02 into
|
||||
**[archive/docs/rules/](archive/docs/rules/index.md)** and **[archive/product/](archive/product/index.md)**
|
||||
respectively — reference material, not actively maintained.
|
||||
|
||||
`CLAUDE.md` is the single source of truth at every level of this repo; these `AGENTS.md` files are
|
||||
just pointers so the convention is discoverable under either name.
|
||||
|
||||
@@ -1,127 +1,145 @@
|
||||
# Balinyaar — Repository Guide (root)
|
||||
|
||||
This is the **shared, repo-wide** guide for AI coding agents. It is intentionally short.
|
||||
Everything specific to one side of the stack lives in that project's own `CLAUDE.md`.
|
||||
The **shared, repo-wide** guide for AI coding agents. It is intentionally short. Everything specific to one
|
||||
side of the stack lives in that project's own `CLAUDE.md`.
|
||||
|
||||
> **Read the guide for the side you are editing — and only that one.**
|
||||
> Working in `client/`? Read [client/CLAUDE.md](client/CLAUDE.md).
|
||||
> Working in `server/`? Read [server/CLAUDE.md](server/CLAUDE.md) (+ [server/CONVENTIONS.md](server/CONVENTIONS.md)).
|
||||
> Working in `server/`? Read [server/CLAUDE.md](server/CLAUDE.md).
|
||||
> You almost never need both. A frontend change does not touch server files, and vice-versa.
|
||||
|
||||
> `AGENTS.md` files in this repo are thin pointers to the `CLAUDE.md` in the same folder.
|
||||
> `CLAUDE.md` is the single source of truth at every level.
|
||||
> Last verified: 2026-08-02 against commit `51e86a1`.
|
||||
|
||||
---
|
||||
|
||||
## What Balinyaar is
|
||||
|
||||
Balinyaar is a **trust-first home-nursing marketplace in Iran**. Independent nurses (and
|
||||
nursing-company employees) list configurable services; families search, book, pay, and review.
|
||||
The platform holds funds in an escrow-style ledger and pays nurses out weekly after a confirmed
|
||||
check-out.
|
||||
Balinyaar is a **trust-first home-nursing marketplace in Iran**. Independent nurses (and nursing-company
|
||||
employees) list configurable services; families search, book, pay, and review. The platform holds funds in an
|
||||
escrow-style ledger and pays nurses out weekly after a confirmed check-out.
|
||||
|
||||
Product/domain knowledge — business rules, the database model, payments/BNPL, escrow, the
|
||||
verification pipeline — is **not** in the code. It lives in [`product/`](product/), organized as a
|
||||
**structured docs tree** (one topic per file; start at [product/index.md](product/index.md) or its
|
||||
[README](product/README.md)):
|
||||
**Start at [`mvp/README.md`](mvp/README.md) for the current state of the product**, in three short,
|
||||
non-technical files: how to manually test any user journey, what's broken and blocking a real launch, and
|
||||
what's missing that isn't clearly scheduled. That folder is the live, load-bearing answer to "what's next."
|
||||
|
||||
| Folder | What it covers |
|
||||
| --- | --- |
|
||||
| [product/overview/](product/overview/platform-summary.md) | What Balinyaar is, the four cross-cutting ground truths, Persian glossary. **Read first.** |
|
||||
| [product/business/](product/business/index.md) | The 14 functional/business requirement areas, one file each |
|
||||
| [product/data-model/](product/data-model/index.md) | The ~54-table SQL Server schema across 13 domains + [diagrams](product/data-model/diagrams.md) |
|
||||
| [product/payments/](product/payments/index.md) | BNPL, escrow ledger, settlement, VAT, integrations (with sources) |
|
||||
| [product/research/](product/research/index.md) | Market/legal/verification research & go-to-market (EN) |
|
||||
| [product/notes/](product/notes/open-questions.md) | Living notes: open questions, future ideas |
|
||||
| [product/fa/](product/fa/index.html) | Farsi versions (research report + verification flow) |
|
||||
Deeper product/business knowledge — the full business-requirement write-ups, the ~54-table database model,
|
||||
payments/BNPL research, market/legal research — was consolidated into [`archive/product/`](archive/product/index.md)
|
||||
during the 2026-08-02 documentation cleanup. It is **reference material, not required reading**: correct as
|
||||
of that date, but not actively maintained going forward. Read it when `mvp/` doesn't answer your question in
|
||||
enough depth — e.g. designing a new table, or needing the full reasoning behind a business rule.
|
||||
|
||||
**Read the relevant `product/` doc before designing any schema, API, or feature.** Don't infer
|
||||
business rules from code — the code is young and the docs are the source of truth.
|
||||
|
||||
> **Docs format:** the `.md` files are canonical; matching `.html` files are a generated, cross-linked
|
||||
> browsing view (`cd product && node build-docs.mjs`). Edit the Markdown and regenerate — never
|
||||
> hand-edit the `.html`. If you add/rename a `.md`, update the `NAV` manifest in `product/build-docs.mjs`.
|
||||
**Never infer business rules from code alone** — the code is young. If `mvp/` and `archive/product/` both go
|
||||
silent on a money, auth, tenancy, or clinical-data rule, say so rather than guessing.
|
||||
|
||||
---
|
||||
|
||||
## Repository layout
|
||||
|
||||
This is **two independent projects in one repo**. There is no root-level build, package, or
|
||||
solution — each project is built, linted, and run on its own.
|
||||
This is **two independent projects in one repo**, plus their documentation. There is no root-level build,
|
||||
package, or solution — each project is built, linted, and run on its own.
|
||||
|
||||
| Path | Project | Stack | Guide |
|
||||
| Path | What it is | Stack | Guide |
|
||||
| --- | --- | --- | --- |
|
||||
| [`client/`](client/) | Web frontend | Next.js 16 (App Router) · React 19 · TypeScript · MUI v9 · next-intl | [client/CLAUDE.md](client/CLAUDE.md) |
|
||||
| [`server/`](server/) | Backend API | ASP.NET Core (.NET 10) · Clean Architecture · CQRS · EF Core | [server/CLAUDE.md](server/CLAUDE.md) |
|
||||
| [`product/`](product/) | Product docs | Markdown | — (see table above) |
|
||||
| [`dev/`](dev/) | Build plan (not app code) | Markdown | [dev/README.md](dev/README.md) |
|
||||
| [`telegram-otp-bot/`](telegram-otp-bot/) | OTP relay (standalone) | Node 18+, zero deps | [telegram-otp-bot/README.md](telegram-otp-bot/README.md) |
|
||||
| [`mvp/`](mvp/README.md) | **Current truth** — plain-language test flows, launch blockers, missing MVP features | Markdown | [mvp/README.md](mvp/README.md) |
|
||||
| [`archive/`](archive/README.md) | Everything else: business docs, engineering rules/contracts/flow-atlas, and the executed build history. **Reference/history, not instruction** — nothing to build from it, and nothing here is kept current | Markdown | [archive/README.md](archive/README.md) |
|
||||
| [`telegram-otp-bot/`](telegram-otp-bot/) | OTP relay (standalone, the pre-launch demo rail) | Node 18+, zero deps | [telegram-otp-bot/README.md](telegram-otp-bot/README.md) |
|
||||
| [`deploy/`](deploy/) | Reverse-proxy config | Caddyfile | [DEPLOY.md](DEPLOY.md) |
|
||||
|
||||
The two communicate over **HTTP/JSON** (optionally gRPC). The client reads the API base URL from
|
||||
`NEXT_PUBLIC_API_URL`; the server listens on `https://localhost:5002` by default.
|
||||
`AGENTS.md` files in this repo are thin pointers to the `CLAUDE.md` in the same folder. **`CLAUDE.md` is the
|
||||
single source of truth at every level.**
|
||||
|
||||
**Deployment** is three Docker containers — one `Dockerfile` per project directory, orchestrated by the
|
||||
root [`docker-compose.yml`](docker-compose.yml) — behind an existing Caddy reverse proxy on the external
|
||||
`caddy_net` network, serving `balinyaar.ir` (client) and `api.balinyaar.ir` (server). The database is
|
||||
**not** containerised; it is a remote SQL Server. Full runbook: [DEPLOY.md](DEPLOY.md).
|
||||
The two projects communicate over **HTTP/JSON** (optionally gRPC). The client reads the API base URL from
|
||||
`NEXT_PUBLIC_API_URL`; the server listens on `http://localhost:5002` by default.
|
||||
|
||||
[`dev/`](dev/README.md) holds the **phased build plan** that takes the repo from its current baseline to
|
||||
the MVP: a chain of agent-runnable prompt files split into a `backend/` and a `frontend/` track
|
||||
([dev/phases/](dev/phases/README.md)), the cross-project API [`contracts/`](dev/contracts/README.md), and
|
||||
a [`shared-working-context/`](dev/shared-working-context/README.md) that lets a backend agent and a
|
||||
frontend agent run in parallel without touching the same files. It is planning/tooling, **not** a third
|
||||
project — there is nothing to build in it.
|
||||
**Deployment** is three Docker containers — one `Dockerfile` per project directory, orchestrated by the root
|
||||
[`docker-compose.yml`](docker-compose.yml) — behind an existing Caddy reverse proxy on the external `caddy_net`
|
||||
network, serving `balinyaar.ir` (client) and `api.balinyaar.ir` (server). The database is **not**
|
||||
containerised; it is a remote SQL Server. Full runbook: [DEPLOY.md](DEPLOY.md).
|
||||
|
||||
`archive/` holds the executed build history, the former `docs/` (engineering rules, API contracts, the
|
||||
per-flow test atlas, status/backlog) and the former `product/` (business requirements, data model, research)
|
||||
— consolidated there on 2026-08-02 so the live tree stays focused on MVP work. **Anything in it is a record,
|
||||
not an instruction.**
|
||||
|
||||
---
|
||||
|
||||
## Where the rules live
|
||||
|
||||
Three tiers. Open the `CLAUDE.md` for the side you are editing, then **one** reference file for the area you
|
||||
are touching.
|
||||
|
||||
| Tier | Where | What |
|
||||
| --- | --- | --- |
|
||||
| **Hard rules** | this file · [client/CLAUDE.md](client/CLAUDE.md) · [server/CLAUDE.md](server/CLAUDE.md) | Constraints whose violation breaks the build, the gate, or a business invariant |
|
||||
| **Reference** (archived) | [`archive/docs/rules/`](archive/docs/rules/index.md) | The *how* and the *why*, as of 2026-08-02 — 3 shared files, 8 client, 6 server, plus the documentation convention. Not actively maintained; read it on demand, don't expect it to track later changes |
|
||||
| **Procedure** | `.claude/skills/` | Playbooks: **frontend-designer** (the design contract for `client/` UI), **backend-feature** (adding a server feature), **flow-testing** (walking a flow end to end) |
|
||||
|
||||
Start at [archive/docs/rules/index.md](archive/docs/rules/index.md) — it maps "working on X" to the one file
|
||||
to open.
|
||||
|
||||
**Precedence when two sources disagree:** `archive/product/` (business truth) → the relevant `CLAUDE.md`
|
||||
(engineering truth) → `archive/docs/rules/` (the reasoning behind it) → the task in front of you. **Never
|
||||
silently guess on money, auth, tenancy, or clinical-data rules** — do the safe thing, and say so.
|
||||
|
||||
---
|
||||
|
||||
## Working agreements (apply to both projects)
|
||||
|
||||
1. **Stay within one project per change** unless the task explicitly spans both.
|
||||
2. **Match the surrounding style.** Mirror existing patterns; don't introduce new ones. Each
|
||||
project documents its conventions in its own `CLAUDE.md`.
|
||||
2. **Match the surrounding style.** Mirror existing patterns; don't introduce new ones. Each project documents
|
||||
its conventions in its own `CLAUDE.md`.
|
||||
3. **Run that project's own checks before declaring work done:**
|
||||
- client: `npm run check` (type + lint), plus `npm run test:ci` if you touched a tested component.
|
||||
- server: `dotnet build Baya.sln` and `dotnet test Baya.sln`.
|
||||
4. **Read the product docs before changing behavior.** Business rules are decisions, not guesses.
|
||||
5. **Don't reintroduce template/starter scaffolding.** Both projects were derived from open-source
|
||||
starters; their branding, demo/showcase pages, and `_TITLE_`/`_DESCRIPTION_` placeholders were
|
||||
intentionally removed. Don't add them back.
|
||||
6. **Configuration lives in files, not in a secret store.** `dotnet user-secrets` is **not** used — the
|
||||
`<UserSecretsId>` was removed from `Baya.Web.Api.csproj`, so that store isn't even read. Server config
|
||||
(including keys) lives in `appsettings.*.json`; client config in `.env.development` / `.env.production`;
|
||||
the deployment's container-specific overrides in `docker-compose.yml`. This is a deliberate pre-launch
|
||||
trade for a demo deployment — **the repo therefore contains live credentials**. Before onboarding real
|
||||
users, rotate them and move the secret half out of git (see [DEPLOY.md](DEPLOY.md) "Going to Production").
|
||||
One value is load-bearing and must never change: `Seams:FieldEncryption:Key`/`:HashKey` decrypt all
|
||||
existing PII and derive the phone-lookup hash.
|
||||
7. **Keep docs honest, and keep the architecture map current.** If you change how something works,
|
||||
update the `CLAUDE.md` that describes it in the same change. Each level documents its architecture
|
||||
in one canonical place — **this file's "Repository layout"** (repo), **client/CLAUDE.md "Project
|
||||
Structure"** (frontend), **server/CLAUDE.md "Project map"** (backend). When a change alters that
|
||||
structure — adds, removes, or renames a project, layer, route group, provider, or major folder, or
|
||||
changes a cross-project / cross-layer boundary — update the matching architecture section in the
|
||||
same change. Stale instructions are worse than none.
|
||||
- client: `cd client && npm run check` (type + lint + copy), plus `npm run test:ci` if you touched a tested
|
||||
component.
|
||||
- server: `cd server && dotnet build Baya.sln` (**zero new warnings**) and `dotnet test Baya.sln`.
|
||||
- What "done" means in full: [archive/docs/rules/shared/git-and-gates.md](archive/docs/rules/shared/git-and-gates.md).
|
||||
4. **Read [`mvp/`](mvp/README.md) (and `archive/product/` for depth) before changing behavior.** Business rules are decisions, not guesses.
|
||||
5. **Don't reintroduce template/starter scaffolding.** Both projects were derived from open-source starters;
|
||||
their branding, demo/showcase pages, and `_TITLE_`/`_DESCRIPTION_` placeholders were intentionally removed.
|
||||
Don't add them back.
|
||||
6. **Configuration lives in files, not a secret store.** `dotnet user-secrets` is **not used** — the
|
||||
`<UserSecretsId>` was removed from `Baya.Web.Api.csproj`, so that store **is not even read**. Any
|
||||
instruction anywhere to set a value with it is stale. Server config (including keys) lives in
|
||||
`appsettings.*.json`; client config in `.env.development` / `.env.production`; the deployment's
|
||||
container-specific overrides in `docker-compose.yml`.
|
||||
This is a deliberate pre-launch trade for a demo deployment — **the repo therefore contains live
|
||||
credentials.** Before onboarding real users, rotate them and move the secret half out of git (see
|
||||
[DEPLOY.md](DEPLOY.md) "Going to Production"). **One value is load-bearing and must never change:**
|
||||
`Seams:FieldEncryption:Key` / `:HashKey` decrypt all existing PII and derive the phone-lookup hash.
|
||||
7. **Keep docs honest, and keep the architecture map current.** If you change how something works, update the
|
||||
doc that describes it in the **same** change. Each level documents its architecture in one canonical place —
|
||||
**this file's "Repository layout"** (repo), **client/CLAUDE.md "Project structure"** (frontend),
|
||||
**server/CLAUDE.md "Project map"** (backend). When a change alters that structure — adds, removes, or
|
||||
renames a project, layer, route group, provider, or major folder, or changes a cross-project / cross-layer
|
||||
boundary — update the matching section in the same change. The full anti-drift convention (what to update
|
||||
when X changes, the `> Last verified:` stamp, length budgets) is
|
||||
[archive/docs/rules/documentation.md](archive/docs/rules/documentation.md). **Stale instructions are worse than none.**
|
||||
8. **Write clean, self-documenting code.**
|
||||
- **No dead code.** Remove unused variables, imports/usings, parameters, and private members —
|
||||
don't leave them behind and don't suppress the warning. The client enforces this with ESLint
|
||||
(`@typescript-eslint/no-unused-vars` as an *error*); on the server they are build warnings and
|
||||
the gate is zero new warnings. Per-project specifics live in each project's `CLAUDE.md` /
|
||||
`CONVENTIONS.md`.
|
||||
- **Comment the *why*, not the *what*.** Don't write verbose comments that restate what the code
|
||||
already says. Add a comment only where a non-obvious decision, constraint, business rule, or
|
||||
trade-off isn't evident from the code itself. Prefer a clearer name over a comment.
|
||||
- **No dead code.** Remove unused variables, imports/usings, parameters, and private members — don't leave
|
||||
them behind and don't suppress the warning. The client enforces this with ESLint
|
||||
(`@typescript-eslint/no-unused-vars` as an *error*); on the server they are build warnings and the gate is
|
||||
zero new warnings.
|
||||
- **Comment the *why*, not the *what*.** Don't write verbose comments that restate what the code already
|
||||
says. Add a comment only where a non-obvious decision, constraint, business rule, or trade-off isn't
|
||||
evident from the code itself. Prefer a clearer name over a comment.
|
||||
- Details and worked examples: [archive/docs/rules/shared/code-quality.md](archive/docs/rules/shared/code-quality.md).
|
||||
9. **A mock is only sanctioned behind a DI-registered seam**, selected by configuration, defaulting to the
|
||||
mock, and recorded in [`mvp/blockers.md`](mvp/blockers.md) (or `archive/docs/status/` for the full historical
|
||||
ledger). Never an `if (mock)` branch scattered through the code.
|
||||
|
||||
---
|
||||
|
||||
## Naming
|
||||
|
||||
- The **server**'s C# namespaces, projects, and solution all use the `Baya*` prefix
|
||||
(`Baya.Web.Api`, `Baya.sln`). Keep new server code under the `Baya.*` convention.
|
||||
- The **server**'s C# namespaces, projects, and solution all use the `Baya*` prefix (`Baya.Web.Api`,
|
||||
`Baya.sln`). Keep new server code under the `Baya.*` convention.
|
||||
- The **client** package is `balinyaar-client`; the `@/*` import alias maps to `client/src/*`.
|
||||
|
||||
The product/brand name is **Balinyaar**; the server's `Baya*` prefix is a legacy code namespace —
|
||||
do not rename it without explicit instruction.
|
||||
The product/brand name is **Balinyaar** — «بالینیار» in Persian copy, with a ZWNJ, always. The server's
|
||||
`Baya*` prefix is a legacy code namespace: **do not rename it without explicit instruction.** Full
|
||||
conventions: [archive/docs/rules/shared/naming.md](archive/docs/rules/shared/naming.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -132,5 +150,5 @@ do not rename it without explicit instruction.
|
||||
cd client && npm install && npm run dev # http://localhost:3000
|
||||
|
||||
# Backend
|
||||
cd server && dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj # https://localhost:5002/swagger
|
||||
cd server && dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj # http://localhost:5002/swagger
|
||||
```
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
A first, shareable deployment of the whole stack under **balinyaar.ir**, in Docker, behind an existing
|
||||
Caddy reverse proxy that terminates TLS.
|
||||
|
||||
> **This file is the deploy *procedure*.** The runtime dependency graph — every edge, what breaks when it is
|
||||
> down, and where it is configured — is [archive/docs/integration/topology.md](archive/docs/integration/topology.md)
|
||||
> (archived reference, not actively maintained), and
|
||||
> every configuration key on both sides is
|
||||
> [archive/docs/integration/config-matrix.md](archive/docs/integration/config-matrix.md). Read those to answer
|
||||
> "what talks to what" or "where is this value set"; read this one to actually ship.
|
||||
|
||||
| Host | Serves | Container |
|
||||
| --- | --- | --- |
|
||||
| `balinyaar.ir`, `www.balinyaar.ir` | Next.js web client | `balinyaar-web:3000` |
|
||||
@@ -28,9 +35,16 @@ machine is now inert and can be deleted. Every value lives in a file in the repo
|
||||
| Client build-time config (API URL, site origin) | `client/.env.production` |
|
||||
| Telegram relay config (bot token, chat ids, API key, proxy) | `docker-compose.yml` → `otp-relay.environment` |
|
||||
|
||||
The placeholder string `SET_VIA_USER_SECRETS_OR_ENV` in the base `appsettings.json` names that removed
|
||||
store; the *name* is a historical artifact, kept only because it is the sentinel `StartupSecretsGuard`
|
||||
rejects. **The mechanism is appsettings files and environment variables** — see
|
||||
[archive/docs/integration/config-matrix.md](archive/docs/integration/config-matrix.md), which lists every key,
|
||||
its default, and who reads it.
|
||||
|
||||
The API runs as **`ASPNETCORE_ENVIRONMENT=Development`**, so `appsettings.Development.json` is the file
|
||||
that actually loads. An `appsettings.Production.json` would be ignored — put changes in the Development
|
||||
file, or change the environment name first.
|
||||
that actually loads. There is **no `appsettings.Production.json` in the repo at all**, and adding one would
|
||||
be ignored until the environment name changes too — put changes in the Development file, or change the
|
||||
environment name first.
|
||||
|
||||
The relay's shared secret appears twice and the two must match: `Seams:Sms:Telegram:ApiKey` in the
|
||||
appsettings file and `API_KEY` in the compose file. It was rotated away from the value in
|
||||
@@ -64,7 +78,7 @@ people you trust, and none of which are acceptable once strangers can reach the
|
||||
### Going to Production later
|
||||
|
||||
1. Set `ASPNETCORE_ENVIRONMENT: Production` in `docker-compose.yml`.
|
||||
2. Create `appsettings.Production.json` with the same content as the Development file, but with **real**
|
||||
2. **Create** `appsettings.Production.json` (it does not exist) with the same content as the Development file, but with **real**
|
||||
`IdentitySettings:SecretKey` / `Encryptkey` — `StartupSecretsGuard` rejects anything containing
|
||||
`not-for-production` outside Development, so the current dev keys will refuse to boot (by design).
|
||||
Keep `Seams:FieldEncryption` byte-identical.
|
||||
|
||||
+1
-1
@@ -23,6 +23,6 @@ NEXT_PUBLIC_API_URL = https://localhost:5002
|
||||
|
||||
# Neshan **web** key (client-embeddable maps/search/reverse-geocode) — get one from
|
||||
# https://platform.neshan.org (a separate key from the server's NeshanGeocoder key, which lives in
|
||||
# server appsettings/user-secrets, never here). Leave unset to keep the address map-pin picker's
|
||||
# server appsettings, never here). Leave unset to keep the address map-pin picker's
|
||||
# bounded-canvas grid fallback (dev/CI/jsdom all work without a key).
|
||||
# NEXT_PUBLIC_NESHAN_KEY = your-neshan-web-key
|
||||
+6
-3
@@ -1,10 +1,13 @@
|
||||
# AGENTS.md — Balinyaar Web Client
|
||||
|
||||
The canonical agent guide for the frontend is **[CLAUDE.md](CLAUDE.md)** (same folder). It is the
|
||||
engineering contract: stack, commands, lint/type gates, routing, providers, data fetching, theming,
|
||||
i18n, cookies, and the rules every change must follow.
|
||||
The canonical agent guide for the frontend is **[CLAUDE.md](CLAUDE.md)** (same folder): stack,
|
||||
commands, the quality gates, the project structure, and the hard rules every change must follow.
|
||||
|
||||
- Current product truth (what to test, what's blocking, what's missing) → [../mvp/](../mvp/README.md)
|
||||
- Reference rules, archived, read on demand per area → [../archive/docs/rules/client/](../archive/docs/rules/client/)
|
||||
(structure · theme · components · forms · i18n · services · auth · testing)
|
||||
- Repo-wide context → [../CLAUDE.md](../CLAUDE.md)
|
||||
- Business rules in depth (archived) → [../archive/product/](../archive/product/index.md)
|
||||
- Human setup/run instructions → [README.md](README.md)
|
||||
- UI/design work → the **frontend-designer** skill
|
||||
|
||||
|
||||
+142
-1062
File diff suppressed because it is too large
Load Diff
@@ -1,116 +0,0 @@
|
||||
# Persian (fa) copy style guide
|
||||
|
||||
One page, binding for `messages/fa.json`. `scripts/check-copy.mjs` (`npm run lint:copy`, part of
|
||||
`npm run check`) enforces the banned-variant rules below so these decisions cannot silently regress.
|
||||
This file does not repeat `en.json` conventions beyond what's noted in §7 — the English catalog is
|
||||
hand-written and reviewed for idiom, not linted.
|
||||
|
||||
## 1. Brand name
|
||||
|
||||
**«بالینیار» — ZWNJ (``) between بالین and یار, always.** Never a plain space («بالین یار»).
|
||||
The brand name appears in money/trust copy (login, escrow, refunds) as often as anywhere else — an
|
||||
unstable brand mark there is the worst place to be inconsistent.
|
||||
|
||||
## 2. تأیید — hamza, always
|
||||
|
||||
Write **تأیید** (with hamza) and its derived forms — **تأییدشده**, **تأییدیه**, **تأیید کردن** — every
|
||||
time, never تایید/تاییدشده/تاییدیه (hamza-less). This is the single most frequent word in a
|
||||
verification product; one spelling, no exceptions, in every namespace (booking, payment, auth,
|
||||
verification, admin, payouts, bnpl, refunds, legal — all of them).
|
||||
|
||||
## 3. جستجو — one form
|
||||
|
||||
Standard form: **جستجو** (no ZWNJ, one word). Not «جستوجو» / «جست و جو». Applies to the noun and any
|
||||
compound (`در جستجو`, `نتایج جستجو`).
|
||||
|
||||
## 4. ZWNJ (نیمفاصله) rules
|
||||
|
||||
Use ZWNJ (``) — never a plain space or no separator — in:
|
||||
- **می + verb stem**: میشود، میکند، میپردازید، میماند (never میشود/می شود).
|
||||
- **Plural ها**: مراقبها-style compounds keep the ZWNJ before ها when the base ends in a consonant that
|
||||
would otherwise misread (`شبها` not `شبها`); a plain plural on a word already ending in a vowel/silent-h
|
||||
takes the ZWNJ too (`بچهها`).
|
||||
- **Compound past-participle adjectives**: تأییدشده، لغوشده، ردشده، منتشرشده، پرداختشده — the doer/state
|
||||
compound is one ZWNJ-joined word, not two spaced words («تایید شده») and not fused with no separator.
|
||||
- Brand name itself (§1) is the other load-bearing ZWNJ case.
|
||||
|
||||
## 5. Punctuation & quotes
|
||||
|
||||
- Persian text uses «...» guillemets for quoted terms/labels in prose (as this document does), and
|
||||
Persian «،» / «؛» for commas/semicolons *inside translated sentences* where the surrounding punctuation
|
||||
is itself Persian prose (most UI strings use plain Latin `,`/`;` today for simplicity in short labels —
|
||||
don't retrofit existing short strings, but prefer «،»/«؛» in new multi-clause sentences).
|
||||
- English (`en.json`) uses **straight** apostrophes (`don't`, `couldn't`) throughout — never curly
|
||||
(`’`, `‘`). One admin-namespace holdout (curly `don't`/`couldn't`) is fixed by this phase; don't
|
||||
reintroduce curly quotes when editing English copy.
|
||||
|
||||
## 6. Domain glossary
|
||||
|
||||
- **بیمار** — the care recipient, used consistently everywhere except one parenthetical. Do not adopt
|
||||
«مددجو» — it appeared exactly once (`booking.patient_label`) and has been dropped in favor of the
|
||||
99%-majority «بیمار».
|
||||
- **پرستار** — the caregiver, always (never «مراقب» as a noun for the person — «مراقب» only survives as
|
||||
an adjective/role qualifier, e.g. `booking.gender_label` "جنسیت مراقب" meaning "the caregiver's gender").
|
||||
- **رزرو** — a confirmed, paid booking. **درخواست رزرو** — a pre-payment request. Never conflate the two;
|
||||
a `booking_request` is never called «رزرو» before it converts.
|
||||
- **ویزیت** — one scheduled visit/session within a booking.
|
||||
- **شبا** — IBAN, always («شماره شبا» for the field label, «شبا» alone elsewhere).
|
||||
|
||||
## 7. Shell naming system
|
||||
|
||||
One metaphor per audience class, not four:
|
||||
- **End-user shells** (family, nurse — the apps people book/work through day to day) → **«اپلیکیشن»**:
|
||||
«اپلیکیشن خانواده», «اپلیکیشن پرستار».
|
||||
- **Back-office shells** (staff consoles — admin, partner-center) → **«کنسول»**: «کنسول مدیریت»,
|
||||
«کنسول همکار».
|
||||
- Never «نما» (view) or «پرتال» (portal) for a whole shell name — those read as one-off inconsistent
|
||||
metaphors. (`booking.evv_nurse_view` "نمای پرستار" is a different thing — a chip labeling *whose
|
||||
perspective* a shared booking-detail screen is rendered from, not a shell name; it correctly keeps
|
||||
«نما» in that narrower sense.)
|
||||
|
||||
## 8. Verification pipeline vs. the identity step
|
||||
|
||||
**«تأیید صلاحیت»** names the whole 7-step nurse trust pipeline (nav entry, the verification hub's title,
|
||||
its start/progress/approved states, the admin queue). **«احراز هویت»** stays the name of the one KYC
|
||||
step inside it (national-ID + civil-registry + liveness selfie) — both on the nurse side
|
||||
(`verification.step_identity_kyc`) and the admin side (`admin.step_identity_kyc`). A nurse who passed the
|
||||
KYC step but still sees a pipeline titled «احراز هویت» incomplete in the nav used to read as a
|
||||
contradiction; they no longer share a name.
|
||||
|
||||
## 9. Status vocabulary — one nurse-facing form, one admin-facing form
|
||||
|
||||
For "this step/item was rejected/failed" style states that appear on both a nurse-facing screen and an
|
||||
admin-facing screen for the *same underlying concept* (a verification step's outcome):
|
||||
- **Nurse-facing**: «رد شد» (`verification.status_failed`) — a short declarative sentence-style status,
|
||||
matching the register of its sibling `status_passed` ("تأییدشده")/`status_in_review` ("در حال بررسی").
|
||||
- **Admin-facing**: «ردشده» (`admin.step_failed`, `admin.agg_rejected`, `admin.rstatus_rejected`,
|
||||
`admin.mstatus_rejected`) — the compound-adjective state form, matching the admin namespace's own
|
||||
`step_passed`/`agg_approved`/`center_state_verified` ("تأییدشده") pattern.
|
||||
|
||||
This does **not** extend to unrelated money-failure vocabulary (`payouts.pstatus_failed`,
|
||||
`refunds.rstatus_failed`, `admin.batch_status_failed` all legitimately use «ناموفق» — a transfer/payment
|
||||
*failing* is a different concept from a document being *rejected*, and conflating them would blur a real
|
||||
distinction).
|
||||
|
||||
## 10. Digits policy
|
||||
|
||||
Persian digits (۰۱۲۳۴۵۶۷۸۹) everywhere on `/fa` — both hard-coded literals (`"۲۴ ساعت"`) and
|
||||
interpolated numbers. For an interpolated `{count}`/`{hours}`/… inside an ICU message, use the ICU
|
||||
`number` sub-format (`{count, number}`) or a plain `#` inside a `plural` block — next-intl formats both
|
||||
through the active locale (`fa` → Persian digits) automatically. When a raw number is interpolated at a
|
||||
call site instead of through ICU (e.g. built into a larger string in code, not a message placeholder),
|
||||
route it through `formatNumber` (`@/utils`) — never template a raw JS number directly into Persian text.
|
||||
|
||||
## 11. Register
|
||||
|
||||
Formal شما throughout, with polite imperatives (کنید) for actions and instructions. Already consistent
|
||||
across the whole catalog — this codifies it so a future addition can't drift into informal تو/imperative
|
||||
stems (نکن, برو).
|
||||
|
||||
## 12. Policy numbers
|
||||
|
||||
Legally/financially sensitive numbers that the admin config panel can change (the dispute-window hours,
|
||||
cancellation lead-time hours, refund ETA days) are **never hard-coded into a message string**. The
|
||||
message key takes a parameter (`{hours}`, `{minDays}`/`{maxDays}`) and the call site interpolates from
|
||||
`client/src/constants/policy.ts` (single-sourced, REQ-065 tracks the eventual public config-read that
|
||||
replaces the constants file). A config edit must never again silently make the UI copy lie.
|
||||
@@ -23,6 +23,7 @@
|
||||
"payouts": "Payouts",
|
||||
"reviews": "Reviews",
|
||||
"config": "Configuration",
|
||||
"catalog": "Catalog",
|
||||
"holidays": "Holidays",
|
||||
"alerts": "Alerts",
|
||||
"audit": "Audit log",
|
||||
@@ -309,6 +310,10 @@
|
||||
"line_hint": "Building, street, unit — the detail a nurse needs to find the door.",
|
||||
"line_required": "Enter the street address",
|
||||
"city_required": "Select a city",
|
||||
"recipient_name_label": "Recipient name",
|
||||
"recipient_name_required": "Enter who the nurse should ask for",
|
||||
"recipient_phone_label": "Recipient phone",
|
||||
"recipient_phone_invalid": "Enter a valid Iranian mobile number",
|
||||
"set_primary_toggle": "Set as primary address",
|
||||
"map_hint": "Tap or drag the marker to the patient's exact location.",
|
||||
"map_required": "Drop a pin on the map",
|
||||
@@ -474,6 +479,7 @@
|
||||
"unnamed_nurse": "Nurse",
|
||||
"unnamed_service": "Service",
|
||||
"completed_visits": "{count, number} successful visits",
|
||||
"more_services_count": "+{count, plural, one {# more service} other {# more services}}",
|
||||
"reviews_count": "({count, plural, =0 {no reviews} one {# review} other {# reviews}})",
|
||||
"distance_km": "{km} km",
|
||||
"price_from": "from",
|
||||
@@ -773,6 +779,11 @@
|
||||
"total_payable_label": "Total to pay",
|
||||
"secure_gateway_notice": "Secure payment via bank gateway",
|
||||
"cta_pay": "Continue to payment",
|
||||
"gateway_title": "Redirecting to the payment gateway",
|
||||
"gateway_hint": "This is a demo page standing in for the bank gateway.",
|
||||
"gateway_reference_label": "Reference",
|
||||
"gateway_pay_success": "Pay (demo)",
|
||||
"gateway_pay_fail": "Cancel",
|
||||
"bnpl_option": "Or pay in installments",
|
||||
"state_initiating": "Starting payment…",
|
||||
"state_redirecting": "Redirecting to the payment gateway…",
|
||||
@@ -1683,6 +1694,45 @@
|
||||
"hol_bank_hint": "When on, payouts falling on this day shift to the next business day.",
|
||||
"hol_saved": "Holiday saved.",
|
||||
"hol_year": "Year",
|
||||
"status_active": "Active",
|
||||
"status_inactive": "Inactive",
|
||||
"cat_title": "Catalog",
|
||||
"cat_subtitle": "Categories and the pricing dimensions nurses build their offerings from.",
|
||||
"cat_empty": "No categories yet.",
|
||||
"cat_col_name": "Category",
|
||||
"cat_col_order": "Order",
|
||||
"cat_col_status": "Status",
|
||||
"cat_add": "Add category",
|
||||
"cat_edit": "Edit",
|
||||
"cat_activate": "Activate",
|
||||
"cat_deactivate": "Deactivate",
|
||||
"cat_activated": "Category activated.",
|
||||
"cat_deactivated": "Category deactivated.",
|
||||
"cat_saved": "Category saved.",
|
||||
"cat_not_found": "This category could not be found.",
|
||||
"cat_name_fa": "Name (Persian)",
|
||||
"cat_name_en": "Name (English)",
|
||||
"cat_description_fa": "Description (Persian)",
|
||||
"cat_description_en": "Description (English)",
|
||||
"cat_icon_key": "Icon key (optional)",
|
||||
"cat_icon_hint": "Matches a name in the icon registry; an unknown or missing key falls back gracefully.",
|
||||
"cat_sort_order": "Sort order",
|
||||
"og_section_title": "Pricing options",
|
||||
"og_empty": "No pricing dimensions yet.",
|
||||
"og_add": "Add dimension",
|
||||
"og_edit": "Edit",
|
||||
"og_cross_category_badge": "All categories",
|
||||
"og_required_badge": "Required",
|
||||
"og_optional_badge": "Optional",
|
||||
"og_scope_all": "Applies to all categories",
|
||||
"og_scope_all_hint": "A cross-category dimension is offered for every category, not just this one.",
|
||||
"og_scope_this_hint": "Offered only for this category.",
|
||||
"og_is_required": "Must be answered before booking",
|
||||
"og_saved": "Pricing dimension saved.",
|
||||
"og_value_add": "Add value",
|
||||
"og_value_edit": "Edit value",
|
||||
"og_value_saved": "Value saved.",
|
||||
"og_value_empty": "No values yet.",
|
||||
"alert_title": "Support alerts",
|
||||
"alert_subtitle": "Internal triage — never shown to customers or nurses.",
|
||||
"alert_empty": "No open alerts.",
|
||||
@@ -2100,6 +2150,7 @@
|
||||
"admin_tickets_sub": "The global ticket queue",
|
||||
"admin_alerts_sub": "The internal alert worklist",
|
||||
"admin_config_sub": "Config keys and their change history",
|
||||
"admin_catalog_sub": "Categories and their pricing dimensions",
|
||||
"admin_holidays_sub": "The bank-holiday calendar",
|
||||
"admin_audit_sub": "Read-only record of every change",
|
||||
"admin_partners_sub": "Partner centers and sponsored nurses",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"payouts": "تسویهها",
|
||||
"reviews": "نظرات",
|
||||
"config": "پیکربندی",
|
||||
"catalog": "کاتالوگ",
|
||||
"holidays": "تعطیلات",
|
||||
"alerts": "هشدارها",
|
||||
"audit": "گزارش ممیزی",
|
||||
@@ -309,6 +310,10 @@
|
||||
"line_hint": "پلاک، خیابان، واحد — جزئیاتی که پرستار برای یافتن درِ منزل نیاز دارد.",
|
||||
"line_required": "نشانی کامل را وارد کنید",
|
||||
"city_required": "شهر را انتخاب کنید",
|
||||
"recipient_name_label": "نام تحویلگیرنده",
|
||||
"recipient_name_required": "نام فردی که پرستار باید بپرسد را وارد کنید",
|
||||
"recipient_phone_label": "شماره تماس تحویلگیرنده",
|
||||
"recipient_phone_invalid": "یک شماره موبایل معتبر ایرانی وارد کنید",
|
||||
"set_primary_toggle": "بهعنوان آدرس اصلی تنظیم شود",
|
||||
"map_hint": "برای تعیین محل دقیق بیمار، نشانگر را بکشید یا روی نقشه بزنید.",
|
||||
"map_required": "روی نقشه یک پین بگذارید",
|
||||
@@ -474,6 +479,7 @@
|
||||
"unnamed_nurse": "پرستار",
|
||||
"unnamed_service": "خدمت",
|
||||
"completed_visits": "{count, number} ویزیت موفق",
|
||||
"more_services_count": "+{count, plural, one {# خدمت دیگر} other {# خدمت دیگر}}",
|
||||
"reviews_count": "({count, plural, =0 {بدون نظر} one {# نظر} other {# نظر}})",
|
||||
"distance_km": "{km} کیلومتر",
|
||||
"price_from": "از",
|
||||
@@ -773,6 +779,11 @@
|
||||
"total_payable_label": "مبلغ قابل پرداخت",
|
||||
"secure_gateway_notice": "پرداخت امن از طریق درگاه بانکی",
|
||||
"cta_pay": "ادامه پرداخت",
|
||||
"gateway_title": "در حال انتقال به درگاه پرداخت",
|
||||
"gateway_hint": "این صفحه نمایشی جایگزین درگاه بانکی است.",
|
||||
"gateway_reference_label": "کد مرجع",
|
||||
"gateway_pay_success": "پرداخت (نمایشی)",
|
||||
"gateway_pay_fail": "انصراف",
|
||||
"bnpl_option": "یا پرداخت اقساطی",
|
||||
"state_initiating": "در حال آغاز پرداخت…",
|
||||
"state_redirecting": "در حال انتقال به درگاه پرداخت…",
|
||||
@@ -1683,6 +1694,45 @@
|
||||
"hol_bank_hint": "در صورت فعال بودن، تسویههای این روز به روز کاری بعد منتقل میشوند.",
|
||||
"hol_saved": "تعطیلی ذخیره شد.",
|
||||
"hol_year": "سال",
|
||||
"status_active": "فعال",
|
||||
"status_inactive": "غیرفعال",
|
||||
"cat_title": "کاتالوگ خدمات",
|
||||
"cat_subtitle": "دستهبندیهای خدمات و ابعاد قیمتگذاری که پرستاران خدمات خود را بر اساس آنها میسازند.",
|
||||
"cat_empty": "هنوز دستهبندیای ثبت نشده است.",
|
||||
"cat_col_name": "دستهبندی",
|
||||
"cat_col_order": "ترتیب",
|
||||
"cat_col_status": "وضعیت",
|
||||
"cat_add": "افزودن دستهبندی",
|
||||
"cat_edit": "ویرایش",
|
||||
"cat_activate": "فعالسازی",
|
||||
"cat_deactivate": "غیرفعالسازی",
|
||||
"cat_activated": "دستهبندی فعال شد.",
|
||||
"cat_deactivated": "دستهبندی غیرفعال شد.",
|
||||
"cat_saved": "دستهبندی ذخیره شد.",
|
||||
"cat_not_found": "این دستهبندی پیدا نشد.",
|
||||
"cat_name_fa": "نام (فارسی)",
|
||||
"cat_name_en": "نام (انگلیسی)",
|
||||
"cat_description_fa": "توضیحات (فارسی)",
|
||||
"cat_description_en": "توضیحات (انگلیسی)",
|
||||
"cat_icon_key": "کلید آیکون (اختیاری)",
|
||||
"cat_icon_hint": "باید با یکی از نامهای ثبتشدهٔ آیکون مطابقت داشته باشد؛ کلید ناشناخته یا خالی بهصورت خودکار جایگزین میشود.",
|
||||
"cat_sort_order": "ترتیب نمایش",
|
||||
"og_section_title": "ابعاد قیمتگذاری",
|
||||
"og_empty": "هنوز بُعد قیمتگذاریای ثبت نشده است.",
|
||||
"og_add": "افزودن بُعد",
|
||||
"og_edit": "ویرایش",
|
||||
"og_cross_category_badge": "همهٔ دستهبندیها",
|
||||
"og_required_badge": "اجباری",
|
||||
"og_optional_badge": "اختیاری",
|
||||
"og_scope_all": "برای همهٔ دستهبندیها اعمال شود",
|
||||
"og_scope_all_hint": "یک بُعد بیندستهای برای همهٔ دستهبندیها نمایش داده میشود، نه فقط همین یکی.",
|
||||
"og_scope_this_hint": "فقط برای همین دستهبندی نمایش داده میشود.",
|
||||
"og_is_required": "پیش از رزرو باید پاسخ داده شود",
|
||||
"og_saved": "بُعد قیمتگذاری ذخیره شد.",
|
||||
"og_value_add": "افزودن مقدار",
|
||||
"og_value_edit": "ویرایش مقدار",
|
||||
"og_value_saved": "مقدار ذخیره شد.",
|
||||
"og_value_empty": "هنوز مقداری ثبت نشده است.",
|
||||
"alert_title": "هشدارهای پشتیبانی",
|
||||
"alert_subtitle": "صف داخلی — هرگز به مشتری یا پرستار نمایش داده نمیشود.",
|
||||
"alert_empty": "هشداری برای رسیدگی نیست.",
|
||||
@@ -2100,6 +2150,7 @@
|
||||
"admin_tickets_sub": "صف سراسری تیکتها",
|
||||
"admin_alerts_sub": "کارتابل داخلی هشدارها",
|
||||
"admin_config_sub": "کلیدهای پیکربندی و تاریخچهٔ تغییرها",
|
||||
"admin_catalog_sub": "دستهبندیهای خدمات و ابعاد قیمتگذاری آنها",
|
||||
"admin_holidays_sub": "تقویم تعطیلات بانکی",
|
||||
"admin_audit_sub": "گزارش تغییرها، فقطخواندنی",
|
||||
"admin_partners_sub": "مراکز همکار و پرستاران تحت پوشش",
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Lints client/messages/fa.json against the banned-orthography-variant rules in
|
||||
* client/messages/STYLE.md — enforces the phase-12 sweep so it cannot silently regress.
|
||||
* docs/rules/client/i18n.md §4 — enforces the phase-12 copy sweep so it cannot silently regress.
|
||||
* Exits non-zero (and prints every offending key) on any match.
|
||||
*
|
||||
* The rules below are the machine-checkable subset. The full Persian style guide (glossary, register,
|
||||
* shell naming, the ZWNJ cases a grep can't express) lives in that doc; keep the two in step.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@@ -172,6 +172,8 @@ export default function AddressesPage() {
|
||||
latitude: editing.latitude,
|
||||
longitude: editing.longitude,
|
||||
isPrimary: editing.isPrimary,
|
||||
recipientName: editing.recipientName,
|
||||
recipientPhone: editing.recipientPhone,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
import { Suspense } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Box, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppLoading, PaymentStateCard } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import {
|
||||
CHECKOUT_QUERY_OUTCOME,
|
||||
CHECKOUT_QUERY_REQUEST_ID,
|
||||
CHECKOUT_QUERY_TRANSACTION_ID,
|
||||
} from '@/services/payment/constants';
|
||||
import type { GatewayReturnOutcome } from '@/services/payment/types';
|
||||
|
||||
/**
|
||||
* Mock-gateway harness — a **test harness, not a product feature**, mirroring the BNPL provider-handoff
|
||||
* harness (`checkout/bnpl/gateway/page.tsx`). It stands in for the PSP so the initiate → redirect → return
|
||||
* round-trip is exercisable without a real gateway: `MockPaymentProvider.InitPaymentAsync` points its
|
||||
* `redirectUrl` here, and the pay/cancel buttons drive both outcome branches of the return surface (a real
|
||||
* PSP redirects back after the cardholder pays or cancels). Deliberately reachable in every build, including
|
||||
* production — unlike the BNPL harness, this one is NOT env-gated: this deployment is a demo with no real
|
||||
* gateway wired up (`mvp/blockers.md` §B.5), so the mock stays reachable until a real acquirer is switched
|
||||
* on, at which point `redirectUrl` becomes the PSP's absolute URL and this page is never reached.
|
||||
*/
|
||||
export default function MockGatewayPage() {
|
||||
return (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<MockGatewayScreen />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function MockGatewayScreen() {
|
||||
const t = useTranslations('payment');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
|
||||
const requestId = params.get(CHECKOUT_QUERY_REQUEST_ID) ?? '';
|
||||
const transactionId = params.get(CHECKOUT_QUERY_TRANSACTION_ID) ?? '';
|
||||
|
||||
const returnWith = (outcome: GatewayReturnOutcome) => {
|
||||
const query = new URLSearchParams({
|
||||
[CHECKOUT_QUERY_REQUEST_ID]: requestId,
|
||||
[CHECKOUT_QUERY_TRANSACTION_ID]: transactionId,
|
||||
[CHECKOUT_QUERY_OUTCOME]: outcome,
|
||||
});
|
||||
router.replace(`/${locale}${ROUTES.CHECKOUT_RETURN}?${query.toString()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<PaymentStateCard icon="payment" tone="var(--bal-secondary)" title={t('gateway_title')} body={t('gateway_hint')}>
|
||||
{transactionId ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('gateway_reference_label')}:{' '}
|
||||
<Box component="span" dir="ltr">
|
||||
#{transactionId}
|
||||
</Box>
|
||||
</Typography>
|
||||
) : null}
|
||||
<Stack sx={{ gap: 1.5, alignItems: 'center', width: '100%' }}>
|
||||
<AppButton color="secondary" variant="contained" size="large" onClick={() => returnWith('success')}>
|
||||
{t('gateway_pay_success')}
|
||||
</AppButton>
|
||||
<AppButton variant="text" color="error" onClick={() => returnWith('failure')}>
|
||||
{t('gateway_pay_fail')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</PaymentStateCard>
|
||||
);
|
||||
}
|
||||
@@ -170,14 +170,19 @@ function EditableTabs({ patientId, tab, canEdit }: { patientId: number; tab: Car
|
||||
}
|
||||
|
||||
const data = record.data;
|
||||
// The real endpoint fully replaces all three lists from the request (no server-side merge), so every save
|
||||
// must resend the whole current plan — not just the tab being edited — or the other two tabs get wiped.
|
||||
const save = (patch: Partial<Pick<FamilyCareRecord, 'medications' | 'routine' | 'tasks'>>, onDone: () => void) => {
|
||||
update.mutate(patch, {
|
||||
onSuccess: () => {
|
||||
enqueueSnackbar(t('saved'), { variant: 'success' });
|
||||
onDone();
|
||||
update.mutate(
|
||||
{ medications: data.medications, routine: data.routine, tasks: data.tasks, ...patch },
|
||||
{
|
||||
onSuccess: () => {
|
||||
enqueueSnackbar(t('saved'), { variant: 'success' });
|
||||
onDone();
|
||||
},
|
||||
onError: () => enqueueSnackbar(t('save_error'), { variant: 'error' }),
|
||||
},
|
||||
onError: () => enqueueSnackbar(t('save_error'), { variant: 'error' }),
|
||||
});
|
||||
);
|
||||
};
|
||||
|
||||
if (tab === 'medications') {
|
||||
|
||||
@@ -140,6 +140,9 @@ function ProfileHeader({ profile }: { profile: NurseProfile }) {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
// Reached by direct URL, not gated by the search-index verified-only invariant — unlike the result card
|
||||
// (NurseResultCard), an unverified nurse's profile CAN be opened this way, so the badge must reflect
|
||||
// `profile.isVerified` (already correctly fetched by `getNurseProfile`), never an assumed-verified literal.
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
@@ -170,7 +173,7 @@ function ProfileHeader({ profile }: { profile: NurseProfile }) {
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
|
||||
<TrustBadge state="verified" nurseId={profile.nurseId} />
|
||||
<TrustBadge state={profile.isVerified ? 'verified' : 'unverified'} nurseId={profile.nurseId} />
|
||||
{profile.inoMembership ? (
|
||||
<Chip
|
||||
icon={<AppIcon icon="license" size={16} color="var(--bal-primary)" />}
|
||||
|
||||
@@ -35,6 +35,7 @@ export default function AdminOverviewScreen() {
|
||||
{ section: 'support', title: tNav('tickets'), subtitle: th('admin_tickets_sub'), path: ROUTES.ADMIN_TICKETS, icon: 'support', enabled: caps.canManageTickets },
|
||||
{ section: 'support', title: tNav('alerts'), subtitle: th('admin_alerts_sub'), path: ROUTES.ADMIN_ALERTS, icon: 'alerts', enabled: caps.canManageAlerts },
|
||||
{ section: 'system', title: tNav('config'), subtitle: th('admin_config_sub'), path: ROUTES.ADMIN_CONFIG, icon: 'config', enabled: caps.canConfig },
|
||||
{ section: 'system', title: tNav('catalog'), subtitle: th('admin_catalog_sub'), path: ROUTES.ADMIN_CATALOG, icon: 'category', enabled: caps.canManageCatalog },
|
||||
{ section: 'system', title: tNav('holidays'), subtitle: th('admin_holidays_sub'), path: ROUTES.ADMIN_HOLIDAYS, icon: 'calendar', enabled: caps.canConfig },
|
||||
{ section: 'system', title: tNav('audit'), subtitle: th('admin_audit_sub'), path: ROUTES.ADMIN_AUDIT, icon: 'audit', enabled: caps.canViewAudit },
|
||||
{ section: 'system', title: tNav('partners'), subtitle: th('admin_partners_sub'), path: ROUTES.ADMIN_PARTNERS, icon: 'partners', enabled: caps.canManagePartners },
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import {
|
||||
Box,
|
||||
Chip,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Divider,
|
||||
FormControlLabel,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Switch,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { AppButton, PageHeader, RhfControlGroup, RhfTextField, StatusChip } from '@/components';
|
||||
import { AdminEmptyState, AdminErrorState } from '@/components/admin';
|
||||
import { useAdminCapabilities, useAdminBackToList } from '@/hooks';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { ADMIN_CATEGORIES_PAGE_SIZE } from '@/services/catalog/constants';
|
||||
import type {
|
||||
CreateOptionValueInput,
|
||||
OptionGroupInput,
|
||||
ServiceOptionGroup,
|
||||
ServiceOptionValue,
|
||||
UpdateOptionValueInput,
|
||||
} from '@/services/catalog/types';
|
||||
import {
|
||||
useAdminCategories,
|
||||
useAdminOptionGroups,
|
||||
useCreateOptionGroup,
|
||||
useCreateOptionValue,
|
||||
useSetCategoryActive,
|
||||
useUpdateOptionGroup,
|
||||
useUpdateOptionValue,
|
||||
} from '@/services/catalog';
|
||||
import { CategoryFormDialog } from '../page';
|
||||
|
||||
/**
|
||||
* A category's pricing-options console (f6 blocker) — the option groups (dimensions) and their values
|
||||
* the nurse builder offers for this category, plus every cross-category group. Option groups have no
|
||||
* active/inactive toggle in the contract (create/edit only); values do, exposed as a switch in the
|
||||
* value edit dialog rather than a hard delete, matching the "reference data, never hard-deleted" rule
|
||||
* that already governs categories and variants.
|
||||
*/
|
||||
export default function AdminCatalogCategoryPage() {
|
||||
const t = useTranslations('admin');
|
||||
const locale = useLocale();
|
||||
const caps = useAdminCapabilities();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const goBack = useAdminBackToList(`/${locale}${ROUTES.ADMIN_CATALOG}`);
|
||||
|
||||
const params = useParams<{ categoryId: string }>();
|
||||
const parsed = Number(params?.categoryId);
|
||||
const categoryId = Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||
|
||||
// No single-category admin read exists — categories are few, so the admin list (uncached) doubles
|
||||
// as the detail source, exactly like the public reference-data reads do for the browse side.
|
||||
const categories = useAdminCategories({ page: 1, pageSize: ADMIN_CATEGORIES_PAGE_SIZE });
|
||||
const category = categories.data?.items.find((c) => c.id === categoryId) ?? null;
|
||||
|
||||
const groups = useAdminOptionGroups(categoryId || null);
|
||||
const setActive = useSetCategoryActive();
|
||||
|
||||
const [editingCategory, setEditingCategory] = useState(false);
|
||||
const [groupDialog, setGroupDialog] = useState<ServiceOptionGroup | 'new' | null>(null);
|
||||
const [valueDialog, setValueDialog] = useState<{ groupId: number; value: ServiceOptionValue | 'new' } | null>(null);
|
||||
|
||||
const back = <PageHeader title={t('og_section_title')} onBack={goBack} backLabel={t('back')} />;
|
||||
|
||||
if (categories.isLoading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{back}
|
||||
<Skeleton variant="rounded" height={120} />
|
||||
<Skeleton variant="rounded" height={220} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (categories.isError) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{back}
|
||||
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => categories.refetch()} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!category) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{back}
|
||||
<AdminEmptyState icon="category" title={t('cat_not_found')} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const onToggleActive = () => {
|
||||
setActive.mutate(
|
||||
{ id: category.id, isActive: !category.isActive },
|
||||
{ onSuccess: () => enqueueSnackbar(t(category.isActive ? 'cat_deactivated' : 'cat_activated'), { variant: 'success' }) },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<PageHeader
|
||||
title={category.nameFa}
|
||||
subtitle={category.nameEn}
|
||||
onBack={goBack}
|
||||
backLabel={t('back')}
|
||||
meta={<StatusChip status={category.isActive ? 'active' : 'neutral'} label={t(category.isActive ? 'status_active' : 'status_inactive')} />}
|
||||
/>
|
||||
|
||||
{category.descriptionFa || category.descriptionEn ? (
|
||||
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{category.descriptionFa || category.descriptionEn}
|
||||
</Typography>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
{caps.canManageCatalog ? (
|
||||
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
|
||||
<AppButton variant="outlined" color="inherit" startIcon="edit" onClick={() => setEditingCategory(true)}>
|
||||
{t('cat_edit')}
|
||||
</AppButton>
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color={category.isActive ? 'error' : 'primary'}
|
||||
onClick={onToggleActive}
|
||||
disabled={setActive.isPending}
|
||||
>
|
||||
{t(category.isActive ? 'cat_deactivate' : 'cat_activate')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('og_section_title')}
|
||||
</Typography>
|
||||
{caps.canManageCatalog ? (
|
||||
<AppButton variant="text" color="primary" startIcon="add" onClick={() => setGroupDialog('new')}>
|
||||
{t('og_add')}
|
||||
</AppButton>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
{groups.isLoading ? (
|
||||
<Skeleton variant="rounded" height={160} />
|
||||
) : groups.isError ? (
|
||||
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => groups.refetch()} />
|
||||
) : (groups.data ?? []).length === 0 ? (
|
||||
<AdminEmptyState icon="tune" title={t('og_empty')} />
|
||||
) : (
|
||||
(groups.data ?? []).map((group) => (
|
||||
<Paper
|
||||
key={group.id}
|
||||
elevation={0}
|
||||
sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}
|
||||
>
|
||||
<Stack sx={{ gap: 1.25 }}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'flex-start', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{group.nameFa} · {group.nameEn}
|
||||
</Typography>
|
||||
<Stack direction="row" sx={{ gap: 0.5, mt: 0.5, flexWrap: 'wrap' }}>
|
||||
{group.serviceCategoryId == null ? <Chip size="small" label={t('og_cross_category_badge')} /> : null}
|
||||
<Chip size="small" variant="outlined" label={t(group.isRequired ? 'og_required_badge' : 'og_optional_badge')} />
|
||||
</Stack>
|
||||
</Box>
|
||||
{caps.canManageCatalog ? (
|
||||
<AppButton variant="text" color="primary" startIcon="edit" onClick={() => setGroupDialog(group)}>
|
||||
{t('cat_edit')}
|
||||
</AppButton>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
{group.values.length === 0 ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('og_value_empty')}
|
||||
</Typography>
|
||||
) : (
|
||||
group.values.map((value) => (
|
||||
<Stack
|
||||
key={value.id}
|
||||
direction="row"
|
||||
sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}
|
||||
>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Typography variant="body2">
|
||||
{value.nameFa} · {value.nameEn}
|
||||
</Typography>
|
||||
<StatusChip status={value.isActive ? 'active' : 'neutral'} label={t(value.isActive ? 'status_active' : 'status_inactive')} />
|
||||
</Stack>
|
||||
{caps.canManageCatalog ? (
|
||||
<AppButton variant="text" color="primary" onClick={() => setValueDialog({ groupId: group.id, value })}>
|
||||
{t('cat_edit')}
|
||||
</AppButton>
|
||||
) : null}
|
||||
</Stack>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{caps.canManageCatalog ? (
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
startIcon="add"
|
||||
onClick={() => setValueDialog({ groupId: group.id, value: 'new' })}
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
{t('og_value_add')}
|
||||
</AppButton>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{editingCategory ? <CategoryFormDialog category={category} onClose={() => setEditingCategory(false)} /> : null}
|
||||
{groupDialog ? (
|
||||
<OptionGroupFormDialog
|
||||
categoryId={category.id}
|
||||
group={groupDialog === 'new' ? null : groupDialog}
|
||||
onClose={() => setGroupDialog(null)}
|
||||
/>
|
||||
) : null}
|
||||
{valueDialog ? (
|
||||
<OptionValueFormDialog
|
||||
groupId={valueDialog.groupId}
|
||||
value={valueDialog.value === 'new' ? null : valueDialog.value}
|
||||
onClose={() => setValueDialog(null)}
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** `'this'` maps to `serviceCategoryId: categoryId`; `'all'` maps to the cross-category `null`. */
|
||||
interface GroupFormState {
|
||||
scope: 'this' | 'all';
|
||||
nameFa: string;
|
||||
nameEn: string;
|
||||
isRequired: boolean;
|
||||
sortOrder: string;
|
||||
}
|
||||
|
||||
function initialGroupForm(group: ServiceOptionGroup | null): GroupFormState {
|
||||
return {
|
||||
scope: group != null && group.serviceCategoryId == null ? 'all' : 'this',
|
||||
nameFa: group?.nameFa ?? '',
|
||||
nameEn: group?.nameEn ?? '',
|
||||
isRequired: group?.isRequired ?? false,
|
||||
sortOrder: group != null ? String(group.sortOrder) : '0',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The create/edit dialog for a pricing dimension (option group). Re-scoping an existing cross-category
|
||||
* group to "this category only" removes it from every other category that currently sees it — the hint
|
||||
* text under the switch says so, matching what `UpdateServiceOptionGroupCommand` actually does server-side.
|
||||
*/
|
||||
function OptionGroupFormDialog({
|
||||
categoryId,
|
||||
group,
|
||||
onClose,
|
||||
}: {
|
||||
categoryId: number;
|
||||
group: ServiceOptionGroup | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const t = useTranslations('admin');
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const isEdit = group != null;
|
||||
const create = useCreateOptionGroup();
|
||||
const update = useUpdateOptionGroup();
|
||||
const isPending = create.isPending || update.isPending;
|
||||
|
||||
const form = useForm<GroupFormState>({ mode: 'onTouched', defaultValues: initialGroupForm(group) });
|
||||
const { control, handleSubmit, formState } = form;
|
||||
const scope = useWatch({ control, name: 'scope' });
|
||||
|
||||
const onSave = (values: GroupFormState) => {
|
||||
const input: OptionGroupInput = {
|
||||
serviceCategoryId: values.scope === 'all' ? null : categoryId,
|
||||
nameFa: values.nameFa.trim(),
|
||||
nameEn: values.nameEn.trim(),
|
||||
isRequired: values.isRequired,
|
||||
sortOrder: Number(values.sortOrder),
|
||||
};
|
||||
const onSuccess = () => {
|
||||
enqueueSnackbar(t('og_saved'), { variant: 'success' });
|
||||
onClose();
|
||||
};
|
||||
if (isEdit) update.mutate({ id: group.id, input }, { onSuccess });
|
||||
else create.mutate(input, { onSuccess });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onClose={isPending ? undefined : onClose} fullWidth maxWidth="xs">
|
||||
<DialogTitle sx={{ fontWeight: 800 }}>{isEdit ? t('og_edit') : t('og_add')}</DialogTitle>
|
||||
<FormProvider {...form}>
|
||||
<DialogContent>
|
||||
{/* A real <form> so Enter submits from any field; the footer button (outside DialogContent)
|
||||
calls the same handler directly rather than relying on cross-element form association. */}
|
||||
<Stack component="form" noValidate onSubmit={handleSubmit(onSave)} sx={{ gap: 2, mt: 1 }}>
|
||||
<RhfTextField<GroupFormState>
|
||||
name="nameFa"
|
||||
label={t('cat_name_fa')}
|
||||
rules={{ validate: (value) => String(value ?? '').trim() !== '' }}
|
||||
/>
|
||||
<RhfTextField<GroupFormState>
|
||||
name="nameEn"
|
||||
label={t('cat_name_en')}
|
||||
rules={{ validate: (value) => String(value ?? '').trim() !== '' }}
|
||||
/>
|
||||
<Box>
|
||||
<RhfControlGroup<GroupFormState> name="scope">
|
||||
{({ field }) => (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={field.value === 'all'}
|
||||
onChange={(event) => field.onChange(event.target.checked ? 'all' : 'this')}
|
||||
/>
|
||||
}
|
||||
label={t('og_scope_all')}
|
||||
/>
|
||||
)}
|
||||
</RhfControlGroup>
|
||||
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>
|
||||
{scope === 'all' ? t('og_scope_all_hint') : t('og_scope_this_hint')}
|
||||
</Typography>
|
||||
</Box>
|
||||
<RhfControlGroup<GroupFormState> name="isRequired">
|
||||
{({ field }) => (
|
||||
<FormControlLabel
|
||||
control={<Switch checked={Boolean(field.value)} onChange={(event) => field.onChange(event.target.checked)} />}
|
||||
label={t('og_is_required')}
|
||||
/>
|
||||
)}
|
||||
</RhfControlGroup>
|
||||
<RhfTextField<GroupFormState>
|
||||
name="sortOrder"
|
||||
type="number"
|
||||
label={t('cat_sort_order')}
|
||||
rules={{ validate: (value) => Number.isFinite(Number(value)) }}
|
||||
slotProps={{ htmlInput: { step: 1 } }}
|
||||
/>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<AppButton variant="text" color="inherit" onClick={onClose} disabled={isPending}>
|
||||
{t('cancel')}
|
||||
</AppButton>
|
||||
<AppButton
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleSubmit(onSave)}
|
||||
disabled={!formState.isValid || isPending}
|
||||
>
|
||||
{isPending ? t('saving') : t('save')}
|
||||
</AppButton>
|
||||
</DialogActions>
|
||||
</FormProvider>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface ValueFormState {
|
||||
nameFa: string;
|
||||
nameEn: string;
|
||||
sortOrder: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
function initialValueForm(value: ServiceOptionValue | null): ValueFormState {
|
||||
return {
|
||||
nameFa: value?.nameFa ?? '',
|
||||
nameEn: value?.nameEn ?? '',
|
||||
sortOrder: value != null ? String(value.sortOrder) : '0',
|
||||
isActive: value?.isActive ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The create/edit dialog for a concrete option value. Create has no active flag (the server always
|
||||
* creates active); edit exposes it as a switch — the only way to deactivate a value, since re-parenting
|
||||
* to a different group is deliberately not supported (it would silently change variants that already
|
||||
* answered with this value).
|
||||
*/
|
||||
function OptionValueFormDialog({
|
||||
groupId,
|
||||
value,
|
||||
onClose,
|
||||
}: {
|
||||
groupId: number;
|
||||
value: ServiceOptionValue | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const t = useTranslations('admin');
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const isEdit = value != null;
|
||||
const create = useCreateOptionValue();
|
||||
const update = useUpdateOptionValue();
|
||||
const isPending = create.isPending || update.isPending;
|
||||
|
||||
const form = useForm<ValueFormState>({ mode: 'onTouched', defaultValues: initialValueForm(value) });
|
||||
const { handleSubmit, formState } = form;
|
||||
|
||||
const onSave = (values: ValueFormState) => {
|
||||
const onSuccess = () => {
|
||||
enqueueSnackbar(t('og_value_saved'), { variant: 'success' });
|
||||
onClose();
|
||||
};
|
||||
if (isEdit) {
|
||||
const input: UpdateOptionValueInput = {
|
||||
nameFa: values.nameFa.trim(),
|
||||
nameEn: values.nameEn.trim(),
|
||||
sortOrder: Number(values.sortOrder),
|
||||
isActive: values.isActive,
|
||||
};
|
||||
update.mutate({ id: value.id, input }, { onSuccess });
|
||||
} else {
|
||||
const input: CreateOptionValueInput = {
|
||||
optionGroupId: groupId,
|
||||
nameFa: values.nameFa.trim(),
|
||||
nameEn: values.nameEn.trim(),
|
||||
sortOrder: Number(values.sortOrder),
|
||||
};
|
||||
create.mutate(input, { onSuccess });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onClose={isPending ? undefined : onClose} fullWidth maxWidth="xs">
|
||||
<DialogTitle sx={{ fontWeight: 800 }}>{isEdit ? t('og_value_edit') : t('og_value_add')}</DialogTitle>
|
||||
<FormProvider {...form}>
|
||||
<DialogContent>
|
||||
<Stack component="form" noValidate onSubmit={handleSubmit(onSave)} sx={{ gap: 2, mt: 1 }}>
|
||||
<RhfTextField<ValueFormState>
|
||||
name="nameFa"
|
||||
label={t('cat_name_fa')}
|
||||
rules={{ validate: (val) => String(val ?? '').trim() !== '' }}
|
||||
/>
|
||||
<RhfTextField<ValueFormState>
|
||||
name="nameEn"
|
||||
label={t('cat_name_en')}
|
||||
rules={{ validate: (val) => String(val ?? '').trim() !== '' }}
|
||||
/>
|
||||
<RhfTextField<ValueFormState>
|
||||
name="sortOrder"
|
||||
type="number"
|
||||
label={t('cat_sort_order')}
|
||||
rules={{ validate: (val) => Number.isFinite(Number(val)) }}
|
||||
slotProps={{ htmlInput: { step: 1 } }}
|
||||
/>
|
||||
{isEdit ? (
|
||||
<RhfControlGroup<ValueFormState> name="isActive">
|
||||
{({ field }) => (
|
||||
<FormControlLabel
|
||||
control={<Switch checked={Boolean(field.value)} onChange={(event) => field.onChange(event.target.checked)} />}
|
||||
label={t(field.value ? 'status_active' : 'status_inactive')}
|
||||
/>
|
||||
)}
|
||||
</RhfControlGroup>
|
||||
) : null}
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<AppButton variant="text" color="inherit" onClick={onClose} disabled={isPending}>
|
||||
{t('cancel')}
|
||||
</AppButton>
|
||||
<AppButton
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleSubmit(onSave)}
|
||||
disabled={!formState.isValid || isPending}
|
||||
>
|
||||
{isPending ? t('saving') : t('save')}
|
||||
</AppButton>
|
||||
</DialogActions>
|
||||
</FormProvider>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
'use client';
|
||||
import { Suspense, useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { Box, Dialog, DialogActions, DialogContent, DialogTitle, Skeleton, Stack } from '@mui/material';
|
||||
import { AppButton, AppLoading, RhfTextField, StatusChip } from '@/components';
|
||||
import { AdminDataTable, AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, type AdminTableColumn } from '@/components/admin';
|
||||
import { useAdminCapabilities, useAdminListState } from '@/hooks';
|
||||
import { adminCatalogCategoryPath } from '@/constants';
|
||||
import { ADMIN_CATEGORIES_PAGE_SIZE } from '@/services/catalog/constants';
|
||||
import type { CategoryInput, ServiceCategory } from '@/services/catalog/types';
|
||||
import { useAdminCategories, useCreateCategory, useSetCategoryActive, useUpdateCategory } from '@/services/catalog';
|
||||
|
||||
/** No list-level filters today (the list is unfiltered) — `useAdminListState` still URL-syncs the page. */
|
||||
type CategoryListFilters = Record<string, never>;
|
||||
const EMPTY_FILTERS: CategoryListFilters = {};
|
||||
|
||||
/**
|
||||
* Catalog admin — the top-level category list (f6 blocker). Categories are seeded by migration, but
|
||||
* pricing options are deliberately admin-authored data (never a migration), so outside a developer's
|
||||
* own dev-seeded box there is nothing here until an admin creates it — this console is the fix.
|
||||
* A row opens the category's option-groups/values console; the create dialog here handles categories
|
||||
* only. Categories are reference data — deactivating (never a hard delete) hides a category from
|
||||
* public browse and new bookings while leaving existing variants/bookings intact.
|
||||
*/
|
||||
export default function AdminCatalogPage() {
|
||||
return (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<AdminCatalogPageInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminCatalogPageInner() {
|
||||
const t = useTranslations('admin');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const caps = useAdminCapabilities();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const [creating, setCreating] = useState(false);
|
||||
const setActive = useSetCategoryActive();
|
||||
|
||||
const listState = useAdminListState<CategoryListFilters>({
|
||||
parse: () => EMPTY_FILTERS,
|
||||
serialize: () => ({}),
|
||||
empty: EMPTY_FILTERS,
|
||||
});
|
||||
const page = listState.page;
|
||||
|
||||
const categories = useAdminCategories({ page, pageSize: ADMIN_CATEGORIES_PAGE_SIZE });
|
||||
const items = categories.data?.items ?? [];
|
||||
const total = categories.data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / ADMIN_CATEGORIES_PAGE_SIZE));
|
||||
const from = items.length === 0 ? 0 : (page - 1) * ADMIN_CATEGORIES_PAGE_SIZE + 1;
|
||||
const to = (page - 1) * ADMIN_CATEGORIES_PAGE_SIZE + items.length;
|
||||
|
||||
const onToggleActive = (category: ServiceCategory) => {
|
||||
setActive.mutate(
|
||||
{ id: category.id, isActive: !category.isActive },
|
||||
{ onSuccess: () => enqueueSnackbar(t(category.isActive ? 'cat_deactivated' : 'cat_activated'), { variant: 'success' }) },
|
||||
);
|
||||
};
|
||||
|
||||
const columns: AdminTableColumn<ServiceCategory>[] = [
|
||||
{ key: 'name', header: t('cat_col_name'), render: (c) => `${c.nameFa} · ${c.nameEn}` },
|
||||
{ key: 'order', header: t('cat_col_order'), render: (c) => c.sortOrder },
|
||||
{
|
||||
key: 'status',
|
||||
header: t('cat_col_status'),
|
||||
render: (c) => <StatusChip status={c.isActive ? 'active' : 'neutral'} label={t(c.isActive ? 'status_active' : 'status_inactive')} />,
|
||||
},
|
||||
...(caps.canManageCatalog
|
||||
? [
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (c: ServiceCategory) => (
|
||||
<Stack direction="row" sx={{ gap: 0.5, flexWrap: 'wrap' }}>
|
||||
<AppButton
|
||||
variant="text"
|
||||
color={c.isActive ? 'error' : 'primary'}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleActive(c);
|
||||
}}
|
||||
disabled={setActive.isPending}
|
||||
>
|
||||
{t(c.isActive ? 'cat_deactivate' : 'cat_activate')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
),
|
||||
} as AdminTableColumn<ServiceCategory>,
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<AdminPageHeader
|
||||
title={t('cat_title')}
|
||||
subtitle={t('cat_subtitle')}
|
||||
actions={
|
||||
caps.canManageCatalog ? (
|
||||
<AppButton variant="contained" color="primary" startIcon="add" onClick={() => setCreating(true)}>
|
||||
{t('cat_add')}
|
||||
</AppButton>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{categories.isLoading ? (
|
||||
<Skeleton variant="rounded" height={220} />
|
||||
) : categories.isError ? (
|
||||
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => categories.refetch()} />
|
||||
) : items.length === 0 ? (
|
||||
<AdminEmptyState icon="category" title={t('cat_empty')} />
|
||||
) : (
|
||||
<AdminDataTable
|
||||
columns={columns}
|
||||
rows={items}
|
||||
getRowKey={(c) => c.id}
|
||||
ariaLabel={t('cat_title')}
|
||||
onRowClick={(c) => router.push(`/${locale}${adminCatalogCategoryPath(c.id)}`)}
|
||||
footer={total > 0 ? t('showing_range', { from, to, total }) : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AdminPager
|
||||
page={page}
|
||||
pageCount={pageCount}
|
||||
onPrev={() => listState.goToPage(Math.max(1, page - 1))}
|
||||
onNext={() => listState.goToPage(Math.min(pageCount, page + 1))}
|
||||
prevLabel={t('prev_page')}
|
||||
nextLabel={t('next_page')}
|
||||
indicator={t('page_indicator', { page, total: pageCount })}
|
||||
/>
|
||||
|
||||
{creating ? <CategoryFormDialog category={null} onClose={() => setCreating(false)} /> : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** The editable slice of `CategoryInput`, kept as strings for controlled text/number inputs. */
|
||||
interface CategoryFormState {
|
||||
nameFa: string;
|
||||
nameEn: string;
|
||||
descriptionFa: string;
|
||||
descriptionEn: string;
|
||||
iconKey: string;
|
||||
sortOrder: string;
|
||||
}
|
||||
|
||||
function initialForm(category: ServiceCategory | null): CategoryFormState {
|
||||
return {
|
||||
nameFa: category?.nameFa ?? '',
|
||||
nameEn: category?.nameEn ?? '',
|
||||
descriptionFa: category?.descriptionFa ?? '',
|
||||
descriptionEn: category?.descriptionEn ?? '',
|
||||
iconKey: category?.iconKey ?? '',
|
||||
sortOrder: category != null ? String(category.sortOrder) : '0',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The create/edit dialog for a category — shared by the list (create, `category=null`) and the detail
|
||||
* page (edit, `category` prefilled). Both labels are required; description/icon are optional.
|
||||
*/
|
||||
export function CategoryFormDialog({ category, onClose }: { category: ServiceCategory | null; onClose: () => void }) {
|
||||
const t = useTranslations('admin');
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const isEdit = category != null;
|
||||
const create = useCreateCategory();
|
||||
const update = useUpdateCategory();
|
||||
|
||||
const form = useForm<CategoryFormState>({ mode: 'onTouched', defaultValues: initialForm(category) });
|
||||
const { handleSubmit, formState } = form;
|
||||
const isPending = create.isPending || update.isPending;
|
||||
|
||||
const onSave = (values: CategoryFormState) => {
|
||||
const input: CategoryInput = {
|
||||
nameFa: values.nameFa.trim(),
|
||||
nameEn: values.nameEn.trim(),
|
||||
descriptionFa: values.descriptionFa.trim() || null,
|
||||
descriptionEn: values.descriptionEn.trim() || null,
|
||||
iconKey: values.iconKey.trim() || null,
|
||||
sortOrder: Number(values.sortOrder),
|
||||
};
|
||||
const onSuccess = () => {
|
||||
enqueueSnackbar(t('cat_saved'), { variant: 'success' });
|
||||
onClose();
|
||||
};
|
||||
if (isEdit) update.mutate({ id: category.id, input }, { onSuccess });
|
||||
else create.mutate(input, { onSuccess });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onClose={isPending ? undefined : onClose} fullWidth maxWidth="xs">
|
||||
<DialogTitle sx={{ fontWeight: 800 }}>{isEdit ? t('cat_edit') : t('cat_add')}</DialogTitle>
|
||||
<FormProvider {...form}>
|
||||
<DialogContent>
|
||||
{/* A real <form> so Enter submits from any field; the footer button (outside DialogContent)
|
||||
calls the same handler directly rather than relying on cross-element form association. */}
|
||||
<Stack component="form" noValidate onSubmit={handleSubmit(onSave)} sx={{ gap: 2, mt: 1 }}>
|
||||
<RhfTextField<CategoryFormState>
|
||||
name="nameFa"
|
||||
label={t('cat_name_fa')}
|
||||
rules={{ validate: (value) => String(value ?? '').trim() !== '' }}
|
||||
/>
|
||||
<RhfTextField<CategoryFormState>
|
||||
name="nameEn"
|
||||
label={t('cat_name_en')}
|
||||
rules={{ validate: (value) => String(value ?? '').trim() !== '' }}
|
||||
/>
|
||||
<RhfTextField<CategoryFormState> name="descriptionFa" label={t('cat_description_fa')} multiline minRows={2} />
|
||||
<RhfTextField<CategoryFormState> name="descriptionEn" label={t('cat_description_en')} multiline minRows={2} />
|
||||
<RhfTextField<CategoryFormState> name="iconKey" label={t('cat_icon_key')} helperText={t('cat_icon_hint')} slotProps={{ htmlInput: { dir: 'ltr' } }} />
|
||||
<RhfTextField<CategoryFormState>
|
||||
name="sortOrder"
|
||||
type="number"
|
||||
label={t('cat_sort_order')}
|
||||
rules={{ validate: (value) => Number.isFinite(Number(value)) }}
|
||||
slotProps={{ htmlInput: { step: 1 } }}
|
||||
/>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<AppButton variant="text" color="inherit" onClick={onClose} disabled={isPending}>
|
||||
{t('cancel')}
|
||||
</AppButton>
|
||||
<AppButton
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleSubmit(onSave)}
|
||||
disabled={!formState.isValid || isPending}
|
||||
>
|
||||
{isPending ? t('saving') : t('save')}
|
||||
</AppButton>
|
||||
</DialogActions>
|
||||
</FormProvider>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,13 @@ export default function AdminSystemPage() {
|
||||
path: ROUTES.ADMIN_CONFIG,
|
||||
enabled: caps.canConfig,
|
||||
},
|
||||
{
|
||||
title: tn('catalog'),
|
||||
subtitle: t('admin_catalog_sub'),
|
||||
icon: 'category',
|
||||
path: ROUTES.ADMIN_CATALOG,
|
||||
enabled: caps.canManageCatalog,
|
||||
},
|
||||
{
|
||||
title: tn('holidays'),
|
||||
subtitle: t('admin_holidays_sub'),
|
||||
|
||||
@@ -75,7 +75,7 @@ function AdminVerificationCaseScreen() {
|
||||
const caps = useAdminCapabilities();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
const { data, isLoading, isError, refetch } = useVerificationCase(
|
||||
const { data, isLoading, isError, isFetching, refetch } = useVerificationCase(
|
||||
Number.isFinite(nurseVerificationId) ? nurseVerificationId : null,
|
||||
);
|
||||
const approve = useApproveVerification();
|
||||
@@ -199,6 +199,8 @@ function AdminVerificationCaseScreen() {
|
||||
step={step}
|
||||
nurseVerificationId={nurseVerificationId}
|
||||
canVerify={caps.canVerify}
|
||||
onReloadDocuments={refetch}
|
||||
reloadingDocuments={isFetching}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
@@ -290,10 +292,14 @@ function StepCard({
|
||||
step,
|
||||
nurseVerificationId,
|
||||
canVerify,
|
||||
onReloadDocuments,
|
||||
reloadingDocuments,
|
||||
}: {
|
||||
step: AdminVerificationStepDetail;
|
||||
nurseVerificationId: number;
|
||||
canVerify: boolean;
|
||||
onReloadDocuments: () => void;
|
||||
reloadingDocuments: boolean;
|
||||
}) {
|
||||
const t = useTranslations('admin');
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
@@ -339,7 +345,12 @@ function StepCard({
|
||||
{step.documents.length > 0 ? (
|
||||
<Stack sx={{ gap: 1.5, mt: 1.5 }}>
|
||||
{step.documents.map((doc) => (
|
||||
<DocumentViewer key={doc.id} document={doc} />
|
||||
<DocumentViewer
|
||||
key={doc.id}
|
||||
document={doc}
|
||||
onReload={onReloadDocuments}
|
||||
reloading={reloadingDocuments}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
) : isManual ? (
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ const PREVIEW: CancellationPolicyPreview = {
|
||||
bookingId: 5003,
|
||||
cancellable: true,
|
||||
cancellationPolicyCode: 'free_24h',
|
||||
refundPercentageApplied: 1,
|
||||
refundPercentageApplied: 100,
|
||||
feePercentage: 0,
|
||||
refundAmountIrr: '17600000',
|
||||
feeAmountIrr: '0',
|
||||
|
||||
@@ -15,9 +15,10 @@ export interface CancellationPolicyDisclosureProps {
|
||||
preview: CancellationPolicyPreview;
|
||||
}
|
||||
|
||||
/** Percent (integer) from a 0–1 fraction — a small display number, never money, so JS math is safe. */
|
||||
function toPercent(fraction: number): number {
|
||||
return Math.round(fraction * 100);
|
||||
/** Rounds an already-0–100 percent value for display — a small display number, never money, so JS math is
|
||||
* safe. Never re-multiply by 100: the server (and the mock) already serve this scale. */
|
||||
function toPercent(percent: number): number {
|
||||
return Math.round(percent);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -25,6 +25,7 @@ const NURSE: NurseSearchResult = {
|
||||
nurseId: 1,
|
||||
variantId: 11,
|
||||
serviceCategoryId: 1,
|
||||
matchingServiceCount: 1,
|
||||
nurseName: 'Maryam Rezaei',
|
||||
avatarUrl: null,
|
||||
isVerified: true,
|
||||
@@ -112,6 +113,14 @@ describe('<NurseResultCard/> component', () => {
|
||||
expect(screen.getByText(/منظم و دقیق/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('discloses "+N more services" only when the nurse matched more than one variant', () => {
|
||||
renderCard(NURSE);
|
||||
expect(screen.queryByText(/more_services_count/)).not.toBeInTheDocument();
|
||||
|
||||
renderCard({ ...NURSE, matchingServiceCount: 3 });
|
||||
expect(screen.getByText(/more_services_count:2/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to a label when the name is missing (b7 join gap)', () => {
|
||||
renderCard({ ...NURSE, nurseName: '' });
|
||||
expect(screen.getByText('unnamed_nurse')).toBeInTheDocument();
|
||||
|
||||
@@ -84,11 +84,12 @@ const NurseResultCard = ({ nurse, serviceLabel, onSelect }: NurseResultCardProps
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{name}
|
||||
</Typography>
|
||||
<TrustBadge state="verified" nurseId={nurse.nurseId} />
|
||||
<TrustBadge state={nurse.isVerified ? 'verified' : 'unverified'} nurseId={nurse.nurseId} />
|
||||
</Stack>
|
||||
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{serviceLabel}
|
||||
{nurse.matchingServiceCount > 1 ? ` · ${t('more_services_count', { count: nurse.matchingServiceCount - 1 })}` : ''}
|
||||
</Typography>
|
||||
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap', mt: 0.25 }}>
|
||||
|
||||
@@ -24,7 +24,7 @@ const base: RefundSummary = {
|
||||
totalRefundedIrr: '45000000',
|
||||
expectedCustomerRefundEta: null,
|
||||
externalRevertReference: null,
|
||||
refundPercentageApplied: 1,
|
||||
refundPercentageApplied: 100,
|
||||
cancellationPolicyCode: 'free_24h',
|
||||
platformFeeRefundedIrr: '5400000',
|
||||
nursePayoutRefundedIrr: '39600000',
|
||||
|
||||
@@ -3,9 +3,6 @@ import { ThemeProvider } from '../../theme';
|
||||
|
||||
jest.mock('next-intl', () => ({ useTranslations: () => (k: string) => k, useLocale: () => 'en' }));
|
||||
|
||||
const mockUseDocUrl = jest.fn();
|
||||
jest.mock('@/services/verification', () => ({ useVerificationDocumentUrl: (...a: unknown[]) => mockUseDocUrl(...a) }));
|
||||
|
||||
import DocumentViewer from './DocumentViewer';
|
||||
import type { VerificationDocument } from '@/services/verification/types';
|
||||
|
||||
@@ -14,35 +11,28 @@ const DOC: VerificationDocument = {
|
||||
contentType: 'image/png',
|
||||
fileSizeBytes: 2048,
|
||||
originalFileName: 'license.png',
|
||||
url: 'ignored-embedded-url',
|
||||
url: 'https://signed/fresh.png',
|
||||
};
|
||||
|
||||
describe('<DocumentViewer/>', () => {
|
||||
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
|
||||
afterEach(() => mockUseDocUrl.mockReset());
|
||||
|
||||
it('renders the loaded image from the on-demand signed url (never the embedded url)', () => {
|
||||
mockUseDocUrl.mockReturnValue({ data: { url: 'https://signed/fresh.png', expiresInSeconds: 60 }, isLoading: false, isFetching: false, isError: false, refetch: jest.fn() });
|
||||
const { container } = wrap(<DocumentViewer document={DOC} />);
|
||||
it('renders the image from the case-embedded signed url', () => {
|
||||
const { container } = wrap(<DocumentViewer document={DOC} onReload={jest.fn()} reloading={false} />);
|
||||
const img = container.querySelector('img') as HTMLImageElement;
|
||||
expect(img).toBeTruthy();
|
||||
expect(img.src).toContain('signed/fresh.png');
|
||||
expect(img.src).not.toContain('ignored-embedded-url');
|
||||
});
|
||||
|
||||
it('offers a re-request affordance on error and calls refetch', () => {
|
||||
const refetch = jest.fn();
|
||||
mockUseDocUrl.mockReturnValue({ data: undefined, isLoading: false, isFetching: false, isError: true, refetch });
|
||||
wrap(<DocumentViewer document={DOC} />);
|
||||
expect(screen.getByText('doc_error')).toBeInTheDocument();
|
||||
// Two re-request buttons (header + error panel); click the first.
|
||||
fireEvent.click(screen.getAllByText('doc_reload')[0]);
|
||||
expect(refetch).toHaveBeenCalled();
|
||||
it('calls onReload — there is no per-document endpoint, so reload refetches the parent case', () => {
|
||||
const onReload = jest.fn();
|
||||
wrap(<DocumentViewer document={DOC} onReload={onReload} reloading={false} />);
|
||||
fireEvent.click(screen.getByText('doc_reload'));
|
||||
expect(onReload).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows a skeleton while the signed url is loading', () => {
|
||||
mockUseDocUrl.mockReturnValue({ data: undefined, isLoading: true, isFetching: true, isError: false, refetch: jest.fn() });
|
||||
const { container } = wrap(<DocumentViewer document={DOC} />);
|
||||
it('shows a skeleton while the parent case is (re)loading', () => {
|
||||
const { container } = wrap(<DocumentViewer document={DOC} onReload={jest.fn()} reloading />);
|
||||
expect(container.querySelector('.MuiSkeleton-root')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,12 +3,16 @@ import { FunctionComponent } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Box, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import type { VerificationDocument } from '@/services/verification/types';
|
||||
import { useVerificationDocumentUrl } from '@/services/verification';
|
||||
import AppButton from '../common/AppButton';
|
||||
import AppIcon from '../common/AppIcon';
|
||||
|
||||
export interface DocumentViewerProps {
|
||||
document: VerificationDocument;
|
||||
/** Refetches the parent case — there is no per-document re-sign route, so a reopen re-fetches the whole
|
||||
* case to get a freshly-signed `document.url`. */
|
||||
onReload: () => void;
|
||||
/** True while the parent case is (re)loading — drives the skeleton. */
|
||||
reloading: boolean;
|
||||
}
|
||||
|
||||
/** Human-readable file size. */
|
||||
@@ -19,16 +23,15 @@ function formatSize(bytes: number): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* A verification-document viewer that fetches its **signed URL on demand** (never the embedded one — those
|
||||
* are short-lived) via `useVerificationDocumentUrl`. Handles the full lifecycle: **loading** the link,
|
||||
* **error / expired → re-request** (the URL is short-lived, so a manual re-request re-signs it), and the
|
||||
* loaded state (inline image preview for images, otherwise an "open in a new tab" affordance). PII → only
|
||||
* the signed URL is ever surfaced, never a public asset (phase §5).
|
||||
* A verification-document viewer. `document.url` is the **short-lived signed GET URL the case detail
|
||||
* already carries** — there is no separate per-document re-sign route (b6 gap), so "reload" refetches the
|
||||
* whole case (`onReload`) rather than calling a document-specific endpoint. Handles the loading (parent
|
||||
* case fetching) and loaded states (inline image preview for images, otherwise an "open in a new tab"
|
||||
* affordance). PII → only the signed URL is ever surfaced, never a public asset (phase §5).
|
||||
* @component DocumentViewer
|
||||
*/
|
||||
const DocumentViewer: FunctionComponent<DocumentViewerProps> = ({ document }) => {
|
||||
const DocumentViewer: FunctionComponent<DocumentViewerProps> = ({ document, onReload, reloading }) => {
|
||||
const t = useTranslations('admin');
|
||||
const signed = useVerificationDocumentUrl(document.id);
|
||||
const isImage = document.contentType.startsWith('image/');
|
||||
|
||||
return (
|
||||
@@ -45,36 +48,27 @@ const DocumentViewer: FunctionComponent<DocumentViewerProps> = ({ document }) =>
|
||||
variant="text"
|
||||
color="primary"
|
||||
startIcon="refresh"
|
||||
onClick={() => signed.refetch()}
|
||||
disabled={signed.isFetching}
|
||||
onClick={onReload}
|
||||
disabled={reloading}
|
||||
sx={{ minWidth: 0 }}
|
||||
>
|
||||
{t('doc_reload')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
|
||||
{signed.isLoading || signed.isFetching ? (
|
||||
{reloading ? (
|
||||
<Skeleton variant="rounded" height={isImage ? 180 : 44} />
|
||||
) : signed.isError ? (
|
||||
<Stack sx={{ gap: 1, alignItems: 'flex-start' }}>
|
||||
<Typography variant="body2" sx={{ color: 'var(--bal-error)' }}>
|
||||
{t('doc_error')}
|
||||
</Typography>
|
||||
<AppButton variant="outlined" color="primary" startIcon="refresh" onClick={() => signed.refetch()}>
|
||||
{t('doc_reload')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
) : signed.data?.url ? (
|
||||
) : document.url ? (
|
||||
isImage ? (
|
||||
// Signed, short-lived, cross-host URL (not a static asset) — a plain <img> via Box, not next/image.
|
||||
<Box
|
||||
component="img"
|
||||
src={signed.data.url}
|
||||
src={document.url}
|
||||
alt={document.originalFileName ?? String(document.id)}
|
||||
sx={{ maxWidth: '100%', maxHeight: 320, borderRadius: 'var(--bal-radius-sm)', display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
<AppButton variant="outlined" color="primary" endIcon="external" href={signed.data.url} openInNewTab>
|
||||
<AppButton variant="outlined" color="primary" endIcon="external" href={document.url} openInNewTab>
|
||||
{t('doc_open_new')}
|
||||
</AppButton>
|
||||
)
|
||||
|
||||
@@ -51,6 +51,8 @@ describe('<AddressForm/> component', () => {
|
||||
latitude: 35.7,
|
||||
longitude: 51.4,
|
||||
isPrimary: false,
|
||||
recipientName: 'Sara',
|
||||
recipientPhone: '09120001234',
|
||||
}}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
@@ -67,6 +69,8 @@ describe('<AddressForm/> component', () => {
|
||||
latitude: 35.7,
|
||||
longitude: 51.4,
|
||||
isPrimary: false,
|
||||
recipientName: 'Sara',
|
||||
recipientPhone: '09120001234',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import Stack from '@mui/material/Stack';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import { AppButton } from '@/components/common';
|
||||
import { RhfControlGroup, RhfTextField } from '@/components/common/form';
|
||||
import PhoneNumberField, { isIranianMobile } from '@/components/PhoneNumberField';
|
||||
import { cityCentroid } from '@/services/geography/constants';
|
||||
import type { CreateAddressInput, LatLng } from '@/services/addresses/types';
|
||||
import CascadingRegionSelect, { type CascadingRegionValue } from './CascadingRegionSelect';
|
||||
@@ -22,6 +23,8 @@ export interface AddressFormInitial {
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
isPrimary?: boolean;
|
||||
recipientName?: string | null;
|
||||
recipientPhone?: string | null;
|
||||
}
|
||||
|
||||
export interface AddressFormProps {
|
||||
@@ -39,6 +42,8 @@ interface AddressFormValues {
|
||||
pin: LatLng | null;
|
||||
addressLine: string;
|
||||
isPrimary: boolean;
|
||||
recipientName: string;
|
||||
recipientPhone: string;
|
||||
}
|
||||
|
||||
// Only prefill the region when we have the province — a city id without its province can't drive
|
||||
@@ -78,6 +83,8 @@ const AddressForm: FunctionComponent<AddressFormProps> = ({ initial, submitting
|
||||
pin: initialPin(initial),
|
||||
addressLine: initial?.addressLine ?? '',
|
||||
isPrimary: initial?.isPrimary ?? false,
|
||||
recipientName: initial?.recipientName ?? '',
|
||||
recipientPhone: initial?.recipientPhone ?? '',
|
||||
},
|
||||
});
|
||||
const { control, handleSubmit, formState } = form;
|
||||
@@ -98,6 +105,8 @@ const AddressForm: FunctionComponent<AddressFormProps> = ({ initial, submitting
|
||||
latitude: (values.pin as LatLng).latitude,
|
||||
longitude: (values.pin as LatLng).longitude,
|
||||
isPrimary: values.isPrimary,
|
||||
recipientName: values.recipientName.trim(),
|
||||
recipientPhone: values.recipientPhone,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -153,6 +162,29 @@ const AddressForm: FunctionComponent<AddressFormProps> = ({ initial, submitting
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<RhfTextField<AddressFormValues>
|
||||
name="recipientName"
|
||||
label={t('recipient_name_label')}
|
||||
rules={{ validate: (value) => String(value ?? '').trim().length > 0 || t('recipient_name_required') }}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<RhfControlGroup<AddressFormValues>
|
||||
name="recipientPhone"
|
||||
rules={{ validate: (value) => isIranianMobile(String(value ?? '')) }}
|
||||
>
|
||||
{({ field, hasError }) => (
|
||||
<PhoneNumberField
|
||||
label={t('recipient_phone_label')}
|
||||
value={(field.value as string) ?? ''}
|
||||
onChange={field.onChange}
|
||||
error={hasError}
|
||||
helperText={hasError ? t('recipient_phone_invalid') : undefined}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
</RhfControlGroup>
|
||||
|
||||
<RhfControlGroup<AddressFormValues> name="isPrimary">
|
||||
{({ field }) => (
|
||||
<FormControlLabel
|
||||
|
||||
@@ -27,6 +27,8 @@ export const ROUTES = {
|
||||
BOOKING_REQUEST_STATUS: '/bookings/request',
|
||||
// Checkout (f9) — C6 summary & pay; the C5 accept CTA hands off here with `?request_id=`.
|
||||
CHECKOUT: '/bookings/checkout',
|
||||
// Mock-gateway harness (test-only stand-in for the PSP redirect; the mock initiate points here).
|
||||
CHECKOUT_GATEWAY: '/bookings/checkout/gateway',
|
||||
// Return-from-gateway surface — pending-callback poll → succeeded/failed states.
|
||||
CHECKOUT_RETURN: '/bookings/checkout/return',
|
||||
// Post-payment success screen — links to the booking detail + invoice.
|
||||
@@ -108,6 +110,8 @@ export const ROUTES = {
|
||||
ADMIN_REVIEWS: '/admin/reviews',
|
||||
// Platform config editor + change history.
|
||||
ADMIN_CONFIG: '/admin/config',
|
||||
// Catalog skeleton editor — categories + a category's option groups/values; append `/{categoryId}`.
|
||||
ADMIN_CATALOG: '/admin/catalog',
|
||||
// Iranian-holiday calendar manager (drives payout scheduling; is_bank_closed toggle).
|
||||
ADMIN_HOLIDAYS: '/admin/holidays',
|
||||
// Support-alert triage board — internal-only (assign/resolve).
|
||||
@@ -144,6 +148,10 @@ export const adminPayoutBatchPath = (batchId: number | string): string =>
|
||||
export const adminPartnerCenterPath = (centerId: number | string): string =>
|
||||
`${ROUTES.ADMIN_PARTNERS}/${centerId}`;
|
||||
|
||||
/** The admin catalog category detail — its option groups/values, including inactive ones. */
|
||||
export const adminCatalogCategoryPath = (categoryId: number | string): string =>
|
||||
`${ROUTES.ADMIN_CATALOG}/${categoryId}`;
|
||||
|
||||
/** The partner-portal sponsored-booking detail (f15) — a bookings-list row deep-links here (read-only). */
|
||||
export const partnerBookingDetailPath = (bookingId: number | string): string =>
|
||||
`${ROUTES.PARTNER_BOOKINGS}/${bookingId}`;
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface AdminCapabilities {
|
||||
canPayout: boolean;
|
||||
canModerate: boolean;
|
||||
canConfig: boolean;
|
||||
canManageCatalog: boolean;
|
||||
canManageAlerts: boolean;
|
||||
canManageTickets: boolean;
|
||||
canManagePartners: boolean;
|
||||
@@ -57,6 +58,7 @@ export function useAdminCapabilities(): AdminCapabilities {
|
||||
canPayout: has(roles, 'super_admin', 'admin', 'finance'),
|
||||
canModerate: has(roles, 'super_admin', 'admin', 'moderation'),
|
||||
canConfig: has(roles, 'super_admin', 'admin', 'finance'),
|
||||
canManageCatalog: has(roles, 'super_admin', 'admin'),
|
||||
canManageAlerts: has(roles, 'super_admin', 'admin', 'support'),
|
||||
canManageTickets: has(roles, 'super_admin', 'admin', 'support'),
|
||||
canManagePartners: has(roles, 'super_admin', 'admin'),
|
||||
|
||||
@@ -2,18 +2,12 @@
|
||||
* When true, the BNPL domain is served by the in-memory mock (`apis/mockApi.ts`) behind the `BnplApi`
|
||||
* seam.
|
||||
*
|
||||
* **Mock is primary this phase.** b12 ships the eligibility/initiate/webhook/settle endpoints server-side,
|
||||
* but the D1–D5 checkout cannot run real end-to-end from the client yet:
|
||||
* - the accepted request being financed comes from the **mock-primary** `bookingRequests` store (f7), so a
|
||||
* real `initiate` would reference an id that exists only in memory (same reason f9 payment is mock-primary);
|
||||
* - the contract serves **no provider/plan options** (D1/D2), **no repayment schedule** (D4 — the contract
|
||||
* explicitly does not model the customer's repayment schedule), and **no provider-reported installment
|
||||
* status** for the Wallet (D5) → REQ-022/023/024;
|
||||
* - nothing fires the provider webhook in dev, so a real order would never settle.
|
||||
* The mock closes the loop: the settle (down-payment cleared) converts the f7 request, inserts a **confirmed**
|
||||
* booking into the f8 store (the SAME bridge f9 uses — a settled BNPL order is a card payment net-of-fee),
|
||||
* and seeds a Wallet installment plan — so C6 → D1 → … → D4 → confirmation → D5 demos end-to-end. Flip to
|
||||
* `false` once the upstream domains are real and REQ-022/023/024 land — no hook/component change.
|
||||
* **Mock is still primary** (blocker-phase 05 landed Bug A — the seeded BNPL gateway — but not Bug B):
|
||||
* the D1 wizard hard-gates on `getBnplOptions` (`GET checkout_bnpl/options/{id}`), and `getBnplSchedule` /
|
||||
* `getWalletInstallments` have no server counterpart either — `CheckoutBnplController` only ever grew
|
||||
* `eligibility` / `initiate` / `GET {id}` / `by_request/{id}` (REQ-022/023/024 never landed). Flipping this
|
||||
* flag today trades the old "mock store ages out" failure for an immediate `isError` on D1. Flip once those
|
||||
* three endpoints exist server-side — no hook/component change needed at that point.
|
||||
*/
|
||||
export const USE_BNPL_MOCK = true;
|
||||
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import { clientFetch } from '@/lib/api/client';
|
||||
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
|
||||
import { CATEGORIES_PAGE_SIZE, MY_VARIANTS_PAGE_SIZE } from '../constants';
|
||||
import { ADMIN_CATEGORIES_PAGE_SIZE, CATEGORIES_PAGE_SIZE, MY_VARIANTS_PAGE_SIZE } from '../constants';
|
||||
import type {
|
||||
CatalogApi,
|
||||
CategoryInput,
|
||||
CreateOptionValueInput,
|
||||
CreateVariantInput,
|
||||
NurseServiceVariant,
|
||||
OptionGroupInput,
|
||||
ServiceCategory,
|
||||
ServiceOptionGroup,
|
||||
ServiceOptionValue,
|
||||
UpdateOptionValueInput,
|
||||
UpdateVariantInput,
|
||||
} from '../types';
|
||||
|
||||
const CATALOG_BASE = '/api/v1/catalog';
|
||||
const VARIANTS_BASE = '/api/v1/nurse_variants';
|
||||
const ADMIN_CATALOG_BASE = '/api/v1/admin_catalog';
|
||||
|
||||
/**
|
||||
* Real HTTP implementation of the CatalogApi seam (b5 contract `dev/contracts/domains/catalog.md`).
|
||||
@@ -73,4 +79,75 @@ export const catalogClientApi: CatalogApi = {
|
||||
body: JSON.stringify({ isActive }),
|
||||
});
|
||||
},
|
||||
|
||||
adminListCategories: async (params) => {
|
||||
const query = new URLSearchParams();
|
||||
query.set('page', String(params?.page ?? 1));
|
||||
query.set('pageSize', String(params?.pageSize ?? ADMIN_CATEGORIES_PAGE_SIZE));
|
||||
return unwrap(
|
||||
await clientFetch<ApiEnvelope<Paginated<ServiceCategory>>>(`${ADMIN_CATALOG_BASE}/list_categories?${query.toString()}`),
|
||||
);
|
||||
},
|
||||
|
||||
createCategory: async (input: CategoryInput) =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<ServiceCategory>>(`${ADMIN_CATALOG_BASE}/create_category`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
),
|
||||
|
||||
updateCategory: async (id, input: CategoryInput) =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<ServiceCategory>>(`${ADMIN_CATALOG_BASE}/update_category/${id}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
),
|
||||
|
||||
setCategoryActive: async (id, isActive) => {
|
||||
await clientFetch<ApiEnvelope<boolean>>(`${ADMIN_CATALOG_BASE}/set_category_active/${id}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ isActive }),
|
||||
});
|
||||
},
|
||||
|
||||
adminListOptionGroups: async (categoryId) =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<ServiceOptionGroup[]>>(
|
||||
`${ADMIN_CATALOG_BASE}/list_option_groups?category_id=${categoryId}`,
|
||||
),
|
||||
),
|
||||
|
||||
createOptionGroup: async (input: OptionGroupInput) =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<ServiceOptionGroup>>(`${ADMIN_CATALOG_BASE}/create_option_group`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
),
|
||||
|
||||
updateOptionGroup: async (id, input: OptionGroupInput) =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<ServiceOptionGroup>>(`${ADMIN_CATALOG_BASE}/update_option_group/${id}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
),
|
||||
|
||||
createOptionValue: async (input: CreateOptionValueInput) =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<ServiceOptionValue>>(`${ADMIN_CATALOG_BASE}/create_option_value`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
),
|
||||
|
||||
updateOptionValue: async (id, input: UpdateOptionValueInput) =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<ServiceOptionValue>>(`${ADMIN_CATALOG_BASE}/update_option_value/${id}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { sleep } from '@/utils';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import type { PageParams, Paginated } from '@/lib/api/types';
|
||||
import { CATEGORIES_PAGE_SIZE, MY_VARIANTS_PAGE_SIZE } from '../constants';
|
||||
import { ADMIN_CATEGORIES_PAGE_SIZE, CATEGORIES_PAGE_SIZE, MY_VARIANTS_PAGE_SIZE } from '../constants';
|
||||
import {
|
||||
isGroupApplicable,
|
||||
optionSetSignature,
|
||||
type CatalogApi,
|
||||
type CategoryInput,
|
||||
type CreateOptionValueInput,
|
||||
type CreateVariantInput,
|
||||
type NurseServiceVariant,
|
||||
type OptionGroupInput,
|
||||
type ServiceCategory,
|
||||
type ServiceOptionGroup,
|
||||
type ServiceOptionValue,
|
||||
type UpdateOptionValueInput,
|
||||
type UpdateVariantInput,
|
||||
type VariantOption,
|
||||
type VariantOptionSelection,
|
||||
@@ -27,6 +31,14 @@ const bySortOrder = <T extends { sortOrder: number }>(a: T, b: T) => a.sortOrder
|
||||
let store: NurseServiceVariant[] = [];
|
||||
let nextVariantId = 1;
|
||||
|
||||
// The admin-managed skeleton — seeded as a mutable copy so a create/update/deactivate in the admin
|
||||
// console is reflected back through the public read methods below, exactly like the real DB.
|
||||
let categoryStore: ServiceCategory[] = SEED_CATEGORIES.map((category) => ({ ...category }));
|
||||
let groupStore: ServiceOptionGroup[] = SEED_OPTION_GROUPS.map((group) => ({ ...group, values: [...group.values] }));
|
||||
let nextCategoryId = Math.max(0, ...categoryStore.map((c) => c.id)) + 1;
|
||||
let nextGroupId = Math.max(0, ...groupStore.map((g) => g.id)) + 1;
|
||||
let nextValueId = Math.max(0, ...groupStore.flatMap((g) => g.values.map((v) => v.id))) + 1;
|
||||
|
||||
/** Active-first (contract list order), then most-recent within each group. */
|
||||
const orderedVariants = () =>
|
||||
[...store].sort((a, b) => Number(b.isActive) - Number(a.isActive) || b.id - a.id);
|
||||
@@ -39,9 +51,7 @@ function paginate<T>(all: T[], params?: PageParams, defaultSize = 50): Paginated
|
||||
}
|
||||
|
||||
function applicableGroups(categoryId: number): ServiceOptionGroup[] {
|
||||
return SEED_OPTION_GROUPS.filter((group) => group.isActive && isGroupApplicable(group, categoryId)).sort(
|
||||
bySortOrder,
|
||||
);
|
||||
return groupStore.filter((group) => group.isActive && isGroupApplicable(group, categoryId)).sort(bySortOrder);
|
||||
}
|
||||
|
||||
function findValue(group: ServiceOptionGroup, valueId: number): ServiceOptionValue | undefined {
|
||||
@@ -128,7 +138,7 @@ function assertValidCreate(category: ServiceCategory, groups: ServiceOptionGroup
|
||||
export const catalogMockApi: CatalogApi = {
|
||||
listCategories: async (params) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const active = SEED_CATEGORIES.filter((category) => category.isActive).sort(bySortOrder);
|
||||
const active = categoryStore.filter((category) => category.isActive).sort(bySortOrder);
|
||||
return paginate(active, params, CATEGORIES_PAGE_SIZE);
|
||||
},
|
||||
|
||||
@@ -155,7 +165,7 @@ export const catalogMockApi: CatalogApi = {
|
||||
|
||||
createVariant: async (input) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const category = SEED_CATEGORIES.find((candidate) => candidate.isActive && candidate.id === input.serviceCategoryId);
|
||||
const category = categoryStore.find((candidate) => candidate.isActive && candidate.id === input.serviceCategoryId);
|
||||
if (!category) throw new ApiError(400, 'Missing or inactive category', 'invalid_category');
|
||||
|
||||
const groups = applicableGroups(category.id);
|
||||
@@ -205,4 +215,92 @@ export const catalogMockApi: CatalogApi = {
|
||||
if (!existing) throw new ApiError(404, 'Variant not found', 'not_found');
|
||||
store = store.map((candidate) => (candidate.id === id ? { ...candidate, isActive } : candidate));
|
||||
},
|
||||
|
||||
adminListCategories: async (params) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
return paginate([...categoryStore].sort(bySortOrder), params, ADMIN_CATEGORIES_PAGE_SIZE);
|
||||
},
|
||||
|
||||
createCategory: async (input: CategoryInput) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const category: ServiceCategory = { id: nextCategoryId++, ...input, isActive: true };
|
||||
categoryStore = [...categoryStore, category];
|
||||
return category;
|
||||
},
|
||||
|
||||
updateCategory: async (id, input: CategoryInput) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const existing = categoryStore.find((candidate) => candidate.id === id);
|
||||
if (!existing) throw new ApiError(404, 'Category not found', 'not_found');
|
||||
const updated: ServiceCategory = { ...existing, ...input };
|
||||
categoryStore = categoryStore.map((candidate) => (candidate.id === id ? updated : candidate));
|
||||
return updated;
|
||||
},
|
||||
|
||||
setCategoryActive: async (id, isActive) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const existing = categoryStore.find((candidate) => candidate.id === id);
|
||||
if (!existing) throw new ApiError(404, 'Category not found', 'not_found');
|
||||
categoryStore = categoryStore.map((candidate) => (candidate.id === id ? { ...candidate, isActive } : candidate));
|
||||
},
|
||||
|
||||
adminListOptionGroups: async (categoryId) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
return groupStore
|
||||
.filter((group) => isGroupApplicable(group, categoryId))
|
||||
.sort(bySortOrder)
|
||||
.map((group) => ({ ...group, values: [...group.values].sort(bySortOrder) }));
|
||||
},
|
||||
|
||||
createOptionGroup: async (input: OptionGroupInput) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
if (input.serviceCategoryId != null && !categoryStore.some((c) => c.id === input.serviceCategoryId)) {
|
||||
throw new ApiError(400, 'Category not found', 'invalid_category');
|
||||
}
|
||||
const group: ServiceOptionGroup = { id: nextGroupId++, ...input, isActive: true, values: [] };
|
||||
groupStore = [...groupStore, group];
|
||||
return group;
|
||||
},
|
||||
|
||||
updateOptionGroup: async (id, input: OptionGroupInput) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const existing = groupStore.find((candidate) => candidate.id === id);
|
||||
if (!existing) throw new ApiError(404, 'Option group not found', 'not_found');
|
||||
if (input.serviceCategoryId != null && !categoryStore.some((c) => c.id === input.serviceCategoryId)) {
|
||||
throw new ApiError(400, 'Category not found', 'invalid_category');
|
||||
}
|
||||
const updated: ServiceOptionGroup = { ...existing, ...input };
|
||||
groupStore = groupStore.map((candidate) => (candidate.id === id ? updated : candidate));
|
||||
return updated;
|
||||
},
|
||||
|
||||
createOptionValue: async (input: CreateOptionValueInput) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const group = groupStore.find((candidate) => candidate.id === input.optionGroupId);
|
||||
if (!group) throw new ApiError(400, 'Option group not found', 'invalid_group');
|
||||
const value: ServiceOptionValue = {
|
||||
id: nextValueId++,
|
||||
nameFa: input.nameFa,
|
||||
nameEn: input.nameEn,
|
||||
sortOrder: input.sortOrder,
|
||||
isActive: true,
|
||||
};
|
||||
groupStore = groupStore.map((candidate) =>
|
||||
candidate.id === group.id ? { ...candidate, values: [...candidate.values, value] } : candidate,
|
||||
);
|
||||
return value;
|
||||
},
|
||||
|
||||
updateOptionValue: async (id, input: UpdateOptionValueInput) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const group = groupStore.find((candidate) => candidate.values.some((value) => value.id === id));
|
||||
if (!group) throw new ApiError(404, 'Option value not found', 'not_found');
|
||||
const updated: ServiceOptionValue = { id, ...input };
|
||||
groupStore = groupStore.map((candidate) =>
|
||||
candidate.id === group.id
|
||||
? { ...candidate, values: candidate.values.map((value) => (value.id === id ? updated : value)) }
|
||||
: candidate,
|
||||
);
|
||||
return updated;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -22,3 +22,10 @@ export const MY_VARIANTS_STALE_TIME = 60_000;
|
||||
/** api-conventions default/max page sizes. A nurse has a handful of offerings; categories are few. */
|
||||
export const CATEGORIES_PAGE_SIZE = 50;
|
||||
export const MY_VARIANTS_PAGE_SIZE = 50;
|
||||
|
||||
/**
|
||||
* The admin catalog-management reads (categories incl. inactive; a category's groups/values incl.
|
||||
* inactive) are deliberately uncached (`staleTime: 0`) — an admin editing the skeleton expects every
|
||||
* write reflected immediately, and this console's traffic is negligible next to the public browse.
|
||||
*/
|
||||
export const ADMIN_CATEGORIES_PAGE_SIZE = 50;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { PageParams } from '@/lib/api/types';
|
||||
import { catalogApi } from '../apis';
|
||||
import { catalogKeys } from '../keys';
|
||||
|
||||
/**
|
||||
* Every category regardless of active state, for the admin catalog console — unlike the public,
|
||||
* session-cached `useServiceCategories`, this always refetches (`staleTime: 0`) so a deactivate/
|
||||
* reactivate or edit is visible immediately without a stale cached page.
|
||||
*/
|
||||
export function useAdminCategories(params?: PageParams) {
|
||||
return useQuery({
|
||||
queryKey: catalogKeys.adminCategories(params),
|
||||
queryFn: () => catalogApi.adminListCategories(params),
|
||||
staleTime: 0,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { catalogApi } from '../apis';
|
||||
import { catalogKeys } from '../keys';
|
||||
|
||||
/**
|
||||
* A category's applicable option groups **including inactive groups, each with every value
|
||||
* including inactive ones** — the admin console's read of the skeleton it manages. Always
|
||||
* refetches (`staleTime: 0`); disabled until a category is chosen.
|
||||
*/
|
||||
export function useAdminOptionGroups(categoryId: number | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: catalogKeys.adminOptionGroups(categoryId ?? 0),
|
||||
queryFn: () => catalogApi.adminListOptionGroups(categoryId as number),
|
||||
enabled: categoryId != null,
|
||||
staleTime: 0,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { catalogApi } from '../apis';
|
||||
import { catalogKeys } from '../keys';
|
||||
import type { CategoryInput } from '../types';
|
||||
|
||||
/** Adds a top-level category, then invalidates the admin category list. */
|
||||
export function useCreateCategory() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: CategoryInput) => catalogApi.createCategory(input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: catalogKeys.adminCategoryLists() });
|
||||
queryClient.invalidateQueries({ queryKey: catalogKeys.categories() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { catalogApi } from '../apis';
|
||||
import { catalogKeys } from '../keys';
|
||||
import type { OptionGroupInput } from '../types';
|
||||
|
||||
/**
|
||||
* Adds a pricing dimension — invalidates every cached category's option-groups (admin + public), not
|
||||
* just the current one, because a cross-category group (`serviceCategoryId: null`) affects all of them.
|
||||
*/
|
||||
export function useCreateOptionGroup() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: OptionGroupInput) => catalogApi.createOptionGroup(input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: catalogKeys.adminOptionGroupsAll() });
|
||||
queryClient.invalidateQueries({ queryKey: catalogKeys.categoryOptionGroupsAll() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { catalogApi } from '../apis';
|
||||
import { catalogKeys } from '../keys';
|
||||
import type { CreateOptionValueInput } from '../types';
|
||||
|
||||
/** Adds a concrete choice to an option group — invalidates every cached category's option-groups. */
|
||||
export function useCreateOptionValue() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: CreateOptionValueInput) => catalogApi.createOptionValue(input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: catalogKeys.adminOptionGroupsAll() });
|
||||
queryClient.invalidateQueries({ queryKey: catalogKeys.categoryOptionGroupsAll() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { catalogApi } from '../apis';
|
||||
import { catalogKeys } from '../keys';
|
||||
|
||||
/**
|
||||
* Soft deactivates or reactivates a category — **never a hard delete**. Deactivating hides it from
|
||||
* public browse and new variant creation; existing variants in it are left intact. Drives both the
|
||||
* deactivate-with-confirm action and the reactivate affordance on an inactive row.
|
||||
*/
|
||||
export function useSetCategoryActive() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, isActive }: { id: number; isActive: boolean }) => catalogApi.setCategoryActive(id, isActive),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: catalogKeys.adminCategoryLists() });
|
||||
queryClient.invalidateQueries({ queryKey: catalogKeys.categories() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { catalogApi } from '../apis';
|
||||
import { catalogKeys } from '../keys';
|
||||
import type { CategoryInput } from '../types';
|
||||
|
||||
/** Edits a category's labels/description/icon/order, then invalidates the admin + public lists. */
|
||||
export function useUpdateCategory() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }: { id: number; input: CategoryInput }) => catalogApi.updateCategory(id, input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: catalogKeys.adminCategoryLists() });
|
||||
queryClient.invalidateQueries({ queryKey: catalogKeys.categories() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { catalogApi } from '../apis';
|
||||
import { catalogKeys } from '../keys';
|
||||
import type { OptionGroupInput } from '../types';
|
||||
|
||||
/**
|
||||
* Edits a group's category scope/labels/required flag/order — invalidates every cached category's
|
||||
* option-groups, since re-scoping to/from cross-category changes what more than one category sees.
|
||||
*/
|
||||
export function useUpdateOptionGroup() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }: { id: number; input: OptionGroupInput }) => catalogApi.updateOptionGroup(id, input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: catalogKeys.adminOptionGroupsAll() });
|
||||
queryClient.invalidateQueries({ queryKey: catalogKeys.categoryOptionGroupsAll() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { catalogApi } from '../apis';
|
||||
import { catalogKeys } from '../keys';
|
||||
import type { UpdateOptionValueInput } from '../types';
|
||||
|
||||
/**
|
||||
* Edits a value's labels/order and activates/deactivates it (never a hard delete — re-parenting to a
|
||||
* different group is not supported). Invalidates every cached category's option-groups.
|
||||
*/
|
||||
export function useUpdateOptionValue() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }: { id: number; input: UpdateOptionValueInput }) => catalogApi.updateOptionValue(id, input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: catalogKeys.adminOptionGroupsAll() });
|
||||
queryClient.invalidateQueries({ queryKey: catalogKeys.categoryOptionGroupsAll() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -4,3 +4,12 @@ export { useMyVariants } from './hooks/useMyVariants';
|
||||
export { useCreateVariant } from './hooks/useCreateVariant';
|
||||
export { useUpdateVariant } from './hooks/useUpdateVariant';
|
||||
export { useSetVariantActive } from './hooks/useSetVariantActive';
|
||||
export { useAdminCategories } from './hooks/useAdminCategories';
|
||||
export { useCreateCategory } from './hooks/useCreateCategory';
|
||||
export { useUpdateCategory } from './hooks/useUpdateCategory';
|
||||
export { useSetCategoryActive } from './hooks/useSetCategoryActive';
|
||||
export { useAdminOptionGroups } from './hooks/useAdminOptionGroups';
|
||||
export { useCreateOptionGroup } from './hooks/useCreateOptionGroup';
|
||||
export { useUpdateOptionGroup } from './hooks/useUpdateOptionGroup';
|
||||
export { useCreateOptionValue } from './hooks/useCreateOptionValue';
|
||||
export { useUpdateOptionValue } from './hooks/useUpdateOptionValue';
|
||||
|
||||
@@ -11,12 +11,26 @@ export const catalogKeys = {
|
||||
|
||||
// Reference data — cached for the whole session, never invalidated by this phase.
|
||||
categories: () => [...catalogKeys.all, 'categories'] as const,
|
||||
/** Prefix shared by every `categoryOptionGroups(id)` key — invalidate this to catch every cached
|
||||
* category when a group/value edit is cross-category (its `serviceCategoryId` is null) or its exact
|
||||
* category set isn't known to the caller. */
|
||||
categoryOptionGroupsAll: () => [...catalogKeys.all, 'option-groups'] as const,
|
||||
categoryOptionGroups: (categoryId?: number | null) =>
|
||||
[...catalogKeys.all, 'option-groups', categoryId ?? null] as const,
|
||||
[...catalogKeys.categoryOptionGroupsAll(), categoryId ?? null] as const,
|
||||
|
||||
// The nurse's offerings — mutable; mutations invalidate the `myVariantsLists()` prefix.
|
||||
variants: () => [...catalogKeys.all, 'variants'] as const,
|
||||
myVariantsLists: () => [...catalogKeys.variants(), 'mine'] as const,
|
||||
myVariants: (params?: PageParams) => [...catalogKeys.myVariantsLists(), params ?? {}] as const,
|
||||
variant: (id: number) => [...catalogKeys.variants(), 'detail', id] as const,
|
||||
|
||||
// Admin catalog management — separate from the public/cached reference-data keys above so a mutation
|
||||
// invalidating one never stales the other's cache entry.
|
||||
admin: () => [...catalogKeys.all, 'admin'] as const,
|
||||
adminCategoryLists: () => [...catalogKeys.admin(), 'categories'] as const,
|
||||
adminCategories: (params?: PageParams) => [...catalogKeys.adminCategoryLists(), params ?? {}] as const,
|
||||
/** Prefix shared by every `adminOptionGroups(id)` key — see `categoryOptionGroupsAll`'s doc for why a
|
||||
* group/value mutation invalidates this prefix instead of a single category id. */
|
||||
adminOptionGroupsAll: () => [...catalogKeys.admin(), 'option-groups'] as const,
|
||||
adminOptionGroups: (categoryId: number) => [...catalogKeys.adminOptionGroupsAll(), categoryId] as const,
|
||||
};
|
||||
|
||||
@@ -121,6 +121,47 @@ export interface UpdateVariantInput {
|
||||
displayName?: string | null;
|
||||
}
|
||||
|
||||
// ── Admin write models (AdminCatalogController — categories/option groups/values management) ───────────
|
||||
/** `CreateServiceCategoryCommand`/`UpdateServiceCategoryCommand` body. */
|
||||
export interface CategoryInput {
|
||||
nameFa: string;
|
||||
nameEn: string;
|
||||
descriptionFa: string | null;
|
||||
descriptionEn: string | null;
|
||||
iconKey: string | null;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
/** `CreateServiceOptionGroupCommand`/`UpdateServiceOptionGroupCommand` body — `serviceCategoryId = null`
|
||||
* makes the group cross-category (applies to every category), exactly like the read side. */
|
||||
export interface OptionGroupInput {
|
||||
serviceCategoryId: number | null;
|
||||
nameFa: string;
|
||||
nameEn: string;
|
||||
isRequired: boolean;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
/** `CreateServiceOptionValueCommand` body. */
|
||||
export interface CreateOptionValueInput {
|
||||
optionGroupId: number;
|
||||
nameFa: string;
|
||||
nameEn: string;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* `UpdateServiceOptionValueCommand` body. Re-parenting to a different group is deliberately not
|
||||
* supported server-side — it would silently change the meaning of variants that already answered
|
||||
* with this value — so there is no `optionGroupId` here.
|
||||
*/
|
||||
export interface UpdateOptionValueInput {
|
||||
nameFa: string;
|
||||
nameEn: string;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalog domain's API seam — the real HTTP client and the in-memory mock both implement this
|
||||
* interface; selection is by config (`USE_CATALOG_MOCK`), never scattered `if (mock)` checks.
|
||||
@@ -137,6 +178,21 @@ export interface CatalogApi {
|
||||
updateVariant(id: number, input: UpdateVariantInput): Promise<NurseServiceVariant>;
|
||||
/** Soft deactivate/reactivate — never a hard delete. */
|
||||
setVariantActive(id: number, isActive: boolean): Promise<void>;
|
||||
|
||||
// Admin — every category regardless of active state, so a deactivated one stays reachable to reactivate.
|
||||
adminListCategories(params?: PageParams): Promise<Paginated<ServiceCategory>>;
|
||||
createCategory(input: CategoryInput): Promise<ServiceCategory>;
|
||||
updateCategory(id: number, input: CategoryInput): Promise<ServiceCategory>;
|
||||
/** Soft deactivate/reactivate — categories/groups/values are reference data, never hard-deleted. */
|
||||
setCategoryActive(id: number, isActive: boolean): Promise<void>;
|
||||
|
||||
// Admin — a category's groups including inactive ones, each with every value including inactive.
|
||||
adminListOptionGroups(categoryId: number): Promise<ServiceOptionGroup[]>;
|
||||
createOptionGroup(input: OptionGroupInput): Promise<ServiceOptionGroup>;
|
||||
updateOptionGroup(id: number, input: OptionGroupInput): Promise<ServiceOptionGroup>;
|
||||
/** There is no group-level active toggle in the contract — a group can only be created/edited. */
|
||||
createOptionValue(input: CreateOptionValueInput): Promise<ServiceOptionValue>;
|
||||
updateOptionValue(id: number, input: UpdateOptionValueInput): Promise<ServiceOptionValue>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,6 +23,7 @@ interface CareRecordWire {
|
||||
nurseProfileId: number;
|
||||
nurseName: string | null;
|
||||
body: string;
|
||||
taskResults: TaskResult[];
|
||||
recordedAt: string;
|
||||
}
|
||||
|
||||
@@ -33,42 +34,27 @@ function toVisitNote(w: CareRecordWire): VisitNote {
|
||||
nurseProfileId: w.nurseProfileId,
|
||||
nurseDisplayName: w.nurseName,
|
||||
body: w.body,
|
||||
// The wire body carries only free text; the structured checklist is composed into it on write (see below).
|
||||
taskResults: [],
|
||||
taskResults: w.taskResults,
|
||||
recordedAt: w.recordedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds the nurse's ticked task checklist into the free-text note body, because the wire
|
||||
* `WriteCareRecordBody` has only `{ bookingId?, body }` — there is no structured task field (REQ-027 would
|
||||
* add one). The mock keeps `taskResults` structured; the real path serialises them as a leading summary line.
|
||||
*/
|
||||
export function composeVisitNoteBody(body: string, taskResults: TaskResult[] | undefined): string {
|
||||
const trimmed = body.trim();
|
||||
if (!taskResults || taskResults.length === 0) return trimmed;
|
||||
const summary = taskResults.map((t) => `${t.done ? '✓' : '✗'} ${t.label}`).join(' · ');
|
||||
return trimmed ? `${summary}\n\n${trimmed}` : summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Real HTTP implementation of the `PatientRecordsApi` seam (b14 contract). Two methods map **published**
|
||||
* b14 routes:
|
||||
* Real HTTP implementation of the `PatientRecordsApi` seam (b14 contract). Every method maps a real route:
|
||||
* - `getPatientHistory` → `GET patients/{id}/care_records` (the patient-scoped, newest-first note history;
|
||||
* a `403` from the envelope surfaces as an `ApiError` the E2 screen renders as access-denied).
|
||||
* - `createVisitNote` → `POST patients/{id}/care_records` (a nurse appends one encrypted note).
|
||||
*
|
||||
* The family-record + access methods target contract gaps the frontend filed (**REQ-027**) — no wire
|
||||
* endpoint exists — which is why the domain stays mock-primary (see `constants.ts`).
|
||||
* - `getRecordAccess` → `GET patients/{id}/record_access`.
|
||||
* - `getFamilyRecord`/`updateFamilyRecord` → `GET`/`PUT patients/{id}/care_record` — the routes exist, but
|
||||
* `FamilyCareRecord`'s medication/routine fields are richer than the wire's (see `constants.ts`'s care-plan
|
||||
* schema note), so these two are **not safe to use as written** until that's resolved.
|
||||
*
|
||||
* NOT the primary implementation this phase (`USE_PATIENT_RECORDS_MOCK = true`).
|
||||
*/
|
||||
export const patientRecordsClientApi: PatientRecordsApi = {
|
||||
// REQ-027: proposed owner/nurse-scoped read of the family-owned record (no wire endpoint yet).
|
||||
getFamilyRecord: async (patientId: number): Promise<FamilyCareRecord> =>
|
||||
unwrap(await clientFetch<ApiEnvelope<FamilyCareRecord>>(`${API}/patients/${patientId}/care_record`)),
|
||||
|
||||
// REQ-027: proposed access check. On the real path the 403 on the history read is the true access signal.
|
||||
getRecordAccess: async (patientId: number): Promise<RecordAccess> =>
|
||||
unwrap(await clientFetch<ApiEnvelope<RecordAccess>>(`${API}/patients/${patientId}/record_access`)),
|
||||
|
||||
@@ -84,7 +70,6 @@ export const patientRecordsClientApi: PatientRecordsApi = {
|
||||
return { ...page, items: page.items.map(toVisitNote) };
|
||||
},
|
||||
|
||||
// REQ-027: proposed customer edit of the family record.
|
||||
updateFamilyRecord: async (patientId: number, body: UpdateFamilyRecordRequest): Promise<FamilyCareRecord> =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<FamilyCareRecord>>(`${API}/patients/${patientId}/care_record`, {
|
||||
@@ -99,7 +84,8 @@ export const patientRecordsClientApi: PatientRecordsApi = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
bookingId: body.bookingId ?? null,
|
||||
body: composeVisitNoteBody(body.body, body.taskResults),
|
||||
body: body.body.trim(),
|
||||
taskResults: body.taskResults ?? [],
|
||||
}),
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -15,9 +15,9 @@ import type {
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* In-memory `PatientRecordsApi` — **the primary implementation this phase** (the nurse-authored visit-note
|
||||
* history/append are real b14, but the family-owned medications/routine/tasks record + the access check are
|
||||
* REQ-027 gaps — see `constants.ts`).
|
||||
* In-memory `PatientRecordsApi` — **the primary implementation this phase**. Every route this seam needs
|
||||
* exists on the server; the domain stays mock-primary because the family-owned medications/routine/tasks
|
||||
* shape doesn't match yet (an unresolved product decision — see `constants.ts`).
|
||||
*
|
||||
* The store is **patient-scoped** and lazily seeds a coherent default the first time any patient is read, so
|
||||
* every E2 record viewer has content and every state is demoable:
|
||||
@@ -149,7 +149,7 @@ function paginate<T>(all: T[], params: PageParams): Paginated<T> {
|
||||
function assertAccess(patientId: number): void {
|
||||
// The real 403 comes from the server clinical-access check; the mock denies a designated foreign patient.
|
||||
if (patientId === MOCK_FOREIGN_PATIENT_ID) {
|
||||
throw new ApiError(403, 'No clinical access to this patient', 'no_access');
|
||||
throw new ApiError(403, 'No clinical access to this patient', 'not_authorized');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ export const patientRecordsMockApi: PatientRecordsApi = {
|
||||
getRecordAccess: async (patientId: number): Promise<RecordAccess> => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
if (patientId === MOCK_FOREIGN_PATIENT_ID) {
|
||||
return { canView: false, canEdit: false, canAppendNote: false, deniedReason: 'no_access' };
|
||||
return { canView: false, canEdit: false, canAppendNote: false, deniedReason: 'not_authorized' };
|
||||
}
|
||||
// In the single-session mock, an authorized viewer can do everything; the SCREEN (customer vs nurse
|
||||
// shell) decides which affordances to render — the nurse view never wires the edit path (append-only).
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
* When true, the patient-records domain is served by the in-memory mock (`apis/mockApi.ts`) behind the
|
||||
* `PatientRecordsApi` seam.
|
||||
*
|
||||
* **Mock is primary this phase.** b14 serves the nurse-authored **visit-note history** (`care_records`
|
||||
* GET/POST) — those two methods are real — but the **family-owned editable record** (medications/routine/
|
||||
* tasks) and the **access check** have **no backend at all** (neither the contract nor the data model has
|
||||
* them; **REQ-027**). The mock seeds a default family record + a multi-nurse continuity history per patient,
|
||||
* enforces a foreign-patient **access-denied** (403) path, and lets the nurse append notes that appear in the
|
||||
* history. Flip to `false` once REQ-027 lands — only `clientApi.ts`'s family-record/access methods flip; the
|
||||
* history/append methods already map the real routes.
|
||||
* **Mock is primary — the backend is fully built, but the shapes don't match yet.** `PatientCareRecordsController`
|
||||
* implements every route this seam needs: `care_records` GET/POST (visit-note history/append), `care_record`
|
||||
* GET/PUT (the family care plan), and `record_access` GET. The blocker is a genuine schema mismatch, not a
|
||||
* missing endpoint — see `mvp/fix-plan.md`'s "Patient records" follow-up: the client's medication/routine
|
||||
* shape (structured dose amount/unit, frequency preset codes, a multi-select `timeOfDay`) was built after the
|
||||
* server shipped a simpler one (one free-text dose, one required frequency string, no `timeOfDay` at all),
|
||||
* and which side to change is a product decision, not something to guess in code. Flip to `false` once that's
|
||||
* resolved and `clientApi.ts`'s family-record methods are updated to match.
|
||||
*/
|
||||
export const USE_PATIENT_RECORDS_MOCK = true;
|
||||
|
||||
|
||||
@@ -8,12 +8,12 @@ import type { PageParams, Paginated } from '@/lib/api/types';
|
||||
* GET/POST). Encrypted at rest, returned decrypted only after the clinical-access check passes; it is
|
||||
* **patient-scoped, not booking-scoped**, so a new nurse taking over reads the whole history. A nurse
|
||||
* with a qualifying booking may **append** a note; nobody edits it.
|
||||
* 2. **The family-owned editable record** (داروها/روتین/وظایف — medications/routine/tasks) — **NO backend
|
||||
* exists** (neither the b14 contract nor the data model has it; **REQ-027**). The customer maintains it;
|
||||
* it is mocked behind this seam. The domain is therefore **mock-primary** (see `constants.ts`).
|
||||
* ui-phase-9 added the structured shape (dose amount/unit, frequency preset codes, time-of-day codes)
|
||||
* that replaced free-text dose/frequency/routine-time editing — recorded as a REQ-027 addendum, not a
|
||||
* new REQ number, in `requests/for-backend.md`.
|
||||
* 2. **The family-owned editable record** (داروها/روتین/وظایف — medications/routine/tasks) — the backend
|
||||
* route exists (`GET`/`PUT patients/{id}/care_record`) but its medication/routine shape is simpler than
|
||||
* what ui-phase-9 shipped (structured dose amount/unit, frequency preset codes, multi-select `timeOfDay`
|
||||
* vs. one free-text dose, one required frequency string, no `timeOfDay`) — a product decision on which
|
||||
* side to change, not yet made (see `mvp/fix-plan.md`). The customer maintains it; it is mocked behind
|
||||
* this seam until that's resolved. The domain is therefore **mock-primary** (see `constants.ts`).
|
||||
*
|
||||
* Load-bearing rules (contract + phase §5):
|
||||
* - **Family-owned & patient-scoped.** The customer owns/edits medications/routine/tasks; the record
|
||||
@@ -112,9 +112,10 @@ export interface VisitNote {
|
||||
recordedAt: string;
|
||||
}
|
||||
|
||||
// ── Access (REQ-027 — no wire endpoint; derived from the 403 on a read / the caller role) ─────────────────
|
||||
// ── Access (`GET record_access` exists; matches this shape exactly — mocked only because the domain switches
|
||||
// as one seam, and the care-plan schema decision below still gates the flip) ───────────────────────────────
|
||||
|
||||
export type RecordAccessDeniedReason = 'no_access' | 'not_found';
|
||||
export type RecordAccessDeniedReason = 'not_authorized' | 'not_found';
|
||||
|
||||
/**
|
||||
* Who may do what with this patient's record. `canEdit` is the owning customer only; `canAppendNote` is a
|
||||
@@ -127,17 +128,15 @@ export interface RecordAccess {
|
||||
deniedReason?: RecordAccessDeniedReason;
|
||||
}
|
||||
|
||||
/** The customer edit body (REQ-027) — replaces the provided sections of the family record. */
|
||||
/** The customer edit body — replaces the provided sections of the family record (maps to `UpsertCarePlanBody`,
|
||||
* pending the care-plan schema decision — see `constants.ts`). */
|
||||
export interface UpdateFamilyRecordRequest {
|
||||
medications?: Medication[];
|
||||
routine?: RoutineItem[];
|
||||
tasks?: CareTask[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The nurse append body. Maps to the wire `WriteCareRecordBody` (`{ bookingId?, body }`): on the real path
|
||||
* `taskResults` is composed into `body` (the wire has no structured task field); the mock keeps it structured.
|
||||
*/
|
||||
/** The nurse append body. Maps 1:1 to the wire `WriteCareRecordBody` (`{ bookingId?, body, taskResults? }`). */
|
||||
export interface CreateVisitNoteRequest {
|
||||
bookingId?: number | null;
|
||||
body: string;
|
||||
@@ -153,18 +152,19 @@ export interface WriteVisitNoteResult {
|
||||
|
||||
/**
|
||||
* The patient-records API seam — the real HTTP client and the in-memory mock both implement this; selection
|
||||
* is by config (`USE_PATIENT_RECORDS_MOCK`), never scattered `if (mock)` checks. `getPatientHistory` +
|
||||
* `createVisitNote` map real b14 routes; the family-record + access methods are REQ-027 gaps (mocked).
|
||||
* is by config (`USE_PATIENT_RECORDS_MOCK`), never scattered `if (mock)` checks. Every method maps a real
|
||||
* route; the whole domain still runs on the mock because the family-record methods need the care-plan schema
|
||||
* decision resolved first (see `constants.ts`) before `clientApi.ts` can implement them correctly.
|
||||
*/
|
||||
export interface PatientRecordsApi {
|
||||
/** REQ-027 — the family-owned medications/routine/tasks (customer-maintained). */
|
||||
/** `GET care_record` exists; blocked on the care-plan schema decision. */
|
||||
getFamilyRecord(patientId: number): Promise<FamilyCareRecord>;
|
||||
/** REQ-027 — who may view/edit/append for this patient (derived from the 403 on a read + the caller role). */
|
||||
/** `GET record_access` — who may view/edit/append for this patient. */
|
||||
getRecordAccess(patientId: number): Promise<RecordAccess>;
|
||||
/** REAL — the patient-scoped longitudinal visit-note history, newest-first, paged. */
|
||||
/** `GET care_records` — the patient-scoped longitudinal visit-note history, newest-first, paged. */
|
||||
getPatientHistory(patientId: number, params: PageParams): Promise<Paginated<VisitNote>>;
|
||||
/** REQ-027 — the customer replaces sections of the family record. */
|
||||
/** `PUT care_record` exists; blocked on the care-plan schema decision. */
|
||||
updateFamilyRecord(patientId: number, body: UpdateFamilyRecordRequest): Promise<FamilyCareRecord>;
|
||||
/** REAL — a nurse appends a visit note (append-only; never edits the record). */
|
||||
/** `POST care_records` — a nurse appends a visit note (append-only; never edits the record). */
|
||||
createVisitNote(patientId: number, body: CreateVisitNoteRequest): Promise<WriteVisitNoteResult>;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,19 @@ interface RefundStatusWire {
|
||||
reference: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `CancellationPolicyPreviewDto` — everything `CancellationPolicyPreview` has except `refundableSessionIds`,
|
||||
* which the server doesn't serve as its own field (derived below from `sessions` instead).
|
||||
*/
|
||||
type CancellationPolicyPreviewWire = Omit<CancellationPolicyPreview, 'refundableSessionIds'>;
|
||||
|
||||
function toPreview(wire: CancellationPolicyPreviewWire): CancellationPolicyPreview {
|
||||
return {
|
||||
...wire,
|
||||
refundableSessionIds: wire.sessions.filter((s) => s.refundable).map((s) => s.bookingSessionId),
|
||||
};
|
||||
}
|
||||
|
||||
function toSummary(wire: RefundStatusWire): RefundSummary {
|
||||
return {
|
||||
id: wire.id,
|
||||
@@ -53,23 +66,20 @@ function toSummary(wire: RefundStatusWire): RefundSummary {
|
||||
}
|
||||
|
||||
/**
|
||||
* Real HTTP implementation of the `RefundsApi` seam. Only `getRefund` maps a **published** b11 route
|
||||
* (`GET refunds/{id}/status`, tenancy-scoped); the other three target contract gaps the frontend filed
|
||||
* (which is why the domain stays mock-primary — see `constants.ts`):
|
||||
* - `resolveCancellationPolicy` → REQ-020 (`GET bookings/{id}/cancellation_policy`): b9 snapshots the
|
||||
* policy only *after* a cancel; there is no pre-cancel preview resolving the tier by current lead time
|
||||
* + per-session refundability.
|
||||
* - `cancelBooking` → REQ-019 (`POST bookings/{id}/cancel`): b11 refunds are admin-only, no customer path.
|
||||
* - `getRefundByBooking` → REQ-021 (`GET refunds/by_booking/{id}`): the customer cannot obtain a refund
|
||||
* id from the admin-only worklist, so it needs to reach its refund from the booking. `404` = no refund.
|
||||
*
|
||||
* NOT the primary implementation this phase (`USE_REFUNDS_MOCK = true`).
|
||||
* Real HTTP implementation of the `RefundsApi` seam. The customer half (`resolveCancellationPolicy`/
|
||||
* `cancelBooking`/`getRefundByBooking`/`getRefund`/`getMyRefunds`) is the primary implementation as of
|
||||
* phase 08 (REQ-019/020/021 routes are live: `BookingsController.CancellationPolicy`/`.Cancel`,
|
||||
* `RefundsController.Status`/`.ByBooking`). The admin console methods below (`getRefundPreview`/
|
||||
* `approveRefund`/`rejectRefund`) still target proposed routes (REQ-035) — `AdminRefundsController` only
|
||||
* has create-and-execute — so `USE_ADMIN_REFUNDS_MOCK` keeps them on the mock (see `constants.ts`).
|
||||
*/
|
||||
export const refundsClientApi: RefundsApi = {
|
||||
resolveCancellationPolicy: async (bookingId: number) =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<CancellationPolicyPreview>>(
|
||||
`${BOOKINGS}/${bookingId}/cancellation_policy`,
|
||||
toPreview(
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<CancellationPolicyPreviewWire>>(
|
||||
`${BOOKINGS}/${bookingId}/cancellation_policy`,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -99,10 +109,16 @@ export const refundsClientApi: RefundsApi = {
|
||||
toSummary(unwrap(await clientFetch<ApiEnvelope<RefundStatusWire>>(`${REFUNDS}/${refundId}/status`))),
|
||||
|
||||
// REQ-048 proposed slug — no "all my refunds" list exists yet (only by-booking/by-id reads); 404s
|
||||
// until delivered (the wallet «استردادها» tab renders its empty state until then).
|
||||
// until delivered, so the wallet «استردادها» tab renders its empty state until then rather than an
|
||||
// error (a 404 here means "not built yet", not "something went wrong").
|
||||
getMyRefunds: async () => {
|
||||
const wire = unwrap(await clientFetch<ApiEnvelope<RefundStatusWire[]>>(`${REFUNDS}/my`));
|
||||
return wire.map(toSummary);
|
||||
try {
|
||||
const wire = unwrap(await clientFetch<ApiEnvelope<RefundStatusWire[]>>(`${REFUNDS}/my`));
|
||||
return wire.map(toSummary);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) return [];
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// REQ-035: refund preview endpoint. b11 computes the fee-leg decomposition only *on create* (there is no
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
import { USE_REFUNDS_MOCK } from '../constants';
|
||||
import { USE_ADMIN_REFUNDS_MOCK, USE_CUSTOMER_REFUNDS_MOCK } from '../constants';
|
||||
import type { RefundsApi } from '../types';
|
||||
import { refundsClientApi } from './clientApi';
|
||||
import { refundsMockApi } from './mockApi';
|
||||
|
||||
const customer = USE_CUSTOMER_REFUNDS_MOCK ? refundsMockApi : refundsClientApi;
|
||||
const admin = USE_ADMIN_REFUNDS_MOCK ? refundsMockApi : refundsClientApi;
|
||||
|
||||
/**
|
||||
* The selected `RefundsApi` implementation — the single seam the hooks import. Selection is by config
|
||||
* (`USE_REFUNDS_MOCK`), never by scattered `if (mock)` checks.
|
||||
* The selected `RefundsApi` implementation — the single seam the hooks import. The customer surface and
|
||||
* the admin console are two independently-selected halves (`USE_CUSTOMER_REFUNDS_MOCK` /
|
||||
* `USE_ADMIN_REFUNDS_MOCK`), composed into one object here rather than scattered `if (mock)` checks —
|
||||
* see the constants for why the admin group must never be split across real and mock.
|
||||
*/
|
||||
export const refundsApi: RefundsApi = USE_REFUNDS_MOCK ? refundsMockApi : refundsClientApi;
|
||||
export const refundsApi: RefundsApi = {
|
||||
resolveCancellationPolicy: customer.resolveCancellationPolicy,
|
||||
cancelBooking: customer.cancelBooking,
|
||||
getRefundByBooking: customer.getRefundByBooking,
|
||||
getRefund: customer.getRefund,
|
||||
getMyRefunds: customer.getMyRefunds,
|
||||
|
||||
getRefundPreview: admin.getRefundPreview,
|
||||
initiateRefund: admin.initiateRefund,
|
||||
approveRefund: admin.approveRefund,
|
||||
rejectRefund: admin.rejectRefund,
|
||||
};
|
||||
|
||||
@@ -124,8 +124,10 @@ function computePreview(bookingId: number): CancellationPolicyPreview {
|
||||
bookingId,
|
||||
cancellable,
|
||||
cancellationPolicyCode: policyCode,
|
||||
refundPercentageApplied: tier.refundFraction,
|
||||
feePercentage: Math.round((1 - tier.refundFraction) * 100) / 100,
|
||||
// 0–100 (matches the real server's decimal `RefundPercentageApplied`/`FeePercentage`), not the 0–1
|
||||
// fraction `tier.refundFraction` uses internally for the BigInt math above.
|
||||
refundPercentageApplied: tier.refundFraction * 100,
|
||||
feePercentage: (1 - tier.refundFraction) * 100,
|
||||
refundAmountIrr: refundAmount.toString(),
|
||||
feeAmountIrr: feeAmount.toString(),
|
||||
refundableAmountIrr: refundableGross.toString(),
|
||||
@@ -158,7 +160,7 @@ refundsByBooking[5004] = {
|
||||
totalRefundedIrr: '6000000',
|
||||
expectedCustomerRefundEta: null,
|
||||
externalRevertReference: maskedReference(5004),
|
||||
refundPercentageApplied: 0.5,
|
||||
refundPercentageApplied: 50,
|
||||
cancellationPolicyCode: 'partial_under_24h',
|
||||
platformFeeRefundedIrr: '720000',
|
||||
nursePayoutRefundedIrr: '5280000',
|
||||
@@ -282,13 +284,14 @@ const adminRefundsById: Record<number, AdminRefundResult> = {};
|
||||
const adminInitiateAttempts: Record<number, number> = {};
|
||||
|
||||
/**
|
||||
* In-memory mock behind the `RefundsApi` seam — the whole customer cancel + refund surface b11 doesn't
|
||||
* serve (admin-only refunds; no cancel command / policy preview / refund-by-booking / decomposition on the
|
||||
* customer status → REQ-019/020/021). It reads the shared f8 bookings store to resolve the tier + per-
|
||||
* session refundability, flips the booking to `cancelled` on confirm (so the booking-detail cache reflects
|
||||
* it after invalidation), enforces the outside-policy `409`, and drives the refund through the customer
|
||||
* steps (card immediate `succeeded`; BNPL `processing` with an ETA that reconciles over polls). Swap to the
|
||||
* real `clientApi` once REQ-019/020/021 land (`USE_REFUNDS_MOCK = false`).
|
||||
* In-memory mock behind the `RefundsApi` seam. The customer half (cancel/preview/status) is a config-
|
||||
* selectable fallback now that REQ-019/020/021 are real (`USE_CUSTOMER_REFUNDS_MOCK = false` by default) —
|
||||
* kept for local demo/dev without a backend. It reads the shared f8 bookings store to resolve the tier +
|
||||
* per-session refundability, flips the booking to `cancelled` on confirm (so the booking-detail cache
|
||||
* reflects it after invalidation), enforces the outside-policy `409`, and drives the refund through the
|
||||
* customer steps (card immediate `succeeded`; BNPL `processing` with an ETA that reconciles over polls).
|
||||
* The admin half below (preview/initiate/approve/reject) is the **primary** implementation
|
||||
* (`USE_ADMIN_REFUNDS_MOCK = true`) — REQ-035's real endpoints don't exist yet.
|
||||
*/
|
||||
export const refundsMockApi: RefundsApi = {
|
||||
resolveCancellationPolicy: async (bookingId) => {
|
||||
|
||||
@@ -2,20 +2,24 @@ import { REFUND_ETA_MAX_BUSINESS_DAYS } from '@/constants';
|
||||
import type { CancellationPolicyCode } from './types';
|
||||
|
||||
/**
|
||||
* When true, the refunds domain is served by the in-memory mock (`apis/mockApi.ts`) behind the
|
||||
* `RefundsApi` seam.
|
||||
* When true, the **customer-facing** refund surface (`resolveCancellationPolicy`/`cancelBooking`/
|
||||
* `getRefundByBooking`/`getRefund`/`getMyRefunds`) is served by the in-memory mock (`apis/mockApi.ts`).
|
||||
*
|
||||
* **Mock is primary this phase.** b11 shipped the refund lifecycle **admin-only**: the only
|
||||
* customer-visible surface is `GET refunds/{id}/status` (thin: status/channel/amount/ETA/masked ref).
|
||||
* There is **no** customer cancel command, **no** cancellation-policy preview, **no** refund-by-booking
|
||||
* lookup, and the customer status carries **no** fee-leg decomposition — all filed as REQ-019/020/021.
|
||||
* So the whole cancel + policy-disclosure + fee-split surface is mocked behind this seam. The mock reads
|
||||
* the shared f8 bookings store (lead time + per-session refundability), flips the booking to `cancelled`
|
||||
* on confirm (so the booking-detail cache reflects it), and drives a refund through
|
||||
* `submitted → on_its_way → completed` (card immediate; BNPL processing with an ETA). Flip to `false`
|
||||
* once REQ-019/020/021 land — no hook/component change.
|
||||
* **Real as of phase 08 (blocker-phases/08-refunds-demock.md).** REQ-019/020/021's routes are live and
|
||||
* shape-matched (`BookingsController.CancellationPolicy`/`.Cancel`, `RefundsController.Status`/`.ByBooking`)
|
||||
* — the mock stays only as a config-selectable fallback for local demo/dev without a backend.
|
||||
*/
|
||||
export const USE_REFUNDS_MOCK = true;
|
||||
export const USE_CUSTOMER_REFUNDS_MOCK = false;
|
||||
|
||||
/**
|
||||
* When true, the **admin refund console** (`getRefundPreview`/`initiateRefund`/`approveRefund`/
|
||||
* `rejectRefund`) is served by the mock. `AdminRefundsController` only implements create-and-execute
|
||||
* (`initiateRefund`'s real endpoint) — there is no real read-only preview, no retry/approve, and no
|
||||
* reject route (REQ-035). Mixing a real `initiateRefund` with a mocked preview would let an admin approve
|
||||
* against numbers that don't match what actually executes, so the whole admin group stays on the mock
|
||||
* together until all four land. Flip once REQ-035 ships.
|
||||
*/
|
||||
export const USE_ADMIN_REFUNDS_MOCK = true;
|
||||
|
||||
/**
|
||||
* The cancellation preview depends on `now` vs the booking start (the resolved tier moves as the visit
|
||||
|
||||
@@ -106,9 +106,9 @@ export interface CancellationPolicyPreview {
|
||||
/** `false` when nothing is refundable (already cancelled/completed, or no un-started sessions). */
|
||||
cancellable: boolean;
|
||||
cancellationPolicyCode: CancellationPolicyCode;
|
||||
/** 0–1 fraction of the refundable amount returned to the customer. */
|
||||
/** 0–100 percent of the refundable amount returned to the customer (matches the server's decimal). */
|
||||
refundPercentageApplied: number;
|
||||
/** 0–1 fraction retained as the cancellation fee/penalty (`= 1 - refundPercentageApplied`). */
|
||||
/** 0–100 percent retained as the cancellation fee/penalty (`= 100 - refundPercentageApplied`). */
|
||||
feePercentage: number;
|
||||
/** IRR digit-string — the amount refunded to the customer. */
|
||||
refundAmountIrr: string;
|
||||
@@ -125,7 +125,8 @@ export interface CancellationPolicyPreview {
|
||||
refundChannel: RefundChannel;
|
||||
/** Populated only for `bnpl_revert` (the ~7–10 business-day window); a date `YYYY-MM-DD`. */
|
||||
expectedCustomerRefundEta: string | null;
|
||||
/** The refundable session ids the confirm submits (all un-started sessions). */
|
||||
/** The refundable session ids the confirm submits (all un-started sessions); derived from `sessions` on
|
||||
* the real path (the server doesn't serve it as its own field). */
|
||||
refundableSessionIds: number[];
|
||||
sessions: CancellationSessionPreview[];
|
||||
}
|
||||
@@ -164,6 +165,7 @@ export interface RefundSummary {
|
||||
/** Opaque, **masked** (last 4 only) external reference — never parse it. */
|
||||
externalRevertReference: string | null;
|
||||
/** --- Fee-leg decomposition + policy snapshot (REQ-021: `null` on the real path until served). --- */
|
||||
/** 0–100 percent, matching `CancellationPolicyPreview.refundPercentageApplied`'s scale. */
|
||||
refundPercentageApplied: number | null;
|
||||
cancellationPolicyCode: string | null;
|
||||
platformFeeRefundedIrr: string | null;
|
||||
|
||||
@@ -32,6 +32,8 @@ interface NurseSearchResultDto {
|
||||
nurseName: string | null;
|
||||
avatarUrl: string | null;
|
||||
distanceKm: number | null;
|
||||
/** Phase 10: how many of the nurse's variants matched this query (server-side dedup). */
|
||||
matchingServiceCount: number;
|
||||
/** REQ-040 (proposed) — not yet served; absent until the backend lands it. */
|
||||
topReviewTag?: string | null;
|
||||
}
|
||||
@@ -94,6 +96,7 @@ export const searchClientApi: SearchApi = {
|
||||
nurseId: dto.nurseId,
|
||||
variantId: dto.variantId,
|
||||
serviceCategoryId: dto.serviceCategoryId,
|
||||
matchingServiceCount: dto.matchingServiceCount,
|
||||
// REQ-012 — identity denormalized onto the index row; card falls back to a label only when null.
|
||||
nurseName: dto.nurseName ?? '',
|
||||
avatarUrl: dto.avatarUrl,
|
||||
|
||||
@@ -34,11 +34,12 @@ function withinPrice(priceIrr: string, min?: string, max?: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
function toResult(nurse: SeedNurse, variant: SeedVariant): NurseSearchResult {
|
||||
function toResult(nurse: SeedNurse, variant: SeedVariant, matchingServiceCount: number): NurseSearchResult {
|
||||
return {
|
||||
nurseId: nurse.nurseId,
|
||||
variantId: variant.variantId,
|
||||
serviceCategoryId: variant.serviceCategoryId,
|
||||
matchingServiceCount,
|
||||
nurseName: nurse.nurseName,
|
||||
avatarUrl: nurse.avatarUrl,
|
||||
isVerified: true,
|
||||
@@ -54,6 +55,25 @@ function toResult(nurse: SeedNurse, variant: SeedVariant): NurseSearchResult {
|
||||
};
|
||||
}
|
||||
|
||||
/** One card per nurse (phase 10): collapse the matched variant rows down to her cheapest matching
|
||||
* variant, counting how many others matched. Mirrors the real `SqlNurseSearch` grouping. */
|
||||
function dedupeByNurse(rows: { nurse: SeedNurse; variant: SeedVariant }[]): NurseSearchResult[] {
|
||||
const byNurse = new Map<number, { nurse: SeedNurse; cheapest: SeedVariant; matchCount: number }>();
|
||||
for (const { nurse, variant } of rows) {
|
||||
const existing = byNurse.get(nurse.nurseId);
|
||||
if (!existing) {
|
||||
byNurse.set(nurse.nurseId, { nurse, cheapest: variant, matchCount: 1 });
|
||||
continue;
|
||||
}
|
||||
existing.matchCount += 1;
|
||||
const isCheaper =
|
||||
BigInt(variant.priceIrr) < BigInt(existing.cheapest.priceIrr) ||
|
||||
(BigInt(variant.priceIrr) === BigInt(existing.cheapest.priceIrr) && variant.variantId < existing.cheapest.variantId);
|
||||
if (isCheaper) existing.cheapest = variant;
|
||||
}
|
||||
return Array.from(byNurse.values()).map(({ nurse, cheapest, matchCount }) => toResult(nurse, cheapest, matchCount));
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory mock behind the `SearchApi` seam. Reproduces the b7 filter + geography + rating-sort
|
||||
* semantics over verified-only fixtures, so C1/C2/C3 (incl. the empty state and the caching revert)
|
||||
@@ -71,25 +91,24 @@ export const searchMockApi: SearchApi = {
|
||||
throw new ApiError(400, 'min_price must not exceed max_price', 'invalid_price_range');
|
||||
}
|
||||
|
||||
const matched = allRows()
|
||||
.filter(({ nurse, variant }) => {
|
||||
if (variant.serviceCategoryId !== filters.serviceCategoryId) return false;
|
||||
if (variant.cityId !== filters.cityId) return false;
|
||||
if (!matchesDistrict(variant.districtId, filters.districtId)) return false;
|
||||
if (filters.nurseGender && nurse.gender !== filters.nurseGender) return false;
|
||||
if (filters.priceUnit && variant.priceUnit !== filters.priceUnit) return false;
|
||||
if (!withinPrice(variant.priceIrr, filters.priceMin, filters.priceMax)) return false;
|
||||
return true;
|
||||
})
|
||||
// Rating desc, tiebroken by review count then ids so paging is deterministic (contract order).
|
||||
const matchedRows = allRows().filter(({ nurse, variant }) => {
|
||||
if (variant.serviceCategoryId !== filters.serviceCategoryId) return false;
|
||||
if (variant.cityId !== filters.cityId) return false;
|
||||
if (!matchesDistrict(variant.districtId, filters.districtId)) return false;
|
||||
if (filters.nurseGender && nurse.gender !== filters.nurseGender) return false;
|
||||
if (filters.priceUnit && variant.priceUnit !== filters.priceUnit) return false;
|
||||
if (!withinPrice(variant.priceIrr, filters.priceMin, filters.priceMax)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const matched = dedupeByNurse(matchedRows)
|
||||
// Rating desc, tiebroken by review count then nurse id so paging is deterministic (contract order).
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.nurse.averageRating - a.nurse.averageRating ||
|
||||
b.nurse.totalReviews - a.nurse.totalReviews ||
|
||||
a.nurse.nurseId - b.nurse.nurseId ||
|
||||
a.variant.variantId - b.variant.variantId,
|
||||
)
|
||||
.map(({ nurse, variant }) => toResult(nurse, variant));
|
||||
b.averageRating - a.averageRating ||
|
||||
b.totalReviews - a.totalReviews ||
|
||||
a.nurseId - b.nurseId,
|
||||
);
|
||||
|
||||
const pageSize = filters.pageSize || SEARCH_PAGE_SIZE;
|
||||
const page = filters.page || 1;
|
||||
|
||||
@@ -11,8 +11,10 @@ import type { PriceUnit } from '@/services/catalog/types';
|
||||
* - **Every returned row is already bookable.** The `nurse_search_index` invariant guarantees a hit
|
||||
* only when the nurse is verified + not suspended + accepting + the variant is active. The UI must
|
||||
* **never** re-filter for verification, and never surface an unverified/paused nurse.
|
||||
* - **The result unit is the variant, not the nurse** — a nurse with several variants/areas can appear
|
||||
* as several hits.
|
||||
* - **The result unit is the nurse, one card per nurse** (phase 10 — previously the variant, so a nurse
|
||||
* with several matching variants/areas surfaced as several hits). The server groups the underlying
|
||||
* per-variant index rows and picks the cheapest matching variant as the card's representative;
|
||||
* `matchingServiceCount` says how many of her variants matched.
|
||||
* - **`districtId = null` ⇒ whole city**, both directions; the client omits `districtId` for a
|
||||
* whole-city search rather than sending a bogus value.
|
||||
* - **Same-gender is first-class** — `nurseGender` is an up-front filter, never silently defaulted or
|
||||
@@ -52,11 +54,15 @@ export interface NurseSearchFilters {
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
/** A single C2 result card row (one bookable variant matched in a covered area). */
|
||||
/** A single C2 result card — one nurse (phase 10: server-deduplicated; was previously one bookable
|
||||
* variant matched in a covered area, so the same nurse could repeat across several cards). */
|
||||
export interface NurseSearchResult {
|
||||
nurseId: number;
|
||||
/** The nurse's cheapest matching variant — the card's "from X" price and the profile deep-link target. */
|
||||
variantId: number;
|
||||
serviceCategoryId: number;
|
||||
/** How many of the nurse's variants matched this query (>= 1); the card discloses "+N more" when > 1. */
|
||||
matchingServiceCount: number;
|
||||
/** Display name (mock/future-backend; the real b7 row omits it — card falls back to a label). */
|
||||
nurseName: string;
|
||||
avatarUrl: string | null;
|
||||
|
||||
@@ -15,7 +15,6 @@ import type {
|
||||
IdentityKycInput,
|
||||
NurseCredential,
|
||||
RunStepResult,
|
||||
SignedDocumentUrl,
|
||||
TrustBadge,
|
||||
UploadUrlResult,
|
||||
VerificationAggregateStatus,
|
||||
@@ -249,12 +248,6 @@ export const verificationClientApi: VerificationApi = {
|
||||
};
|
||||
},
|
||||
|
||||
// REQ-034: b6 has no per-document signed-URL route (documents already carry a short-lived signed `url` on
|
||||
// the case detail). This targets a proposed `GET admin_verifications/documents/{documentId}/url` for an
|
||||
// on-demand re-sign; until it ships, callers can re-fetch the case to get a fresh document `url`.
|
||||
getDocumentSignedUrl: async (documentId: number): Promise<SignedDocumentUrl> =>
|
||||
unwrap(await clientFetch<ApiEnvelope<SignedDocumentUrl>>(`${ADMIN_BASE}/documents/${documentId}/url`)),
|
||||
|
||||
decideStep: async (stepId: number, input: DecideStepInput): Promise<DecideStepResult> =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<DecideStepResult>>(`${ADMIN_BASE}/steps/${stepId}/decide`, {
|
||||
@@ -263,9 +256,9 @@ export const verificationClientApi: VerificationApi = {
|
||||
}),
|
||||
),
|
||||
|
||||
// REQ-034: b6 has no whole-verification approve/reject route — approval emerges from the final step
|
||||
// `decide` re-aggregating `is_verified`. These target proposed `POST admin_verifications/{id}/approve` and
|
||||
// `/reject` for an explicit admin action (until they ship, approve by deciding the last pending step).
|
||||
// Phase 09: explicit whole-verification approve/reject actions (AdminVerificationsController.Approve/
|
||||
// Reject). Approve re-confirms what Finalize already flipped once every step passed; reject is a distinct
|
||||
// admin override, not a per-step decision.
|
||||
approveVerification: async (nurseVerificationId: number): Promise<void> => {
|
||||
await clientFetch<ApiEnvelope<boolean>>(`${ADMIN_BASE}/${nurseVerificationId}/approve`, { method: 'POST' });
|
||||
},
|
||||
|
||||
@@ -143,7 +143,8 @@ function mkDoc(id: number, originalFileName: string): VerificationDocument {
|
||||
contentType: 'application/pdf',
|
||||
fileSizeBytes: 482_000,
|
||||
originalFileName,
|
||||
// A short-lived signed GET URL; the on-demand `getDocumentSignedUrl` re-signs it fresh each open.
|
||||
// A short-lived signed GET URL, embedded on the case detail — re-opening the viewer refetches the
|
||||
// case (there is no separate per-document re-sign route) to get a fresh one.
|
||||
url: `https://mock.balinyaar.local/docs/${id}`,
|
||||
};
|
||||
}
|
||||
@@ -414,20 +415,6 @@ export const verificationMockApi: VerificationApi = {
|
||||
return toCaseView(record);
|
||||
},
|
||||
|
||||
getDocumentSignedUrl: async (documentId) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
// Sentinel for the viewer's error/re-request path: this document can never be signed.
|
||||
if (documentId === 9999) {
|
||||
throw new ApiError(404, 'Document not found', 'document_not_found');
|
||||
}
|
||||
// A FRESH short-lived URL each call — the signature + timestamp differ so it is never re-used from cache.
|
||||
const sig = Math.random().toString(36).slice(2, 12);
|
||||
return {
|
||||
url: `https://mock.balinyaar.local/docs/${documentId}?sig=${sig}&t=${Date.now()}`,
|
||||
expiresInSeconds: 60,
|
||||
};
|
||||
},
|
||||
|
||||
decideStep: async (stepId, input) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const found = findCaseByStepId(stepId);
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
/**
|
||||
* When true, the verification domain is served by the in-memory mock (apis/mockApi.ts) behind the
|
||||
* VerificationApi seam. The b6 routes exist server-side, but — like `catalog` — the mock lets the full
|
||||
* nurse flow (checklist → identity run → credential upload → under-review → admin-approval → verified
|
||||
* badge + publish gate) demo standalone before the backend is reachable in this environment. Flip to
|
||||
* false to hit the live endpoints — no hook/component changes (see
|
||||
* dev/shared-working-context/reports/mocks-registry.md).
|
||||
* VerificationApi seam.
|
||||
*
|
||||
* **Real as of phase 09 (blocker-phases/09-nurse-verification-badge.md).** The b6 routes are live and
|
||||
* shape-matched; the two admin gaps the mock papered over (whole-verification approve/reject, and the
|
||||
* per-document signed-URL re-sign) shipped in the same change — see `AdminVerificationsController`'s
|
||||
* `Approve`/`Reject` actions and `DocumentViewer`'s refetch-the-case rewire. The mock stays only as a
|
||||
* config-selectable fallback for local demo/dev without a backend.
|
||||
*/
|
||||
export const USE_VERIFICATION_MOCK = true;
|
||||
export const USE_VERIFICATION_MOCK = false;
|
||||
|
||||
/**
|
||||
* The checklist is **moderately fresh** — submitting a step changes it, and every mutation invalidates
|
||||
@@ -35,11 +37,3 @@ export const ADMIN_QUEUE_PAGE_SIZE = 20;
|
||||
|
||||
/** A single admin case — same freshness as the queue; invalidated on every decide / approve / reject. */
|
||||
export const ADMIN_CASE_STALE_TIME = 20_000;
|
||||
|
||||
/**
|
||||
* A document's **signed GET URL is short-lived** (server issues ~60 s URLs). Fetch it on demand and keep it
|
||||
* out of long-term cache: a short `staleTime` re-fetches a fresh URL on reopen; a short `gcTime` drops the
|
||||
* stale URL soon after the viewer closes (never retry — a failed/expired sign is surfaced, not re-hammered).
|
||||
*/
|
||||
export const SIGNED_DOCUMENT_URL_STALE_TIME = 30_000;
|
||||
export const SIGNED_DOCUMENT_URL_GC_TIME = 60_000;
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { verificationApi } from '../apis';
|
||||
import { verificationKeys } from '../keys';
|
||||
import { SIGNED_DOCUMENT_URL_GC_TIME, SIGNED_DOCUMENT_URL_STALE_TIME } from '../constants';
|
||||
|
||||
/**
|
||||
* A document's **short-lived signed GET URL**, fetched on demand when the viewer opens a document (pass
|
||||
* `null` while none is open). Short `staleTime` + short `gcTime` keep the URL out of long-term cache — a
|
||||
* reopen re-signs a fresh URL rather than reusing an expired one. `retry: false`: a failed/expired sign is
|
||||
* surfaced to the viewer's error/re-request path, not silently re-hammered.
|
||||
*/
|
||||
export function useVerificationDocumentUrl(documentId: number | null) {
|
||||
return useQuery({
|
||||
queryKey: verificationKeys.adminDocumentUrl(documentId ?? -1),
|
||||
queryFn: () => verificationApi.getDocumentSignedUrl(documentId as number),
|
||||
enabled: documentId != null,
|
||||
staleTime: SIGNED_DOCUMENT_URL_STALE_TIME,
|
||||
gcTime: SIGNED_DOCUMENT_URL_GC_TIME,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
@@ -9,7 +9,6 @@ export { useNurseTrustBadge } from './hooks/useNurseTrustBadge';
|
||||
// Admin review queue (b6 AdminVerificationsController)
|
||||
export { useVerificationQueue } from './hooks/useVerificationQueue';
|
||||
export { useVerificationCase } from './hooks/useVerificationCase';
|
||||
export { useVerificationDocumentUrl } from './hooks/useVerificationDocumentUrl';
|
||||
export { useDecideStep } from './hooks/useDecideStep';
|
||||
export { useApproveVerification } from './hooks/useApproveVerification';
|
||||
export { useRejectVerification } from './hooks/useRejectVerification';
|
||||
|
||||
@@ -7,9 +7,9 @@ import type { PageParams } from '@/lib/api/types';
|
||||
* submit/upload/run mutation invalidates `status()` so the checklist re-renders from cache with no
|
||||
* manual refetch. The public `badge(nurseId)` is longer-lived and reused by search/f6.
|
||||
*
|
||||
* The admin subtree (`admin()` → queue / case / document-url) mirrors the same hierarchy: each queue
|
||||
* variant (filters+params) and each case keys independently, and the `adminQueues()` / `adminCases()`
|
||||
* prefixes let a decision invalidate every queue page and a single case in one call.
|
||||
* The admin subtree (`admin()` → queue / case) mirrors the same hierarchy: each queue variant
|
||||
* (filters+params) and each case keys independently, and the `adminQueues()` / `adminCases()` prefixes let
|
||||
* a decision invalidate every queue page and a single case in one call.
|
||||
*/
|
||||
export const verificationKeys = {
|
||||
all: ['verification'] as const,
|
||||
@@ -35,8 +35,4 @@ export const verificationKeys = {
|
||||
// A single nurse's full case — invalidated on every decide / approve / reject.
|
||||
adminCases: () => [...verificationKeys.admin(), 'case'] as const,
|
||||
adminCase: (nurseVerificationId: number) => [...verificationKeys.adminCases(), nurseVerificationId] as const,
|
||||
|
||||
// A document's short-lived signed URL — keyed per document; kept out of long-term cache (fetched on demand).
|
||||
adminDocumentUrls: () => [...verificationKeys.admin(), 'document_url'] as const,
|
||||
adminDocumentUrl: (documentId: number) => [...verificationKeys.adminDocumentUrls(), documentId] as const,
|
||||
};
|
||||
|
||||
@@ -260,12 +260,6 @@ export interface DecideStepResult {
|
||||
credentialId: number | null;
|
||||
}
|
||||
|
||||
/** A freshly-signed, short-lived GET URL for a document — fetched on demand (never long-cached). */
|
||||
export interface SignedDocumentUrl {
|
||||
url: string;
|
||||
expiresInSeconds: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The verification domain's API seam — the real HTTP client and the in-memory mock both implement
|
||||
* this interface; selection is by config (`USE_VERIFICATION_MOCK`), never scattered `if (mock)` checks.
|
||||
@@ -294,10 +288,12 @@ export interface VerificationApi {
|
||||
// --- Admin review queue (b6 AdminVerificationsController) ---
|
||||
/** The review queue, folded to one item per nurse. `status`/`search` filter (status default `in_review`); paginated. */
|
||||
listVerificationQueue(filters: AdminVerificationQueueFilters, params: PageParams): Promise<AdminVerificationQueuePage>;
|
||||
/** The full admin case for one nurse — steps + documents + credentials + the identity name for cross-check. */
|
||||
/**
|
||||
* The full admin case for one nurse — steps + documents + credentials + the identity name for cross-check.
|
||||
* Each document's `url` is already a short-lived signed GET URL; re-opening the viewer refetches this
|
||||
* case (there is no separate per-document re-sign route) to get a fresh one.
|
||||
*/
|
||||
getVerificationCase(nurseVerificationId: number): Promise<AdminVerificationCase>;
|
||||
/** A freshly-signed, short-lived GET URL for a document — fetched on demand (URLs expire; never long-cached). */
|
||||
getDocumentSignedUrl(documentId: number): Promise<SignedDocumentUrl>;
|
||||
/** Approve or reject a manual step; on a credential-bearing step, records the (encrypted) credential. Re-aggregates. */
|
||||
decideStep(stepId: number, input: DecideStepInput): Promise<DecideStepResult>;
|
||||
/** Approve the whole verification (all required steps pass → `approved`), removing it from the queue. */
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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/` (b0–b15) and `frontend/` (f0–f15), 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 0–13 + 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 ~7–10 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.
|
||||
@@ -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.
|
||||
@@ -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 0–100, 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).
|
||||
@@ -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.
|
||||
@@ -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`).
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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`.
|
||||
@@ -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` (0–1 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` ~7–10 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.
|
||||
@@ -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 1–5; 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` 1–5
|
||||
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 1–5 / 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
Reference in New Issue
Block a user