1import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from "@nestjs/common";
2import { Prisma } from "@prisma/client";
3import { ZodError } from "zod";
4import type { Request, Response } from "express";
5import { ApiError, type ErrorCode, type ErrorDetail } from "../errors.js";
6import { logger } from "../logger.js";
7import { scrubSecrets } from "../log-scrubber.js";
8import { m } from "@factory/observability";
9
10
21@Catch()
22export class AllExceptionsFilter implements ExceptionFilter {
23 catch(exception: unknown, host: ArgumentsHost): void {
24 const ctx = host.switchToHttp();
25 const res = ctx.getResponse<Response>();
26 const req = ctx.getRequest<Request>();
27 const requestId = req.id ?? "unknown";
28
29 const mapped = this.map(exception, req);
30 const status = mapped.status;
31
32
33
34
35
36 m.error.record({ service: "factory-api", code: mapped.code });
37
38 if (status >= 500) {
39 logger.error(scrubSecrets(`unhandled error on ${req.method} ${req.originalUrl}`), {
40 requestId,
41 name: exception instanceof Error ? exception.name : typeof exception,
42 message: scrubSecrets(exception instanceof Error ? exception.message : String(exception)),
43 stack: exception instanceof Error ? scrubSecrets(String(exception.stack ?? "")) : null
44 });
45 } else if (status >= 400) {
46 logger.warn(scrubSecrets(`request failed ${status} on ${req.method} ${req.originalUrl}`), {
47 requestId,
48 code: mapped.code,
49 message: scrubSecrets(mapped.message)
50 });
51 }
52
53 res.status(status).json({
54 error: {
55 code: mapped.code,
56 message: mapped.message,
57 ...(mapped.details && mapped.details.length > 0 ? { details: mapped.details } : {}),
58 requestId
59 }
60 });
61 }
62
63 private map(exception: unknown, req: Request): { status: number; code: ErrorCode; message: string; details?: ErrorDetail[] } {
64 if (exception instanceof ApiError) {
65 return { status: exception.httpStatus, code: exception.code, message: exception.message, details: exception.details };
66 }
67 if (isTokenErrorLike(exception)) {
68 return {
69 status: 401,
70 code: exception.code,
71 message: exception.message,
72 details: [{ reason: exception.code === "TOKEN_EXPIRED" ? "token_expired" : "token_malformed" }]
73 };
74 }
75 if (exception instanceof ZodError) {
76 const details: ErrorDetail[] = exception.issues.slice(0, 20).map((i) => ({
77 field: i.path.join("."),
78 issue: i.message
79 }));
80 return { status: 400, code: "VALIDATION_ERROR", message: "Request validation failed.", details };
81 }
82 if (exception instanceof Prisma.PrismaClientKnownRequestError) {
83 if (exception.code === "P2002") {
84 const target = Array.isArray(exception.meta?.target) ? (exception.meta?.target as string[]).join(", ") : String(exception.meta?.target ?? "");
85 return { status: 409, code: "CONFLICT", message: "A record with the same unique value already exists.", details: [{ field: target, issue: "already_exists" }] };
86 }
87 if (exception.code === "P2025") {
88 return { status: 404, code: "NOT_FOUND", message: "Resource not found." };
89 }
90 if (exception.code === "P2003") {
91 return { status: 409, code: "CONFLICT", message: "Operation violates a relational constraint.", details: [{ issue: "foreign_key_constraint", received: exception.meta }] };
92 }
93 return { status: 400, code: "UNPROCESSABLE_ENTITY", message: "Database request could not be completed.", details: [{ issue: exception.code }] };
94 }
95 if (exception instanceof Prisma.PrismaClientValidationError) {
96 return { status: 400, code: "VALIDATION_ERROR", message: "Invalid database query.", details: [] };
97 }
98 if (exception instanceof HttpException) {
99 const status = exception.getStatus();
100 const code = this.httpCode(status);
101 let message = "Request failed.";
102 let details: ErrorDetail[] | undefined;
103 const body = exception.getResponse();
104 if (typeof body === "string") message = body;
105 else if (body && typeof body === "object") {
106 const b = body as Record<string, unknown>;
107 if (typeof b.message === "string") message = b.message;
108 else if (Array.isArray(b.message)) message = b.message.join("; ");
109 if (b.details) details = b.details as ErrorDetail[];
110 if (b.code) details = [...(details ?? []), { issue: String(b.code) }];
111 }
112 return { status, code, message, details };
113 }
114
115 return { status: 500, code: "INTERNAL_ERROR", message: "Internal server error." };
116 }
117
118 private httpCode(status: number): ErrorCode {
119 switch (status) {
120 case 400: return "VALIDATION_ERROR";
121 case 401: return "UNAUTHORIZED";
122 case 403: return "FORBIDDEN";
123 case 404: return "NOT_FOUND";
124 case 405: return "NOT_FOUND";
125 case 409: return "CONFLICT";
126 case 410: return "GONE";
127 case 412: return "PRECONDITION_FAILED";
128 case 413: return "PAYLOAD_TOO_LARGE";
129 case 422: return "VALIDATION_ERROR";
130 case 429: return "RATE_LIMITED";
131 case 501: return "NOT_IMPLEMENTED";
132 case 503: return "SERVICE_UNAVAILABLE";
133 default: return "INTERNAL_ERROR";
134 }
135 }
136}
137
138interface TokenErrorLike {
139 code: "INVALID_TOKEN" | "TOKEN_EXPIRED";
140 message: string;
141}
142
143function isTokenErrorLike(e: unknown): e is TokenErrorLike {
144 return typeof e === "object" && e !== null && "code" in e && ((e as TokenErrorLike).code === "INVALID_TOKEN" || (e as TokenErrorLike).code === "TOKEN_EXPIRED");
145}
146