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
179 lines
5.8 KiB
TypeScript
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 });
|
|
}
|