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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user