Bluewoo HRMS
Micro-Step Build PlanBuilding Blocks

Phase 07: Tags & Custom Fields

Flexible categorization with tags and tenant-defined custom metadata fields

Phase 07: Tags & Custom Fields

Goal: Build a flexible tagging system for employees and documents, plus a custom fields system allowing tenants to define their own metadata fields for different entity types.

Architecture Note

This phase uses the Service pattern for tag assignment logic and custom field validation. For simpler CRUD without business logic, use the Controller → Prisma pattern directly (see patterns.mdx).

AttributeValue
Steps109-122
Estimated Time7-10 hours
DependenciesPhase 06 complete (Document model, TanStack Query available)
Completion GateTags can be created and assigned to employees/documents. Custom fields can be defined and values stored per entity.

Step Timing Estimates

StepTaskEst. Time
109Add TagStatus enum10 min
110Add TagCategory model (no relations)15 min
111Add Tag model (no relations)15 min
112Add TagPermission model15 min
113Add EmployeeTag junction15 min
114Add DocumentTag junction + ALL relations20 min
115Run Tag migration15 min
116Create TagService40 min
117Create TagController35 min
118Add EntityType, FieldType, FieldVisibility enums15 min
119Add CustomFieldDefinition model20 min
120Add CustomFieldValue model + Run migration20 min
121Create CustomFieldService45 min
122Add custom fields to employee form40 min

Phase Context (READ FIRST)

What This Phase Accomplishes

  • Tag categories with asset type scoping (employee, document, goal)
  • Tags with colors, descriptions, and status (active/archived)
  • Role-based tag permissions (who can assign/remove tags)
  • Tag assignment to employees and documents
  • Custom field definitions per entity type
  • Field types: TEXT, TEXTAREA, NUMBER, DATE, DROPDOWN, CHECKBOX, URL, EMAIL
  • Field visibility levels: ALL, MANAGER_PLUS, HR_ADMIN_ONLY
  • Dynamic custom field rendering in employee forms

Asset Types

Valid values for TagCategory.assetTypes:

  • "employee" - Tags assignable to employees
  • "document" - Tags assignable to documents
  • "goal" - Tags assignable to goals (future)

Case-sensitive. Use lowercase only.

What This Phase Does NOT Include

  • Tag search/filtering optimization (basic queries only)
  • Bulk tag operations - individual assignments only
  • Tag analytics/reporting - future enhancement
  • Custom field validation rules beyond basic types - future enhancement
  • Custom field conditional logic - future enhancement
  • Custom fields for documents (employees only in this phase)

Bluewoo Anti-Pattern Reminder

This phase intentionally has NO:

  • Complex permission inheritance - simple role-based checks
  • Tag hierarchies - flat structure only
  • Custom field versioning - direct updates only
  • External integrations for tags/fields - internal only

If the AI suggests adding any of these, REJECT and continue with the spec.


Step 109: Add TagStatus Enum

Input

  • Phase 06 complete
  • Prisma schema at packages/database/prisma/schema.prisma

Constraints

  • DO NOT modify existing models
  • ONLY add TagStatus enum
  • Enums must be created BEFORE models that reference them

Task

Add to packages/database/prisma/schema.prisma:

// ==========================================
// TAG SYSTEM ENUMS & MODELS
// ==========================================

enum TagStatus {
  ACTIVE
  ARCHIVED
}

Gate

cd packages/database
npx prisma format
# Should complete without errors

cat prisma/schema.prisma | grep -A 4 "enum TagStatus"
# Should show ACTIVE and ARCHIVED

Common Errors

ErrorCauseFix
Enum already existsDuplicate definitionRemove duplicate

Rollback

# Remove TagStatus enum from schema.prisma

Lock

packages/database/prisma/schema.prisma (TagStatus enum)

Checkpoint

  • TagStatus enum added
  • prisma format succeeds

Step 110: Add TagCategory Model

Input

  • Step 109 complete
  • TagStatus enum exists

