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 { + const commands = await this.prisma.iiosOutboundCommand.findMany({ + where: scopeId ? { scopeId } : {}, + select: { id: true, scopeId: true, createdAt: true }, + }); + let created = 0; + for (const c of commands) { + const exists = await this.prisma.iiosRetentionPolicySnapshot.findUnique({ + where: { targetType_targetId: { targetType: 'outbound_command', targetId: c.id } }, + }); + if (exists) continue; + const deleteAt = new Date(c.createdAt.getTime() + this.windowDays('outbound', 'DELETE') * DAY_MS); + await this.prisma.iiosRetentionPolicySnapshot.create({ + data: { + policyKey: 'outbound:pii:v1', + scopeSnapshotId: c.scopeId ?? 'unscoped', + targetType: 'outbound_command', + targetId: c.id, + dataClass: 'outbound', + archiveAfter: deleteAt, + deleteAfter: deleteAt, + sourceVersion: process.env.IIOS_RETENTION_POLICY_VERSION ?? 'v1', + }, + }); + created++; + } + return created; + } + /** Act on due snapshots: archive, or delete (redact-in-place), honoring compliance holds. */ async applySweep(scopeId?: string): Promise { const now = new Date(); @@ -90,13 +125,22 @@ export class RetentionService implements OnModuleInit, OnModuleDestroy { } if (s.deleteAfter <= now) { - await this.prisma.$transaction([ - this.prisma.iiosMessagePart.updateMany({ where: { interactionId: s.targetId }, data: { bodyText: '[redacted]', contentRef: null } }), - this.prisma.iiosInboundRawEvent.updateMany({ where: { interactionId: s.targetId }, data: { payload: { redacted: true } } }), - this.prisma.iiosInteraction.update({ where: { id: s.targetId }, data: { status: 'REDACTED' } }), - this.prisma.iiosRetentionPolicySnapshot.update({ where: { id: s.id }, data: { status: 'REDACTED' } }), - ]); - await recordAudit(this.prisma, { action: 'retention.redacted', resourceType: 'interaction', resourceId: s.targetId, scopeId: s.scopeSnapshotId }); + if (s.targetType === 'outbound_command') { + // Redact the recipient address + rendered body; KEEP the template provenance columns. + await this.prisma.$transaction([ + this.prisma.iiosOutboundCommand.update({ where: { id: s.targetId }, data: { target: '[redacted]', payload: { redacted: true } } }), + this.prisma.iiosRetentionPolicySnapshot.update({ where: { id: s.id }, data: { status: 'REDACTED' } }), + ]); + await recordAudit(this.prisma, { action: 'retention.redacted', resourceType: 'outbound_command', resourceId: s.targetId, scopeId: s.scopeSnapshotId }); + } else { + await this.prisma.$transaction([ + this.prisma.iiosMessagePart.updateMany({ where: { interactionId: s.targetId }, data: { bodyText: '[redacted]', contentRef: null } }), + this.prisma.iiosInboundRawEvent.updateMany({ where: { interactionId: s.targetId }, data: { payload: { redacted: true } } }), + this.prisma.iiosInteraction.update({ where: { id: s.targetId }, data: { status: 'REDACTED' } }), + this.prisma.iiosRetentionPolicySnapshot.update({ where: { id: s.id }, data: { status: 'REDACTED' } }), + ]); + await recordAudit(this.prisma, { action: 'retention.redacted', resourceType: 'interaction', resourceId: s.targetId, scopeId: s.scopeSnapshotId }); + } result.redacted++; } else if (s.status === 'ACTIVE') { await this.prisma.iiosRetentionPolicySnapshot.update({ where: { id: s.id }, data: { status: 'ARCHIVED' } }); @@ -107,9 +151,10 @@ export class RetentionService implements OnModuleInit, OnModuleDestroy { return result; } - /** Full sweep: capture missing snapshots, then act on due ones. */ + /** Full sweep: capture missing snapshots (interactions + outbound commands), then act on due ones. */ async sweep(scopeId?: string): Promise { await this.ensureSnapshots(scopeId); + await this.ensureOutboundSnapshots(scopeId); return this.applySweep(scopeId); }