From 961ca04a403e0f409664313edce2df14eeb46d78 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Wed, 9 Sep 2026 11:47:28 +0200 Subject: [PATCH] fix: translate duplicate-code errors into clean 409 responses No code anywhere caught Prisma's P2002 (unique constraint violation). Colony/apiary/queen codes, product SKUs, batch numbers, and equipment inventory numbers are all @unique DB columns with no application-level duplicate check ahead of the insert, relying on the constraint itself - so a beekeeper reusing a code by mistake got a bare 500 "Internal server error" with zero indication what went wrong, and the incident polluted server logs as a fake error rather than a routine 4xx. AllExceptionsFilter now recognizes P2002, returns 409 with a specific German message naming the conflicting field when it's a single known column (falling back to a generic duplicate message otherwise), and logs it at warn level like any other client-side rejection. Co-Authored-By: Claude Sonnet 5 --- .../filters/all-exceptions.filter.spec.ts | 56 +++++++++++++++++++ .../common/filters/all-exceptions.filter.ts | 49 +++++++++++++++- 2 files changed, 102 insertions(+), 3 deletions(-) diff --git a/ApiCore-Backend/src/common/filters/all-exceptions.filter.spec.ts b/ApiCore-Backend/src/common/filters/all-exceptions.filter.spec.ts index e0d6a52..4eccd64 100644 --- a/ApiCore-Backend/src/common/filters/all-exceptions.filter.spec.ts +++ b/ApiCore-Backend/src/common/filters/all-exceptions.filter.spec.ts @@ -1,4 +1,5 @@ import { ArgumentsHost, BadRequestException, Logger } from '@nestjs/common'; +import { Prisma } from '../../../generated/prisma'; import { AllExceptionsFilter } from './all-exceptions.filter'; describe('AllExceptionsFilter', () => { @@ -67,4 +68,59 @@ describe('AllExceptionsFilter', () => { expect(warnSpy).toHaveBeenCalled(); expect(errorSpy).not.toHaveBeenCalled(); }); + + it('translates a P2002 unique-constraint error on a known field to a 409 with a specific message', () => { + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + const exception = new Prisma.PrismaClientKnownRequestError( + 'Unique constraint failed on the fields: (`code`)', + { code: 'P2002', clientVersion: '6.0.0', meta: { target: ['code'] } }, + ); + + filter.catch(exception, host); + + expect(statusMock).toHaveBeenCalledWith(409); + expect(jsonMock).toHaveBeenCalledWith({ + statusCode: 409, + message: 'Code wird bereits verwendet - bitte einen anderen Wert wählen', + }); + }); + + it('falls back to a generic duplicate message for a P2002 on an unmapped or compound field', () => { + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + const exception = new Prisma.PrismaClientKnownRequestError( + 'Unique constraint failed on the fields: (`sourceType`,`sourceId`)', + { + code: 'P2002', + clientVersion: '6.0.0', + meta: { target: ['sourceType', 'sourceId'] }, + }, + ); + + filter.catch(exception, host); + + expect(statusMock).toHaveBeenCalledWith(409); + expect(jsonMock).toHaveBeenCalledWith({ + statusCode: 409, + message: + 'Dieser Wert wird bereits verwendet - bitte einen anderen Wert wählen', + }); + }); + + it('logs a P2002 error at warn level, not error level, since it is a user input problem', () => { + const errorSpy = jest + .spyOn(Logger.prototype, 'error') + .mockImplementation(() => undefined); + const warnSpy = jest + .spyOn(Logger.prototype, 'warn') + .mockImplementation(() => undefined); + const exception = new Prisma.PrismaClientKnownRequestError( + 'Unique constraint failed on the fields: (`code`)', + { code: 'P2002', clientVersion: '6.0.0', meta: { target: ['code'] } }, + ); + + filter.catch(exception, host); + + expect(warnSpy).toHaveBeenCalled(); + expect(errorSpy).not.toHaveBeenCalled(); + }); }); diff --git a/ApiCore-Backend/src/common/filters/all-exceptions.filter.ts b/ApiCore-Backend/src/common/filters/all-exceptions.filter.ts index 3e8a6c0..93f1677 100644 --- a/ApiCore-Backend/src/common/filters/all-exceptions.filter.ts +++ b/ApiCore-Backend/src/common/filters/all-exceptions.filter.ts @@ -7,6 +7,32 @@ import { Logger, } from '@nestjs/common'; import type { Request, Response } from 'express'; +import { Prisma } from '../../../generated/prisma'; + +// Unique-constraint fields a beekeeper might plausibly retype by mistake +// (a hive code, a batch number) - translated so the P2002 branch below can +// name the actual field instead of a raw column identifier. +const UNIQUE_FIELD_LABELS: Record = { + code: 'Code', + email: 'E-Mail-Adresse', + sku: 'Artikelnummer (SKU)', + batchNumber: 'Chargennummer', + lotNumber: 'Los-/Chargennummer', + inventoryNumber: 'Inventarnummer', +}; + +function duplicateFieldMessage(target: unknown): string { + const fields = Array.isArray(target) + ? target.filter((value): value is string => typeof value === 'string') + : typeof target === 'string' + ? [target] + : []; + const label = + fields.length === 1 ? UNIQUE_FIELD_LABELS[fields[0]] : undefined; + return label + ? `${label} wird bereits verwendet - bitte einen anderen Wert wählen` + : 'Dieser Wert wird bereits verwendet - bitte einen anderen Wert wählen'; +} /** * Replaces Nest's default exception handling so EVERY error - not just @@ -20,7 +46,14 @@ import type { Request, Response } from 'express'; * Client-facing behaviour is unchanged: HttpExceptions still return * exactly the body/status Nest would have produced. Non-HttpException * errors still collapse to a generic 500 body - the real message and - * stack go to the log, never to the client. + * stack go to the log, never to the client. The one exception is Prisma's + * P2002 (unique constraint violation): services create/update records + * straight off a DB `@unique` column (colony/apiary/queen codes, SKUs, + * batch numbers) with no application-level duplicate check, relying on + * the constraint itself - so without this branch, a beekeeper reusing a + * code by mistake saw a bare 500 "Internal server error" instead of a + * clean, specific message, and the log filled with fake server errors for + * what is really a user input problem. */ @Catch() export class AllExceptionsFilter implements ExceptionFilter { @@ -32,12 +65,22 @@ export class AllExceptionsFilter implements ExceptionFilter { const request = ctx.getRequest(); const isHttpException = exception instanceof HttpException; + const isUniqueConstraintError = + exception instanceof Prisma.PrismaClientKnownRequestError && + exception.code === 'P2002'; const status = isHttpException ? exception.getStatus() - : HttpStatus.INTERNAL_SERVER_ERROR; + : isUniqueConstraintError + ? HttpStatus.CONFLICT + : HttpStatus.INTERNAL_SERVER_ERROR; const body = isHttpException ? exception.getResponse() - : { statusCode: status, message: 'Internal server error' }; + : isUniqueConstraintError + ? { + statusCode: status, + message: duplicateFieldMessage(exception.meta?.target), + } + : { statusCode: status, message: 'Internal server error' }; const message = exception instanceof Error ? exception.message : 'Unknown error'; const context = `${request.method} ${request.originalUrl} -> ${status}`;