feat(documents): dedupe intake channels on content, not just provenance (#1528)
* feat(documents): dedupe intake channels on content, not just provenance Every ingestion path already computes and stores sha256_hash, but only WhatsApp ever read it back: the manual upload, Resend inbound, and mail hunt deduped on provenance keys alone (or not at all), so the same receipt forwarded to two inboxes, re-hunted by a sweep, or uploaded twice became a second archived document and a second inbox item. With the hunt live and three channels feeding one inbox, that is an unbounded duplicate generator (flows plan, prerequisite PR 1). uploadDocument gains an opt-in dedupeByContent flag: before storing, it looks for a current-version document in the same company with the same SHA-256 and returns it (marked deduplicated) instead of archiving a copy. Opt-in because archival callers must store what they produced even when bytes repeat; the SELECT-then-insert race is accepted exactly as in the WhatsApp intake precedent. uploadAndExtract turns the flag on for every inbox channel. On a hit it adopts the oldest inbox item for that document, so callers always receive a real inbox_item_id, and only files a new item (against the EXISTING document) when the content entered the archive outside the inbox. The mail hunt skips outright: its provenance key catches the same message re-hunted, the content check catches the same receipt arriving through another inbox. WhatsApp keeps its own pre-check, which also drives the duplicate reply to the sender. No migration: the hash column and its index have existed since the original archive schema. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(documents): review round: fail closed, adopt-or-file in the hunt, audit trail CodeRabbit: both dedupe lookups failed OPEN, so a transient DB error would silently archive the duplicate the feature exists to prevent; both now throw before anything is stored, and a regression test locks it. The ingest test also asserts the dedupeByContent flag in the production call, so removing the flag fails the suite. Swedish compliance review, both findings real: (1) the mail hunt's unconditional skip could swallow a receipt whose content matches a document that never passed the inbox (a manually attached copy), leaving an affärshändelse without underlag routing (BFL 5 kap): the hunt now mirrors the funnel's adopt-or-file semantics, skipping only when an inbox item already carries the document and otherwise filing an item against the EXISTING document. (2) The skip decision now lands in behandlingshistorik as DocumentDuplicateSkipped (BFNAR 2013:2 kap 8), not just the app log. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(receipt-hunt): keep the audit payload pseudonymous; lock the skip trail in tests Review round 2. The DocumentDuplicateSkipped payload carried the mailbox address, violating the processing-history contract (pseudonymous IDs only, never emails); the digit-shaped PII validator would not have caught it, which is exactly why the contract must hold at the call site. Which mailbox first delivered the receipt is already on the existing item's channel_context. Tests now assert the audit event lands with the right identifiers and no address, and that a history outage still skips rather than filing a duplicate. Not changed: a duplicate-lookup error still soft-fails the attachment (warn + continue). Aborting the candidate would contradict this function's documented contract (one bad message never costs the night's hunt); fail-closed holds either way, and the next sweep retries since no item was filed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
05b0c57ecd
commit
845add4573
@@ -307,6 +307,72 @@ describe('uploadDocument', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('returns the existing document instead of storing a copy when dedupeByContent hits', async () => {
|
||||
const existing = makeDocumentAttachment({ id: 'doc-orig', sha256_hash: 'same' })
|
||||
results = [
|
||||
{ data: [existing], error: null }, // dedupe lookup
|
||||
]
|
||||
|
||||
const handler = vi.fn()
|
||||
eventBus.on('document.uploaded', handler)
|
||||
|
||||
const upload = vi.fn().mockResolvedValue({ data: {}, error: null })
|
||||
const supabase = makeClient({ upload })
|
||||
const result = await uploadDocument(supabase as never, 'user-1', 'company-1', {
|
||||
name: 'kvitto.pdf',
|
||||
buffer: pdfBuffer(),
|
||||
type: 'application/pdf',
|
||||
}, { dedupeByContent: true })
|
||||
|
||||
expect(result.id).toBe('doc-orig')
|
||||
expect(result.deduplicated).toBe(true)
|
||||
// Nothing reaches storage and no document.uploaded fires: the archive
|
||||
// already holds this content, so re-extraction must not run either.
|
||||
expect(upload).not.toHaveBeenCalled()
|
||||
expect(handler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects when the dedupe lookup itself fails, touching nothing', async () => {
|
||||
// Fail closed: treating a broken lookup as "no match" would archive the
|
||||
// duplicate the flag exists to prevent, silently, on transient DB errors.
|
||||
results = [{ data: null, error: { message: 'connection reset' } }]
|
||||
|
||||
const handler = vi.fn()
|
||||
eventBus.on('document.uploaded', handler)
|
||||
|
||||
const upload = vi.fn().mockResolvedValue({ data: {}, error: null })
|
||||
const supabase = makeClient({ upload })
|
||||
|
||||
await expect(
|
||||
uploadDocument(supabase as never, 'user-1', 'company-1', {
|
||||
name: 'kvitto.pdf',
|
||||
buffer: pdfBuffer(),
|
||||
type: 'application/pdf',
|
||||
}, { dedupeByContent: true }),
|
||||
).rejects.toThrow(/dedupe lookup failed/i)
|
||||
expect(upload).not.toHaveBeenCalled()
|
||||
expect(handler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stores normally when dedupeByContent finds no match', async () => {
|
||||
results = [
|
||||
{ data: [], error: null }, // dedupe lookup: miss
|
||||
{ data: makeDocumentAttachment({ id: 'doc-new' }), error: null }, // insert
|
||||
]
|
||||
|
||||
const upload = vi.fn().mockResolvedValue({ data: {}, error: null })
|
||||
const supabase = makeClient({ upload })
|
||||
const result = await uploadDocument(supabase as never, 'user-1', 'company-1', {
|
||||
name: 'kvitto.pdf',
|
||||
buffer: pdfBuffer(),
|
||||
type: 'application/pdf',
|
||||
}, { dedupeByContent: true })
|
||||
|
||||
expect(result.id).toBe('doc-new')
|
||||
expect(result.deduplicated).toBeUndefined()
|
||||
expect(upload).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('writes to the company-scoped key, not the legacy uploader-scoped key', async () => {
|
||||
results = [{ data: makeDocumentAttachment({ id: 'doc-1' }), error: null }]
|
||||
|
||||
|
||||
@@ -597,8 +597,18 @@ export async function uploadDocument(
|
||||
upload_source?: DocumentUploadSource
|
||||
journal_entry_id?: string
|
||||
journal_entry_line_id?: string
|
||||
/**
|
||||
* Content dedupe for intake channels: before storing, look for a
|
||||
* current-version document in the same company with the same SHA-256 and
|
||||
* return it (marked `deduplicated`) instead of archiving a copy. Opt-in,
|
||||
* because archival callers (sent invoices, filings, bank exports) must
|
||||
* store what they produced even when the bytes repeat. SELECT-then-insert
|
||||
* leaves a small concurrent-upload race, accepted exactly as in the
|
||||
* WhatsApp intake precedent: the loser stores a copy, nothing corrupts.
|
||||
*/
|
||||
dedupeByContent?: boolean
|
||||
} = {}
|
||||
): Promise<DocumentAttachment> {
|
||||
): Promise<DocumentAttachment & { deduplicated?: boolean }> {
|
||||
await ensureDocumentsBucket()
|
||||
|
||||
// Reject corrupt uploads at the boundary: see validateDocumentMagicBytes.
|
||||
@@ -610,6 +620,28 @@ export async function uploadDocument(
|
||||
// Compute SHA-256 hash
|
||||
const sha256Hash = await computeSHA256(file.buffer)
|
||||
|
||||
if (metadata.dedupeByContent) {
|
||||
// Oldest current-version match wins so repeated deliveries keep
|
||||
// converging on the same archived original. Pre-dedupe data can hold
|
||||
// several identical documents, hence limit(1) rather than maybeSingle.
|
||||
const { data: existing, error: dedupeError } = await supabase
|
||||
.from('document_attachments')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.eq('sha256_hash', sha256Hash)
|
||||
.eq('is_current_version', true)
|
||||
.order('created_at', { ascending: true })
|
||||
.limit(1)
|
||||
if (dedupeError) {
|
||||
// Fail closed: treating a broken lookup as "no match" would archive
|
||||
// the duplicate this flag exists to prevent, silently, on every
|
||||
// transient DB error. Intake callers (webhooks, sweeps) retry.
|
||||
throw new Error(`Content dedupe lookup failed: ${dedupeError.message}`)
|
||||
}
|
||||
const hit = (existing as DocumentAttachment[] | null)?.[0]
|
||||
if (hit) return { ...hit, deduplicated: true }
|
||||
}
|
||||
|
||||
// Company-scoped storage key: the tenant id must be IN the key so the
|
||||
// storage RLS policy can revoke access when a membership is removed.
|
||||
const storagePath = buildDocumentStoragePath(companyId, userId, file.name)
|
||||
|
||||
Reference in New Issue
Block a user