Constraints

  • DO NOT add tags Tag[] relation yet (Tag model doesn't exist)
  • Relations to other Tag models will be added in Step 114

Task

Add to packages/database/prisma/schema.prisma:

model TagCategory {
  id          String   @id @default(cuid())
  tenantId    String
  name        String   // "skills", "departments", "status", etc.
  assetTypes  String[] // ["employee", "document", "goal"]
  color       String?  // Default color for tags in this category
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt

  tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  // tags Tag[] - Added in Step 114 after Tag model exists

  @@unique([tenantId, name])
  @@index([tenantId])
  @@map("tag_categories")
}

Also add the relation to the Tenant model:

// In Tenant model, add:
tagCategories TagCategory[]

Gate

cd packages/database
npx prisma format
# Should complete without errors

cat prisma/schema.prisma | grep -A 12 "model TagCategory"
# Should show model without tags relation

Common Errors

ErrorCauseFix
Relation not found on TenantMissing relation fieldAdd tagCategories TagCategory[] to Tenant

Rollback

# Remove TagCategory model from schema.prisma
# Remove tagCategories from Tenant model

Lock

packages/database/prisma/schema.prisma (TagCategory model - partial)

Checkpoint

  • TagCategory model added (without tags relation)
  • Tenant relation added
  • prisma format succeeds

Step 111: Add Tag Model

Input

  • Step 110 complete
  • TagCategory model exists
  • TagStatus enum exists

Constraints

  • DO NOT add permissions, employees, documents relations yet
  • Relations to junction models will be added in Step 114

Task

Add to packages/database/prisma/schema.prisma:

model Tag {
  id          String    @id @default(cuid())
  tenantId    String
  categoryId  String
  name        String
  color       String?   // Override category color
  description String?
  status      TagStatus @default(ACTIVE)
  createdAt   DateTime  @default(now())
  updatedAt   DateTime  @updatedAt

  tenant      Tenant        @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  category    TagCategory   @relation(fields: [categoryId], references: [id], onDelete: Cascade)
  // permissions TagPermission[] - Added in Step 114
  // employees   EmployeeTag[]   - Added in Step 114
  // documents   DocumentTag[]   - Added in Step 114

  @@unique([tenantId, categoryId, name])
  @@index([tenantId])
  @@index([categoryId])
  @@map("tags")
}

Also add relations:

// In Tenant model, add:
tags Tag[]

// In TagCategory model, add:
tags Tag[]

Gate

cd packages/database
npx prisma format
# Should complete without errors

cat prisma/schema.prisma | grep -A 18 "model Tag {"

Common Errors

ErrorCauseFix
Unknown type TagStatusEnum not added in Step 109Go back to Step 109

Rollback

# Remove Tag model from schema.prisma
# Remove tags from Tenant model
# Remove tags from TagCategory model

Lock

packages/database/prisma/schema.prisma (Tag model - partial)

Checkpoint

  • Tag model added (without junction relations)
  • Tenant and TagCategory relations added
  • prisma format succeeds

Step 112: Add TagPermission Model

Input

  • Step 111 complete
  • Tag model exists

Constraints

  • DO NOT add relation to Tag model yet (added in Step 114)

Task

Add to packages/database/prisma/schema.prisma:

model TagPermission {
  id        String     @id @default(cuid())
  tenantId  String
  tagId     String
  role      SystemRole // Who can use this tag (from Phase 01)
  canAssign Boolean    @default(true)
  canRemove Boolean    @default(true)

  tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  tag    Tag    @relation(fields: [tagId], references: [id], onDelete: Cascade)

  @@unique([tenantId, tagId, role])
  @@index([tenantId])
  @@map("tag_permissions")
}

Also add the relation to the Tenant model:

// In Tenant model, add:
tagPermissions TagPermission[]

Gate

cd packages/database
npx prisma format

cat prisma/schema.prisma | grep -A 14 "model TagPermission"

Common Errors

ErrorCauseFix
Unknown type SystemRoleEnum not in schemaVerify Phase 01 added SystemRole enum

Rollback

# Remove TagPermission model from schema.prisma
# Remove tagPermissions from Tenant model

Lock

packages/database/prisma/schema.prisma (TagPermission model)

Checkpoint

  • TagPermission model added
  • Tenant relation added
  • prisma format succeeds

Step 113: Add EmployeeTag Junction

Input

  • Step 112 complete
  • Tag and Employee models exist

Constraints

  • DO NOT add relation to Tag model yet (added in Step 114)

Task

Add to packages/database/prisma/schema.prisma:

model EmployeeTag {
  employeeId String
  tagId      String
  assignedAt DateTime @default(now())
  assignedBy String?  // userId who assigned the tag

  employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
  tag      Tag      @relation(fields: [tagId], references: [id], onDelete: Cascade)

  @@id([employeeId, tagId])
  @@map("employee_tags")
}

Also add the relation to the Employee model:

// In Employee model, add:
tags EmployeeTag[]

Gate

cd packages/database
npx prisma format

cat prisma/schema.prisma | grep -A 12 "model EmployeeTag"

Rollback

# Remove EmployeeTag model from schema.prisma
# Remove tags from Employee model

Lock

packages/database/prisma/schema.prisma (EmployeeTag model)

Checkpoint

  • EmployeeTag junction added
  • Employee relation added
  • prisma format succeeds

Step 114: Add DocumentTag Junction + Complete All Relations

Input

  • Steps 109-113 complete
  • All Tag models exist

Constraints

  • Add DocumentTag model
  • Add ALL missing relations to complete the schema

Task

Part 1: Add DocumentTag model

Add to packages/database/prisma/schema.prisma:

model DocumentTag {
  documentId String
  tagId      String
  assignedAt DateTime @default(now())
  assignedBy String?  // userId who assigned the tag

  document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
  tag      Tag      @relation(fields: [tagId], references: [id], onDelete: Cascade)

  @@id([documentId, tagId])
  @@map("document_tags")
}

Also add the relation to the Document model:

// In Document model, add:
tags DocumentTag[]

Part 2: Add missing relations to Tag model

Update the Tag model to include all relations:

model Tag {
  id          String    @id @default(cuid())
  tenantId    String
  categoryId  String
  name        String
  color       String?
  description String?
  status      TagStatus @default(ACTIVE)
  createdAt   DateTime  @default(now())
  updatedAt   DateTime  @updatedAt

  tenant      Tenant          @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  category    TagCategory     @relation(fields: [categoryId], references: [id], onDelete: Cascade)
  permissions TagPermission[] // NOW added
  employees   EmployeeTag[]   // NOW added
  documents   DocumentTag[]   // NOW added

  @@unique([tenantId, categoryId, name])
  @@index([tenantId])
  @@index([categoryId])
  @@map("tags")
}

Gate

cd packages/database
npx prisma format
# Should complete without errors - ALL models and relations now complete

cat prisma/schema.prisma | grep -A 22 "model Tag {"
# Should show permissions, employees, documents relations

Common Errors

ErrorCauseFix
Relation not foundMissing back-relationEnsure both sides of relation exist

Rollback

# Remove DocumentTag model
# Remove permissions/employees/documents from Tag model
# Remove tags from Document model

Lock

packages/database/prisma/schema.prisma (Tag system complete)

Checkpoint

  • DocumentTag junction added
  • Document relation added
  • Tag model has ALL relations (permissions, employees, documents)
  • prisma format succeeds with NO errors

Step 115: Run Migration for Tags

Input

  • Steps 109-114 complete
  • All Tag models and relations defined

Constraints

  • Run migration ONLY after all Tag models are complete
  • Custom Field models will have their own migration in Step 120

Task

cd packages/database
npx prisma db push

Gate

# Check tables exist
npx prisma studio
# Should see:
# - tag_categories table
# - tags table
# - tag_permissions table
# - employee_tags table
# - document_tags table

Common Errors

ErrorCauseFix
Foreign key constraint failedMissing referenced tableCheck Document/Employee models exist
Unique constraint failedData issueEnsure no duplicate migrations

Rollback

# Drop the created tables manually or reset database
cd packages/database
npx prisma db push --force-reset  # WARNING: Destroys all data

Lock

packages/database/prisma/schema.prisma (Tag system models)

Checkpoint

  • Migration succeeded
  • All 5 tag tables visible in Prisma Studio
  • Relations work correctly

Step 116: Create TagService

Input

  • Step 115 complete
  • Tag tables exist in database

Prerequisites

Verify SystemRole enum exists from Phase 01:

cat packages/database/prisma/schema.prisma | grep -A 6 "enum SystemRole"
# Should show SYSTEM_ADMIN, HR_ADMIN, MANAGER, EMPLOYEE

Create directories:

mkdir -p apps/api/src/tags
mkdir -p apps/api/src/tags/dto

Constraints

  • Follow Phase 05/06 patterns for repository and service
  • Use tenant filtering
  • Include permission checking

Task

Create apps/api/src/tags/tag.repository.ts:

import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { TagStatus, SystemRole, Prisma } from '@prisma/client';

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

  // ==========================================
  // TAG CATEGORY OPERATIONS
  // ==========================================

  async createCategory(
    tenantId: string,
    data: {
      name: string;
      assetTypes: string[];
      color?: string;
    },
  ) {
    return this.prisma.tagCategory.create({
      data: {
        tenantId,
        ...data,
      },
    });
  }

  async findCategoriesByTenant(tenantId: string) {
    return this.prisma.tagCategory.findMany({
      where: { tenantId },
      include: {
        tags: {
          where: { status: TagStatus.ACTIVE },
        },
      },
      orderBy: { name: 'asc' },
    });
  }

  async findCategoryById(tenantId: string, id: string) {
    return this.prisma.tagCategory.findFirst({
      where: { id, tenantId },
      include: { tags: true },
    });
  }

  async updateCategory(
    tenantId: string,
    id: string,
    data: Prisma.TagCategoryUpdateInput,
  ) {
    // Use updateMany to enforce tenant isolation
    const result = await this.prisma.tagCategory.updateMany({
      where: { id, tenantId },
      data,
    });

    if (result.count === 0) {
      throw new NotFoundException(`Tag category ${id} not found`);
    }

    return this.findCategoryById(tenantId, id);
  }

  async deleteCategory(tenantId: string, id: string) {
    // Use deleteMany to enforce tenant isolation
    // Tags will cascade delete due to onDelete: Cascade on Tag.category
    const result = await this.prisma.tagCategory.deleteMany({
      where: { id, tenantId },
    });

    if (result.count === 0) {
      throw new NotFoundException(`Tag category ${id} not found`);
    }
  }

  // ==========================================
  // TAG OPERATIONS
  // ==========================================

  async createTag(
    tenantId: string,
    data: {
      categoryId: string;
      name: string;
      color?: string;
      description?: string;
    },
  ) {
    return this.prisma.tag.create({
      data: {
        tenantId,
        ...data,
      },
      include: { category: true },
    });
  }

  async findTagsByTenant(
    tenantId: string,
    options?: {
      categoryId?: string;
      status?: TagStatus;
      assetType?: string;
    },
  ) {
    const where: Prisma.TagWhereInput = {
      tenantId,
      ...(options?.categoryId && { categoryId: options.categoryId }),
      ...(options?.status && { status: options.status }),
      ...(options?.assetType && {
        category: {
          assetTypes: { has: options.assetType },
        },
      }),
    };

    return this.prisma.tag.findMany({
      where,
      include: { category: true },
      orderBy: { name: 'asc' },
    });
  }

  async findTagById(tenantId: string, id: string) {
    return this.prisma.tag.findFirst({
      where: { id, tenantId },
      include: { category: true, permissions: true },
    });
  }

  async updateTag(tenantId: string, id: string, data: Prisma.TagUpdateInput) {
    // Use updateMany to enforce tenant isolation
    const result = await this.prisma.tag.updateMany({
      where: { id, tenantId },
      data,
    });

    if (result.count === 0) {
      throw new NotFoundException(`Tag ${id} not found`);
    }

    return this.findTagById(tenantId, id);
  }

  async archiveTag(tenantId: string, id: string) {
    // Use updateMany to enforce tenant isolation
    const result = await this.prisma.tag.updateMany({
      where: { id, tenantId },
      data: { status: TagStatus.ARCHIVED },
    });

    if (result.count === 0) {
      throw new NotFoundException(`Tag ${id} not found`);
    }

    return this.findTagById(tenantId, id);
  }

  // ==========================================
  // TAG PERMISSION OPERATIONS
  // ==========================================

  async setTagPermission(
    tenantId: string,
    tagId: string,
    role: SystemRole,
    canAssign: boolean,
    canRemove: boolean,
  ) {
    return this.prisma.tagPermission.upsert({
      where: {
        tenantId_tagId_role: { tenantId, tagId, role },
      },
      update: { canAssign, canRemove },
      create: { tenantId, tagId, role, canAssign, canRemove },
    });
  }

  async getTagPermissions(tenantId: string, tagId: string) {
    return this.prisma.tagPermission.findMany({
      where: { tenantId, tagId },
    });
  }

  async canUserAssignTag(
    tenantId: string,
    tagId: string,
    userRole: SystemRole,
  ): Promise<boolean> {
    // SYSTEM_ADMIN and HR_ADMIN can always assign
    if (userRole === SystemRole.SYSTEM_ADMIN || userRole === SystemRole.HR_ADMIN) {
      return true;
    }

    const permission = await this.prisma.tagPermission.findUnique({
      where: {
        tenantId_tagId_role: { tenantId, tagId, role: userRole },
      },
    });

    return permission?.canAssign ?? false;
  }

  async canUserRemoveTag(
    tenantId: string,
    tagId: string,
    userRole: SystemRole,
  ): Promise<boolean> {
    // SYSTEM_ADMIN and HR_ADMIN can always remove
    if (userRole === SystemRole.SYSTEM_ADMIN || userRole === SystemRole.HR_ADMIN) {
      return true;
    }

    const permission = await this.prisma.tagPermission.findUnique({
      where: {
        tenantId_tagId_role: { tenantId, tagId, role: userRole },
      },
    });

    return permission?.canRemove ?? false;
  }

  // ==========================================
  // TAG ASSIGNMENT OPERATIONS
  // ==========================================

  async assignTagToEmployee(
    employeeId: string,
    tagId: string,
    assignedBy: string,
  ) {
    return this.prisma.employeeTag.create({
      data: {
        employeeId,
        tagId,
        assignedBy,
      },
    });
  }

  async removeTagFromEmployee(employeeId: string, tagId: string) {
    return this.prisma.employeeTag.delete({
      where: {
        employeeId_tagId: { employeeId, tagId },
      },
    });
  }

  async getEmployeeTags(tenantId: string, employeeId: string) {
    return this.prisma.employeeTag.findMany({
      where: {
        employeeId,
        employee: { tenantId },  // Join through Employee for tenant isolation
      },
      include: {
        tag: {
          include: { category: true },
        },
      },
    });
  }

  async assignTagToDocument(
    documentId: string,
    tagId: string,
    assignedBy: string,
  ) {
    return this.prisma.documentTag.create({
      data: {
        documentId,
        tagId,
        assignedBy,
      },
    });
  }

  async removeTagFromDocument(documentId: string, tagId: string) {
    return this.prisma.documentTag.delete({
      where: {
        documentId_tagId: { documentId, tagId },
      },
    });
  }

  async getDocumentTags(tenantId: string, documentId: string) {
    return this.prisma.documentTag.findMany({
      where: {
        documentId,
        document: { tenantId },  // Join through Document for tenant isolation
      },
      include: {
        tag: {
          include: { category: true },
        },
      },
    });
  }
}

Create apps/api/src/tags/tag.service.ts:

import {
  Injectable,
  NotFoundException,
  ForbiddenException,
} from '@nestjs/common';
import { TagRepository } from './tag.repository';
import { TagStatus, SystemRole } from '@prisma/client';

@Injectable()
export class TagService {
  constructor(private tagRepository: TagRepository) {}

  // ==========================================
  // CATEGORY OPERATIONS
  // ==========================================

  async createCategory(
    tenantId: string,
    data: {
      name: string;
      assetTypes: string[];
      color?: string;
    },
  ) {
    return this.tagRepository.createCategory(tenantId, data);
  }

  async getCategories(tenantId: string) {
    return this.tagRepository.findCategoriesByTenant(tenantId);
  }

  async getCategory(tenantId: string, id: string) {
    const category = await this.tagRepository.findCategoryById(tenantId, id);
    if (!category) {
      throw new NotFoundException('Tag category not found');
    }
    return category;
  }

  async updateCategory(
    tenantId: string,
    id: string,
    data: { name?: string; assetTypes?: string[]; color?: string },
  ) {
    await this.getCategory(tenantId, id); // Verify exists
    return this.tagRepository.updateCategory(tenantId, id, data);
  }

