Files
pn-new-crm/src/lib/errors.ts
Matt 221ae5784e chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged:

- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
  never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
  after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
  redirects (ocr to ai, reports to dashboard, invitations to users),
  docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
  flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
  let-reassign), set-state-in-effect disables in CountryFlag and
  UploadForSigning preview-bytes effect, unused 'confirm' destructures in
  interest contract + reservation tabs, unescaped apostrophe in test-template
  card copy
2026-05-23 00:52:59 +02:00

179 lines
5.8 KiB
TypeScript

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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = { 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 });
}