237b77a366
* fix(security): guard MCP test keys, RLS role gate + voucher RPC guards, /api MFA gate, deps - MCP: force dry-run / block writes for test-mode API keys in tools/call (extensions/general/mcp-server) - DB: current_user_can_write role gate on write policies (40 tables) + tenant guards, SET search_path, REVOKE anon on commit_journal_entry / next_voucher_number / detect_voucher_gaps (migration 20260702093000) - Middleware: MFA (AAL2) gate on cookie-authenticated /api routes via apiPathSkipsMfaGate - Deps: npm audit fix clears mailparser/linkify-it/nodemailer/svix/uuid highs; xlsx -> SheetJS 0.20.3 Adds unit + pg-real tests. Does not touch in-progress ROT/RUT or invoice-email-texts work. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(invoices): rot/rut begäran om utbetalning — HUS XML (V6), payout tracking + settlement, MCP tool Generates Skatteverkets begäran-om-utbetalning file (schema V6) from paid ROT/RUT invoices — no submission API exists, the file is uploaded manually at skatteverket.se. Headless by design for now: API routes + MCP tool (gnubok_generate_rot_rut_file), no UI surfaces. - lib/invoices/rot-rut-file.ts: pure XML generator with deterministic per-invoice blockers (hours, work type, personnummer, property info, mixed rot+rut, XSD limits) + 31 January deadline warnings - rot_rut_payout_requests(+items) tables: one active begäran per invoice (DB triggers incl. reactivation guard), RLS, audit, pg-real tests - Settlement: POST /settle books debit 1930 / credit 1513 via the engine (source_type rot_rut_payout); partial payouts → partially_paid - Work-type lists corrected against Begaran.xsd: IT-tjänster is rut-only, snöskottning/tillsyn/tvätt added (schablontjänster utfört-only) - Fix: invoice-level fastighetsbeteckning was validated but never persisted — now stamped onto rot lines in build-invoice-write; API accepts bostadsrätt pair (lägenhetsnr + BRF orgnr, editor UI deferred) - invoice_items.brf_org_number migration + MCP scope invoices:write Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(invoices): per-company editable invoice email texts Add an "E-posttexter" section under Settings -> Fakturering where the subject, greeting, body and sign-off of the standard invoice email can be customized per company in Swedish and English. Fields pre-fill with the standard texts and only diffs from the standard are stored (company_settings.invoice_email_texts JSONB), so future improvements to the stock wording still reach companies that have not customized. Each field has a reset-to-standard button; cleared fields snap back. Texts support a fixed placeholder set (invoice number, customer name, first name, company, due date, amount) substituted at send time in a single pass; unknown placeholders stay literal. Custom texts are HTML-escaped after substitution, newlines become <br> in the HTML variant, and subject lines are flattened to a single header line. Overrides apply to standard invoices only - credit notes, proforma and delivery notes keep the stock texts. All send paths (UI, v1 API, MCP approval, recurring) pick the texts up via the existing settings row. The Zod schema half of this change (InvoiceEmailTextsSchema in lib/api/schemas.ts) was inadvertently included in 8291f745. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(documents): accept PDFs with preamble before %PDF- header, surface content rejections as 400 detectFileMagic required the %PDF- signature at byte 0 (BOM aside), rejecting genuine PDFs that carry a leading newline or junk bytes — files every ISO 32000 reader opens fine. Now scan the first 1024 bytes for the signature, matching real-reader behavior. Image types stay strict at offset 0 to keep the anti-placeholder defense tight. Magic-byte rejections were also mislabeled as DOC_UPLOAD_STORAGE_FAILED (500 'Filen kunde inte sparas'), blaming storage for a client-side file problem. Both upload routes now map them to a new DOC_UPLOAD_INVALID_CONTENT (400) with an accurate message. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(bookkeeping): full keyboard flow for manual journal entry Enter now drives the whole verifikat flow: verifikationstext drops into the first row missing an account, konto commits advance to debet, Enter on an empty debet hops to kredit, and an entered amount jumps to the next row. Once the voucher balances, Enter opens the review (unchanged gate) and the auto-focused confirm posts it — including through the no-underlag warning dialog. Escape in the inline review goes back to the form. Also fixes an Enter footgun in AccountCombobox: a bare Enter on a freshly focused field no longer selects the first account in the list — selection now requires typing or arrow navigation; otherwise Enter re-commits the current value or bubbles to the form-level handler. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: add custom inbound domains management for companies - Implemented functionality to allow companies to claim and manage their own inbound email domains via Resend's API. - Created a new table `company_inbound_domains` to store domain information, including status and DNS records. - Added necessary RLS policies to restrict access based on user roles (owner/admin). - Developed functions for domain normalization, validation, claiming, verification, and removal. - Implemented webhook handling for domain status updates from Resend. - Added comprehensive tests for RLS, constraints, and triggers related to the new domain management feature. * fix: address PR #878 review findings and CI failures - migrations: drop the ai_usage_tracking policy block from the role-gate migration — the table was removed by 20260504120000_remove_ai_subsystem and only lingers on staging as drift; a from-scratch chain (pg-real, Supabase preview) failed on it - invoice-inbox: never flip a custom domain to verified off a domain.updated webhook alone — confirm the receiving capability with Resend first (fail-closed); normalize both sides of the orphan-adoption domain match - rot/rut: block files where begärt belopp exceeds what the buyer paid (DEDUCTION_EXCEEDS_PAYMENT); tighten brf_org_number validation to real orgnr shapes; parameterize the settlement bank account (19xx, default 1930) - rot/rut routes: log acting user on financial mutations, stop swallowing item mirror errors, narrow response projections (no customer ids through the invoice join); document the deliberate inline-XML decision - documents: stop echoing raw storage-layer error messages to clients Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: round-2 CI + compliance findings on PR #878 - migrations: the role-gate migration targeted automation_webhooks, which 20260515170000_webhooks_v2 renamed to webhooks on the canonical chain (staging kept the old name — drift); gate public.webhooks instead, dropping legacy schema-sync policy names defensively. Restore the 20260623130000 owner fallback in next_voucher_number that the stale copied-verbatim body silently reverted (caught by engine.pg locally). Full migration chain verified from scratch against supabase/postgres:15. - mcp: bump the tools/list payload ceiling 44K -> 45K — main's #877 qualified-identifier schemas plus this branch's rot/rut tool crossed the ceiling only in combination; documented in the test's history log. - rot/rut: refuse partial settlement before Skatteverkets beslut is recorded (would bypass the PATCH lifecycle and strand the request); block zero-kronor ärenden (ZERO_DEDUCTION); require sekelsiffra 16 on 12-digit brf orgnr in both schema validation and normalizeBrfOrgNr Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: rename branch migrations off main's colliding versions After the merge with main, two versions were shared by two files each (20260702100000: rot_rut_payout_requests vs company_settings_dimensions_ enabled; 20260702130000: invoice_email_texts vs pending_operations_add_ create_dimension_value). psql-based CI applies by filename and doesn't care, but Supabase branching records migrations by version (PK) — the second file with the same version breaks the preview with a schema_migrations_pkey duplicate. Neither branch migration is version- recorded on staging or prod, so renaming to fresh 20260703 versions is safe; nothing between the old and new positions depends on these objects. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): scope the /api MFA-gate bypass to real Bearer-auth surfaces Any Authorization header — attacker-controlled — used to skip the AAL2 gate for every /api route, so a stolen-password AAL1 cookie session could reach cookie-authenticated routes (which ignore the header) by attaching `Authorization: x`. The skip is now scoped to the surfaces whose auth contract IS the header (/api/v1 API keys, the MCP endpoint's OAuth tokens); pure Bearer callers elsewhere (cron secret, signed webhooks) carry no cookie session and were never touched by the gate, which only fires for cookie users. Superagent P2 on PR #878. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: normalize path separators in dimension statutory guard scan The route scan compared walked file paths against a POSIX-path allowlist, so the suite failed on Windows (backslash separators) while passing on Linux CI. Normalize the scanned paths to forward slashes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
488 lines
16 KiB
TypeScript
488 lines
16 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
||
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||
import { eventBus } from '@/lib/events'
|
||
import type { DocumentAttachment, DocumentUploadSource } from '@/types'
|
||
|
||
/**
|
||
* Document Service - WORM-style document archive
|
||
*
|
||
* Handles document upload with SHA-256 integrity, version chains,
|
||
* and linking to journal entries. Deletion is blocked by DB triggers
|
||
* for documents linked to committed entries.
|
||
*/
|
||
|
||
/**
|
||
* Sanitize a filename for use in Supabase Storage keys.
|
||
* Replaces spaces and non-ASCII characters with underscores,
|
||
* collapses consecutive underscores, and truncates to avoid
|
||
* exceeding Supabase Storage path length limits.
|
||
*/
|
||
function sanitizeFileName(name: string): string {
|
||
const dotIndex = name.lastIndexOf('.')
|
||
const ext = dotIndex > 0 ? name.slice(dotIndex) : ''
|
||
const base = dotIndex > 0 ? name.slice(0, dotIndex) : name
|
||
|
||
const sanitizedBase = base
|
||
.replace(/[^a-zA-Z0-9._-]/g, '_')
|
||
.replace(/_+/g, '_')
|
||
.replace(/^_|_$/g, '')
|
||
.slice(0, 100) || 'file'
|
||
const sanitizedExt = ext.replace(/[^a-zA-Z0-9.]/g, '_')
|
||
|
||
return sanitizedBase + sanitizedExt
|
||
}
|
||
|
||
export const MAX_DOCUMENT_SIZE = 10 * 1024 * 1024 // 10 MB
|
||
export const ALLOWED_DOCUMENT_TYPES = [
|
||
'application/pdf',
|
||
'image/jpeg',
|
||
'image/png',
|
||
'image/webp',
|
||
]
|
||
|
||
/**
|
||
* Validate file size and MIME type before upload.
|
||
* Returns an error string or null if valid.
|
||
*/
|
||
export function validateDocumentFile(file: { size: number; type?: string }): string | null {
|
||
if (file.size === 0) {
|
||
return 'Filen är tom'
|
||
}
|
||
if (file.size > MAX_DOCUMENT_SIZE) {
|
||
return `Filen är för stor (max ${MAX_DOCUMENT_SIZE / 1024 / 1024} MB)`
|
||
}
|
||
if (!file.type || !ALLOWED_DOCUMENT_TYPES.includes(file.type)) {
|
||
return 'Otillåten filtyp. Tillåtna: PDF, JPG, PNG, WebP.'
|
||
}
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* Inspect the first bytes of a buffer to identify the actual file format.
|
||
* Defends against callers (typically MCP agents) that base64-encode a text
|
||
* placeholder or summary instead of the real binary file — those uploads
|
||
* succeed at the storage layer but the bytes are unreadable as a PDF/image.
|
||
*/
|
||
function detectFileMagic(bytes: Uint8Array): string | null {
|
||
if (bytes.length < 4) return null
|
||
// PDF: %PDF- anywhere in the first 1024 bytes. ISO 32000 readers accept a
|
||
// preamble before the header (Acrobat scans the first 1 KB), and real-world
|
||
// invoice PDFs arrive with leading newlines/junk — requiring offset 0
|
||
// rejected files every normal reader opens. Image types stay strict at
|
||
// offset 0: genuine image files always start with their signature, and the
|
||
// looseness is not needed there to keep the anti-placeholder defense tight.
|
||
const pdfScanEnd = Math.min(bytes.length - 5, 1024)
|
||
for (let i = 0; i <= pdfScanEnd; i++) {
|
||
if (
|
||
bytes[i] === 0x25 &&
|
||
bytes[i + 1] === 0x50 &&
|
||
bytes[i + 2] === 0x44 &&
|
||
bytes[i + 3] === 0x46 &&
|
||
bytes[i + 4] === 0x2D
|
||
) return 'application/pdf'
|
||
}
|
||
// PNG: 89 50 4E 47
|
||
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47) return 'image/png'
|
||
// JPEG: FF D8 FF
|
||
if (bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF) return 'image/jpeg'
|
||
// WebP: RIFF<4-byte size>WEBP
|
||
if (
|
||
bytes.length >= 12 &&
|
||
bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
|
||
bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50
|
||
) return 'image/webp'
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* XHTML/XML has no binary magic number. For the declared type
|
||
* application/xhtml+xml (system-generated iXBRL årsredovisningar) we instead
|
||
* require the content to start with an XML declaration, an HTML doctype, or
|
||
* an <html> root element (after an optional UTF-8 BOM and leading
|
||
* whitespace). This branch is consulted ONLY for that declared type — it
|
||
* never loosens detection for PDF/PNG/JPEG/WEBP uploads.
|
||
*/
|
||
function looksLikeXhtml(bytes: Uint8Array): boolean {
|
||
const offset = bytes[0] === 0xEF && bytes[1] === 0xBB && bytes[2] === 0xBF ? 3 : 0
|
||
const head = Buffer.from(bytes.slice(offset, offset + 256))
|
||
.toString('utf8')
|
||
.replace(/^[\s]+/, '')
|
||
.toLowerCase()
|
||
return head.startsWith('<?xml') || head.startsWith('<!doctype html') || head.startsWith('<html')
|
||
}
|
||
|
||
/**
|
||
* Verify the buffer actually contains a file of the declared type.
|
||
* Returns an error string or null if valid. HEIC has many ftyp brands so
|
||
* we skip the check for now — the UI path doesn't allow HEIC anyway, only
|
||
* the MCP upload tool does, and corrupted HEIC has not been observed.
|
||
*/
|
||
export function validateDocumentMagicBytes(buffer: ArrayBuffer, declaredMimeType: string): string | null {
|
||
if (declaredMimeType === 'image/heic') return null
|
||
if (declaredMimeType === 'application/xhtml+xml') {
|
||
if (looksLikeXhtml(new Uint8Array(buffer))) return null
|
||
return `Filinnehållet kunde inte verifieras som ${declaredMimeType}. Filen verkar inte vara ett XHTML/XML-dokument.`
|
||
}
|
||
const detected = detectFileMagic(new Uint8Array(buffer))
|
||
if (!detected) {
|
||
return `Filinnehållet kunde inte verifieras som ${declaredMimeType}. Filen verkar vara skadad eller inte en riktig binärfil — vid uppladdning via API, kontrollera att file_content_base64 är base64-kodade råbytes, inte en textrepresentation.`
|
||
}
|
||
if (detected !== declaredMimeType) {
|
||
return `Filinnehållet matchar inte den angivna filtypen (förväntade ${declaredMimeType}, hittade ${detected}).`
|
||
}
|
||
return null
|
||
}
|
||
|
||
let bucketVerified = false
|
||
|
||
/** @internal Reset bucket verification flag — for testing only */
|
||
export function _resetBucketVerified() {
|
||
bucketVerified = false
|
||
}
|
||
|
||
/**
|
||
* Ensure the 'documents' storage bucket exists, creating it if missing.
|
||
* Runs once per process lifetime (same pattern as ensureInitialized).
|
||
*
|
||
* Uses a cookieless service-role client for bucket admin operations
|
||
* (getBucket/createBucket require service-role). This avoids the cookie
|
||
* dependency that hangs in API-key auth contexts (e.g. MCP server).
|
||
*/
|
||
async function ensureDocumentsBucket(): Promise<void> {
|
||
if (bucketVerified) return
|
||
|
||
const serviceClient = createServiceClientNoCookies()
|
||
const { data: bucket } = await serviceClient.storage.getBucket('documents')
|
||
|
||
if (!bucket) {
|
||
await serviceClient.storage.createBucket('documents', {
|
||
public: false,
|
||
fileSizeLimit: 52428800, // 50 MB
|
||
})
|
||
}
|
||
|
||
bucketVerified = true
|
||
}
|
||
|
||
/**
|
||
* Compute SHA-256 hash of a file buffer
|
||
*/
|
||
export async function computeSHA256(buffer: ArrayBuffer): Promise<string> {
|
||
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer)
|
||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
|
||
}
|
||
|
||
/**
|
||
* Upload a document and create a record with SHA-256 integrity hash
|
||
*/
|
||
export async function uploadDocument(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
companyId: string,
|
||
file: { name: string; buffer: ArrayBuffer; type?: string },
|
||
metadata: {
|
||
upload_source?: DocumentUploadSource
|
||
journal_entry_id?: string
|
||
journal_entry_line_id?: string
|
||
} = {}
|
||
): Promise<DocumentAttachment> {
|
||
await ensureDocumentsBucket()
|
||
|
||
// Reject corrupt uploads at the boundary — see validateDocumentMagicBytes.
|
||
if (file.type) {
|
||
const magicError = validateDocumentMagicBytes(file.buffer, file.type)
|
||
if (magicError) throw new Error(magicError)
|
||
}
|
||
|
||
// Compute SHA-256 hash
|
||
const sha256Hash = await computeSHA256(file.buffer)
|
||
|
||
// Generate storage path
|
||
const timestamp = Date.now()
|
||
const storagePath = `documents/${userId}/${timestamp}_${sanitizeFileName(file.name)}`
|
||
|
||
// Upload to Supabase Storage
|
||
const { error: uploadError } = await supabase.storage
|
||
.from('documents')
|
||
.upload(storagePath, file.buffer, {
|
||
contentType: file.type || 'application/octet-stream',
|
||
upsert: false,
|
||
})
|
||
|
||
if (uploadError) {
|
||
throw new Error(`Failed to upload document: ${uploadError.message}`)
|
||
}
|
||
|
||
// Create document record
|
||
const { data, error } = await supabase
|
||
.from('document_attachments')
|
||
.insert({
|
||
user_id: userId,
|
||
company_id: companyId,
|
||
storage_path: storagePath,
|
||
file_name: file.name,
|
||
file_size_bytes: file.buffer.byteLength,
|
||
mime_type: file.type || null,
|
||
sha256_hash: sha256Hash,
|
||
version: 1,
|
||
is_current_version: true,
|
||
uploaded_by: userId,
|
||
upload_source: metadata.upload_source || 'file_upload',
|
||
digitization_date: new Date().toISOString(),
|
||
journal_entry_id: metadata.journal_entry_id || null,
|
||
journal_entry_line_id: metadata.journal_entry_line_id || null,
|
||
})
|
||
.select()
|
||
.single()
|
||
|
||
if (error) {
|
||
// Clean up uploaded file on record creation failure
|
||
await supabase.storage.from('documents').remove([storagePath])
|
||
throw new Error(`Failed to create document record: ${error.message}`)
|
||
}
|
||
|
||
const result = data as DocumentAttachment
|
||
|
||
await eventBus.emit({
|
||
type: 'document.uploaded',
|
||
payload: { document: result, userId, companyId },
|
||
})
|
||
|
||
return result
|
||
}
|
||
|
||
/**
|
||
* Create a new version of an existing document (WORM: old version is superseded)
|
||
*
|
||
* Uses the create_document_version RPC for atomic versioning with:
|
||
* - Row-level locking (prevents concurrent versioning race condition)
|
||
* - Cryptographic hash chain (prev_version_hash links to previous version)
|
||
* - Single transaction (insert new + mark old superseded)
|
||
*/
|
||
export async function createNewVersion(
|
||
supabase: SupabaseClient,
|
||
userId: string,
|
||
originalId: string,
|
||
file: { name: string; buffer: ArrayBuffer; type?: string }
|
||
): Promise<DocumentAttachment> {
|
||
await ensureDocumentsBucket()
|
||
|
||
if (file.type) {
|
||
const magicError = validateDocumentMagicBytes(file.buffer, file.type)
|
||
if (magicError) throw new Error(magicError)
|
||
}
|
||
|
||
// Compute SHA-256 hash
|
||
const sha256Hash = await computeSHA256(file.buffer)
|
||
|
||
// Upload new file to Storage
|
||
const timestamp = Date.now()
|
||
const storagePath = `documents/${userId}/${timestamp}_${sanitizeFileName(file.name)}`
|
||
|
||
const { error: uploadError } = await supabase.storage
|
||
.from('documents')
|
||
.upload(storagePath, file.buffer, {
|
||
contentType: file.type || 'application/octet-stream',
|
||
upsert: false,
|
||
})
|
||
|
||
if (uploadError) {
|
||
throw new Error(`Failed to upload new version: ${uploadError.message}`)
|
||
}
|
||
|
||
// Atomic version creation via RPC (row lock + hash chain + supersede in one tx)
|
||
const { data: newDocId, error: rpcError } = await supabase.rpc('create_document_version', {
|
||
p_user_id: userId,
|
||
p_original_doc_id: originalId,
|
||
p_storage_path: storagePath,
|
||
p_file_name: file.name,
|
||
p_file_size_bytes: file.buffer.byteLength,
|
||
p_mime_type: file.type || null,
|
||
p_sha256_hash: sha256Hash,
|
||
})
|
||
|
||
if (rpcError) {
|
||
// Clean up uploaded file on RPC failure
|
||
await supabase.storage.from('documents').remove([storagePath])
|
||
throw new Error(`Failed to create new version: ${rpcError.message}`)
|
||
}
|
||
|
||
// Fetch the complete new version record
|
||
const { data: newDoc, error: fetchError } = await supabase
|
||
.from('document_attachments')
|
||
.select('*')
|
||
.eq('id', newDocId)
|
||
.single()
|
||
|
||
if (fetchError || !newDoc) {
|
||
throw new Error('Failed to fetch new version record')
|
||
}
|
||
|
||
return newDoc as DocumentAttachment
|
||
}
|
||
|
||
/**
|
||
* Link an existing document to a journal entry
|
||
*/
|
||
export async function linkToJournalEntry(
|
||
supabase: SupabaseClient,
|
||
companyId: string,
|
||
documentId: string,
|
||
journalEntryId: string,
|
||
journalEntryLineId?: string
|
||
): Promise<DocumentAttachment> {
|
||
// The document is company-filtered below, but the journal entry id arrives
|
||
// from the client and the FK only requires existence — verify it belongs to
|
||
// the same company so a crafted id can't anchor a document to another
|
||
// tenant's verifikation. (RLS hides foreign rows either way; this makes the
|
||
// rejection explicit instead of a confusing downstream state.)
|
||
const { data: entry, error: entryError } = await supabase
|
||
.from('journal_entries')
|
||
.select('id')
|
||
.eq('id', journalEntryId)
|
||
.eq('company_id', companyId)
|
||
.maybeSingle()
|
||
|
||
if (entryError || !entry) {
|
||
throw new Error('Failed to link document: journal entry not found')
|
||
}
|
||
|
||
const { data, error } = await supabase
|
||
.from('document_attachments')
|
||
.update({
|
||
journal_entry_id: journalEntryId,
|
||
journal_entry_line_id: journalEntryLineId || null,
|
||
})
|
||
.eq('id', documentId)
|
||
.eq('company_id', companyId)
|
||
.select()
|
||
.single()
|
||
|
||
if (error) {
|
||
throw new Error(`Failed to link document: ${error.message}`)
|
||
}
|
||
|
||
return data as DocumentAttachment
|
||
}
|
||
|
||
export type DeleteDocumentResult =
|
||
| { ok: true; document: Pick<DocumentAttachment, 'id' | 'file_name'> }
|
||
| { ok: false; reason: 'not_found' | 'linked_to_entry'; status: number; message: string }
|
||
|
||
/**
|
||
* Delete a document if and only if it is not yet linked to a journal entry.
|
||
*
|
||
* BFL 7 kap 2§: once a document is attached to a verifikation it becomes
|
||
* räkenskapsinformation and may not be deleted within the 7-year retention
|
||
* window. Linked docs must be superseded via createNewVersion() instead.
|
||
* The block_document_deletion() trigger is the DB-level backstop.
|
||
*/
|
||
export async function deleteDocument(
|
||
supabase: SupabaseClient,
|
||
companyId: string,
|
||
documentId: string
|
||
): Promise<DeleteDocumentResult> {
|
||
const { data: doc, error: fetchError } = await supabase
|
||
.from('document_attachments')
|
||
.select('id, file_name, storage_path, journal_entry_id, user_id')
|
||
.eq('id', documentId)
|
||
.eq('company_id', companyId)
|
||
.maybeSingle()
|
||
|
||
if (fetchError || !doc) {
|
||
return {
|
||
ok: false,
|
||
reason: 'not_found',
|
||
status: 404,
|
||
message: 'Underlaget hittades inte.',
|
||
}
|
||
}
|
||
|
||
if (doc.journal_entry_id) {
|
||
return {
|
||
ok: false,
|
||
reason: 'linked_to_entry',
|
||
status: 409,
|
||
message:
|
||
'Underlaget är knutet till en verifikation och utgör räkenskapsinformation enligt Bokföringslagen 7 kap 2§. Räkenskapsinformation ska bevaras i minst 7 år och får inte raderas. Använd "Ersätt med ny version" om underlaget behöver korrigeras.',
|
||
}
|
||
}
|
||
|
||
const { error: deleteError } = await supabase
|
||
.from('document_attachments')
|
||
.delete()
|
||
.eq('id', documentId)
|
||
.eq('company_id', companyId)
|
||
|
||
if (deleteError) {
|
||
const msg = (deleteError as { message?: string }).message ?? ''
|
||
if (msg.includes('Bokföringslagen') || msg.includes('retention')) {
|
||
return {
|
||
ok: false,
|
||
reason: 'linked_to_entry',
|
||
status: 409,
|
||
message:
|
||
'Underlaget kan inte tas bort på grund av Bokföringslagens bevarandekrav (7 kap 2§).',
|
||
}
|
||
}
|
||
throw new Error(`Failed to delete document: ${msg}`)
|
||
}
|
||
|
||
if (doc.storage_path) {
|
||
await supabase.storage.from('documents').remove([doc.storage_path])
|
||
}
|
||
|
||
await eventBus.emit({
|
||
type: 'document.deleted',
|
||
payload: {
|
||
document: { id: doc.id, file_name: doc.file_name },
|
||
userId: doc.user_id,
|
||
companyId,
|
||
},
|
||
})
|
||
|
||
return { ok: true, document: { id: doc.id, file_name: doc.file_name } }
|
||
}
|
||
|
||
/**
|
||
* Verify document integrity by re-hashing and comparing
|
||
*/
|
||
export async function verifyIntegrity(
|
||
supabase: SupabaseClient,
|
||
companyId: string,
|
||
documentId: string
|
||
): Promise<{ valid: boolean; storedHash: string; computedHash: string }> {
|
||
|
||
// Fetch document record
|
||
const { data: doc, error: docError } = await supabase
|
||
.from('document_attachments')
|
||
.select('storage_path, sha256_hash')
|
||
.eq('id', documentId)
|
||
.eq('company_id', companyId)
|
||
.single()
|
||
|
||
if (docError || !doc) {
|
||
throw new Error('Document not found')
|
||
}
|
||
|
||
// Download file from storage
|
||
const { data: fileData, error: downloadError } = await supabase.storage
|
||
.from('documents')
|
||
.download(doc.storage_path)
|
||
|
||
if (downloadError || !fileData) {
|
||
throw new Error(`Failed to download document: ${downloadError?.message}`)
|
||
}
|
||
|
||
// Re-compute hash
|
||
const buffer = await fileData.arrayBuffer()
|
||
const computedHash = await computeSHA256(buffer)
|
||
|
||
return {
|
||
valid: computedHash === doc.sha256_hash,
|
||
storedHash: doc.sha256_hash,
|
||
computedHash,
|
||
}
|
||
}
|