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>
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 });
|
|
}
|