import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' import { linkToJournalEntry } from '@/lib/core/documents/document-service' import { withRouteContext } from '@/lib/api/with-route-context' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' ensureInitialized() /** * POST /api/documents/[id]/link — link a document to a journal entry. * * Body: { journal_entry_id: string, journal_entry_line_id?: string, inbox_item_id?: string } * * When `inbox_item_id` is supplied (the "choose from inbox" flow), the inbox * item is stamped with the verifikat id after a successful link so it drops out * of the active inbox into "Bokförda" — reusing the inbox's own * created_journal_entry_id lifecycle. The document link is the legally-relevant * write and happens first; the inbox stamp is operational housekeeping, so a * stamp failure is logged but does not fail the request (the doc is correctly * attached and the DB immutability trigger still blocks any double-link). */ export const POST = withRouteContext( 'document.link', async (request, ctx, { params }: { params: Promise<{ id: string }> }) => { const { id } = await params const { supabase, companyId, log, requestId } = ctx const opLog = log.child({ documentId: id }) const body = await request.json().catch(() => ({})) if (!body.journal_entry_id) { return errorResponseFromCode('VALIDATION_ERROR', opLog, { requestId, details: { field: 'journal_entry_id', reason: 'required' }, }) } try { const document = await linkToJournalEntry( supabase, companyId!, id, body.journal_entry_id, body.journal_entry_line_id, ) if (body.inbox_item_id) { const { data: stamped, error: inboxError } = await supabase .from('invoice_inbox_items') .update({ created_journal_entry_id: body.journal_entry_id }) .eq('id', body.inbox_item_id) .eq('company_id', companyId!) // Only stamp the inbox item that actually owns this document — a // mismatched pairing becomes a safe no-op rather than mis-marking an // unrelated item as consumed. .eq('document_id', id) .select('id') if (inboxError) { // Non-fatal — the verifikat ↔ underlag link already succeeded. opLog.warn('inbox item stamp after link failed', { inboxItemId: body.inbox_item_id, reason: inboxError.message, }) } else if (!stamped || stamped.length === 0) { // Zero rows updated means the supplied inbox_item_id / document_id // pairing did not match (wrong company, wrong document, or a stale // id). The doc link itself still succeeded; surface the cross-resource // mismatch as an observable warning rather than silently ignoring it. opLog.warn('inbox item stamp matched no rows (cross-resource mismatch)', { inboxItemId: body.inbox_item_id, }) } } return NextResponse.json({ data: document }) } catch (err) { opLog.error('document link failed', err as Error, { journalEntryId: body.journal_entry_id, }) const message = err instanceof Error ? err.message : '' // Linking writes journal_entry_id on document_attachments; the // enforce_period_lock trigger blocks that when the target entry sits in a // closed/locked period. if (/locked\/closed fiscal period|Bokföringen är låst/i.test(message)) { return errorResponseFromCode('PERIOD_LOCKED', opLog, { requestId }) } if (/journal entry not found/i.test(message)) { return errorResponseFromCode('DOC_LINK_ENTRY_NOT_FOUND', opLog, { requestId }) } if (/already linked/i.test(message)) { return errorResponseFromCode('DOC_LINK_ALREADY_LINKED', opLog, { requestId }) } return errorResponseFromCode('DOC_LINK_FAILED', opLog, { requestId, details: { reason: message || 'unknown' }, }) } }, { requireWrite: true }, )