  async deleteCategory(tenantId: string, id: string) {
    await this.getCategory(tenantId, id); // Verify exists
    return this.tagRepository.deleteCategory(tenantId, id);
  }

  // ==========================================
  // TAG OPERATIONS
  // ==========================================

  async createTag(
    tenantId: string,
    data: {
      categoryId: string;
      name: string;
      color?: string;
      description?: string;
    },
  ) {
    // Verify category exists and belongs to tenant
    await this.getCategory(tenantId, data.categoryId);
    return this.tagRepository.createTag(tenantId, data);
  }

  async getTags(
    tenantId: string,
    options?: {
      categoryId?: string;
      status?: TagStatus;
      assetType?: string;
    },
  ) {
    return this.tagRepository.findTagsByTenant(tenantId, options);
  }

  async getTag(tenantId: string, id: string) {
    const tag = await this.tagRepository.findTagById(tenantId, id);
    if (!tag) {
      throw new NotFoundException('Tag not found');
    }
    return tag;
  }

  async updateTag(
    tenantId: string,
    id: string,
    data: { name?: string; color?: string; description?: string },
  ) {
    await this.getTag(tenantId, id); // Verify exists
    return this.tagRepository.updateTag(tenantId, id, data);
  }

  async archiveTag(tenantId: string, id: string) {
    await this.getTag(tenantId, id); // Verify exists
    return this.tagRepository.archiveTag(tenantId, id);
  }

  // ==========================================
  // PERMISSION OPERATIONS
  // ==========================================

  async setTagPermission(
    tenantId: string,
    tagId: string,
    role: SystemRole,
    canAssign: boolean,
    canRemove: boolean,
  ) {
    await this.getTag(tenantId, tagId); // Verify tag exists
    return this.tagRepository.setTagPermission(
      tenantId,
      tagId,
      role,
      canAssign,
      canRemove,
    );
  }

  async getTagPermissions(tenantId: string, tagId: string) {
    await this.getTag(tenantId, tagId); // Verify tag exists
    return this.tagRepository.getTagPermissions(tenantId, tagId);
  }

  // ==========================================
  // ASSIGNMENT OPERATIONS
  // ==========================================

  async assignTagToEmployee(
    tenantId: string,
    employeeId: string,
    tagId: string,
    userId: string,
    userRole: SystemRole,
  ) {
    // Verify tag exists and user has permission
    const tag = await this.getTag(tenantId, tagId);

    // Check if tag is for employees
    if (!tag.category.assetTypes.includes('employee')) {
      throw new ForbiddenException('This tag cannot be assigned to employees');
    }

    const canAssign = await this.tagRepository.canUserAssignTag(
      tenantId,
      tagId,
      userRole,
    );
    if (!canAssign) {
      throw new ForbiddenException('You do not have permission to assign this tag');
    }

    return this.tagRepository.assignTagToEmployee(employeeId, tagId, userId);
  }

  async removeTagFromEmployee(
    tenantId: string,
    employeeId: string,
    tagId: string,
    userRole: SystemRole,
  ) {
    const canRemove = await this.tagRepository.canUserRemoveTag(
      tenantId,
      tagId,
      userRole,
    );
    if (!canRemove) {
      throw new ForbiddenException('You do not have permission to remove this tag');
    }

    return this.tagRepository.removeTagFromEmployee(employeeId, tagId);
  }

  async getEmployeeTags(tenantId: string, employeeId: string) {
    return this.tagRepository.getEmployeeTags(tenantId, employeeId);
  }

  async assignTagToDocument(
    tenantId: string,
    documentId: string,
    tagId: string,
    userId: string,
    userRole: SystemRole,
  ) {
    // Verify tag exists and user has permission
    const tag = await this.getTag(tenantId, tagId);

    // Check if tag is for documents
    if (!tag.category.assetTypes.includes('document')) {
      throw new ForbiddenException('This tag cannot be assigned to documents');
    }

    const canAssign = await this.tagRepository.canUserAssignTag(
      tenantId,
      tagId,
      userRole,
    );
    if (!canAssign) {
      throw new ForbiddenException('You do not have permission to assign this tag');
    }

    return this.tagRepository.assignTagToDocument(documentId, tagId, userId);
  }

  async removeTagFromDocument(
    tenantId: string,
    documentId: string,
    tagId: string,
    userRole: SystemRole,
  ) {
    const canRemove = await this.tagRepository.canUserRemoveTag(
      tenantId,
      tagId,
      userRole,
    );
    if (!canRemove) {
      throw new ForbiddenException('You do not have permission to remove this tag');
    }

    return this.tagRepository.removeTagFromDocument(documentId, tagId);
  }

  async getDocumentTags(tenantId: string, documentId: string) {
    return this.tagRepository.getDocumentTags(tenantId, documentId);
  }
}

Gate

cd apps/api
npm run build
# Should compile without errors

# Check files exist
ls -la src/tags/
# Should show:
# - tag.repository.ts
# - tag.service.ts

Common Errors

ErrorCauseFix
Cannot find module '@prisma/client'Prisma not generatedRun npx prisma generate in database package
Property 'tagCategory' does not existPrisma client outdatedRegenerate Prisma client

Rollback

rm -rf apps/api/src/tags/

Lock

apps/api/src/tags/tag.repository.ts
apps/api/src/tags/tag.service.ts

Checkpoint

  • TagRepository created with all methods
  • TagService created with permission checking
  • API compiles successfully

Step 117: Create TagController

Input

  • Step 116 complete
  • TagService exists

Constraints

  • Use TenantGuard pattern from Phase 01
  • Use correct import paths
  • Use api/v1/tags route prefix

Task

Create apps/api/src/tags/dto/tag.dto.ts:

import { IsString, IsArray, IsOptional, IsBoolean, IsEnum } from 'class-validator';
import { SystemRole, TagStatus } from '@prisma/client';

export class CreateTagCategoryDto {
  @IsString()
  name: string;

  @IsArray()
  @IsString({ each: true })
  assetTypes: string[]; // ['employee', 'document', 'goal']

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

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

  @IsOptional()
  @IsArray()
  @IsString({ each: true })
  assetTypes?: string[];

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

export class CreateTagDto {
  @IsString()
  categoryId: string;

  @IsString()
  name: string;

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

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

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

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

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

export class SetTagPermissionDto {
  @IsEnum(SystemRole)
  role: SystemRole;

  @IsBoolean()
  canAssign: boolean;

  @IsBoolean()
  canRemove: boolean;
}

export class AssignTagDto {
  @IsString()
  tagId: string;
}

Create apps/api/src/tags/tag.controller.ts:

import {
  Controller,
  Get,
  Post,
  Patch,
  Delete,
  Body,
  Param,
  Query,
  UseGuards,
} from '@nestjs/common';
import { TagService } from './tag.service';
import { TenantGuard } from '../tenant/tenant.guard';
import { TenantId } from '../tenant/tenant.decorator';
import { CurrentUser } from '../auth/current-user.decorator';
import { SystemRole, TagStatus } from '@prisma/client';
import {
  CreateTagCategoryDto,
  UpdateTagCategoryDto,
  CreateTagDto,
  UpdateTagDto,
  SetTagPermissionDto,
  AssignTagDto,
} from './dto/tag.dto';

interface AuthUser {
  id: string;
  systemRole: SystemRole;
}

@Controller('api/v1/tags')
@UseGuards(TenantGuard)
export class TagController {
  constructor(private tagService: TagService) {}

  // ==========================================
  // CATEGORY ENDPOINTS
  // ==========================================

  @Post('categories')
  async createCategory(
    @TenantId() tenantId: string,
    @Body() dto: CreateTagCategoryDto,
  ) {
    const category = await this.tagService.createCategory(tenantId, dto);
    return { data: category, error: null };
  }

  @Get('categories')
  async getCategories(@TenantId() tenantId: string) {
    const categories = await this.tagService.getCategories(tenantId);
    return { data: categories, error: null };
  }

  @Get('categories/:id')
  async getCategory(@TenantId() tenantId: string, @Param('id') id: string) {
    const category = await this.tagService.getCategory(tenantId, id);
    return { data: category, error: null };
  }

  @Patch('categories/:id')
  async updateCategory(
    @TenantId() tenantId: string,
    @Param('id') id: string,
    @Body() dto: UpdateTagCategoryDto,
  ) {
    const category = await this.tagService.updateCategory(tenantId, id, dto);
    return { data: category, error: null };
  }

  @Delete('categories/:id')
  async deleteCategory(@TenantId() tenantId: string, @Param('id') id: string) {
    await this.tagService.deleteCategory(tenantId, id);
    return { data: { success: true }, error: null };
  }

  // ==========================================
  // TAG ENDPOINTS
  // ==========================================

  @Post()
  async createTag(@TenantId() tenantId: string, @Body() dto: CreateTagDto) {
    const tag = await this.tagService.createTag(tenantId, dto);
    return { data: tag, error: null };
  }

  @Get()
  async getTags(
    @TenantId() tenantId: string,
    @Query('categoryId') categoryId?: string,
    @Query('status') status?: TagStatus,
    @Query('assetType') assetType?: string, // Valid values: 'employee', 'document', 'goal'
  ) {
    const tags = await this.tagService.getTags(tenantId, {
      categoryId,
      status,
      assetType,
    });
    return { data: tags, error: null };
  }

  @Get(':id')
  async getTag(@TenantId() tenantId: string, @Param('id') id: string) {
    const tag = await this.tagService.getTag(tenantId, id);
    return { data: tag, error: null };
  }

  @Patch(':id')
  async updateTag(
    @TenantId() tenantId: string,
    @Param('id') id: string,
    @Body() dto: UpdateTagDto,
  ) {
    const tag = await this.tagService.updateTag(tenantId, id, dto);
    return { data: tag, error: null };
  }

  @Delete(':id')
  async archiveTag(@TenantId() tenantId: string, @Param('id') id: string) {
    const tag = await this.tagService.archiveTag(tenantId, id);
    return { data: tag, error: null };
  }

  // ==========================================
  // PERMISSION ENDPOINTS
  // ==========================================

  @Post(':id/permissions')
  async setTagPermission(
    @TenantId() tenantId: string,
    @Param('id') tagId: string,
    @Body() dto: SetTagPermissionDto,
  ) {
    const permission = await this.tagService.setTagPermission(
      tenantId,
      tagId,
      dto.role,
      dto.canAssign,
      dto.canRemove,
    );
    return { data: permission, error: null };
  }

