# 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//{Commands|Queries}//` (request `record` + `internal sealed` handler + `OperationResult` — never throw), an `IEntityTypeConfiguration` under `Persistence/Configuration/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` 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 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.