import { randomUUID } from 'node:crypto'; import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma, IiosTicketState, IiosTicketPriority } from '@prisma/client'; import { CloudEvent, IIOS_EVENTS, IiosPlatformPorts } from '@insignia/iios-contracts'; import { PrismaService } from '../prisma/prisma.service'; import { PLATFORM_PORTS } from '../platform/platform-ports'; import { decideOrThrow } from '../platform/fail-closed'; import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver'; const TICKET_TRANSITIONS: Record = { NEW: ['OPEN', 'CANCELLED'], OPEN: ['PENDING_CUSTOMER', 'PENDING_INTERNAL', 'RESOLVED', 'CANCELLED'], PENDING_CUSTOMER: ['OPEN', 'RESOLVED', 'CANCELLED'], PENDING_INTERNAL: ['OPEN', 'RESOLVED', 'CANCELLED'], RESOLVED: ['CLOSED', 'OPEN'], CLOSED: ['OPEN'], CANCELLED: [], }; /** * Support specialization (P4). Imports the kernel via ActorResolver; NEVER * touched by the message/inbox layers. Escalation creates a ticket linked M:N * to a generic kernel thread and emits support.ticket.created for the * (event-driven) assignment worker. */ @Injectable() export class SupportService { constructor( private readonly prisma: PrismaService, @Inject(PLATFORM_PORTS) private readonly ports: IiosPlatformPorts, private readonly actors: ActorResolver, ) {} async createTicket( principal: MessagePrincipal, input: { subject: string; priority?: IiosTicketPriority; threadId?: string; queueId?: string }, ) { await decideOrThrow(this.ports, { action: 'iios.support.ticket.create', scope: principal }); const scope = await this.actors.resolveScope(principal); const requester = await this.actors.resolveActor(scope.id, principal); const traceId = randomUUID(); // Every ticket gets a queue so assignment has somewhere to route it — find // the scope's default queue or create one. const queueId = input.queueId ?? ( (await this.prisma.iiosSupportQueue.findFirst({ where: { scopeId: scope.id, enabled: true } })) ?? (await this.prisma.iiosSupportQueue.create({ data: { scopeId: scope.id, name: 'Support' } })) ).id; return this.prisma.$transaction(async (tx) => { const ticket = await tx.iiosTicket.create({ data: { scopeId: scope.id, queueId, requesterActorId: requester.id, subject: input.subject, priority: input.priority ?? 'P3', traceId, }, }); await tx.iiosTicketStateHistory.create({ data: { ticketId: ticket.id, toState: 'NEW', actorId: requester.id, reasonCode: 'created' }, }); if (input.threadId) { await tx.iiosTicketThreadLink.create({ data: { ticketId: ticket.id, threadId: input.threadId } }); } const event: CloudEvent = { specversion: '1.0', id: `evt_${ticket.id}`, type: IIOS_EVENTS.ticketCreated, source: `iios/support/${scope.id}`, subject: `ticket/${ticket.id}`, time: new Date().toISOString(), datacontenttype: 'application/json', insignia: { scopeSnapshotId: scope.id, correlationId: traceId, idempotencyKey: `ticket:${ticket.id}`, dataClass: 'internal' }, data: { ticketId: ticket.id, scopeId: scope.id, queueId, threadId: input.threadId }, }; await tx.iiosOutboxEvent.create({ data: { aggregateType: 'ticket', aggregateId: ticket.id, eventType: IIOS_EVENTS.ticketCreated, cloudEvent: event as unknown as Prisma.InputJsonValue, partitionKey: `${scope.id}:${ticket.id}`, }, }); return ticket; }); } /** Escalate a chat thread to support: create a ticket linked to that thread. */ async escalate(threadId: string, principal: MessagePrincipal, subject?: string) { const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } }); if (!thread) throw new NotFoundException('thread not found'); return this.createTicket(principal, { subject: subject ?? thread.subject ?? 'Support request', threadId }); } async linkThread(ticketId: string, threadId: string, relationKind = 'PRIMARY'): Promise { await this.prisma.iiosTicketThreadLink.upsert({ where: { ticketId_threadId: { ticketId, threadId } }, create: { ticketId, threadId, relationKind }, update: {}, }); } async unlinkThread(ticketId: string, threadId: string): Promise { await this.prisma.iiosTicketThreadLink .delete({ where: { ticketId_threadId: { ticketId, threadId } } }) .catch(() => undefined); } async transition(ticketId: string, principal: MessagePrincipal, toState: IiosTicketState, reason?: string) { const ticket = await this.prisma.iiosTicket.findUnique({ where: { id: ticketId } }); if (!ticket) throw new NotFoundException('ticket not found'); await decideOrThrow(this.ports, { action: 'iios.support.ticket.transition', ticketId, scopeId: ticket.scopeId }); if (!TICKET_TRANSITIONS[ticket.state].includes(toState)) { throw new BadRequestException(`illegal transition ${ticket.state} -> ${toState}`); } const actor = await this.actors.resolveActor(ticket.scopeId, principal); const event: CloudEvent = { specversion: '1.0', id: `evt_ticketstate_${ticketId}_${toState}`, type: IIOS_EVENTS.ticketStateChanged, source: `iios/support/${ticket.scopeId}`, subject: `ticket/${ticketId}`, time: new Date().toISOString(), datacontenttype: 'application/json', insignia: { scopeSnapshotId: ticket.scopeId, correlationId: ticket.traceId ?? undefined, idempotencyKey: `ticketstate:${ticketId}:${toState}`, dataClass: 'internal', }, data: { ticketId, fromState: ticket.state, toState }, }; const [updated] = await this.prisma.$transaction([ this.prisma.iiosTicket.update({ where: { id: ticketId }, data: { state: toState } }), this.prisma.iiosTicketStateHistory.create({ data: { ticketId, fromState: ticket.state, toState, actorId: actor.id, reasonCode: reason }, }), this.prisma.iiosOutboxEvent.create({ data: { aggregateType: 'ticket', aggregateId: ticketId, eventType: IIOS_EVENTS.ticketStateChanged, cloudEvent: event as unknown as Prisma.InputJsonValue, partitionKey: `${ticket.scopeId}:${ticketId}`, }, }), ]); return updated; } async requestCallback( principal: MessagePrincipal, input: { preferChannel: string; preferredTime?: Date; notes?: string; ticketId?: string }, ) { await decideOrThrow(this.ports, { action: 'iios.support.callback.request', scope: principal }); const scope = await this.actors.resolveScope(principal); const requester = await this.actors.resolveActor(scope.id, principal); return this.prisma.iiosCallbackRequest.create({ data: { scopeId: scope.id, requesterActorId: requester.id, preferChannel: input.preferChannel, preferredTime: input.preferredTime, notes: input.notes, ticketId: input.ticketId, status: input.preferredTime ? 'SCHEDULED' : 'PENDING', }, }); } async listTickets(principal: MessagePrincipal, opts: { scope: 'mine' | 'assigned' }) { await decideOrThrow(this.ports, { action: 'iios.support.ticket.list', scope: principal }); const scope = await this.actors.resolveScope(principal); const actor = await this.actors.resolveActor(scope.id, principal); const where = opts.scope === 'assigned' ? { assignedActorId: actor.id } : { requesterActorId: actor.id }; return this.prisma.iiosTicket.findMany({ where, orderBy: { updatedAt: 'desc' }, include: { threadLinks: true }, }); } }