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.tsField 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
- Tenant Isolation: Each field definition belongs to a tenant
- Field Types: Support for text, number, date, dropdown, checkbox, URL, email
- Validation: Server-side validation for all field types with customizable rules
- Searchability: Fields can be marked as searchable for advanced queries
- Display Control:
showInList,displayOrder,groupNamefor UI customization - Soft Delete: Use
isActiveflag to deactivate instead of hard delete - Default Values: Field definitions can have default values
- Bulk Operations: Support for setting values on multiple entities at once