Files
baya-monorepo/dev/phases/backend/backend-phase-15.md
T
2026-06-28 21:59:59 +03:30

42 KiB

Backend Phase 15 — Messaging (tickets), partner centers & admin backoffice

Mission: close the platform's operational loop. Build the ticket system that is the only sanctioned post-booking communication channel — nurse and customer coordinate under full admin visibility, with admin-only internal notes that can never leak to users and no direct nurse↔customer side-channel. Stand up partner_centers — the licensed home-nursing center that sponsors nurses and, when it is the merchant-of-record, becomes the legal invoice issuer and settlement target (not the platform). Finally, consolidate the admin backoffice: tie the verification queue (b6), refund tooling (b11), payout dashboard (b13), review moderation queue (b14), config/holiday/ audit (b1), and the support-alert worklist (b1) into one RBAC-gated, fully audited admin surface. This is the last backend phase — it makes the support, admin, and partner UIs buildable.

Track: backend · Depends on: backend-phase-3 (users/profiles/nurse_profiles), backend-phase-1 (notifications, support_alerts, platform_configs, audit_logs, RBAC, holidays), backend-phase-11 (refunds + the ticket-link hook, invoices), backend-phase-13 (payout batches/dashboard), backend-phase-14 (review moderation queue) · Unlocks: the support + admin + partner UIs (frontend-phase-14-b15, frontend-phase-15-b15) Before you start, read ../_shared/agent-operating-rules.md. It is not optional.


1. Context — where this sits

This is the final backend phase. Every domain the admin backoffice acts on already exists; every signal the support team triages is already being raised. This phase adds the three remaining pieces and then wires them together:

  1. Messaging (tickets) — the structured, admin-readable communication channel. A booking-scoped coordination ticket lets the nurse and customer arrange logistics under admin visibility; tickets also anchor refund conversations and support requests. There is deliberately no live chat and no direct nurse↔customer messaging — this is an anti-disintermediation and patient-safety design.
  2. Partner centers — the licensed center (Asanism-style) that sponsors nurses and, at launch, is plausibly the merchant-of-record. When it is, it is the legal invoice issuer and the IPG settles to its IBAN — invoices and settlement follow partner_centers, never a hardcoded platform.
  3. Admin backoffice consolidation — the verification queue, refunds, payouts, review moderation, config/ holiday/audit, and the support-alert worklist are each already built in their own phases. This phase does not rebuild them; it exposes them under a single RBAC-gated, audited admin surface and adds the one missing admin worklist — the support-alert console (raise API exists since b1; the assign/resolve/ list worklist is built here).

