# Contract — Messaging (tickets), partner centers & admin backoffice (backend phase b15) > One-line: the ticket system (the only sanctioned post-booking channel, admin-readable, with a hard > `is_internal` boundary), the licensed **partner centers** (sponsor / merchant-of-record → invoice issuer + > settlement target), and the consolidated admin backoffice (support-alert worklist + audit viewer + the > verify/refund/payout/moderation surfaces built in prior phases). Assumes > [`../conventions/api-conventions.md`](../conventions/api-conventions.md) + > [`../conventions/money-and-types.md`](../conventions/money-and-types.md). Machine schema: > [`../openapi/swagger.v1.json`](../openapi/README.md). **Status:** live as of backend-phase-b15 · **Frontend consumers:** frontend-phase-14-b15 (messaging/notifications), frontend-phase-15-b15 (admin + partner consoles) Timestamps are UTC ISO-8601. IDs are numbers. Pagination is `page` / `pageSize` (default 50, max 100), response `{ items, total, page, pageSize }`. All settlement money is IRR `BIGINT`; the `settlement_iban` is **never** returned in plaintext — only a masked last-4 (`"••••0001"`). --- ## Critical rules the frontend must respect - **`is_internal` is a hard boundary enforced at the query layer.** `GET /tickets/{id}` (the **user** view) never contains an internal message; `GET /admin/tickets/{id}` (the **admin** view, staff only) contains them. A non-staff caller cannot set `is_internal` on a message (→ `403`) and can never read one. Do not rely on the UI to hide internal notes — the backend already strips them from the user payload. - **No direct nurse↔customer channel.** All post-booking communication is ticket-mediated. Never surface a phone number. The emergency flow (`POST /tickets/emergency`) records the *aftermath* of an out-of-platform call; it exposes no contact. - **Ticket ↔ booking/refund links are optional.** `bookingId` and `refundId` are both nullable — a pure support ticket has neither. - **`referenceCode` is stable + unique** (`"TKT-9F3K2A7Q"`), quoted to users; never mutated. - **Merchant-of-record follows `partner_centers`.** `GET /internal/bookings/{bookingId}/center` returns `issuingEntityType = partner_center` (+ the center id) only when the booking's nurse is sponsored by a merchant-of-record center, else `platform`. Invoices + settlement follow this, not a hardcoded platform. - **Admin endpoints are internal-only + RBAC-gated + audited.** Every admin state change writes an append-only `audit_logs` row (never mutate prior rows). `support_alerts` are internal-only — never in a user response. ## Enums - `ticket.status`: `open` | `closed`. - `ticket.category`: `coordination` | `support` | `refund` | `emergency`. - `ticket_participant.role_on_ticket`: `customer` | `nurse` | `admin` (display label, not an auth source). - `support_alert.status`: `open` | `assigned` | `resolved` (forward-only). - `support_alert.type`: `low_rating` | `evv_no_show` | `evv_location_mismatch` | `verification_expired` | `shared_sim` | `payment_anomaly` | `fraud_signal` | `nurse_clawback` | `emergency`. - `invoice.issuing_entity_type` (resolver output): `platform` | `partner_center`. --- ## Tickets — authenticated (participant-scoped) | Verb & route | Maps to | Auth | | --- | --- | --- | | `POST /api/v1/tickets` | open a ticket | authenticated | | `POST /api/v1/tickets/emergency` | log an emergency ticket (+ optional alert) | assigned nurse / staff | | `POST /api/v1/tickets/{id}/messages` | post a message | participant (staff may set `isInternal`) | | `POST /api/v1/tickets/{id}/participants` | add a participant | staff / ticket owner | | `DELETE /api/v1/tickets/{id}/participants/{userId}` | soft-remove a participant | staff / ticket owner | | `POST /api/v1/tickets/{id}/close` · `/reopen` | status transitions | participant / staff | | `GET /api/v1/tickets` | my tickets (paginated) | authenticated (own) | | `GET /api/v1/tickets/{id}` | thread — **user view, internal stripped** | participant / staff | ### `POST /api/v1/tickets` Request: ```json { "category": "support", "subject": "Reschedule", "body": "Can we move to 5pm?", "bookingId": 42, "refundId": null } ``` `bookingId`/`refundId` optional. A booking link requires the caller to be a party to the booking (staff bypass); a refund link is staff-only. Response `data`: ```json { "ticketId": 12, "referenceCode": "TKT-9F3K2A7Q", "status": "open", "category": "support" } ``` ### `POST /api/v1/tickets/{id}/messages` ```json { "body": "internal note", "isInternal": true } ``` `isInternal` defaults `false`; a non-staff caller sending `true` → `403`; posting to a closed ticket as a non-staff caller → `403`. Response `data`: `{ "messageId", "ticketId", "sentAt" }`. ### `POST /api/v1/tickets/emergency` ```json { "bookingId": 42, "body": "Called 115; patient stable.", "raiseAlert": true } ``` Only the assigned nurse (or staff). Response is the same shape as opening a ticket (`category: "emergency"`). ### `GET /api/v1/tickets/{id}` (user) / `GET /api/v1/admin/tickets/{id}` (admin) Response `data` (admin view shown; the user view omits internal messages): ```json { "id": 12, "referenceCode": "TKT-9F3K2A7Q", "subject": "Reschedule", "status": "open", "category": "support", "bookingId": 42, "refundId": null, "openedById": 7, "closedAt": null, "participants": [ { "userId": 7, "roleOnTicket": "customer" }, { "userId": 3, "roleOnTicket": "admin" } ], "messages": [ { "id": 1, "senderId": 7, "body": "…", "isInternal": false, "sentAt": "2026-07-10T…Z" } ] } ``` A duplicate `POST …/participants` returns **409** (backed by `UNIQUE(ticket_id, user_id)`), never a 500. ## Tickets — admin (`support`/`admin`) | Verb & route | Maps to | | --- | --- | | `GET /api/v1/admin/tickets` | global queue (filter `status`/`category`, search `referenceCode`, `bookingId`/`refundId`) | | `GET /api/v1/admin/tickets/{id}` | thread — **admin view, internal included** | --- ## Partner centers — admin (`admin`/`super_admin`) | Verb & route | Maps to | | --- | --- | | `POST /api/v1/admin/partner-centers` | create (inactive until verified) | | `PATCH /api/v1/admin/partner-centers/{id}` | update (replace semantics) | | `POST /api/v1/admin/partner-centers/{id}/verify` | record licensing approval + activate | | `POST /api/v1/admin/partner-centers/{id}/sponsor-nurse` | set/clear `nurse_profiles.partner_center_id` | | `GET /api/v1/admin/partner-centers` | list (no IBAN, sponsored-nurse counts) | | `GET /api/v1/admin/partner-centers/{id}` | detail (**IBAN masked**) | ### `POST /api/v1/admin/partner-centers` ```json { "name": "Asanism Center", "legalEntityType": "llc", "mohEstablishmentPermitNo": "MOH-12345", "technicalDirectorNurseUserId": null, "technicalDirectorLicenseNo": null, "enamadCode": "EN-999", "settlementIban": "IR062960000000100324200001", "isMerchantOfRecord": true, "commissionRate": 0.05, "adminUserId": 8 } ``` Validation: `commissionRate ∈ [0, 1)`; `settlementIban` required when `isMerchantOfRecord=true`; `mohEstablishmentPermitNo` non-empty. Response `data` (detail): ```json { "id": 1, "name": "Asanism Center", "legalEntityType": "llc", "mohEstablishmentPermitNo": "MOH-12345", "technicalDirectorNurseUserId": null, "technicalDirectorLicenseNo": null, "enamadCode": "EN-999", "settlementIbanMasked": "••••0001", "isMerchantOfRecord": true, "commissionRate": 0.05, "adminUserId": 8, "isActive": false, "verifiedAt": null, "sponsoredNurseCount": 0, "createdAt": "…Z" } ``` ### `POST /api/v1/admin/partner-centers/{id}/sponsor-nurse` ```json { "nurseProfileId": 15, "unlink": false } ``` Staff, or the center's own `adminUserId`, may sponsor within that center. `unlink: true` clears the link. ## Partner center — portal + internal resolver | Verb & route | Maps to | Auth | | --- | --- | --- | | `GET /api/v1/centers/{id}/dashboard` | sponsored nurses + booking/invoice counts + masked settlement | center `adminUserId` / staff | | `GET /api/v1/internal/bookings/{bookingId}/center` | issuer/settlement resolution | internal / admin | `GET /internal/bookings/{bookingId}/center` response `data`: ```json { "bookingId": 42, "issuingEntityType": "partner_center", "partnerCenterId": 1, "partnerCenterName": "Asanism Center", "isMerchantOfRecord": true } ``` For an unsponsored / non-merchant-of-record nurse: `{ "issuingEntityType": "platform", "partnerCenterId": null, … }`. --- ## Admin backoffice (surfaced, built in prior phases) The support-alert worklist and audit viewer existed since b1; b15 confirms them as the backoffice surface (no rebuild). All are `[Authorize(DynamicPermission)]` (admin role passes; other staff scopes via seeded claims). | Verb & route | Maps to | Scope | | --- | --- | --- | | `GET /api/v1/support_alerts/get_support_alerts` | list (filter `type`/`status`/`ownerUserId`) | `support`/`admin` | | `POST /api/v1/support_alerts/assign_support_alert` | set owner | `support`/`admin` | | `POST /api/v1/support_alerts/resolve_support_alert` | resolve + note | `support`/`admin` | | `GET /api/v1/audit/get_audit_trail` | append-only audit log (filter entity/actor/date) | `super_admin`/`admin` | | Verification queue / refunds / payouts / moderation / config / holidays | their own phase routes | b6/b11/b13/b14/b1 | `support_alerts` are internal-only and must never appear in a user-facing response or join. --- ## Refinement phase 3 additions (REQ-029/030/031/032/033/034/035/036/037) **Delivered:** - **REQ-029** `PlatformConfigDto` gains `updatedAt` + `updatedBy` (from the entity audit fields). - **REQ-030** `GET audit/get_audit_trail` filters also by `actorId`, `action`, `from`, `to` (all optional; `entityType`/ `entityId` now optional too). **Query params bind camelCase** (`actorId`/`from`/`to`), not `actor_id`. - **REQ-037** `tagCodes: string[]` on `ModerationQueueItemDto`. **REQ-033** `totalIrr` on `InvoiceDto` (= platform commission + BNPL commission + VAT). - **REQ-032** activate/suspend toggle `POST admin/partner-centers/{id}/set-active { isActive }`. **Route casing pinned:** the admin partner-center routes are **kebab-case** (`admin/partner-centers`, `.../set-active`) — an intentional b15 divergence from the `snake_case` convention; the frontend's kebab-case client is CORRECT. **Deferred (admin-console polish, documented in the tracker):** REQ-031 (RBAC `admin_roles/list|grant|revoke`), REQ-032 `centers/me` + split portal reads (need the user↔center admin association REQ-038 deferred), REQ-033 center-scoped invoice list, REQ-034 verification nurse-queue/signed-url/whole-approve, REQ-035 refund admin preview+approve/reject (the customer preview REQ-020 IS delivered), REQ-036 payout admin preview/holidayShifted/ transfer-reference.