  @Get(':id/permissions')
  async getTagPermissions(
    @TenantId() tenantId: string,
    @Param('id') tagId: string,
  ) {
    const permissions = await this.tagService.getTagPermissions(tenantId, tagId);
    return { data: permissions, error: null };
  }

  // ==========================================
  // EMPLOYEE TAG ENDPOINTS
  // ==========================================

  @Post('employees/:employeeId/tags')
  async assignTagToEmployee(
    @TenantId() tenantId: string,
    @Param('employeeId') employeeId: string,
    @Body() dto: AssignTagDto,
    @CurrentUser() user: AuthUser,
  ) {
    const result = await this.tagService.assignTagToEmployee(
      tenantId,
      employeeId,
      dto.tagId,
      user.id,
      user.systemRole,
    );
    return { data: result, error: null };
  }

  @Delete('employees/:employeeId/tags/:tagId')
  async removeTagFromEmployee(
    @TenantId() tenantId: string,
    @Param('employeeId') employeeId: string,
    @Param('tagId') tagId: string,
    @CurrentUser() user: AuthUser,
  ) {
    await this.tagService.removeTagFromEmployee(
      tenantId,
      employeeId,
      tagId,
      user.systemRole,
    );
    return { data: { success: true }, error: null };
  }

  @Get('employees/:employeeId/tags')
  async getEmployeeTags(
    @TenantId() tenantId: string,
    @Param('employeeId') employeeId: string,
  ) {
    const tags = await this.tagService.getEmployeeTags(tenantId, employeeId);
    return { data: tags, error: null };
  }

  // ==========================================
  // DOCUMENT TAG ENDPOINTS
  // ==========================================

  @Post('documents/:documentId/tags')
  async assignTagToDocument(
    @TenantId() tenantId: string,
    @Param('documentId') documentId: string,
    @Body() dto: AssignTagDto,
    @CurrentUser() user: AuthUser,
  ) {
    const result = await this.tagService.assignTagToDocument(
      tenantId,
      documentId,
      dto.tagId,
      user.id,
      user.systemRole,
    );
    return { data: result, error: null };
  }

  @Delete('documents/:documentId/tags/:tagId')
  async removeTagFromDocument(
    @TenantId() tenantId: string,
    @Param('documentId') documentId: string,
    @Param('tagId') tagId: string,
    @CurrentUser() user: AuthUser,
  ) {
    await this.tagService.removeTagFromDocument(
      tenantId,
      documentId,
      tagId,
      user.systemRole,
    );
    return { data: { success: true }, error: null };
  }

  @Get('documents/:documentId/tags')
  async getDocumentTags(
    @TenantId() tenantId: string,
    @Param('documentId') documentId: string,
  ) {
    const tags = await this.tagService.getDocumentTags(tenantId, documentId);
    return { data: tags, error: null };
  }
}

Create apps/api/src/tags/tags.module.ts:

import { Module } from '@nestjs/common';
import { TagController } from './tag.controller';
import { TagService } from './tag.service';
import { TagRepository } from './tag.repository';
import { PrismaModule } from '../prisma/prisma.module';

@Module({
  imports: [PrismaModule],
  controllers: [TagController],
  providers: [TagService, TagRepository],
  exports: [TagService],
})
export class TagsModule {}

Register in apps/api/src/app.module.ts:

import { TagsModule } from './tags/tags.module';

@Module({
  imports: [
    // ... existing modules
    TagsModule,
  ],
})
export class AppModule {}

Gate

cd apps/api
npm run build
# Should compile without errors

# Test endpoint (after starting the API)
curl -X GET http://localhost:3001/api/v1/tags/categories \
  -H "X-Tenant-ID: your-tenant-id"
# Should return { data: [], error: null }

Common Errors

ErrorCauseFix
Cannot find module '../tenant/tenant.guard'Wrong pathCheck path matches Phase 01 structure
Cannot find module '../auth/current-user.decorator'Wrong pathCheck path matches Phase 01 structure
TagsModule is not registeredMissing importAdd to AppModule imports

Rollback

rm apps/api/src/tags/dto/tag.dto.ts
rm apps/api/src/tags/tag.controller.ts
rm apps/api/src/tags/tags.module.ts
# Remove TagsModule from app.module.ts imports

Lock

apps/api/src/tags/dto/tag.dto.ts
apps/api/src/tags/tag.controller.ts
apps/api/src/tags/tags.module.ts

Checkpoint

  • DTOs created with validation
  • Controller created with all endpoints
  • TagsModule created and registered
  • API compiles and endpoints respond

Step 118: Add Custom Field Enums

Input

  • Step 117 complete
  • Tag system working

Constraints

  • Add all three enums BEFORE models that use them
  • Enums must exist before CustomFieldDefinition and CustomFieldValue

Task

Add to packages/database/prisma/schema.prisma:

// ==========================================
// CUSTOM FIELDS ENUMS & MODELS
// ==========================================

enum EntityType {
  EMPLOYEE
  DEPARTMENT
  TEAM
  GOAL
}

enum FieldType {
  TEXT
  TEXTAREA
  NUMBER
  DATE
  DROPDOWN
  CHECKBOX
  URL
  EMAIL
}

enum FieldVisibility {
  ALL
  MANAGER_PLUS
  HR_ADMIN_ONLY
}

Gate

cd packages/database
npx prisma format
# Should complete without errors

cat prisma/schema.prisma | grep -A 6 "enum EntityType"
cat prisma/schema.prisma | grep -A 10 "enum FieldType"
cat prisma/schema.prisma | grep -A 5 "enum FieldVisibility"

Rollback

# Remove enum blocks from schema.prisma

Lock

packages/database/prisma/schema.prisma (EntityType, FieldType, FieldVisibility enums)

Checkpoint

  • All three enums added
  • prisma format succeeds

Step 119: Add CustomFieldDefinition Model

Input

  • Step 118 complete
  • EntityType, FieldType, FieldVisibility enums exist

Constraints

  • DO NOT modify Tag models
  • Enums already exist from Step 118

Task

Add to packages/database/prisma/schema.prisma:

model CustomFieldDefinition {
  id          String          @id @default(cuid())
  tenantId    String
  entityType  EntityType
  fieldName   String
  fieldType   FieldType
  label       String
  description String?
  required    Boolean         @default(false)
  visibility  FieldVisibility @default(ALL)
  editable    Boolean         @default(true)
  options     Json?           // For DROPDOWN: ["option1", "option2"]
  validation  Json?           // { min, max, pattern, etc. }
  section     String?         // UI grouping
  sortOrder   Int             @default(0)
  createdAt   DateTime        @default(now())
  updatedAt   DateTime        @updatedAt

  tenant Tenant             @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  // values CustomFieldValue[] - Added in Step 120

  @@unique([tenantId, entityType, fieldName])
  @@index([tenantId])
  @@map("custom_field_definitions")
}

Also add the relation to the Tenant model:

// In Tenant model, add:
customFieldDefs CustomFieldDefinition[]

Gate

cd packages/database
npx prisma format
# Should complete without errors (enums exist from Step 118)

cat prisma/schema.prisma | grep -A 20 "model CustomFieldDefinition"

Rollback

# Remove CustomFieldDefinition model from schema.prisma
# Remove customFieldDefs from Tenant model

Lock

packages/database/prisma/schema.prisma (CustomFieldDefinition model)

Checkpoint

  • CustomFieldDefinition model added
  • Tenant relation added
  • prisma format succeeds

Step 120: Add CustomFieldValue Model + Run Migration

Input

  • Step 119 complete
  • CustomFieldDefinition model exists
  • All enums exist

Constraints

  • Add CustomFieldValue model
  • Add values relation to CustomFieldDefinition
  • Run migration after all models complete

Task

Part 1: Add CustomFieldValue model

Add to packages/database/prisma/schema.prisma:

model CustomFieldValue {
  id         String     @id @default(cuid())
  tenantId   String
  fieldId    String
  entityType EntityType
  entityId   String     // employeeId, departmentId, teamId, goalId
  value      String?    // Stored as JSON-compatible string
  createdAt  DateTime   @default(now())
  updatedAt  DateTime   @updatedAt

  tenant Tenant                @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  field  CustomFieldDefinition @relation(fields: [fieldId], references: [id], onDelete: Cascade)

  @@unique([tenantId, fieldId, entityType, entityId])
  @@index([tenantId])
  @@index([tenantId, entityType, entityId])  // For loading entity custom fields
  @@map("custom_field_values")
}

Also add relations:

// In Tenant model, add:
customFieldValues CustomFieldValue[]

// In CustomFieldDefinition model, add:
values CustomFieldValue[]

Part 2: Run migration

cd packages/database
npx prisma db push
# Should create custom_field_definitions and custom_field_values tables

Gate

cd packages/database
npx prisma format
# Should complete without errors

# Check tables exist
npx prisma studio
# Should see:
# - custom_field_definitions table
# - custom_field_values table

Rollback

# Remove CustomFieldValue model from schema.prisma
# Remove customFieldValues from Tenant model
# Remove values from CustomFieldDefinition model

Lock

packages/database/prisma/schema.prisma (Custom Field system complete)

Checkpoint

  • CustomFieldValue model added
  • Tenant relation added
  • CustomFieldDefinition.values relation added
  • Migration succeeded
  • Tables visible in Prisma Studio

Step 121: Create CustomFieldService

Input

  • Step 120 complete
  • CustomField tables exist

Prerequisites

Create directories:

mkdir -p apps/api/src/custom-fields
mkdir -p apps/api/src/custom-fields/dto

Constraints

  • Follow same patterns as TagService
  • Include visibility checking based on user role

Task

Create apps/api/src/custom-fields/custom-field.repository.ts:

import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { EntityType, FieldType, FieldVisibility, Prisma } from '@prisma/client';

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

  // ==========================================
  // DEFINITION OPERATIONS
  // ==========================================

