import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { recordAudit } from '../observability/audit'; const DAY_MS = 86_400_000; export interface SweepResult { archived: number; redacted: number; skippedHeld: number; } /** * Data retention (P9). Each interaction gets a per-resource retention snapshot with * frozen archive/delete timestamps; a scheduled sweep archives (status change) then * deletes = redacts-in-place (reusing the DSR tombstone semantics) once a resource ages * past its window. An active compliance hold blocks both. Never a hard delete. */ @Injectable() export class RetentionService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(RetentionService.name); private timer?: ReturnType; constructor(private readonly prisma: PrismaService) {} onModuleInit(): void { const ms = Number(process.env.IIOS_RETENTION_SWEEP_INTERVAL_MS ?? 0); if (ms > 0) { this.timer = setInterval(() => { void this.sweep().catch((err) => this.logger.warn(`retention sweep failed: ${(err as Error).message}`)); }, ms); } } onModuleDestroy(): void { if (this.timer) clearInterval(this.timer); } private windowDays(dataClass: string, kind: 'ARCHIVE' | 'DELETE'): number { const cls = process.env[`IIOS_RETENTION_${kind}_DAYS_${dataClass.toUpperCase()}`]; const glob = process.env[`IIOS_RETENTION_${kind}_DAYS`]; return Number(cls ?? glob ?? (kind === 'ARCHIVE' ? 90 : 365)); } /** Lazily capture a per-resource snapshot for any interaction that lacks one. */ async ensureSnapshots(scopeId?: string): Promise { const interactions = await this.prisma.iiosInteraction.findMany({ where: scopeId ? { scopeId } : {}, select: { id: true, scopeId: true, dataClass: true, receivedAt: true }, }); let created = 0; for (const i of interactions) { const exists = await this.prisma.iiosRetentionPolicySnapshot.findUnique({ where: { targetType_targetId: { targetType: 'interaction', targetId: i.id } }, }); if (exists) continue; const base = i.receivedAt.getTime(); await this.prisma.iiosRetentionPolicySnapshot.create({ data: { policyKey: `default:${i.dataClass}:v1`, scopeSnapshotId: i.scopeId, targetType: 'interaction', targetId: i.id, dataClass: i.dataClass, archiveAfter: new Date(base + this.windowDays(i.dataClass, 'ARCHIVE') * DAY_MS), deleteAfter: new Date(base + this.windowDays(i.dataClass, 'DELETE') * DAY_MS), sourceVersion: process.env.IIOS_RETENTION_POLICY_VERSION ?? 'v1', }, }); created++; } 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(); const due = await this.prisma.iiosRetentionPolicySnapshot.findMany({ where: { status: { in: ['ACTIVE', 'ARCHIVED'] }, archiveAfter: { lte: now }, ...(scopeId ? { scopeSnapshotId: scopeId } : {}) }, }); const result: SweepResult = { archived: 0, redacted: 0, skippedHeld: 0 }; for (const s of due) { const held = await this.prisma.iiosComplianceHold.findFirst({ where: { targetId: s.targetId, status: 'ACTIVE', OR: [{ expiresAt: null }, { expiresAt: { gt: now } }] }, }); if (held) { result.skippedHeld++; continue; } if (s.deleteAfter <= now) { 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' } }); await recordAudit(this.prisma, { action: 'retention.archived', resourceType: 'interaction', resourceId: s.targetId, scopeId: s.scopeSnapshotId }); result.archived++; } } return result; } /** 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); } /** Snapshot lifecycle counts for /metrics (global ops). */ async summary(): Promise<{ total: number; byStatus: Record }> { const groups = await this.prisma.iiosRetentionPolicySnapshot.groupBy({ by: ['status'], _count: true }); const byStatus: Record = {}; let total = 0; for (const g of groups) { byStatus[g.status] = g._count; total += g._count; } return { total, byStatus }; } }