Skip to main content

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.
  • SupportTicket Prisma model — 14 fields, six-value status enum, four-priority soft-enum (low / normal / high / urgent), six-category soft-enum (account, billing, referral, commission, technical, other), slaDueAt stamped at create from a per-priority in-code table.
  • SupportTicketMessage model — 6 fields, internal / both visibility, 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 slaDueAt was meant to enforce; nothing watches it. Reconsideration means the closed state 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 → dispute or ticket → complaint) with an audit link, so IRA inspections can trace the escalation chain.
  • DPPA 2019 §11 + §21 — the two free-text columns (SupportTicket.description 5000 chars, SupportTicketMessage.body 5000 chars) carry every kind of PII (national IDs quoted in the narrative, phone numbers, dispute-specific transaction references). RetentionPurgeJob doesn'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: ALLOWED gates every status flip; out-of-order transitions refuse cleanly.
  • Per-priority SLA is stamped: slaDueAt set 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 on findById.
  • 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.

Phase 1 shipped (2026-07-18)

Release support-p1-20260718-1830. Closes gaps 1–4:

  • gap 1 — Migration 20260718190000_support_tickets_phase1 adds firstResponseDueAt, firstResponseAt, firstResponseBreachedAt, resolutionDueAt, resolutionBreachedAt + indexes. create() populates the due-columns from support_tickets.first_response_sla_hours
    • support_tickets.resolution_sla_hours policies. First ops-side reply() stamps firstResponseAt. New SupportTicketSlaEscalationJob runs hourly, stamps breach columns idempotently, emits support_ticket.sla.first_response_breached / resolution_breached / sweep, and fans out to compliance officers via RoleFanoutService.
  • gap 2 — SupportTicketsService.create() runs SanctionsService.screenForPayout('user', reporterUserId, ctx) when the category is in the new support_tickets.money_categories policy (default commission,billing). Block → refuse + emit support.ticket.create.sanctions_block + stamp metadata.intakeScreeningId. Non-money categories skip the gate.
  • gap 3 — Migration adds retentionScrubbedAt to both support_tickets and support_ticket_messages. New data_sharing.support_tickets_retention_days policy (default 2555 = 7 y). RetentionPurgeJob scrubs subject / body on closed tickets past window + every child message.body + emits support_ticket.retention.pii_scrubbed.
  • gap 4 — SupportTicketsService gains @Optional() NotificationDispatchService
    • notifyRaiser() helper. assign / resolve / close dispatch support_ticket.<status>; ops-side reply with visibility='both' dispatches support_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).

Phase 2 shipped (2026-07-18)

Release support-p2-20260718-1848. Closes gaps 5–8:

  • gap 5 — Migration 20260718200000_support_tickets_phase2 adds subjectType (soft enum policy / claim / payment / referral / commission / dispute / complaint / other) + subjectId (nullable UUID) + compound index. CreateTicketDto accepts both; list() accepts filter by subjectType+subjectId for the "all tickets about policy ABC" query.
  • gap 6 — New POST /support/tickets/:id/reopen endpoint gated by existing SupportTicketResolve permission. ALLOWED now allows closed → assigned. reopen() clears closedAt + resolvedAt, stamps metadata.reopenedAt + reopenedReason, emits support.ticket.reopen audit event with the reason, and notifies the raiser via the Phase-1 template pipeline.
  • gap 7 — reply() refuses visibility='internal' unless the caller holds SupportTicketRead. Refused attempts emit support.ticket.reply.internal_denied with attempted-by + primary role. Reporter callers can only write visibility='both'.
  • gap 8 — Migration adds SupportTicketDocument join table with a unique index on (ticketId, documentId). CreateTicketDto + ReplyTicketDto accept documentIds?: UUID[] (max 20 per DTO). New attachDocuments() helper enforces a per-ticket cap from support_tickets.max_attachments_per_ticket policy (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).

Phase 3 shipped (2026-07-18)

Release support-p3-20260718-1903. Closes gaps 9–12:

  • gap 9 — SupportTicketsService exposes idempotent wrappers (createIdempotent, assignIdempotent, replyIdempotent, resolveIdempotent, closeIdempotent, reopenIdempotent, graduateIdempotent) via shared IdempotencyService. Controller threads the Idempotency-Key header through every mutation.
  • gap 10 — @Throttle on every mutation: create 10/min, reply 30/min, lifecycle mutations 20/min, graduate 10/min.
  • gap 11 — New POST /support/tickets/:id/graduate endpoint gated by SupportTicketResolve. DTO carries targetType: 'dispute' | 'complaint' + reason (+ optional complaintCategory for complaint targets). For dispute: requires the ticket to carry subjectType+subjectId (validated against the dispute subject enum); calls DisputesService.raise(). For complaint: calls ComplaintsService.file() with the mapped category + ticket subject + body. Both stamp Dispute.metadata.graduatedTo = { targetType, targetId, targetReference }, transition the ticket to closed with an auto-message, and emit support.ticket.graduate linking source → target.
  • gap 12 — AuditRedactor gains an ENTITY_TYPE_PII_KEYS registry keyed by entityType. For support_ticket + support_ticket_message, body / description / subject / resolutionnote / reopenedreason are 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.