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).
| Attribute | Value |
|---|---|
| Steps | 109-122 |
| Estimated Time | 7-10 hours |
| Dependencies | Phase 06 complete (Document model, TanStack Query available) |
| Completion Gate | Tags can be created and assigned to employees/documents. Custom fields can be defined and values stored per entity. |
Step Timing Estimates
| Step | Task | Est. Time |
|---|---|---|
| 109 | Add TagStatus enum | 10 min |
| 110 | Add TagCategory model (no relations) | 15 min |
| 111 | Add Tag model (no relations) | 15 min |
| 112 | Add TagPermission model | 15 min |
| 113 | Add EmployeeTag junction | 15 min |
| 114 | Add DocumentTag junction + ALL relations | 20 min |
| 115 | Run Tag migration | 15 min |
| 116 | Create TagService | 40 min |
| 117 | Create TagController | 35 min |
| 118 | Add EntityType, FieldType, FieldVisibility enums | 15 min |
| 119 | Add CustomFieldDefinition model | 20 min |
| 120 | Add CustomFieldValue model + Run migration | 20 min |
| 121 | Create CustomFieldService | 45 min |
| 122 | Add custom fields to employee form | 40 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 ARCHIVEDCommon Errors
| Error | Cause | Fix |
|---|---|---|
Enum already exists | Duplicate definition | Remove duplicate |
Rollback
# Remove TagStatus enum from schema.prismaLock
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 relationCommon Errors
| Error | Cause | Fix |
|---|---|---|
Relation not found on Tenant | Missing relation field | Add tagCategories TagCategory[] to Tenant |
Rollback
# Remove TagCategory model from schema.prisma
# Remove tagCategories from Tenant modelLock
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,documentsrelations 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
| Error | Cause | Fix |
|---|---|---|
Unknown type TagStatus | Enum not added in Step 109 | Go back to Step 109 |
Rollback
# Remove Tag model from schema.prisma
# Remove tags from Tenant model
# Remove tags from TagCategory modelLock
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
| Error | Cause | Fix |
|---|---|---|
Unknown type SystemRole | Enum not in schema | Verify Phase 01 added SystemRole enum |
Rollback
# Remove TagPermission model from schema.prisma
# Remove tagPermissions from Tenant modelLock
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 modelLock
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 relationsCommon Errors
| Error | Cause | Fix |
|---|---|---|
Relation not found | Missing back-relation | Ensure both sides of relation exist |
Rollback
# Remove DocumentTag model
# Remove permissions/employees/documents from Tag model
# Remove tags from Document modelLock
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 pushGate
# Check tables exist
npx prisma studio
# Should see:
# - tag_categories table
# - tags table
# - tag_permissions table
# - employee_tags table
# - document_tags tableCommon Errors
| Error | Cause | Fix |
|---|---|---|
Foreign key constraint failed | Missing referenced table | Check Document/Employee models exist |
Unique constraint failed | Data issue | Ensure no duplicate migrations |
Rollback
# Drop the created tables manually or reset database
cd packages/database
npx prisma db push --force-reset # WARNING: Destroys all dataLock
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, EMPLOYEECreate directories:
mkdir -p apps/api/src/tags
mkdir -p apps/api/src/tags/dtoConstraints
- 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.tsCommon Errors
| Error | Cause | Fix |
|---|---|---|
Cannot find module '@prisma/client' | Prisma not generated | Run npx prisma generate in database package |
Property 'tagCategory' does not exist | Prisma client outdated | Regenerate Prisma client |
Rollback
rm -rf apps/api/src/tags/Lock
apps/api/src/tags/tag.repository.ts
apps/api/src/tags/tag.service.tsCheckpoint
- 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/tagsroute 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
| Error | Cause | Fix |
|---|---|---|
Cannot find module '../tenant/tenant.guard' | Wrong path | Check path matches Phase 01 structure |
Cannot find module '../auth/current-user.decorator' | Wrong path | Check path matches Phase 01 structure |
TagsModule is not registered | Missing import | Add 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 importsLock
apps/api/src/tags/dto/tag.dto.ts
apps/api/src/tags/tag.controller.ts
apps/api/src/tags/tags.module.tsCheckpoint
- 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.prismaLock
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 modelLock
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 tablesGate
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 tableRollback
# Remove CustomFieldValue model from schema.prisma
# Remove customFieldValues from Tenant model
# Remove values from CustomFieldDefinition modelLock
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/dtoConstraints
- 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
| Error | Cause | Fix |
|---|---|---|
EntityType is not defined | Missing import | Import from @prisma/client |
Put decorator not found | Missing import | Add Put to @nestjs/common imports |
Rollback
rm -rf apps/api/src/custom-fields/
# Remove CustomFieldsModule from app.module.ts importsLock
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.patchfollows the existing pattern inapps/web/lib/api.ts. The api helper adds the/api/v1prefix, 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-fieldsConstraints
- 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 existCommon Errors
| Error | Cause | Fix |
|---|---|---|
Module not found | Wrong import path | Check relative paths |
Type error on api.patch | api.ts doesn't have patch | See Prerequisites - add patch method |
Shadcn component not found | Components not installed | Run 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 pageLock
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
| Step | Files |
|---|---|
| 109 | schema.prisma (TagStatus enum) |
| 110 | schema.prisma (TagCategory model) |
| 111 | schema.prisma (Tag model) |
| 112 | schema.prisma (TagPermission model) |
| 113 | schema.prisma (EmployeeTag model) |
| 114 | schema.prisma (DocumentTag model + all relations) |
| 116-117 | apps/api/src/tags/* |
| 118 | schema.prisma (EntityType, FieldType, FieldVisibility enums) |
| 119 | schema.prisma (CustomFieldDefinition model) |
| 120 | schema.prisma (CustomFieldValue model) |
| 121 | apps/api/src/custom-fields/* |
| 122 | apps/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.tsapps/web/components/custom-fields/*
Navigation
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/tagspage (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 tagsCheckpoint
- 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
Related Documentation
- Database Schema - Tag and CustomField models
- Phase 06: Document Management - DocumentTag usage
- Phase 02: Employee Entity - Employee form integration
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 entry3. 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-fieldsNext Phase
After verification, proceed to Phase 08: Dashboard
Last Updated: 2025-11-30