From bd058cd53388df9798de2bdceb5ba75927169ad8 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 30 Sep 2025 15:34:06 +0200 Subject: [PATCH] feat(custom-fields): allow creating values without predefined field ID --- src/custom-fields/custom-fields.controller.ts | 7 +- .../custom-fields.service.spec.ts | 85 +++++++++++--- src/custom-fields/custom-fields.service.ts | 104 +++++++++++++----- src/shared/dto/custom-field.dto.ts | 30 ++++- 4 files changed, 176 insertions(+), 50 deletions(-) diff --git a/src/custom-fields/custom-fields.controller.ts b/src/custom-fields/custom-fields.controller.ts index 0d079a1..bdb10dc 100644 --- a/src/custom-fields/custom-fields.controller.ts +++ b/src/custom-fields/custom-fields.controller.ts @@ -59,11 +59,6 @@ export class CustomFieldsController { @Post('values/upsert') upsertCustomFieldValue(@Body() body: UpsertCustomFieldValueDto) { - return this.customFieldsService.upsertCustomFieldValue( - body.customFieldId, - body.entityType, - body.entityId, - body.value, - ); + return this.customFieldsService.upsertCustomFieldValue(body); } } diff --git a/src/custom-fields/custom-fields.service.spec.ts b/src/custom-fields/custom-fields.service.spec.ts index 77d3690..da568f1 100644 --- a/src/custom-fields/custom-fields.service.spec.ts +++ b/src/custom-fields/custom-fields.service.spec.ts @@ -9,7 +9,7 @@ describe('CustomFieldsService', () => { machine: { findUnique: jest.Mock }; composant: { findUnique: jest.Mock }; piece: { findUnique: jest.Mock }; - customField: { findFirst: jest.Mock }; + customField: { findFirst: jest.Mock; create: jest.Mock }; customFieldValue: { findFirst: jest.Mock; update: jest.Mock; @@ -22,7 +22,7 @@ describe('CustomFieldsService', () => { machine: { findUnique: jest.fn() }, composant: { findUnique: jest.fn() }, piece: { findUnique: jest.fn() }, - customField: { findFirst: jest.fn() }, + customField: { findFirst: jest.fn(), create: jest.fn() }, customFieldValue: { findFirst: jest.fn(), update: jest.fn(), @@ -39,12 +39,12 @@ describe('CustomFieldsService', () => { prisma.customField.findFirst.mockResolvedValue(null); await expect( - service.upsertCustomFieldValue( - 'custom-field-1', - CustomFieldEntityType.MACHINE, - 'machine-1', - 'value', - ), + service.upsertCustomFieldValue({ + customFieldId: 'custom-field-1', + entityType: CustomFieldEntityType.MACHINE, + entityId: 'machine-1', + value: 'value', + }), ).rejects.toBeInstanceOf(BadRequestException); expect(prisma.customField.findFirst).toHaveBeenCalledWith({ @@ -68,12 +68,12 @@ describe('CustomFieldsService', () => { customField: { id: 'custom-field-1' }, }); - const result = await service.upsertCustomFieldValue( - 'custom-field-1', - CustomFieldEntityType.MACHINE, - 'machine-1', - 'updated', - ); + const result = await service.upsertCustomFieldValue({ + customFieldId: 'custom-field-1', + entityType: CustomFieldEntityType.MACHINE, + entityId: 'machine-1', + value: 'updated', + }); expect(prisma.customField.findFirst).toHaveBeenCalledWith({ where: { @@ -99,5 +99,62 @@ describe('CustomFieldsService', () => { customField: { id: 'custom-field-1' }, }); }); + + it('should create the custom field when no identifier is provided but metadata exists', async () => { + prisma.machine.findUnique.mockResolvedValue({ typeMachineId: 'type-1' }); + prisma.customField.findFirst.mockResolvedValue(null); + prisma.customField.create.mockResolvedValue({ id: 'custom-field-2' }); + prisma.customFieldValue.findFirst.mockResolvedValue(null); + prisma.customFieldValue.create.mockResolvedValue({ + id: 'value-2', + value: 'created', + customField: { id: 'custom-field-2' }, + }); + + const result = await service.upsertCustomFieldValue({ + customFieldName: 'Température maximale', + customFieldType: 'number', + customFieldRequired: true, + customFieldOptions: [], + entityType: CustomFieldEntityType.MACHINE, + entityId: 'machine-1', + value: 'created', + }); + + expect(prisma.customField.create).toHaveBeenCalledWith({ + data: { + name: 'Température maximale', + type: 'number', + required: true, + options: [], + typeMachineId: 'type-1', + }, + }); + expect(prisma.customFieldValue.findFirst).toHaveBeenCalledWith({ + where: { + customFieldId: 'custom-field-2', + machineId: 'machine-1', + }, + }); + expect(prisma.customFieldValue.create).toHaveBeenCalledWith({ + data: { + customFieldId: 'custom-field-2', + value: 'created', + machineId: 'machine-1', + }, + include: { customField: true }, + }); + expect(result).toEqual({ + id: 'value-2', + value: 'created', + customField: { id: 'custom-field-2' }, + }); + }); }); }); + expect(prisma.customField.findFirst).toHaveBeenCalledWith({ + where: { + name: 'Température maximale', + typeMachineId: 'type-1', + }, + }); diff --git a/src/custom-fields/custom-fields.service.ts b/src/custom-fields/custom-fields.service.ts index be538b8..5b6ba5d 100644 --- a/src/custom-fields/custom-fields.service.ts +++ b/src/custom-fields/custom-fields.service.ts @@ -8,6 +8,7 @@ import { CreateCustomFieldValueDto, UpdateCustomFieldValueDto, CustomFieldEntityType, + UpsertCustomFieldValueDto, } from '../shared/dto/custom-field.dto'; @Injectable() @@ -170,37 +171,87 @@ export class CustomFieldsService { // Créer ou mettre à jour une valeur de champ personnalisé async upsertCustomFieldValue( - customFieldId: string, - entityType: CustomFieldEntityType, - entityId: string, - value: string, + dto: UpsertCustomFieldValueDto, ) { + const { + customFieldId: rawCustomFieldId, + customFieldName, + customFieldType, + customFieldOptions, + customFieldRequired, + entityType, + entityId, + value, + } = dto; + const { typeId, customFieldTypeField, valueKey } = await this.resolveEntityContext(entityType, entityId); - const allowedCustomField = await this.prisma.customField.findFirst({ - where: { - id: customFieldId, - [customFieldTypeField]: typeId, - }, - }); + let targetCustomFieldId = rawCustomFieldId?.trim() || null; - if (!allowedCustomField) { + if (!targetCustomFieldId) { + const normalizedName = customFieldName?.trim(); + if (!normalizedName) { + throw new BadRequestException( + 'customFieldId ou customFieldName est requis pour sauvegarder une valeur.', + ); + } + + const normalizedOptions = Array.isArray(customFieldOptions) + ? customFieldOptions.map((option) => String(option)) + : []; + + const existingField = await this.prisma.customField.findFirst({ + where: { + name: normalizedName, + [customFieldTypeField]: typeId, + }, + }); + + if (existingField) { + targetCustomFieldId = existingField.id; + } else { + const createdField = await this.prisma.customField.create({ + data: { + name: normalizedName, + type: (customFieldType || 'text').trim() || 'text', + required: !!customFieldRequired, + options: normalizedOptions, + [customFieldTypeField]: typeId, + }, + }); + + targetCustomFieldId = createdField.id; + } + } else { + const allowedCustomField = await this.prisma.customField.findFirst({ + where: { + id: targetCustomFieldId, + [customFieldTypeField]: typeId, + }, + }); + + if (!allowedCustomField) { + throw new BadRequestException( + "Le champ personnalisé n'est pas autorisé pour cette entité.", + ); + } + } + + if (!targetCustomFieldId) { throw new BadRequestException( - "Le champ personnalisé n'est pas autorisé pour cette entité.", + 'Impossible de déterminer le champ personnalisé ciblé.', ); } - // D'abord, essayer de trouver une valeur existante const existingValue = await this.prisma.customFieldValue.findFirst({ where: { - customFieldId, + customFieldId: targetCustomFieldId, [valueKey]: entityId, }, }); if (existingValue) { - // Mettre à jour la valeur existante return this.prisma.customFieldValue.update({ where: { id: existingValue.id }, data: { value }, @@ -208,18 +259,17 @@ export class CustomFieldsService { customField: true, }, }); - } else { - // Créer une nouvelle valeur - return this.prisma.customFieldValue.create({ - data: { - customFieldId, - value, - [valueKey]: entityId, - }, - include: { - customField: true, - }, - }); } + + return this.prisma.customFieldValue.create({ + data: { + customFieldId: targetCustomFieldId, + value, + [valueKey]: entityId, + }, + include: { + customField: true, + }, + }); } } diff --git a/src/shared/dto/custom-field.dto.ts b/src/shared/dto/custom-field.dto.ts index 1631853..8813340 100644 --- a/src/shared/dto/custom-field.dto.ts +++ b/src/shared/dto/custom-field.dto.ts @@ -1,4 +1,11 @@ -import { IsString, IsOptional, IsNotEmpty, IsEnum } from 'class-validator'; +import { + IsString, + IsOptional, + IsNotEmpty, + IsEnum, + IsBoolean, + IsArray, +} from 'class-validator'; export enum CustomFieldEntityType { MACHINE = 'machine', @@ -16,9 +23,26 @@ export class CustomFieldEntityParamsDto { } export class UpsertCustomFieldValueDto { + @IsOptional() @IsString() - @IsNotEmpty() - customFieldId: string; + customFieldId?: string; + + @IsOptional() + @IsString() + customFieldName?: string; + + @IsOptional() + @IsString() + customFieldType?: string; + + @IsOptional() + @IsBoolean() + customFieldRequired?: boolean; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + customFieldOptions?: string[]; @IsEnum(CustomFieldEntityType) entityType: CustomFieldEntityType;