diff --git a/prisma/migrations/20250930130000_enforce_unique_names/migration.sql b/prisma/migrations/20250930130000_enforce_unique_names/migration.sql new file mode 100644 index 0000000..84f33fa --- /dev/null +++ b/prisma/migrations/20250930130000_enforce_unique_names/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5f35420..1df3ec1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 { diff --git a/src/composants/composants.service.ts b/src/composants/composants.service.ts index 5fb98f3..68b1ded 100644 --- a/src/composants/composants.service.ts +++ b/src/composants/composants.service.ts @@ -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; + } } diff --git a/src/machines/machines.service.ts b/src/machines/machines.service.ts index 7cd674c..5033214 100644 --- a/src/machines/machines.service.ts +++ b/src/machines/machines.service.ts @@ -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>; + 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; + } } diff --git a/src/model-type/model-type.service.ts b/src/model-type/model-type.service.ts index 61bd0a1..7c57648 100644 --- a/src/model-type/model-type.service.ts +++ b/src/model-type/model-type.service.ts @@ -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, diff --git a/src/pieces/pieces.service.ts b/src/pieces/pieces.service.ts index 3a24d3a..e2f8159 100644 --- a/src/pieces/pieces.service.ts +++ b/src/pieces/pieces.service.ts @@ -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<{