From c5a63934bd44e702f13a13efe6f3f15b7afcc948 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 26 Sep 2026 14:42:13 +0200 Subject: [PATCH] fix(documents): an import of bytes deleted in HealthLog stays deleted under any source key Keyed uploads and picker imports also check deleted, not yet purged documents by content and answer deleted, remembering the new key on the tombstone so the purge carries it into the ledger. A hand upload without a key still stores a new copy. No content hash of a deleted document is kept past the purge. --- src/lib/documents/ingest.ts | 32 +++++++++++ tests/integration/document-sources.test.ts | 64 ++++++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/src/lib/documents/ingest.ts b/src/lib/documents/ingest.ts index 2e21291c4..0932ba813 100644 --- a/src/lib/documents/ingest.ts +++ b/src/lib/documents/ingest.ts @@ -16,6 +16,8 @@ * id names nothing the caller owns; * - sha256 duplicate detection against live rows, remembering the source key * for the existing document so a later delete keeps that key deleted too; + * for an import, also against deleted (not yet purged) rows, which answer + * "deleted" rather than storing the file again; * - the quota gate and the insert in one transaction under a per-user * advisory lock, and the race on either partial unique index resolved the * way the fast paths above would have answered; @@ -216,6 +218,36 @@ export async function ingestDocument( }); if (existing) return answerDuplicate(input, existing); + // An import (a source key is present) of bytes the person deleted here, + // arriving under a key HealthLog has not seen: another system, another + // instance, or a re-scan with a new id. The deletion was a decision about + // the document, not about one key, so it is answered "deleted", and the + // new key is remembered on the tombstone so the purge carries it into the + // ledger. After the 30-day purge the bytes and their hash are gone and only + // the keys remain; no content hash of a deleted document is kept. A person + // uploading the file by hand (no key) is making a new copy on purpose. + if (sourceSystem && sourceId) { + const tombstone = await prisma.inboundDocument.findFirst({ + where: { userId, contentSha256, deletedAt: { not: null } }, + select: { id: true }, + orderBy: { deletedAt: "desc" }, + }); + if (tombstone) { + const remembered = await rememberSourceAlias( + userId, + tombstone.id, + sourceSystem, + sourceId, + sourceInstance, + ); + if (remembered === "limit") return { kind: "aliasLimit" }; + return { + kind: "sourceKey", + match: { state: "deleted", id: tombstone.id }, + }; + } + } + // Quota gate + insert + pre-links in ONE transaction. Usage counts every // non-purged row — tombstones still hold TOAST bytes, so "deleted" bytes // are never invisible weight (undo-delete never changes usage). diff --git a/tests/integration/document-sources.test.ts b/tests/integration/document-sources.test.ts index 0bcb842e6..70776ef58 100644 --- a/tests/integration/document-sources.test.ts +++ b/tests/integration/document-sources.test.ts @@ -638,6 +638,70 @@ describe("search and import", () => { }); describe("review follow-ups", () => { + it("keeps a document deleted in HealthLog deleted when the same bytes arrive under another source key", async () => { + await seedOwner(); + await connect(); + const prisma = getPrismaClient(); + const first = await importDoc({ sourceId: "41" }); + const id = (await first.json()).data.documentId as string; + await prisma.inboundDocument.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + + // The same file, uploaded by another importer under its own key (a + // document token or the script sending from another system). + const form = new FormData(); + form.append( + "file", + new Blob([ + new Uint8Array( + Buffer.concat([DOCS[0].bytes, Buffer.from(`% ${ORIGIN}\n`)]), + ), + ]), + "x.pdf", + ); + form.append("sourceSystem", "PAPRA"); + form.append("sourceId", "doc_same_bytes"); + const { POST } = await import("@/app/api/documents/inbound/route"); + const res = await (POST as unknown as (r: Request) => Promise)( + new Request("http://localhost/api/documents/inbound", { + method: "POST", + body: form, + }), + ); + expect(res.status).toBe(200); + expect((await res.json()).data).toMatchObject({ deleted: true }); + expect(await prisma.inboundDocument.count()).toBe(1); + // The new key is remembered on the deleted document, so the purge + // carries it into the ledger. + expect( + await prisma.documentSourceAlias.count({ + where: { documentId: id, sourceId: "doc_same_bytes" }, + }), + ).toBe(1); + + // A person uploading the same file by hand, without a key, is a + // deliberate new copy and is stored. + const manual = new FormData(); + manual.append( + "file", + new Blob([ + new Uint8Array( + Buffer.concat([DOCS[0].bytes, Buffer.from(`% ${ORIGIN}\n`)]), + ), + ]), + "x.pdf", + ); + const byHand = await (POST as unknown as (r: Request) => Promise)( + new Request("http://localhost/api/documents/inbound", { + method: "POST", + body: manual, + }), + ); + expect(byHand.status).toBe(201); + }); + it("never sends the stored token to a new origin", async () => { await seedOwner(); await connect();