  async createDefinition(
    tenantId: string,
    data: {
      entityType: EntityType;
      fieldName: string;
      fieldType: FieldType;
      label: string;
      description?: string;
      required?: boolean;
      visibility?: FieldVisibility;
      editable?: boolean;
      options?: string[];
      validation?: Record<string, unknown>;
      section?: string;
      sortOrder?: number;
    },
  ) {
    return this.prisma.customFieldDefinition.create({
      data: {
        tenantId,
        entityType: data.entityType,
        fieldName: data.fieldName,
        fieldType: data.fieldType,
        label: data.label,
        description: data.description,
        required: data.required ?? false,
        visibility: data.visibility ?? FieldVisibility.ALL,
        editable: data.editable ?? true,
        options: data.options,
        validation: data.validation,
        section: data.section,
        sortOrder: data.sortOrder ?? 0,
      },
    });
  }

  async findDefinitionsByTenant(
    tenantId: string,
    entityType?: EntityType,
  ) {
    return this.prisma.customFieldDefinition.findMany({
      where: {
        tenantId,
        ...(entityType && { entityType }),
      },
      orderBy: [{ section: 'asc' }, { sortOrder: 'asc' }],
    });
  }

  async findDefinitionById(tenantId: string, id: string) {
    return this.prisma.customFieldDefinition.findFirst({
      where: { id, tenantId },
    });
  }

  async updateDefinition(
    tenantId: string,
    id: string,
    data: Prisma.CustomFieldDefinitionUpdateInput,
  ) {
    // Use updateMany to enforce tenant isolation
    const result = await this.prisma.customFieldDefinition.updateMany({
      where: { id, tenantId },
      data,
    });

    if (result.count === 0) {
      throw new NotFoundException(`Custom field definition ${id} not found`);
    }

    return this.findDefinitionById(tenantId, id);
  }

  async deleteDefinition(tenantId: string, id: string) {
    // Use deleteMany to enforce tenant isolation
    // Values will cascade delete due to onDelete: Cascade on CustomFieldValue.field
    const result = await this.prisma.customFieldDefinition.deleteMany({
      where: { id, tenantId },
    });

    if (result.count === 0) {
      throw new NotFoundException(`Custom field definition ${id} not found`);
    }
  }

  // ==========================================
  // VALUE OPERATIONS
  // ==========================================

  async setValue(
    tenantId: string,
    fieldId: string,
    entityType: EntityType,
    entityId: string,
    value: string | null,
  ) {
    return this.prisma.customFieldValue.upsert({
      where: {
        tenantId_fieldId_entityType_entityId: {
          tenantId,
          fieldId,
          entityType,
          entityId,
        },
      },
      update: { value },
      create: {
        tenantId,
        fieldId,
        entityType,
        entityId,
        value,
      },
    });
  }

  async getValue(
    tenantId: string,
    fieldId: string,
    entityType: EntityType,
    entityId: string,
  ) {
    return this.prisma.customFieldValue.findUnique({
      where: {
        tenantId_fieldId_entityType_entityId: {
          tenantId,
          fieldId,
          entityType,
          entityId,
        },
      },
    });
  }

  async getValuesForEntity(
    tenantId: string,
    entityType: EntityType,
    entityId: string,
  ) {
    return this.prisma.customFieldValue.findMany({
      where: {
        tenantId,
        entityType,
        entityId,
      },
      include: { field: true },
    });
  }

  async deleteValue(
    tenantId: string,
    fieldId: string,
    entityType: EntityType,
    entityId: string,
  ) {
    return this.prisma.customFieldValue.delete({
      where: {
        tenantId_fieldId_entityType_entityId: {
          tenantId,
          fieldId,
          entityType,
          entityId,
        },
      },
    });
  }
}

Create apps/api/src/custom-fields/custom-field.service.ts:

import {
  Injectable,
  NotFoundException,
  ForbiddenException,
  BadRequestException,
} from '@nestjs/common';
import { CustomFieldRepository } from './custom-field.repository';
import {
  EntityType,
  FieldType,
  FieldVisibility,
  SystemRole,
} from '@prisma/client';

@Injectable()
export class CustomFieldService {
  constructor(private repository: CustomFieldRepository) {}

  // ==========================================
  // DEFINITION OPERATIONS
  // ==========================================

  async createDefinition(
    tenantId: string,
    data: {
      entityType: EntityType;
      fieldName: string;
      fieldType: FieldType;
      label: string;
      description?: string;
      required?: boolean;
      visibility?: FieldVisibility;
      editable?: boolean;
      options?: string[];
      validation?: Record<string, unknown>;
      section?: string;
      sortOrder?: number;
    },
  ) {
    return this.repository.createDefinition(tenantId, data);
  }

  async getDefinitions(tenantId: string, entityType?: EntityType) {
    return this.repository.findDefinitionsByTenant(tenantId, entityType);
  }

  async getDefinition(tenantId: string, id: string) {
    const definition = await this.repository.findDefinitionById(tenantId, id);
    if (!definition) {
      throw new NotFoundException('Custom field definition not found');
    }
    return definition;
  }

  async updateDefinition(
    tenantId: string,
    id: string,
    data: {
      label?: string;
      description?: string;
      required?: boolean;
      visibility?: FieldVisibility;
      editable?: boolean;
      options?: string[];
      validation?: Record<string, unknown>;
      section?: string;
      sortOrder?: number;
    },
  ) {
    await this.getDefinition(tenantId, id); // Verify exists
    return this.repository.updateDefinition(tenantId, id, data);
  }

  async deleteDefinition(tenantId: string, id: string) {
    await this.getDefinition(tenantId, id); // Verify exists
    return this.repository.deleteDefinition(tenantId, id);
  }

  // ==========================================
  // VALUE OPERATIONS
  // ==========================================

  /**
   * Get visible definitions and their values for an entity
   * Filters based on user's role
   */
  async getFieldsWithValues(
    tenantId: string,
    entityType: EntityType,
    entityId: string,
    userRole: SystemRole,
  ) {
    // Get all definitions for this entity type
    const definitions = await this.repository.findDefinitionsByTenant(
      tenantId,
      entityType,
    );

    // Filter by visibility based on user role
    const visibleDefinitions = definitions.filter((def) =>
      this.canViewField(def.visibility, userRole),
    );

    // Get values for this entity
    const values = await this.repository.getValuesForEntity(
      tenantId,
      entityType,
      entityId,
    );

    // Merge definitions with values
    return visibleDefinitions.map((def) => {
      const value = values.find((v) => v.fieldId === def.id);
      return {
        ...def,
        value: value?.value ?? null,
      };
    });
  }

  async setValue(
    tenantId: string,
    fieldId: string,
    entityType: EntityType,
    entityId: string,
    value: string | null,
    userRole: SystemRole,
  ) {
    const definition = await this.getDefinition(tenantId, fieldId);

    // Check if user can edit this field
    if (!this.canEditField(definition.visibility, definition.editable, userRole)) {
      throw new ForbiddenException('You cannot edit this field');
    }

    // Validate value based on field type
    this.validateValue(definition, value);

    return this.repository.setValue(tenantId, fieldId, entityType, entityId, value);
  }

  async setMultipleValues(
    tenantId: string,
    entityType: EntityType,
    entityId: string,
    values: Array<{ fieldId: string; value: string | null }>,
    userRole: SystemRole,
  ) {
    const results = [];
    for (const { fieldId, value } of values) {
      try {
        const result = await this.setValue(
          tenantId,
          fieldId,
          entityType,
          entityId,
          value,
          userRole,
        );
        results.push(result);
      } catch (error) {
        // Continue with other fields if one fails
        console.error(`Failed to set field ${fieldId}:`, error);
      }
    }
    return results;
  }

  // ==========================================
  // VISIBILITY HELPERS
  // ==========================================

  private canViewField(visibility: FieldVisibility, userRole: SystemRole): boolean {
    switch (visibility) {
      case FieldVisibility.ALL:
        return true;
      case FieldVisibility.MANAGER_PLUS:
        return [
          SystemRole.SYSTEM_ADMIN,
          SystemRole.HR_ADMIN,
          SystemRole.MANAGER,
        ].includes(userRole);
      case FieldVisibility.HR_ADMIN_ONLY:
        return [SystemRole.SYSTEM_ADMIN, SystemRole.HR_ADMIN].includes(userRole);
      default:
        return false;
    }
  }

  private canEditField(
    visibility: FieldVisibility,
    editable: boolean,
    userRole: SystemRole,
  ): boolean {
    // SYSTEM_ADMIN and HR_ADMIN can always edit
    if ([SystemRole.SYSTEM_ADMIN, SystemRole.HR_ADMIN].includes(userRole)) {
      return true;
    }

    // If field is not editable, only admins can edit
    if (!editable) {
      return false;
    }

    // Check visibility
    return this.canViewField(visibility, userRole);
  }

  private validateValue(
    definition: { fieldType: FieldType; required: boolean; options: unknown; label: string },
    value: string | null,
  ) {
    // Check required - use BadRequestException (400) for validation errors
    if (definition.required && (value === null || value === '')) {
      throw new BadRequestException(`Field ${definition.label} is required`);
    }

    if (value === null || value === '') {
      return; // Empty value is allowed if not required
    }

    // Type-specific validation - all use BadRequestException (400)
    switch (definition.fieldType) {
      case FieldType.NUMBER:
        if (isNaN(Number(value))) {
          throw new BadRequestException(`${definition.label} must be a number`);
        }
        break;
      case FieldType.EMAIL:
        if (!value.includes('@')) {
          throw new BadRequestException(`${definition.label} must be a valid email`);
        }
        break;
      case FieldType.URL:
        try {
          new URL(value);
        } catch {
          throw new BadRequestException(`${definition.label} must be a valid URL`);
        }
        break;
      case FieldType.DROPDOWN:
        const options = definition.options as string[] | null;
        if (options && !options.includes(value)) {
          throw new BadRequestException(`${definition.label} must be one of the allowed options`);
        }
        break;
      case FieldType.CHECKBOX:
        if (!['true', 'false'].includes(value)) {
          throw new BadRequestException(`${definition.label} must be true or false`);
        }
        break;
      case FieldType.DATE:
        if (isNaN(Date.parse(value))) {
          throw new BadRequestException(`${definition.label} must be a valid date`);
        }
        break;
      // TEXT and TEXTAREA have no specific validation
    }
  }
}

Create apps/api/src/custom-fields/dto/custom-field.dto.ts:

import {
  IsString,
  IsEnum,
  IsOptional,
  IsBoolean,
  IsArray,
  IsNumber,
  IsObject,
} from 'class-validator';
import { EntityType, FieldType, FieldVisibility } from '@prisma/client';

export class CreateCustomFieldDto {
  @IsEnum(EntityType)
  entityType: EntityType;

