Files
pn-new-crm/src/lib/errors.ts
Matt acf878f997 feat(deps): bump zod 3→4 + @hookform/resolvers 3→5
Resolved 65 type errors across the codebase via these v4 migration
patterns:

- `ZodError.errors` renamed to `ZodError.issues` (4 call sites in auth
  routes + central error handler).
- `z.record(value)` now requires explicit key type: `z.record(z.string(),
  value)`. Updated 7 sites across templates / forms / saved-views /
  website-inquiries.
- `.refine(check, msgFn)` second-arg shape changed — now requires an
  `{ error: (issue) => ... }` object form. Updated
  `mergeFieldsSchema` in document-templates validator.
- `.transform(...).default(...)` chains: v4 enforces default value type
  matches transform OUTPUT. Reordered to `.default(...).transform(...)`
  in list-query / company-memberships handlers.
- `z.coerce.*()` INPUT type widened to `unknown` in v4. Service signatures
  using `z.input<typeof schema>` (kept for caller flexibility around
  defaults) now re-parse via `schema.parse(data)` to recover the
  post-coercion shape Drizzle needs. Done in berth-reservations service.
  Invoice service narrows `lineItems` locally with a typed cast since
  re-parsing would double-validate.
- `.optional().transform(...)` no longer propagates the optional marker
  through v4's new ZodPipe. Moved `.optional()` to the END of chain in
  `optionalDesiredDimSchema` (interests) and documents list query
  (folderId, signatureOnly).
- ZodIssue subtype shapes simplified: `received` removed from
  invalid_type, `type` renamed to `origin` on too_small. Test fixtures
  updated.
- @hookform/resolvers v5 splits Resolver into 3-generic form (Input,
  Context, Output). useForm calls in 6 forms (client, yacht, berth,
  interest, expense, invoices-new-page) now pass explicit generics:
  `useForm<z.input<typeof schema>, unknown, z.infer<typeof schema>>`.

Verified: tsc clean (0 errors), vitest 1293/1293 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:29:03 +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 });
}