Logs/improved logging (#398)
* feat(mcp): add create_transactions tool with /pending approval gate New MCP tool gnubok_create_transactions stages 1–10 transactions per call as pending_operations of type create_transaction (risk: medium). Each item becomes its own card on /pending; on confirm, the executor inserts the row into transactions with import_source='mcp' so MCP-staged ingestion is distinguishable from PSD2 sync. Designed for skill workflows that pull external data (e.g., Airtable) and want the user to gate the writes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(bas): strip concatenated group headers from corrupted account names A chart-data import bug had glued the next group's header onto the last account in each preceding group across all eight bas-data class files (e.g. account 2670 read "Utgående moms på försäljning inom EU, OSS 27 PERSONALENS SKATTER, AVGIFTER OCH LÖNEAVDRAG"). The corrupted names surface in transaction dropdowns, ledgers, SIE exports and årsredovisning, and risk VAT miscategorization on the OSS (2670) and blandad-verksamhet (6999) accounts specifically. - Cleans 69 account_name and 64 description fields across class-1..8 files - Adds a regression test asserting no name contains a concatenated header - Ships an idempotent safety-net migration that updates already-seeded chart_of_accounts rows, gated on the corrupted string so user customizations are preserved Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(errors): add structured error codes and handling for various operations - Introduced a new structured error registry in `structured-errors.ts` to standardize error handling across the application. - Added Swedish and English messages for various error scenarios, including validation, authorization, and bookkeeping errors. - Implemented a client-side error toast in `use-error-toast.ts` to display user-friendly error messages with remediation hints. - Created a wrapper for recording operation outcomes in `record-operation.ts`, enhancing audit capabilities for operations. - Developed a provider call wrapper in `with-provider-call.ts` to handle external HTTP calls with structured logging and error mapping. - Added a new SQL migration to extend the processing history with new event types and aggregate types for better operational telemetry. * Refactor supplier API routes to use context-based logging and error handling - Replaced direct Supabase client usage in GET and POST routes with context-based approach using `withRouteContext`. - Enhanced error handling to provide structured error responses for supplier creation and listing. - Updated logging to include request IDs for better traceability. - Introduced new error codes for supplier-related operations. - Refactored tax deadlines cron job to utilize context and improved error handling. - Updated ESLint configuration to enforce logging practices across API and lib directories. - Enhanced arcim migration extension with structured error handling and logging. - Added classification for provider errors to improve user-facing error messages. - Introduced request ID in extension context for better log correlation. * fix(route-context): update DynamicParams type for improved type safety in route handlers * feat(transactions): add 'create_transaction' operation to PendingOperationType * fix(route): ensure companyId is non-nullable in loadAndDeriveAbsence function * fix(route-context): ensure companyId is always non-null by short-circuiting with COMPANY_CONTEXT_MISSING --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
94f15b9c6c
commit
5725c25bf1
@@ -1,63 +1,57 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
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 (verifikation)
|
||||
* POST /api/documents/[id]/link — link a document to a journal entry.
|
||||
*
|
||||
* Request body:
|
||||
* - journal_entry_id: string (required)
|
||||
* - journal_entry_line_id: string (optional)
|
||||
* Body: { journal_entry_id: string, journal_entry_line_id?: string }
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
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 { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const body = await request.json().catch(() => ({}))
|
||||
|
||||
if (!body.journal_entry_id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'journal_entry_id is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
return errorResponseFromCode('VALIDATION_ERROR', opLog, {
|
||||
requestId,
|
||||
details: { field: 'journal_entry_id', reason: 'required' },
|
||||
})
|
||||
}
|
||||
|
||||
const document = await linkToJournalEntry(
|
||||
supabase,
|
||||
companyId,
|
||||
id,
|
||||
body.journal_entry_id,
|
||||
body.journal_entry_line_id
|
||||
)
|
||||
|
||||
return NextResponse.json({ data: document })
|
||||
} catch (error) {
|
||||
console.error('[documents/link/POST] Link failed:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Link failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
try {
|
||||
const document = await linkToJournalEntry(
|
||||
supabase,
|
||||
companyId!,
|
||||
id,
|
||||
body.journal_entry_id,
|
||||
body.journal_entry_line_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 : ''
|
||||
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 },
|
||||
)
|
||||
|
||||
+79
-88
@@ -1,122 +1,113 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { uploadDocument, validateDocumentFile } from '@/lib/core/documents/document-service'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import type { DocumentUploadSource } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* POST /api/documents
|
||||
* Upload a document to the WORM archive
|
||||
* POST /api/documents — upload a document to the WORM archive.
|
||||
*
|
||||
* Accepts multipart/form-data with:
|
||||
* - file: The document file
|
||||
* - upload_source (optional): 'camera' | 'file_upload' | 'email' | ...
|
||||
* - journal_entry_id (optional): Link to a journal entry
|
||||
* - journal_entry_line_id (optional): Link to a journal entry line
|
||||
* multipart/form-data:
|
||||
* file: the document file
|
||||
* upload_source (optional): 'camera' | 'file_upload' | 'email' | …
|
||||
* journal_entry_id (optional)
|
||||
* journal_entry_line_id (optional)
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
export const POST = withRouteContext(
|
||||
'document.upload',
|
||||
async (request, ctx) => {
|
||||
const { user, supabase, companyId, log, requestId } = ctx
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
try {
|
||||
const formData = await request.formData()
|
||||
const file = formData.get('file') as File | null
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
|
||||
return errorResponseFromCode('DOC_UPLOAD_NO_FILE', log, { requestId })
|
||||
}
|
||||
|
||||
const validationError = validateDocumentFile({ size: file.size, type: file.type })
|
||||
if (validationError) {
|
||||
return NextResponse.json({ error: validationError }, { status: 400 })
|
||||
// The validator returns a Swedish string today. Bucket the failure into
|
||||
// a size or type code based on its content.
|
||||
const code = /storlek|stor|MB/i.test(validationError)
|
||||
? 'DOC_UPLOAD_TOO_LARGE'
|
||||
: 'DOC_UPLOAD_UNSUPPORTED_TYPE'
|
||||
return errorResponseFromCode(code, log, {
|
||||
requestId,
|
||||
details: { reason: validationError, sizeBytes: file.size, mimeType: file.type },
|
||||
})
|
||||
}
|
||||
|
||||
const uploadSource = (formData.get('upload_source') as string) || 'file_upload'
|
||||
const journalEntryId = formData.get('journal_entry_id') as string | null
|
||||
const journalEntryLineId = formData.get('journal_entry_line_id') as string | null
|
||||
const opLog = log.child({ filename: file.name, sizeBytes: file.size })
|
||||
|
||||
const buffer = await file.arrayBuffer()
|
||||
try {
|
||||
const uploadSource = (formData.get('upload_source') as string) || 'file_upload'
|
||||
const journalEntryId = formData.get('journal_entry_id') as string | null
|
||||
const journalEntryLineId = formData.get('journal_entry_line_id') as string | null
|
||||
|
||||
const document = await uploadDocument(supabase, user.id, companyId, {
|
||||
name: file.name,
|
||||
buffer,
|
||||
type: file.type,
|
||||
}, {
|
||||
upload_source: uploadSource as import('@/types').DocumentUploadSource,
|
||||
journal_entry_id: journalEntryId || undefined,
|
||||
journal_entry_line_id: journalEntryLineId || undefined,
|
||||
})
|
||||
const buffer = await file.arrayBuffer()
|
||||
|
||||
return NextResponse.json({ data: document })
|
||||
} catch (error) {
|
||||
console.error('[documents/POST] Upload failed:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Upload failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
const document = await uploadDocument(supabase, user.id, companyId!, {
|
||||
name: file.name,
|
||||
buffer,
|
||||
type: file.type,
|
||||
}, {
|
||||
upload_source: uploadSource as DocumentUploadSource,
|
||||
journal_entry_id: journalEntryId || undefined,
|
||||
journal_entry_line_id: journalEntryLineId || undefined,
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: document })
|
||||
} catch (err) {
|
||||
opLog.error('document upload failed', err as Error)
|
||||
return errorResponseFromCode('DOC_UPLOAD_STORAGE_FAILED', opLog, {
|
||||
requestId,
|
||||
details: { reason: err instanceof Error ? err.message : 'unknown' },
|
||||
})
|
||||
}
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
/**
|
||||
* GET /api/documents
|
||||
* List documents with optional filtering
|
||||
* GET /api/documents — list documents.
|
||||
*
|
||||
* Query params:
|
||||
* - journal_entry_id: Filter by journal entry
|
||||
* - current_only: If 'true', only return current versions (default: true)
|
||||
* - limit: Number of results (default: 50)
|
||||
* - offset: Pagination offset (default: 0)
|
||||
* journal_entry_id: filter by JE
|
||||
* current_only: 'false' to include older versions (default true)
|
||||
* limit, offset
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
export const GET = withRouteContext(
|
||||
'document.list',
|
||||
async (request, ctx) => {
|
||||
const { supabase, companyId, log, requestId } = ctx
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
const { searchParams } = new URL(request.url)
|
||||
const journalEntryId = searchParams.get('journal_entry_id')
|
||||
const currentOnly = searchParams.get('current_only') !== 'false'
|
||||
const limit = parseInt(searchParams.get('limit') || '50')
|
||||
const offset = parseInt(searchParams.get('offset') || '0')
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
let query = supabase
|
||||
.from('document_attachments')
|
||||
.select('*', { count: 'exact' })
|
||||
.eq('company_id', companyId)
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + limit - 1)
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
if (journalEntryId) query = query.eq('journal_entry_id', journalEntryId)
|
||||
if (currentOnly) query = query.eq('is_current_version', true)
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const journalEntryId = searchParams.get('journal_entry_id')
|
||||
const currentOnly = searchParams.get('current_only') !== 'false'
|
||||
const limit = parseInt(searchParams.get('limit') || '50')
|
||||
const offset = parseInt(searchParams.get('offset') || '0')
|
||||
const { data, error, count } = await query
|
||||
|
||||
let query = supabase
|
||||
.from('document_attachments')
|
||||
.select('*', { count: 'exact' })
|
||||
.eq('company_id', companyId)
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + limit - 1)
|
||||
if (error) {
|
||||
log.error('document list failed', error)
|
||||
return errorResponse(error, log, { requestId })
|
||||
}
|
||||
|
||||
if (journalEntryId) {
|
||||
query = query.eq('journal_entry_id', journalEntryId)
|
||||
}
|
||||
|
||||
if (currentOnly) {
|
||||
query = query.eq('is_current_version', true)
|
||||
}
|
||||
|
||||
const { data, error, count } = await query
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data, count })
|
||||
}
|
||||
return NextResponse.json({ data, count })
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,34 +1,27 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { verifyCronSecret } from '@/lib/auth/cron'
|
||||
import { withCronContext } from '@/lib/api/with-cron-context'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
|
||||
/**
|
||||
* GET /api/documents/verify/cron
|
||||
* Batch integrity verification of WORM document archive
|
||||
*
|
||||
* Runs weekly (Sunday 03:00 UTC / 05:00 Swedish time).
|
||||
* Processes up to 100 documents per run, prioritizing
|
||||
* documents never checked or least recently checked.
|
||||
*
|
||||
* Uses service role for cross-user verification (RLS bypass).
|
||||
* GET /api/documents/verify/cron — weekly Sunday 03:00 UTC.
|
||||
* Spot-checks WORM archive integrity by recomputing SHA-256 for the next
|
||||
* batch of documents and writing INTEGRITY_FAILURE rows to the audit log
|
||||
* for any mismatches.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const authError = verifyCronSecret(request)
|
||||
if (authError) return authError
|
||||
|
||||
export const GET = withCronContext('cron.documents_verify', async (_request, ctx) => {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
|
||||
if (!supabaseUrl || !supabaseServiceKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing Supabase configuration' },
|
||||
{ status: 500 }
|
||||
)
|
||||
return errorResponseFromCode('INTERNAL_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { reason: 'Missing Supabase configuration' },
|
||||
})
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey)
|
||||
|
||||
// Fetch up to 100 current-version documents, prioritizing unchecked/oldest
|
||||
const { data: documents, error: fetchError } = await supabase
|
||||
.from('document_attachments')
|
||||
.select('id, user_id, company_id, storage_path, sha256_hash, file_name')
|
||||
@@ -37,8 +30,8 @@ export async function GET(request: Request) {
|
||||
.limit(parseInt(process.env.DOCUMENT_VERIFY_BATCH_SIZE || '500', 10))
|
||||
|
||||
if (fetchError) {
|
||||
console.error('[doc-verify-cron] Failed to fetch documents:', fetchError)
|
||||
return NextResponse.json({ error: 'Failed to fetch documents' }, { status: 500 })
|
||||
ctx.log.error('failed to fetch documents for verify', fetchError)
|
||||
return errorResponse(fetchError, ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
|
||||
if (!documents || documents.length === 0) {
|
||||
@@ -47,68 +40,63 @@ export async function GET(request: Request) {
|
||||
|
||||
let verified = 0
|
||||
let failures = 0
|
||||
let errors = 0
|
||||
|
||||
for (const doc of documents) {
|
||||
try {
|
||||
// Download file from storage
|
||||
const { data: fileData, error: downloadError } = await supabase.storage
|
||||
.from('documents')
|
||||
.download(doc.storage_path)
|
||||
const summary = await ctx.forEach('document', documents, async (doc, itemCtx) => {
|
||||
const { data: fileData, error: downloadError } = await supabase.storage
|
||||
.from('documents')
|
||||
.download(doc.storage_path)
|
||||
|
||||
if (downloadError || !fileData) {
|
||||
console.error(`[doc-verify-cron] Download failed for ${doc.id}:`, downloadError)
|
||||
errors++
|
||||
continue
|
||||
}
|
||||
|
||||
// Compute SHA-256 hash
|
||||
const buffer = await fileData.arrayBuffer()
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer)
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
const computedHash = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
|
||||
|
||||
const isValid = computedHash === doc.sha256_hash
|
||||
|
||||
// Update last_integrity_check_at
|
||||
await supabase
|
||||
.from('document_attachments')
|
||||
.update({ last_integrity_check_at: new Date().toISOString() })
|
||||
.eq('id', doc.id)
|
||||
|
||||
if (!isValid) {
|
||||
// Log integrity failure to audit_log
|
||||
await supabase.from('audit_log').insert({
|
||||
user_id: doc.user_id,
|
||||
company_id: doc.company_id,
|
||||
action: 'INTEGRITY_FAILURE',
|
||||
table_name: 'document_attachments',
|
||||
record_id: doc.id,
|
||||
description: `Integrity check failed for document "${doc.file_name}": stored hash ${doc.sha256_hash}, computed hash ${computedHash}`,
|
||||
old_state: { sha256_hash: doc.sha256_hash },
|
||||
new_state: { computed_hash: computedHash },
|
||||
})
|
||||
|
||||
console.error(`[doc-verify-cron] INTEGRITY FAILURE: document ${doc.id} (${doc.file_name})`)
|
||||
failures++
|
||||
} else {
|
||||
verified++
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[doc-verify-cron] Error verifying document ${doc.id}:`, error)
|
||||
errors++
|
||||
// Continue with other documents
|
||||
if (downloadError || !fileData) {
|
||||
throw new Error(downloadError?.message || 'download_failed')
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[doc-verify-cron] Processed ${documents.length}: ${verified} verified, ${failures} failures, ${errors} errors`
|
||||
)
|
||||
const buffer = await fileData.arrayBuffer()
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer)
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
const computedHash = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
|
||||
|
||||
return NextResponse.json({
|
||||
processed: documents.length,
|
||||
const isValid = computedHash === doc.sha256_hash
|
||||
|
||||
await supabase
|
||||
.from('document_attachments')
|
||||
.update({ last_integrity_check_at: new Date().toISOString() })
|
||||
.eq('id', doc.id)
|
||||
|
||||
if (!isValid) {
|
||||
await supabase.from('audit_log').insert({
|
||||
user_id: doc.user_id,
|
||||
company_id: doc.company_id,
|
||||
action: 'INTEGRITY_FAILURE',
|
||||
table_name: 'document_attachments',
|
||||
record_id: doc.id,
|
||||
description: `Integrity check failed for document "${doc.file_name}": stored hash ${doc.sha256_hash}, computed hash ${computedHash}`,
|
||||
old_state: { sha256_hash: doc.sha256_hash },
|
||||
new_state: { computed_hash: computedHash },
|
||||
})
|
||||
|
||||
itemCtx.log.error('integrity failure', new Error('hash_mismatch'), {
|
||||
documentId: doc.id,
|
||||
fileName: doc.file_name,
|
||||
storedHash: doc.sha256_hash,
|
||||
computedHash,
|
||||
})
|
||||
failures++
|
||||
} else {
|
||||
verified++
|
||||
}
|
||||
})
|
||||
|
||||
ctx.log.info('document verify summary', {
|
||||
processed: summary.total,
|
||||
verified,
|
||||
failures,
|
||||
errors,
|
||||
downloadErrors: summary.failed,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
processed: summary.total,
|
||||
verified,
|
||||
failures,
|
||||
errors: summary.failed,
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user