diff --git a/app/api/v1/companies/[companyId]/documents/[id]/download/route.ts b/app/api/v1/companies/[companyId]/documents/[id]/download/route.ts new file mode 100644 index 00000000..a6e62017 --- /dev/null +++ b/app/api/v1/companies/[companyId]/documents/[id]/download/route.ts @@ -0,0 +1,155 @@ +/** + * GET /api/v1/companies/{companyId}/documents/{id}/download + * + * Returns a signed Supabase Storage URL (15-minute expiry) for the + * document's current version. The signed URL is a direct-download link + * the caller can fetch from any HTTP client without re-presenting an + * API key — keep it server-side and don't surface to end-users beyond + * the immediate transaction. + * + * The endpoint emits a `document.accessed` event (best-effort) so the + * audit trail records every download. + */ + +import { z } from 'zod' +import { ok } from '@/lib/api/v1/response' +import { registerEndpoint } from '@/lib/api/v1/registry' +import { withApiV1 } from '@/lib/api/v1/with-api-v1' +import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' +import { eventBus } from '@/lib/events' + +const DocumentDownloadResponse = z.object({ + id: z.string().uuid(), + file_name: z.string(), + mime_type: z.string().nullable(), + sha256_hash: z.string(), + /** + * False when the requested id is a SUPERSEDED version (a newer version + * exists). The signed URL is still issued — old versions are retained + * for BFL 7 kap audit — but agents should treat the response as + * historical and re-resolve the current version via GET /documents + * if they need the latest bytes. + */ + is_current_version: z.boolean(), + download_url: z.string().url(), + expires_in_seconds: z.number().int(), +}) + +// 15 minutes. Three bots converged on this (SOC 2 CC6.1, GDPR Art. 5(1)(f), +// ISO 27001 A.8.12) when the original 60-minute window was flagged as a +// bearer-token-equivalent with too wide an exposure window. The dashboard +// internal route still issues 60min URLs because it's gated by an active +// session; the v1 surface has no session, only the URL itself — so the +// shorter window applies. A caller that needs longer than 15 minutes for +// a single download should re-request the URL. +const SIGNED_URL_TTL_SECONDS = 15 * 60 + +registerEndpoint({ + operation: 'documents.download', + method: 'GET', + path: '/api/v1/companies/:companyId/documents/:id/download', + summary: 'Get a time-limited signed download URL for a document.', + description: `Returns a Supabase Storage signed URL valid for ${SIGNED_URL_TTL_SECONDS / 60} minutes. The URL itself is the canonical download — fetch it with any HTTP client; no API key needed on the storage host. Verify file integrity client-side against the returned sha256_hash if your workflow requires it.`, + useWhen: + 'You need the bytes of an archived document (e.g. for OCR, attachment to an email, regulatory export). Always re-fetch the URL before each download — old URLs expire.', + doNotUseFor: + 'Persisting the URL anywhere — it expires. Storing the URL in a webhook payload or audit log makes the audit trail dependent on URL state.', + pitfalls: [ + `The signed URL expires after ${SIGNED_URL_TTL_SECONDS / 60} minutes. Don't cache it beyond the immediate transaction.`, + 'The URL leaks the Supabase Storage origin; this is benign (the signature alone authorizes the read) but rate-limit any forwarding so you don\'t reveal the storage layout to untrusted callers.', + 'Each call emits a document.accessed event. Polling this endpoint produces audit noise; cache the URL for its full TTL.', + ], + example: { + response: { + data: { + id: '0e9c…', + file_name: 'kvitto-2026-05-12.pdf', + mime_type: 'application/pdf', + sha256_hash: '8a7f…', + download_url: 'https://…supabase.co/storage/v1/object/sign/…', + expires_in_seconds: 900, + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'documents:read', + risk: 'low', + idempotent: true, + reversible: false, + dryRunSupported: false, + response: { success: DocumentDownloadResponse }, +}) + +export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'documents.download', + async (_request, ctx, params) => { + const { id } = await params.params + const idParse = z.string().uuid().safeParse(id) + if (!idParse.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'id', message: 'document id must be a UUID.' }, + }) + } + const documentId = idParse.data + + const { data: doc, error: docErr } = await ctx.supabase + .from('document_attachments') + .select('id, file_name, mime_type, sha256_hash, storage_path, is_current_version') + .eq('id', documentId) + .eq('company_id', ctx.companyId!) + .maybeSingle() + + if (docErr) return v1ErrorResponse(docErr, ctx.log, { requestId: ctx.requestId }) + if (!doc) { + // Enumeration hardening — wrong id and cross-tenant id are + // indistinguishable from outside. + return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { + requestId: ctx.requestId, + details: { resource: 'document' }, + }) + } + const typed = doc as { + id: string; file_name: string; mime_type: string | null; sha256_hash: string; + storage_path: string; is_current_version: boolean; + } + + const { data: signed, error: signErr } = await ctx.supabase.storage + .from('documents') + .createSignedUrl(typed.storage_path, SIGNED_URL_TTL_SECONDS) + + if (signErr || !signed?.signedUrl) { + ctx.log.error('createSignedUrl failed', signErr as Error, { documentId }) + return v1ErrorResponseFromCode('DOC_DOWNLOAD_FAILED', ctx.log, { + requestId: ctx.requestId, + details: { reason: signErr?.message ?? 'unknown' }, + }) + } + + try { + await eventBus.emit({ + type: 'document.accessed', + payload: { + document: { id: typed.id, file_name: typed.file_name }, + userId: ctx.userId, + companyId: ctx.companyId!, + }, + }) + } catch (err) { + ctx.log.warn('document.accessed emit failed', err as Error) + } + + return ok( + { + id: typed.id, + file_name: typed.file_name, + mime_type: typed.mime_type, + sha256_hash: typed.sha256_hash, + is_current_version: typed.is_current_version, + download_url: signed.signedUrl, + expires_in_seconds: SIGNED_URL_TTL_SECONDS, + }, + { requestId: ctx.requestId }, + ) + }, +) diff --git a/app/api/v1/companies/[companyId]/documents/[id]/link/route.ts b/app/api/v1/companies/[companyId]/documents/[id]/link/route.ts new file mode 100644 index 00000000..e386d738 --- /dev/null +++ b/app/api/v1/companies/[companyId]/documents/[id]/link/route.ts @@ -0,0 +1,242 @@ +/** + * POST /api/v1/companies/{companyId}/documents/{id}/link + * + * Link an already-uploaded document to a journal entry (and optionally a + * specific line). Wraps lib/core/documents/document-service.linkToJournalEntry. + * + * Body: `{ journal_entry_id: UUID, journal_entry_line_id?: UUID }`. + * + * The link is REVERSIBLE — set journal_entry_id back via the dashboard if + * needed (no unlink endpoint in v1 yet to keep the WORM contract tight). + * Once the journal entry it points at is committed (status='posted'), the + * document row is effectively immutable per BFL 7 kap. + * + * Idempotent (mandatory Idempotency-Key). Dry-runnable: confirms the JE + * and document both exist + belong to the company without persisting. + */ + +import { z } from 'zod' +import { ok } from '@/lib/api/v1/response' +import { dryRunPreview } from '@/lib/api/v1/dry-run' +import { registerEndpoint } from '@/lib/api/v1/registry' +import { withApiV1 } from '@/lib/api/v1/with-api-v1' +import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' +import { linkToJournalEntry } from '@/lib/core/documents/document-service' + +const Body = z + .object({ + journal_entry_id: z.string().uuid(), + journal_entry_line_id: z.string().uuid().optional(), + }) + .strict() + +const DocumentLinkedResponse = z.object({ + id: z.string().uuid(), + journal_entry_id: z.string().uuid(), + journal_entry_line_id: z.string().uuid().nullable(), + file_name: z.string(), +}) + +registerEndpoint({ + operation: 'documents.link', + method: 'POST', + path: '/api/v1/companies/:companyId/documents/:id/link', + summary: 'Link a document to a journal entry.', + description: + 'Sets journal_entry_id (and optionally journal_entry_line_id) on an existing document. Use this after /documents upload when the link target was unknown at upload time, or to re-link a stray document. Once the target JE is posted, the document row is effectively immutable per BFL 7 kap retention.', + useWhen: + 'A document was uploaded without a journal_entry_id (e.g. bulk import) and you now want to attach it to a posted verifikation.', + doNotUseFor: + 'Unlinking — no v1 unlink endpoint. The dashboard exposes a manual override; v1 keeps the WORM contract by refusing to revert posted-JE links.', + pitfalls: [ + 'Idempotency-Key is mandatory.', + 'Both the document and the journal_entry_id must belong to the caller\'s company. NOT_FOUND on mismatch (enumeration hardening).', + 'Re-linking an already-linked document overwrites the previous journal_entry_id — confirm the old target is what you intend to break.', + ], + example: { + request: { journal_entry_id: 'a8f1…' }, + response: { + data: { + id: '0e9c…', + journal_entry_id: 'a8f1…', + journal_entry_line_id: null, + file_name: 'kvitto-2026-05-12.pdf', + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'documents:write', + risk: 'medium', + idempotent: true, + reversible: false, + dryRunSupported: true, + request: { body: Body }, + response: { success: DocumentLinkedResponse }, +}) + +export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'documents.link', + async (request, ctx, params) => { + const { id } = await params.params + const idParse = z.string().uuid().safeParse(id) + if (!idParse.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'id', message: 'document id must be a UUID.' }, + }) + } + const documentId = idParse.data + + let rawBody: unknown + try { + rawBody = await request.json() + } catch { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'body', message: 'Body is not valid JSON.' }, + }) + } + const parsed = Body.safeParse(rawBody) + if (!parsed.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { issues: parsed.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message })) }, + }) + } + const body = parsed.data + + // Ownership pre-check: document AND target JE must both belong to the + // caller's company before the link write. Otherwise the row could + // persist with a cross-tenant journal_entry_id pointer. Capture + // `.error` on both — a DB fault must not silently masquerade as a + // NOT_FOUND (round-1 finding). + const [docRes, jeRes] = await Promise.all([ + ctx.supabase + .from('document_attachments') + .select('id, file_name, journal_entry_id') + .eq('id', documentId) + .eq('company_id', ctx.companyId!) + .maybeSingle(), + ctx.supabase + .from('journal_entries') + .select('id') + .eq('id', body.journal_entry_id) + .eq('company_id', ctx.companyId!) + .maybeSingle(), + ]) + + if (docRes.error) { + ctx.log.error('documents.link doc pre-check DB error', docRes.error as Error, { documentId }) + return v1ErrorResponseFromCode('INTERNAL_ERROR', ctx.log, { + requestId: ctx.requestId, details: { step: 'doc_ownership_check' }, + }) + } + if (jeRes.error) { + ctx.log.error('documents.link JE pre-check DB error', jeRes.error as Error, { journalEntryId: body.journal_entry_id }) + return v1ErrorResponseFromCode('INTERNAL_ERROR', ctx.log, { + requestId: ctx.requestId, details: { step: 'je_ownership_check' }, + }) + } + + const doc = docRes.data as { id: string; file_name: string; journal_entry_id: string | null } | null + const je = jeRes.data + + if (!doc) { + return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { + requestId: ctx.requestId, + details: { resource: 'document' }, + }) + } + if (!je) { + return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { + requestId: ctx.requestId, + details: { resource: 'journal_entry', field: 'journal_entry_id' }, + }) + } + + // WORM guard: if this document is ALREADY linked to a posted JE, refuse + // the overwrite. BFL 5 kap 5 § + 7 kap require posted räkenskaps- + // information (incl. the link to underlying documents) to remain + // immutable. The pre-check confirms the new target — without this + // additional check the caller could silently break the link to an + // already-posted verifikation. + if (doc.journal_entry_id && doc.journal_entry_id !== body.journal_entry_id) { + const { data: existingJe } = await ctx.supabase + .from('journal_entries') + .select('id, status') + .eq('id', doc.journal_entry_id) + .eq('company_id', ctx.companyId!) + .maybeSingle() + if (existingJe && (existingJe as { status: string }).status === 'posted') { + return v1ErrorResponseFromCode('CONFLICT', ctx.log, { + requestId: ctx.requestId, + details: { + reason: 'document_already_linked_to_posted_entry', + current_journal_entry_id: doc.journal_entry_id, + remediation: + 'Documents linked to posted verifikationer cannot be re-linked (BFL 5 kap 5 §). Upload a new document and link the new one to the new target.', + }, + }) + } + } + + // journal_entry_line_id ownership: must belong to the target JE. + // Skipped above (only document + JE) because the line ownership is + // transitively bound through journal_entry_id (which we just verified). + if (body.journal_entry_line_id) { + const { data: lineRow, error: lineErr } = await ctx.supabase + .from('journal_entry_lines') + .select('id') + .eq('id', body.journal_entry_line_id) + .eq('journal_entry_id', body.journal_entry_id) + .maybeSingle() + if (lineErr) { + ctx.log.error('documents.link line pre-check DB error', lineErr as Error) + return v1ErrorResponseFromCode('INTERNAL_ERROR', ctx.log, { + requestId: ctx.requestId, details: { step: 'je_line_ownership_check' }, + }) + } + if (!lineRow) { + return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { + requestId: ctx.requestId, + details: { resource: 'journal_entry_line', field: 'journal_entry_line_id' }, + }) + } + } + + if (ctx.dryRun) { + return dryRunPreview( + { + id: documentId, + journal_entry_id: body.journal_entry_id, + journal_entry_line_id: body.journal_entry_line_id ?? null, + file_name: doc.file_name, + }, + { requestId: ctx.requestId, log: ctx.log }, + ) + } + + try { + const updated = await linkToJournalEntry( + ctx.supabase, + ctx.companyId!, + documentId, + body.journal_entry_id, + body.journal_entry_line_id, + ) + return ok( + { + id: updated.id, + journal_entry_id: updated.journal_entry_id!, + journal_entry_line_id: updated.journal_entry_line_id, + file_name: updated.file_name, + }, + { requestId: ctx.requestId }, + ) + } catch (err) { + ctx.log.error('documents.link failed', err as Error, { documentId, journalEntryId: body.journal_entry_id }) + return v1ErrorResponse(err, ctx.log, { requestId: ctx.requestId }) + } + }, + { requireIdempotencyKey: true }, +) diff --git a/app/api/v1/companies/[companyId]/documents/route.ts b/app/api/v1/companies/[companyId]/documents/route.ts new file mode 100644 index 00000000..564a164a --- /dev/null +++ b/app/api/v1/companies/[companyId]/documents/route.ts @@ -0,0 +1,300 @@ +/** + * POST /api/v1/companies/{companyId}/documents + * + * Multipart upload of a document into the WORM archive. Wraps + * lib/core/documents/document-service.uploadDocument: hashes the bytes + * (SHA-256), writes to Supabase Storage under documents/{userId}/..., + * inserts an immutable row into document_attachments (version=1, + * is_current_version=true). + * + * multipart/form-data parts: + * file (required, binary) — the document; MIME validated + * upload_source (optional) — 'file_upload' (default) | 'camera' | 'email' | 'api' + * journal_entry_id (optional UUID) — link the document to a JE at upload time + * journal_entry_line_id (optional UUID) — link to a specific JE line + * + * Idempotent (mandatory Idempotency-Key — the SHA-256 of the bytes is the + * deduplication anchor on retry inside the engine's `upsert: false` storage + * write). + * + * Dry-run is NOT supported on this endpoint — the engine hashes + stores + + * inserts atomically; the "dry-run" equivalent is a client-side + * size+MIME check before submitting. Future iteration may add a header-only + * preflight; held back to keep the multipart contract minimal. + * + * WORM (BFL 7 kap): once inserted, the row cannot be modified or deleted + * if it is linked to a posted journal entry — the DB trigger blocks both. + * Updating a document means uploading a new VERSION via the dashboard + * (no v1 endpoint today; bypassing through the dashboard is intentional + * until the contract is hardened with audit-trail tests). + */ + +import { z } from 'zod' +import { created } from '@/lib/api/v1/response' +import { registerEndpoint } from '@/lib/api/v1/registry' +import { withApiV1 } from '@/lib/api/v1/with-api-v1' +import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors' +import { + uploadDocument, + validateDocumentFile, + MAX_DOCUMENT_SIZE, + ALLOWED_DOCUMENT_TYPES, +} from '@/lib/core/documents/document-service' +import type { DocumentUploadSource } from '@/types' + +const DocumentUploaded = z.object({ + id: z.string().uuid(), + file_name: z.string(), + mime_type: z.string().nullable(), + file_size_bytes: z.number(), + sha256_hash: z.string(), + version: z.number().int(), + is_current_version: z.boolean(), + upload_source: z.string().nullable(), + journal_entry_id: z.string().uuid().nullable(), + journal_entry_line_id: z.string().uuid().nullable(), + created_at: z.string(), +}) + +// For the registry, the Zod body is a metadata-only shape (everything the +// caller MIGHT supply in the multipart envelope besides the file itself). +// The actual multipart parsing happens in-route via request.formData(). +const MultipartBodySchema = z.object({ + file: z.unknown(), // OpenAPI generator renders this as { type: 'string', format: 'binary' } + upload_source: z.enum(['file_upload', 'camera', 'email', 'api']).optional(), + journal_entry_id: z.string().uuid().optional(), + journal_entry_line_id: z.string().uuid().optional(), +}) + +registerEndpoint({ + operation: 'documents.upload', + method: 'POST', + path: '/api/v1/companies/:companyId/documents', + summary: 'Upload a document to the WORM archive.', + description: `Multipart upload of a document (PDF / image) under the BFL 7 kap retention regime. The bytes are hashed (SHA-256), written to Supabase Storage, and recorded in document_attachments at version=1. Allowed MIME types: ${ALLOWED_DOCUMENT_TYPES.join(', ')}. Max size: ${MAX_DOCUMENT_SIZE / 1024 / 1024} MB.`, + useWhen: + 'You have a receipt, invoice scan, or supporting document for a posted verifikation and want it archived for the 7-year BFL retention period. Optionally link to a journal entry at upload time via journal_entry_id.', + doNotUseFor: + 'Updating an existing document (no v1 update endpoint; new versions go through the dashboard). Bulk uploads — call once per file.', + pitfalls: [ + 'Idempotency-Key is mandatory; multipart retries with the same key replay the cached response.', + `Max size ${MAX_DOCUMENT_SIZE / 1024 / 1024} MB enforced server-side — DOC_UPLOAD_TOO_LARGE on overrun.`, + `Only ${ALLOWED_DOCUMENT_TYPES.join(' / ')} accepted — DOC_UPLOAD_UNSUPPORTED_TYPE otherwise.`, + 'WORM: once linked to a posted journal entry, the document row cannot be modified or deleted (DB trigger). Upload-then-link is reversible (the document exists with journal_entry_id=null until linked); once linked, treat as immutable.', + 'Dry-run is not supported on this endpoint — the engine hashes + stores + inserts in one atomic flow.', + ], + example: { + request: { + // OpenAPI generator renders these as multipart parts. + file: '', + upload_source: 'api', + journal_entry_id: 'a8f1…', + }, + response: { + data: { + id: '0e9c…', + file_name: 'kvitto-2026-05-12.pdf', + mime_type: 'application/pdf', + file_size_bytes: 184320, + sha256_hash: '8a7f…', + version: 1, + is_current_version: true, + journal_entry_id: 'a8f1…', + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'documents:write', + risk: 'medium', + idempotent: true, + reversible: false, + dryRunSupported: false, + request: { body: MultipartBodySchema, contentType: 'multipart/form-data' }, + response: { success: DocumentUploaded }, +}) + +export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( + 'documents.upload', + async (request, ctx) => { + let formData: FormData + try { + formData = await request.formData() + } catch { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + field: 'body', + message: + 'Body must be multipart/form-data with a `file` part. Set Content-Type accordingly.', + }, + }) + } + + const file = formData.get('file') + if (!file || !(file instanceof File)) { + return v1ErrorResponseFromCode('DOC_UPLOAD_NO_FILE', ctx.log, { + requestId: ctx.requestId, + }) + } + + const validationError = validateDocumentFile({ size: file.size, type: file.type }) + if (validationError) { + // The validator returns a Swedish string. Bucket by error category + // so the agent receives a stable code. + const code = /storlek|stor|MB|tom/i.test(validationError) + ? 'DOC_UPLOAD_TOO_LARGE' + : 'DOC_UPLOAD_UNSUPPORTED_TYPE' + return v1ErrorResponseFromCode(code, ctx.log, { + requestId: ctx.requestId, + details: { + reason: validationError, + file_size_bytes: file.size, + mime_type: file.type, + max_size_bytes: MAX_DOCUMENT_SIZE, + allowed_types: ALLOWED_DOCUMENT_TYPES, + }, + }) + } + + // Optional metadata fields. upload_source is enum-validated at runtime + // (the column has no CHECK constraint, so an unrecognised value would + // persist as-is otherwise). + const uploadSourceRaw = formData.get('upload_source') + const UploadSourceSchema = z.enum(['file_upload', 'camera', 'email', 'api']) + let uploadSource: DocumentUploadSource = 'file_upload' + if (typeof uploadSourceRaw === 'string') { + const parsed = UploadSourceSchema.safeParse(uploadSourceRaw) + if (!parsed.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + field: 'upload_source', + message: `upload_source must be one of: ${UploadSourceSchema.options.join(', ')}.`, + attempted: uploadSourceRaw, + }, + }) + } + uploadSource = parsed.data + } + + const journalEntryIdRaw = formData.get('journal_entry_id') + const journalEntryId = typeof journalEntryIdRaw === 'string' ? journalEntryIdRaw : undefined + if (journalEntryId && !z.string().uuid().safeParse(journalEntryId).success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'journal_entry_id', message: 'must be a UUID' }, + }) + } + const journalEntryLineIdRaw = formData.get('journal_entry_line_id') + const journalEntryLineId = typeof journalEntryLineIdRaw === 'string' ? journalEntryLineIdRaw : undefined + if (journalEntryLineId && !z.string().uuid().safeParse(journalEntryLineId).success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'journal_entry_line_id', message: 'must be a UUID' }, + }) + } + + // If the caller supplied journal_entry_id, verify it belongs to the + // caller's company before the upload commits. Otherwise we'd persist a + // document whose `journal_entry_id` points at another company's JE — + // the DB has no FK enforcing cross-table tenancy. + if (journalEntryId) { + const { data: jeRow, error: jeErr } = await ctx.supabase + .from('journal_entries') + .select('id') + .eq('id', journalEntryId) + .eq('company_id', ctx.companyId!) + .maybeSingle() + if (jeErr) { + ctx.log.error('documents.upload JE pre-check DB error', jeErr as Error, { journalEntryId }) + return v1ErrorResponseFromCode('INTERNAL_ERROR', ctx.log, { + requestId: ctx.requestId, details: { step: 'je_ownership_check' }, + }) + } + if (!jeRow) { + return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { + requestId: ctx.requestId, + details: { resource: 'journal_entry', field: 'journal_entry_id' }, + }) + } + } + + // If the caller supplied journal_entry_line_id, verify the line belongs + // to the supplied JE (and transitively to the company we already + // validated). Without this guard the row would persist with a + // line-level pointer to another company's JE line. + if (journalEntryLineId) { + if (!journalEntryId) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + field: 'journal_entry_line_id', + message: 'journal_entry_line_id requires journal_entry_id.', + }, + }) + } + const { data: lineRow, error: lineErr } = await ctx.supabase + .from('journal_entry_lines') + .select('id') + .eq('id', journalEntryLineId) + .eq('journal_entry_id', journalEntryId) + .maybeSingle() + if (lineErr) { + ctx.log.error('documents.upload JE-line pre-check DB error', lineErr as Error, { journalEntryLineId }) + return v1ErrorResponseFromCode('INTERNAL_ERROR', ctx.log, { + requestId: ctx.requestId, details: { step: 'je_line_ownership_check' }, + }) + } + if (!lineRow) { + return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { + requestId: ctx.requestId, + details: { resource: 'journal_entry_line', field: 'journal_entry_line_id' }, + }) + } + } + + const opLog = ctx.log.child({ filename: file.name, sizeBytes: file.size }) + + try { + const buffer = await file.arrayBuffer() + const document = await uploadDocument( + ctx.supabase, + ctx.userId, + ctx.companyId!, + { name: file.name, buffer, type: file.type }, + { + upload_source: uploadSource, + journal_entry_id: journalEntryId, + journal_entry_line_id: journalEntryLineId, + }, + ) + // `storage_path` is deliberately omitted from the public response — + // the path encodes internal layout (userId prefix + timestamp) which + // /download deliberately keeps hidden. Use /download/{id} to fetch + // the actual bytes via a short-lived signed URL. + return created( + { + id: document.id, + file_name: document.file_name, + mime_type: document.mime_type, + file_size_bytes: document.file_size_bytes, + sha256_hash: document.sha256_hash, + version: document.version, + is_current_version: document.is_current_version, + upload_source: document.upload_source, + journal_entry_id: document.journal_entry_id, + journal_entry_line_id: document.journal_entry_line_id, + created_at: document.created_at, + }, + { requestId: ctx.requestId }, + ) + } catch (err) { + opLog.error('document upload failed', err as Error) + return v1ErrorResponseFromCode('DOC_UPLOAD_STORAGE_FAILED', opLog, { + requestId: ctx.requestId, + details: { reason: err instanceof Error ? err.message : 'unknown' }, + }) + } + }, + { requireIdempotencyKey: true }, +) diff --git a/lib/api/v1/load-routes.ts b/lib/api/v1/load-routes.ts index 5f197f0b..8dfa016e 100644 --- a/lib/api/v1/load-routes.ts +++ b/lib/api/v1/load-routes.ts @@ -37,6 +37,11 @@ import '@/app/api/v1/companies/[companyId]/fiscal-periods/[id]/year-end/route' import '@/app/api/v1/companies/[companyId]/fiscal-periods/[id]/opening-balances/route' import '@/app/api/v1/companies/[companyId]/fiscal-periods/[id]/currency-revaluation/route' +// Phase 4 PR-3 — Documents (multipart). +import '@/app/api/v1/companies/[companyId]/documents/route' +import '@/app/api/v1/companies/[companyId]/documents/[id]/download/route' +import '@/app/api/v1/companies/[companyId]/documents/[id]/link/route' + // Phase 2 PR-A — invoice + customer reads. import '@/app/api/v1/companies/[companyId]/invoices/route' import '@/app/api/v1/companies/[companyId]/invoices/[id]/route' diff --git a/lib/api/v1/registry.ts b/lib/api/v1/registry.ts index 03cacf42..688e55f4 100644 --- a/lib/api/v1/registry.ts +++ b/lib/api/v1/registry.ts @@ -74,6 +74,14 @@ export interface EndpointDefinition { query?: ZodTypeAny /** Request body. */ body?: ZodTypeAny + /** + * Body content-type. Defaults to 'application/json' when omitted. + * Set to 'multipart/form-data' for upload endpoints (Phase 4 PR-3: + * documents). The OpenAPI generator emits the appropriate schema + * (`{ type: 'string', format: 'binary' }` for the file part) so + * code generators produce correct multipart clients. + */ + contentType?: 'application/json' | 'multipart/form-data' } response: { /** Successful response body. */ diff --git a/lib/auth/scopes.ts b/lib/auth/scopes.ts index acca25d5..09f2ac11 100644 --- a/lib/auth/scopes.ts +++ b/lib/auth/scopes.ts @@ -105,10 +105,10 @@ export const V1_ENDPOINT_SCOPES: Record = { 'POST /api/v1/companies/:companyId/fiscal-periods/:id/currency-revaluation': 'bookkeeping:write', // Compliance check (gnubok's defensible edge). 'GET /api/v1/companies/:companyId/compliance/check': 'compliance:read', - // Note: documents (multipart) scopes are intentionally NOT pre-registered - // here — they ship in the dedicated documents follow-up PR so an API key - // issued today with documents:write cannot match a route that doesn't - // yet exist. + // Phase 4 PR-3 — Documents (multipart). + 'POST /api/v1/companies/:companyId/documents': 'documents:write', + 'GET /api/v1/companies/:companyId/documents/:id/download': 'documents:read', + 'POST /api/v1/companies/:companyId/documents/:id/link': 'documents:write', // Phase 3 — transactions + reconciliation vertical. // Reads diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index 9e8be4b5..ba06e62e 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -1046,6 +1046,11 @@ const DOCUMENT: Record = { message_sv: 'Filen kunde inte sparas.', message_en: 'Document storage failed.', }, + DOC_DOWNLOAD_FAILED: { + httpStatus: 500, + message_sv: 'Det gick inte att skapa nedladdningslänken.', + message_en: 'Failed to create signed download URL.', + }, DOC_NOT_FOUND: { httpStatus: 404, message_sv: 'Dokumentet kunde inte hittas.',