feat(iios): idempotent support ticket + callback creates via ledger (P9)

SupportService.createTicket + requestCallback now accept an optional
idempotencyKey (from the Idempotency-Key header). With a key, a same-body
retry replays the same row and a different-body reuse is a 409; without one,
they run unwrapped as before. Tenant-scoped via the command ledger.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-03 02:22:19 +05:30
parent b8b7e0d3e8
commit 093f6484b4
4 changed files with 128 additions and 71 deletions
@@ -5,6 +5,7 @@ import { makeFakePorts } from '@insignia/iios-testkit';
import { IIOS_EVENTS, type CloudEvent } from '@insignia/iios-contracts';
import { MessageService, type MessagePrincipal } from '../messaging/message.service';
import { SupportService } from './support.service';
import { IdempotencyService } from '../idempotency/idempotency.service';
import { AssignmentService } from './assignment.service';
import { ActorResolver } from '../identity/actor.resolver';
import { OutboxBus } from '../outbox/outbox.bus';
@@ -14,7 +15,7 @@ const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/i
const prisma = new PrismaClient({ datasources: { db: { url } } });
const asService = prisma as unknown as PrismaService;
const actors = new ActorResolver(asService);
const support = () => new SupportService(asService, makeFakePorts(), actors);
const support = () => new SupportService(asService, makeFakePorts(), actors, new IdempotencyService(asService));
const assignment = () => new AssignmentService(asService, new OutboxBus(), actors);
const msg = () => new MessageService(asService, makeFakePorts(), actors);
@@ -22,8 +22,12 @@ export class SupportController {
) {}
@Post('tickets')
async createTicket(@Body() body: CreateTicketDto, @Headers('authorization') auth?: string) {
return this.support.createTicket(this.principal(auth), body);
async createTicket(
@Body() body: CreateTicketDto,
@Headers('idempotency-key') idempotencyKey?: string,
@Headers('authorization') auth?: string,
) {
return this.support.createTicket(this.principal(auth), body, idempotencyKey);
}
@Post('escalate')
@@ -42,13 +46,21 @@ export class SupportController {
}
@Post('callbacks')
async callback(@Body() body: CallbackDto, @Headers('authorization') auth?: string) {
return this.support.requestCallback(this.principal(auth), {
preferChannel: body.preferChannel,
preferredTime: body.preferredTime ? new Date(body.preferredTime) : undefined,
notes: body.notes,
ticketId: body.ticketId,
});
async callback(
@Body() body: CallbackDto,
@Headers('idempotency-key') idempotencyKey?: string,
@Headers('authorization') auth?: string,
) {
return this.support.requestCallback(
this.principal(auth),
{
preferChannel: body.preferChannel,
preferredTime: body.preferredTime ? new Date(body.preferredTime) : undefined,
notes: body.notes,
ticketId: body.ticketId,
},
idempotencyKey,
);
}
// ─── admin / agent ────────────────────────────────────────────────
@@ -6,6 +6,7 @@ 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';
import { IdempotencyService } from '../idempotency/idempotency.service';
const TICKET_TRANSITIONS: Record<IiosTicketState, IiosTicketState[]> = {
NEW: ['OPEN', 'CANCELLED'],
@@ -29,65 +30,73 @@ export class SupportService {
private readonly prisma: PrismaService,
@Inject(PLATFORM_PORTS) private readonly ports: IiosPlatformPorts,
private readonly actors: ActorResolver,
private readonly idempotency: IdempotencyService,
) {}
async createTicket(
principal: MessagePrincipal,
input: { subject: string; priority?: IiosTicketPriority; threadId?: string; queueId?: string },
idempotencyKey?: 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;
const body = async () => {
const requester = await this.actors.resolveActor(scope.id, principal);
const traceId = randomUUID();
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,
},
// 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;
});
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;
});
};
if (!idempotencyKey) return body();
return this.idempotency.run({ scopeId: scope.id, commandName: 'support.ticket.create', key: idempotencyKey, request: input }, body);
}
/** Escalate a chat thread to support: create a ticket linked to that thread. */
@@ -156,21 +165,28 @@ export class SupportService {
async requestCallback(
principal: MessagePrincipal,
input: { preferChannel: string; preferredTime?: Date; notes?: string; ticketId?: string },
idempotencyKey?: 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',
},
});
const body = async () => {
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',
},
});
};
if (!idempotencyKey) return body();
return this.idempotency.run({ scopeId: scope.id, commandName: 'support.callback.request', key: idempotencyKey, request: input }, body);
}
async listTickets(principal: MessagePrincipal, opts: { scope: 'mine' | 'assigned' }) {
@@ -5,6 +5,7 @@ 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 { IdempotencyService } from '../idempotency/idempotency.service';
import { ActorResolver } from '../identity/actor.resolver';
import type { PrismaService } from '../prisma/prisma.service';
@@ -12,7 +13,7 @@ const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/i
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 support = (ports = makeFakePorts()) => new SupportService(asService, ports, actors, new IdempotencyService(asService));
const msg = () => new MessageService(asService, makeFakePorts(), actors);
const cust: MessagePrincipal = { userId: 'cust', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Customer' };
@@ -80,3 +81,30 @@ describe('SupportService (P4)', () => {
expect(await prisma.iiosTicket.count()).toBe(0);
});
});
describe('SupportService — idempotent creates (P9 slice 6)', () => {
it('createTicket twice with the same Idempotency-Key + body → one ticket, same id', async () => {
const a = await support().createTicket(cust, { subject: 'dup me' }, 'k-ticket-1');
const b = await support().createTicket(cust, { subject: 'dup me' }, 'k-ticket-1');
expect(b.id).toBe(a.id);
expect(await prisma.iiosTicket.count()).toBe(1); // no duplicate side-effect
});
it('same key + a different body → conflict', async () => {
await support().createTicket(cust, { subject: 'first' }, 'k-ticket-2');
await expect(support().createTicket(cust, { subject: 'CHANGED' }, 'k-ticket-2')).rejects.toThrow();
expect(await prisma.iiosTicket.count()).toBe(1);
});
it('no key → runs unwrapped, no ledger row', async () => {
await support().createTicket(cust, { subject: 'no-key' });
expect(await prisma.iiosIdempotencyCommand.count()).toBe(0);
});
it('requestCallback twice with the same key → one callback request', async () => {
const a = await support().requestCallback(cust, { preferChannel: 'PHONE' }, 'k-cb-1');
const b = await support().requestCallback(cust, { preferChannel: 'PHONE' }, 'k-cb-1');
expect(b.id).toBe(a.id);
expect(await prisma.iiosCallbackRequest.count()).toBe(1);
});
});