  @IsString()
  fieldName: string;

  @IsEnum(FieldType)
  fieldType: FieldType;

  @IsString()
  label: string;

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

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

  @IsOptional()
  @IsEnum(FieldVisibility)
  visibility?: FieldVisibility;

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

  @IsOptional()
  @IsArray()
  @IsString({ each: true })
  options?: string[];

  @IsOptional()
  @IsObject()
  validation?: Record<string, unknown>;

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

  @IsOptional()
  @IsNumber()
  sortOrder?: number;
}

export class UpdateCustomFieldDto {
  @IsOptional()
  @IsString()
  label?: string;

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

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

  @IsOptional()
  @IsEnum(FieldVisibility)
  visibility?: FieldVisibility;

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

  @IsOptional()
  @IsArray()
  @IsString({ each: true })
  options?: string[];

  @IsOptional()
  @IsObject()
  validation?: Record<string, unknown>;

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

  @IsOptional()
  @IsNumber()
  sortOrder?: number;
}

export class SetFieldValueDto {
  @IsString()
  fieldId: string;

  @IsOptional()
  @IsString()
  value?: string | null;
}

export class SetMultipleValuesDto {
  @IsArray()
  values: SetFieldValueDto[];
}

Create apps/api/src/custom-fields/custom-field.controller.ts:

import {
  Controller,
  Get,
  Post,
  Put,
  Patch,
  Delete,
  Body,
  Param,
  Query,
  UseGuards,
} from '@nestjs/common';
import { CustomFieldService } from './custom-field.service';
import { TenantGuard } from '../tenant/tenant.guard';
import { TenantId } from '../tenant/tenant.decorator';
import { CurrentUser } from '../auth/current-user.decorator';
import { EntityType, SystemRole } from '@prisma/client';
import {
  CreateCustomFieldDto,
  UpdateCustomFieldDto,
  SetFieldValueDto,
  SetMultipleValuesDto,
} from './dto/custom-field.dto';

interface AuthUser {
  id: string;
  systemRole: SystemRole;
}

@Controller('api/v1/custom-fields')
@UseGuards(TenantGuard)
export class CustomFieldController {
  constructor(private service: CustomFieldService) {}

  // ==========================================
  // DEFINITION ENDPOINTS
  // ==========================================

  @Post('definitions')
  async createDefinition(
    @TenantId() tenantId: string,
    @Body() dto: CreateCustomFieldDto,
  ) {
    const definition = await this.service.createDefinition(tenantId, dto);
    return { data: definition, error: null };
  }

  @Get('definitions')
  async getDefinitions(
    @TenantId() tenantId: string,
    @Query('entityType') entityType?: EntityType,
  ) {
    const definitions = await this.service.getDefinitions(tenantId, entityType);
    return { data: definitions, error: null };
  }

  @Get('definitions/:id')
  async getDefinition(
    @TenantId() tenantId: string,
    @Param('id') id: string,
  ) {
    const definition = await this.service.getDefinition(tenantId, id);
    return { data: definition, error: null };
  }

  @Patch('definitions/:id')
  async updateDefinition(
    @TenantId() tenantId: string,
    @Param('id') id: string,
    @Body() dto: UpdateCustomFieldDto,
  ) {
    const definition = await this.service.updateDefinition(tenantId, id, dto);
    return { data: definition, error: null };
  }

  @Delete('definitions/:id')
  async deleteDefinition(
    @TenantId() tenantId: string,
    @Param('id') id: string,
  ) {
    await this.service.deleteDefinition(tenantId, id);
    return { data: { success: true }, error: null };
  }

  // ==========================================
  // VALUE ENDPOINTS
  // ==========================================

  @Get(':entityType/:entityId')
  async getFieldsWithValues(
    @TenantId() tenantId: string,
    @Param('entityType') entityType: EntityType,
    @Param('entityId') entityId: string,
    @CurrentUser() user: AuthUser,
  ) {
    const fields = await this.service.getFieldsWithValues(
      tenantId,
      entityType,
      entityId,
      user.systemRole,
    );
    return { data: fields, error: null };
  }

  @Patch(':entityType/:entityId')
  async setValues(
    @TenantId() tenantId: string,
    @Param('entityType') entityType: EntityType,
    @Param('entityId') entityId: string,
    @Body() dto: SetMultipleValuesDto,
    @CurrentUser() user: AuthUser,
  ) {
    const results = await this.service.setMultipleValues(
      tenantId,
      entityType,
      entityId,
      dto.values,
      user.systemRole,
    );
    return { data: results, error: null };
  }

