diff --git a/packages/iios-service/src/support/support.module.ts b/packages/iios-service/src/support/support.module.ts new file mode 100644 index 0000000..8da8f18 --- /dev/null +++ b/packages/iios-service/src/support/support.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { SupportService } from './support.service'; + +@Module({ + providers: [SupportService], + exports: [SupportService], +}) +export class SupportModule {} diff --git a/packages/iios-service/src/support/support.service.ts b/packages/iios-service/src/support/support.service.ts new file mode 100644 index 0000000..9a8c553 --- /dev/null +++ b/packages/iios-service/src/support/support.service.ts @@ -0,0 +1,179 @@ +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(); + + // Default to the scope's first enabled queue so assignment has candidates. + const queueId = + input.queueId ?? + (await this.prisma.iiosSupportQueue.findFirst({ where: { scopeId: scope.id, enabled: true } }))?.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, 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 }, + }); + } +} diff --git a/packages/iios-service/src/support/support.spec.ts b/packages/iios-service/src/support/support.spec.ts new file mode 100644 index 0000000..fae0b2f --- /dev/null +++ b/packages/iios-service/src/support/support.spec.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { PrismaClient } from '@prisma/client'; +import { resetDb } from '../test-utils/reset-db'; +import { makeFakePorts } from '@insignia/iios-testkit'; +import { PolicyDeniedError, IIOS_EVENTS } from '@insignia/iios-contracts'; +import { MessageService, type MessagePrincipal } from '../messaging/message.service'; +import { SupportService } from './support.service'; +import { ActorResolver } from '../identity/actor.resolver'; +import type { PrismaService } from '../prisma/prisma.service'; + +const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public'; +const prisma = new PrismaClient({ datasources: { db: { url } } }); +const asService = prisma as unknown as PrismaService; +const actors = new ActorResolver(asService); +const support = (ports = makeFakePorts()) => new SupportService(asService, ports, actors); +const msg = () => new MessageService(asService, makeFakePorts(), actors); + +const cust: MessagePrincipal = { userId: 'cust', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Customer' }; + +beforeAll(async () => { await prisma.$connect(); }); +afterAll(async () => { await prisma.$disconnect(); }); +beforeEach(async () => { await resetDb(prisma); }); + +describe('SupportService (P4)', () => { + it('creates a ticket (NEW) with a state-history row and a ticket.created event', async () => { + const t = await support().createTicket(cust, { subject: 'help me' }); + expect(t.state).toBe('NEW'); + expect(t.subject).toBe('help me'); + expect(await prisma.iiosTicketStateHistory.count({ where: { ticketId: t.id } })).toBe(1); + const ev = await prisma.iiosOutboxEvent.findFirstOrThrow({ where: { eventType: IIOS_EVENTS.ticketCreated } }); + expect((ev.cloudEvent as { data: { ticketId: string } }).data.ticketId).toBe(t.id); + }); + + it('links tickets ⟷ threads many-to-many (both directions)', async () => { + const m = msg(); + const t1 = await m.openThread(null, cust); + const t2 = await m.openThread(null, cust); + const ticketA = await support().createTicket(cust, { subject: 'A' }); + const ticketB = await support().createTicket(cust, { subject: 'B' }); + + // one ticket ↔ two threads + await support().linkThread(ticketA.id, t1.threadId); + await support().linkThread(ticketA.id, t2.threadId); + // one thread ↔ two tickets + await support().linkThread(ticketB.id, t1.threadId); + + expect(await prisma.iiosTicketThreadLink.count({ where: { ticketId: ticketA.id } })).toBe(2); + expect(await prisma.iiosTicketThreadLink.count({ where: { threadId: t1.threadId } })).toBe(2); + }); + + it('escalate creates a ticket linked to the thread', async () => { + const { threadId } = await msg().openThread(null, cust); + const t = await support().escalate(threadId, cust); + const links = await prisma.iiosTicketThreadLink.findMany({ where: { ticketId: t.id } }); + expect(links).toHaveLength(1); + expect(links[0]?.threadId).toBe(threadId); + }); + + it('transitions NEW→OPEN→RESOLVED→CLOSED with history + rejects illegal moves', async () => { + const t = await support().createTicket(cust, { subject: 's' }); + await support().transition(t.id, cust, 'OPEN'); + await support().transition(t.id, cust, 'RESOLVED'); + await support().transition(t.id, cust, 'CLOSED'); + expect((await prisma.iiosTicket.findUniqueOrThrow({ where: { id: t.id } })).state).toBe('CLOSED'); + // NEW(created)+OPEN+RESOLVED+CLOSED = 4 history rows + expect(await prisma.iiosTicketStateHistory.count({ where: { ticketId: t.id } })).toBe(4); + await expect(support().transition(t.id, cust, 'PENDING_CUSTOMER')).rejects.toThrow(); // CLOSED can only reopen + }); + + it('creates a callback placeholder', async () => { + const cb = await support().requestCallback(cust, { preferChannel: 'PHONE', notes: 'call me' }); + expect(cb.status).toBe('PENDING'); + expect(cb.preferChannel).toBe('PHONE'); + }); + + it('is fail-closed on create', async () => { + const ports = makeFakePorts(); + ports.opa.deny(); + await expect(support(ports).createTicket(cust, { subject: 'x' })).rejects.toBeInstanceOf(PolicyDeniedError); + expect(await prisma.iiosTicket.count()).toBe(0); + }); +});