diff --git a/packages/iios-service/src/support/assignment.spec.ts b/packages/iios-service/src/support/assignment.spec.ts index 5f08262..8a4b7ea 100644 --- a/packages/iios-service/src/support/assignment.spec.ts +++ b/packages/iios-service/src/support/assignment.spec.ts @@ -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); diff --git a/packages/iios-service/src/support/support.controller.ts b/packages/iios-service/src/support/support.controller.ts index f711e97..aeb51ba 100644 --- a/packages/iios-service/src/support/support.controller.ts +++ b/packages/iios-service/src/support/support.controller.ts @@ -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 ──────────────────────────────────────────────── diff --git a/packages/iios-service/src/support/support.service.ts b/packages/iios-service/src/support/support.service.ts index a490d96..93b25de 100644 --- a/packages/iios-service/src/support/support.service.ts +++ b/packages/iios-service/src/support/support.service.ts @@ -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 = { 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' }) { diff --git a/packages/iios-service/src/support/support.spec.ts b/packages/iios-service/src/support/support.spec.ts index fae0b2f..288effb 100644 --- a/packages/iios-service/src/support/support.spec.ts +++ b/packages/iios-service/src/support/support.spec.ts @@ -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); + }); +});