diff --git a/packages/iios-service/src/retention/retention.service.spec.ts b/packages/iios-service/src/retention/retention.service.spec.ts index 3c51f75..40d88d1 100644 --- a/packages/iios-service/src/retention/retention.service.spec.ts +++ b/packages/iios-service/src/retention/retention.service.spec.ts @@ -117,3 +117,59 @@ describe('RetentionService (P9 — retention sweep)', () => { expect(await bodyOf(b.interactionId)).toBe('tenant b'); // scope B untouched }); }); + +// T8: an outbound email/SMS command holds PII (the recipient address, and the rendered body with +// their name). Once aged, redact those while KEEPING the template provenance for audit/replay. +describe('RetentionService — outbound command PII (T8)', () => { + const setOutboundSnapshot = (targetId: string, data: { archiveAfter?: Date; deleteAfter?: Date }) => + prisma.iiosRetentionPolicySnapshot.update({ where: { targetType_targetId: { targetType: 'outbound_command', targetId } }, data }); + + async function command(target: string): Promise<{ id: string; scopeId: string }> { + const scope = await prisma.iiosScope.create({ data: { orgId: 'org_demo', appId: 'crm-web' } }); + const cmd = await prisma.iiosOutboundCommand.create({ + data: { + channelType: 'EMAIL', target, scopeId: scope.id, status: 'SENT', idempotencyKey: `k-${randomUUID()}`, + payload: { subject: 'Hi Dana', html: '
secret body
' }, + templateKey: 'welcome', templateVersion: 1, templateLocale: 'en', renderedHash: 'abc123', + }, + }); + return { id: cmd.id, scopeId: scope.id }; + } + + it('ensureOutboundSnapshots captures one snapshot per outbound command', async () => { + const { id } = await command('dana@acme.com'); + const n = await svc().ensureOutboundSnapshots(); + expect(n).toBe(1); + const snap = await prisma.iiosRetentionPolicySnapshot.findUniqueOrThrow({ where: { targetType_targetId: { targetType: 'outbound_command', targetId: id } } }); + expect(snap.targetType).toBe('outbound_command'); + expect(snap.dataClass).toBe('outbound'); + }); + + it('redacts target + payload of an aged command but keeps template provenance', async () => { + const { id } = await command('dana@acme.com'); + await svc().ensureOutboundSnapshots(); + await setOutboundSnapshot(id, { archiveAfter: past, deleteAfter: past }); + + const res = await svc().applySweep(); + expect(res.redacted).toBe(1); + const after = await prisma.iiosOutboundCommand.findUniqueOrThrow({ where: { id } }); + expect(after.target).toBe('[redacted]'); + expect(after.payload).toEqual({ redacted: true }); + // Provenance survives — you can still answer "which template version did we send?" + expect(after.templateKey).toBe('welcome'); + expect(after.templateVersion).toBe(1); + expect(after.renderedHash).toBe('abc123'); + expect(await prisma.iiosAuditLink.count({ where: { action: 'retention.redacted', resourceType: 'outbound_command' } })).toBe(1); + }); + + it('an active compliance hold blocks the command sweep', async () => { + const { id, scopeId } = await command('held@acme.com'); + await svc().ensureOutboundSnapshots(); + await setOutboundSnapshot(id, { archiveAfter: past, deleteAfter: past }); + await prisma.iiosComplianceHold.create({ data: { scopeId, targetType: 'outbound_command', targetId: id, holdReason: 'legal' } }); + + const res = await svc().applySweep(); + expect(res.skippedHeld).toBe(1); + expect((await prisma.iiosOutboundCommand.findUniqueOrThrow({ where: { id } })).target).toBe('held@acme.com'); + }); +}); diff --git a/packages/iios-service/src/retention/retention.service.ts b/packages/iios-service/src/retention/retention.service.ts index b5831a7..f88b409 100644 --- a/packages/iios-service/src/retention/retention.service.ts +++ b/packages/iios-service/src/retention/retention.service.ts @@ -72,6 +72,41 @@ export class RetentionService implements OnModuleInit, OnModuleDestroy { return created; } + /** + * Capture a retention snapshot for any outbound command that lacks one. An email/SMS command + * holds PII (the recipient address, and the rendered body with their name), so it gets a single + * window and goes STRAIGHT to redact (archiveAfter == deleteAfter — no archive phase). Unscoped + * system sends use an 'unscoped' partition tag so the global sweep still reaches them. + */ + async ensureOutboundSnapshots(scopeId?: string): Promise