Bluewoo HRMS
AI Development GuideEntity References

Reference - Tags Implementation

Complete NestJS tag system implementation with role-based permissions

Reference: Tags Implementation

This is the complete Tag system implementation, demonstrating role-based tag assignment with permission checking.

Module Structure

apps/api/src/modules/tag/
├── tag.module.ts
├── tag.controller.ts
├── tag.repository.ts
├── tag.service.ts
├── dto/
│   ├── create-tag-category.dto.ts
│   ├── create-tag.dto.ts
│   ├── assign-tag.dto.ts
│   └── tag-response.dto.ts
└── tag.controller.spec.ts

Tag Module

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

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

Tag Controller

// tag.controller.ts
import {
  Controller,
  Get,
  Post,
  Put,
  Delete,
  Body,
  Param,
  Query,
  HttpCode,
  HttpStatus,
  ParseUUIDPipe,
} from '@nestjs/common';
import { TagService } from './tag.service';
import { TagRepository } from './tag.repository';
import { CreateTagCategoryDto } from './dto/create-tag-category.dto';
import { CreateTagDto } from './dto/create-tag.dto';
import { AssignTagDto } from './dto/assign-tag.dto';
import { TagCategoryResponseDto, TagResponseDto } from './dto/tag-response.dto';
import { TenantId } from '../../common/decorators/tenant.decorator';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { RequirePermissions } from '../../common/decorators/permissions.decorator';
import { User } from '@prisma/client';

@Controller('tags')
export class TagController {
  constructor(
    private readonly tagRepository: TagRepository,
    private readonly tagService: TagService,
  ) {}

  // ==================== CATEGORIES ====================

  /**
   * List all tag categories
   */
  @Get('categories')
  @RequirePermissions('tags:read')
  async listCategories(
    @TenantId() tenantId: string,
  ): Promise<TagCategoryResponseDto[]> {
    const categories = await this.tagRepository.findAllCategories(tenantId);
    return categories.map((cat) => new TagCategoryResponseDto(cat));
  }

  /**
   * Create a new tag category
   */
  @Post('categories')
  @RequirePermissions('tags:manage')
  @HttpCode(HttpStatus.CREATED)
  async createCategory(
    @TenantId() tenantId: string,
    @Body() dto: CreateTagCategoryDto,
  ): Promise<TagCategoryResponseDto> {
    const category = await this.tagRepository.createCategory(tenantId, dto);
    return new TagCategoryResponseDto(category);
  }

  /**
   * Delete a tag category
   */
  @Delete('categories/:id')
  @RequirePermissions('tags:manage')
  @HttpCode(HttpStatus.NO_CONTENT)
  async deleteCategory(
    @TenantId() tenantId: string,
    @Param('id', ParseUUIDPipe) id: string,
  ): Promise<void> {
    await this.tagRepository.deleteCategory(tenantId, id);
  }

  // ==================== TAGS ====================

  /**
   * List all tags with optional filters
   */
  @Get()
  @RequirePermissions('tags:read')
  async listTags(
    @TenantId() tenantId: string,
    @Query('categoryId') categoryId?: string,
    @Query('status') status?: string,
    @Query('search') search?: string,
  ): Promise<TagResponseDto[]> {
    const tags = await this.tagRepository.findAllTags(tenantId, {
      categoryId,
      status,
      search,
    });
    return tags.map((tag) => new TagResponseDto(tag));
  }

  /**
   * Create a new tag
   */
  @Post()
  @RequirePermissions('tags:manage')
  @HttpCode(HttpStatus.CREATED)
  async createTag(
    @TenantId() tenantId: string,
    @Body() dto: CreateTagDto,
  ): Promise<TagResponseDto> {
    const tag = await this.tagRepository.createTag(tenantId, dto);
    return new TagResponseDto(tag);
  }

