feat(service): P4.2 SupportService — tickets, M:N thread links, transitions, callback
createTicket (NEW + history + ticket.created event, default queue), escalate, linkThread/unlinkThread (M:N both directions), transition state machine + ticket.state.changed, requestCallback placeholder, listTickets (mine/assigned), fail-closed. 6 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { SupportService } from './support.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [SupportService],
|
||||||
|
exports: [SupportService],
|
||||||
|
})
|
||||||
|
export class SupportModule {}
|
||||||
@@ -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<IiosTicketState, IiosTicketState[]> = {
|
||||||
|
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<void> {
|
||||||
|
await this.prisma.iiosTicketThreadLink.upsert({
|
||||||
|
where: { ticketId_threadId: { ticketId, threadId } },
|
||||||
|
create: { ticketId, threadId, relationKind },
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async unlinkThread(ticketId: string, threadId: string): Promise<void> {
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user