Merge pull request #3 from MatthieuTD/codex/refactor-customfieldscontroller-and-service
Secure custom field value routing
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
|
||||
import { CustomFieldsService } from './custom-fields.service';
|
||||
import { CreateCustomFieldValueDto, UpdateCustomFieldValueDto } from '../shared/dto/custom-field.dto';
|
||||
import {
|
||||
CreateCustomFieldValueDto,
|
||||
UpdateCustomFieldValueDto,
|
||||
CustomFieldEntityParamsDto,
|
||||
UpsertCustomFieldValueDto,
|
||||
} from '../shared/dto/custom-field.dto';
|
||||
|
||||
@Controller('custom-fields')
|
||||
export class CustomFieldsController {
|
||||
@@ -12,11 +17,11 @@ export class CustomFieldsController {
|
||||
}
|
||||
|
||||
@Get('values/:entityType/:entityId')
|
||||
findCustomFieldValuesByEntity(
|
||||
@Param('entityType') entityType: string,
|
||||
@Param('entityId') entityId: string,
|
||||
) {
|
||||
return this.customFieldsService.findCustomFieldValuesByEntity(entityType, entityId);
|
||||
findCustomFieldValuesByEntity(@Param() params: CustomFieldEntityParamsDto) {
|
||||
return this.customFieldsService.findCustomFieldValuesByEntity(
|
||||
params.entityType,
|
||||
params.entityId,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('values/:id')
|
||||
@@ -38,12 +43,7 @@ export class CustomFieldsController {
|
||||
}
|
||||
|
||||
@Post('values/upsert')
|
||||
upsertCustomFieldValue(@Body() body: {
|
||||
customFieldId: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
value: string;
|
||||
}) {
|
||||
upsertCustomFieldValue(@Body() body: UpsertCustomFieldValueDto) {
|
||||
return this.customFieldsService.upsertCustomFieldValue(
|
||||
body.customFieldId,
|
||||
body.entityType,
|
||||
@@ -51,4 +51,4 @@ export class CustomFieldsController {
|
||||
body.value,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
103
src/custom-fields/custom-fields.service.spec.ts
Normal file
103
src/custom-fields/custom-fields.service.spec.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { CustomFieldsService } from './custom-fields.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CustomFieldEntityType } from '../shared/dto/custom-field.dto';
|
||||
|
||||
describe('CustomFieldsService', () => {
|
||||
let service: CustomFieldsService;
|
||||
let prisma: {
|
||||
machine: { findUnique: jest.Mock };
|
||||
composant: { findUnique: jest.Mock };
|
||||
piece: { findUnique: jest.Mock };
|
||||
customField: { findFirst: jest.Mock };
|
||||
customFieldValue: {
|
||||
findFirst: jest.Mock;
|
||||
update: jest.Mock;
|
||||
create: jest.Mock;
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = {
|
||||
machine: { findUnique: jest.fn() },
|
||||
composant: { findUnique: jest.fn() },
|
||||
piece: { findUnique: jest.fn() },
|
||||
customField: { findFirst: jest.fn() },
|
||||
customFieldValue: {
|
||||
findFirst: jest.fn(),
|
||||
update: jest.fn(),
|
||||
create: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
service = new CustomFieldsService(prisma as unknown as PrismaService);
|
||||
});
|
||||
|
||||
describe('upsertCustomFieldValue', () => {
|
||||
it('should reject when the custom field is not allowed for the machine', async () => {
|
||||
prisma.machine.findUnique.mockResolvedValue({ typeMachineId: 'type-1' });
|
||||
prisma.customField.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.upsertCustomFieldValue(
|
||||
'custom-field-1',
|
||||
CustomFieldEntityType.MACHINE,
|
||||
'machine-1',
|
||||
'value',
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(prisma.customField.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: 'custom-field-1',
|
||||
typeMachineId: 'type-1',
|
||||
},
|
||||
});
|
||||
expect(prisma.customFieldValue.findFirst).not.toHaveBeenCalled();
|
||||
expect(prisma.customFieldValue.update).not.toHaveBeenCalled();
|
||||
expect(prisma.customFieldValue.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update an existing value when the custom field is allowed', async () => {
|
||||
prisma.machine.findUnique.mockResolvedValue({ typeMachineId: 'type-1' });
|
||||
prisma.customField.findFirst.mockResolvedValue({ id: 'custom-field-1' });
|
||||
prisma.customFieldValue.findFirst.mockResolvedValue({ id: 'value-1' });
|
||||
prisma.customFieldValue.update.mockResolvedValue({
|
||||
id: 'value-1',
|
||||
value: 'updated',
|
||||
customField: { id: 'custom-field-1' },
|
||||
});
|
||||
|
||||
const result = await service.upsertCustomFieldValue(
|
||||
'custom-field-1',
|
||||
CustomFieldEntityType.MACHINE,
|
||||
'machine-1',
|
||||
'updated',
|
||||
);
|
||||
|
||||
expect(prisma.customField.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: 'custom-field-1',
|
||||
typeMachineId: 'type-1',
|
||||
},
|
||||
});
|
||||
expect(prisma.customFieldValue.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
customFieldId: 'custom-field-1',
|
||||
machineId: 'machine-1',
|
||||
},
|
||||
});
|
||||
expect(prisma.customFieldValue.update).toHaveBeenCalledWith({
|
||||
where: { id: 'value-1' },
|
||||
data: { value: 'updated' },
|
||||
include: { customField: true },
|
||||
});
|
||||
expect(prisma.customFieldValue.create).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
id: 'value-1',
|
||||
value: 'updated',
|
||||
customField: { id: 'custom-field-1' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,10 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateCustomFieldValueDto, UpdateCustomFieldValueDto } from '../shared/dto/custom-field.dto';
|
||||
import {
|
||||
CreateCustomFieldValueDto,
|
||||
UpdateCustomFieldValueDto,
|
||||
CustomFieldEntityType,
|
||||
} from '../shared/dto/custom-field.dto';
|
||||
|
||||
@Injectable()
|
||||
export class CustomFieldsService {
|
||||
@@ -17,9 +21,99 @@ export class CustomFieldsService {
|
||||
}
|
||||
|
||||
// Trouver toutes les valeurs de champs personnalisés pour une entité
|
||||
async findCustomFieldValuesByEntity(entityType: string, entityId: string) {
|
||||
private getCustomFieldValueKey(entityType: CustomFieldEntityType) {
|
||||
switch (entityType) {
|
||||
case CustomFieldEntityType.MACHINE:
|
||||
return 'machineId' as const;
|
||||
case CustomFieldEntityType.COMPOSANT:
|
||||
return 'composantId' as const;
|
||||
case CustomFieldEntityType.PIECE:
|
||||
return 'pieceId' as const;
|
||||
default:
|
||||
throw new BadRequestException('Type d\'entité de champ personnalisé invalide.');
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveEntityContext(entityType: CustomFieldEntityType, entityId: string) {
|
||||
switch (entityType) {
|
||||
case CustomFieldEntityType.MACHINE: {
|
||||
const machine = await this.prisma.machine.findUnique({
|
||||
where: { id: entityId },
|
||||
select: { typeMachineId: true },
|
||||
});
|
||||
|
||||
if (!machine) {
|
||||
throw new NotFoundException('Machine introuvable.');
|
||||
}
|
||||
|
||||
if (!machine.typeMachineId) {
|
||||
throw new BadRequestException(
|
||||
'La machine ne possède pas de type associé pour les champs personnalisés.',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
typeId: machine.typeMachineId,
|
||||
customFieldTypeField: 'typeMachineId' as const,
|
||||
valueKey: 'machineId' as const,
|
||||
};
|
||||
}
|
||||
case CustomFieldEntityType.COMPOSANT: {
|
||||
const composant = await this.prisma.composant.findUnique({
|
||||
where: { id: entityId },
|
||||
select: { typeComposantId: true },
|
||||
});
|
||||
|
||||
if (!composant) {
|
||||
throw new NotFoundException('Composant introuvable.');
|
||||
}
|
||||
|
||||
if (!composant.typeComposantId) {
|
||||
throw new BadRequestException(
|
||||
'Le composant ne possède pas de type associé pour les champs personnalisés.',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
typeId: composant.typeComposantId,
|
||||
customFieldTypeField: 'typeComposantId' as const,
|
||||
valueKey: 'composantId' as const,
|
||||
};
|
||||
}
|
||||
case CustomFieldEntityType.PIECE: {
|
||||
const piece = await this.prisma.piece.findUnique({
|
||||
where: { id: entityId },
|
||||
select: { typePieceId: true },
|
||||
});
|
||||
|
||||
if (!piece) {
|
||||
throw new NotFoundException('Pièce introuvable.');
|
||||
}
|
||||
|
||||
if (!piece.typePieceId) {
|
||||
throw new BadRequestException(
|
||||
'La pièce ne possède pas de type associé pour les champs personnalisés.',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
typeId: piece.typePieceId,
|
||||
customFieldTypeField: 'typePieceId' as const,
|
||||
valueKey: 'pieceId' as const,
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw new BadRequestException('Type d\'entité de champ personnalisé invalide.');
|
||||
}
|
||||
}
|
||||
|
||||
async findCustomFieldValuesByEntity(
|
||||
entityType: CustomFieldEntityType,
|
||||
entityId: string,
|
||||
) {
|
||||
const key = this.getCustomFieldValueKey(entityType);
|
||||
const whereClause = {
|
||||
[entityType + 'Id']: entityId,
|
||||
[key]: entityId,
|
||||
};
|
||||
|
||||
return this.prisma.customFieldValue.findMany({
|
||||
@@ -59,12 +153,35 @@ export class CustomFieldsService {
|
||||
}
|
||||
|
||||
// Créer ou mettre à jour une valeur de champ personnalisé
|
||||
async upsertCustomFieldValue(customFieldId: string, entityType: string, entityId: string, value: string) {
|
||||
async upsertCustomFieldValue(
|
||||
customFieldId: string,
|
||||
entityType: CustomFieldEntityType,
|
||||
entityId: string,
|
||||
value: string,
|
||||
) {
|
||||
const { typeId, customFieldTypeField, valueKey } = await this.resolveEntityContext(
|
||||
entityType,
|
||||
entityId,
|
||||
);
|
||||
|
||||
const allowedCustomField = await this.prisma.customField.findFirst({
|
||||
where: {
|
||||
id: customFieldId,
|
||||
[customFieldTypeField]: typeId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!allowedCustomField) {
|
||||
throw new BadRequestException(
|
||||
'Le champ personnalisé n\'est pas autorisé pour cette entité.',
|
||||
);
|
||||
}
|
||||
|
||||
// D'abord, essayer de trouver une valeur existante
|
||||
const existingValue = await this.prisma.customFieldValue.findFirst({
|
||||
where: {
|
||||
customFieldId,
|
||||
[entityType + 'Id']: entityId,
|
||||
[valueKey]: entityId,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -83,7 +200,7 @@ export class CustomFieldsService {
|
||||
data: {
|
||||
customFieldId,
|
||||
value,
|
||||
[entityType + 'Id']: entityId,
|
||||
[valueKey]: entityId,
|
||||
},
|
||||
include: {
|
||||
customField: true,
|
||||
@@ -91,4 +208,4 @@ export class CustomFieldsService {
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,36 @@
|
||||
import { IsString, IsOptional, IsNotEmpty } from 'class-validator';
|
||||
import { IsString, IsOptional, IsNotEmpty, IsEnum } from 'class-validator';
|
||||
|
||||
export enum CustomFieldEntityType {
|
||||
MACHINE = 'machine',
|
||||
COMPOSANT = 'composant',
|
||||
PIECE = 'piece',
|
||||
}
|
||||
|
||||
export class CustomFieldEntityParamsDto {
|
||||
@IsEnum(CustomFieldEntityType)
|
||||
entityType: CustomFieldEntityType;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
entityId: string;
|
||||
}
|
||||
|
||||
export class UpsertCustomFieldValueDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
customFieldId: string;
|
||||
|
||||
@IsEnum(CustomFieldEntityType)
|
||||
entityType: CustomFieldEntityType;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
entityId: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
value: string;
|
||||
}
|
||||
|
||||
export class CreateCustomFieldValueDto {
|
||||
@IsString()
|
||||
|
||||
Reference in New Issue
Block a user