  @Put(':entityType/:entityId/:fieldId')
  async setValue(
    @TenantId() tenantId: string,
    @Param('entityType') entityType: EntityType,
    @Param('entityId') entityId: string,
    @Param('fieldId') fieldId: string,
    @Body() dto: SetFieldValueDto,
    @CurrentUser() user: AuthUser,
  ) {
    const result = await this.service.setValue(
      tenantId,
      fieldId,
      entityType,
      entityId,
      dto.value ?? null,
      user.systemRole,
    );
    return { data: result, error: null };
  }
}

Create apps/api/src/custom-fields/custom-fields.module.ts:

import { Module } from '@nestjs/common';
import { CustomFieldController } from './custom-field.controller';
import { CustomFieldService } from './custom-field.service';
import { CustomFieldRepository } from './custom-field.repository';
import { PrismaModule } from '../prisma/prisma.module';

@Module({
  imports: [PrismaModule],
  controllers: [CustomFieldController],
  providers: [CustomFieldService, CustomFieldRepository],
  exports: [CustomFieldService],
})
export class CustomFieldsModule {}

Register in apps/api/src/app.module.ts:

import { CustomFieldsModule } from './custom-fields/custom-fields.module';

@Module({
  imports: [
    // ... existing modules
    CustomFieldsModule,
  ],
})
export class AppModule {}

Gate

cd apps/api
npm run build
# Should compile without errors

# Test endpoint
curl -X GET "http://localhost:3001/api/v1/custom-fields/definitions?entityType=EMPLOYEE" \
  -H "X-Tenant-ID: your-tenant-id"
# Should return { data: [], error: null }

Common Errors

ErrorCauseFix
EntityType is not definedMissing importImport from @prisma/client
Put decorator not foundMissing importAdd Put to @nestjs/common imports

Rollback

rm -rf apps/api/src/custom-fields/
# Remove CustomFieldsModule from app.module.ts imports

Lock

apps/api/src/custom-fields/*

Checkpoint

  • Repository created
  • Service created with visibility logic
  • Controller created with all endpoints
  • Module created and registered
  • API compiles successfully

Step 122: Add Custom Fields to Employee Form

Input

  • Step 121 complete
  • CustomField API working

Prerequisites

1. Ensure api.patch method exists in apps/web/lib/api.ts:

If api.patch doesn't exist (it should from Phase 05/06), add it now following the existing pattern in lib/api.ts:

Note: Ensure api.patch follows the existing pattern in apps/web/lib/api.ts. The api helper adds the /api/v1 prefix, so use /custom-fields/... not /api/v1/custom-fields/....

// In apps/web/lib/api.ts, add the patch method:
// Follow existing pattern - use API_URL constant and /api/v1 prefix
async patch<T = unknown>(path: string, body: unknown): Promise<{ data: T; error: null }> {
  const tenantId = await getTenantId();
  const response = await fetch(`${API_URL}/api/v1${path}`, {
    method: 'PATCH',
    headers: {
      'Content-Type': 'application/json',
      'x-tenant-id': tenantId,
    },
    body: JSON.stringify(body),
  });

  if (!response.ok) {
    const error = await response.json().catch(() => ({ message: 'Request failed' }));
    throw new Error(error.message || 'Request failed');
  }

  const data = await response.json();
  return { data: data.data ?? data, error: null };
}

2. Create directories:

mkdir -p apps/web/components/custom-fields

Constraints

  • Integrate with existing employee form
  • Render fields dynamically based on definitions
  • Respect visibility rules
  • Use Shadcn UI components for consistency

Task

Create apps/web/lib/queries/custom-fields.ts:

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '../api';

// Types (defined locally to avoid @prisma/client import issues)
export type EntityType = 'EMPLOYEE' | 'DEPARTMENT' | 'TEAM' | 'GOAL';
export type FieldType = 'TEXT' | 'TEXTAREA' | 'NUMBER' | 'DATE' | 'DROPDOWN' | 'CHECKBOX' | 'URL' | 'EMAIL';
export type FieldVisibility = 'ALL' | 'MANAGER_PLUS' | 'HR_ADMIN_ONLY';

export interface CustomFieldDefinition {
  id: string;
  tenantId: string;
  entityType: EntityType;
  fieldName: string;
  fieldType: FieldType;
  label: string;
  description: string | null;
  required: boolean;
  visibility: FieldVisibility;
  editable: boolean;
  options: string[] | null;
  validation: Record<string, unknown> | null;
  section: string | null;
  sortOrder: number;
}

export interface CustomFieldWithValue extends CustomFieldDefinition {
  value: string | null;
}

// Get custom field definitions
export function useCustomFieldDefinitions(entityType?: EntityType) {
  const params = entityType ? `?entityType=${entityType}` : '';
  return useQuery({
    queryKey: ['custom-field-definitions', entityType],
    queryFn: async (): Promise<CustomFieldDefinition[]> => {
      const response = await api.get<CustomFieldDefinition[]>(
        `/custom-fields/definitions${params}`
      );
      return response.data;
    },
  });
}

// Get custom fields with values for an entity
export function useCustomFieldValues(entityType: EntityType, entityId: string) {
  return useQuery({
    queryKey: ['custom-field-values', entityType, entityId],
    queryFn: async (): Promise<CustomFieldWithValue[]> => {
      const response = await api.get<CustomFieldWithValue[]>(
        `/custom-fields/${entityType}/${entityId}`
      );
      return response.data;
    },
    enabled: !!entityId,
  });
}

// Update custom field values
export function useUpdateCustomFieldValues() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async ({
      entityType,
      entityId,
      values,
    }: {
      entityType: EntityType;
      entityId: string;
      values: Array<{ fieldId: string; value: string | null }>;
    }) => {
      const response = await api.patch<unknown>(
        `/custom-fields/${entityType}/${entityId}`,
        { values }
      );
      return response.data;
    },
    onSuccess: (_, variables) => {
      queryClient.invalidateQueries({
        queryKey: ['custom-field-values', variables.entityType, variables.entityId],
      });
    },
  });
}

Create apps/web/components/custom-fields/custom-field-input.tsx:

'use client';

import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select';
import { CustomFieldWithValue } from '@/lib/queries/custom-fields';

interface CustomFieldInputProps {
  field: CustomFieldWithValue;
  value: string | null;
  onChange: (value: string | null) => void;
  disabled?: boolean;
}

export function CustomFieldInput({
  field,
  value,
  onChange,
  disabled = false,
}: CustomFieldInputProps) {
  const isDisabled = disabled || !field.editable;

  switch (field.fieldType) {
    case 'TEXT':
    case 'EMAIL':
    case 'URL':
      return (
        <Input
          type={field.fieldType === 'EMAIL' ? 'email' : field.fieldType === 'URL' ? 'url' : 'text'}
          value={value ?? ''}
          onChange={(e) => onChange(e.target.value || null)}
          disabled={isDisabled}
          placeholder={field.description ?? undefined}
        />
      );

    case 'TEXTAREA':
      return (
        <Textarea
          value={value ?? ''}
          onChange={(e) => onChange(e.target.value || null)}
          disabled={isDisabled}
          placeholder={field.description ?? undefined}
          className="min-h-[80px]"
        />
      );

    case 'NUMBER':
      return (
        <Input
          type="number"
          value={value ?? ''}
          onChange={(e) => onChange(e.target.value || null)}
          disabled={isDisabled}
        />
      );

    case 'DATE':
      return (
        <Input
          type="date"
          value={value ?? ''}
          onChange={(e) => onChange(e.target.value || null)}
          disabled={isDisabled}
        />
      );

    case 'DROPDOWN':
      return (
        <Select
          value={value ?? ''}
          onValueChange={(val) => onChange(val || null)}
          disabled={isDisabled}
        >
          <SelectTrigger>
            <SelectValue placeholder="Select..." />
          </SelectTrigger>
          <SelectContent>
            {(field.options ?? []).map((option) => (
              <SelectItem key={option} value={option}>
                {option}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
      );

    case 'CHECKBOX':
      return (
        <Checkbox
          checked={value === 'true'}
          onCheckedChange={(checked) => onChange(checked ? 'true' : 'false')}
          disabled={isDisabled}
        />
      );

    default:
      return (
        <Input
          type="text"
          value={value ?? ''}
          onChange={(e) => onChange(e.target.value || null)}
          disabled={isDisabled}
        />
      );
  }
}

Create apps/web/components/custom-fields/custom-fields-section.tsx:

'use client';

import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import {
  useCustomFieldValues,
  useUpdateCustomFieldValues,
  CustomFieldWithValue,
  EntityType,
} from '@/lib/queries/custom-fields';
import { CustomFieldInput } from './custom-field-input';

interface CustomFieldsSectionProps {
  entityType: EntityType;
  entityId: string;
  onSave?: () => void;
}

export function CustomFieldsSection({
  entityType,
  entityId,
  onSave,
}: CustomFieldsSectionProps) {
  const { data: fields, isLoading } = useCustomFieldValues(entityType, entityId);
  const updateMutation = useUpdateCustomFieldValues();
  const [localValues, setLocalValues] = useState<Record<string, string | null>>({});
  const [hasChanges, setHasChanges] = useState(false);

  // Initialize local values when fields load
  useEffect(() => {
    if (fields) {
      const initial: Record<string, string | null> = {};
      fields.forEach((f) => {
        initial[f.id] = f.value;
      });
      setLocalValues(initial);
      setHasChanges(false);
    }
  }, [fields]);

  const handleChange = (fieldId: string, value: string | null) => {
    setLocalValues((prev) => ({ ...prev, [fieldId]: value }));
    setHasChanges(true);
  };

  const handleSave = async () => {
    if (!fields) return;

    const changedValues = fields
      .filter((f) => localValues[f.id] !== f.value)
      .map((f) => ({ fieldId: f.id, value: localValues[f.id] }));

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

    await updateMutation.mutateAsync({
      entityType,
      entityId,
      values: changedValues,
    });

    setHasChanges(false);
    onSave?.();
  };

  if (isLoading) {
    return <div className="text-sm text-muted-foreground">Loading custom fields...</div>;
  }

  if (!fields || fields.length === 0) {
    return null; // No custom fields defined
  }

  // Group fields by section
  const sections = fields.reduce(
    (acc, field) => {
      const section = field.section ?? 'Other';
      if (!acc[section]) acc[section] = [];
      acc[section].push(field);
      return acc;
    },
    {} as Record<string, CustomFieldWithValue[]>
  );

  return (
    <div className="space-y-6">
      <div className="flex items-center justify-between">
        <h3 className="text-lg font-medium">Custom Fields</h3>
        {hasChanges && (
          <Button
            onClick={handleSave}
            disabled={updateMutation.isPending}
            size="sm"
          >
            {updateMutation.isPending ? 'Saving...' : 'Save Changes'}
          </Button>
        )}
      </div>

      {Object.entries(sections).map(([sectionName, sectionFields]) => (
        <div key={sectionName} className="space-y-4">
          {Object.keys(sections).length > 1 && (
            <h4 className="text-sm font-medium text-gray-700">{sectionName}</h4>
          )}
          <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
            {sectionFields.map((field) => (
              <div key={field.id} className="space-y-1">
                <Label>
                  {field.label}
                  {field.required && <span className="text-destructive ml-1">*</span>}
                </Label>
                {field.description && (
                  <p className="text-xs text-muted-foreground">{field.description}</p>
                )}
                <CustomFieldInput
                  field={field}
                  value={localValues[field.id] ?? null}
                  onChange={(value) => handleChange(field.id, value)}
                  disabled={updateMutation.isPending}
                />
              </div>
            ))}
          </div>
        </div>
      ))}
    </div>
  );
}

Usage in Employee Edit Page

In the employee edit page (apps/web/app/dashboard/employees/[id]/page.tsx), add the custom fields section:

import { CustomFieldsSection } from '@/components/custom-fields/custom-fields-section';

// Inside the component, after the main form fields:
<div className="mt-8 border-t pt-8">
  <CustomFieldsSection
    entityType="EMPLOYEE"
    entityId={employee.id}
    onSave={() => {
      // Optional: Show success message or refetch employee data
    }}
  />
</div>

Gate

cd apps/web
npm run build
# Should compile without errors

# Navigate to employee edit page
# Should see Custom Fields section if definitions exist

Common Errors

ErrorCauseFix
Module not foundWrong import pathCheck relative paths
Type error on api.patchapi.ts doesn't have patchSee Prerequisites - add patch method
Shadcn component not foundComponents not installedRun npx shadcn@latest add input textarea select checkbox button label

Rollback

rm apps/web/lib/queries/custom-fields.ts
rm -rf apps/web/components/custom-fields/
# Remove CustomFieldsSection from employee edit page

Lock

apps/web/lib/queries/custom-fields.ts
apps/web/components/custom-fields/*

Checkpoint

  • Query hooks created
  • CustomFieldInput component renders all field types
  • CustomFieldsSection groups by section and saves changes
  • Integration with employee form works
  • Frontend compiles successfully

Files Created

StepFiles
109schema.prisma (TagStatus enum)
110schema.prisma (TagCategory model)
111schema.prisma (Tag model)
112schema.prisma (TagPermission model)
113schema.prisma (EmployeeTag model)
114schema.prisma (DocumentTag model + all relations)
116-117apps/api/src/tags/*
118schema.prisma (EntityType, FieldType, FieldVisibility enums)
119schema.prisma (CustomFieldDefinition model)
120schema.prisma (CustomFieldValue model)
121apps/api/src/custom-fields/*
122apps/web/lib/queries/custom-fields.ts, apps/web/components/custom-fields/*

Phase Completion Gate

Before moving to Phase 08:

# 1. All tag tables exist
npx prisma studio
# Verify: tag_categories, tags, tag_permissions, employee_tags, document_tags

# 2. All custom field tables exist
# Verify: custom_field_definitions, custom_field_values

# 3. API compiles
cd apps/api && npm run build

# 4. Frontend compiles
cd apps/web && npm run build

# 5. Tag endpoints work
curl -X POST http://localhost:3001/api/v1/tags/categories \
  -H "X-Tenant-ID: test" \
  -H "Content-Type: application/json" \
  -d '{"name": "Skills", "assetTypes": ["employee"]}'

# 6. Custom field endpoints work
curl -X POST http://localhost:3001/api/v1/custom-fields/definitions \
  -H "X-Tenant-ID: test" \
  -H "Content-Type: application/json" \
  -d '{"entityType": "EMPLOYEE", "fieldName": "linkedin", "fieldType": "URL", "label": "LinkedIn Profile"}'

Locked Files After Phase 7

  • All Phase 6 locks, plus:
  • All tag/custom field models in schema.prisma
  • apps/api/src/tags/*
  • apps/api/src/custom-fields/*
  • apps/web/lib/queries/custom-fields.ts
  • apps/web/components/custom-fields/*

Add sidebar entry for tags management (admin only):

// In sidebar navigation
{
  name: 'Tags',
  href: '/dashboard/tags',
  icon: TagIcon,
  roles: ['SYSTEM_ADMIN', 'HR_ADMIN'],
}

Note: The /dashboard/tags page (tag categories, tag CRUD, permissions management UI) is a future enhancement. This phase provides the API endpoints only. The sidebar entry can be added when the page is implemented.



Step 123: Add Filter by Tags Endpoint (SRCH-02)

Input

  • Step 122 complete
  • Tags system exists

Constraints

  • Filter employees by assigned tags
  • Support multiple tag filter (AND/OR)
  • ONLY add filter endpoint and UI

Task

1. Add Filter by Tags to EmployeesService:

// Add to apps/api/src/employees/employees.service.ts

async findByTags(
  tenantId: string,
  tagIds: string[],
  matchAll: boolean = false,
): Promise<Employee[]> {
  if (tagIds.length === 0) {
    return this.repository.findAll(tenantId);
  }

  if (matchAll) {
    // AND: Employee must have ALL specified tags
    return this.prisma.employee.findMany({
      where: {
        tenantId,
        deletedAt: null,
        AND: tagIds.map((tagId) => ({
          tags: { some: { tagId } },
        })),
      },
      include: {
        tags: {
          include: { tag: true },
        },
      },
      orderBy: { lastName: 'asc' },
    });
  } else {
    // OR: Employee must have ANY of the specified tags
    return this.prisma.employee.findMany({
      where: {
        tenantId,
        deletedAt: null,
        tags: {
          some: {
            tagId: { in: tagIds },
          },
        },
      },
      include: {
        tags: {
          include: { tag: true },
        },
      },
      orderBy: { lastName: 'asc' },
    });
  }
}

2. Add Endpoint to EmployeesController:

// Add to apps/api/src/employees/employees.controller.ts

@Get('by-tags')
async findByTags(
  @TenantId() tenantId: string,
  @Query('tagIds') tagIds: string,
  @Query('matchAll') matchAll?: string,
) {
  const ids = tagIds ? tagIds.split(',').filter(Boolean) : [];
  const data = await this.employeesService.findByTags(
    tenantId,
    ids,
    matchAll === 'true',
  );
  return { data, error: null };
}

3. Create Tag Filter Component at apps/web/components/employees/tag-filter.tsx:

'use client';

import { useState, useEffect } from 'react';
import { useSession } from 'next-auth/react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Check, ChevronsUpDown, X, Tags } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
} from '@/components/ui/command';
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from '@/components/ui/popover';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';

interface Tag {
  id: string;
  name: string;
  color: string;
  category: { id: string; name: string };
}

interface TagFilterProps {
  tags: Tag[];
  onFilterChange: (tagIds: string[], matchAll: boolean) => void;
}

export function TagFilter({ tags, onFilterChange }: TagFilterProps) {
  const [open, setOpen] = useState(false);
  const [selectedTags, setSelectedTags] = useState<string[]>([]);
  const [matchAll, setMatchAll] = useState(false);

  useEffect(() => {
    onFilterChange(selectedTags, matchAll);
  }, [selectedTags, matchAll, onFilterChange]);

  const toggleTag = (tagId: string) => {
    setSelectedTags((current) =>
      current.includes(tagId)
        ? current.filter((id) => id !== tagId)
        : [...current, tagId]
    );
  };

  const clearAll = () => {
    setSelectedTags([]);
  };

  const selectedTagObjects = tags.filter((t) => selectedTags.includes(t.id));

  // Group tags by category
  const groupedTags = tags.reduce((acc, tag) => {
    const category = tag.category.name;
    if (!acc[category]) acc[category] = [];
    acc[category].push(tag);
    return acc;
  }, {} as Record<string, Tag[]>);

  return (
    <div className="flex items-center gap-2">
      <Popover open={open} onOpenChange={setOpen}>
        <PopoverTrigger asChild>
          <Button
            variant="outline"
            role="combobox"
            aria-expanded={open}
            className="justify-between min-w-[200px]"
          >
            <Tags className="mr-2 h-4 w-4" />
            {selectedTags.length === 0
              ? 'Filter by tags...'
              : `${selectedTags.length} tag${selectedTags.length > 1 ? 's' : ''} selected`}
            <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
          </Button>
        </PopoverTrigger>
        <PopoverContent className="w-[300px] p-0" align="start">
          <Command>
            <CommandInput placeholder="Search tags..." />
            <CommandList>
              <CommandEmpty>No tags found.</CommandEmpty>
              {Object.entries(groupedTags).map(([category, categoryTags]) => (
                <CommandGroup key={category} heading={category}>
                  {categoryTags.map((tag) => (
                    <CommandItem
                      key={tag.id}
                      value={tag.name}
                      onSelect={() => toggleTag(tag.id)}
                    >
                      <Check
                        className={cn(
                          'mr-2 h-4 w-4',
                          selectedTags.includes(tag.id)
                            ? 'opacity-100'
                            : 'opacity-0'
                        )}
                      />
                      <div
                        className="w-3 h-3 rounded-full mr-2"
                        style={{ backgroundColor: tag.color }}
                      />
                      {tag.name}
                    </CommandItem>
                  ))}
                </CommandGroup>
              ))}
            </CommandList>
          </Command>

          {selectedTags.length > 0 && (
            <div className="border-t p-2">
              <div className="flex items-center justify-between">
                <div className="flex items-center space-x-2">
                  <Switch
                    id="match-all"
                    checked={matchAll}
                    onCheckedChange={setMatchAll}
                  />
                  <Label htmlFor="match-all" className="text-sm">
                    Match all tags
                  </Label>
                </div>
                <Button variant="ghost" size="sm" onClick={clearAll}>
                  Clear
                </Button>
              </div>
            </div>
          )}
        </PopoverContent>
      </Popover>

      {/* Selected tags badges */}
      {selectedTagObjects.length > 0 && (
        <div className="flex flex-wrap gap-1">
          {selectedTagObjects.map((tag) => (
            <Badge
              key={tag.id}
              variant="secondary"
              className="gap-1"
              style={{ borderColor: tag.color }}
            >
              <div
                className="w-2 h-2 rounded-full"
                style={{ backgroundColor: tag.color }}
              />
              {tag.name}
              <button
                onClick={() => toggleTag(tag.id)}
                className="ml-1 hover:bg-muted rounded"
              >
                <X className="h-3 w-3" />
              </button>
            </Badge>
          ))}
        </div>
      )}
    </div>
  );
}

