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 {
|
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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
const created = await this.prisma.composant.create({
|
try {
|
||||||
data: this.buildCreateInput(createComposantDto),
|
const created = await this.prisma.composant.create({
|
||||||
include: COMPONENT_WITH_RELATIONS_INCLUDE,
|
data: this.buildCreateInput(createComposantDto),
|
||||||
});
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (await this.prisma.composant.update({
|
try {
|
||||||
where: { id },
|
return (await this.prisma.composant.update({
|
||||||
data,
|
where: { id },
|
||||||
include: COMPONENT_WITH_RELATIONS_INCLUDE,
|
data,
|
||||||
})) as ComposantWithRelations;
|
include: COMPONENT_WITH_RELATIONS_INCLUDE,
|
||||||
|
})) 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,14 +1680,20 @@ export class MachinesService {
|
|||||||
pieceLinks,
|
pieceLinks,
|
||||||
);
|
);
|
||||||
|
|
||||||
const machine = await this.prisma.machine.create({
|
let machine: Awaited<ReturnType<typeof this.prisma.machine.create>>;
|
||||||
data: machineData,
|
try {
|
||||||
include: {
|
machine = await this.prisma.machine.create({
|
||||||
site: true,
|
data: machineData,
|
||||||
typeMachine: true,
|
include: {
|
||||||
constructeur: true,
|
site: true,
|
||||||
},
|
typeMachine: 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,13 +1846,17 @@ export class MachinesService {
|
|||||||
: { disconnect: true };
|
: { disconnect: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
const machine = await this.prisma.machine.update({
|
try {
|
||||||
where: { id },
|
const machine = await this.prisma.machine.update({
|
||||||
data,
|
where: { id },
|
||||||
include: MACHINE_DEFAULT_INCLUDE,
|
data,
|
||||||
});
|
include: MACHINE_DEFAULT_INCLUDE,
|
||||||
|
});
|
||||||
|
|
||||||
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -212,8 +212,16 @@ 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') {
|
||||||
throw new ConflictException('Ce code est déjà utilisé.');
|
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') {
|
if (error.code === 'P2025') {
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,20 +54,24 @@ export class PiecesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(createPieceDto: CreatePieceDto) {
|
async create(createPieceDto: CreatePieceDto) {
|
||||||
const created = await this.prisma.piece.create({
|
try {
|
||||||
data: this.buildCreateInput(createPieceDto),
|
const created = await this.prisma.piece.create({
|
||||||
include: PIECE_WITH_RELATIONS_INCLUDE,
|
data: this.buildCreateInput(createPieceDto),
|
||||||
});
|
include: PIECE_WITH_RELATIONS_INCLUDE,
|
||||||
|
});
|
||||||
|
|
||||||
await this.applyPieceSkeleton({
|
await this.applyPieceSkeleton({
|
||||||
pieceId: created.id,
|
pieceId: created.id,
|
||||||
typePiece: created.typePiece as PieceTypeWithSkeleton | null,
|
typePiece: created.typePiece as PieceTypeWithSkeleton | null,
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.prisma.piece.findUnique({
|
return this.prisma.piece.findUnique({
|
||||||
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,24 +115,51 @@ export class PiecesService {
|
|||||||
: { disconnect: true };
|
: { disconnect: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await this.prisma.piece.update({
|
try {
|
||||||
where: { id },
|
const updated = await this.prisma.piece.update({
|
||||||
data,
|
where: { id },
|
||||||
include: PIECE_WITH_RELATIONS_INCLUDE,
|
data,
|
||||||
});
|
include: PIECE_WITH_RELATIONS_INCLUDE,
|
||||||
|
});
|
||||||
|
|
||||||
await this.applyPieceSkeleton({
|
await this.applyPieceSkeleton({
|
||||||
pieceId: updated.id,
|
pieceId: updated.id,
|
||||||
typePiece: updated.typePiece as PieceTypeWithSkeleton | null,
|
typePiece: updated.typePiece as PieceTypeWithSkeleton | null,
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.prisma.piece.findUnique({
|
return this.prisma.piece.findUnique({
|
||||||
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<{
|
||||||
|
|||||||
Reference in New Issue
Block a user