* fix(documents): anchor underlag at deferred SI booking, sanctioned duplicate detach Support case 2026-08-24: a verifikat booked from a leverantorsfaktura/utlagg stayed under 'Saknar underlag' with the PDF attached, and a twice-uploaded underlag could only be replaced, never removed. - POST /api/supplier-invoices/[id]/book now calls anchorSupplierInvoiceDocument() after the CAS link: the deferred (#967) flow was the last booking surface that never anchored the invoice's retained source document, so every missing-underlag surface kept flagging the registration verifikat until payment. - Repair migration 20260824150000 re-runs the 20260727180000 sweep for rows created since (idempotent, open unlocked periods only). - New detach_underlag_duplicate RPC (migration 20260824151000): the one sanctioned path to detach a redundant duplicate underlag from a posted verifikat. Guarded: writer role, open unlocked period, company lock date, at least one other anchored underlag must remain (BFL 5 kap 7 par), pinned docs (transactions/supplier_invoices.document_id) stay replace-only. Audit-logged first, transaction-local gnubok.allow_delete carve-out. The file is never deleted: it returns to the unlinked pool. - POST /api/documents/[id]/detach + 'Koppla bort dubblett' in the verifikat attachments blocked-dialog when the entry keeps 2+ direct docs (sv+en). - Tests: book-route anchor assertions, detach route unit tests, pg-real suite for the RPC incl. the direct-UPDATE-stays-blocked invariant. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(documents): harden detach_underlag_duplicate per skeptic findings - Require sha256 identity: detach only when a remaining anchored sibling carries the same immutable sha256_hash, so only byte-identical duplicates ever leave a verifikat (two different handlingar both stay behind the WORM guards). UI gates the button on the same condition. - Enforce the documented posted-status guard (reversed/cancelled verifikat refuse detach). - Set company_id on the RPC's audit_log row: the SELECT policy filters on company_id, so the provenance row was invisible to every reader (same defect 20260528120600 fixed for delete_last_voucher). - Swedish 403 message on the tenant guard (CodeRabbit). - pg tests: closed-period case now seeds open and closes via UPDATE (the period-lock trigger blocks seeding into a closed period), duplicate pairs share a hash, added non-duplicate and reversed-entry refusals, audit assertion pins the RPC's own row (description + company_id + actor). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
49 lines
2.0 KiB
TypeScript
49 lines
2.0 KiB
TypeScript
import { NextResponse } from 'next/server'
|
|
import { withRouteContext } from '@/lib/api/with-route-context'
|
|
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
|
|
|
/**
|
|
* POST /api/documents/[id]/detach
|
|
*
|
|
* Detach a redundant duplicate underlag from its posted verifikation. The
|
|
* detach_underlag_duplicate RPC enforces everything (writer role, open and
|
|
* unlocked period, company lock date, at least one other anchored underlag
|
|
* remaining, not pinned to a transaction or supplier invoice) and writes an
|
|
* append-only audit_log row before the carve-out UPDATE. The document itself
|
|
* is never deleted: it returns to the unlinked document pool, where the
|
|
* ordinary deletion rules apply (an unlinked doc may be deleted).
|
|
*/
|
|
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
|
'documents.detach',
|
|
async (_request, { supabase, companyId, user, log }, { params }) => {
|
|
const { id } = await params
|
|
|
|
const { data, error } = await supabase.rpc('detach_underlag_duplicate', {
|
|
p_company_id: companyId,
|
|
p_document_id: id,
|
|
p_user_id: user.id,
|
|
})
|
|
|
|
if (error) {
|
|
// Rule violations are plain RAISE EXCEPTION (P0001) with user-facing
|
|
// Swedish messages: surface verbatim as 409. Tenant guard raises 42501.
|
|
if (error.code === 'P0001') {
|
|
return NextResponse.json({ error: getErrorMessage(error) }, { status: 409 })
|
|
}
|
|
if (error.code === '42501') {
|
|
// The RPC's tenant-guard message is English (log/diagnostic text);
|
|
// getErrorMessage would pass it through verbatim, so map it here.
|
|
return NextResponse.json(
|
|
{ error: 'Du saknar behörighet att ändra underlag i det här företaget.' },
|
|
{ status: 403 },
|
|
)
|
|
}
|
|
log.error('detach_underlag_duplicate failed', new Error(error.message), { documentId: id })
|
|
return NextResponse.json({ error: 'Underlaget kunde inte kopplas bort' }, { status: 500 })
|
|
}
|
|
|
|
return NextResponse.json({ data })
|
|
},
|
|
{ requireWrite: true },
|
|
)
|