  /**
   * Update a tag
   */
  @Put(':id')
  @RequirePermissions('tags:manage')
  async updateTag(
    @TenantId() tenantId: string,
    @Param('id', ParseUUIDPipe) id: string,
    @Body() dto: Partial<CreateTagDto>,
  ): Promise<TagResponseDto> {
    const tag = await this.tagRepository.updateTag(tenantId, id, dto);
    return new TagResponseDto(tag);
  }

  /**
   * Archive a tag (soft delete)
   */
  @Delete(':id')
  @RequirePermissions('tags:manage')
  @HttpCode(HttpStatus.NO_CONTENT)
  async archiveTag(
    @TenantId() tenantId: string,
    @Param('id', ParseUUIDPipe) id: string,
  ): Promise<void> {
    await this.tagRepository.archiveTag(tenantId, id);
  }

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

  /**
   * Assign a tag to an entity (employee, document, goal)
   */
  @Post('assign')
  @RequirePermissions('tags:assign')
  async assignTag(
    @TenantId() tenantId: string,
    @CurrentUser() user: User,
    @Body() dto: AssignTagDto,
  ): Promise<void> {
    await this.tagService.assignTag(tenantId, user, dto);
  }

  /**
   * Remove a tag from an entity
   */
  @Delete('assign')
  @RequirePermissions('tags:assign')
  @HttpCode(HttpStatus.NO_CONTENT)
  async removeTag(
    @TenantId() tenantId: string,
    @CurrentUser() user: User,
    @Body() dto: AssignTagDto,
  ): Promise<void> {
    await this.tagService.removeTag(tenantId, user, dto);
  }

  /**
   * Get all tags for an entity
   */
  @Get('entity/:entityType/:entityId')
  @RequirePermissions('tags:read')
  async getEntityTags(
    @TenantId() tenantId: string,
    @Param('entityType') entityType: string,
    @Param('entityId', ParseUUIDPipe) entityId: string,
  ): Promise<TagResponseDto[]> {
    const tags = await this.tagRepository.findTagsByEntity(
      tenantId,
      entityType as 'employee' | 'document' | 'goal',
      entityId,
    );
    return tags.map((tag) => new TagResponseDto(tag));
  }

  /**
   * Search entities by tag
   */
  @Get('search')
  @RequirePermissions('tags:read')
  async searchByTag(
    @TenantId() tenantId: string,
    @Query('tagId', ParseUUIDPipe) tagId: string,
    @Query('entityType') entityType?: string,
  ): Promise<{ entityType: string; entityId: string }[]> {
    return this.tagRepository.findEntitiesByTag(tenantId, tagId, entityType);
  }
}

Tag Repository

// tag.repository.ts
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { TagCategory, Tag, TagStatus, Prisma } from '@prisma/client';
import { CreateTagCategoryDto } from './dto/create-tag-category.dto';
import { CreateTagDto } from './dto/create-tag.dto';

