feat(backend): enforce unique names and surface duplicate errors
This commit is contained in:
@@ -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");
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<{
|
||||
|
||||
Reference in New Issue
Block a user