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:
- 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.
- 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. - 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 & profiles — backend-phase-3 built
nurse_profiles,customer_profiles,patients, andnurse_bank_accounts.nurse_profiles.partner_center_idis 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 builtusers(+ gender, national_id) and sessions; backend-phase-1 built the admin RBAC (roles/user_roles, scopessuper_admin/admin/support/finance/moderator) you authorize every admin endpoint against. - Platform signals, config, audit, holidays — backend-phase-1 built
notifications(typed in-app write + list/unread-count/mark-read + 90-day retention),support_alerts(the internal-only worklist table + theRaiseSupportAlertraise API),platform_configs(typed cached accessor — incl.platform_fee_rate,vat_rate),audit_logs(append-only, written by the SaveChanges interceptor on sensitive entities), andiranian_holidays. Reuse all of these. This phase consumes them — it builds thesupport_alertsassign/resolve/list worklist and the audit viewer, it does not redefine the tables. - Refunds, invoices & the ticket-link hook — backend-phase-11 built
refunds(admin-only, ticket-linked, fee/payout decomposition, channel-aware),nurse_clawbacks, andinvoices(VAT on commission,issuing_entity_type). b11 createdrefunds.ticket_idand the expectation that a ticket anchors every admin refund; this phase ships the ticket system that link points at and wiresOpenTicketinto the refund flow (§3.2). Theinvoices.issuing_entity_type/ center-issuer resolution thatpartner_centersdrives is consumed by b11'sGenerateCommissionInvoice— you provideGetCenterForBooking(§3.4) as the resolver. - Payouts — backend-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 moderation — backend-phase-14 built
GetReviewModerationQueueQuery+ModerateReviewCommandand raises low-ratingsupport_alerts. The backoffice surfaces the moderation queue and the alerts it raises. - Verification — backend-phase-6 built
nurse_verifications+ the admin review queue (ListVerificationQueue, the guardedis_verifiedflip). The backoffice surfaces that queue. - Cross-cutting seams — backend-phase-0 introduced
IFieldEncryptor(used here forpartner_centers.settlement_iban),ICacheService,IDateTimeProvider, andINotificationDispatcher; backend-phase-3 introducedIBankAccountOwnershipVerifier(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 frompartner_centers, the launch sponsor),fraud_flags(ML output; rule-basedsupport_alertsfraud_signalcovers it manually),recurring_booking_schedules(booking_sessionsalready meets the concrete multi-day need). Seeproduct/data-model/13-partner-centers-and-future.md.
2. Required reading (do this first)
../_shared/agent-operating-rules.mdand../_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):
product/data-model/09-messaging.md—tickets/ticket_participants/ticket_messages,is_internal,reference_code,UNIQUE(ticket_id, user_id), optionalbookings/refundslinks.product/data-model/13-partner-centers-and-future.md— the exactpartner_centersfield list (read the "Why" notes — they encode the merchant-of-record and partner-vs-organization guards), and the four DEFERRED tables that stay inactive.
- Prior contracts you consume / extend:
- backend-phase-11's contract
dev/contracts/domains/payments-refunds.md(or equivalent) for therefunds.ticket_idlink andinvoices.issuing_entity_typeyou resolve. - backend-phase-1's handoff + contract for the
RaiseSupportAlertsignature, thesupport_alertsshape, thenotificationswrite, 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.
- backend-phase-11's contract
- Code to mirror (existing patterns): an existing feature folder under
Baya.Application/Features/<Area>/{Commands|Queries}/<Name>/(requestrecord+internal sealedhandler +OperationResult— never throw), anIEntityTypeConfiguration<T>underPersistence/Configuration/<Area>Config/, a controller underBaya.Web.Api/Controllers/V1/(sealed,BaseController, injectISender,[controller]/[action]snake_case tokens,base.OperationResult(...), narrowest authorize policy), how prior phases callIFieldEncryptorfor encrypted columns (b3 IBAN, b9 care instructions), and how b14 raised asupport_alert(you build the worklist that resolves them). - Contract conventions:
../../contracts/conventions/api-conventions.md(envelope, routes, status codes, pagination) andmoney-and-types.md(the merchant-of-record / settlement fields touch money — keep IRRBIGINT, 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; defaultopen),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 onstatus; index onbooking_idandrefund_idfor the "tickets for this booking/refund" lookups; the admin list filters by(status, CreatedAt). - Soft-delete query filter (
!IsDeleted).
- Columns:
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)forListMyTickets.
- Columns:
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.
- Columns:
partner_centers— the licensed sponsor center (fields fromproduct/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 viaIFieldEncryptor, NULL) — only when merchant-of-record,is_merchant_of_record(BIT),commission_rate(DECIMAL(5,4), NULL — the center's cut, separate fromplatform_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, vianurse_profiles.partner_center_id),bookings(legally covered by),invoices(issuer). Index onis_active; index onadmin_user_idfor 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, orrecurring_booking_schedulesin 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 stablereference_code(e.g. a collision-checked short code; the UNIQUE index is the backstop), attach the optionalbooking_id/refund_id(handle null — both links are optional), setcategory, and add the opener as the firstticket_participant. Used by: the customer/nurse "Contact support" flow, the refund flow (b11 anchors itsrefunds.ticket_idhere), andLogEmergencyTicket(below).- FluentValidation: subject/body length bounds; if
booking_id/refund_idsupplied, they must exist and the opener must be a party to them (tenancy).
- FluentValidation: subject/body length bounds; if
AutoCreateCoordinationTicketCommand(Commands/AutoCreateCoordinationTicket/) — on booking confirmation (invoked by the b9 confirmation flow, or by a domain hook), auto-create acategory=coordinationticket linked to thebooking_idand add nurse + customer as participants. One coordination ticket per booking (idempotent — re-confirmation must not create a second).PostMessageCommand(Commands/PostMessage/) — a participant appends aticket_message. Admins may setis_internal=true; a non-admin caller can never setis_internaland 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 viaINotificationDispatcher(reuse — no push at MVP).AddParticipantCommand/RemoveParticipantCommand(Commands/AddParticipant/,Commands/RemoveParticipant/) — admin (or the ticket owner, per policy) attaches/detaches a user. Enforce theUNIQUE(ticket_id, user_id): a duplicate add returns a cleanOperationResultfailure, never a raw DB exception. Admin may attach to any ticket for full read.CloseTicketCommand/ReopenTicketCommand(Commands/CloseTicket/,Commands/ReopenTicket/) — status transitionsopen→closed/closed→open, stampingclosed_at/closed_by_id; the owner trail comes from the audit interceptor.LogEmergencyTicketCommand(Commands/LogEmergencyTicket/) — convenience path: after an emergency call, a nurse opens acategory=emergencyticket and optionally raises asupport_alertsrow (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 encryptedbooking_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 everyis_internalmessage; 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 bystatusand searchable byreference_code, newest first.ListTicketsForAdminQuery(Queries/ListTicketsForAdmin/) — admin, paginated global queue; filter bystatus/category, search byreference_code, optionalbooking_id/refund_id.
3.3 Partner centers — commands & queries
Feature folder Baya.Application/Features/PartnerCenters/.
CreatePartnerCenterCommand/UpdatePartnerCenterCommand(Commands/CreatePartnerCenter/,Commands/UpdatePartnerCenter/) — capturename,legal_entity_type,moh_establishment_permit_no,technical_director_nurse_user_id+technical_director_license_no,enamad_code,settlement_iban(encrypt viaIFieldEncryptorbefore 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 reusedIBankAccountOwnershipVerifier(b3) before activation.- FluentValidation:
commission_ratein[0, 1);settlement_ibanrequired whenis_merchant_of_record=1;moh_establishment_permit_nonon-empty.
- FluentValidation:
VerifyPartnerCenterCommand(Commands/VerifyPartnerCenter/) — setsverified_atandis_active. At MVP the licensing check (eNamad / MoH establishment-permit) is a manual admin approval behind the newILicenseVerificationServiceseam (§4) — the command records the decision; the seam call is the swap point for a real registry/API later.SponsorNurseCommand(Commands/SponsorNurse/) — setnurse_profiles.partner_center_idto link a nurse to its sponsoring center (and a corresponding unlink/nullpath 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 whenNULL). 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'sGenerateCommissionInvoicecalls to set the invoice issuer and the settlement target — invoices and settlement followpartner_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 fullsettlement_iban— mask it (last 4) per the money-and-types masking convention.- Center dashboard read models (
Queries/GetCenterDashboard/and friends, scoped to the center'sadmin_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.
3.4 Refund↔ticket link (wire b11 to the ticket system)
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 bytype/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 frompartner_centers. Seeproduct/data-model/13-partner-centers-and-future.md.fraud_flags(ML fraud output) — (DEFERRED); rule-basedsupport_alertsfraud_signalcovers it.recurring_booking_schedules(RFC-5545 recurrence) — (DEFERRED);booking_sessionsmeets the need.- Real-time chat / SLA-bearing
incidentsentity / 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;IMoadianClientbelongs 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 |
IFieldEncryptor — REUSE from b0 |
b0 | local symmetric key; Encrypt/Decrypt. Used for partner_centers.settlement_iban. Do not redefine. |
reuse |
IBankAccountOwnershipVerifier — REUSE from b3 |
b3 | IBAN ownership inquiry; optionally used to verify a center's settlement_iban when merchant-of-record. |
reuse |
INotificationDispatcher — REUSE 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'sLogEmergencyTicketrecords 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_internalis 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 everyis_internalmessage in the projection; only the admin view returns them. A non-admin caller can never setis_internalonPostMessageand can never read one. This is enforced in the query/serialization layer, not just the UI.reference_codeis 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_idandrefund_idare 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 cleanOperationResultfailure, 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 followpartner_centers, not a hardcoded platform.GetCenterForBookingis 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, andplatformotherwise. This is a legal/tax correctness issue, not cosmetic. partner_centers≠organizations. Keep the launch licensing sponsor (partner_centers) distinct from the future employer (organizations). Do not conflate "sponsor for legality" with "employer," and do not repurposeorganizationsfor launch (it stays DEFERRED/inactive).commission_rate(the center's cut) is separate fromplatform_fee_rate. Money math must account for an additional center cut where present; never collapse the two. Money is IRRBIGINT— no floats, anywhere; the three booking amounts always satisfygross = commission + payout; theledger_entrieson the money path stay append-only and balanced (Σdebit = Σcredit pertransaction_group_id); webhook handling is idempotent; payouts are dispute-window gated and one payout per booking (nurse_payout_booking_links.booking_idUNIQUE). 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_ibanis encrypted PII. Encrypt viaIFieldEncryptorbefore 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_schedulesare 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 (
supportcannot pay out,moderatorcannot refund, etc.); every state-changing admin op writes an append-onlyaudit_logsrow via the interceptor — never mutate or delete prior audit rows.support_alertsare 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_centersexist via one additive migration with the constraints in §3.1 (uniquereference_code,UNIQUE(ticket_id, user_id),is_internalcolumn,settlement_ibanencrypted);nurse_profiles.partner_center_idFK added; the four DEFERRED tables are not created.OpenTicket/AutoCreateCoordinationTicket/PostMessage/AddParticipant/RemoveParticipant/CloseTicket/ReopenTicket/LogEmergencyTicket/GetTicketThread/ListMyTickets/ListTicketsForAdminare implemented as CQRS features with validators and the §3.7 endpoints, returning the standardOperationResultenvelope.GetTicketThreadstripsis_internalin 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_ibanis encrypted at rest and masked in reads;SponsorNursesetsnurse_profiles.partner_center_id;GetCenterForBookingreturns 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. ILicenseVerificationServiceis introduced behind a DI seam with a manual-approve mock and a registry row.dotnet build Baya.slnzero new warnings;dotnet test Baya.slngreen including this phase's tests.- The contract
dev/contracts/domains/messaging-notifications-admin.mdis written and theswagger.jsonsnapshot is refreshed; theserver/CLAUDE.mdProject map notes the new feature areas (Messaging,PartnerCenters,SupportAlerts, the admin backoffice route group), the four new tables + thenurse_profiles.partner_center_idFK, and theILicenseVerificationServiceseam.
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.
- Open a ticket + post a message.
POST /v1/tickets(nobooking_id/refund_id) →200, a ticket with a uniquereference_code, the opener added as a participant.POST /v1/tickets/{id}/messageswith a normal body →200, message appears in the thread. - Internal note is hidden in the user view, shown in the admin view. As an admin,
POST /v1/tickets/{id}/messageswithis_internal: true→200.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 setis_internal: true→ rejected. - Add/remove participant with uniqueness.
POST /v1/tickets/{id}/participantsadds a user →200; adding the same user again → anOperationResultfailure (not a 500) from theUNIQUE(ticket_id, user_id).DELETE /v1/tickets/{id}/participants/{user_id}removes them. - Create a partner center + sponsor a nurse.
POST /v1/admin/partner-centerswithis_merchant_of_record: trueand asettlement_iban→200;GET /v1/admin/partner-centers/{id}shows the IBAN masked (last 4), never plaintext.POST /v1/admin/partner-centers/{id}/sponsor-nurse→nurse_profiles.partner_center_idis set for that nurse. GetCenterForBookingreturns the merchant-of-record.GET /v1/internal/bookings/{booking_id}/centerfor 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.- Refund anchors to a ticket. Initiate a refund (b11 flow) → the resulting
refunds.ticket_idis non-null and the ticket is acategory=refundticket. - Admin worklists list the pending items.
GET /v1/admin/support-alertslists the alerts b14/b6/b9/b13 raised (internal-only — never in a user response);PATCH …/assignthen…/resolveupdates owner + status. The verification queue, refunds, payout dashboard, and moderation queue are reachable under their admin routes with the correct RBAC scope (asupporttoken is denied the payout route →403). - Audit trail. Any admin state change (e.g.
VerifyPartnerCenter,ResolveSupportAlert) writes anaudit_logsrow visible viaGET /v1/admin/audit-logs; prior rows are never mutated.
8. Hand off & document (close the phase)
- Docs to update (same change):
server/CLAUDE.mdProject map — add theFeatures/Messaging,Features/PartnerCenters,Features/SupportAlertsareas, the admin backoffice route group, the four new tables + their config folders, thenurse_profiles.partner_center_idFK, and theILicenseVerificationServiceseam (where it's registered). If you discovered/decided any business rule not already in the product docs, reflect it inproduct/business/12-messaging-and-emergencies.md,product/business/14-notifications-and-admin.md,product/business/13-tax-invoicing-and-legal.md, orproduct/data-model/13-partner-centers-and-future.md(no invented rules — record decisions, and regenerate the HTML view perproduct/CLAUDE.mdif you touched Markdown). - Contract to write: publish
dev/contracts/domains/messaging-notifications-admin.md(the §3.7 routes, request/response shapes, theticket.status/categoryandsupport_alerts.statusenums, theis_internaluser-vs-admin view rule, thepartner_centersshape with the maskedsettlement_iban, theGetCenterForBookingissuer-resolution response, the RBAC scope per admin route, status codes, and examples) per../../contracts/conventions/api-conventions.md, and refresh theswagger.jsonsnapshot per../../contracts/openapi/README.mdso 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; theis_internaluser-vs-admin rule and the RBAC scope per route the frontend must respect; what's mocked — theILicenseVerificationServicemanual-approve seam), append your phase summary toshared-working-context/backend/STATUS.md, writereports/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 updatereports/mocks-registry.mdwith theILicenseVerificationServicerow → 🟡. - Memory: save a
project-type memory note for the non-obvious decisions here — theis_internalhard-boundary at the query layer (and the user-vs-adminGetTicketThreadviews), the merchant-of-record resolution viaGetCenterForBooking(issuer + settlement target followpartner_centers, not the platform), thepartner_centers≠organizationsdistinction, and the list of DEFERRED-inactive tables — with a one-lineMEMORY.mdpointer. This is the last backend phase; the memory note should also record that the backend chain is complete.