# 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.
