196 lines
11 KiB
Markdown
196 lines
11 KiB
Markdown
# 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.
|