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 { ArgumentsHost, BadRequestException, Logger } from '@nestjs/common';
|
||||||
|
import { Prisma } from '../../../generated/prisma';
|
||||||
import { AllExceptionsFilter } from './all-exceptions.filter';
|
import { AllExceptionsFilter } from './all-exceptions.filter';
|
||||||
|
|
||||||
describe('AllExceptionsFilter', () => {
|
describe('AllExceptionsFilter', () => {
|
||||||
@@ -67,4 +68,59 @@ describe('AllExceptionsFilter', () => {
|
|||||||
expect(warnSpy).toHaveBeenCalled();
|
expect(warnSpy).toHaveBeenCalled();
|
||||||
expect(errorSpy).not.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,
|
Logger,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type { Request, Response } from 'express';
|
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
|
* 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
|
* Client-facing behaviour is unchanged: HttpExceptions still return
|
||||||
* exactly the body/status Nest would have produced. Non-HttpException
|
* exactly the body/status Nest would have produced. Non-HttpException
|
||||||
* errors still collapse to a generic 500 body - the real message and
|
* 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()
|
@Catch()
|
||||||
export class AllExceptionsFilter implements ExceptionFilter {
|
export class AllExceptionsFilter implements ExceptionFilter {
|
||||||
@@ -32,11 +65,21 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
|||||||
const request = ctx.getRequest<Request>();
|
const request = ctx.getRequest<Request>();
|
||||||
|
|
||||||
const isHttpException = exception instanceof HttpException;
|
const isHttpException = exception instanceof HttpException;
|
||||||
|
const isUniqueConstraintError =
|
||||||
|
exception instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
exception.code === 'P2002';
|
||||||
const status = isHttpException
|
const status = isHttpException
|
||||||
? exception.getStatus()
|
? exception.getStatus()
|
||||||
|
: isUniqueConstraintError
|
||||||
|
? HttpStatus.CONFLICT
|
||||||
: HttpStatus.INTERNAL_SERVER_ERROR;
|
: HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
const body = isHttpException
|
const body = isHttpException
|
||||||
? exception.getResponse()
|
? exception.getResponse()
|
||||||
|
: isUniqueConstraintError
|
||||||
|
? {
|
||||||
|
statusCode: status,
|
||||||
|
message: duplicateFieldMessage(exception.meta?.target),
|
||||||
|
}
|
||||||
: { statusCode: status, message: 'Internal server error' };
|
: { statusCode: status, message: 'Internal server error' };
|
||||||
const message =
|
const message =
|
||||||
exception instanceof Error ? exception.message : 'Unknown error';
|
exception instanceof Error ? exception.message : 'Unknown error';
|
||||||
|
|||||||
Reference in new issue
Block a user