Bluewoo HRMS

Extension Guide

How to extend the HRMS with new features

Extension Guide

Overview

Follow established patterns when extending the HRMS:

  1. Backend: Define entity → Create repository → (Optional: Service) → Add controller
  2. Frontend: Create page → Build components → Add forms → Integrate API

Two Patterns

Simple Pattern (Controller → Repository)

Use for: Simple CRUD without business logic

  • Direct repository calls from controller
  • Minimal boilerplate
  • Fast to implement

Full Pattern (Controller → Service → Repository)

Use for: Operations with business logic

  • Service handles validation, coordination, rules
  • Background jobs (BullMQ)
  • External service calls
  • Complex transactions

Decision Flow

QuestionAnswerPattern
Simple CRUD only?YesSimple
Business logic needed?YesFull
Multiple repository calls?YesFull
Background jobs?YesFull
Default-Simple

Start simple, add service layer when needed.

Adding a New Entity

Backend Steps

  1. Prisma Schema: Add model with tenantId, timestamps, soft delete
  2. Migration: npx prisma migrate dev --name add_entity
  3. DTOs: Create/Update DTOs with validation
  4. Repository: CRUD methods, always filter by tenantId
  5. Controller: REST endpoints with guards and decorators
  6. Module: Register and import in AppModule

Frontend Steps

  1. Types: TypeScript interfaces for entity
  2. API Client: Fetch functions for each endpoint
  3. Page: Server Component with data fetching
  4. Components: List, forms, detail views

Critical Rules

Always Include

  • tenantId on every entity
  • tenantId filter in every query
  • @UseGuards(AuthGuard) on controllers
  • @TenantId() decorator for tenant context
  • Soft deletes (deletedAt field)
  • Validation on DTOs

Naming Conventions

  • EntityController, EntityService, EntityRepository
  • CreateEntityDto, UpdateEntityDto
  • entity.controller.ts, entity.repository.ts

Code Examples

Controller Example

// src/modules/skills/skills.controller.ts
import {
  Controller,
  Get,
  Post,
  Put,
  Delete,
  Body,
  Param,
  Query,
  UseGuards,
  ParseUUIDPipe,
  HttpCode,
  HttpStatus,
} from '@nestjs/common'
import { SkillsRepository } from './skills.repository'
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'
import { PermissionsGuard } from '../auth/guards/permissions.guard'
import { RequirePermissions } from '../auth/decorators/permissions.decorator'
import { CurrentUser } from '../auth/decorators/current-user.decorator'
import { TenantId } from '../auth/decorators/tenant-id.decorator'
import { CreateSkillDto } from './dto/create-skill.dto'
import { UpdateSkillDto } from './dto/update-skill.dto'

@Controller('skills')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class SkillsController {
  constructor(private readonly repository: SkillsRepository) {}

  @Post()
  @RequirePermissions('skills:create')
  async create(
    @TenantId() tenantId: string,
    @CurrentUser() user: any,
    @Body() dto: CreateSkillDto,
  ) {
    return this.repository.create({
      ...dto,
      tenant: { connect: { id: tenantId } },
      createdBy: { connect: { id: user.id } },
    })
  }

  @Get()
  @RequirePermissions('skills:read')
  async findAll(
    @TenantId() tenantId: string,
    @Query('search') search?: string,
    @Query('page') page?: string,
    @Query('limit') limit?: string,
  ) {
    const pageNum = parseInt(page || '1', 10)
    const limitNum = Math.min(parseInt(limit || '20', 10), 100)

    return this.repository.findAll(tenantId, {
      search,
      skip: (pageNum - 1) * limitNum,
      take: limitNum,
    })
  }

  @Get(':id')
  @RequirePermissions('skills:read')
  async findOne(
    @TenantId() tenantId: string,
    @Param('id', ParseUUIDPipe) id: string,
  ) {
    return this.repository.findById(id, tenantId)
  }

  @Put(':id')
  @RequirePermissions('skills:update')
  async update(
    @TenantId() tenantId: string,
    @Param('id', ParseUUIDPipe) id: string,
    @Body() dto: UpdateSkillDto,
  ) {
    return this.repository.update(id, tenantId, dto)
  }

  @Delete(':id')
  @RequirePermissions('skills:delete')
  @HttpCode(HttpStatus.NO_CONTENT)
  async delete(
    @TenantId() tenantId: string,
    @Param('id', ParseUUIDPipe) id: string,
  ) {
    await this.repository.softDelete(id, tenantId)
  }
}

