mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(documents): daily purge for tombstoned documents past the undo grace
Delete in the vault is undo-able: DELETE tombstones, the restore endpoint clears the tombstone, and this pg-boss job (daily 04:10 Europe/Berlin, single-flight like every other cleanup) hard-deletes tombstones older than 30 days — returning the TOAST space the encrypted blob holds. Until then a tombstoned row keeps counting against the quota, so deleted bytes are never invisible backup weight. Staged facts cascade via the FK; restore-after-purge answers 409 at the API. Task: W1-T12.
This commit is contained in:
1 parent
972c5fbf48
commit
de9e528614
2 files changed
+80
No files matched your search
@@ -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<number> {
|
||||
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<DocumentPurgePayload>[]) {
|
||||
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}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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<DocumentPurgePayload>(
|
||||
DOCUMENT_PURGE_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleDocumentPurge,
|
||||
);
|
||||
await boss.work<PrDetectionPayload>(
|
||||
PR_DETECTION_QUEUE,
|
||||
{ localConcurrency: PR_DETECTION_CONCURRENCY },
|
||||
|
||||
Reference in new issue
Block a user