mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
- 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.
117 lines
4.4 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
}
|