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:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'")
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user