import { ok, err, type CommandContext } from "@tailor-platform/erp-kit/core"; import type { Transaction } from "../generated/kysely-tailordb"; import { InvalidRetentionTtlError } from "../lib/errors.generated"; const TOMBSTONED = "TOMBSTONED"; const SYSTEM_SENTINEL = "system"; const DEFAULT_TTL_DAYS = 90; const MS_PER_DAY = 24 * 60 * 60 * 1000; export interface RunNotificationAuditRetentionSweepInput { ttlDays?: number; } /** * Anonymizes audit and destination-delivery-log rows older than the retention cutoff (default 90 days) via set-based updates. Audit-permission authorized; never deletes. */ export async function run( db: Transaction, input: RunNotificationAuditRetentionSweepInput, ctx: CommandContext, ) { void ctx; const ttlDays = input.ttlDays ?? DEFAULT_TTL_DAYS; // ttlDays must be a positive integer: zero, negative, fractional, or NaN // values would push the cutoff to (or past) "now" and anonymize live rows. if (!Number.isInteger(ttlDays) || ttlDays < 1) { return err(new InvalidRetentionTtlError(`ttlDays=${String(ttlDays)}`)); } const now = new Date(); const cutoff = new Date(now.getTime() - ttlDays * MS_PER_DAY); // PERSONAL-track sweep, set-based. Two statements so that the system // sentinel keeps its occurredBy while user-driven rows are tombstoned. // Both exclude already-anonymized rows, so repeated sweep passes do not // pick them up again. Failures abort the transaction and surface to the // scheduler instead of being silently swallowed per row. // // 1) User-driven rows past TTL: tombstone occurredBy and clear errorDetail. const tombstonedRows = await db .updateTable("NotificationDeliveryAudit") .set({ errorDetail: null, occurredBy: TOMBSTONED, updatedAt: now }) .where("occurredAt", "<", cutoff) .where("occurredBy", "not in", [SYSTEM_SENTINEL, TOMBSTONED]) .returningAll() .execute(); // 2) System / already-tombstoned rows past TTL still carrying an // errorDetail: clear it, keep occurredBy as-is (the system sentinel is // not a user identifier). const clearedRows = await db .updateTable("NotificationDeliveryAudit") .set({ errorDetail: null, updatedAt: now }) .where("occurredAt", "<", cutoff) .where("occurredBy", "in", [SYSTEM_SENTINEL, TOMBSTONED]) .where("errorDetail", "is not", null) .returningAll() .execute(); const rowsAnonymized = tombstonedRows.length + clearedRows.length; // DESTINATION-track sweep: clear the redacted provider response and failure // reason on DestinationDeliveryLog rows past TTL, preserving the structural // shell. Same "anonymize, do not delete" stance as the PERSONAL track; the // free-form-fields-already-null predicate keeps anonymized rows out of the // sweep on subsequent passes. const destinationRows = await db .updateTable("DestinationDeliveryLog") .set({ providerResponse: null, failureReason: null, updatedAt: now }) .where("attemptedAt", "<", cutoff) .where((eb) => eb.or([eb("providerResponse", "is not", null), eb("failureReason", "is not", null)]), ) .returningAll() .execute(); return ok({ rowsAnonymized, destinationRowsAnonymized: destinationRows.length }); }