Files
accounted/lib/documents/unlinked-documents.ts
T
Jakob Wennberg 5fe3ae71a1 feat(mcp): surface documents that are attached to nothing on the attention resource (#1979)
A document_attachments row is reachable from eight places. A row referenced by
none of them is stored, retained for seven years under BFL, and connected to no
bookkeeping at all. Nothing surfaced those, so they accumulated: 4 497 across
210 companies, 481 of them in the preceding week.

The naive predicate is a trap. Without a mime filter the same query returns
15 806 rows, and 11 309 of those are archived PSD2 bank-API responses that are
unlinked by design. Putting them on an orientation surface would hand an agent
eleven thousand items of work it must not do, which is worse than showing
nothing.

So the rule is an allow-list of the mime types an underlag can actually be.
Measured on production: application/json was 11 309 of 11 309 PSD2 archive, and
pdf/png/jpeg/heic were 0 of 4 495. The split is clean, and an allow-list keeps
the next machine-payload format out by default rather than after someone
notices it leaking.

Two passes, mirroring fetchPurchasesWithoutUnderlag: the indexed column filter
first, then eight reference lookups that run only when candidates exist, so a
company with none costs exactly one query. The scan cap is set by URL length
rather than table size, because every candidate id is echoed back through those
eight .in() lookups.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 18:00:27 +02:00

159 lines
6.5 KiB
TypeScript

/**
* Documents in the archive that are attached to nothing.
*
* A `document_attachments` row is reachable from eight places: a journal
* entry (its own `journal_entry_id`), a bank transaction, a receipt, an
* inbox item, a supplier invoice, an invoice delivery, an inbound Peppol
* document, an årsredovisning submission, and a ROT/RUT payout request. A
* row referenced by none of them is stored, retained for seven years under
* BFL, and connected to no bookkeeping at all. Nothing surfaces those today,
* so they accumulate silently: 4 497 of them across 210 companies as of
* 2026-08-27, 481 of them created in the preceding week.
*
* ## Why the mime allow-list is the whole design
*
* The obvious predicate, "current version with no journal_entry_id and no
* referencing row", returns 15 806 rows on production. 11 309 of those are
* `application/json` and every single one is named `psd2-response_<ts>_pN.json`:
* the archived bank-API responses the PSD2 integration stores as evidence of
* each fetch. They are SUPPOSED to have no verifikat and no transaction. Put
* them on an attention surface and an agent is handed 11 309 items of busywork
* that it must not action, which is worse than showing nothing.
*
* Measured on production 2026-08-27: of the unreferenced rows,
* `application/json` was 11 309 of 11 309 PSD2 archive, and pdf / png / jpeg /
* heic were 0 of 4 495. The split is clean, so the rule is an allow-list of
* the mime types an underlag can actually be, not a filename exclusion. A new
* machine-payload format (XML, CSV, an audit bundle) stays out by default
* rather than needing another exclusion added after it starts leaking.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
/**
* Mime types a bookkeeping underlag can plausibly be: something a human could
* open and read as a receipt or an invoice. Everything else on this surface is
* a machine payload that is unlinked by design.
*/
export const UNDERLAG_MIME_TYPES = [
'application/pdf',
'image/png',
'image/jpeg',
'image/heic',
'image/webp',
'image/tiff',
] as const
/**
* The candidate scan is bounded, and the bound is set by URL length rather
* than by table size.
*
* Every candidate id is sent back through eight `.in(column, ids)` lookups,
* and a UUID costs ~38 bytes in a PostgREST query string, so 300 ids is
* already a ~12 KB URL per lookup. A cap in the thousands would exceed what
* the gateway accepts and the eight lookups would start failing, which the
* error handling below would read as "nothing claims these" and would turn
* every candidate into a false positive. Small and honest beats large and
* silently wrong.
*
* 300 covers all but one company on production (median 3, worst 1 298 as of
* 2026-08-27). When the cap is hit, `capped` says so and `count` becomes a
* floor rather than a total, because the caller renders a number either way
* and a silently truncated one would read as "nearly done".
*/
export const UNLINKED_DOCUMENT_SCAN_CAP = 300
/**
* A type alias, not an interface, on purpose: the attention resource assigns
* these straight into its `samples: Record<string, unknown>[]` field, and an
* interface has no implicit index signature so that assignment does not
* type-check. Vitest does not typecheck, so this only ever shows up in
* `npm run build`.
*/
export type UnlinkedDocument = {
id: string
file_name: string
mime_type: string | null
file_size_bytes: number | null
upload_source: string | null
created_at: string
}
export interface UnlinkedDocumentsResult {
documents: UnlinkedDocument[]
count: number
capped: boolean
}
const COLUMNS = 'id, file_name, mime_type, file_size_bytes, upload_source, created_at'
/**
* The eight tables that can claim a document, as [table, column] pairs.
*
* `journal_entry_id` is deliberately absent: it lives on the document row
* itself and is already excluded by the column filter below.
*/
const REFERENCING_COLUMNS: ReadonlyArray<readonly [table: string, column: string]> = [
['transactions', 'document_id'],
['receipts', 'document_id'],
['invoice_inbox_items', 'document_id'],
['supplier_invoices', 'document_id'],
['invoice_deliveries', 'document_attachment_id'],
['peppol_inbound_documents', 'xml_document_id'],
['arsredovisning_submissions', 'dokument_id'],
['rot_rut_payout_requests', 'file_document_id'],
]
/**
* Underlag-shaped documents in this company's archive that nothing references.
*
* Two passes, the same shape as `fetchPurchasesWithoutUnderlag`: the column
* filter is the cheap part the database can index, and the eight reference
* lookups settle it afterwards for the candidates that survived. They run only
* when there are candidates, so the common case (a company with none) costs
* exactly one query.
*/
export async function fetchUnlinkedDocuments(
supabase: SupabaseClient,
companyId: string,
options?: { limit?: number },
): Promise<UnlinkedDocumentsResult> {
const cap = options?.limit ?? UNLINKED_DOCUMENT_SCAN_CAP
const { data: candidates } = await supabase
.from('document_attachments')
.select(COLUMNS)
.eq('company_id', companyId)
.is('journal_entry_id', null)
.not('is_current_version', 'is', false)
.in('mime_type', UNDERLAG_MIME_TYPES as unknown as string[])
.order('created_at', { ascending: false })
.limit(cap)
const rows = (candidates ?? []) as UnlinkedDocument[]
if (rows.length === 0) return { documents: [], count: 0, capped: false }
const ids = rows.map((r) => r.id)
const referenced = new Set<string>()
// Fetched once for the whole candidate set rather than per row. A failing
// lookup is treated as "claims nothing", which can only ever ADD a row to
// the list; the alternative (dropping the whole category on one bad table)
// would hide real work because an unrelated feature's table misbehaved.
await Promise.all(
REFERENCING_COLUMNS.map(async ([table, column]) => {
const { data } = await supabase.from(table).select(column).in(column, ids)
// The column name is resolved at runtime, so the client cannot infer a
// row type here; `unknown` first because the inferred error union does
// not overlap the record shape.
const referencingRows = (data ?? []) as unknown as Array<Record<string, string | null>>
for (const row of referencingRows) {
const value = row[column]
if (value) referenced.add(value)
}
}),
)
const documents = rows.filter((r) => !referenced.has(r.id))
return { documents, count: documents.length, capped: rows.length >= cap }
}