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,5 +1,6 @@
|
||||
import { z } from 'zod'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { Logger } from '@/lib/logger'
|
||||
|
||||
export interface ValidationSuccess<T> {
|
||||
success: true
|
||||
@@ -13,6 +14,28 @@ export interface ValidationFailure {
|
||||
|
||||
export type ValidationResult<T> = ValidationSuccess<T> | ValidationFailure
|
||||
|
||||
interface ValidationOptions {
|
||||
/** Optional logger; when present, validation failures are logged at warn level. */
|
||||
log?: Logger
|
||||
/** Identifier for the operation/route being validated, included in the log line. */
|
||||
operation?: string
|
||||
}
|
||||
|
||||
function logIssues(
|
||||
options: ValidationOptions | undefined,
|
||||
kind: 'body' | 'query' | 'json',
|
||||
issues: Array<{ field: string; message: string; code: string }> | string,
|
||||
) {
|
||||
if (!options?.log) return
|
||||
options.log.warn('validation failed', {
|
||||
operation: options.operation,
|
||||
kind,
|
||||
...(typeof issues === 'string'
|
||||
? { reason: issues }
|
||||
: { issueCount: issues.length, issues }),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a request body against a Zod schema.
|
||||
*
|
||||
@@ -29,11 +52,13 @@ export type ValidationResult<T> = ValidationSuccess<T> | ValidationFailure
|
||||
export async function validateBody<T>(
|
||||
request: Request,
|
||||
schema: z.ZodType<T>,
|
||||
options?: ValidationOptions,
|
||||
): Promise<ValidationResult<T>> {
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
logIssues(options, 'json', 'Invalid JSON in request body')
|
||||
return {
|
||||
success: false,
|
||||
response: NextResponse.json(
|
||||
@@ -55,6 +80,8 @@ export async function validateBody<T>(
|
||||
code: issue.code,
|
||||
}))
|
||||
|
||||
logIssues(options, 'body', errors)
|
||||
|
||||
return {
|
||||
success: false,
|
||||
response: NextResponse.json(
|
||||
@@ -84,6 +111,7 @@ export async function validateBody<T>(
|
||||
export function validateQuery<T>(
|
||||
request: Request,
|
||||
schema: z.ZodType<T>,
|
||||
options?: ValidationOptions,
|
||||
): ValidationResult<T> {
|
||||
const url = new URL(request.url)
|
||||
const raw = Object.fromEntries(url.searchParams.entries())
|
||||
@@ -97,6 +125,8 @@ export function validateQuery<T>(
|
||||
code: issue.code,
|
||||
}))
|
||||
|
||||
logIssues(options, 'query', errors)
|
||||
|
||||
return {
|
||||
success: false,
|
||||
response: NextResponse.json(
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Sibling of withRouteContext for cron endpoints.
|
||||
*
|
||||
* - Verifies CRON_SECRET via verifyCronSecret(); returns the standard envelope
|
||||
* on failure.
|
||||
* - Generates a parent requestId so every per-item log line for a single run
|
||||
* shares a correlation id you can grep for in Vercel logs.
|
||||
* - Provides a `forEach` helper that runs the iteratee in an isolated try/catch
|
||||
* per item and logs the outcome at info/error level. A single failing item
|
||||
* never aborts the run.
|
||||
*
|
||||
* Usage:
|
||||
* export const GET = withCronContext('cron.invoice-reminders', async (ctx) => {
|
||||
* const reminders = await loadDueReminders()
|
||||
* const summary = await ctx.forEach('reminder', reminders, async (item, itemCtx) => {
|
||||
* await sendReminder(item)
|
||||
* })
|
||||
* return NextResponse.json({ data: summary })
|
||||
* })
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server'
|
||||
import { verifyCronSecret } from '@/lib/auth/cron'
|
||||
import { createLogger, type Logger } from '@/lib/logger'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
|
||||
export interface CronItemContext {
|
||||
/** Per-item requestId, child of the run's parent requestId. */
|
||||
requestId: string
|
||||
log: Logger
|
||||
parentRequestId: string
|
||||
}
|
||||
|
||||
interface CronForEachResult {
|
||||
total: number
|
||||
succeeded: number
|
||||
failed: number
|
||||
failures: Array<{ index: number; error: string }>
|
||||
}
|
||||
|
||||
export interface CronContext {
|
||||
requestId: string
|
||||
log: Logger
|
||||
/**
|
||||
* Iterate items with isolated try/catch + structured per-item logs. The
|
||||
* returned summary is suitable to ship in the response body so an operator
|
||||
* can see how many succeeded/failed at a glance.
|
||||
*/
|
||||
forEach<T>(
|
||||
label: string,
|
||||
items: T[],
|
||||
iteratee: (item: T, itemCtx: CronItemContext) => Promise<void>,
|
||||
): Promise<CronForEachResult>
|
||||
}
|
||||
|
||||
type CronHandler = (request: Request, ctx: CronContext) => Promise<NextResponse | Response>
|
||||
|
||||
function generateRequestId(prefix: 'cron' | 'cron_item' = 'cron'): string {
|
||||
return `${prefix}_${crypto.randomUUID()}`
|
||||
}
|
||||
|
||||
export function withCronContext(
|
||||
operation: string,
|
||||
handler: CronHandler,
|
||||
): (request: Request) => Promise<Response> {
|
||||
return async function wrapped(request: Request): Promise<Response> {
|
||||
const requestId = generateRequestId('cron')
|
||||
const start = Date.now()
|
||||
const log = createLogger(`cron/${operation}`, { requestId, operation })
|
||||
|
||||
const authError = verifyCronSecret(request)
|
||||
if (authError) {
|
||||
log.warn('cron auth failed')
|
||||
return errorResponseFromCode('UNAUTHORIZED', log, { requestId })
|
||||
}
|
||||
|
||||
log.info('cron run started')
|
||||
|
||||
const forEach: CronContext['forEach'] = async (label, items, iteratee) => {
|
||||
const result: CronForEachResult = {
|
||||
total: items.length,
|
||||
succeeded: 0,
|
||||
failed: 0,
|
||||
failures: [],
|
||||
}
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i]
|
||||
const itemRequestId = generateRequestId('cron_item')
|
||||
const itemLog = log.child({ itemRequestId, itemIndex: i, itemLabel: label })
|
||||
const itemCtx: CronItemContext = {
|
||||
requestId: itemRequestId,
|
||||
log: itemLog,
|
||||
parentRequestId: requestId,
|
||||
}
|
||||
|
||||
try {
|
||||
await iteratee(item, itemCtx)
|
||||
result.succeeded++
|
||||
itemLog.info('cron item ok')
|
||||
} catch (err) {
|
||||
result.failed++
|
||||
const errorMessage = err instanceof Error ? err.message : String(err)
|
||||
result.failures.push({ index: i, error: errorMessage })
|
||||
itemLog.error('cron item failed', err as Error)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const ctx: CronContext = { requestId, log, forEach }
|
||||
|
||||
try {
|
||||
const response = await handler(request, ctx)
|
||||
if (response instanceof Response && !response.headers.get('X-Request-Id')) {
|
||||
response.headers.set('X-Request-Id', requestId)
|
||||
}
|
||||
log.info('cron run completed', {
|
||||
durationMs: Date.now() - start,
|
||||
status: response.status,
|
||||
})
|
||||
return response
|
||||
} catch (err) {
|
||||
log.error('cron run failed', err as Error, { durationMs: Date.now() - start })
|
||||
return errorResponse(err, log, { requestId })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Single wrapper that gives every API route the same shape:
|
||||
*
|
||||
* - generates a request id (`req_<uuid>`) and threads it through the logger
|
||||
* - resolves auth via requireAuth() and (by default) the active companyId
|
||||
* - emits one structured `info` log on completion with duration
|
||||
* - converts any thrown value into the canonical error envelope via
|
||||
* errorResponse(); the request id appears in the response body and the
|
||||
* X-Request-Id response header
|
||||
*
|
||||
* Usage:
|
||||
* export const POST = withRouteContext('invoice.send', async (req, ctx) => {
|
||||
* // ctx.requestId, ctx.log, ctx.user, ctx.supabase, ctx.companyId
|
||||
* const result = await sendInvoice(...)
|
||||
* return NextResponse.json({ data: result })
|
||||
* })
|
||||
*
|
||||
* For dynamic routes the second parameter is the Next.js params promise:
|
||||
* export const POST = withRouteContext('invoice.send', async (req, ctx, { params }) => {
|
||||
* const { id } = await params
|
||||
* ...
|
||||
* })
|
||||
*/
|
||||
|
||||
import type { SupabaseClient, User } from '@supabase/supabase-js'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireAuth } from '@/lib/auth/require-auth'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { getActiveCompanyId } from '@/lib/company/context'
|
||||
import { createLogger, type Logger } from '@/lib/logger'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
|
||||
export interface RouteContext {
|
||||
/** Stable id for this HTTP request — appears in logs, error envelope, X-Request-Id header. */
|
||||
requestId: string
|
||||
/** Logger pre-bound with { requestId, userId, companyId, operation }. */
|
||||
log: Logger
|
||||
/** Authenticated user. Always present — wrapper short-circuits with 401 otherwise. */
|
||||
user: User
|
||||
/** Authenticated Supabase client (request-scoped, RLS active). */
|
||||
supabase: SupabaseClient
|
||||
/**
|
||||
* Resolved active company id. The wrapper short-circuits with
|
||||
* COMPANY_CONTEXT_MISSING before invoking the handler when no company is
|
||||
* resolved, so handlers can treat this as guaranteed non-null. Routes that
|
||||
* need to opt out of the guarantee (e.g. onboarding) shouldn't use
|
||||
* withRouteContext.
|
||||
*/
|
||||
companyId: string
|
||||
}
|
||||
|
||||
interface RouteContextOptions {
|
||||
/**
|
||||
* Defaults to false. When true, the wrapper rejects callers whose role in
|
||||
* the active company is `viewer` (or who have no membership). Mirrors the
|
||||
* existing requireWritePermission() helper so mutating routes can drop two
|
||||
* lines of boilerplate.
|
||||
*/
|
||||
requireWrite?: boolean
|
||||
}
|
||||
|
||||
// Next.js 16 always passes a `{ params: Promise<...> }` second arg to route
|
||||
// handlers — including on non-dynamic routes, where it's `Promise<{}>`. The
|
||||
// generic defaults to that empty shape so static routes type-check without
|
||||
// having to declare any params at the call site.
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
type DynamicParams = { params: Promise<Record<string, string | string[]>> } | { params: Promise<{}> }
|
||||
|
||||
type RouteHandler<P extends DynamicParams = { params: Promise<Record<string, never>> }> = (
|
||||
request: Request,
|
||||
ctx: RouteContext,
|
||||
params: P,
|
||||
) => Promise<NextResponse | Response>
|
||||
|
||||
function generateRequestId(): string {
|
||||
// crypto.randomUUID is available in Node 20+/edge runtimes used by Next.js.
|
||||
return `req_${crypto.randomUUID()}`
|
||||
}
|
||||
|
||||
export function withRouteContext<P extends DynamicParams = { params: Promise<Record<string, never>> }>(
|
||||
operation: string,
|
||||
handler: RouteHandler<P>,
|
||||
options: RouteContextOptions = {},
|
||||
): (request: Request, params: P) => Promise<Response> {
|
||||
const { requireWrite = false } = options
|
||||
|
||||
return async function wrapped(request: Request, params: P): Promise<Response> {
|
||||
const requestId = generateRequestId()
|
||||
const start = Date.now()
|
||||
const log = createLogger(`api/${operation}`, { requestId, operation })
|
||||
|
||||
try {
|
||||
const auth = await requireAuth()
|
||||
if (auth.error) {
|
||||
log.warn('auth failed', { status: auth.error.status })
|
||||
// Pass through requireAuth's response unchanged for backwards-compat
|
||||
// with existing route tests; only inject the request id header so
|
||||
// support can still trace the request.
|
||||
if (!auth.error.headers.get('X-Request-Id')) {
|
||||
auth.error.headers.set('X-Request-Id', requestId)
|
||||
}
|
||||
return auth.error
|
||||
}
|
||||
|
||||
const { user, supabase } = auth
|
||||
const userLog = log.child({ userId: user.id })
|
||||
|
||||
let companyId: string | null = null
|
||||
try {
|
||||
companyId = await getActiveCompanyId(supabase, user.id)
|
||||
} catch (err) {
|
||||
userLog.error('failed to resolve active company', err as Error)
|
||||
}
|
||||
|
||||
if (!companyId) {
|
||||
return errorResponseFromCode('COMPANY_CONTEXT_MISSING', userLog, { requestId })
|
||||
}
|
||||
|
||||
if (requireWrite) {
|
||||
// Delegate to the existing helper so tests that already mock it
|
||||
// continue to work. The helper returns its own 403 NextResponse;
|
||||
// we wrap it in our request-id header for traceability.
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) {
|
||||
userLog.warn('write permission denied')
|
||||
if (!writeCheck.response.headers.get('X-Request-Id')) {
|
||||
writeCheck.response.headers.set('X-Request-Id', requestId)
|
||||
}
|
||||
return writeCheck.response
|
||||
}
|
||||
}
|
||||
|
||||
const ctx: RouteContext = {
|
||||
requestId,
|
||||
log: userLog.child({ companyId }),
|
||||
user,
|
||||
supabase,
|
||||
companyId,
|
||||
}
|
||||
|
||||
const response = await handler(request, ctx, params)
|
||||
|
||||
if (response instanceof Response && !response.headers.get('X-Request-Id')) {
|
||||
response.headers.set('X-Request-Id', requestId)
|
||||
}
|
||||
|
||||
ctx.log.info('op completed', {
|
||||
durationMs: Date.now() - start,
|
||||
status: response.status,
|
||||
})
|
||||
return response
|
||||
} catch (err) {
|
||||
log.error('op failed', err as Error, { durationMs: Date.now() - start })
|
||||
return errorResponse(err, log, { requestId })
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user