4. Add Tag Query Hook to apps/web/lib/queries/tags.ts:

import { useQuery } from '@tanstack/react-query';
import { api } from '../api';

interface Tag {
  id: string;
  name: string;
  color: string;
  category: { id: string; name: string };
}

export function useTags() {
  return useQuery({
    queryKey: ['tags'],
    queryFn: async (): Promise<Tag[]> => {
      const response = await api.get<Tag[]>('/tags');
      return response.data;
    },
  });
}

export function useEmployeesByTags(tagIds: string[], matchAll: boolean) {
  return useQuery({
    queryKey: ['employees-by-tags', tagIds, matchAll],
    queryFn: async () => {
      if (tagIds.length === 0) {
        const response = await api.get('/employees');
        return response.data;
      }
      const params = new URLSearchParams({
        tagIds: tagIds.join(','),
        matchAll: matchAll.toString(),
      });
      const response = await api.get(`/employees/by-tags?${params}`);
      return response.data;
    },
    enabled: true,
  });
}

5. Integrate with Employee List - Update apps/web/app/dashboard/employees/page.tsx:

import { TagFilter } from '@/components/employees/tag-filter';
import { useTags, useEmployeesByTags } from '@/lib/queries/tags';

// In the component:
const { data: tags } = useTags();
const [filterTagIds, setFilterTagIds] = useState<string[]>([]);
const [matchAll, setMatchAll] = useState(false);
const { data: employees, isLoading } = useEmployeesByTags(filterTagIds, matchAll);

// In the JSX (after search, before table):
<div className="flex items-center gap-4">
  {/* Existing search */}
  {tags && (
    <TagFilter
      tags={tags}
      onFilterChange={(ids, all) => {
        setFilterTagIds(ids);
        setMatchAll(all);
      }}
    />
  )}
</div>

Gate

# Test filter by tags
curl "http://localhost:3001/api/v1/employees/by-tags?tagIds=tag1,tag2&matchAll=true" \
  -H "x-tenant-id: YOUR_TENANT_ID"
# Should return employees with BOTH tags

curl "http://localhost:3001/api/v1/employees/by-tags?tagIds=tag1,tag2&matchAll=false" \
  -H "x-tenant-id: YOUR_TENANT_ID"
# Should return employees with ANY of the tags

Checkpoint

  • GET /employees/by-tags endpoint works
  • matchAll=true requires all tags
  • matchAll=false requires any tag
  • UI filter component shows tags grouped by category
  • Selected tags filter the employee list
  • Type "GATE 123 PASSED" to continue


Phase Completion Checklist (MANDATORY)

BEFORE MOVING TO NEXT PHASE

Complete ALL items before proceeding. Do NOT skip any step.

1. Gate Verification

  • All step gates passed
  • Tag management working
  • Custom fields CRUD working
  • Field validation working

2. Update PROJECT_STATE.md

- Mark Phase 07 as COMPLETED with timestamp
- Update "Current Phase" to Phase 08
- Add session log entry

3. Update WHAT_EXISTS.md

## Database Models
- Tag, CustomField, CustomFieldValue

## API Endpoints
- /api/v1/tags/*
- /api/v1/custom-fields/*

4. Git Tag & Commit

git add PROJECT_STATE.md WHAT_EXISTS.md
git commit -m "chore: complete Phase 07 - Tags & Custom Fields"
git tag phase-07-tags-custom-fields

Next Phase

After verification, proceed to Phase 08: Dashboard


Last Updated: 2025-11-30

On this page

Phase 07: Tags & Custom FieldsStep Timing EstimatesPhase Context (READ FIRST)What This Phase AccomplishesAsset TypesWhat This Phase Does NOT IncludeBluewoo Anti-Pattern ReminderStep 109: Add TagStatus EnumInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 110: Add TagCategory ModelInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 111: Add Tag ModelInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 112: Add TagPermission ModelInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 113: Add EmployeeTag JunctionInputConstraintsTaskGateRollbackLockCheckpointStep 114: Add DocumentTag Junction + Complete All RelationsInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 115: Run Migration for TagsInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 116: Create TagServiceInputPrerequisitesConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 117: Create TagControllerInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 118: Add Custom Field EnumsInputConstraintsTaskGateRollbackLockCheckpointStep 119: Add CustomFieldDefinition ModelInputConstraintsTaskGateRollbackLockCheckpointStep 120: Add CustomFieldValue Model + Run MigrationInputConstraintsTaskGateRollbackLockCheckpointStep 121: Create CustomFieldServiceInputPrerequisitesConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 122: Add Custom Fields to Employee FormInputPrerequisitesConstraintsTaskUsage in Employee Edit PageGateCommon ErrorsRollbackLockCheckpointFiles CreatedPhase Completion GateLocked Files After Phase 7NavigationStep 123: Add Filter by Tags Endpoint (SRCH-02)InputConstraintsTaskGateCheckpointRelated DocumentationPhase Completion Checklist (MANDATORY)1. Gate Verification2. Update PROJECT_STATE.md3. Update WHAT_EXISTS.md4. Git Tag & CommitNext Phase