fix(articles): article delete failed for everyone on a phantom column (#1188)

* fix(articles): article delete failed for everyone on a phantom column

Support report (odinaero.se): three unused articles could not be
deleted. Root cause: the DELETE route's usage preflight filtered
invoice_items by company_id, a column that table does not have, so
PostgREST answered 42703 and the route mapped it to
ARTICLE_DELETE_FAILED on every delete since 8a9a930f. The queued
supabase mock swallows chained filters, which is why tests stayed
green; a source-pin regression test now guards the query shape.

Tenancy is unaffected: the preceding articles lookup already scopes
the id to the company, and invoice_items rows are only reachable via
that article's UUID.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: decision log for the currency-honesty batch and the phantom-column lesson

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-25 13:11:02 +02:00
committed by GitHub
parent 01d2a1a946
commit b5e3c476a5
3 changed files with 24 additions and 1 deletions
+3
View File
@@ -372,3 +372,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-25] Article EUR-price support bug: root cause was the edit dialog omitting currency from initialData (form defaulted SEK and PATCHed it back) plus kr-hardcoded formatCurrency calls; export gets a Valuta column + suffix-free decimalColumn instead of extending CURRENCY_FORMAT, importer Valuta detection deferred as follow-up to keep the diff scoped.
[2026-07-25] Reinstated article deactivation as an explicit PATCH active-toggle button on the detail page (support: odinaero.se) instead of reverting DELETE to soft-delete: 8a9a930f intentionally made DELETE hard-delete for unused articles, but that left invoice-referenced articles (ARTICLE_IN_USE) with no retire path; the old deactivate i18n keys were still in messages/ and are reused.
[2026-07-25] Momsdeklaration period selection fused into one chip (year+quarter/month, reverse-chron, 5 years) instead of separate year and period pickers; cadence stays behind the Period chip. Standalone report pages now render their own PageHeader so Exportera sits on the title row per convention 9.
[2026-07-25] Currency-honesty batch (#1177-#1187): aggregates over mixed-currency rows group per currency instead of converting (supplier stats, bank-file totals): read paths must not depend on live FX fetches, and a per-currency line is honest where a converted single number would hide the mix. Where a stored SEK conversion exists but is NULL (invoices.total_sek after a failed rate fetch), aggregates skip-and-flag (unconvertedCount + one visible note) rather than fall back to the raw foreign amount.
[2026-07-25] Editing a draft ROT/RUT invoice keeps the stored encrypted personnummer when the field is left empty and deduction lines remain (#1186): the plaintext is not client-rehydratable by design, so empty-means-keep is the only edit semantics that neither blocks the edit nor wipes the ciphertext; typed value replaces, removing all deduction lines clears.
[2026-07-25] Article delete was broken globally by a phantom invoice_items.company_id filter (42703 -> ARTICLE_DELETE_FAILED) that mocked route tests cannot catch; fixed in #1188 with a source-pin test. Lesson: supabase-mock tests validate flow, never schema: any new filtered column needs a schema-level check or pg-real coverage.
+4 -1
View File
@@ -140,11 +140,14 @@ export const DELETE = withRouteContext(
})
}
// invoice_items has NO company_id column: filtering on it made PostgREST
// 42703 here, which the error branch below turned into ARTICLE_DELETE_FAILED
// for EVERY delete (support: odinaero.se). Tenancy is already enforced by
// the article lookup above: article_id is a UUID owned by this company.
const { count: usageCount, error: usageError } = await supabase
.from('invoice_items')
.select('id', { count: 'exact', head: true })
.eq('article_id', id)
.eq('company_id', companyId)
if (usageError) {
opLog.error('article usage check failed', usageError)
+17
View File
@@ -165,3 +165,20 @@ describe('GET/PATCH/DELETE /api/articles/[id]', () => {
})
})
})
describe('DELETE usage-check query shape', () => {
it('must not filter invoice_items by company_id (column does not exist)', async () => {
// Regression pin for the odinaero support case: invoice_items has no
// company_id column, so filtering on it made PostgREST answer 42703 and
// the route mapped that to ARTICLE_DELETE_FAILED for EVERY delete. The
// queued supabase mock swallows chained filters, so no behavioral mock
// test can catch a phantom column: pin the source instead. Tenancy is
// enforced by the preceding articles lookup.
const fs = await import('node:fs')
const path = await import('node:path')
const src = fs.readFileSync(path.resolve(__dirname, '../[id]/route.ts'), 'utf8')
const usageBlock = src.slice(src.indexOf("from('invoice_items')"))
const firstQuery = usageBlock.slice(0, usageBlock.indexOf('usageError'))
expect(firstQuery).not.toContain("eq('company_id'")
})
})