feat(backend): enforce unique names and surface duplicate errors

This commit is contained in:
Matthieu
2025-10-13 17:03:36 +02:00
parent dc4a12440b
commit 582a6fd7e1
6 changed files with 228 additions and 58 deletions

View File

@@ -0,0 +1,16 @@
-- Drop previous non-unique index to replace it with a unique constraint
DROP INDEX IF EXISTS "ModelType_category_name_idx";
-- Ensure unique names for machines, components, and pieces
ALTER TABLE "machines"
ADD CONSTRAINT "machines_name_key" UNIQUE ("name");
ALTER TABLE "composants"
ADD CONSTRAINT "composants_name_key" UNIQUE ("name");
ALTER TABLE "pieces"
ADD CONSTRAINT "pieces_name_key" UNIQUE ("name");
-- Enforce unique category/name pairs for model types (component & piece categories)
ALTER TABLE "ModelType"
ADD CONSTRAINT "ModelType_category_name_key" UNIQUE ("category", "name");

View File

@@ -53,7 +53,7 @@ model TypeMachine {
model Machine {
id String @id @default(cuid())
name String
name String @unique
reference String?
prix Decimal? @db.Decimal(10, 2)
createdAt DateTime @default(now())
@@ -79,7 +79,7 @@ model Machine {
model Composant {
id String @id @default(cuid())
name String
name String @unique
reference String?
prix Decimal? @db.Decimal(10, 2)
createdAt DateTime @default(now())
@@ -101,7 +101,7 @@ model Composant {
model Piece {
id String @id @default(cuid())
name String
name String @unique
reference String?
prix Decimal? @db.Decimal(10, 2)
createdAt DateTime @default(now())
@@ -186,7 +186,7 @@ model ModelType {
pieces Piece[] @relation("ModelTypePieceAssignments")
pieceCustomFields CustomField[] @relation("ModelTypePieceCustomFields")
@@index([category, name])
@@unique([category, name])
}
model Constructeur {

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { ConflictException, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import {
@@ -44,12 +44,16 @@ export class ComposantsService {
}
async create(createComposantDto: CreateComposantDto) {
const created = await this.prisma.composant.create({
data: this.buildCreateInput(createComposantDto),
include: COMPONENT_WITH_RELATIONS_INCLUDE,
});
try {
const created = await this.prisma.composant.create({
data: this.buildCreateInput(createComposantDto),
include: COMPONENT_WITH_RELATIONS_INCLUDE,
});
return created as ComposantWithRelations;
return created as ComposantWithRelations;
} catch (error) {
this.handlePrismaError(error);
}
}
async findAll() {
@@ -97,16 +101,64 @@ export class ComposantsService {
data.structure = updateComposantDto.structure as Prisma.InputJsonValue;
}
return (await this.prisma.composant.update({
where: { id },
data,
include: COMPONENT_WITH_RELATIONS_INCLUDE,
})) as ComposantWithRelations;
try {
return (await this.prisma.composant.update({
where: { id },
data,
include: COMPONENT_WITH_RELATIONS_INCLUDE,
})) as ComposantWithRelations;
} catch (error) {
this.handlePrismaError(error);
}
}
async remove(id: string) {
const [machineLinksCount, documentsCount, customFieldValuesCount] =
await Promise.all([
this.prisma.machineComponentLink.count({
where: { composantId: id },
}),
this.prisma.document.count({
where: { composantId: id },
}),
this.prisma.customFieldValue.count({
where: { composantId: id },
}),
]);
if (
machineLinksCount > 0 ||
documentsCount > 0 ||
customFieldValuesCount > 0
) {
throw new ConflictException(
'Impossible de supprimer ce composant car il possède des éléments liés.',
);
}
return this.prisma.composant.delete({
where: { id },
});
}
private handlePrismaError(error: unknown): never {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === 'P2002' && this.isNameConstraint(error)) {
throw new ConflictException('Un composant avec ce nom existe déjà.');
}
}
throw error;
}
private isNameConstraint(error: Prisma.PrismaClientKnownRequestError) {
const { target } = error.meta ?? {};
if (Array.isArray(target)) {
return target.includes('name');
}
if (typeof target === 'string') {
return target === 'name';
}
return false;
}
}

View File

@@ -1,5 +1,5 @@
import { randomUUID } from 'crypto';
import { Injectable } from '@nestjs/common';
import { ConflictException, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import {
@@ -1680,14 +1680,20 @@ export class MachinesService {
pieceLinks,
);
const machine = await this.prisma.machine.create({
data: machineData,
include: {
site: true,
typeMachine: true,
constructeur: true,
},
});
let machine: Awaited<ReturnType<typeof this.prisma.machine.create>>;
try {
machine = await this.prisma.machine.create({
data: machineData,
include: {
site: true,
typeMachine: true,
constructeur: true,
},
});
} catch (error) {
this.handlePrismaError(error);
return;
}
try {
if (typeMachine.customFields && typeMachine.customFields.length > 0) {
@@ -1840,13 +1846,17 @@ export class MachinesService {
: { disconnect: true };
}
const machine = await this.prisma.machine.update({
where: { id },
data,
include: MACHINE_DEFAULT_INCLUDE,
});
try {
const machine = await this.prisma.machine.update({
where: { id },
data,
include: MACHINE_DEFAULT_INCLUDE,
});
return this.hydrateMachine(machine);
return this.hydrateMachine(machine);
} catch (error) {
this.handlePrismaError(error);
}
}
async remove(id: string) {
@@ -1928,4 +1938,25 @@ export class MachinesService {
return { success: true };
}
private handlePrismaError(error: unknown): never {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === 'P2002' && this.isNameConstraint(error)) {
throw new ConflictException('Une machine avec ce nom existe déjà.');
}
}
throw error;
}
private isNameConstraint(error: Prisma.PrismaClientKnownRequestError) {
const { target } = error.meta ?? {};
if (Array.isArray(target)) {
return target.includes('name');
}
if (typeof target === 'string') {
return target === 'name';
}
return false;
}
}

View File

@@ -212,8 +212,16 @@ export class ModelTypeService {
private handlePrismaError(error: unknown): never {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === 'P2002' && this.isUniqueCodeConstraint(error)) {
throw new ConflictException('Ce code est déjà utilisé.');
if (error.code === 'P2002') {
if (this.isUniqueCodeConstraint(error)) {
throw new ConflictException('Ce code est déjà utilisé.');
}
if (this.isUniqueNameConstraint(error)) {
throw new ConflictException(
'Une catégorie avec ce nom existe déjà.',
);
}
}
if (error.code === 'P2025') {
@@ -235,6 +243,17 @@ export class ModelTypeService {
return false;
}
private isUniqueNameConstraint(error: Prisma.PrismaClientKnownRequestError) {
const { target } = error.meta ?? {};
if (Array.isArray(target)) {
return target.includes('name') && target.includes('category');
}
if (typeof target === 'string') {
return target.includes('name') && target.includes('category');
}
return false;
}
private normalizeStructure(
category: ModelCategory,
structure: unknown,

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { ConflictException, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { CreatePieceDto, UpdatePieceDto } from '../shared/dto/piece.dto';
@@ -54,20 +54,24 @@ export class PiecesService {
}
async create(createPieceDto: CreatePieceDto) {
const created = await this.prisma.piece.create({
data: this.buildCreateInput(createPieceDto),
include: PIECE_WITH_RELATIONS_INCLUDE,
});
try {
const created = await this.prisma.piece.create({
data: this.buildCreateInput(createPieceDto),
include: PIECE_WITH_RELATIONS_INCLUDE,
});
await this.applyPieceSkeleton({
pieceId: created.id,
typePiece: created.typePiece as PieceTypeWithSkeleton | null,
});
await this.applyPieceSkeleton({
pieceId: created.id,
typePiece: created.typePiece as PieceTypeWithSkeleton | null,
});
return this.prisma.piece.findUnique({
where: { id: created.id },
include: PIECE_WITH_RELATIONS_INCLUDE,
});
return this.prisma.piece.findUnique({
where: { id: created.id },
include: PIECE_WITH_RELATIONS_INCLUDE,
});
} catch (error) {
this.handlePrismaError(error);
}
}
async findAll() {
@@ -111,24 +115,51 @@ export class PiecesService {
: { disconnect: true };
}
const updated = await this.prisma.piece.update({
where: { id },
data,
include: PIECE_WITH_RELATIONS_INCLUDE,
});
try {
const updated = await this.prisma.piece.update({
where: { id },
data,
include: PIECE_WITH_RELATIONS_INCLUDE,
});
await this.applyPieceSkeleton({
pieceId: updated.id,
typePiece: updated.typePiece as PieceTypeWithSkeleton | null,
});
await this.applyPieceSkeleton({
pieceId: updated.id,
typePiece: updated.typePiece as PieceTypeWithSkeleton | null,
});
return this.prisma.piece.findUnique({
where: { id: updated.id },
include: PIECE_WITH_RELATIONS_INCLUDE,
});
return this.prisma.piece.findUnique({
where: { id: updated.id },
include: PIECE_WITH_RELATIONS_INCLUDE,
});
} catch (error) {
this.handlePrismaError(error);
}
}
async remove(id: string) {
const [machineLinksCount, documentsCount, customFieldValuesCount] =
await Promise.all([
this.prisma.machinePieceLink.count({
where: { pieceId: id },
}),
this.prisma.document.count({
where: { pieceId: id },
}),
this.prisma.customFieldValue.count({
where: { pieceId: id },
}),
]);
if (
machineLinksCount > 0 ||
documentsCount > 0 ||
customFieldValuesCount > 0
) {
throw new ConflictException(
'Impossible de supprimer cette pièce car elle possède des éléments liés.',
);
}
return this.prisma.piece.delete({
where: { id },
});
@@ -343,6 +374,27 @@ export class PiecesService {
return JSON.stringify(value);
}
private handlePrismaError(error: unknown): never {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === 'P2002' && this.isNameConstraint(error)) {
throw new ConflictException('Une pièce avec ce nom existe déjà.');
}
}
throw error;
}
private isNameConstraint(error: Prisma.PrismaClientKnownRequestError) {
const { target } = error.meta ?? {};
if (Array.isArray(target)) {
return target.includes('name');
}
if (typeof target === 'string') {
return target === 'name';
}
return false;
}
}
type PieceTypeWithSkeleton = Prisma.ModelTypeGetPayload<{