11 KiB
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_internalboundary), 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/money-and-types.md. Machine schema:../openapi/swagger.v1.json.
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_internalis 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 setis_internalon 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.
bookingIdandrefundIdare both nullable — a pure support ticket has neither. referenceCodeis stable + unique ("TKT-9F3K2A7Q"), quoted to users; never mutated.- Merchant-of-record follows
partner_centers.GET /internal/bookings/{bookingId}/centerreturnsissuingEntityType = partner_center(+ the center id) only when the booking's nurse is sponsored by a merchant-of-record center, elseplatform. 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_logsrow (never mutate prior rows).support_alertsare 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:
{ "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:
{ "ticketId": 12, "referenceCode": "TKT-9F3K2A7Q", "status": "open", "category": "support" }
POST /api/v1/tickets/{id}/messages
{ "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
{ "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):
{
"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
{
"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):
{
"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
{ "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:
{ "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
PlatformConfigDtogainsupdatedAt+updatedBy(from the entity audit fields). - REQ-030
GET audit/get_audit_trailfilters also byactorId,action,from,to(all optional;entityType/entityIdnow optional too). Query params bind camelCase (actorId/from/to), notactor_id. - REQ-037
tagCodes: string[]onModerationQueueItemDto. REQ-033totalIrronInvoiceDto(= 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 thesnake_caseconvention; 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.