Bluewoo HRMS
AI Development GuideEntity References

Reference Implementation - Custom Fields

Complete Custom Fields module for tenant-specific entity metadata

Reference Implementation: Custom Fields Module

This reference provides a complete Custom Fields module implementation that allows tenants to define additional metadata fields for entities like employees, departments, teams, and goals.

Module Structure

apps/api/src/modules/custom-fields/
├── custom-fields.module.ts
├── custom-fields.controller.ts
├── custom-fields.repository.ts
├── custom-fields.service.ts
├── dto/
│   ├── create-field-definition.dto.ts
│   ├── update-field-definition.dto.ts
│   ├── field-value.dto.ts
│   └── field-response.dto.ts
├── validators/
│   └── field-value.validator.ts
└── custom-fields.controller.spec.ts

Field Types

// apps/api/src/modules/custom-fields/types.ts
export enum FieldType {
  TEXT = 'TEXT',
  TEXTAREA = 'TEXTAREA',
  NUMBER = 'NUMBER',
  DATE = 'DATE',
  DROPDOWN = 'DROPDOWN',
  CHECKBOX = 'CHECKBOX',
  URL = 'URL',
  EMAIL = 'EMAIL',
}

export enum EntityType {
  EMPLOYEE = 'EMPLOYEE',
  DEPARTMENT = 'DEPARTMENT',
  TEAM = 'TEAM',
  GOAL = 'GOAL',
}

export interface FieldOptions {
  // Dropdown options
  choices?: { value: string; label: string }[];

  // Number options
  min?: number;
  max?: number;
  step?: number;

  // Text options
  minLength?: number;
  maxLength?: number;
  pattern?: string; // Regex pattern

  // Date options
  minDate?: string;
  maxDate?: string;

  // Display options
  placeholder?: string;
  helpText?: string;
}

DTOs

Create Field Definition DTO

// apps/api/src/modules/custom-fields/dto/create-field-definition.dto.ts
import { IsString, IsNotEmpty, IsEnum, IsOptional, IsBoolean, IsObject, IsNumber, Min, Max } from 'class-validator';
import { FieldType, EntityType, FieldOptions } from '../types';

export class CreateFieldDefinitionDto {
  @IsString()
  @IsNotEmpty()
  name: string;

  @IsString()
  @IsOptional()
  description?: string;

  @IsEnum(EntityType)
  entityType: EntityType;

  @IsEnum(FieldType)
  fieldType: FieldType;

  @IsBoolean()
  @IsOptional()
  isRequired?: boolean = false;

  @IsBoolean()
  @IsOptional()
  isSearchable?: boolean = false;

  @IsBoolean()
  @IsOptional()
  isFilterable?: boolean = false;

  @IsBoolean()
  @IsOptional()
  showInList?: boolean = false;

  @IsNumber()
  @Min(0)
  @Max(100)
  @IsOptional()
  displayOrder?: number = 0;

  @IsString()
  @IsOptional()
  groupName?: string;

  @IsObject()
  @IsOptional()
  options?: FieldOptions;

  @IsString()
  @IsOptional()
  defaultValue?: string;
}

Update Field Definition DTO

// apps/api/src/modules/custom-fields/dto/update-field-definition.dto.ts
import { IsString, IsOptional, IsBoolean, IsObject, IsNumber, Min, Max } from 'class-validator';
import { FieldOptions } from '../types';

export class UpdateFieldDefinitionDto {
  @IsString()
  @IsOptional()
  name?: string;

  @IsString()
  @IsOptional()
  description?: string;

  @IsBoolean()
  @IsOptional()
  isRequired?: boolean;

  @IsBoolean()
  @IsOptional()
  isSearchable?: boolean;

  @IsBoolean()
  @IsOptional()
  isFilterable?: boolean;

  @IsBoolean()
  @IsOptional()
  showInList?: boolean;

  @IsNumber()
  @Min(0)
  @Max(100)
  @IsOptional()
  displayOrder?: number;

  @IsString()
  @IsOptional()
  groupName?: string;

  @IsObject()
  @IsOptional()
  options?: FieldOptions;

  @IsString()
  @IsOptional()
  defaultValue?: string;

