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.tsTag 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
TagPermissionmodel 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
assetTypesfield controls which entities can use tags
3. Color Inheritance
- Tags inherit color from category if not specified
- Allows consistent visual grouping