import { NextResponse } from 'next/server'; import { ZodError } from 'zod'; import { logger } from '@/lib/logger'; import { getRequestId } from '@/lib/request-context'; import { captureErrorEvent } from '@/lib/services/error-events.service'; import { ERROR_CODES, type ErrorCode } from '@/lib/error-codes'; export class AppError extends Error { constructor( public statusCode: number, message: string, public code?: string, ) { super(message); this.name = 'AppError'; } } /** * Throw site for any registered error code. Consolidates the * status + plain-text message + stable code into one constructor. * * throw new CodedError('EXPENSES_RECEIPT_REQUIRED'); * * Pass `details` for structured payload (e.g. zod validation issues), * or `internalMessage` for an admin-only string that lands in the * error_events row but is NEVER returned to the user (the user gets * the plain-text message from the registry). */ export class CodedError extends AppError { /** Optional structured details surfaced to the client. */ public details?: unknown; /** Optional verbose message for admin logs only - never sent to client. */ public internalMessage?: string; constructor(code: ErrorCode, opts: { details?: unknown; internalMessage?: string } = {}) { const def = ERROR_CODES[code]; super(def.status, def.userMessage, code); this.name = 'CodedError'; this.details = opts.details; this.internalMessage = opts.internalMessage; } } /** * Backwards-compat shims: these existing subclasses are still used in * lots of places; new throw sites should prefer `CodedError` so the * code surfaces in the registry. * * Messages have been rewritten to plain language (no internal jargon) * so the user-facing toast reads naturally even before a service is * migrated to a specific CodedError code. */ export class NotFoundError extends AppError { constructor(entity: string) { // Plain-text version of "X not found" - the registered code stays // generic until callers migrate to specific codes per entity. super( 404, `We couldn't find that ${entity.toLowerCase()}. It may have been removed.`, 'NOT_FOUND', ); } } export class ForbiddenError extends AppError { constructor( message = "You don't have permission to do that. Ask an admin if you think you should.", ) { super(403, message, 'FORBIDDEN'); } } export class UnauthorizedError extends AppError { constructor(message = 'Please sign in to continue.') { super(401, message, 'UNAUTHORIZED'); } } export class ValidationError extends AppError { constructor( message: string, public details?: Array<{ field: string; message: string }>, ) { super(400, message, 'VALIDATION_ERROR'); } } export class ConflictError extends AppError { constructor(message: string) { super(409, message, 'CONFLICT'); } } export class RateLimitError extends AppError { constructor(public retryAfter: number) { super( 429, "You've done that a lot in a short time. Please wait a moment and try again.", 'RATE_LIMITED', ); } } /** * Converts any thrown value into a sanitised NextResponse. * * Always attaches the active `X-Request-Id` to: * - the response header (so a curl/dev-tools user can see it) * - the JSON body (so a UI toast can surface "Error ID: …") * * For unhandled (5xx) errors, also persists a row to `error_events` * so a super admin can paste the request id into the inspector and * pull the full stack + body excerpt + log lines. * * Never leaks stack traces, internal paths, or DB error details to * the client - that data goes to pino + the error_events row only. */ export function errorResponse(error: unknown): NextResponse { const requestId = getRequestId(); const headers = requestId ? { 'X-Request-Id': requestId } : undefined; if (error instanceof AppError) { const body: Record = { error: error.message, code: error.code, }; if (requestId) body.requestId = requestId; if (error instanceof ValidationError && error.details) { body.details = error.details; } if (error instanceof CodedError && error.details !== undefined) { body.details = error.details; } if (error instanceof RateLimitError) { body.retryAfter = error.retryAfter; } // 4xx errors are user-action mistakes (validation, not-found, // permission). They DON'T go to error_events - that table is for // platform faults the super admin needs to triage. The exception: // when a CodedError carries an internalMessage, persist it under // a debug_events flag so admins can still trace deliberate-throw // patterns. (Only 5xx CodedErrors get persisted automatically.) if (error.statusCode >= 500) { void captureErrorEvent({ statusCode: error.statusCode, error, metadata: error instanceof CodedError ? { internalMessage: error.internalMessage } : {}, }); } return NextResponse.json(body, { status: error.statusCode, headers }); } if (error instanceof ZodError) { const body: Record = { error: 'Validation failed', code: 'VALIDATION_ERROR', details: error.issues.map((e) => ({ field: e.path.join('.'), message: e.message, })), }; if (requestId) body.requestId = requestId; return NextResponse.json(body, { status: 400, headers }); } // Unhandled - full details to pino + persist to error_events. logger.error({ err: error }, 'Unhandled error'); void captureErrorEvent({ statusCode: 500, error }); const body: Record = { error: 'Internal server error', code: 'INTERNAL' }; if (requestId) { body.requestId = requestId; body.message = `Something went wrong on our end. Quote error ID ${requestId} when reporting this.`; } return NextResponse.json(body, { status: 500, headers }); }