Files
accounted/app/api/v1/companies/[companyId]/documents/route.ts
T
Jakob WennbergandClaude Opus 4.8 fce6faff2c fix(api): stabilize report pagination + declare real { data, meta } envelope on v1 single/write endpoints (#811)
* fix(reports): stabilize fetchAllRows paging to stop doubled/dropped balances (#790, #791)

PostgREST `.range()` paging is only correct when the underlying query has a
stable TOTAL order. Several aggregating report queries (general ledger, trial
balance, grundbok, supplier/AR ledgers, etc.) paginated without `.order()`, so
on datasets larger than one 1000-row page Postgres could return rows in a
different order between requests — silently DUPLICATING or SKIPPING rows on a
page boundary and doubling or dropping financial totals.

- fetch-all.ts: document the ordering invariant and add an optional
  `dedupeBy` defense-in-depth that drops cross-page duplicates and warns when
  it fires (surfaces a missing `.order()` in logs instead of corrupting money).
- Add a stable `.order()` (line PK or account_number) to every paginated query
  in lib/reports/ and the account-balances route; pass `dedupeBy` on the
  money-aggregating line queries.
- Add fetch-all unit tests and update report test fixtures to carry row ids.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(api): declare the real { data, meta } envelope on v1 single/write/204 endpoints (#794)

The OpenAPI generator derives each endpoint's documented body purely from its
registered `response.success` Zod schema, and that schema is never validated at
runtime — so a route could advertise a shape its handler never sends. #802
fixed this for list endpoints; the same drift was latent on single-resource and
write endpoints, which declared the bare resource schema instead of the
`{ data, meta }` envelope the handlers actually return.

- registry.ts: extend `ResponseMetaSchema` with the optional `audit` block and
  `partial_expansions` list that writes/expansions emit; add the `NoBodyResponse`
  sentinel so 204 DELETE handlers document a bare 204 instead of a phantom 200.
- Wrap every single/write endpoint's `response.success` in `dataEnvelope(...)`
  (or `NoBodyResponse` for 204s) across the v1 routes.
- Add a response-envelope contract test that fails CI if any JSON endpoint
  forgets to wrap its schema, with binary downloads and 204s as the only
  exemptions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reports): extend paging dedupeBy to rc-basis-gaps and opening-balances

Address PR review: these two money-aggregating line queries already had the
stable `.order('id')` (so paging was correct) but didn't carry `id` in the
select, so they couldn't use the `dedupeBy` defense-in-depth that general-ledger
and trial-balance got. Select `id` and pass `dedupeBy: r => r.id` so the whole
report layer applies the ordering invariant consistently.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:42:50 +02:00

301 lines
12 KiB
TypeScript

/**
* 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, dataEnvelope } 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: '<binary>',
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: dataEnvelope(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 },
)