Repository Example

// src/modules/skills/skills.repository.ts
import { Injectable } from '@nestjs/common'
import { PrismaService } from '../../prisma/prisma.service'
import { Prisma, Skill } from '@prisma/client'

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

  async create(data: Prisma.SkillCreateInput): Promise<Skill> {
    return this.prisma.skill.create({ data })
  }

  async findById(id: string, tenantId: string): Promise<Skill | null> {
    return this.prisma.skill.findFirst({
      where: { id, tenantId, deletedAt: null },
    })
  }

  async findAll(
    tenantId: string,
    options?: { search?: string; skip?: number; take?: number },
  ): Promise<{ data: Skill[]; total: number }> {
    const where: Prisma.SkillWhereInput = {
      tenantId,
      deletedAt: null,
      ...(options?.search && {
        name: { contains: options.search, mode: 'insensitive' },
      }),
    }

    const [data, total] = await Promise.all([
      this.prisma.skill.findMany({
        where,
        orderBy: { name: 'asc' },
        skip: options?.skip ?? 0,
        take: options?.take ?? 20,
      }),
      this.prisma.skill.count({ where }),
    ])

    return { data, total }
  }

  async update(id: string, tenantId: string, data: Prisma.SkillUpdateInput): Promise<Skill> {
    return this.prisma.skill.update({
      where: { id, tenantId },
      data,
    })
  }

  async softDelete(id: string, tenantId: string): Promise<void> {
    await this.prisma.skill.update({
      where: { id, tenantId },
      data: { deletedAt: new Date() },
    })
  }
}

DTOs with Validation

// src/modules/skills/dto/create-skill.dto.ts
import { IsString, IsNotEmpty, IsOptional, MaxLength } from 'class-validator'

export class CreateSkillDto {
  @IsString()
  @IsNotEmpty()
  @MaxLength(100)
  name: string

  @IsString()
  @IsOptional()
  @MaxLength(500)
  description?: string

  @IsString()
  @IsOptional()
  category?: string
}

// src/modules/skills/dto/update-skill.dto.ts
import { PartialType } from '@nestjs/mapped-types'
import { CreateSkillDto } from './create-skill.dto'

export class UpdateSkillDto extends PartialType(CreateSkillDto) {}

Module Registration

// src/modules/skills/skills.module.ts
import { Module } from '@nestjs/common'
import { SkillsController } from './skills.controller'
import { SkillsRepository } from './skills.repository'

@Module({
  controllers: [SkillsController],
  providers: [SkillsRepository],
  exports: [SkillsRepository],
})
export class SkillsModule {}

// Register in AppModule
// src/app.module.ts
import { SkillsModule } from './modules/skills/skills.module'

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

Frontend Form with React Hook Form + Zod

// components/forms/skill-form.tsx
'use client'

import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'

const skillSchema = z.object({
  name: z.string().min(1, 'Name is required').max(100),
  description: z.string().max(500).optional(),
  category: z.string().optional(),
})

type SkillFormData = z.infer<typeof skillSchema>

interface SkillFormProps {
  onSubmit: (data: SkillFormData) => Promise<void>
  defaultValues?: Partial<SkillFormData>
  isLoading?: boolean
}

