Files
2026-06-28 21:59:59 +03:30

508 lines
42 KiB
Markdown

# Backend Phase 15 — Messaging (tickets), partner centers & admin backoffice
> **Mission:** close the platform's operational loop. Build the **ticket system** that is the *only*
> sanctioned post-booking communication channel — nurse and customer coordinate under full admin
> visibility, with admin-only **internal notes** that can never leak to users and **no direct
> nurse↔customer side-channel**. Stand up **`partner_centers`** — the licensed home-nursing center that
> sponsors nurses and, when it is the **merchant-of-record**, becomes the legal invoice issuer and
> settlement target (not the platform). Finally, **consolidate the admin backoffice**: tie the verification
> queue (b6), refund tooling (b11), payout dashboard (b13), review moderation queue (b14), config/holiday/
> audit (b1), and the support-alert worklist (b1) into one RBAC-gated, fully audited admin surface. This is
> the last backend phase — it makes the support, admin, and partner UIs buildable.
>
> **Track:** backend · **Depends on:** [backend-phase-3](backend-phase-3.md) (users/profiles/`nurse_profiles`), [backend-phase-1](backend-phase-1.md) (`notifications`, `support_alerts`, `platform_configs`, `audit_logs`, RBAC, holidays), [backend-phase-11](backend-phase-11.md) (`refunds` + the ticket-link hook, `invoices`), [backend-phase-13](backend-phase-13.md) (payout batches/dashboard), [backend-phase-14](backend-phase-14.md) (review moderation queue) · **Unlocks:** the support + admin + partner UIs ([frontend-phase-14-b15](../frontend/frontend-phase-14-b15.md), [frontend-phase-15-b15](../frontend/frontend-phase-15-b15.md))
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
This is the **final backend phase**. Every domain the admin backoffice acts on already exists; every signal
the support team triages is already being raised. This phase adds the three remaining pieces and then
*wires them together*:
1. **Messaging (tickets)** — the structured, admin-readable communication channel. A booking-scoped
coordination ticket lets the nurse and customer arrange logistics under admin visibility; tickets also
anchor refund conversations and support requests. There is deliberately **no live chat and no direct
nurse↔customer messaging** — this is an anti-disintermediation and patient-safety design.
2. **Partner centers** — the licensed center (Asanism-style) that **sponsors** nurses and, at launch, is
plausibly the **merchant-of-record**. When it is, *it* is the legal invoice issuer and the IPG settles to
*its* IBAN — invoices and settlement follow `partner_centers`, never a hardcoded platform.
3. **Admin backoffice consolidation** — the verification queue, refunds, payouts, review moderation, config/
holiday/audit, and the support-alert worklist are each already built in their own phases. This phase does
**not** rebuild them; it exposes them under a single **RBAC-gated, audited** admin surface and adds the
one missing admin worklist — the **support-alert console** (raise API exists since b1; the assign/resolve/
list worklist is built here).
**What already exists (do not rebuild):**
- **Users, roles & profiles** — [backend-phase-3](backend-phase-3.md) built `nurse_profiles`,
`customer_profiles`, `patients`, and `nurse_bank_accounts`. **`nurse_profiles.partner_center_id`** is the
sponsorship FK this phase *sets* (add the column to the b3 entity in place if it is not already there, and
note it in your report — do **not** fork a parallel table). [backend-phase-2](backend-phase-2.md) built
`users` (+ gender, national_id) and sessions; [backend-phase-1](backend-phase-1.md) built the admin **RBAC**
(`roles`/`user_roles`, scopes `super_admin`/`admin`/`support`/`finance`/`moderator`) you authorize every
admin endpoint against.
- **Platform signals, config, audit, holidays** — [backend-phase-1](backend-phase-1.md) built
**`notifications`** (typed in-app write + list/unread-count/mark-read + 90-day retention),
**`support_alerts`** (the internal-only worklist *table* + the `RaiseSupportAlert` raise API),
`platform_configs` (typed cached accessor — incl. `platform_fee_rate`, `vat_rate`),
**`audit_logs`** (append-only, written by the SaveChanges interceptor on sensitive entities), and
`iranian_holidays`. **Reuse all of these.** This phase *consumes* them — it builds the `support_alerts`
assign/resolve/list **worklist** and the audit **viewer**, it does not redefine the tables.
- **Refunds, invoices & the ticket-link hook** — [backend-phase-11](backend-phase-11.md) built `refunds`
(admin-only, ticket-linked, fee/payout decomposition, channel-aware), `nurse_clawbacks`, and `invoices`
(VAT on commission, `issuing_entity_type`). b11 created `refunds.ticket_id` and the *expectation* that a
ticket anchors every admin refund; **this phase ships the ticket system that link points at** and wires
`OpenTicket` into the refund flow (§3.2). The `invoices.issuing_entity_type` / center-issuer resolution
that `partner_centers` drives is consumed by b11's `GenerateCommissionInvoice` — you provide
`GetCenterForBooking` (§3.4) as the resolver.
- **Payouts** — [backend-phase-13](backend-phase-13.md) built `nurse_payout_batches`, `nurse_payouts`,
`nurse_payout_booking_links` (booking_id UNIQUE), the weekly holiday-aware batch, clawback netting, and the
payout dashboard queries. The admin backoffice **surfaces** these read models — it does not re-implement
payout logic.
- **Review moderation** — [backend-phase-14](backend-phase-14.md) built `GetReviewModerationQueueQuery` +
`ModerateReviewCommand` and raises low-rating `support_alerts`. The backoffice **surfaces** the moderation
queue and the alerts it raises.
- **Verification** — [backend-phase-6](backend-phase-6.md) built `nurse_verifications` + the admin review
queue (`ListVerificationQueue`, the guarded `is_verified` flip). The backoffice **surfaces** that queue.
- **Cross-cutting seams** — [backend-phase-0](backend-phase-0.md) introduced **`IFieldEncryptor`** (used here
for `partner_centers.settlement_iban`), `ICacheService`, `IDateTimeProvider`, and
**`INotificationDispatcher`**; [backend-phase-3](backend-phase-3.md) introduced
**`IBankAccountOwnershipVerifier`** (IBAN ownership — reused if you verify a center's settlement IBAN).
**Reuse these; do not redefine them.**
> **DEFERRED — modeled-but-inactive, do NOT build/migrate in this phase** (they are pure additive migrations
> for future features and must stay unreferenced by launch flows): `organizations`, `organization_nurses`
> (the future *employer* model — distinct from `partner_centers`, the launch *sponsor*), `fraud_flags` (ML
> output; rule-based `support_alerts` `fraud_signal` covers it manually), `recurring_booking_schedules`
> (`booking_sessions` already meets the concrete multi-day need). See
> [`product/data-model/13-partner-centers-and-future.md`](../../../product/data-model/13-partner-centers-and-future.md).
## 2. Required reading (do this first)
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md).
- **Product — business rules (source of truth):**
- [`product/business/12-messaging-and-emergencies.md`](../../../product/business/12-messaging-and-emergencies.md)
— the no-direct-channel rule, the auto-created booking-coordination ticket, internal notes, tickets as the
**mandatory anchor for admin refunds**, and the on-site **emergency playbook** (call the surfaced
emergency contact, then open a ticket — operational, not a schema feature).
- [`product/business/14-notifications-and-admin.md`](../../../product/business/14-notifications-and-admin.md)
— the RBAC scopes that gate verify/refund/payout/moderate, the append-only audit requirement, the
support-alert console, and the holiday-aware backoffice reasoning.
- [`product/business/13-tax-invoicing-and-legal.md`](../../../product/business/13-tax-invoicing-and-legal.md)
— the seller split (platform invoices its **commission** only), the **partner licensed-center** launch
vehicle, merchant-of-record resolution, and why operating outside a licensed vehicle is the real legal
risk.
- **Product — data model (source of truth):**
- [`product/data-model/09-messaging.md`](../../../product/data-model/09-messaging.md) — `tickets` /
`ticket_participants` / `ticket_messages`, `is_internal`, `reference_code`, `UNIQUE(ticket_id, user_id)`,
optional `bookings`/`refunds` links.
- [`product/data-model/13-partner-centers-and-future.md`](../../../product/data-model/13-partner-centers-and-future.md)
— the exact `partner_centers` field list (read the **"Why"** notes — they encode the merchant-of-record
and partner-vs-organization guards), and the four DEFERRED tables that stay inactive.
- **Prior contracts you consume / extend:**
- [backend-phase-11](backend-phase-11.md)'s contract `dev/contracts/domains/payments-refunds.md` (or
equivalent) for the `refunds.ticket_id` link and `invoices.issuing_entity_type` you resolve.
- [backend-phase-1](backend-phase-1.md)'s handoff + contract for the `RaiseSupportAlert` signature, the
`support_alerts` shape, the `notifications` write, RBAC scopes, and the typed config accessor.
- [backend-phase-6](backend-phase-6.md) / [backend-phase-13](backend-phase-13.md) /
[backend-phase-14](backend-phase-14.md) handoffs for the verification-queue, payout-dashboard, and
moderation-queue read models the backoffice surfaces.
- **Code to mirror (existing patterns):** an existing feature folder under
`Baya.Application/Features/<Area>/{Commands|Queries}/<Name>/` (request `record` + `internal sealed`
handler + `OperationResult` — never throw), an `IEntityTypeConfiguration<T>` under
`Persistence/Configuration/<Area>Config/`, a controller under `Baya.Web.Api/Controllers/V1/` (`sealed`,
`BaseController`, inject `ISender`, `[controller]`/`[action]` snake_case tokens, `base.OperationResult(...)`,
narrowest authorize policy), how prior phases call `IFieldEncryptor` for encrypted columns (b3 IBAN, b9 care
instructions), and how b14 *raised* a `support_alert` (you build the worklist that resolves them).
- **Contract conventions:** [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
(envelope, routes, status codes, pagination) and
[`money-and-types.md`](../../contracts/conventions/money-and-types.md) (the merchant-of-record / settlement
fields touch money — keep IRR `BIGINT`, no floats, and follow the id/enum/timestamp format rules).
## 3. Scope — build this
A vertical slice per capability: entity + EF config + migration → command/query handler(s) → controller
endpoint → contract. Everything async with `CancellationToken`; reads are `AsNoTracking()` + `.Select()`
projection + pagination; writes go through `IUnitOfWork` with a single `CommitAsync`. Admin endpoints are
**RBAC-gated and audited** (the SaveChanges interceptor writes `audit_logs` on every state change).
### 3.1 Entities, configs & migration
Add these tables as a single additive EF Core migration. One `IEntityTypeConfiguration<T>` per entity, in
`Persistence/Configuration/MessagingConfig/` and `Persistence/Configuration/PartnerCentersConfig/`.
- **`tickets`** — root of all post-booking communication.
- Columns: `id` (BIGINT PK), `reference_code` (NVARCHAR, **UNIQUE**, human-facing support id —
stable once minted, quoted to users), `subject` (NVARCHAR, nullable), `status` (enum:
`open` | `closed`; default `open`), `category`/`type` (enum, e.g. `coordination` | `support` |
`refund` | `emergency`), `booking_id` (FK → `bookings`, **nullable** — optional link),
`refund_id` (FK → `refunds`, **nullable** — optional link), `opened_by_id` (FK → users),
`closed_at` (datetimeoffset, nullable), `closed_by_id` (FK → users, nullable), plus audit fields
(`CreatedAt/ModifiedAt/CreatedById/ModifiedById`) and soft-delete.
- Indexes: unique on `reference_code`; index on `status`; index on `booking_id` and `refund_id` for the
"tickets for this booking/refund" lookups; the admin list filters by `(status, CreatedAt)`.
- Soft-delete query filter (`!IsDeleted`).
- **`ticket_participants`** — who is on a thread.
- Columns: `id` (BIGINT PK), `ticket_id` (FK → `tickets`), `user_id` (FK → users), `role_on_ticket`
(enum: `customer` | `nurse` | `admin`, nullable/derived), `added_by_id` (FK → users, nullable),
`removed_at` (datetimeoffset, nullable — soft-remove, or hard delete per your soft-delete convention),
plus audit.
- **UNIQUE `(ticket_id, user_id)`** — the authoritative backstop against adding a user twice.
- Index on `(user_id, ticket_id)` for `ListMyTickets`.
- **`ticket_messages`** — individual messages.
- Columns: `id` (BIGINT PK), `ticket_id` (FK → `tickets`), `sender_id` (FK → users), `body` (NVARCHAR),
**`is_internal` (BIT, default 0)** — admin-only note; the **hard visibility boundary**, `sent_at`
(datetimeoffset), plus audit and soft-delete.
- Index on `(ticket_id, sent_at)` for the thread read.
- **`partner_centers`** — the licensed sponsor center (fields from
[`product/data-model/13-partner-centers-and-future.md`](../../../product/data-model/13-partner-centers-and-future.md),
exact):
- `id` (BIGINT PK), `name` (NVARCHAR(300)), `legal_entity_type` (NVARCHAR(30)),
`moh_establishment_permit_no` (NVARCHAR(100) — پروانه تأسیس),
`technical_director_nurse_user_id` (BIGINT FK → users, **NULL** — مسئول فنی),
`technical_director_license_no` (NVARCHAR(100), NULL), `enamad_code` (NVARCHAR(100), NULL — نماد اعتماد
الکترونیکی), **`settlement_iban` (NVARCHAR(34), encrypted via `IFieldEncryptor`, NULL)** — only when
merchant-of-record, `is_merchant_of_record` (BIT), `commission_rate` (DECIMAL(5,4), NULL — the **center's
cut**, separate from `platform_fee_rate`), `admin_user_id` (BIGINT FK → users — the center's dashboard
account), `is_active` (BIT), `verified_at` (datetimeoffset, nullable), plus audit + soft-delete.
- Relations: 1:N → `nurse_profiles` (sponsors, via `nurse_profiles.partner_center_id`), `bookings` (legally
covered by), `invoices` (issuer). Index on `is_active`; index on `admin_user_id` for the center portal
scope.
- **`nurse_profiles.partner_center_id`** — FK → `partner_centers`, **NULL** (NULL once Balinyaar holds its
own permit). Add to the b3 entity in place; note it in your report.
> **Do NOT create** `organizations`, `organization_nurses`, `fraud_flags`, or `recurring_booking_schedules`
> in this phase. They are DEFERRED and must stay unreferenced. (See §1.)
### 3.2 Messaging — commands & queries
Feature folder `Baya.Application/Features/Messaging/`.
- **`OpenTicketCommand`** (`Commands/OpenTicket/`) — create a ticket, **mint a unique stable
`reference_code`** (e.g. a collision-checked short code; the UNIQUE index is the backstop), attach the
optional `booking_id`/`refund_id` (handle **null** — both links are optional), set `category`, and add the
**opener as the first `ticket_participant`**. Used by: the customer/nurse "Contact support" flow, the
refund flow (b11 anchors its `refunds.ticket_id` here), and `LogEmergencyTicket` (below).
- FluentValidation: subject/body length bounds; if `booking_id`/`refund_id` supplied, they must exist and
the opener must be a party to them (tenancy).
- **`AutoCreateCoordinationTicketCommand`** (`Commands/AutoCreateCoordinationTicket/`) — on **booking
confirmation** (invoked by the b9 confirmation flow, or by a domain hook), auto-create a
`category=coordination` ticket linked to the `booking_id` and add **nurse + customer** as participants. One
coordination ticket per booking (idempotent — re-confirmation must not create a second).
- **`PostMessageCommand`** (`Commands/PostMessage/`) — a participant appends a `ticket_message`. **Admins may
set `is_internal=true`**; a **non-admin caller can never set `is_internal` and can never post to a closed
ticket**. Guard: only ticket participants (or admins) may post. Optionally raise an in-app notification to
the *other* (non-internal) participants via `INotificationDispatcher` (reuse — no push at MVP).
- **`AddParticipantCommand`** / **`RemoveParticipantCommand`** (`Commands/AddParticipant/`,
`Commands/RemoveParticipant/`) — admin (or the ticket owner, per policy) attaches/detaches a user. Enforce
the **`UNIQUE(ticket_id, user_id)`**: a duplicate add returns a clean `OperationResult` failure, never a raw
DB exception. Admin may attach to any ticket for full read.
- **`CloseTicketCommand`** / **`ReopenTicketCommand`** (`Commands/CloseTicket/`, `Commands/ReopenTicket/`) —
status transitions `open``closed`/`closed``open`, stamping `closed_at`/`closed_by_id`; the owner trail
comes from the audit interceptor.
- **`LogEmergencyTicketCommand`** (`Commands/LogEmergencyTicket/`) — convenience path: after an emergency
call, a nurse opens a `category=emergency` ticket and **optionally raises a `support_alerts` row** (reuse
the b1 raise API). This is the *operational* side of the emergency playbook — it does **not** dial anyone
and does **not** expose a phone number (the emergency contact is surfaced from encrypted
`booking_care_instructions`, owned by b9; see §5).
- **`GetTicketThreadQuery`** (`Queries/GetTicketThread/`) — returns the ordered messages for a ticket the
caller may see. **Role-aware at the QUERY layer:** the **USER view filters out every `is_internal`
message**; the **ADMIN view returns all**. This filter lives in the query projection, not the UI — an
internal note must never appear in any user-facing payload (see §5). `AsNoTracking()` + `.Select()`.
- **`ListMyTicketsQuery`** (`Queries/ListMyTickets/`) — paginated tickets where the caller is a participant,
filterable by `status` and searchable by `reference_code`, newest first.
- **`ListTicketsForAdminQuery`** (`Queries/ListTicketsForAdmin/`) — **admin**, paginated global queue;
filter by `status`/`category`, search by `reference_code`, optional `booking_id`/`refund_id`.
### 3.3 Partner centers — commands & queries
Feature folder `Baya.Application/Features/PartnerCenters/`.
- **`CreatePartnerCenterCommand`** / **`UpdatePartnerCenterCommand`** (`Commands/CreatePartnerCenter/`,
`Commands/UpdatePartnerCenter/`) — capture `name`, `legal_entity_type`, `moh_establishment_permit_no`,
`technical_director_nurse_user_id` + `technical_director_license_no`, `enamad_code`,
**`settlement_iban` (encrypt via `IFieldEncryptor` before persisting — never plaintext)**,
`is_merchant_of_record`, **`commission_rate`** (the center's cut), `admin_user_id`. Admin-only. If a
settlement IBAN is provided and the center is merchant-of-record, optionally verify ownership via the
reused **`IBankAccountOwnershipVerifier`** (b3) before activation.
- FluentValidation: `commission_rate` in `[0, 1)`; `settlement_iban` required when
`is_merchant_of_record=1`; `moh_establishment_permit_no` non-empty.
- **`VerifyPartnerCenterCommand`** (`Commands/VerifyPartnerCenter/`) — sets `verified_at` and `is_active`.
At MVP the licensing check (eNamad / MoH establishment-permit) is a **manual admin approval** behind the
new **`ILicenseVerificationService`** seam (§4) — the command records the decision; the seam call is the
swap point for a real registry/API later.
- **`SponsorNurseCommand`** (`Commands/SponsorNurse/`) — set `nurse_profiles.partner_center_id` to link a
nurse to its sponsoring center (and a corresponding unlink/`null` path once Balinyaar holds its own
permit). Admin-only. The center's dashboard account (`admin_user_id`) may sponsor within its own center.
- **`GetCenterForBookingQuery`** (`Queries/GetCenterForBooking/`) — resolve **which center legally covers a
booking** (via the booking's nurse → `nurse_profiles.partner_center_id`, falling back to platform when
`NULL`). Returns the issuer/settlement decision: `issuing_entity_type` (`platform` | `partner_center`),
the center id (when applicable), and whether the center is merchant-of-record. **This is the resolver b11's
`GenerateCommissionInvoice` calls** to set the invoice issuer and the settlement target — invoices and
settlement follow `partner_centers`, never a hardcoded platform.
- **`ListPartnerCentersQuery`** / **`GetPartnerCenterByIdQuery`** (`Queries/ListPartnerCenters/`,
`Queries/GetPartnerCenterById/`) — admin, paginated, with the **sponsored-nurse count**. The detail view
**never returns the plaintext or full `settlement_iban`** — mask it (last 4) per the money-and-types masking
convention.
- **Center dashboard read models** (`Queries/GetCenterDashboard/` and friends, scoped to the center's
`admin_user_id`): list **sponsored nurses**, **sponsored bookings** (bookings whose nurse the center
sponsors), and the **settlement/invoice view** (the center's invoices when it is merchant-of-record). These
surface b13 payout / b11 invoice read models filtered to the center — they do not re-implement that logic.
### 3.4 Refund↔ticket link (wire b11 to the ticket system)
b11 created `refunds.ticket_id` and the rule that **every admin refund hangs off a ticket** (dispute paper
trail). This phase ships the ticket system that link targets. Wire it: when a refund is initiated (b11's
`InitiateRefund`), ensure a ticket exists (open a `category=refund` ticket via `OpenTicketCommand` if the
flow didn't already, or attach to the existing one) and set `refunds.ticket_id`. **Do not move refund money
logic into this phase** — only provide/confirm the ticket the refund anchors to. If b11 already opens the
ticket itself, this phase just provides the `OpenTicketCommand` it calls and confirms the link is non-null.
### 3.5 Support-alert worklist (build the console on b1's raise API)
Feature folder `Baya.Application/Features/SupportAlerts/`. b1 built the `support_alerts` *table* and the
`RaiseSupportAlert` API (and b14/b6/b9/b13 are the producers). Build the **admin worklist**:
- **`ListSupportAlertsQuery`** (`Queries/ListSupportAlerts/`) — **admin/support**, paginated, filter by
`type`/`status`/`owner`. `AsNoTracking()` + `.Select()`. **Internal-only** — these must never appear in any
user-facing endpoint or join.
- **`AssignSupportAlertCommand`** (`Commands/AssignSupportAlert/`) — set the owner.
- **`ResolveSupportAlertCommand`** (`Commands/ResolveSupportAlert/`) — set status + resolution trail.
### 3.6 Admin backoffice consolidation (surface, don't rebuild)
Expose the existing read models / commands under one **admin RBAC** surface (the b1 scopes). **Do not
re-implement** any of the underlying logic — these endpoints delegate to handlers built in prior phases:
| Backoffice area | Surfaces (built in) | RBAC scope |
| --- | --- | --- |
| Verification review queue | `ListVerificationQueue` + approve/reject ([b6](backend-phase-6.md)) | `admin` (verify) |
| Refund tooling | `InitiateRefund`/`ApproveRefund`/`RejectRefund` ([b11](backend-phase-11.md)) | `finance` (refund) |
| Payout dashboard | `ListPayoutBatches`/`GetPayoutBatch` ([b13](backend-phase-13.md)) | `finance` (payout) |
| Review moderation queue | `GetReviewModerationQueue` + `ModerateReview` ([b14](backend-phase-14.md)) | `moderator` |
| Config / holiday / audit | config CRUD, holiday calendar, **audit-log viewer** ([b1](backend-phase-1.md)) | `super_admin`/`admin` |
| Support-alert worklist | §3.5 (this phase) | `support`/`admin` |
| Tickets (admin) | §3.2 admin queries (this phase) | `support`/`admin` |
| Partner centers | §3.3 (this phase) | `admin`/`super_admin` |
Add the **audit-log viewer** query if b1 did not already expose it: **`ListAuditLogsQuery`**
(`Queries/ListAuditLogs/`) — read-only, paginated, filter by `entity_type`/`entity_id`/`actor`/date. (If b1
already shipped it, reuse it and note so — do not duplicate.)
### 3.7 REST endpoints
Controllers under `Baya.Web.Api/Controllers/V1/` (`sealed`, `BaseController`, inject `ISender`,
`[controller]`/`[action]` snake_case tokens, `base.OperationResult(...)`, narrowest authorize policy). Admin
endpoints are **internal-only** (admin RBAC) and audited.
| Verb & route | Maps to | Auth |
| --- | --- | --- |
| `POST /v1/tickets` | `OpenTicketCommand` | authenticated |
| `POST /v1/tickets/{id}/messages` | `PostMessageCommand` | participant (admin may set internal) |
| `POST /v1/tickets/{id}/participants` | `AddParticipantCommand` | admin / owner |
| `DELETE /v1/tickets/{id}/participants/{user_id}` | `RemoveParticipantCommand` | admin / owner |
| `POST /v1/tickets/{id}/close` | `CloseTicketCommand` | participant / admin |
| `POST /v1/tickets/{id}/reopen` | `ReopenTicketCommand` | participant / admin |
| `POST /v1/tickets/emergency` | `LogEmergencyTicketCommand` | nurse (assigned) |
| `GET /v1/tickets` | `ListMyTicketsQuery` | authenticated (own) |
| `GET /v1/tickets/{id}` | `GetTicketThreadQuery` (**user view: internal stripped**) | participant |
| `GET /v1/admin/tickets` | `ListTicketsForAdminQuery` | `support`/`admin` |
| `GET /v1/admin/tickets/{id}` | `GetTicketThreadQuery` (**admin view: all**) | `support`/`admin` |
| `POST /v1/admin/partner-centers` | `CreatePartnerCenterCommand` | `admin`/`super_admin` |
| `PATCH /v1/admin/partner-centers/{id}` | `UpdatePartnerCenterCommand` | `admin`/`super_admin` |
| `POST /v1/admin/partner-centers/{id}/verify` | `VerifyPartnerCenterCommand` | `admin`/`super_admin` |
| `POST /v1/admin/partner-centers/{id}/sponsor-nurse` | `SponsorNurseCommand` | `admin`/`super_admin` |
| `GET /v1/admin/partner-centers` | `ListPartnerCentersQuery` | `admin`/`super_admin` |
| `GET /v1/admin/partner-centers/{id}` | `GetPartnerCenterByIdQuery` (**IBAN masked**) | `admin`/`super_admin` |
| `GET /v1/centers/{id}/dashboard` | `GetCenterDashboardQuery` | center `admin_user_id` |
| `GET /v1/internal/bookings/{booking_id}/center` | `GetCenterForBookingQuery` | internal/admin |
| `GET /v1/admin/support-alerts` | `ListSupportAlertsQuery` | `support`/`admin` |
| `PATCH /v1/admin/support-alerts/{id}/assign` | `AssignSupportAlertCommand` | `support`/`admin` |
| `PATCH /v1/admin/support-alerts/{id}/resolve` | `ResolveSupportAlertCommand` | `support`/`admin` |
| `GET /v1/admin/audit-logs` | `ListAuditLogsQuery` | `super_admin`/`admin` |
> Verification-queue, refund, payout-dashboard, moderation-queue, and config/holiday endpoints are the ones
> their own phases already published — surface them under the admin route group with the RBAC scope in §3.6;
> do **not** redefine their handlers.
### 3.8 Out of scope (DEFERRED — do not build/migrate)
- **`organizations` / `organization_nurses`** (future employer model) — **(DEFERRED)**; distinct from
`partner_centers`. See [`product/data-model/13-partner-centers-and-future.md`](../../../product/data-model/13-partner-centers-and-future.md).
- **`fraud_flags`** (ML fraud output) — **(DEFERRED)**; rule-based `support_alerts` `fraud_signal` covers it.
- **`recurring_booking_schedules`** (RFC-5545 recurrence) — **(DEFERRED)**; `booking_sessions` meets the need.
- **Real-time chat / SLA-bearing `incidents` entity / push delivery** — **(DEFERRED)**; emergencies stay an
operational playbook ([`product/business/12-messaging-and-emergencies.md`](../../../product/business/12-messaging-and-emergencies.md) (c)).
- **Real eNamad / MoH establishment-permit / مودیان integrations** — mocked behind seams (this phase
introduces `ILicenseVerificationService`; `IMoadianClient` belongs to [b11](backend-phase-11.md)).
## 4. Mocks & seams in this phase
| Seam | Owner | Mock behaviour | Registry |
| --- | --- | --- | --- |
| **`ILicenseVerificationService`** (eNamad / MoH establishment-permit) — **INTRODUCED here** | this phase | e.g. `Task<LicenseVerdict> VerifyEstablishmentPermitAsync(string permitNo, CancellationToken)` and `VerifyENamadAsync(string enamadCode, …)` returning a verdict (`Valid`/`Invalid`/`NeedsManualReview` + reason). Mock = **manual admin approve** (returns `NeedsManualReview`, so `VerifyPartnerCenter` records the human decision). No external call. Selection by config/registration. | **add row** |
| `IFieldEncryptor`**REUSE from [b0](backend-phase-0.md)** | b0 | local symmetric key; `Encrypt`/`Decrypt`. Used for `partner_centers.settlement_iban`. Do not redefine. | reuse |
| `IBankAccountOwnershipVerifier`**REUSE from [b3](backend-phase-3.md)** | b3 | IBAN ownership inquiry; optionally used to verify a center's `settlement_iban` when merchant-of-record. | reuse |
| `INotificationDispatcher`**REUSE from [b0](backend-phase-0.md)/[b1](backend-phase-1.md)** | b0/b1 | in-app write (no push at MVP). New-ticket-message alerts to the other participant. | reuse |
| `RaiseSupportAlert` (on `support_alerts`) — **REUSE from [b1](backend-phase-1.md)** | b1 | inserts an internal alert row; used by `LogEmergencyTicket`. The worklist that resolves them is built here. | reuse |
Register `ILicenseVerificationService` (interface in `Application/Contracts/`, mock impl in Infrastructure)
via a `ServiceConfiguration/` extension — never inline in `Program.cs`. Record it in
[`mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) with: seam, file, what's faked
(manual approval at MVP), config keys (auto-approve toggle), how to make it real (point the methods at a real
eNamad / MoH registry/API without touching `VerifyPartnerCenter`), status 🟡. There is **no telephony/VoIP
seam** — the emergency call is out-of-platform by design (a `tel:` link in the UI); do not build a calling
seam.
## 5. Critical rules you must not get wrong
- **No direct nurse↔customer channel.** All post-booking communication is **ticket-mediated and
admin-readable**. **Never expose the family's phone number to the nurse or vice-versa**, and never build a
side-channel. The **emergency-contact playbook is the only sanctioned bypass** — it is *operational*, not a
contact-sharing feature: the emergency contact lives in encrypted `booking_care_instructions` (owned by b9,
surfaced post-confirmation to the assigned nurse only via the two-stage clinical disclosure gate); this
phase's `LogEmergencyTicket` records the *aftermath* (a ticket, optionally an alert), it does **not** widen
that disclosure. Keep it scoped to emergencies; do not turn it into a general contact directory.
- **`is_internal` is a HARD visibility boundary, enforced at the QUERY layer.** Admin-only internal notes
must **never** leak into any user-facing query, payload, or join. `GetTicketThreadQuery`'s **user view
strips every `is_internal` message** in the projection; only the **admin view** returns them. A non-admin
caller can never *set* `is_internal` on `PostMessage` and can never *read* one. This is enforced in the
query/serialization layer, not just the UI.
- **`reference_code` is unique + stable.** It is quoted to users; mint it once (collision-checked), back it
with a UNIQUE index, and never mutate it.
- **Ticket↔booking/refund links are optional — handle null.** `booking_id` and `refund_id` are both nullable;
every read/write path must tolerate a ticket with neither link (a pure support ticket).
- **Authorization/tenancy on every ticket read and write.** Only ticket participants (and admins) can read or
post; enforce on both. Admin may attach to any ticket for full read.
- **`UNIQUE(ticket_id, user_id)` on participants** — a duplicate add returns a clean `OperationResult`
failure, never a raw DB exception; the unique index is the authoritative backstop.
- **Merchant-of-record correctness.** If `partner_centers.is_merchant_of_record = 1`, the **CENTER** (not
Balinyaar, not the nurse) is the legal **invoice issuer** and the **settlement target** — invoices and
settlement follow `partner_centers`, **not** a hardcoded platform. `GetCenterForBooking` is the single
resolver b11's invoice issuance calls; it must return the center as issuer/settlement target when the
booking's nurse is sponsored by a merchant-of-record center, and `platform` otherwise. This is a legal/tax
correctness issue, not cosmetic.
- **`partner_centers``organizations`.** Keep the **launch licensing sponsor** (`partner_centers`) distinct
from the future **employer** (`organizations`). Do not conflate "sponsor for legality" with "employer," and
do not repurpose `organizations` for launch (it stays DEFERRED/inactive).
- **`commission_rate` (the center's cut) is separate from `platform_fee_rate`.** Money math must account for
an additional center cut where present; never collapse the two. **Money is IRR `BIGINT` — no floats,
anywhere**; the three booking amounts always satisfy **`gross = commission + payout`**; the `ledger_entries`
on the money path stay **append-only and balanced** (Σdebit = Σcredit per `transaction_group_id`); webhook
handling is **idempotent**; payouts are **dispute-window gated** and **one payout per booking**
(`nurse_payout_booking_links.booking_id` UNIQUE). This phase does not *post* ledger entries, but the
merchant-of-record / settlement-target it resolves feeds those invariants — get the issuer/target right or
the downstream money is wrong.
- **`settlement_iban` is encrypted PII.** Encrypt via `IFieldEncryptor` before persisting; never store, log,
or project it in plaintext; mask it (last 4) in any read response.
- **Deferred tables stay inactive/unreferenced.** `organizations`, `organization_nurses`, `fraud_flags`,
`recurring_booking_schedules` are **not** created or referenced — adding them later must remain a pure
additive migration.
- **Admin endpoints are internal-only, RBAC-scoped, and audited.** Authorize every admin command/query with
the narrowest fitting b1 scope (`support` cannot pay out, `moderator` cannot refund, etc.); every
state-changing admin op writes an **append-only `audit_logs`** row via the interceptor — never mutate or
delete prior audit rows. **`support_alerts` are internal-only** and must never surface in any user-facing
response.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] `tickets`, `ticket_participants`, `ticket_messages`, `partner_centers` exist via one additive migration
with the constraints in §3.1 (unique `reference_code`, `UNIQUE(ticket_id, user_id)`, `is_internal`
column, `settlement_iban` encrypted); `nurse_profiles.partner_center_id` FK added; the four DEFERRED
tables are **not** created.
- [ ] `OpenTicket`/`AutoCreateCoordinationTicket`/`PostMessage`/`AddParticipant`/`RemoveParticipant`/
`CloseTicket`/`ReopenTicket`/`LogEmergencyTicket`/`GetTicketThread`/`ListMyTickets`/`ListTicketsForAdmin`
are implemented as CQRS features with validators and the §3.7 endpoints, returning the standard
`OperationResult` envelope.
- [ ] `GetTicketThread` strips `is_internal` in the **user** view and returns it in the **admin** view —
proven by a test (an internal message is hidden from the user thread, shown in the admin thread).
- [ ] `CreatePartnerCenter`/`UpdatePartnerCenter`/`VerifyPartnerCenter`/`SponsorNurse`/`GetCenterForBooking`/
`ListPartnerCenters`/`GetPartnerCenterById` + the center dashboard are implemented; `settlement_iban` is
encrypted at rest and masked in reads; `SponsorNurse` sets `nurse_profiles.partner_center_id`;
`GetCenterForBooking` returns the **merchant-of-record** issuer/settlement target.
- [ ] The refund↔ticket link is wired (§3.4): every admin refund has a non-null `ticket_id`.
- [ ] The support-alert worklist (`ListSupportAlerts`/`AssignSupportAlert`/`ResolveSupportAlert`) is built on
b1's raise API; the admin backoffice **surfaces** verification/refund/payout/moderation/config/holiday/
audit under RBAC scopes without rebuilding their handlers.
- [ ] `ILicenseVerificationService` is introduced behind a DI seam with a manual-approve mock and a registry
row.
- [ ] `dotnet build Baya.sln` zero new warnings; `dotnet test Baya.sln` green including this phase's tests.
- [ ] The contract `dev/contracts/domains/messaging-notifications-admin.md` is written and the `swagger.json`
snapshot is refreshed; the `server/CLAUDE.md` *Project map* notes the new feature areas (`Messaging`,
`PartnerCenters`, `SupportAlerts`, the admin backoffice route group), the four new tables + the
`nurse_profiles.partner_center_id` FK, and the `ILicenseVerificationService` seam.
## 7. How to test (what a human can verify after this phase)
Run the API (`dotnet run --project src/API/Baya.Web.Api/...`) against a reachable SQL Server; use Swagger or
curl. Expected results below become the "what can be tested" section of your report.
1. **Open a ticket + post a message.** `POST /v1/tickets` (no `booking_id`/`refund_id`) → `200`, a ticket
with a unique `reference_code`, the opener added as a participant. `POST /v1/tickets/{id}/messages` with a
normal body → `200`, message appears in the thread.
2. **Internal note is hidden in the user view, shown in the admin view.** As an admin, `POST
/v1/tickets/{id}/messages` with `is_internal: true` → `200`. `GET /v1/tickets/{id}` (user view) → the
internal message is **absent**. `GET /v1/admin/tickets/{id}` (admin view) → the internal message **is
present**. A non-admin attempt to set `is_internal: true` → rejected.
3. **Add/remove participant with uniqueness.** `POST /v1/tickets/{id}/participants` adds a user → `200`;
adding the **same** user again → an `OperationResult` failure (not a 500) from the `UNIQUE(ticket_id,
user_id)`. `DELETE /v1/tickets/{id}/participants/{user_id}` removes them.
4. **Create a partner center + sponsor a nurse.** `POST /v1/admin/partner-centers` with
`is_merchant_of_record: true` and a `settlement_iban` → `200`; `GET /v1/admin/partner-centers/{id}` shows
the IBAN **masked** (last 4), never plaintext. `POST /v1/admin/partner-centers/{id}/sponsor-nurse` →
`nurse_profiles.partner_center_id` is set for that nurse.
5. **`GetCenterForBooking` returns the merchant-of-record.** `GET /v1/internal/bookings/{booking_id}/center`
for a booking whose nurse is sponsored by the merchant-of-record center → `issuing_entity_type:
partner_center` + the center id; for a booking whose nurse is unsponsored → `platform`.
6. **Refund anchors to a ticket.** Initiate a refund (b11 flow) → the resulting `refunds.ticket_id` is
non-null and the ticket is a `category=refund` ticket.
7. **Admin worklists list the pending items.** `GET /v1/admin/support-alerts` lists the alerts b14/b6/b9/b13
raised (internal-only — never in a user response); `PATCH …/assign` then `…/resolve` updates owner +
status. The verification queue, refunds, payout dashboard, and moderation queue are reachable under their
admin routes with the correct RBAC scope (a `support` token is denied the payout route → `403`).
8. **Audit trail.** Any admin state change (e.g. `VerifyPartnerCenter`, `ResolveSupportAlert`) writes an
`audit_logs` row visible via `GET /v1/admin/audit-logs`; prior rows are never mutated.
## 8. Hand off & document (close the phase)
- **Docs to update (same change):** `server/CLAUDE.md` *Project map* — add the `Features/Messaging`,
`Features/PartnerCenters`, `Features/SupportAlerts` areas, the admin backoffice route group, the four new
tables + their config folders, the `nurse_profiles.partner_center_id` FK, and the
`ILicenseVerificationService` seam (where it's registered). If you discovered/decided any business rule not
already in the product docs, reflect it in
[`product/business/12-messaging-and-emergencies.md`](../../../product/business/12-messaging-and-emergencies.md),
[`product/business/14-notifications-and-admin.md`](../../../product/business/14-notifications-and-admin.md),
[`product/business/13-tax-invoicing-and-legal.md`](../../../product/business/13-tax-invoicing-and-legal.md),
or [`product/data-model/13-partner-centers-and-future.md`](../../../product/data-model/13-partner-centers-and-future.md)
(no invented rules — record decisions, and regenerate the HTML view per `product/CLAUDE.md` if you touched
Markdown).
- **Contract to write:** publish **`dev/contracts/domains/messaging-notifications-admin.md`** (the §3.7
routes, request/response shapes, the `ticket.status`/`category` and `support_alerts.status` enums, the
`is_internal` user-vs-admin view rule, the `partner_centers` shape with the **masked** `settlement_iban`,
the `GetCenterForBooking` issuer-resolution response, the RBAC scope per admin route, status codes, and
examples) per [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md),
and refresh the `swagger.json` snapshot per [`../../contracts/openapi/README.md`](../../contracts/openapi/README.md)
so [frontend-phase-14-b15](../frontend/frontend-phase-14-b15.md) (messaging/notifications) and
[frontend-phase-15-b15](../frontend/frontend-phase-15-b15.md) (admin + partner consoles) can derive their
types (they do not guess shapes).
- **Handoff & report:** write `shared-working-context/backend/handoff/after-backend-phase-15.md` (tickets,
partner centers, the support-alert worklist, and the consolidated admin backoffice are live; what f14/f15
can now build; the `is_internal` user-vs-admin rule and the RBAC scope per route the frontend must respect;
what's mocked — the `ILicenseVerificationService` manual-approve seam), append your phase summary to
`shared-working-context/backend/STATUS.md`, write `reports/backend-phase-15-report.md` (what was built, what
is now testable and exactly how — the §7 steps — what is mocked + how to make it real, contracts produced,
follow-ups), and update
[`reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) with the
`ILicenseVerificationService` row → 🟡.
- **Memory:** save a `project`-type memory note for the non-obvious decisions here — the **`is_internal`
hard-boundary at the query layer** (and the user-vs-admin `GetTicketThread` views), the
**merchant-of-record resolution** via `GetCenterForBooking` (issuer + settlement target follow
`partner_centers`, not the platform), the **`partner_centers` ≠ `organizations`** distinction, and the
list of **DEFERRED-inactive tables** — with a one-line `MEMORY.md` pointer. This is the last backend phase;
the memory note should also record that the backend chain is complete.