backend phase 15 & frontend phase 8

This commit is contained in:
hamid
2026-07-10 03:22:29 +03:30
parent 93cc5ecb98
commit cd6c2591a6
154 changed files with 15335 additions and 37 deletions
@@ -0,0 +1,175 @@
# 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.
File diff suppressed because it is too large Load Diff
@@ -12,6 +12,30 @@ One block per completed backend phase. Newest at the top. Backend lane writes he
- **Notes for frontend:** <anything load-bearing>
-->
## backend-phase-15 — Messaging (tickets), partner centers & admin backoffice — 2026-07-10
- **Shipped (FINAL backend phase):** new `messaging` schema — `Tickets` (`UNIQUE(reference_code)`, status/
category, nullable `booking_id`/`refund_id`), `TicketParticipants` (`UNIQUE(ticket_id, user_id)`, soft-remove
via `removed_at`), `TicketMessages` (`is_internal` hard boundary) — and new `partner` schema — `PartnerCenters`
(`IAuditable`, encrypted+masked `settlement_iban`, `commission_rate` separate from `platform_fee_rate`). Added
the `nurse_profiles.partner_center_id` FK in place. One migration (`MessagingAndPartnerCenters`). CQRS:
OpenTicket / AutoCreateCoordinationTicket / PostMessage / Add+RemoveParticipant / Close+ReopenTicket /
LogEmergencyTicket / GetTicketThread (user vs admin view) / ListMyTickets / ListTicketsForAdmin; CreatePartnerCenter
/ UpdatePartnerCenter / VerifyPartnerCenter / SponsorNurse / GetCenterForBooking / ListPartnerCenters /
GetPartnerCenterById / GetCenterDashboard. 5 new controllers (Tickets/AdminTickets/AdminPartnerCenters/Centers/
InternalCenters). Wired: b11 `IssueInvoice` now resolves issuer/settlement via `GetCenterForBooking`; b11
`CreateRefund` auto-opens a `refund` ticket (so `refunds.ticket_id` is always non-null); the card confirm + BNPL
settle handlers auto-create the coordination ticket. Support-alert worklist + audit viewer reused from b1 (not
rebuilt). The 4 DEFERRED tables were **not** created.
- **Contracts:** `dev/contracts/domains/messaging-notifications-admin.md` + openapi snapshot refreshed (yes).
- **Mocked:** `ILicenseVerificationService` (eNamad / MoH permit — manual-approve at MVP) → 🟡 (see reports/mocks-registry.md).
- **Gate:** build clean (0 new code warnings) / tests green (358 total: 4 identity + 240 foundation + 114 API,
incl. 4 new merchant-of-record resolver tests + 8 new ticket/partner-center API tests).
- **Handoff:** backend/handoff/after-backend-phase-15.md
- **Notes for frontend:** `is_internal` is stripped from the user thread view server-side (never trust the UI);
no direct nurse↔customer channel / no phone numbers; ticket↔booking/refund links are optional (nullable);
duplicate participant add = 409; `settlement_iban` is only ever returned masked (last 4); merchant-of-record
(invoice issuer + settlement) follows `partner_centers`, resolved by `GET /internal/bookings/{id}/center`.
## backend-phase-14 — Reviews, ratings & patient care records — 2026-07-09
- **Shipped:** new `reviews` schema, 4 tables — `Reviews` (`UNIQUE(booking_id)`, `CHECK(rating 15)`, guarded
`moderation_status`, `IAuditable`), `ReviewTagsMaster` (seeded 5-tag vocab, `UNIQUE(code)`), `ReviewTagLinks`
@@ -0,0 +1,63 @@
# Handoff — after backend phase 15 (Messaging, partner centers & admin backoffice)
**This is the final backend phase. The backend chain is complete.** Every domain the admin backoffice acts on
now exists and is wired together.
## What is now live (frontend can build against it)
### Tickets — the post-booking channel (f14 messaging)
- `POST /api/v1/tickets` — open a ticket (`category``support|coordination|refund|emergency`; optional
`bookingId`/`refundId`; body optional). Opener is auto-added as the first participant. Returns
`{ ticketId, referenceCode, status, category }`.
- `POST /api/v1/tickets/{id}/messages` — post a message. **`isInternal` is staff-only**; a non-staff caller
sending `true``403`; posting to a closed ticket as non-staff → `403`.
- `POST /api/v1/tickets/{id}/participants` (add) / `DELETE …/participants/{userId}` (soft-remove) — staff or
ticket owner. A **duplicate add is `409`** (backed by `UNIQUE(ticket_id, user_id)`), never a 500.
- `POST /api/v1/tickets/{id}/close` · `/reopen` — participant or staff (idempotent).
- `POST /api/v1/tickets/emergency` — assigned nurse (or staff) logs an emergency (+ optional support alert).
- `GET /api/v1/tickets` — my tickets (paginated, filter `status`, search `referenceCode`).
- `GET /api/v1/tickets/{id}`**user thread view: internal notes are stripped** in the projection.
- `GET /api/v1/admin/tickets` + `GET /api/v1/admin/tickets/{id}` — admin queue + **admin thread view: internal
notes included** (`support`/`admin`).
**The rule f14 must respect:** never build a direct nurse↔customer channel, never surface a phone number, and
never rely on the UI to hide internal notes — the backend already strips them from the user payload. The
coordination ticket for a booking is auto-created on confirmation (you don't create it).
### Partner centers + merchant-of-record (f15 admin + partner consoles)
- `POST /api/v1/admin/partner-centers` (create, inactive) · `PATCH …/{id}` (update) ·
`POST …/{id}/verify` (activate) · `POST …/{id}/sponsor-nurse` · `GET …` (list) · `GET …/{id}` (detail) —
`admin`/`super_admin`. **`settlementIbanMasked` (last 4) is the only IBAN ever returned** — never plaintext.
`commissionRate ∈ [0,1)`; a merchant-of-record center requires a `settlementIban`.
- `GET /api/v1/centers/{id}/dashboard` — the center's own account (or staff): sponsored nurses + booking/invoice
counts + masked settlement summary.
- `GET /api/v1/internal/bookings/{bookingId}/center` — the issuer/settlement resolver
(`platform` | `partner_center`).
### Admin backoffice (surfaced, not rebuilt)
- Support-alert worklist: `GET support_alerts/get_support_alerts`, `POST …/assign_support_alert`,
`POST …/resolve_support_alert` (built b1). Audit viewer: `GET audit/get_audit_trail` (built b1). Both
`DynamicPermission`. Verification queue / refunds / payout dashboard / moderation queue are their own phases'
routes — surface them under the admin console with the right RBAC scope.
## RBAC the frontend must respect (per route)
- Authenticated (own) ticket routes: any logged-in user; participation is enforced server-side.
- Admin ticket queue + admin thread: `support`/`admin`. Partner centers: `admin`/`super_admin`. Center
dashboard: the center's `adminUserId` (or staff). Support alerts: `support`/`admin`. Audit: `super_admin`/`admin`.
- The admin role passes every `DynamicPermission` check; narrower staff scopes (`support`/`finance`/`moderation`)
are granted via seeded role claims.
## What's mocked
- **`ILicenseVerificationService`** (eNamad / MoH establishment-permit) — `MockLicenseVerificationService`,
manual-approve at MVP (`NeedsManualReview`); `VerifyPartnerCenter` records the human decision. Config
`Seams:LicenseVerification:AutoApprove` forces `Valid`. See the mock registry (🟡). There is **no** telephony
seam — the emergency call is an out-of-platform `tel:` link by design.
## Contracts
- `dev/contracts/domains/messaging-notifications-admin.md` (this phase). `swagger.v1.json` refreshed (now includes
`/tickets`, `/admin/tickets`, `/admin/partner-centers`, `/centers`, `/internal/bookings/{id}/center`).
## Types / wire notes
- Envelope unchanged (camelCase body, snake_case URL tokens where `[action]`-based; the new controllers use
explicit REST routes). Pagination `page`/`pageSize` (default 50, max 100). `sentAt`/`closedAt`/`verifiedAt` are
UTC ISO-8601; ids are numbers; the settlement IBAN is a masked string (`"••••0001"`).
@@ -12,6 +12,39 @@ for awareness.
- **Requests filed:** frontend/requests/for-backend.md (yes/no)
-->
## frontend-phase-8-b9 — Booking detail, sessions & nurse EVV — 2026-07-10
- **Shipped:** the post-payment engagement — a **new** `services/bookings` domain (the sibling of
`bookingRequests`, NOT a rename): types/keys/constants/apis[client(1:1 b9)+mock+serverApi]/8 hooks +
barrel, plus the `evv/locationProvider.ts` **ILocationProvider** GPS seam. Screens: customer **رزروها
list** `/bookings` + **booking detail** `/bookings/[id]` (BookingDetailView, customer view), nurse
**ویزیت امروز** `/nurse/visits` (today-sessions EVV feed) + **nurse booking detail** `/nurse/visits/[id]`
(EVV controls + gated care). Seven shared tested composites under `src/components/booking/`:
`BookingDetailView`, `BookingStatusTimeline` (server-truth 7-status), `SessionList``SessionCard`
(per-session schedule/status/EVV CTA), `EvvStatusBanner` (advisory in/out-of-range/no-gps), `CareInstructionsCard`,
`BookingMoneySummary`, + `useEvvController`. i18n `booking` extended (`bstatus_*`/`sstatus_*`/`evv_*`/`care_*`/
`money_*`/`list_*`) both locales; new icons (check_in/out, gps, clinical, medication, emergency, lock) +
`--bal-secondary-soft` token.
- **Load-bearing rules honored:** **two-stage disclosure is a UI gate**`useCareInstructions` is
`enabled` only for the assigned-nurse view on a `confirmed`+ booking; the customer NEVER fires it (proven
by test). **EVV mismatch/GPS-denial is advisory, never a block** — out-of-range check-in still succeeds
(warning-tokened banner, not error); denial still submits. **Timeline = server truth** (never advanced
client-side); **money display-only** (gross/commission/payout rendered as sent, never summed/re-split;
`payoutEligibleAt` never recomputed); single-visit renders one session row through the same card; EVV
mutations **invalidate** detail+sessionEvv+today+list.
- **Consumes:** dev/contracts/domains/bookings-evv.md (b9) + swagger `BookingDetailDto`/`BookingSessionSummaryDto`/
`VisitVerificationDto`/`CareInstructionsDto``services/bookings/types.ts` derives from these 1:1.
- **Mocked client-side:** `services/bookings` via `bookingsMockApi` (**USE_BOOKINGS_MOCK=true, primary**) —
seeds 2 confirmed bookings (one 3-session, one single-visit) + care + a check-in/out EVV state machine,
because a real booking only exists after `bookings/convert` runs on a paid request and both upstreams
(bookingRequests mock, card capture b10) aren't real client-side yet. Real `bookingsClientApi` maps the
routes 1:1; swap is one flag. Also the **ILocationProvider** GPS seam (`NEXT_PUBLIC_EVV_MOCK_GPS`
in_range|out_of_range|denied|off) — the first frontend seam recorded in mocks-registry.
- **Gate:** npm run check green · npm run test:ci green (195 tests, +22). Added a committed
`NEXT_PUBLIC_API_URL` default in `jest.setup.ts` (first test to render a service-hook component pulled
`@/config` at import).
- **Requests filed:** frontend/requests/for-backend.md — yes (REQ-015: confirm the booking/session/EVV enum
string codes + the `checkInAddressMatch` tri-state semantics the client unions assume).
## frontend-phase-7-b8 — Booking request flow (customer request + nurse inbox) — 2026-07-09
- **Shipped:** the money-free request phase — `services/bookingRequests` (types/keys/constants/apis[client+
mock]/hooks + barrel) and screens **C4** `/bookings/request` (patient/variant/address/date+time + a
@@ -195,3 +195,22 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
`variantLabel` on the row makes the inbox self-describing; a coarse age is a nice-to-have for triage.
- **Proposed shape:** `BookingRequestListItemDto { …, variantLabel: string, patientAge?: int }`.
- **Status:** open
## REQ-015 — Confirm the booking/session/EVV enum codes + `checkInAddressMatch` tri-state — filed by frontend-phase-8-b9 — 2026-07-10
- **Need:** Two confirmations so the f8 `services/bookings/types.ts` client unions stay wire-accurate:
1. **Enum string codes.** The b9 swagger types `status`/`evvStatus`/session `status` as bare `string`
(no enum constraint). Confirm the stable wire codes match the client unions: `BookingStatus`
= `pending_payment|confirmed|in_progress|completed|disputed|closed|cancelled`; `BookingSessionStatus`
= `scheduled|in_progress|completed|missed|cancelled`; `VisitVerificationStatus`
= `pending|checked_in|completed`. (They match the contract doc's "Enums used" — this just asks that the
serialized JSON emits these exact snake_case codes, not PascalCase/int.)
2. **`checkInAddressMatch` tri-state semantics.** The EVV banner keys off it as: `true` = in range
(«موقعیت تایید شد»), `false` = out-of-range/advisory-under-review («موقعیت خارج از محدوده»), `null` =
GPS unavailable/denied («موقعیت ثبت نشد»). Confirm the server returns `null` (not `false`) when the
nurse checked in **without** coordinates (GPS denied), so the UI can distinguish "flagged mismatch"
from "no position captured". A mismatch stays advisory server-side (support alert, never a block) — the
UI mirrors that.
- **Why:** f8 renders the status timeline, per-session chips, and the EVV banner strictly off these codes;
a casing/int drift or a `false`-vs-`null` conflation would mislabel a visit. Low-risk (mock-primary now),
but worth locking before f9/f13 consume the same shapes.
- **Status:** open
@@ -0,0 +1,86 @@
# Backend Phase 15 report — Messaging (tickets), partner centers & admin backoffice
**The final backend phase.** It closes the operational loop: the ticket system, the licensed partner centers
(merchant-of-record), and the consolidated admin backoffice. The backend chain is now complete.
## What was built
### Messaging (tickets) — new `messaging` schema
- Entities `Domain/Entities/Messaging/`: `Ticket`, `TicketParticipant`, `TicketMessage` + `TicketStatus` /
`TicketCategory` / `TicketParticipantRole` code sets. Configs in `Persistence/Configuration/MessagingConfig/`.
- `ITicketRepository` (+ `TicketRepository`) on `IUnitOfWork`.
- Features `Application/Features/Messaging/`: `OpenTicket`, `AutoCreateCoordinationTicket`, `PostMessage`,
`AddParticipant`, `RemoveParticipant`, `CloseTicket`, `ReopenTicket`, `LogEmergencyTicket`, `GetTicketThread`
(role-aware user/admin view), `ListMyTickets`, `ListTicketsForAdmin`. Shared helpers `TicketReferenceCode`
(collision-checked mint) + `TicketRoleResolver` + `StaffRoles` (Application/Common).
- Controllers `TicketsController` (authenticated) + `AdminTicketsController` (`support`/`admin`).
### Partner centers — new `partner` schema
- Entity `Domain/Entities/PartnerCenters/PartnerCenter` (`IAuditable`; `settlement_iban` `[AuditRedacted]` +
encrypted converter in `ApplicationDbContext`). Config in `Persistence/Configuration/PartnerCentersConfig/`
(also adds the `nurse_profiles.partner_center_id` FK in place). `IPartnerCenterRepository` (+ impl).
- Features: `CreatePartnerCenter`, `UpdatePartnerCenter`, `VerifyPartnerCenter`, `SponsorNurse`,
`GetCenterForBooking` (the merchant-of-record resolver), `ListPartnerCenters`, `GetPartnerCenterById`,
`GetCenterDashboard`. Controllers `AdminPartnerCentersController`, `CentersController` (portal),
`InternalCentersController` (resolver).
### Seam
- **`ILicenseVerificationService`** (`Application/Contracts/Common`) + `MockLicenseVerificationService`
(`CrossCutting/Seams/`, registered in `AddCrossCuttingSeams`, config `Seams:LicenseVerification:AutoApprove`).
### Cross-phase wiring
- b11 `IssueInvoiceCommand` now sets `invoices.issuing_entity_type` + `partner_center_id` from
`ResolveCenterForBookingAsync` (the single merchant-of-record resolver).
- b11 `CreateRefundCommand` auto-opens a `category=refund` ticket via `OpenTicketCommand` when the caller passes
none, so `refunds.ticket_id` is always non-null (replaces the old config-gated "ticket required" check).
- The card `ConfirmPaymentAndPostLedger` and BNPL `SettleBnplOrder` handlers dispatch
`AutoCreateCoordinationTicketCommand` after a booking is confirmed (idempotent, one per booking).
### Reused, not rebuilt (admin backoffice consolidation)
- Support-alert worklist (`ISupportAlertService` List/Assign/Resolve — `SupportAlertsController`) and the audit
viewer (`GetAuditTrail``AuditController`) already existed since b1; verified as the backoffice surface.
Verification/refund/payout/moderation queues are their own phases' endpoints.
## What is now testable and exactly how (the §7 steps)
1. **Open + message:** `POST /api/v1/tickets` (no links) → 200 with a `TKT-…` `referenceCode` + opener as
participant; `POST /api/v1/tickets/{id}/messages` → the message appears in the thread.
2. **Internal boundary (proven by a test):** admin `POST …/messages {isInternal:true}` → 200; user
`GET /api/v1/tickets/{id}` omits it; admin `GET /api/v1/admin/tickets/{id}` includes it; a non-staff
`isInternal:true` → 403. (`MessagingApiTests.InternalNote_IsHiddenInUserView_ShownInAdminView` + `NonAdmin_CannotSetInternal`.)
3. **Participant uniqueness:** add a user → 200; add again → **409** (not 500); delete → 200.
(`MessagingApiTests.AddParticipant_DuplicateIsConflict_NotServerError`.)
4. **Partner center + masked IBAN:** `POST /api/v1/admin/partner-centers {isMerchantOfRecord:true, settlementIban}`
→ 200 with `settlementIbanMasked` (last 4), never plaintext; `GET …/{id}` masks it too; created inactive.
(`PartnerCentersApiTests.CreateMerchantOfRecord_MasksSettlementIban`, `Verify_ActivatesTheCenter`.)
5. **Merchant-of-record resolution:** `GET /api/v1/internal/bookings/{id}/center``partner_center` (+ id) for a
nurse sponsored by a merchant-of-record center, `platform` otherwise. (`CenterForBookingTests`, 4 cases.)
6. **Refund anchors a ticket:** `CreateRefund` yields a non-null `refunds.ticket_id` (foundation refund tests
pass with the auto-open wired via `TestSenders.WithTicketHooks()`).
7. **Admin worklists / RBAC:** support alerts + audit reachable under admin scope; a non-admin token on an admin
route → 403 (`PartnerCentersApiTests.NonAdmin_IsForbidden`), unauthenticated → 401.
8. **Audit:** admin state changes (e.g. `VerifyPartnerCenter`) append an `audit_logs` row (`PartnerCenter` is
`IAuditable`; `settlement_iban` is redacted in the diff).
## What is mocked / waiting on a real service
- `ILicenseVerificationService` → manual-approve at MVP (no public eNamad/MoH B2B API). Make-it-real steps in
`reports/mocks-registry.md` (🟡). No telephony seam — the emergency call is out-of-platform by design.
## Contracts produced
- `dev/contracts/domains/messaging-notifications-admin.md`; `dev/contracts/openapi/swagger.v1.json` refreshed
(now includes tickets, partner centers, the center resolver).
## Gate
- `dotnet build Baya.sln` — 0 new code warnings. `dotnet test Baya.sln` — green: 4 identity + 240 foundation +
114 API (12 new tests this phase). Migration `MessagingAndPartnerCenters` scaffolds cleanly.
## Decisions / notes for the future
- **Merchant-of-record** = `partner_center` issuer only when the sponsoring center `is_merchant_of_record`; a
non-MoR sponsor leaves the platform as issuer (so a sponsored-but-platform-billed nurse is representable).
- **Participant removal** is a soft `removed_at` stamp (not a hard delete / not `deleted_at`), so the
`UNIQUE(ticket_id, user_id)` row survives and a re-add resurrects it.
- **SQLite gotcha (again):** messages are ordered by the monotonic `Id` (== send order), never `ORDER BY sent_at`
(`DateTimeOffset`), which the SQLite test provider can't translate.
- **Follow-ups:** the invoice-issuer wire sets the columns but the downstream settlement rail (paying a center's
IBAN when it is MoR) is not exercised end-to-end here; the center dashboard caps the sponsored-nurse list at 50
(count is exact) — paginate it if a center grows large. Bookings/invoices `partner_center_id` columns exist
without a DB FK (only `nurse_profiles` got the FK, per the DoD).
@@ -0,0 +1,122 @@
# Frontend Phase 8 (f8-b9) — Booking detail, sessions & nurse EVV — report
**Date:** 2026-07-10 · **Lane:** frontend · **Consumes:** [bookings-evv.md](../../contracts/domains/bookings-evv.md) (b9)
· **Unlocks:** f9 (checkout/pay), f13 (reviews & patient records)
## What was built
The post-payment engagement — the hinge between "I asked for a nurse" and "a nurse is delivering care."
### Data layer — a **new** `services/bookings` domain
The **sibling** of `services/bookingRequests` (b8), **not** a rename — a distinct b9 contract, distinct
routes (`/api/v1/bookings/*` + `/api/v1/booking_sessions/*`), distinct shapes. Same `services/{domain}`
shape as every other domain:
- `types.ts` — derived 1:1 from the b9 swagger (camelCase): `BookingDetailDto`, `BookingSessionDto`
(`BookingSessionSummaryDto`), `VisitVerificationDto`, `CareInstructionsDto`, `BookingListItemDto`,
`BookingSessionListItemDto`, `CheckInVisitInput`/`CheckOutVisitInput`, the `BookingsApi` seam, the three
enum unions (`BookingStatus`/`BookingSessionStatus`/`VisitVerificationStatus`), and pure helpers
(`isBookingConfirmedOrBeyond`, `isBookingTerminalBranch`, `bookingTimelineActiveIndex`, `BOOKING_TIMELINE_ORDER`).
- `keys.ts``bookingDetail(id)`, `bookingSessions(id)` (alias of `bookingDetail` — sessions are embedded),
`today(params)`, `sessionEvv(id)`, `careInstructions(id)`, `list(params)`.
- `apis/` — real `clientApi` (maps the routes 1:1), `mockApi` (the seeded state machine, **primary**),
`serverApi.getBookingDetail` (the RSC-prefetch seam for the real path), and a config-selecting `index`.
- `evv/locationProvider.ts` — the **`ILocationProvider`** GPS seam (real `navigator.geolocation` vs a canned
mock; `getCurrentPosition()` resolves `null` on denial, never rejects).
- `hooks/` (one per file): `useBookingDetail`, `useBookingSessions` (a `select` over the detail query, no
second fetch), `useBookingList`, `useTodaySessions`, `useSessionEvv`, `useCareInstructions` (**enabled-gated**),
`useCheckInVisit`, `useCheckOutVisit` (both invalidate detail+sessionEvv+today+list on success).
### Shared composites — `src/components/booking/` (each with a co-located `*.test.tsx`)
- `BookingDetailView` — the both-roles smart container (role-conditioned EVV + gated care).
- `BookingStatusTimeline` — the server-truth 7-status timeline over the f0 `StepperHeader` + status chip.
- `SessionList``SessionCard` — per-session Shamsi schedule, status chip, EVV CTA, elapsed/payout.
- `EvvStatusBanner` — advisory banner (in-range success / out-of-range warning / no-gps neutral).
- `CareInstructionsCard` — the decrypted clinical read (conditions/meds/allergies/instructions/emergency).
- `BookingMoneySummary` — gross / commission (کارمزد) / payout, display-only via the money util.
- `useEvvController` — GPS-capture + check-in/out orchestration (one instance per surface, per-session busy).
- `format.ts` (clock/duration) + `statusKind.ts` (status → StatusChip kind) helpers.
### Screens
- **Customer:** `/bookings` (رزروها list) → `/bookings/[id]` (detail, customer view: timeline + sessions +
money; the care record shows the "visible to your nurse only" affordance and the query never fires).
- **Nurse:** `/nurse/visits` (ویزیت امروز — today's sessions with inline EVV check-in/out) →
`/nurse/visits/[id]` (detail, nurse view: EVV controls + the gated care card).
### Cross-cutting
- i18n `booking` namespace **extended** (both locales, key-synced): `bstatus_*`, `sstatus_*`, `evv_*`
(banner variants + CTAs + GPS copy), `care_*` (+ the customer lock copy), `money_*`, `list_*`, dispute note.
- 8 new registry icons (`check_in`/`check_out`/`gps`/`schedule`/`clinical`/`medication`/`emergency`/`lock`)
and one new token `--bal-secondary-soft` (the نمای پرستار chip / EVV affordance), both schemes.
## What is now testable, and exactly how
Run `npm run dev` (mock is primary — no backend needed). The b9 endpoints are also live if you flip the flag.
1. **Confirmed booking (customer):** open the رزروها tab → the list shows the two seeded bookings; open one
**status timeline** at `confirmed`, the **session schedule** (booking #5002 shows exactly **one**
session; #5001 shows **3**), and the **money summary** in Toman. Toggle `/en``/fa` → strings + `dir`
flip; the timeline reads RTL.
2. **Care gate:** open a booking **as the nurse** (`/nurse/visits` → a session → view booking) → the
**care-instructions card** is visible (conditions/meds/allergies/instructions/emergency). As the
**customer**, the card is absent and the Network tab shows the care request **was never made** (proven by
the `BookingDetailView` test too).
3. **Nurse check-in:** on `/nurse/visits` (or in the nurse booking detail), tap **«ثبت ورود (EVV)»** →
"در حال دریافت موقعیت…" → the **«ورود ثبت شد … موقعیت تایید شد (EVV)»** banner; the session chip →
`in_progress`; the timeline → `in_progress`. Set `NEXT_PUBLIC_EVV_MOCK_GPS=out_of_range` → the **advisory**
«موقعیت خارج از محدوده (در حال بررسی)» banner and the check-in **still succeeds**. `=denied` → the nurse
still checks in (no block; advisory toast + no-gps banner).
4. **Nurse check-out:** tap **«ثبت خروج (EVV)»** → the session chip → `completed` with elapsed duration; for
the single-visit booking (#5002) the timeline advances to `completed` + the dispute-window note appears —
all from the server response, no client-side step jump.
5. **Caching:** in React Query Devtools, an EVV mutation invalidates `bookingDetail`/`sessionEvv`/`today`/
`list` and the UI re-renders from the refetch; revisiting within `staleTime` does not refetch.
6. `npm run check` green · `npm run test:ci` green (195 tests, +22).
## What is mocked / waiting on a real service
- **`services/bookings` — mock-primary** (`USE_BOOKINGS_MOCK=true`). A booking only exists after
`bookings/convert` runs on a **paid** request, and both upstreams (`bookingRequests` mock, card capture
b10) aren't real client-side yet, so a real `bookings/list` returns nothing. The mock seeds confirmed
bookings + sessions + care + the EVV state machine. Real `bookingsClientApi` maps the routes 1:1; the swap
is one flag (see mocks-registry). `serverApi.getBookingDetail` is ready for the RSC prefetch on the real path.
- **`ILocationProvider`** (`NEXT_PUBLIC_EVV_MOCK_GPS`) — GPS capture seam; the real path is
`navigator.geolocation`. Server-side address-match math stays behind the backend geocoding seam.
- Both are recorded in [mocks-registry.md](./mocks-registry.md).
## Contract consumed + gaps filed
- **Consumed:** [bookings-evv.md](../../contracts/domains/bookings-evv.md) + the b9 swagger shapes — types
derive 1:1. No shape was guessed.
- **Filed:** **REQ-015** — confirm the booking/session/EVV enum **string codes** (bare `string` in swagger)
match the client unions, and the **`checkInAddressMatch` tri-state** (`true`/`false`/`null`) so the banner
can distinguish an advisory mismatch from "no GPS captured." Low-risk (mock-primary now); worth locking
before f9/f13 reuse the shapes.
## Deliberate design decisions (non-obvious)
- **Two-stage disclosure is a UI gate, not just a server check.** `useCareInstructions` is `enabled` only for
the assigned-nurse view on a `confirmed`+ booking; the customer/unassigned viewer **never fires** the
request (a 403/404 is treated as a defect path). The `BookingDetailView` test asserts the customer never
calls `getCareInstructions` and the nurse does.
- **EVV mismatch/denial is advisory, never a block.** Out-of-range check-in succeeds with a **warning**-tokened
banner (never the error token); GPS denial still submits. Check-out is never gated on the match.
- **Server-truth timeline + display-only money.** The timeline renders `BookingDetailDto.status` exactly (no
client step advance); money is rendered as-sent (no sum/derive/re-split); `payoutEligibleAt` is never
recomputed. Sessions are **embedded** in the detail (no standalone list endpoint) — `useBookingSessions`
is a `select` over the one detail query, so invalidating `bookingDetail(id)` refreshes both.
- **`ILocationProvider`** is the single new client seam; the `NEXT_PUBLIC_EVV_MOCK_GPS` default is `in_range`
while mock-primary so the happy path is demoable without a device (real GPS would never fall near the
seeded Tehran address).
## Follow-ups for later phases
- **f9 (checkout/pay):** the money summary here shows the confirmed **split only**; the **tax (مالیات) line**,
escrow notice, and invoice are the checkout surface — b9's `BookingDetailDto` has **no tax field** (flagged;
f9 owns it). The C5 accept CTA still lands on `/bookings/checkout?request_id=…` (f7 stub).
- **f13 (reviews & records):** the E3 **visit-note authoring** (bottom half) and the full **E2 patient-record
viewer** are deferred here; the booking-detail/EVV/care pattern (timeline + sessions + gated care + EVV
banner) is the template they extend. The customer-side **care-details authoring** (`submit_care_instructions`)
write form is also f13 — f8 only reads the gated record.
- **f15 (admin):** the EVV-review queue (mismatch / no-show worklist) is the admin console; f8 raises no
alerts client-side (no-show detection is a server job).
- **Swap to real:** flip `USE_BOOKINGS_MOCK=false` once `bookings/convert` is reachable client-side (b10
card capture) — `bookingsClientApi` + `bookingsServerApi` are wired; no hook/component change.
@@ -47,6 +47,8 @@ Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢
| `ICurrencyNormalizer` | backend-phase-12 | Toman↔IRR at the provider boundary — `MockCurrencyNormalizer` (`Baya.Infrastructure.CrossCutting/Seams/`): `ToIrr(amount,"TOMAN")` = `amount × TomanToIrrMultiplier`, IRR passes through; `ToDisplayToman` divides back. **Conversion happens ONLY here, never internally.** Registered singleton in `AddCrossCuttingSeams` | `Seams:Currency:TomanToIrrMultiplier` (default `10`) | Read the multiplier (or a per-provider unit) from provider config; the interface stays — a currency redenomination is a config change | 🟡 |
| `INursePayoutStatus` | backend-phase-11 (interim) → **backend-phase-13 (authoritative)** | "Was the nurse already paid for this booking?" — **b13 shipped the real `NursePayoutLinkStatusService`** (`Persistence/Services/Payments/`): a booking is paid iff a `nurse_payout_booking_links` row ties it to a `nurse_payouts` row in status `paid`. This **supersedes** the interim `NursePayoutStatusService` (dispute-window derivation, now deleted); the `refund_assume_nurse_paid` config override still forces the paid answer for ops/testing. Not a mock of an external — a real ledger-backed derivation. Registered scoped in `AddPersistenceServices`. The refund pre-payout/clawback fork is unchanged | `refund_assume_nurse_paid` (`platform_configs`, default `false`) | Nothing further — this is the real implementation. (A future on-demand-withdrawal model would extend the "paid?" definition, not replace it.) | 🟢 |
| `ILicenseVerificationService` | **backend-phase-15** | Partner-center licensing (eNamad / MoH establishment-permit پروانه تأسیس) — `MockLicenseVerificationService` (`Baya.Infrastructure.CrossCutting/Seams/`), **no external call**: `VerifyEstablishmentPermitAsync`/`VerifyENamadAsync` return `NeedsManualReview` (no automated registry → a human admin decides), so `VerifyPartnerCenterCommand` records the manual approval and activates the center. A config toggle makes clean checks return `Valid` (auto-approve path); an explicit `Invalid` verdict blocks activation. Registered singleton in `AddCrossCuttingSeams` | `Seams:LicenseVerification:AutoApprove` (default `false`) | 1) obtain access to a real eNamad status endpoint and/or the MoH establishment-permit registry (no public B2B API today — likely a manual/partner data feed at launch); 2) implement the two methods to look up the permit/eNamad code and return `Valid`/`Invalid` + a reason; 3) swap the registration (config-selected) — `VerifyPartnerCenter` is unchanged (it keeps the human-override decision authority) | 🟡 |
> Exact config keys and file paths get filled in by the phase that builds each seam. Keep the
> "Make it real →" column actionable enough that a developer can pick up any single row and ship it.
@@ -67,3 +69,5 @@ the frontend can build before the backend phase merges, and swap to the real HTT
| `AddressMapPicker` (map stand-in) | `client/src/components/geography/AddressMapPicker.tsx` | **Not a real map** — a bounded, tappable/draggable marker canvas (CSS grid, no Neshan/Google tiles, no network) that maps the pointer position to `{ latitude, longitude }` around the chosen city's centroid (`CITY_CENTROIDS`/`IRAN_CENTROID` in `services/geography/constants.ts`). Emits real coordinates for the create/update request | _none (component boundary)_ | Replace the canvas internals with a real map widget (Neshan/Google, inlined per the client CSP) that emits the same `{ latitude, longitude }` via `onChange``AddressForm` and every caller stay unchanged | 🟡 |
| `CatalogApi` | `client/src/services/catalog/apis/mockApi.ts` (+ `apis/seed.ts`) | The catalog skeleton + nurse pricing layer. **Categories mirror the b5 seed exactly** (5 categories, ids 15, `sortOrder` 04). Seeds representative **option groups/values** the fresh backend does **not** (an admin authors them per category) — incl. required + optional groups and one **cross-category** (`serviceCategoryId=null`) group — so the builder's required-option gate + cross-category rendering demo. Enforces the server's create validation in-memory: `400` missing required dimension / bad price, and the `(nurse, category, option-set)` duplicate **`409`** (via `optionSetSignature`). Variant store seeded **empty** so the offerings empty-state demos; the nurse builds variants live (across price units). `create`/`update`/`set_active`/`list`(active-first, paginated)/`get`. Money stays an **IRR digit-string** end-to-end | `USE_CATALOG_MOCK` (`services/catalog/constants.ts`, default `true`) | b5 `catalog/*` + `nurse_variants/*` are live; set flag `false``catalogClientApi` is wired to the action-style routes (camelCase bodies, `pageSize` pagination per REQ-010, `category_id` snake_case filter). **When swapped, categories will have NO option groups until an admin authors them** (the mock's groups were illustrative). No hook/component change | 🟡 |
| `VerificationApi` | `client/src/services/verification/apis/mockApi.ts` | The whole nurse trust journey (b6). Seeds the six required steps on `start` (idempotent); `runIdentityKyc` passes any well-formed 10-digit id **except** `0000000000` (→ `failed`/`kyc_no_match`, matches backend `MockIdentityKycProvider`); `runShahkarMatch` requires identity passed, fails **shared-SIM** when the bound national id is `1111111111` (→ `failed`/`shared_sim`); `runBankVerification` passes (assumes a primary bank account); `uploadStepDocument` simulates signed-URL PUT progress then moves the step to `in_review` (metadata only); `submitCredentialDetails` validates the INO number. Re-aggregates like the server (`approved` only when every step passes). **Dev-only** `__mockApproveAll()`/`__mockRejectStep(code,reason)` stand in for the deferred (f15) admin review queue so a human can watch `is_verified`/the trust badge/the publish gate flip — reachable from B3/B6 only while the flag is true | `USE_VERIFICATION_MOCK` (`services/verification/constants.ts`, default `true`) | b6 `nurse_verification/*` + `nurses/{id}/trust_badge` are live; set flag `false``verificationClientApi` is wired (action-style routes, camelCase, XHR signed-URL PUT for upload progress + SHA-256 integrity hash). **Caveat:** the real `submitCredentialDetails` no-ops pending REQ-011 (no nurse-facing endpoint for the structured INO/specialties fields yet) — the document uploads it accompanies are contract-backed. No hook/component change | 🟡 |
| `BookingsApi` | `client/src/services/bookings/apis/mockApi.ts` | The post-payment engagement (b9). Seeds **2 confirmed bookings** (one 3-session multi-day, one single-visit) + `booking_care_instructions` + a per-session **EVV state machine**`checkInVisit` flips the session→`in_progress`/`checked_in` (booking→`in_progress`) and computes the **advisory** `checkInAddressMatch` (haversine vs the seeded address ± `MOCK_EVV_TOLERANCE_METERS`, `null` when GPS was absent); `checkOutVisit` requires an open check-in (**`400 no_open_check_in`** otherwise), completes the session (stamps `payoutEligibleAt`), and completes the booking + opens the dispute window once **all** sessions settle. `getCareInstructions` **404s any viewer but the assigned nurse** (the two-stage-disclosure boundary; the UI `enabled` gate means the customer never even calls it). Money stays IRR digit-strings with `gross = commission + payout` and `Σ visitPayout = payout` | `USE_BOOKINGS_MOCK` (`services/bookings/constants.ts`, default `true`) | b9 `bookings/*` + `booking_sessions/*` are live, but a booking only exists after `bookings/convert` runs on a **paid** request — both upstreams (`bookingRequests` mock, card capture b10) aren't real client-side yet. Once conversion is live, set flag `false``bookingsClientApi` maps the routes 1:1 (+ `bookingsServerApi` for the RSC prefetch). No hook/component change | 🟡 |
| `ILocationProvider` | `client/src/services/bookings/evv/locationProvider.ts` | **EVV GPS capture** — the only client seam f8 introduces. `getCurrentPosition()` never rejects (denied/unavailable → `null`, so a GPS problem is **advisory, never a block**). The **real** provider wraps `navigator.geolocation.getCurrentPosition`; the **mock** returns canned coordinates per mode so the in-range / advisory-out-of-range / denied paths are all demoable without a device (the mock `BookingsApi` computes the match against the same seeded reference point) | `NEXT_PUBLIC_EVV_MOCK_GPS` = `in_range` \| `out_of_range` \| `denied` \| `off` (default `in_range` while `USE_BOOKINGS_MOCK`, else `off`) | Set `NEXT_PUBLIC_EVV_MOCK_GPS=off` (or flip `USE_BOOKINGS_MOCK`) → the real `navigator.geolocation` provider is selected. Real **address-match math** stays server-side (backend geocoding seam), not here — this seam only *captures* the position | 🟡 |