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 { model Machine {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String @unique
reference String? reference String?
prix Decimal? @db.Decimal(10, 2) prix Decimal? @db.Decimal(10, 2)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -79,7 +79,7 @@ model Machine {
model Composant { model Composant {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String @unique
reference String? reference String?
prix Decimal? @db.Decimal(10, 2) prix Decimal? @db.Decimal(10, 2)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -101,7 +101,7 @@ model Composant {
model Piece { model Piece {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String @unique
reference String? reference String?
prix Decimal? @db.Decimal(10, 2) prix Decimal? @db.Decimal(10, 2)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -186,7 +186,7 @@ model ModelType {
pieces Piece[] @relation("ModelTypePieceAssignments") pieces Piece[] @relation("ModelTypePieceAssignments")
pieceCustomFields CustomField[] @relation("ModelTypePieceCustomFields") pieceCustomFields CustomField[] @relation("ModelTypePieceCustomFields")
@@index([category, name]) @@unique([category, name])
} }
model Constructeur { 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 { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { import {
@@ -44,12 +44,16 @@ export class ComposantsService {
} }
async create(createComposantDto: CreateComposantDto) { async create(createComposantDto: CreateComposantDto) {
try {
const created = await this.prisma.composant.create({ const created = await this.prisma.composant.create({
data: this.buildCreateInput(createComposantDto), data: this.buildCreateInput(createComposantDto),
include: COMPONENT_WITH_RELATIONS_INCLUDE, include: COMPONENT_WITH_RELATIONS_INCLUDE,
}); });
return created as ComposantWithRelations; return created as ComposantWithRelations;
} catch (error) {
this.handlePrismaError(error);
}
} }
async findAll() { async findAll() {
@@ -97,16 +101,64 @@ export class ComposantsService {
data.structure = updateComposantDto.structure as Prisma.InputJsonValue; data.structure = updateComposantDto.structure as Prisma.InputJsonValue;
} }
try {
return (await this.prisma.composant.update({ return (await this.prisma.composant.update({
where: { id }, where: { id },
data, data,
include: COMPONENT_WITH_RELATIONS_INCLUDE, include: COMPONENT_WITH_RELATIONS_INCLUDE,
})) as ComposantWithRelations; })) as ComposantWithRelations;
} catch (error) {
this.handlePrismaError(error);
}
} }
async remove(id: string) { 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({ return this.prisma.composant.delete({
where: { id }, 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 { randomUUID } from 'crypto';
import { Injectable } from '@nestjs/common'; import { ConflictException, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { import {
@@ -1680,7 +1680,9 @@ export class MachinesService {
pieceLinks, pieceLinks,
); );
const machine = await this.prisma.machine.create({ let machine: Awaited<ReturnType<typeof this.prisma.machine.create>>;
try {
machine = await this.prisma.machine.create({
data: machineData, data: machineData,
include: { include: {
site: true, site: true,
@@ -1688,6 +1690,10 @@ export class MachinesService {
constructeur: true, constructeur: true,
}, },
}); });
} catch (error) {
this.handlePrismaError(error);
return;
}
try { try {
if (typeMachine.customFields && typeMachine.customFields.length > 0) { if (typeMachine.customFields && typeMachine.customFields.length > 0) {
@@ -1840,6 +1846,7 @@ export class MachinesService {
: { disconnect: true }; : { disconnect: true };
} }
try {
const machine = await this.prisma.machine.update({ const machine = await this.prisma.machine.update({
where: { id }, where: { id },
data, data,
@@ -1847,6 +1854,9 @@ export class MachinesService {
}); });
return this.hydrateMachine(machine); return this.hydrateMachine(machine);
} catch (error) {
this.handlePrismaError(error);
}
} }
async remove(id: string) { async remove(id: string) {
@@ -1928,4 +1938,25 @@ export class MachinesService {
return { success: true }; 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,10 +212,18 @@ export class ModelTypeService {
private handlePrismaError(error: unknown): never { private handlePrismaError(error: unknown): never {
if (error instanceof Prisma.PrismaClientKnownRequestError) { if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === 'P2002' && this.isUniqueCodeConstraint(error)) { if (error.code === 'P2002') {
if (this.isUniqueCodeConstraint(error)) {
throw new ConflictException('Ce code est déjà utilisé.'); 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') { if (error.code === 'P2025') {
throw new NotFoundException('Type de modèle introuvable.'); throw new NotFoundException('Type de modèle introuvable.');
} }
@@ -235,6 +243,17 @@ export class ModelTypeService {
return false; 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( private normalizeStructure(
category: ModelCategory, category: ModelCategory,
structure: unknown, 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 { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { CreatePieceDto, UpdatePieceDto } from '../shared/dto/piece.dto'; import { CreatePieceDto, UpdatePieceDto } from '../shared/dto/piece.dto';
@@ -54,6 +54,7 @@ export class PiecesService {
} }
async create(createPieceDto: CreatePieceDto) { async create(createPieceDto: CreatePieceDto) {
try {
const created = await this.prisma.piece.create({ const created = await this.prisma.piece.create({
data: this.buildCreateInput(createPieceDto), data: this.buildCreateInput(createPieceDto),
include: PIECE_WITH_RELATIONS_INCLUDE, include: PIECE_WITH_RELATIONS_INCLUDE,
@@ -68,6 +69,9 @@ export class PiecesService {
where: { id: created.id }, where: { id: created.id },
include: PIECE_WITH_RELATIONS_INCLUDE, include: PIECE_WITH_RELATIONS_INCLUDE,
}); });
} catch (error) {
this.handlePrismaError(error);
}
} }
async findAll() { async findAll() {
@@ -111,6 +115,7 @@ export class PiecesService {
: { disconnect: true }; : { disconnect: true };
} }
try {
const updated = await this.prisma.piece.update({ const updated = await this.prisma.piece.update({
where: { id }, where: { id },
data, data,
@@ -126,9 +131,35 @@ export class PiecesService {
where: { id: updated.id }, where: { id: updated.id },
include: PIECE_WITH_RELATIONS_INCLUDE, include: PIECE_WITH_RELATIONS_INCLUDE,
}); });
} catch (error) {
this.handlePrismaError(error);
}
} }
async remove(id: string) { 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({ return this.prisma.piece.delete({
where: { id }, where: { id },
}); });
@@ -343,6 +374,27 @@ export class PiecesService {
return JSON.stringify(value); 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<{ type PieceTypeWithSkeleton = Prisma.ModelTypeGetPayload<{