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