  @IsBoolean()
  @IsOptional()
  isActive?: boolean;
}

Field Value DTO

// apps/api/src/modules/custom-fields/dto/field-value.dto.ts
import { IsString, IsNotEmpty, IsUUID, IsOptional, IsArray, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';

export class SetFieldValueDto {
  @IsUUID()
  fieldDefinitionId: string;

  @IsString()
  value: string;
}

export class SetFieldValuesDto {
  @IsUUID()
  entityId: string;

  @IsArray()
  @ValidateNested({ each: true })
  @Type(() => SetFieldValueDto)
  values: SetFieldValueDto[];
}

export class BulkSetFieldValuesDto {
  @IsArray()
  @ValidateNested({ each: true })
  @Type(() => SetFieldValuesDto)
  entities: SetFieldValuesDto[];
}

Field Response DTO

// apps/api/src/modules/custom-fields/dto/field-response.dto.ts
import { FieldType, EntityType, FieldOptions } from '../types';

export class FieldDefinitionResponseDto {
  id: string;
  name: string;
  description: string | null;
  entityType: EntityType;
  fieldType: FieldType;
  isRequired: boolean;
  isSearchable: boolean;
  isFilterable: boolean;
  showInList: boolean;
  displayOrder: number;
  groupName: string | null;
  options: FieldOptions | null;
  defaultValue: string | null;
  isActive: boolean;
  createdAt: Date;
  updatedAt: Date;
}

export class FieldValueResponseDto {
  id: string;
  fieldDefinitionId: string;
  fieldName: string;
  fieldType: FieldType;
  value: string;
  entityId: string;
  updatedAt: Date;
}

export class EntityFieldsResponseDto {
  entityId: string;
  entityType: EntityType;
  fields: {
    definition: FieldDefinitionResponseDto;
    value: string | null;
  }[];
}

Field Value Validator

// apps/api/src/modules/custom-fields/validators/field-value.validator.ts
import { Injectable, BadRequestException } from '@nestjs/common';
import { FieldType, FieldOptions } from '../types';

@Injectable()
export class FieldValueValidator {
  validate(value: string, fieldType: FieldType, options: FieldOptions | null, fieldName: string): void {
    if (value === null || value === undefined || value === '') {
      return; // Empty values are handled by isRequired check
    }

    switch (fieldType) {
      case FieldType.TEXT:
      case FieldType.TEXTAREA:
        this.validateText(value, options, fieldName);
        break;

      case FieldType.NUMBER:
        this.validateNumber(value, options, fieldName);
        break;

      case FieldType.DATE:
        this.validateDate(value, options, fieldName);
        break;

      case FieldType.DROPDOWN:
        this.validateDropdown(value, options, fieldName);
        break;

      case FieldType.CHECKBOX:
        this.validateCheckbox(value, fieldName);
        break;

      case FieldType.URL:
        this.validateUrl(value, fieldName);
        break;

      case FieldType.EMAIL:
        this.validateEmail(value, fieldName);
        break;
    }
  }

  private validateText(value: string, options: FieldOptions | null, fieldName: string): void {
    if (options?.minLength && value.length < options.minLength) {
      throw new BadRequestException(`${fieldName} must be at least ${options.minLength} characters`);
    }
    if (options?.maxLength && value.length > options.maxLength) {
      throw new BadRequestException(`${fieldName} must be at most ${options.maxLength} characters`);
    }
    if (options?.pattern) {
      const regex = new RegExp(options.pattern);
      if (!regex.test(value)) {
        throw new BadRequestException(`${fieldName} does not match required pattern`);
      }
    }
  }

  private validateNumber(value: string, options: FieldOptions | null, fieldName: string): void {
    const num = parseFloat(value);
    if (isNaN(num)) {
      throw new BadRequestException(`${fieldName} must be a valid number`);
    }
    if (options?.min !== undefined && num < options.min) {
      throw new BadRequestException(`${fieldName} must be at least ${options.min}`);
    }
    if (options?.max !== undefined && num > options.max) {
      throw new BadRequestException(`${fieldName} must be at most ${options.max}`);
    }
    if (options?.step !== undefined) {
      const remainder = (num - (options.min || 0)) % options.step;
      if (remainder !== 0) {
        throw new BadRequestException(`${fieldName} must be in increments of ${options.step}`);
      }
    }
  }

  private validateDate(value: string, options: FieldOptions | null, fieldName: string): void {
    const date = new Date(value);
    if (isNaN(date.getTime())) {
      throw new BadRequestException(`${fieldName} must be a valid date`);
    }
    if (options?.minDate) {
      const minDate = new Date(options.minDate);
      if (date < minDate) {
        throw new BadRequestException(`${fieldName} must be on or after ${options.minDate}`);
      }
    }
    if (options?.maxDate) {
      const maxDate = new Date(options.maxDate);
      if (date > maxDate) {
        throw new BadRequestException(`${fieldName} must be on or before ${options.maxDate}`);
      }
    }
  }

  private validateDropdown(value: string, options: FieldOptions | null, fieldName: string): void {
    if (!options?.choices || options.choices.length === 0) {
      throw new BadRequestException(`${fieldName} has no valid choices configured`);
    }
    const validValues = options.choices.map((c) => c.value);
    if (!validValues.includes(value)) {
      throw new BadRequestException(`${fieldName} must be one of: ${validValues.join(', ')}`);
    }
  }

  private validateCheckbox(value: string, fieldName: string): void {
    if (value !== 'true' && value !== 'false') {
      throw new BadRequestException(`${fieldName} must be true or false`);
    }
  }

  private validateUrl(value: string, fieldName: string): void {
    try {
      new URL(value);
    } catch {
      throw new BadRequestException(`${fieldName} must be a valid URL`);
    }
  }

  private validateEmail(value: string, fieldName: string): void {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    if (!emailRegex.test(value)) {
      throw new BadRequestException(`${fieldName} must be a valid email address`);
    }
  }
}

Custom Fields Repository

// apps/api/src/modules/custom-fields/custom-fields.repository.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { Prisma, CustomFieldDefinition, CustomFieldValue, EntityType } from '@prisma/client';

@Injectable()
export class CustomFieldsRepository {
  constructor(private readonly prisma: PrismaService) {}

  // Field Definitions
  async createDefinition(data: Prisma.CustomFieldDefinitionCreateInput): Promise<CustomFieldDefinition> {
    return this.prisma.customFieldDefinition.create({ data });
  }

  async findDefinitionById(id: string, tenantId: string): Promise<CustomFieldDefinition | null> {
    return this.prisma.customFieldDefinition.findFirst({
      where: { id, tenantId },
    });
  }

  async findDefinitionsByEntityType(
    tenantId: string,
    entityType: EntityType,
    options?: { activeOnly?: boolean },
  ): Promise<CustomFieldDefinition[]> {
    return this.prisma.customFieldDefinition.findMany({
      where: {
        tenantId,
        entityType,
        ...(options?.activeOnly !== false && { isActive: true }),
      },
      orderBy: [{ groupName: 'asc' }, { displayOrder: 'asc' }, { name: 'asc' }],
    });
  }

  async findAllDefinitions(tenantId: string): Promise<CustomFieldDefinition[]> {
    return this.prisma.customFieldDefinition.findMany({
      where: { tenantId },
      orderBy: [{ entityType: 'asc' }, { groupName: 'asc' }, { displayOrder: 'asc' }],
    });
  }

  async updateDefinition(
    id: string,
    tenantId: string,
    data: Prisma.CustomFieldDefinitionUpdateInput,
  ): Promise<CustomFieldDefinition> {
    return this.prisma.customFieldDefinition.update({
      where: { id, tenantId },
      data,
    });
  }

  async deleteDefinition(id: string, tenantId: string): Promise<void> {
    // Delete all values first, then the definition
    await this.prisma.$transaction([
      this.prisma.customFieldValue.deleteMany({
        where: { fieldDefinitionId: id },
      }),
      this.prisma.customFieldDefinition.delete({
        where: { id, tenantId },
      }),
    ]);
  }

  async deactivateDefinition(id: string, tenantId: string): Promise<CustomFieldDefinition> {
    return this.prisma.customFieldDefinition.update({
      where: { id, tenantId },
      data: { isActive: false },
    });
  }

  // Field Values
  async setValue(
    fieldDefinitionId: string,
    entityId: string,
    entityType: EntityType,
    value: string,
    updatedById: string,
  ): Promise<CustomFieldValue> {
    return this.prisma.customFieldValue.upsert({
      where: {
        fieldDefinitionId_entityId: { fieldDefinitionId, entityId },
      },
      create: {
        fieldDefinitionId,
        entityId,
        entityType,
        value,
        updatedById,
      },
      update: {
        value,
        updatedById,
      },
      include: {
        fieldDefinition: true,
      },
    });
  }

  async setValues(
    entityId: string,
    entityType: EntityType,
    values: { fieldDefinitionId: string; value: string }[],
    updatedById: string,
  ): Promise<void> {
    await this.prisma.$transaction(
      values.map((v) =>
        this.prisma.customFieldValue.upsert({
          where: {
            fieldDefinitionId_entityId: { fieldDefinitionId: v.fieldDefinitionId, entityId },
          },
          create: {
            fieldDefinitionId: v.fieldDefinitionId,
            entityId,
            entityType,
            value: v.value,
            updatedById,
          },
          update: {
            value: v.value,
            updatedById,
          },
        }),
      ),
    );
  }

  async getValuesByEntity(entityId: string, entityType: EntityType): Promise<CustomFieldValue[]> {
    return this.prisma.customFieldValue.findMany({
      where: { entityId, entityType },
      include: {
        fieldDefinition: true,
      },
    });
  }

  async getValuesByEntityIds(
    entityIds: string[],
    entityType: EntityType,
  ): Promise<Map<string, CustomFieldValue[]>> {
    const values = await this.prisma.customFieldValue.findMany({
      where: {
        entityId: { in: entityIds },
        entityType,
      },
      include: {
        fieldDefinition: true,
      },
    });

    const valuesByEntity = new Map<string, CustomFieldValue[]>();
    for (const value of values) {
      const existing = valuesByEntity.get(value.entityId) || [];
      existing.push(value);
      valuesByEntity.set(value.entityId, existing);
    }

    return valuesByEntity;
  }

  async deleteValue(fieldDefinitionId: string, entityId: string): Promise<void> {
    await this.prisma.customFieldValue.delete({
      where: {
        fieldDefinitionId_entityId: { fieldDefinitionId, entityId },
      },
    });
  }

  async deleteValuesByEntity(entityId: string, entityType: EntityType): Promise<void> {
    await this.prisma.customFieldValue.deleteMany({
      where: { entityId, entityType },
    });
  }

  // Search by custom field value
  async searchByFieldValue(
    tenantId: string,
    entityType: EntityType,
    fieldDefinitionId: string,
    searchValue: string,
  ): Promise<string[]> {
    const values = await this.prisma.customFieldValue.findMany({
      where: {
        fieldDefinitionId,
        entityType,
        value: { contains: searchValue, mode: 'insensitive' },
        fieldDefinition: { tenantId },
      },
      select: { entityId: true },
    });

    return values.map((v) => v.entityId);
  }

  // Get filterable fields
  async getFilterableFields(tenantId: string, entityType: EntityType): Promise<CustomFieldDefinition[]> {
    return this.prisma.customFieldDefinition.findMany({
      where: {
        tenantId,
        entityType,
        isFilterable: true,
        isActive: true,
      },
      orderBy: { displayOrder: 'asc' },
    });
  }

  // Get searchable fields
  async getSearchableFields(tenantId: string, entityType: EntityType): Promise<CustomFieldDefinition[]> {
    return this.prisma.customFieldDefinition.findMany({
      where: {
        tenantId,
        entityType,
        isSearchable: true,
        isActive: true,
      },
      orderBy: { displayOrder: 'asc' },
    });
  }
}

Custom Fields Service

// apps/api/src/modules/custom-fields/custom-fields.service.ts
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { CustomFieldsRepository } from './custom-fields.repository';
import { FieldValueValidator } from './validators/field-value.validator';
import { EntityType, FieldType } from './types';

@Injectable()
export class CustomFieldsService {
  constructor(
    private readonly repository: CustomFieldsRepository,
    private readonly validator: FieldValueValidator,
  ) {}

  async validateAndSetValues(
    tenantId: string,
    entityId: string,
    entityType: EntityType,
    values: { fieldDefinitionId: string; value: string }[],
    updatedById: string,
  ): Promise<void> {
    // Get all field definitions for validation
    const definitions = await this.repository.findDefinitionsByEntityType(tenantId, entityType);
    const definitionMap = new Map(definitions.map((d) => [d.id, d]));

    // Validate all values
    for (const { fieldDefinitionId, value } of values) {
      const definition = definitionMap.get(fieldDefinitionId);

      if (!definition) {
        throw new NotFoundException(`Field definition ${fieldDefinitionId} not found`);
      }

      if (definition.tenantId !== tenantId) {
        throw new BadRequestException('Field definition does not belong to this tenant');
      }

      if (!definition.isActive) {
        throw new BadRequestException(`Field ${definition.name} is no longer active`);
      }

      // Check required
      if (definition.isRequired && (!value || value.trim() === '')) {
        throw new BadRequestException(`${definition.name} is required`);
      }

      // Validate value format
      this.validator.validate(
        value,
        definition.fieldType as FieldType,
        definition.options as any,
        definition.name,
      );
    }

    // Check for missing required fields
    const providedFieldIds = new Set(values.map((v) => v.fieldDefinitionId));
    for (const definition of definitions) {
      if (definition.isRequired && !providedFieldIds.has(definition.id)) {
        // Check if there's an existing value
        const existingValues = await this.repository.getValuesByEntity(entityId, entityType);
        const hasExisting = existingValues.some((v) => v.fieldDefinitionId === definition.id);

        if (!hasExisting && !definition.defaultValue) {
          throw new BadRequestException(`${definition.name} is required`);
        }
      }
    }

    // Set all values
    await this.repository.setValues(entityId, entityType, values, updatedById);
  }

  async getEntityWithFields(
    tenantId: string,
    entityId: string,
    entityType: EntityType,
  ): Promise<{
    entityId: string;
    entityType: EntityType;
    fields: { definition: any; value: string | null }[];
  }> {
    const [definitions, values] = await Promise.all([
      this.repository.findDefinitionsByEntityType(tenantId, entityType),
      this.repository.getValuesByEntity(entityId, entityType),
    ]);

    const valueMap = new Map(values.map((v) => [v.fieldDefinitionId, v.value]));

    return {
      entityId,
      entityType,
      fields: definitions.map((definition) => ({
        definition,
        value: valueMap.get(definition.id) ?? definition.defaultValue ?? null,
      })),
    };
  }

  async searchEntitiesByCustomField(
    tenantId: string,
    entityType: EntityType,
    fieldDefinitionId: string,
    searchValue: string,
  ): Promise<string[]> {
    const definition = await this.repository.findDefinitionById(fieldDefinitionId, tenantId);

    if (!definition) {
      throw new NotFoundException('Field definition not found');
    }

    if (!definition.isSearchable) {
      throw new BadRequestException('This field is not searchable');
    }

    return this.repository.searchByFieldValue(tenantId, entityType, fieldDefinitionId, searchValue);
  }

  async copyFieldValues(
    tenantId: string,
    sourceEntityId: string,
    targetEntityId: string,
    entityType: EntityType,
    updatedById: string,
  ): Promise<void> {
    const sourceValues = await this.repository.getValuesByEntity(sourceEntityId, entityType);

    if (sourceValues.length === 0) return;

    const values = sourceValues.map((v) => ({
      fieldDefinitionId: v.fieldDefinitionId,
      value: v.value,
    }));

    await this.repository.setValues(targetEntityId, entityType, values, updatedById);
  }
}

Custom Fields Module

// apps/api/src/modules/custom-fields/custom-fields.module.ts
import { Module } from '@nestjs/common';
import { CustomFieldsController } from './custom-fields.controller';
import { CustomFieldsRepository } from './custom-fields.repository';
import { CustomFieldsService } from './custom-fields.service';
import { FieldValueValidator } from './validators/field-value.validator';

@Module({
  controllers: [CustomFieldsController],
  providers: [CustomFieldsRepository, CustomFieldsService, FieldValueValidator],
  exports: [CustomFieldsRepository, CustomFieldsService],
})
export class CustomFieldsModule {}

Custom Fields Controller

// apps/api/src/modules/custom-fields/custom-fields.controller.ts
import {
  Controller,
  Get,
  Post,
  Put,
  Delete,
  Body,
  Param,
  Query,
  UseGuards,
  ParseUUIDPipe,
  HttpCode,
  HttpStatus,
} from '@nestjs/common';
import { CustomFieldsRepository } from './custom-fields.repository';
import { CustomFieldsService } from './custom-fields.service';
import { TenantGuard } from '../common/guards';
// Note: PermissionsGuard not yet implemented in MVP - add in later phase
import { RequirePermissions } from '../auth/decorators/permissions.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { CreateFieldDefinitionDto } from './dto/create-field-definition.dto';
import { UpdateFieldDefinitionDto } from './dto/update-field-definition.dto';
import { SetFieldValuesDto, BulkSetFieldValuesDto } from './dto/field-value.dto';
import { EntityType } from './types';

@Controller('custom-fields')
@UseGuards(TenantGuard)
export class CustomFieldsController {
  constructor(
    private readonly repository: CustomFieldsRepository,
    private readonly service: CustomFieldsService,
  ) {}

  // Field Definition Management

  @Post('definitions')
  @RequirePermissions('custom-fields:create')
  async createDefinition(@CurrentUser() user: any, @Body() dto: CreateFieldDefinitionDto) {
    return this.repository.createDefinition({
      ...dto,
      tenant: { connect: { id: user.tenantId } },
      createdBy: { connect: { id: user.id } },
    });
  }

  @Get('definitions')
  @RequirePermissions('custom-fields:read')
  async findAllDefinitions(
    @CurrentUser() user: any,
    @Query('entityType') entityType?: EntityType,
  ) {
    if (entityType) {
      return {
        data: await this.repository.findDefinitionsByEntityType(user.tenantId, entityType),
      };
    }
    return { data: await this.repository.findAllDefinitions(user.tenantId) };
  }

  @Get('definitions/:id')
  @RequirePermissions('custom-fields:read')
  async findDefinition(@CurrentUser() user: any, @Param('id', ParseUUIDPipe) id: string) {
    return this.repository.findDefinitionById(id, user.tenantId);
  }

  @Put('definitions/:id')
  @RequirePermissions('custom-fields:update')
  async updateDefinition(
    @CurrentUser() user: any,
    @Param('id', ParseUUIDPipe) id: string,
    @Body() dto: UpdateFieldDefinitionDto,
  ) {
    return this.repository.updateDefinition(id, user.tenantId, dto);
  }

  @Delete('definitions/:id')
  @RequirePermissions('custom-fields:delete')
  @HttpCode(HttpStatus.NO_CONTENT)
  async deleteDefinition(@CurrentUser() user: any, @Param('id', ParseUUIDPipe) id: string) {
    await this.repository.deleteDefinition(id, user.tenantId);
  }

  @Post('definitions/:id/deactivate')
  @RequirePermissions('custom-fields:update')
  async deactivateDefinition(@CurrentUser() user: any, @Param('id', ParseUUIDPipe) id: string) {
    return this.repository.deactivateDefinition(id, user.tenantId);
  }

  // Field Value Management

  @Get('values/:entityType/:entityId')
  @RequirePermissions('custom-fields:read')
  async getEntityFields(
    @CurrentUser() user: any,
    @Param('entityType') entityType: EntityType,
    @Param('entityId', ParseUUIDPipe) entityId: string,
  ) {
    return this.service.getEntityWithFields(user.tenantId, entityId, entityType);
  }

  @Put('values/:entityType/:entityId')
  @RequirePermissions('custom-fields:update')
  async setEntityFields(
    @CurrentUser() user: any,
    @Param('entityType') entityType: EntityType,
    @Param('entityId', ParseUUIDPipe) entityId: string,
    @Body() dto: SetFieldValuesDto,
  ) {
    await this.service.validateAndSetValues(
      user.tenantId,
      entityId,
      entityType,
      dto.values,
      user.id,
    );
    return this.service.getEntityWithFields(user.tenantId, entityId, entityType);
  }

  @Post('values/:entityType/bulk')
  @RequirePermissions('custom-fields:update')
  async bulkSetEntityFields(
    @CurrentUser() user: any,
    @Param('entityType') entityType: EntityType,
    @Body() dto: BulkSetFieldValuesDto,
  ) {
    for (const entity of dto.entities) {
      await this.service.validateAndSetValues(
        user.tenantId,
        entity.entityId,
        entityType,
        entity.values,
        user.id,
      );
    }
    return { success: true, count: dto.entities.length };
  }

  @Delete('values/:entityType/:entityId')
  @RequirePermissions('custom-fields:delete')
  @HttpCode(HttpStatus.NO_CONTENT)
  async deleteEntityFields(
    @CurrentUser() user: any,
    @Param('entityType') entityType: EntityType,
    @Param('entityId', ParseUUIDPipe) entityId: string,
  ) {
    await this.repository.deleteValuesByEntity(entityId, entityType);
  }

  // Search and Filter

  @Get('search/:entityType')
  @RequirePermissions('custom-fields:read')
  async searchByCustomField(
    @CurrentUser() user: any,
    @Param('entityType') entityType: EntityType,
    @Query('fieldId', ParseUUIDPipe) fieldId: string,
    @Query('value') value: string,
  ) {
    const entityIds = await this.service.searchEntitiesByCustomField(
      user.tenantId,
      entityType,
      fieldId,
      value,
    );
    return { data: entityIds };
  }

  @Get('filterable/:entityType')
  @RequirePermissions('custom-fields:read')
  async getFilterableFields(
    @CurrentUser() user: any,
    @Param('entityType') entityType: EntityType,
  ) {
    return { data: await this.repository.getFilterableFields(user.tenantId, entityType) };
  }

  @Get('searchable/:entityType')
  @RequirePermissions('custom-fields:read')
  async getSearchableFields(
    @CurrentUser() user: any,
    @Param('entityType') entityType: EntityType,
  ) {
    return { data: await this.repository.getSearchableFields(user.tenantId, entityType) };
  }
}

Unit Tests

// apps/api/src/modules/custom-fields/custom-fields.controller.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { CustomFieldsController } from './custom-fields.controller';
import { CustomFieldsRepository } from './custom-fields.repository';
import { CustomFieldsService } from './custom-fields.service';
import { FieldType, EntityType } from './types';

describe('CustomFieldsController', () => {
  let controller: CustomFieldsController;
  let repository: jest.Mocked<CustomFieldsRepository>;
  let service: jest.Mocked<CustomFieldsService>;

  const mockUser = {
    id: 'user-1',
    tenantId: 'tenant-1',
    permissions: ['custom-fields:create', 'custom-fields:read', 'custom-fields:update'],
  };

  const mockDefinition = {
    id: 'field-1',
    name: 'Employee ID',
    description: 'External employee identifier',
    entityType: EntityType.EMPLOYEE,
    fieldType: FieldType.TEXT,
    isRequired: true,
    isSearchable: true,
    isFilterable: false,
    showInList: true,
    displayOrder: 0,
    groupName: null,
    options: { maxLength: 50 },
    defaultValue: null,
    isActive: true,
    tenantId: 'tenant-1',
    createdById: 'user-1',
    createdAt: new Date(),
    updatedAt: new Date(),
  };

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      controllers: [CustomFieldsController],
      providers: [
        {
          provide: CustomFieldsRepository,
          useValue: {
            createDefinition: jest.fn(),
            findDefinitionById: jest.fn(),
            findDefinitionsByEntityType: jest.fn(),
            findAllDefinitions: jest.fn(),
            updateDefinition: jest.fn(),
            deleteDefinition: jest.fn(),
            deactivateDefinition: jest.fn(),
            getFilterableFields: jest.fn(),
            getSearchableFields: jest.fn(),
            deleteValuesByEntity: jest.fn(),
          },
        },
        {
          provide: CustomFieldsService,
          useValue: {
            validateAndSetValues: jest.fn(),
            getEntityWithFields: jest.fn(),
            searchEntitiesByCustomField: jest.fn(),
          },
        },
      ],
    }).compile();

    controller = module.get<CustomFieldsController>(CustomFieldsController);
    repository = module.get(CustomFieldsRepository);
    service = module.get(CustomFieldsService);
  });

  describe('createDefinition', () => {
    it('should create a field definition', async () => {
      repository.createDefinition.mockResolvedValue(mockDefinition);

      const result = await controller.createDefinition(mockUser, {
        name: 'Employee ID',
        entityType: EntityType.EMPLOYEE,
        fieldType: FieldType.TEXT,
        isRequired: true,
      });

      expect(repository.createDefinition).toHaveBeenCalled();
      expect(result).toEqual(mockDefinition);
    });
  });

  describe('findAllDefinitions', () => {
    it('should return all definitions', async () => {
      repository.findAllDefinitions.mockResolvedValue([mockDefinition]);

      const result = await controller.findAllDefinitions(mockUser);

      expect(result.data).toHaveLength(1);
    });

    it('should filter by entity type', async () => {
      repository.findDefinitionsByEntityType.mockResolvedValue([mockDefinition]);

      const result = await controller.findAllDefinitions(mockUser, EntityType.EMPLOYEE);

      expect(repository.findDefinitionsByEntityType).toHaveBeenCalledWith(
        'tenant-1',
        EntityType.EMPLOYEE,
      );
    });
  });

  describe('setEntityFields', () => {
    it('should validate and set field values', async () => {
      service.validateAndSetValues.mockResolvedValue();
      service.getEntityWithFields.mockResolvedValue({
        entityId: 'emp-1',
        entityType: EntityType.EMPLOYEE,
        fields: [{ definition: mockDefinition, value: 'EMP-001' }],
      });

      const result = await controller.setEntityFields(
        mockUser,
        EntityType.EMPLOYEE,
        'emp-1',
        {
          entityId: 'emp-1',
          values: [{ fieldDefinitionId: 'field-1', value: 'EMP-001' }],
        },
      );

      expect(service.validateAndSetValues).toHaveBeenCalled();
      expect(result.fields).toHaveLength(1);
    });
  });

  describe('searchByCustomField', () => {
    it('should return matching entity IDs', async () => {
      service.searchEntitiesByCustomField.mockResolvedValue(['emp-1', 'emp-2']);

      const result = await controller.searchByCustomField(
        mockUser,
        EntityType.EMPLOYEE,
        'field-1',
        'EMP',
      );

      expect(result.data).toEqual(['emp-1', 'emp-2']);
    });
  });
});

