Extension Guide
How to extend the HRMS with new features
Extension Guide
Overview
Follow established patterns when extending the HRMS:
- Backend: Define entity → Create repository → (Optional: Service) → Add controller
- 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
| Question | Answer | Pattern |
|---|---|---|
| Simple CRUD only? | Yes | Simple |
| Business logic needed? | Yes | Full |
| Multiple repository calls? | Yes | Full |
| Background jobs? | Yes | Full |
| Default | - | Simple |
Start simple, add service layer when needed.
Adding a New Entity
Backend Steps
- Prisma Schema: Add model with
tenantId, timestamps, soft delete - Migration:
npx prisma migrate dev --name add_entity - DTOs: Create/Update DTOs with validation
- Repository: CRUD methods, always filter by
tenantId - Controller: REST endpoints with guards and decorators
- Module: Register and import in AppModule
Frontend Steps
- Types: TypeScript interfaces for entity
- API Client: Fetch functions for each endpoint
- Page: Server Component with data fetching
- Components: List, forms, detail views
Critical Rules
Always Include
tenantIdon every entitytenantIdfilter in every query@UseGuards(AuthGuard)on controllers@TenantId()decorator for tenant context- Soft deletes (
deletedAtfield) - Validation on DTOs
Naming Conventions
EntityController,EntityService,EntityRepositoryCreateEntityDto,UpdateEntityDtoentity.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
- Tool name (e.g.,
-
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 Type | AI Tool Pattern | Confirmation Needed |
|---|---|---|
| Create entity | entity_create | Yes |
| Search/list | entity_search | No |
| Update entity | entity_update | Yes |
| Delete entity | entity_delete | Yes |
| Read-only query | entity_get | No |
Full Specification: See AI Chat Specification for complete tool definitions and implementation patterns.
Refer to existing modules as examples