What already exists (do not rebuild):

  • Users, roles & profilesbackend-phase-3 built nurse_profiles, customer_profiles, patients, and nurse_bank_accounts. nurse_profiles.partner_center_id is the sponsorship FK this phase sets (add the column to the b3 entity in place if it is not already there, and note it in your report — do not fork a parallel table). backend-phase-2 built users (+ gender, national_id) and sessions; backend-phase-1 built the admin RBAC (roles/user_roles, scopes super_admin/admin/support/finance/moderator) you authorize every admin endpoint against.
  • Platform signals, config, audit, holidaysbackend-phase-1 built notifications (typed in-app write + list/unread-count/mark-read + 90-day retention), support_alerts (the internal-only worklist table + the RaiseSupportAlert raise API), platform_configs (typed cached accessor — incl. platform_fee_rate, vat_rate), audit_logs (append-only, written by the SaveChanges interceptor on sensitive entities), and iranian_holidays. Reuse all of these. This phase consumes them — it builds the support_alerts assign/resolve/list worklist and the audit viewer, it does not redefine the tables.
  • Refunds, invoices & the ticket-link hookbackend-phase-11 built refunds (admin-only, ticket-linked, fee/payout decomposition, channel-aware), nurse_clawbacks, and invoices (VAT on commission, issuing_entity_type). b11 created refunds.ticket_id and the expectation that a ticket anchors every admin refund; this phase ships the ticket system that link points at and wires OpenTicket into the refund flow (§3.2). The invoices.issuing_entity_type / center-issuer resolution that partner_centers drives is consumed by b11's GenerateCommissionInvoice — you provide GetCenterForBooking (§3.4) as the resolver.
  • Payoutsbackend-phase-13 built nurse_payout_batches, nurse_payouts, nurse_payout_booking_links (booking_id UNIQUE), the weekly holiday-aware batch, clawback netting, and the payout dashboard queries. The admin backoffice surfaces these read models — it does not re-implement payout logic.
  • Review moderationbackend-phase-14 built GetReviewModerationQueueQuery + ModerateReviewCommand and raises low-rating support_alerts. The backoffice surfaces the moderation queue and the alerts it raises.
  • Verificationbackend-phase-6 built nurse_verifications + the admin review queue (ListVerificationQueue, the guarded is_verified flip). The backoffice surfaces that queue.
  • Cross-cutting seamsbackend-phase-0 introduced IFieldEncryptor (used here for partner_centers.settlement_iban), ICacheService, IDateTimeProvider, and INotificationDispatcher; backend-phase-3 introduced IBankAccountOwnershipVerifier (IBAN ownership — reused if you verify a center's settlement IBAN). Reuse these; do not redefine them.

DEFERRED — modeled-but-inactive, do NOT build/migrate in this phase (they are pure additive migrations for future features and must stay unreferenced by launch flows): organizations, organization_nurses (the future employer model — distinct from partner_centers, the launch sponsor), fraud_flags (ML output; rule-based support_alerts fraud_signal covers it manually), recurring_booking_schedules (booking_sessions already meets the concrete multi-day need). See product/data-model/13-partner-centers-and-future.md.

2. Required reading (do this first)

  • ../_shared/agent-operating-rules.md and ../_shared/backend-conventions-checklist.md.
  • Product — business rules (source of truth):
    • product/business/12-messaging-and-emergencies.md — the no-direct-channel rule, the auto-created booking-coordination ticket, internal notes, tickets as the mandatory anchor for admin refunds, and the on-site emergency playbook (call the surfaced emergency contact, then open a ticket — operational, not a schema feature).
    • product/business/14-notifications-and-admin.md — the RBAC scopes that gate verify/refund/payout/moderate, the append-only audit requirement, the support-alert console, and the holiday-aware backoffice reasoning.
    • product/business/13-tax-invoicing-and-legal.md — the seller split (platform invoices its commission only), the partner licensed-center launch vehicle, merchant-of-record resolution, and why operating outside a licensed vehicle is the real legal risk.
  • Product — data model (source of truth):
  • Prior contracts you consume / extend:
    • backend-phase-11's contract dev/contracts/domains/payments-refunds.md (or equivalent) for the refunds.ticket_id link and invoices.issuing_entity_type you resolve.
    • backend-phase-1's handoff + contract for the RaiseSupportAlert signature, the support_alerts shape, the notifications write, RBAC scopes, and the typed config accessor.
    • backend-phase-6 / backend-phase-13 / backend-phase-14 handoffs for the verification-queue, payout-dashboard, and moderation-queue read models the backoffice surfaces.
  • Code to mirror (existing patterns): an existing feature folder under Baya.Application/Features/<Area>/{Commands|Queries}/<Name>/ (request record + internal sealed handler + OperationResult — never throw), an IEntityTypeConfiguration<T> under Persistence/Configuration/<Area>Config/, a controller under Baya.Web.Api/Controllers/V1/ (sealed, BaseController, inject ISender, [controller]/[action] snake_case tokens, base.OperationResult(...), narrowest authorize policy), how prior phases call IFieldEncryptor for encrypted columns (b3 IBAN, b9 care instructions), and how b14 raised a support_alert (you build the worklist that resolves them).
  • Contract conventions: ../../contracts/conventions/api-conventions.md (envelope, routes, status codes, pagination) and money-and-types.md (the merchant-of-record / settlement fields touch money — keep IRR BIGINT, no floats, and follow the id/enum/timestamp format rules).

3. Scope — build this

A vertical slice per capability: entity + EF config + migration → command/query handler(s) → controller endpoint → contract. Everything async with CancellationToken; reads are AsNoTracking() + .Select() projection + pagination; writes go through IUnitOfWork with a single CommitAsync. Admin endpoints are RBAC-gated and audited (the SaveChanges interceptor writes audit_logs on every state change).

3.1 Entities, configs & migration

Add these tables as a single additive EF Core migration. One IEntityTypeConfiguration<T> per entity, in Persistence/Configuration/MessagingConfig/ and Persistence/Configuration/PartnerCentersConfig/.

  • tickets — root of all post-booking communication.
    • Columns: id (BIGINT PK), reference_code (NVARCHAR, UNIQUE, human-facing support id — stable once minted, quoted to users), subject (NVARCHAR, nullable), status (enum: open | closed; default open), category/type (enum, e.g. coordination | support | refund | emergency), booking_id (FK → bookings, nullable — optional link), refund_id (FK → refunds, nullable — optional link), opened_by_id (FK → users), closed_at (datetimeoffset, nullable), closed_by_id (FK → users, nullable), plus audit fields (CreatedAt/ModifiedAt/CreatedById/ModifiedById) and soft-delete.
    • Indexes: unique on reference_code; index on status; index on booking_id and refund_id for the "tickets for this booking/refund" lookups; the admin list filters by (status, CreatedAt).
    • Soft-delete query filter (!IsDeleted).
  • ticket_participants — who is on a thread.
    • Columns: id (BIGINT PK), ticket_id (FK → tickets), user_id (FK → users), role_on_ticket (enum: customer | nurse | admin, nullable/derived), added_by_id (FK → users, nullable), removed_at (datetimeoffset, nullable — soft-remove, or hard delete per your soft-delete convention), plus audit.
    • UNIQUE (ticket_id, user_id) — the authoritative backstop against adding a user twice.
    • Index on (user_id, ticket_id) for ListMyTickets.
  • ticket_messages — individual messages.
    • Columns: id (BIGINT PK), ticket_id (FK → tickets), sender_id (FK → users), body (NVARCHAR), is_internal (BIT, default 0) — admin-only note; the hard visibility boundary, sent_at (datetimeoffset), plus audit and soft-delete.
    • Index on (ticket_id, sent_at) for the thread read.
  • partner_centers — the licensed sponsor center (fields from product/data-model/13-partner-centers-and-future.md, exact):
    • id (BIGINT PK), name (NVARCHAR(300)), legal_entity_type (NVARCHAR(30)), moh_establishment_permit_no (NVARCHAR(100) — پروانه تأسیس), technical_director_nurse_user_id (BIGINT FK → users, NULL — مسئول فنی), technical_director_license_no (NVARCHAR(100), NULL), enamad_code (NVARCHAR(100), NULL — نماد اعتماد الکترونیکی), settlement_iban (NVARCHAR(34), encrypted via IFieldEncryptor, NULL) — only when merchant-of-record, is_merchant_of_record (BIT), commission_rate (DECIMAL(5,4), NULL — the center's cut, separate from platform_fee_rate), admin_user_id (BIGINT FK → users — the center's dashboard account), is_active (BIT), verified_at (datetimeoffset, nullable), plus audit + soft-delete.
    • Relations: 1:N → nurse_profiles (sponsors, via nurse_profiles.partner_center_id), bookings (legally covered by), invoices (issuer). Index on is_active; index on admin_user_id for the center portal scope.
  • nurse_profiles.partner_center_id — FK → partner_centers, NULL (NULL once Balinyaar holds its own permit). Add to the b3 entity in place; note it in your report.

Do NOT create organizations, organization_nurses, fraud_flags, or recurring_booking_schedules in this phase. They are DEFERRED and must stay unreferenced. (See §1.)

3.2 Messaging — commands & queries

Feature folder Baya.Application/Features/Messaging/.

  • OpenTicketCommand (Commands/OpenTicket/) — create a ticket, mint a unique stable reference_code (e.g. a collision-checked short code; the UNIQUE index is the backstop), attach the optional booking_id/refund_id (handle null — both links are optional), set category, and add the opener as the first ticket_participant. Used by: the customer/nurse "Contact support" flow, the refund flow (b11 anchors its refunds.ticket_id here), and LogEmergencyTicket (below).
    • FluentValidation: subject/body length bounds; if booking_id/refund_id supplied, they must exist and the opener must be a party to them (tenancy).
  • AutoCreateCoordinationTicketCommand (Commands/AutoCreateCoordinationTicket/) — on booking confirmation (invoked by the b9 confirmation flow, or by a domain hook), auto-create a category=coordination ticket linked to the booking_id and add nurse + customer as participants. One coordination ticket per booking (idempotent — re-confirmation must not create a second).
  • PostMessageCommand (Commands/PostMessage/) — a participant appends a ticket_message. Admins may set is_internal=true; a non-admin caller can never set is_internal and can never post to a closed ticket. Guard: only ticket participants (or admins) may post. Optionally raise an in-app notification to the other (non-internal) participants via INotificationDispatcher (reuse — no push at MVP).
  • AddParticipantCommand / RemoveParticipantCommand (Commands/AddParticipant/, Commands/RemoveParticipant/) — admin (or the ticket owner, per policy) attaches/detaches a user. Enforce the UNIQUE(ticket_id, user_id): a duplicate add returns a clean OperationResult failure, never a raw DB exception. Admin may attach to any ticket for full read.
  • CloseTicketCommand / ReopenTicketCommand (Commands/CloseTicket/, Commands/ReopenTicket/) — status transitions openclosed/closedopen, stamping closed_at/closed_by_id; the owner trail comes from the audit interceptor.
  • LogEmergencyTicketCommand (Commands/LogEmergencyTicket/) — convenience path: after an emergency call, a nurse opens a category=emergency ticket and optionally raises a support_alerts row (reuse the b1 raise API). This is the operational side of the emergency playbook — it does not dial anyone and does not expose a phone number (the emergency contact is surfaced from encrypted booking_care_instructions, owned by b9; see §5).
  • GetTicketThreadQuery (Queries/GetTicketThread/) — returns the ordered messages for a ticket the caller may see. Role-aware at the QUERY layer: the USER view filters out every is_internal message; the ADMIN view returns all. This filter lives in the query projection, not the UI — an internal note must never appear in any user-facing payload (see §5). AsNoTracking() + .Select().
  • ListMyTicketsQuery (Queries/ListMyTickets/) — paginated tickets where the caller is a participant, filterable by status and searchable by reference_code, newest first.
  • ListTicketsForAdminQuery (Queries/ListTicketsForAdmin/) — admin, paginated global queue; filter by status/category, search by reference_code, optional booking_id/refund_id.

3.3 Partner centers — commands & queries

Feature folder Baya.Application/Features/PartnerCenters/.

  • CreatePartnerCenterCommand / UpdatePartnerCenterCommand (Commands/CreatePartnerCenter/, Commands/UpdatePartnerCenter/) — capture name, legal_entity_type, moh_establishment_permit_no, technical_director_nurse_user_id + technical_director_license_no, enamad_code, settlement_iban (encrypt via IFieldEncryptor before persisting — never plaintext), is_merchant_of_record, commission_rate (the center's cut), admin_user_id. Admin-only. If a settlement IBAN is provided and the center is merchant-of-record, optionally verify ownership via the reused IBankAccountOwnershipVerifier (b3) before activation.
    • FluentValidation: commission_rate in [0, 1); settlement_iban required when is_merchant_of_record=1; moh_establishment_permit_no non-empty.
  • VerifyPartnerCenterCommand (Commands/VerifyPartnerCenter/) — sets verified_at and is_active. At MVP the licensing check (eNamad / MoH establishment-permit) is a manual admin approval behind the new ILicenseVerificationService seam (§4) — the command records the decision; the seam call is the swap point for a real registry/API later.
  • SponsorNurseCommand (Commands/SponsorNurse/) — set nurse_profiles.partner_center_id to link a nurse to its sponsoring center (and a corresponding unlink/null path once Balinyaar holds its own permit). Admin-only. The center's dashboard account (admin_user_id) may sponsor within its own center.
  • GetCenterForBookingQuery (Queries/GetCenterForBooking/) — resolve which center legally covers a booking (via the booking's nurse → nurse_profiles.partner_center_id, falling back to platform when NULL). Returns the issuer/settlement decision: issuing_entity_type (platform | partner_center), the center id (when applicable), and whether the center is merchant-of-record. This is the resolver b11's GenerateCommissionInvoice calls to set the invoice issuer and the settlement target — invoices and settlement follow partner_centers, never a hardcoded platform.
  • ListPartnerCentersQuery / GetPartnerCenterByIdQuery (Queries/ListPartnerCenters/, Queries/GetPartnerCenterById/) — admin, paginated, with the sponsored-nurse count. The detail view never returns the plaintext or full settlement_iban — mask it (last 4) per the money-and-types masking convention.
  • Center dashboard read models (Queries/GetCenterDashboard/ and friends, scoped to the center's admin_user_id): list sponsored nurses, sponsored bookings (bookings whose nurse the center sponsors), and the settlement/invoice view (the center's invoices when it is merchant-of-record). These surface b13 payout / b11 invoice read models filtered to the center — they do not re-implement that logic.

b11 created refunds.ticket_id and the rule that every admin refund hangs off a ticket (dispute paper trail). This phase ships the ticket system that link targets. Wire it: when a refund is initiated (b11's InitiateRefund), ensure a ticket exists (open a category=refund ticket via OpenTicketCommand if the flow didn't already, or attach to the existing one) and set refunds.ticket_id. Do not move refund money logic into this phase — only provide/confirm the ticket the refund anchors to. If b11 already opens the ticket itself, this phase just provides the OpenTicketCommand it calls and confirms the link is non-null.

3.5 Support-alert worklist (build the console on b1's raise API)

Feature folder Baya.Application/Features/SupportAlerts/. b1 built the support_alerts table and the RaiseSupportAlert API (and b14/b6/b9/b13 are the producers). Build the admin worklist:

  • ListSupportAlertsQuery (Queries/ListSupportAlerts/) — admin/support, paginated, filter by type/status/owner. AsNoTracking() + .Select(). Internal-only — these must never appear in any user-facing endpoint or join.
  • AssignSupportAlertCommand (Commands/AssignSupportAlert/) — set the owner.
  • ResolveSupportAlertCommand (Commands/ResolveSupportAlert/) — set status + resolution trail.

3.6 Admin backoffice consolidation (surface, don't rebuild)

Expose the existing read models / commands under one admin RBAC surface (the b1 scopes). Do not re-implement any of the underlying logic — these endpoints delegate to handlers built in prior phases:

Backoffice area Surfaces (built in) RBAC scope
Verification review queue ListVerificationQueue + approve/reject (b6) admin (verify)
Refund tooling InitiateRefund/ApproveRefund/RejectRefund (b11) finance (refund)
Payout dashboard ListPayoutBatches/GetPayoutBatch (b13) finance (payout)
Review moderation queue GetReviewModerationQueue + ModerateReview (b14) moderator
Config / holiday / audit config CRUD, holiday calendar, audit-log viewer (b1) super_admin/admin
Support-alert worklist §3.5 (this phase) support/admin
Tickets (admin) §3.2 admin queries (this phase) support/admin
Partner centers §3.3 (this phase) admin/super_admin

Add the audit-log viewer query if b1 did not already expose it: ListAuditLogsQuery (Queries/ListAuditLogs/) — read-only, paginated, filter by entity_type/entity_id/actor/date. (If b1 already shipped it, reuse it and note so — do not duplicate.)

3.7 REST endpoints

Controllers under Baya.Web.Api/Controllers/V1/ (sealed, BaseController, inject ISender, [controller]/[action] snake_case tokens, base.OperationResult(...), narrowest authorize policy). Admin endpoints are internal-only (admin RBAC) and audited.

Verb & route Maps to Auth
POST /v1/tickets OpenTicketCommand authenticated
POST /v1/tickets/{id}/messages PostMessageCommand participant (admin may set internal)
POST /v1/tickets/{id}/participants AddParticipantCommand admin / owner
DELETE /v1/tickets/{id}/participants/{user_id} RemoveParticipantCommand admin / owner
POST /v1/tickets/{id}/close CloseTicketCommand participant / admin
POST /v1/tickets/{id}/reopen ReopenTicketCommand participant / admin
POST /v1/tickets/emergency LogEmergencyTicketCommand nurse (assigned)
GET /v1/tickets ListMyTicketsQuery authenticated (own)
GET /v1/tickets/{id} GetTicketThreadQuery (user view: internal stripped) participant
GET /v1/admin/tickets ListTicketsForAdminQuery support/admin
GET /v1/admin/tickets/{id} GetTicketThreadQuery (admin view: all) support/admin
POST /v1/admin/partner-centers CreatePartnerCenterCommand admin/super_admin
PATCH /v1/admin/partner-centers/{id} UpdatePartnerCenterCommand admin/super_admin
POST /v1/admin/partner-centers/{id}/verify VerifyPartnerCenterCommand admin/super_admin
POST /v1/admin/partner-centers/{id}/sponsor-nurse SponsorNurseCommand admin/super_admin
GET /v1/admin/partner-centers ListPartnerCentersQuery admin/super_admin
GET /v1/admin/partner-centers/{id} GetPartnerCenterByIdQuery (IBAN masked) admin/super_admin
GET /v1/centers/{id}/dashboard GetCenterDashboardQuery center admin_user_id
GET /v1/internal/bookings/{booking_id}/center GetCenterForBookingQuery internal/admin
GET /v1/admin/support-alerts ListSupportAlertsQuery support/admin
PATCH /v1/admin/support-alerts/{id}/assign AssignSupportAlertCommand support/admin
PATCH /v1/admin/support-alerts/{id}/resolve ResolveSupportAlertCommand support/admin
GET /v1/admin/audit-logs ListAuditLogsQuery super_admin/admin

Verification-queue, refund, payout-dashboard, moderation-queue, and config/holiday endpoints are the ones their own phases already published — surface them under the admin route group with the RBAC scope in §3.6; do not redefine their handlers.

3.8 Out of scope (DEFERRED — do not build/migrate)

  • organizations / organization_nurses (future employer model) — (DEFERRED); distinct from partner_centers. See product/data-model/13-partner-centers-and-future.md.
  • fraud_flags (ML fraud output) — (DEFERRED); rule-based support_alerts fraud_signal covers it.
  • recurring_booking_schedules (RFC-5545 recurrence) — (DEFERRED); booking_sessions meets the need.
  • Real-time chat / SLA-bearing incidents entity / push delivery(DEFERRED); emergencies stay an operational playbook (product/business/12-messaging-and-emergencies.md (c)).
  • Real eNamad / MoH establishment-permit / مودیان integrations — mocked behind seams (this phase introduces ILicenseVerificationService; IMoadianClient belongs to b11).

4. Mocks & seams in this phase

Seam Owner Mock behaviour Registry
ILicenseVerificationService (eNamad / MoH establishment-permit) — INTRODUCED here this phase e.g. Task<LicenseVerdict> VerifyEstablishmentPermitAsync(string permitNo, CancellationToken) and VerifyENamadAsync(string enamadCode, …) returning a verdict (Valid/Invalid/NeedsManualReview + reason). Mock = manual admin approve (returns NeedsManualReview, so VerifyPartnerCenter records the human decision). No external call. Selection by config/registration. add row
IFieldEncryptorREUSE from b0 b0 local symmetric key; Encrypt/Decrypt. Used for partner_centers.settlement_iban. Do not redefine. reuse
IBankAccountOwnershipVerifierREUSE from b3 b3 IBAN ownership inquiry; optionally used to verify a center's settlement_iban when merchant-of-record. reuse
INotificationDispatcherREUSE from b0/b1 b0/b1 in-app write (no push at MVP). New-ticket-message alerts to the other participant. reuse
RaiseSupportAlert (on support_alerts) — REUSE from b1 b1 inserts an internal alert row; used by LogEmergencyTicket. The worklist that resolves them is built here. reuse

Register ILicenseVerificationService (interface in Application/Contracts/, mock impl in Infrastructure) via a ServiceConfiguration/ extension — never inline in Program.cs. Record it in mocks-registry.md with: seam, file, what's faked (manual approval at MVP), config keys (auto-approve toggle), how to make it real (point the methods at a real eNamad / MoH registry/API without touching VerifyPartnerCenter), status 🟡. There is no telephony/VoIP seam — the emergency call is out-of-platform by design (a tel: link in the UI); do not build a calling seam.

5. Critical rules you must not get wrong

  • No direct nurse↔customer channel. All post-booking communication is ticket-mediated and admin-readable. Never expose the family's phone number to the nurse or vice-versa, and never build a side-channel. The emergency-contact playbook is the only sanctioned bypass — it is operational, not a contact-sharing feature: the emergency contact lives in encrypted booking_care_instructions (owned by b9, surfaced post-confirmation to the assigned nurse only via the two-stage clinical disclosure gate); this phase's LogEmergencyTicket records the aftermath (a ticket, optionally an alert), it does not widen that disclosure. Keep it scoped to emergencies; do not turn it into a general contact directory.
  • is_internal is a HARD visibility boundary, enforced at the QUERY layer. Admin-only internal notes must never leak into any user-facing query, payload, or join. GetTicketThreadQuery's user view strips every is_internal message in the projection; only the admin view returns them. A non-admin caller can never set is_internal on PostMessage and can never read one. This is enforced in the query/serialization layer, not just the UI.
  • reference_code is unique + stable. It is quoted to users; mint it once (collision-checked), back it with a UNIQUE index, and never mutate it.
  • Ticket↔booking/refund links are optional — handle null. booking_id and refund_id are both nullable; every read/write path must tolerate a ticket with neither link (a pure support ticket).
  • Authorization/tenancy on every ticket read and write. Only ticket participants (and admins) can read or post; enforce on both. Admin may attach to any ticket for full read.
  • UNIQUE(ticket_id, user_id) on participants — a duplicate add returns a clean OperationResult failure, never a raw DB exception; the unique index is the authoritative backstop.
  • Merchant-of-record correctness. If partner_centers.is_merchant_of_record = 1, the CENTER (not Balinyaar, not the nurse) is the legal invoice issuer and the settlement target — invoices and settlement follow partner_centers, not a hardcoded platform. GetCenterForBooking is the single resolver b11's invoice issuance calls; it must return the center as issuer/settlement target when the booking's nurse is sponsored by a merchant-of-record center, and platform otherwise. This is a legal/tax correctness issue, not cosmetic.
  • partner_centersorganizations. Keep the launch licensing sponsor (partner_centers) distinct from the future employer (organizations). Do not conflate "sponsor for legality" with "employer," and do not repurpose organizations for launch (it stays DEFERRED/inactive).
  • commission_rate (the center's cut) is separate from platform_fee_rate. Money math must account for an additional center cut where present; never collapse the two. Money is IRR BIGINT — no floats, anywhere; the three booking amounts always satisfy gross = commission + payout; the ledger_entries on the money path stay append-only and balanced (Σdebit = Σcredit per transaction_group_id); webhook handling is idempotent; payouts are dispute-window gated and one payout per booking (nurse_payout_booking_links.booking_id UNIQUE). This phase does not post ledger entries, but the merchant-of-record / settlement-target it resolves feeds those invariants — get the issuer/target right or the downstream money is wrong.
  • settlement_iban is encrypted PII. Encrypt via IFieldEncryptor before persisting; never store, log, or project it in plaintext; mask it (last 4) in any read response.
  • Deferred tables stay inactive/unreferenced. organizations, organization_nurses, fraud_flags, recurring_booking_schedules are not created or referenced — adding them later must remain a pure additive migration.
  • Admin endpoints are internal-only, RBAC-scoped, and audited. Authorize every admin command/query with the narrowest fitting b1 scope (support cannot pay out, moderator cannot refund, etc.); every state-changing admin op writes an append-only audit_logs row via the interceptor — never mutate or delete prior audit rows. support_alerts are internal-only and must never surface in any user-facing response.

6. Definition of Done

The shared definition-of-done.md, plus:

  • tickets, ticket_participants, ticket_messages, partner_centers exist via one additive migration with the constraints in §3.1 (unique reference_code, UNIQUE(ticket_id, user_id), is_internal column, settlement_iban encrypted); nurse_profiles.partner_center_id FK added; the four DEFERRED tables are not created.
  • OpenTicket/AutoCreateCoordinationTicket/PostMessage/AddParticipant/RemoveParticipant/ CloseTicket/ReopenTicket/LogEmergencyTicket/GetTicketThread/ListMyTickets/ListTicketsForAdmin are implemented as CQRS features with validators and the §3.7 endpoints, returning the standard OperationResult envelope.
  • GetTicketThread strips is_internal in the user view and returns it in the admin view — proven by a test (an internal message is hidden from the user thread, shown in the admin thread).
  • CreatePartnerCenter/UpdatePartnerCenter/VerifyPartnerCenter/SponsorNurse/GetCenterForBooking/ ListPartnerCenters/GetPartnerCenterById + the center dashboard are implemented; settlement_iban is encrypted at rest and masked in reads; SponsorNurse sets nurse_profiles.partner_center_id; GetCenterForBooking returns the merchant-of-record issuer/settlement target.
  • The refund↔ticket link is wired (§3.4): every admin refund has a non-null ticket_id.
  • The support-alert worklist (ListSupportAlerts/AssignSupportAlert/ResolveSupportAlert) is built on b1's raise API; the admin backoffice surfaces verification/refund/payout/moderation/config/holiday/ audit under RBAC scopes without rebuilding their handlers.
  • ILicenseVerificationService is introduced behind a DI seam with a manual-approve mock and a registry row.
  • dotnet build Baya.sln zero new warnings; dotnet test Baya.sln green including this phase's tests.
  • The contract dev/contracts/domains/messaging-notifications-admin.md is written and the swagger.json snapshot is refreshed; the server/CLAUDE.md Project map notes the new feature areas (Messaging, PartnerCenters, SupportAlerts, the admin backoffice route group), the four new tables + the nurse_profiles.partner_center_id FK, and the ILicenseVerificationService seam.

7. How to test (what a human can verify after this phase)

Run the API (dotnet run --project src/API/Baya.Web.Api/...) against a reachable SQL Server; use Swagger or curl. Expected results below become the "what can be tested" section of your report.

  1. Open a ticket + post a message. POST /v1/tickets (no booking_id/refund_id) → 200, a ticket with a unique reference_code, the opener added as a participant. POST /v1/tickets/{id}/messages with a normal body → 200, message appears in the thread.
  2. Internal note is hidden in the user view, shown in the admin view. As an admin, POST /v1/tickets/{id}/messages with is_internal: true200. GET /v1/tickets/{id} (user view) → the internal message is absent. GET /v1/admin/tickets/{id} (admin view) → the internal message is present. A non-admin attempt to set is_internal: true → rejected.
  3. Add/remove participant with uniqueness. POST /v1/tickets/{id}/participants adds a user → 200; adding the same user again → an OperationResult failure (not a 500) from the UNIQUE(ticket_id, user_id). DELETE /v1/tickets/{id}/participants/{user_id} removes them.
  4. Create a partner center + sponsor a nurse. POST /v1/admin/partner-centers with is_merchant_of_record: true and a settlement_iban200; GET /v1/admin/partner-centers/{id} shows the IBAN masked (last 4), never plaintext. POST /v1/admin/partner-centers/{id}/sponsor-nursenurse_profiles.partner_center_id is set for that nurse.
  5. GetCenterForBooking returns the merchant-of-record. GET /v1/internal/bookings/{booking_id}/center for a booking whose nurse is sponsored by the merchant-of-record center → issuing_entity_type: partner_center + the center id; for a booking whose nurse is unsponsored → platform.
  6. Refund anchors to a ticket. Initiate a refund (b11 flow) → the resulting refunds.ticket_id is non-null and the ticket is a category=refund ticket.
  7. Admin worklists list the pending items. GET /v1/admin/support-alerts lists the alerts b14/b6/b9/b13 raised (internal-only — never in a user response); PATCH …/assign then …/resolve updates owner + status. The verification queue, refunds, payout dashboard, and moderation queue are reachable under their admin routes with the correct RBAC scope (a support token is denied the payout route → 403).
  8. Audit trail. Any admin state change (e.g. VerifyPartnerCenter, ResolveSupportAlert) writes an audit_logs row visible via GET /v1/admin/audit-logs; prior rows are never mutated.

8. Hand off & document (close the phase)

  • Docs to update (same change): server/CLAUDE.md Project map — add the Features/Messaging, Features/PartnerCenters, Features/SupportAlerts areas, the admin backoffice route group, the four new tables + their config folders, the nurse_profiles.partner_center_id FK, and the ILicenseVerificationService seam (where it's registered). If you discovered/decided any business rule not already in the product docs, reflect it in product/business/12-messaging-and-emergencies.md, product/business/14-notifications-and-admin.md, product/business/13-tax-invoicing-and-legal.md, or product/data-model/13-partner-centers-and-future.md (no invented rules — record decisions, and regenerate the HTML view per product/CLAUDE.md if you touched Markdown).
  • Contract to write: publish dev/contracts/domains/messaging-notifications-admin.md (the §3.7 routes, request/response shapes, the ticket.status/category and support_alerts.status enums, the is_internal user-vs-admin view rule, the partner_centers shape with the masked settlement_iban, the GetCenterForBooking issuer-resolution response, the RBAC scope per admin route, status codes, and examples) per ../../contracts/conventions/api-conventions.md, and refresh the swagger.json snapshot per ../../contracts/openapi/README.md so frontend-phase-14-b15 (messaging/notifications) and frontend-phase-15-b15 (admin + partner consoles) can derive their types (they do not guess shapes).
  • Handoff & report: write shared-working-context/backend/handoff/after-backend-phase-15.md (tickets, partner centers, the support-alert worklist, and the consolidated admin backoffice are live; what f14/f15 can now build; the is_internal user-vs-admin rule and the RBAC scope per route the frontend must respect; what's mocked — the ILicenseVerificationService manual-approve seam), append your phase summary to shared-working-context/backend/STATUS.md, write reports/backend-phase-15-report.md (what was built, what is now testable and exactly how — the §7 steps — what is mocked + how to make it real, contracts produced, follow-ups), and update reports/mocks-registry.md with the ILicenseVerificationService row → 🟡.
  • Memory: save a project-type memory note for the non-obvious decisions here — the is_internal hard-boundary at the query layer (and the user-vs-admin GetTicketThread views), the merchant-of-record resolution via GetCenterForBooking (issuer + settlement target follow partner_centers, not the platform), the partner_centersorganizations distinction, and the list of DEFERRED-inactive tables — with a one-line MEMORY.md pointer. This is the last backend phase; the memory note should also record that the backend chain is complete.