interface FindTagsOptions {
  categoryId?: string;
  status?: string;
  search?: string;
}

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

  // ==================== CATEGORIES ====================

  async findAllCategories(tenantId: string): Promise<TagCategory[]> {
    return this.prisma.tagCategory.findMany({
      where: { tenantId },
      orderBy: { name: 'asc' },
    });
  }

  async findCategoryById(tenantId: string, id: string): Promise<TagCategory> {
    const category = await this.prisma.tagCategory.findFirst({
      where: { id, tenantId },
    });

    if (!category) {
      throw new NotFoundException(`Tag category ${id} not found`);
    }

    return category;
  }

  async createCategory(
    tenantId: string,
    dto: CreateTagCategoryDto,
  ): Promise<TagCategory> {
    // Check for duplicate name
    const existing = await this.prisma.tagCategory.findUnique({
      where: {
        tenantId_name: { tenantId, name: dto.name },
      },
    });

    if (existing) {
      throw new ConflictException(`Tag category "${dto.name}" already exists`);
    }

    return this.prisma.tagCategory.create({
      data: {
        tenantId,
        name: dto.name,
        assetTypes: dto.assetTypes || ['employee'],
        color: dto.color,
      },
    });
  }

  async deleteCategory(tenantId: string, id: string): Promise<void> {
    await this.findCategoryById(tenantId, id);

    // Check if category has tags
    const tagCount = await this.prisma.tag.count({
      where: { categoryId: id },
    });

    if (tagCount > 0) {
      throw new ConflictException(
        `Cannot delete category with ${tagCount} existing tags`,
      );
    }

    await this.prisma.tagCategory.delete({
      where: { id },
    });
  }

  // ==================== TAGS ====================

  async findAllTags(tenantId: string, options: FindTagsOptions): Promise<Tag[]> {
    const where: Prisma.TagWhereInput = { tenantId };

    if (options.categoryId) {
      where.categoryId = options.categoryId;
    }

    if (options.status) {
      where.status = options.status as TagStatus;
    }

    if (options.search) {
      where.OR = [
        { name: { contains: options.search, mode: 'insensitive' } },
        { description: { contains: options.search, mode: 'insensitive' } },
      ];
    }

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

  async findTagById(tenantId: string, id: string): Promise<Tag> {
    const tag = await this.prisma.tag.findFirst({
      where: { id, tenantId },
      include: { category: true },
    });

    if (!tag) {
      throw new NotFoundException(`Tag ${id} not found`);
    }

    return tag;
  }

  async createTag(tenantId: string, dto: CreateTagDto): Promise<Tag> {
    // Verify category exists
    await this.findCategoryById(tenantId, dto.categoryId);

    // Check for duplicate name in category
    const existing = await this.prisma.tag.findUnique({
      where: {
        tenantId_categoryId_name: {
          tenantId,
          categoryId: dto.categoryId,
          name: dto.name,
        },
      },
    });

    if (existing) {
      throw new ConflictException(
        `Tag "${dto.name}" already exists in this category`,
      );
    }

    return this.prisma.tag.create({
      data: {
        tenantId,
        categoryId: dto.categoryId,
        name: dto.name,
        color: dto.color,
        description: dto.description,
        status: 'ACTIVE',
      },
      include: { category: true },
    });
  }

  async updateTag(
    tenantId: string,
    id: string,
    dto: Partial<CreateTagDto>,
  ): Promise<Tag> {
    await this.findTagById(tenantId, id);

    return this.prisma.tag.update({
      where: { id },
      data: {
        ...(dto.name && { name: dto.name }),
        ...(dto.color !== undefined && { color: dto.color }),
        ...(dto.description !== undefined && { description: dto.description }),
      },
      include: { category: true },
    });
  }

  async archiveTag(tenantId: string, id: string): Promise<Tag> {
    await this.findTagById(tenantId, id);

    return this.prisma.tag.update({
      where: { id },
      data: { status: 'ARCHIVED' },
      include: { category: true },
    });
  }

  // ==================== TAG ASSIGNMENTS ====================

  async assignTagToEmployee(
    tagId: string,
    employeeId: string,
    assignedBy: string,
  ): Promise<void> {
    await this.prisma.employeeTag.upsert({
      where: {
        employeeId_tagId: { employeeId, tagId },
      },
      create: {
        employeeId,
        tagId,
        assignedBy,
      },
      update: {},
    });
  }

  async removeTagFromEmployee(tagId: string, employeeId: string): Promise<void> {
    await this.prisma.employeeTag.deleteMany({
      where: { employeeId, tagId },
    });
  }

  async assignTagToDocument(
    tagId: string,
    documentId: string,
    assignedBy: string,
  ): Promise<void> {
    await this.prisma.documentTag.upsert({
      where: {
        documentId_tagId: { documentId, tagId },
      },
      create: {
        documentId,
        tagId,
        assignedBy,
      },
      update: {},
    });
  }

  async removeTagFromDocument(tagId: string, documentId: string): Promise<void> {
    await this.prisma.documentTag.deleteMany({
      where: { documentId, tagId },
    });
  }

  async findTagsByEntity(
    tenantId: string,
    entityType: 'employee' | 'document' | 'goal',
    entityId: string,
  ): Promise<Tag[]> {
    if (entityType === 'employee') {
      const employeeTags = await this.prisma.employeeTag.findMany({
        where: { employeeId: entityId },
        include: { tag: { include: { category: true } } },
      });
      return employeeTags.map((et) => et.tag);
    }

    if (entityType === 'document') {
      const documentTags = await this.prisma.documentTag.findMany({
        where: { documentId: entityId },
        include: { tag: { include: { category: true } } },
      });
      return documentTags.map((dt) => dt.tag);
    }

    // For goals, we'd need a GoalTag model - simplified for now
    return [];
  }

  async findEntitiesByTag(
    tenantId: string,
    tagId: string,
    entityType?: string,
  ): Promise<{ entityType: string; entityId: string }[]> {
    const results: { entityType: string; entityId: string }[] = [];

    if (!entityType || entityType === 'employee') {
      const employeeTags = await this.prisma.employeeTag.findMany({
        where: { tagId },
        select: { employeeId: true },
      });
      results.push(
        ...employeeTags.map((et) => ({
          entityType: 'employee',
          entityId: et.employeeId,
        })),
      );
    }

    if (!entityType || entityType === 'document') {
      const documentTags = await this.prisma.documentTag.findMany({
        where: { tagId },
        select: { documentId: true },
      });
      results.push(
        ...documentTags.map((dt) => ({
          entityType: 'document',
          entityId: dt.documentId,
        })),
      );
    }

    return results;
  }

  // ==================== PERMISSIONS ====================

  async getTagPermission(
    tenantId: string,
    tagId: string,
    role: string,
  ): Promise<{ canAssign: boolean; canRemove: boolean }> {
    const permission = await this.prisma.tagPermission.findUnique({
      where: {
        tenantId_tagId_role: {
          tenantId,
          tagId,
          role: role as any,
        },
      },
    });

    // Default: allow if no specific permission exists
    return {
      canAssign: permission?.canAssign ?? true,
      canRemove: permission?.canRemove ?? true,
    };
  }
}

