diff --git a/packages/iios-service/prisma/migrations/20260702155528_outbound_consent/migration.sql b/packages/iios-service/prisma/migrations/20260702155528_outbound_consent/migration.sql new file mode 100644 index 0000000..81ab817 --- /dev/null +++ b/packages/iios-service/prisma/migrations/20260702155528_outbound_consent/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "IiosOutboundCommand" ADD COLUMN "consentReceiptRef" TEXT; diff --git a/packages/iios-service/prisma/schema.prisma b/packages/iios-service/prisma/schema.prisma index 8aa17d9..ae1ca98 100644 --- a/packages/iios-service/prisma/schema.prisma +++ b/packages/iios-service/prisma/schema.prisma @@ -706,10 +706,11 @@ model IiosOutboundCommand { channelType String target String payload Json - status String @default("PENDING") // PENDING | SENT | FAILED | RATE_LIMITED - idempotencyKey String @unique - providerRef String? - createdAt DateTime @default(now()) + status String @default("PENDING") // PENDING | SENT | FAILED | RATE_LIMITED | BLOCKED + idempotencyKey String @unique + providerRef String? + consentReceiptRef String? // CMP consent receipt this send went out under (P9) + createdAt DateTime @default(now()) attempts IiosDeliveryAttempt[] diff --git a/packages/iios-service/src/adapters/adapters.spec.ts b/packages/iios-service/src/adapters/adapters.spec.ts index 0ccf657..beeeabe 100644 --- a/packages/iios-service/src/adapters/adapters.spec.ts +++ b/packages/iios-service/src/adapters/adapters.spec.ts @@ -115,4 +115,22 @@ describe('Adapter outbound (P5)', () => { expect(cmd.status).toBe('BLOCKED'); expect(cmd.providerRef).toBe('blocked'); }); + + it('CMP allow → command records the consent receipt (P9 slice 2)', async () => { + const ports = makeFakePorts(); + ports.cmp.setStatus('ALLOW'); + const svc = new OutboundService(asService, new CapabilityBroker(ports, new CapabilityProviderRegistry())); + const cmd = await svc.send('WEBHOOK', 'user@x', { text: 'hi' }, 'consent-1', 'scope1', 'support'); + expect(cmd.status).toBe('SENT'); + expect(cmd.consentReceiptRef).toBe('fake-receipt'); + }); + + it('CMP DENY for the purpose → command BLOCKED, no send (P9 slice 2)', async () => { + const ports = makeFakePorts(); + ports.cmp.denyPurpose('marketing'); + const svc = new OutboundService(asService, new CapabilityBroker(ports, new CapabilityProviderRegistry())); + const cmd = await svc.send('WEBHOOK', 'user@x', { text: 'promo' }, 'consent-2', 'scope1', 'marketing'); + expect(cmd.status).toBe('BLOCKED'); + expect(await prisma.iiosDeliveryAttempt.count({ where: { commandId: cmd.id, status: 'SENT' } })).toBe(0); + }); }); diff --git a/packages/iios-service/src/adapters/outbound.service.ts b/packages/iios-service/src/adapters/outbound.service.ts index eefd9f4..66ea1cd 100644 --- a/packages/iios-service/src/adapters/outbound.service.ts +++ b/packages/iios-service/src/adapters/outbound.service.ts @@ -27,6 +27,7 @@ export class OutboundService { payload: Record, idempotencyKey: string = randomUUID(), scopeId?: string, + purpose?: string, ) { const existing = await this.prisma.iiosOutboundCommand.findUnique({ where: { idempotencyKey } }); if (existing) return existing; @@ -45,7 +46,7 @@ export class OutboundService { let result; try { - result = await this.broker.invoke({ capability: 'channel.send', channelType, target, payload, idempotencyKey, scopeId }); + result = await this.broker.invoke({ capability: 'channel.send', channelType, target, payload, idempotencyKey, scopeId, purpose }); } catch (err) { // Fail-closed (e.g. PolicyDeniedError): mark the command FAILED, record the attempt, propagate. await this.prisma.$transaction([ @@ -56,7 +57,10 @@ export class OutboundService { } const [updated] = await this.prisma.$transaction([ - this.prisma.iiosOutboundCommand.update({ where: { id: cmd.id }, data: { status: result.outcome, providerRef: result.providerRef } }), + this.prisma.iiosOutboundCommand.update({ + where: { id: cmd.id }, + data: { status: result.outcome, providerRef: result.providerRef, consentReceiptRef: result.consentReceiptRef }, + }), this.prisma.iiosDeliveryAttempt.create({ data: { commandId: cmd.id, attemptNo: 1, status: result.outcome, providerRef: result.providerRef, latencyMs: result.latencyMs, errorCode: result.errorCode }, }), diff --git a/packages/iios-service/src/calendar/calendar.service.ts b/packages/iios-service/src/calendar/calendar.service.ts index 64dbed8..e6e10be 100644 --- a/packages/iios-service/src/calendar/calendar.service.ts +++ b/packages/iios-service/src/calendar/calendar.service.ts @@ -178,6 +178,8 @@ export class CalendarService { `meeting:${meeting.id}`, { text: `Reminder: "${meeting.title}"` }, `meeting-reminder:${meeting.id}`, + meeting.scopeId, + 'meeting_reminder', ); for (const p of meeting.participants) { if (!p.actorRefId) continue; diff --git a/packages/iios-service/src/routing/route.service.ts b/packages/iios-service/src/routing/route.service.ts index c494615..5cec06d 100644 --- a/packages/iios-service/src/routing/route.service.ts +++ b/packages/iios-service/src/routing/route.service.ts @@ -87,6 +87,8 @@ export class RouteService { binding.destinationRef ?? 'default', { text: preview.text ?? '' }, `route:${decision.id}`, + binding.scopeId, + 'route_forward', ); await this.prisma.iiosRouteDecision.update({ where: { id: decision.id }, data: { executed: true, executedCommandId: cmd.id } }); }