cleanup phases 6
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,164 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,166 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,132 @@
|
||||
# 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).
|
||||
@@ -0,0 +1,142 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,113 @@
|
||||
# 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).
|
||||
@@ -0,0 +1,135 @@
|
||||
# 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`).
|
||||
@@ -0,0 +1,141 @@
|
||||
# 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).
|
||||
@@ -0,0 +1,100 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,195 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,30 @@
|
||||
# messaging — MERGED into `docs/integration/domains/`
|
||||
|
||||
> ⛔ **This headerless 851-byte fragment silently amended
|
||||
> [`messaging-notifications-admin.md`](messaging-notifications-admin.md) next to it** — two live files
|
||||
> describing one domain, which is contradiction **C-8**.
|
||||
>
|
||||
> Both are superseded. Phase 2 split the merged content three ways, along the client's actual domain
|
||||
> boundaries:
|
||||
>
|
||||
> | Content | Now |
|
||||
> | --- | --- |
|
||||
> | Tickets, threads, messages, `is_internal`, the staff queue | [`docs/integration/domains/tickets.md`](../../../docs/integration/domains/tickets.md) |
|
||||
> | The notification feed and unread badge | [`docs/integration/domains/notifications.md`](../../../docs/integration/domains/notifications.md) |
|
||||
> | Platform config, holidays, audit, support alerts | [`docs/integration/domains/admin.md`](../../../docs/integration/domains/admin.md) |
|
||||
>
|
||||
> Every REQ-028 amendment below is folded into `tickets.md` as current fact, not as a change log. Phase 6
|
||||
> archives this folder; until then, read it as a record.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,79 @@
|
||||
# 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).
|
||||
@@ -0,0 +1,112 @@
|
||||
# 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`.
|
||||
@@ -0,0 +1,172 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,137 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,117 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,269 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user