Support tickets (SupportTicket + SupportTicketMessage lifecycle)
Scope
One model pair, one module, seven endpoints — the third leg of the client-facing lifecycle triad:
support-tickets/—SupportTicketsService(create/list/findById/assign/reply/resolve/close) + one controller.SupportTicketPrisma model — 14 fields, six-valuestatusenum, four-priority soft-enum (low/normal/high/urgent), six-category soft-enum (account,billing,referral,commission,technical,other),slaDueAtstamped at create from a per-priority in-code table.SupportTicketMessagemodel — 6 fields,internal/bothvisibility, no attachments, no edit history.
Sibling modules already audited: complaints (IRA §102 /
CPA §37 breach + SLA escalation job) and disputes (recently
shipped 2026-07-18, three phases + DisputeSlaEscalationJob).
Support-tickets is the least-hardened of the three; it exists
as a stand-alone CRUD lifecycle with none of the compliance
guardrails the other two have shipped.
Compliance envelope
- CPA 2022 §37 — consumer support notice + timely
resolution + reconsideration right. Timely resolution is
the SLA that
slaDueAtwas meant to enforce; nothing watches it. Reconsideration means theclosedstate must be reopenable. - IRA Insurance Act 2017 §129 — dispute-adjacent handling.
A support ticket that turns into a real dispute needs a
graduation path (
ticket → disputeorticket → complaint) with an audit link, so IRA inspections can trace the escalation chain. - DPPA 2019 §11 + §21 — the two free-text columns
(
SupportTicket.description5000 chars,SupportTicketMessage.body5000 chars) carry every kind of PII (national IDs quoted in the narrative, phone numbers, dispute-specific transaction references).RetentionPurgeJobdoesn't touch them. - AML Act 2013 §13 — when a support ticket resolution looks like "refund UGX 500k on transaction X" the raiser is the beneficiary of a value transfer; §13 CDD applies to the raiser at intake, not at resolution time.
- BOU cybersecurity §5.4 — throttle + audit trail on every mutation.
Current state (2026-07-18)
Module footprint
src/modules/support-tickets/support-tickets.module.ts— 327 lines. DTOs + service + controller + module in one file.- Prisma:
SupportTicket(14 fields, 4 indexes) +SupportTicketMessage(6 fields, 1 index). - No dedicated scheduled job; contrast the sibling
ComplaintSlaEscalationJob+DisputeSlaEscalationJob.
What works today
- Transition matrix is enforced:
ALLOWEDgates every status flip; out-of-order transitions refuse cleanly. - Per-priority SLA is stamped:
slaDueAtset at create from an in-code table (urgent=2h,high=8h,normal=24h,low=72h). - Read-time visibility filter: non-privileged callers
(client-portal / raiser) see only
visibility='both'messages onfindById. - Every mutation writes an audit event:
support.ticket.create,support.ticket.assign,support.ticket.reply,support.ticket.resolve,support.ticket.close.
Gaps
All 12 gaps closed. See shipped-notes below.
Release support-p1-20260718-1830. Closes gaps 1–4:
- gap 1 — Migration
20260718190000_support_tickets_phase1addsfirstResponseDueAt,firstResponseAt,firstResponseBreachedAt,resolutionDueAt,resolutionBreachedAt+ indexes.create()populates the due-columns fromsupport_tickets.first_response_sla_hourssupport_tickets.resolution_sla_hourspolicies. First ops-sidereply()stampsfirstResponseAt. NewSupportTicketSlaEscalationJobruns hourly, stamps breach columns idempotently, emitssupport_ticket.sla.first_response_breached/resolution_breached/sweep, and fans out to compliance officers viaRoleFanoutService.
- gap 2 —
SupportTicketsService.create()runsSanctionsService.screenForPayout('user', reporterUserId, ctx)when the category is in the newsupport_tickets.money_categoriespolicy (defaultcommission,billing). Block → refuse + emitsupport.ticket.create.sanctions_block+ stampmetadata.intakeScreeningId. Non-money categories skip the gate. - gap 3 — Migration adds
retentionScrubbedAtto bothsupport_ticketsandsupport_ticket_messages. Newdata_sharing.support_tickets_retention_dayspolicy (default 2555 = 7 y).RetentionPurgeJobscrubssubject/bodyon closed tickets past window + every childmessage.body+ emitssupport_ticket.retention.pii_scrubbed. - gap 4 —
SupportTicketsServicegains@Optional() NotificationDispatchServicenotifyRaiser()helper.assign/resolve/closedispatchsupport_ticket.<status>; ops-sidereplywithvisibility='both'dispatchessupport_ticket.reply. Best-effort — template lookup failures suppress so a missing template doesn't roll back the transition.
Locked in by src/modules/support-tickets/support-tickets-phase1.spec.ts
(6 cases). Test suite: 543/543 (80 suites).
Release support-p2-20260718-1848. Closes gaps 5–8:
- gap 5 — Migration
20260718200000_support_tickets_phase2addssubjectType(soft enumpolicy/claim/payment/referral/commission/dispute/complaint/other) +subjectId(nullable UUID) + compound index.CreateTicketDtoaccepts both;list()accepts filter bysubjectType+subjectIdfor the "all tickets about policy ABC" query. - gap 6 — New
POST /support/tickets/:id/reopenendpoint gated by existingSupportTicketResolvepermission.ALLOWEDnow allowsclosed → assigned.reopen()clearsclosedAt+resolvedAt, stampsmetadata.reopenedAt+reopenedReason, emitssupport.ticket.reopenaudit event with the reason, and notifies the raiser via the Phase-1 template pipeline. - gap 7 —
reply()refusesvisibility='internal'unless the caller holdsSupportTicketRead. Refused attempts emitsupport.ticket.reply.internal_deniedwith attempted-by + primary role. Reporter callers can only writevisibility='both'. - gap 8 — Migration adds
SupportTicketDocumentjoin table with a unique index on(ticketId, documentId).CreateTicketDto+ReplyTicketDtoacceptdocumentIds?: UUID[](max 20 per DTO). NewattachDocuments()helper enforces a per-ticket cap fromsupport_tickets.max_attachments_per_ticketpolicy (default 20) and silently deduplicates on the unique index.
Locked in by src/modules/support-tickets/support-tickets-phase2.spec.ts
(6 cases). Test suite: 549/549 (81 suites).
Release support-p3-20260718-1903. Closes gaps 9–12:
- gap 9 —
SupportTicketsServiceexposes idempotent wrappers (createIdempotent,assignIdempotent,replyIdempotent,resolveIdempotent,closeIdempotent,reopenIdempotent,graduateIdempotent) via sharedIdempotencyService. Controller threads theIdempotency-Keyheader through every mutation. - gap 10 —
@Throttleon every mutation: create 10/min, reply 30/min, lifecycle mutations 20/min, graduate 10/min. - gap 11 — New
POST /support/tickets/:id/graduateendpoint gated bySupportTicketResolve. DTO carriestargetType: 'dispute' | 'complaint'+reason(+ optionalcomplaintCategoryfor complaint targets). Fordispute: requires the ticket to carrysubjectType+subjectId(validated against the dispute subject enum); callsDisputesService.raise(). Forcomplaint: callsComplaintsService.file()with the mapped category + ticket subject + body. Both stampDispute.metadata.graduatedTo = { targetType, targetId, targetReference }, transition the ticket toclosedwith an auto-message, and emitsupport.ticket.graduatelinking source → target. - gap 12 —
AuditRedactorgains anENTITY_TYPE_PII_KEYSregistry keyed byentityType. Forsupport_ticket+support_ticket_message,body/description/subject/resolutionnote/reopenedreasonare redacted on SIEM egress. In-app audit read-paths continue to show plaintext for compliance-officer forensics.
Locked in by src/modules/support-tickets/support-tickets-phase3.spec.ts
(6 cases). Test suite: 555/555 (82 suites).
All 12 gaps closed. Support-tickets module is audit-clean against CPA §37 (SLA + notice + reconsideration), IRA §129 (subject traceability + graduation), AML §13 (money-category sanctions gate), and DPPA §11 + §21 (retention + audit-egress minimisation).
Phased implementation plan
Complete — every phase shipped.