diff --git a/src/lib/jobs/document-purge.ts b/src/lib/jobs/document-purge.ts new file mode 100644 index 000000000..8f136be07 --- /dev/null +++ b/src/lib/jobs/document-purge.ts @@ -0,0 +1,58 @@ +/** + * Daily physical purge for soft-deleted document-vault rows. + * + * Delete in the vault is undo-able: the DELETE route tombstones + * (`deletedAt`), the UI's undo toast and the restore endpoint clear the + * tombstone, and THIS job hard-deletes tombstones older than the 30-day + * grace window — returning the TOAST space the encrypted blob holds (a + * tombstoned document keeps counting against the quota until purged, so + * "deleted" bytes are never invisible backup weight). Staged facts cascade + * via the FK. Restore-after-purge answers 409 at the API. + */ +import { type Job } from "pg-boss"; +import { withBackgroundEvent } from "@/lib/logging/background"; +import { getWorkerPrisma } from "@/lib/jobs/reminder/shared"; +import type { PrismaClient } from "@/generated/prisma/client"; + +export const DOCUMENT_PURGE_QUEUE = "document-tombstone-purge"; +/** Daily at 04:10 Europe/Berlin — after the med-notes backfill tick (04:05). */ +export const DOCUMENT_PURGE_CRON = "10 4 * * *"; + +/** Days a tombstoned document stays restorable before the purge claims it. */ +export const DOCUMENT_PURGE_GRACE_DAYS = 30; + +const DAY_MS = 86_400_000; + +export interface DocumentPurgePayload { + triggeredAt?: string; +} + +/** + * Hard-delete tombstoned `inbound_documents` rows whose `deletedAt` is older + * than the grace horizon. Returns the number of rows purged. Rows inside the + * grace window and live rows are never touched. + */ +export async function purgeExpiredDocumentTombstones( + prisma: PrismaClient, + now: Date = new Date(), +): Promise { + const cutoff = new Date(now.getTime() - DOCUMENT_PURGE_GRACE_DAYS * DAY_MS); + const { count } = await prisma.inboundDocument.deleteMany({ + where: { deletedAt: { not: null, lt: cutoff } }, + }); + return count; +} + +export async function handleDocumentPurge(jobs: Job[]) { + void jobs; + await withBackgroundEvent("job.document_tombstone_purge", async (evt) => { + const prisma = getWorkerPrisma(); + try { + const purged = await purgeExpiredDocumentTombstones(prisma); + evt.setAction({ name: "documents.vault.purge" }); + evt.addMeta("document_purge_deleted", purged); + } catch (err) { + evt.addWarning(`document-tombstone-purge failed: ${err}`); + } + }); +} diff --git a/src/lib/jobs/reminder/register-maintenance.ts b/src/lib/jobs/reminder/register-maintenance.ts index a6d355f6a..507cb20c8 100644 --- a/src/lib/jobs/reminder/register-maintenance.ts +++ b/src/lib/jobs/reminder/register-maintenance.ts @@ -124,6 +124,12 @@ import { handleMedicationInventoryExpire, handleIntakeAutoSkip, } from "./medication-maintenance"; +import { + DOCUMENT_PURGE_QUEUE, + DOCUMENT_PURGE_CRON, + handleDocumentPurge, + type DocumentPurgePayload, +} from "@/lib/jobs/document-purge"; const DATA_BACKUP_QUEUE = "data-backup"; @@ -287,6 +293,11 @@ const allQueues = [ // plaintext medication note; without this entry pg-boss never provisions the // queue and both the boot enqueue and the cron silently no-op. MED_NOTES_ENCRYPTION_BACKFILL_QUEUE, + // Document vault — daily physical purge for tombstones past the 30-day + // undo grace (returns the encrypted blob's TOAST space). Without this entry + // the daily schedule silently no-ops and "deleted" documents hold backup + // weight forever. + DOCUMENT_PURGE_QUEUE, ]; const schedules: ScheduleEntry[] = [ @@ -352,6 +363,9 @@ const schedules: ScheduleEntry[] = [ // fan out one per-user job per account still holding a plaintext medication // note. [MED_NOTES_ENCRYPTION_BACKFILL_QUEUE, MED_NOTES_ENCRYPTION_BACKFILL_CRON], + // Document vault — daily 04:10 Europe/Berlin purge for tombstoned + // documents past the 30-day undo grace. + [DOCUMENT_PURGE_QUEUE, DOCUMENT_PURGE_CRON], ]; /** @@ -448,6 +462,14 @@ export async function registerMaintenanceQueues( { localConcurrency: 1 }, handleMcpTokenCleanup, ); + // Document vault — daily purge of tombstoned documents past the 30-day + // undo grace. Single-flight like every other cleanup queue; the underlying + // deleteMany is idempotent so a duplicate tick is a no-op. + await boss.work( + DOCUMENT_PURGE_QUEUE, + { localConcurrency: 1 }, + handleDocumentPurge, + ); await boss.work( PR_DETECTION_QUEUE, { localConcurrency: PR_DETECTION_CONCURRENCY },