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 <[email protected]>
This commit is contained in:
1 parent
dfb4de81f8
commit
961ca04a40
2 files changed
+100
-1
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> = {
|
||||
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,11 +65,21 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
const isHttpException = exception instanceof HttpException;
|
||||
const isUniqueConstraintError =
|
||||
exception instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
exception.code === 'P2002';
|
||||
const status = isHttpException
|
||||
? exception.getStatus()
|
||||
: isUniqueConstraintError
|
||||
? HttpStatus.CONFLICT
|
||||
: HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
const body = isHttpException
|
||||
? exception.getResponse()
|
||||
: isUniqueConstraintError
|
||||
? {
|
||||
statusCode: status,
|
||||
message: duplicateFieldMessage(exception.meta?.target),
|
||||
}
|
||||
: { statusCode: status, message: 'Internal server error' };
|
||||
const message =
|
||||
exception instanceof Error ? exception.message : 'Unknown error';
|
||||
|
||||
Reference in new issue
Block a user