diff --git a/api/src/cms/decorators/unique-field-names.decorator.ts b/api/src/cms/decorators/unique-field-names.decorator.ts new file mode 100644 index 00000000..1cbe555d --- /dev/null +++ b/api/src/cms/decorators/unique-field-names.decorator.ts @@ -0,0 +1,23 @@ +/* + * Copyright © 2025 Hexastack. All rights reserved. + * + * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: + * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + +import { registerDecorator, ValidationOptions } from 'class-validator'; + +import { UniqueFieldNamesConstraint } from '../validators/validate-unique-names.validator'; + +export function UniqueFieldNames(validationOptions?: ValidationOptions) { + return function (object: Record, propertyName: string) { + registerDecorator({ + target: object.constructor, + propertyName, + options: validationOptions, + constraints: [], + validator: UniqueFieldNamesConstraint, + }); + }; +} diff --git a/api/src/cms/dto/contentType.dto.ts b/api/src/cms/dto/contentType.dto.ts index 70fddc4c..09abdc70 100644 --- a/api/src/cms/dto/contentType.dto.ts +++ b/api/src/cms/dto/contentType.dto.ts @@ -21,6 +21,7 @@ import { import { FieldType } from '@/setting/schemas/types'; import { DtoConfig } from '@/utils/types/dto.types'; +import { UniqueFieldNames } from '../decorators/unique-field-names.decorator'; import { ValidateRequiredFields } from '../validators/validate-required-fields.validator'; export class ContentField { @@ -56,6 +57,7 @@ export class ContentTypeCreateDto { @ValidateNested({ each: true }) @Validate(ValidateRequiredFields) @Type(() => ContentField) + @UniqueFieldNames() fields?: ContentField[]; } diff --git a/api/src/cms/schemas/content-type.schema.ts b/api/src/cms/schemas/content-type.schema.ts index 6b6b3e43..fb1c1632 100644 --- a/api/src/cms/schemas/content-type.schema.ts +++ b/api/src/cms/schemas/content-type.schema.ts @@ -7,7 +7,6 @@ */ import { ModelDefinition, Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; -import mongoose from 'mongoose'; import { FieldType } from '@/setting/schemas/types'; import { BaseSchema } from '@/utils/generics/base-schema'; @@ -28,7 +27,7 @@ export class ContentType extends BaseSchema { */ @Prop({ - type: mongoose.Schema.Types.Mixed, + type: [ContentField], default: [ { name: 'title', @@ -41,6 +40,25 @@ export class ContentType extends BaseSchema { type: FieldType.checkbox, }, ], + required: true, + validate: { + /** + * Ensures every `name` in the fields array is unique. + * Runs on `save`, `create`, `insertMany`, and `findOneAndUpdate` + * when `runValidators: true` is set. + */ + validator(fields: ContentField[]): boolean { + if (!Array.isArray(fields)) return false; + const seen = new Set(); + return fields.every((f) => { + if (seen.has(f.name)) return false; + seen.add(f.name); + return true; + }); + }, + message: + 'Each element in "fields" must have a unique "name" (duplicate detected)', + }, }) fields: ContentField[]; } diff --git a/api/src/cms/validators/validate-unique-names.validator.ts b/api/src/cms/validators/validate-unique-names.validator.ts new file mode 100644 index 00000000..3cc1c5d3 --- /dev/null +++ b/api/src/cms/validators/validate-unique-names.validator.ts @@ -0,0 +1,34 @@ +/* + * Copyright © 2025 Hexastack. All rights reserved. + * + * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: + * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + +import { + ValidationArguments, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; + +import { ContentField } from '../dto/contentType.dto'; + +@ValidatorConstraint({ async: false }) +export class UniqueFieldNamesConstraint + implements ValidatorConstraintInterface +{ + validate(fields: ContentField[], _args: ValidationArguments) { + if (!Array.isArray(fields)) return false; + const seen = new Set(); + return fields.every((f) => { + if (seen.has(f.name)) return false; + seen.add(f.name); + return true; + }); + } + + defaultMessage(args: ValidationArguments) { + return `${args.property} contains duplicate "name" values; each field.name must be unique`; + } +}