Tag Service (Permission Checking)

// tag.service.ts
import { Injectable, ForbiddenException } from '@nestjs/common';
import { TagRepository } from './tag.repository';
import { AssignTagDto } from './dto/assign-tag.dto';
import { User } from '@prisma/client';

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

  async assignTag(
    tenantId: string,
    user: User,
    dto: AssignTagDto,
  ): Promise<void> {
    // Check permission
    const permission = await this.tagRepository.getTagPermission(
      tenantId,
      dto.tagId,
      user.systemRole,
    );

    if (!permission.canAssign) {
      throw new ForbiddenException(
        `Role ${user.systemRole} cannot assign this tag`,
      );
    }

    // Verify tag exists and is active
    const tag = await this.tagRepository.findTagById(tenantId, dto.tagId);
    if (tag.status !== 'ACTIVE') {
      throw new ForbiddenException('Cannot assign archived tag');
    }

    // Assign based on entity type
    switch (dto.entityType) {
      case 'employee':
        await this.tagRepository.assignTagToEmployee(
          dto.tagId,
          dto.entityId,
          user.id,
        );
        break;
      case 'document':
        await this.tagRepository.assignTagToDocument(
          dto.tagId,
          dto.entityId,
          user.id,
        );
        break;
      default:
        throw new ForbiddenException(`Unknown entity type: ${dto.entityType}`);
    }
  }

  async removeTag(
    tenantId: string,
    user: User,
    dto: AssignTagDto,
  ): Promise<void> {
    // Check permission
    const permission = await this.tagRepository.getTagPermission(
      tenantId,
      dto.tagId,
      user.systemRole,
    );

    if (!permission.canRemove) {
      throw new ForbiddenException(
        `Role ${user.systemRole} cannot remove this tag`,
      );
    }

    // Remove based on entity type
    switch (dto.entityType) {
      case 'employee':
        await this.tagRepository.removeTagFromEmployee(dto.tagId, dto.entityId);
        break;
      case 'document':
        await this.tagRepository.removeTagFromDocument(dto.tagId, dto.entityId);
        break;
      default:
        throw new ForbiddenException(`Unknown entity type: ${dto.entityType}`);
    }
  }

  /**
   * Bulk assign tags to multiple entities
   */
  async bulkAssignTags(
    tenantId: string,
    user: User,
    tagIds: string[],
    entityType: 'employee' | 'document',
    entityIds: string[],
  ): Promise<{ success: number; failed: number }> {
    let success = 0;
    let failed = 0;

    for (const tagId of tagIds) {
      for (const entityId of entityIds) {
        try {
          await this.assignTag(tenantId, user, {
            tagId,
            entityType,
            entityId,
          });
          success++;
        } catch {
          failed++;
        }
      }
    }

    return { success, failed };
  }
}

