Files
HealthLog/src/lib/jobs/document-purge.ts
T
Marc-André Bombeck 67aa3338da feat(jobs): return an outcome from every named queue handler
Sixty-two handler functions across twenty-four modules now resolve to a
JobOutcome instead of undefined. The counts they already wrote to their
wide event ride out in `did`, so a run that deleted nothing and a run
that could not run are no longer the same shape.

Three rules decided each conversion:

  - a catch that swallowed becomes jobFailed, keeping its warning line;
  - a catch that already rethrew keeps its throw, because the throw
    already fails the job and rewrapping it buries the stack;
  - a fan-out pass is judged on the pass. One account's revoked grant
    belongs on that account's integration ledger, not in a queue-wide
    failure that retries the whole cohort, so per-leg failures ride out
    as a users_failed count — visible for the first time.

The retention cleanups are the behaviour change worth naming: every one
of them used to warn and return, so a purge that had not run for weeks
looked like a purge with nothing to do. They fail now. Their registrar
sets retryLimit accordingly in the commit that converts the bindings.

JobFailure carries the thrown error's stack; without it every one of the
101 queues would report the same runJob frame.
2026-07-27 22:56:59 +02:00

66 lines
2.6 KiB
TypeScript

/**
* 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 { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome";
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>[],
): Promise<JobOutcome> {
void jobs;
return 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);
// A night with no expired tombstone purges nothing and is still a run
// that did its work — the zero is the fact, not an absence of one.
return jobDone({ document_purge_deleted: purged });
} catch (err) {
evt.addWarning(`document-tombstone-purge failed: ${err}`);
return jobFailed("document tombstone purge failed", err);
}
});
}