Integration with Employee Module

// Example: Extending EmployeeController to include custom fields

@Get(':id')
async findOne(@CurrentUser() user: any, @Param('id', ParseUUIDPipe) id: string) {
  const [employee, customFields] = await Promise.all([
    this.repository.findById(id, user.tenantId),
    this.customFieldsService.getEntityWithFields(user.tenantId, id, EntityType.EMPLOYEE),
  ]);

  return {
    ...employee,
    customFields: customFields.fields,
  };
}

@Put(':id')
async update(
  @CurrentUser() user: any,
  @Param('id', ParseUUIDPipe) id: string,
  @Body() dto: UpdateEmployeeDto & { customFields?: { fieldDefinitionId: string; value: string }[] },
) {
  const { customFields, ...employeeData } = dto;

  const employee = await this.repository.update(id, user.tenantId, employeeData);

  if (customFields) {
    await this.customFieldsService.validateAndSetValues(
      user.tenantId,
      id,
      EntityType.EMPLOYEE,
      customFields,
      user.id,
    );
  }

  return this.findOne(user, id);
}

Key Implementation Notes

  1. Tenant Isolation: Each field definition belongs to a tenant
  2. Field Types: Support for text, number, date, dropdown, checkbox, URL, email
  3. Validation: Server-side validation for all field types with customizable rules
  4. Searchability: Fields can be marked as searchable for advanced queries
  5. Display Control: showInList, displayOrder, groupName for UI customization
  6. Soft Delete: Use isActive flag to deactivate instead of hard delete
  7. Default Values: Field definitions can have default values
  8. Bulk Operations: Support for setting values on multiple entities at once