Files
HealthLog/src/lib/jobs/document-purge.ts
T
Marc-André Bombeck 1eac05b454 fix(documents): key imports by source instance, keep a token on its origin, and harden the import path
- Source keys carry the normalised origin of the Paperless-ngx or Papra
  they came from (source_instance on the document, the alias and the
  purge ledger; unique NULLS NOT DISTINCT). A key stored without one
  (v1.39.2 imports) matches any instance of its system, and a lookup
  without one matches any stored instance. The upload route, the lookup
  route and the import script accept or send it; backups carry it.
- Saving a connection reuses the stored token only for the same origin;
  a new origin needs the token again (422 tokenRequired).
- Document ids are checked against what each system issues (Paperless
  digits, Papra [A-Za-z0-9_-], never . or ..) before any URL is built;
  the Papra organization id likewise.
- Paperless API 9 filters dates on created__gte / created__lte, in the
  picker and the script. A search page the local re-check empties reads
  on, up to five source pages. Tags are read past the first 250.
- The import's key pre-check uses the lookup allowance, a failed fetch
  no longer charges the upload allowance, and one import per key runs at
  a time under an advisory lock, so a double click downloads once.
- Reading and deleting one's own connection works with the documents
  module off and the operator list unset.
2026-09-26 14:20:17 +02:00

117 lines
4.4 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. An imported
* document's source key is carried to `DocumentImportKey` on the way out.
*/
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 expired = { deletedAt: { not: null, lt: cutoff } };
return prisma.$transaction(async (tx) => {
// v1.39.2 (#1038) — an imported document's source key outlives its row.
// While the tombstone exists the key sits on it and the upload route
// answers "deleted"; once the row goes, this ledger is what keeps a re-run
// of the importer from storing the document again. Same transaction as the
// delete, so a purge that fails halfway forgets nothing.
const keyed = await tx.inboundDocument.findMany({
where: {
...expired,
OR: [
{ sourceSystem: { not: null }, sourceId: { not: null } },
{ sourceAliases: { some: {} } },
],
},
select: {
userId: true,
sourceSystem: true,
sourceId: true,
sourceInstance: true,
// Further keys the import answered with this document (same bytes).
// They cascade away with the row, so they are copied here first.
sourceAliases: {
select: { sourceSystem: true, sourceId: true, sourceInstance: true },
},
},
});
const keys = keyed.flatMap((row) => [
...(row.sourceSystem && row.sourceId
? [
{
userId: row.userId,
sourceSystem: row.sourceSystem,
sourceId: row.sourceId,
sourceInstance: row.sourceInstance,
},
]
: []),
...row.sourceAliases.map((alias) => ({
userId: row.userId,
sourceSystem: alias.sourceSystem,
sourceId: alias.sourceId,
sourceInstance: alias.sourceInstance,
})),
]);
if (keys.length > 0) {
await tx.documentImportKey.createMany({
data: keys,
skipDuplicates: true,
});
}
const { count } = await tx.inboundDocument.deleteMany({ where: expired });
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);
}
});
}