fix(api): add centralized Prisma → TRPCError middleware on protectedProcedure

P2002/P2025/P2003 now map to CONFLICT/NOT_FOUND/BAD_REQUEST with generic
messages. Raw Prisma error details no longer reach the client.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-09 19:58:00 +02:00
parent 245b59723a
commit f7407bd882
2 changed files with 121 additions and 2 deletions
+49 -2
View File
@@ -1,4 +1,4 @@
import { prisma } from "@capakraken/db";
import { prisma, Prisma } from "@capakraken/db";
import { resolvePermissions, PermissionKey, SystemRole } from "@capakraken/shared";
import { initTRPC, TRPCError } from "@trpc/server";
import { ZodError } from "zod";
@@ -89,6 +89,53 @@ export const publicProcedure = t.procedure;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const withLogging = t.middleware(loggingMiddleware as any);
/**
* Maps a known Prisma error to a TRPCError with a safe generic message.
* Exported for unit testing.
*/
export function mapPrismaError(error: Prisma.PrismaClientKnownRequestError): TRPCError {
switch (error.code) {
case "P2002": // Unique constraint
return new TRPCError({
code: "CONFLICT",
message: "A record with this value already exists.",
});
case "P2025": // Record not found
return new TRPCError({
code: "NOT_FOUND",
message: "The requested record was not found.",
});
case "P2003": // Foreign key constraint
return new TRPCError({
code: "BAD_REQUEST",
message: "A referenced record does not exist.",
});
default:
// Log the full error server-side but don't expose internals
// eslint-disable-next-line no-console
console.error("[Prisma error]", error.code, error.message);
return new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "A database error occurred.",
});
}
}
/**
* Middleware that catches PrismaClientKnownRequestError and maps it to a clean TRPCError
* so DB internals never reach the client.
*/
const withPrismaErrors = t.middleware(async ({ next }) => {
try {
return await next();
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw mapPrismaError(error);
}
throw error; // re-throw non-Prisma errors unchanged
}
});
const isE2eTestMode =
process.env["E2E_TEST_MODE"] === "true" && process.env["NODE_ENV"] !== "production";
if (process.env["E2E_TEST_MODE"] === "true" && process.env["NODE_ENV"] === "production") {
@@ -100,7 +147,7 @@ if (process.env["E2E_TEST_MODE"] === "true" && process.env["NODE_ENV"] === "prod
* Protected procedure — requires authenticated session AND a valid DB user record.
* This prevents stale sessions from accessing data after the DB user is deleted.
*/
export const protectedProcedure = t.procedure.use(withLogging).use(async ({ ctx, next }) => {
export const protectedProcedure = t.procedure.use(withPrismaErrors).use(withLogging).use(async ({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Authentication required" });
}