DTOs

Create Tag Category DTO

// dto/create-tag-category.dto.ts
import { IsString, IsOptional, IsArray, MinLength, MaxLength, Matches } from 'class-validator';

export class CreateTagCategoryDto {
  @IsString()
  @MinLength(1)
  @MaxLength(50)
  name: string;

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

  @IsOptional()
  @IsString()
  @Matches(/^#[0-9A-Fa-f]{6}$/, { message: 'Color must be a valid hex color' })
  color?: string;
}

Create Tag DTO

// dto/create-tag.dto.ts
import { IsString, IsOptional, IsUUID, MinLength, MaxLength, Matches } from 'class-validator';

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

  @IsString()
  @MinLength(1)
  @MaxLength(50)
  name: string;

  @IsOptional()
  @IsString()
  @Matches(/^#[0-9A-Fa-f]{6}$/, { message: 'Color must be a valid hex color' })
  color?: string;

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

Assign Tag DTO

// dto/assign-tag.dto.ts
import { IsString, IsUUID, IsEnum } from 'class-validator';

export enum EntityType {
  EMPLOYEE = 'employee',
  DOCUMENT = 'document',
  GOAL = 'goal',
}

export class AssignTagDto {
  @IsUUID()
  tagId: string;

  @IsEnum(EntityType)
  entityType: EntityType;

  @IsUUID()
  entityId: string;
}

Tag Response DTO

// dto/tag-response.dto.ts
import { TagCategory, Tag, TagStatus } from '@prisma/client';

export class TagCategoryResponseDto {
  id: string;
  name: string;
  assetTypes: string[];
  color: string | null;
  createdAt: Date;

  constructor(category: TagCategory) {
    this.id = category.id;
    this.name = category.name;
    this.assetTypes = category.assetTypes;
    this.color = category.color;
    this.createdAt = category.createdAt;
  }
}

export class TagResponseDto {
  id: string;
  categoryId: string;
  categoryName: string;
  name: string;
  color: string | null;
  description: string | null;
  status: TagStatus;
  createdAt: Date;

  constructor(tag: Tag & { category?: TagCategory }) {
    this.id = tag.id;
    this.categoryId = tag.categoryId;
    this.categoryName = tag.category?.name || '';
    this.name = tag.name;
    this.color = tag.color || tag.category?.color || null;
    this.description = tag.description;
    this.status = tag.status;
    this.createdAt = tag.createdAt;
  }
}

Key Patterns

1. Role-Based Tag Permissions

  • TagPermission model controls who can assign/remove tags
  • Service layer checks permissions before assignment
  • Default behavior: allow if no specific permission exists

2. Multi-Entity Tag Support

  • Tags can be applied to employees, documents, goals
  • Separate junction tables for each entity type
  • Category assetTypes field controls which entities can use tags

3. Color Inheritance

  • Tags inherit color from category if not specified
  • Allows consistent visual grouping

Next Steps