Files
iios/packages/iios-service/src/support/support.service.ts
T
maaz519 9c6e2c76e8 fix(support): agent-demo self-seeds (join default queue + go online); tickets always get a queue
Root cause of 'waiting for escalations': no queue/membership meant tickets were
never assigned. Now: createTicket find-or-creates a default queue; new joinDefault
endpoint + useGoOnline hook; agent-demo joins+goes-AVAILABLE on load (assignPending
picks up any waiting ticket). smoke-support self-seeds (no seed script needed).
Also: vitest singleFork to end cross-file DB races (45 tests deterministic).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 12:39:14 +05:30

189 lines
7.8 KiB
TypeScript

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();
// 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<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,
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 },
});
}
}