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), {
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,14 +30,18 @@ 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 body = async () => {
const requester = await this.actors.resolveActor(scope.id, principal);
const traceId = randomUUID();
@@ -88,6 +93,10 @@ export class SupportService {
});
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,9 +165,12 @@ 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 body = async () => {
const requester = await this.actors.resolveActor(scope.id, principal);
return this.prisma.iiosCallbackRequest.create({
data: {
@@ -171,6 +183,10 @@ export class SupportService {
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);
});
});