export function SkillForm({ onSubmit, defaultValues, isLoading }: SkillFormProps) {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<SkillFormData>({
    resolver: zodResolver(skillSchema),
    defaultValues,
  })

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
      <div>
        <Input
          {...register('name')}
          placeholder="Skill name"
          aria-invalid={!!errors.name}
        />
        {errors.name && (
          <p className="text-sm text-red-500 mt-1">{errors.name.message}</p>
        )}
      </div>

      <div>
        <Textarea
          {...register('description')}
          placeholder="Description (optional)"
          rows={3}
        />
        {errors.description && (
          <p className="text-sm text-red-500 mt-1">{errors.description.message}</p>
        )}
      </div>

      <div>
        <Input
          {...register('category')}
          placeholder="Category (optional)"
        />
      </div>

      <Button type="submit" disabled={isLoading}>
        {isLoading ? 'Saving...' : 'Save Skill'}
      </Button>
    </form>
  )
}

Basic Unit Test

// src/modules/skills/skills.controller.spec.ts
import { Test, TestingModule } from '@nestjs/testing'
import { SkillsController } from './skills.controller'
import { SkillsRepository } from './skills.repository'

describe('SkillsController', () => {
  let controller: SkillsController
  let repository: jest.Mocked<SkillsRepository>

  const mockUser = { id: 'user-1', tenantId: 'tenant-1' }
  const mockSkill = {
    id: 'skill-1',
    name: 'TypeScript',
    description: 'JavaScript with types',
    tenantId: 'tenant-1',
    createdAt: new Date(),
    updatedAt: new Date(),
    deletedAt: null,
  }

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      controllers: [SkillsController],
      providers: [
        {
          provide: SkillsRepository,
          useValue: {
            create: jest.fn(),
            findById: jest.fn(),
            findAll: jest.fn(),
            update: jest.fn(),
            softDelete: jest.fn(),
          },
        },
      ],
    }).compile()

    controller = module.get<SkillsController>(SkillsController)
    repository = module.get(SkillsRepository)
  })

  describe('create', () => {
    it('should create a skill', async () => {
      repository.create.mockResolvedValue(mockSkill)

      const result = await controller.create(
        'tenant-1',
        mockUser,
        { name: 'TypeScript', description: 'JavaScript with types' },
      )

      expect(result).toEqual(mockSkill)
      expect(repository.create).toHaveBeenCalled()
    })
  })

  describe('findAll', () => {
    it('should return skills with pagination', async () => {
      repository.findAll.mockResolvedValue({ data: [mockSkill], total: 1 })

      const result = await controller.findAll('tenant-1', undefined, '1', '20')

      expect(result.data).toHaveLength(1)
      expect(result.total).toBe(1)
    })
  })
})

Checklist for New Feature

  • Prisma schema with tenantId
  • Migration created and applied
  • DTOs with validation
  • Repository with tenant filtering
  • Controller with guards
  • Module registered
  • Frontend types
  • API client functions
  • Frontend page
  • Tests written

AI Chat Enablement Checklist

Every new feature should be accessible via AI Chat. When adding a feature, also implement:

Required Steps

  • Define AI Tool: Add tool definition to AI Chat specification

    • Tool name (e.g., feature_create, feature_search)
    • Parameters with types and descriptions
    • Required vs optional parameters
  • Map to API: Connect tool to backend endpoint

    • Ensure endpoint is accessible by AI Service
    • Handle authentication via service token
  • Permission Check: Define who can use the tool

    • Role requirements (Employee, Manager, Admin)
    • Resource-level permissions
  • Confirmation Flow: For write operations

    • Define confirmation card content
    • Show what will be created/modified
  • Result Widget: Define success display

    • Summary of what was done
    • Link to created/modified resource
    • Any relevant metrics

Example Tool Definition

{
  name: 'feature_action',
  description: 'Brief description of what this tool does',
  parameters: {
    type: 'object',
    properties: {
      param1: { type: 'string', description: 'Description' },
      param2: { type: 'number', description: 'Description' }
    },
    required: ['param1']
  }
}

Quick Reference

Feature TypeAI Tool PatternConfirmation Needed
Create entityentity_createYes
Search/listentity_searchNo
Update entityentity_updateYes
Delete entityentity_deleteYes
Read-only queryentity_getNo

Full Specification: See AI Chat Specification for complete tool definitions and implementation patterns.


Refer to existing modules as examples