Files
baya-monorepo/archive/build-chain/contracts/domains/messaging-notifications-admin.md
T
2026-08-02 18:48:32 +03:30

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_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/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_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:

{ "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 true403; 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 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.