Phase 02: Employee Entity
Complete Employee CRUD with tenant isolation and soft delete
Phase 02: Employee Entity
Goal: Implement complete Employee management with tenant-isolated CRUD operations.
Architecture Note
This phase uses the Service pattern because it includes business logic (duplicate email check, pagination formatting). For simpler CRUD without business logic, use the Controller → Prisma pattern directly (see patterns.mdx).
| Attribute | Value |
|---|---|
| Steps | 25-42 |
| Estimated Time | 6-8 hours |
| Dependencies | Phase 01 complete (auth working, tenant context available) |
| Completion Gate | Employee CRUD works via API and frontend, data is tenant-isolated, soft delete implemented |
Step Timing Estimates
| Step | Task | Est. Time |
|---|---|---|
| 25 | Add EmploymentType enum | 5 min |
| 26 | Add WorkMode enum | 5 min |
| 27 | Add EmployeeStatus enum | 5 min |
| 28 | Add Employee model | 10 min |
| 29 | Link User to Employee | 5 min |
| 30 | Run migration | 5 min |
| 31 | Create CreateEmployeeDto | 15 min |
| 32 | Create UpdateEmployeeDto | 5 min |
| 33 | Create EmployeeRepository | 20 min |
| 34 | Create EmployeeService | 20 min |
| 35 | Create EmployeeController | 15 min |
| 36 | Register EmployeeModule | 10 min |
| 37 | Test Employee CRUD via curl | 15 min |
| 38 | Create employee list page | 30 min |
| 39 | Create employee form component | 30 min |
| 40 | Create new employee page | 20 min |
| 41 | Create edit employee page | 20 min |
| 42 | Add delete functionality | 15 min |
Phase Context (READ FIRST)
What This Phase Accomplishes
- Employee model with all core fields in database
- User ↔ Employee optional 1:1 relationship
- Backend CRUD (DTOs, Repository, Service, Controller)
- Frontend pages (list, create, edit, delete)
- Tenant isolation on all employee queries
- Soft delete via EmployeeStatus (not hard delete)
What This Phase Does NOT Include
- Organization structure (Department, Team, Manager) - that's Phase 03
- Manager assignment or reporting structure - Phase 03
- Time-off or documents - Phase 05/06
- Skills, tags, or custom fields - Phase 07
- Complex filtering or search - Future enhancement
Known Limitations (MVP)
Search Architecture
Current Implementation:
- Employee search:
GET /api/v1/employees?search=query(Phase 02) - Document search:
GET /api/v1/documents?search=query(Phase 06) - AI-powered search: Natural language via chat interface (Phase 09)
Not included in MVP:
- Unified global search bar searching all entities simultaneously
- Cross-entity search results (employees + documents + teams in one query)
- Dedicated search UI component with combined results
Future Enhancement: Create GlobalSearch component that queries multiple APIs in parallel and displays categorized results.
Bluewoo Anti-Pattern Reminder
This phase intentionally has NO:
- Generic CRUD base classes (explicit repositories are clearer)
- Complex ORM abstractions (simple Prisma queries)
- Org relations (Department/Team/Manager) - Phase 03
- Hard delete (use EmployeeStatus.TERMINATED)
- Form validation libraries beyond class-validator
If the AI suggests adding any of these, REJECT and continue with the spec.
Step 25: Add EmploymentType Enum
Input
- Phase 01 complete
- Database running with Tenant, User, Auth.js tables
- Schema at:
packages/database/prisma/schema.prisma
Constraints
- DO NOT add any models yet (just enum)
- DO NOT run migrations yet
- DO NOT add other enums yet (one step at a time)
- ONLY modify:
packages/database/prisma/schema.prisma
Task
Add EmploymentType enum to schema.prisma (after existing enums):
// Add after UserStatus enum
enum EmploymentType {
FULL_TIME
PART_TIME
CONTRACTOR
INTERN
TEMPORARY
}Gate
cat packages/database/prisma/schema.prisma | grep -A 6 "enum EmploymentType"
# Should show: FULL_TIME, PART_TIME, CONTRACTOR, INTERN, TEMPORARYCommon Errors
| Error | Cause | Fix |
|---|---|---|
Enum not found | Not added to schema | Add enum definition |
Duplicate enum | Enum already exists | Remove duplicate |
Rollback
# Remove EmploymentType enum from schema.prisma manuallyLock
- No files locked yet (schema not pushed)
Checkpoint
Before proceeding to Step 26:
- EmploymentType enum in schema.prisma
- Contains all 5 values
- Type "GATE 25 PASSED" to continue
Files Created/Modified This Step
| Action | File |
|---|---|
| Modified | packages/database/prisma/schema.prisma |
Step 26: Add WorkMode Enum
Input
- Step 25 complete
- EmploymentType enum exists
Constraints
- DO NOT add Employee model yet
- DO NOT run migrations yet
- ONLY modify:
packages/database/prisma/schema.prisma
Task
Add WorkMode enum to schema.prisma (after EmploymentType):
enum WorkMode {
ONSITE
REMOTE
HYBRID
}Gate
cat packages/database/prisma/schema.prisma | grep -A 4 "enum WorkMode"
# Should show: ONSITE, REMOTE, HYBRIDCommon Errors
| Error | Cause | Fix |
|---|---|---|
Enum not found | Not added to schema | Add enum definition |
Rollback
# Remove WorkMode enum from schema.prisma manuallyLock
- No files locked yet
Checkpoint
Before proceeding to Step 27:
- WorkMode enum in schema.prisma
- Contains all 3 values
- Type "GATE 26 PASSED" to continue
Files Created/Modified This Step
| Action | File |
|---|---|
| Modified | packages/database/prisma/schema.prisma |
Step 27: Add EmployeeStatus Enum
Input
- Step 26 complete
- WorkMode enum exists
Constraints
- DO NOT add Employee model yet
- DO NOT run migrations yet
- ONLY modify:
packages/database/prisma/schema.prisma
Task
Add EmployeeStatus enum to schema.prisma (after WorkMode):
enum EmployeeStatus {
ACTIVE
INACTIVE
ON_LEAVE
TERMINATED
}Gate
cat packages/database/prisma/schema.prisma | grep -A 5 "enum EmployeeStatus"
# Should show: ACTIVE, INACTIVE, ON_LEAVE, TERMINATEDCommon Errors
| Error | Cause | Fix |
|---|---|---|
Enum not found | Not added to schema | Add enum definition |
Rollback
# Remove EmployeeStatus enum from schema.prisma manuallyLock
- No files locked yet
Checkpoint
Before proceeding to Step 28:
- EmployeeStatus enum in schema.prisma
- Contains all 4 values
- Type "GATE 27 PASSED" to continue
Files Created/Modified This Step
| Action | File |
|---|---|
| Modified | packages/database/prisma/schema.prisma |
Step 28: Add Employee Model (Full Schema)
Input
- Step 27 complete
- All 3 employee-related enums exist
Constraints
- DO NOT add User relation yet (that's Step 29)
- DO NOT add org relations (Department, Team, Manager) - that's Phase 03
- DO NOT run migrations yet
- ONLY modify:
packages/database/prisma/schema.prisma
Task
Add Employee model to schema.prisma (after enums, before Auth.js models):
// Phase 02: Employee
model Employee {
id String @id @default(cuid())
tenantId String
employeeNumber String?
// Personal Info
firstName String
lastName String
email String
phone String?
pictureUrl String?
// Job Information
jobTitle String?
jobFamily String?
jobLevel String?
// Employment Details
employmentType EmploymentType @default(FULL_TIME)
workMode WorkMode @default(ONSITE)
status EmployeeStatus @default(ACTIVE)
hireDate DateTime?
terminationDate DateTime?
// Metadata
customFields Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@unique([tenantId, email])
@@unique([tenantId, employeeNumber])
@@index([tenantId, status])
@@map("employees")
}Also add to Tenant model:
model Tenant {
// ... existing fields ...
users User[]
employees Employee[] // ADD THIS LINE
@@map("tenants")
}Gate
cat packages/database/prisma/schema.prisma | grep -A 35 "model Employee"
# Should show Employee model with all fields
# Should show: tenantId, firstName, lastName, email, etc.
# Should show: @@unique([tenantId, email])
# Should show: @@unique([tenantId, employeeNumber])Common Errors
| Error | Cause | Fix |
|---|---|---|
Unknown type "EmploymentType" | Enum not defined | Check Step 25 complete |
Unknown type "Tenant" | Tenant model missing | Verify Phase 01 schema |
Field "employees" references missing model | Employee after Tenant | Move Employee model definition |
Rollback
# Remove Employee model and `employees Employee[]` from TenantLock
- No files locked yet (schema not pushed)
Checkpoint
Before proceeding to Step 29:
- Employee model has all fields from database-schema.mdx
- Tenant relation defined
- Both unique constraints present
- Index on [tenantId, status]
- Type "GATE 28 PASSED" to continue
Files Created/Modified This Step
| Action | File |
|---|---|
| Modified | packages/database/prisma/schema.prisma |
Step 29: Link User to Employee (Optional 1:1)
Input
- Step 28 complete
- Employee model exists
Constraints
- DO NOT change any Employee fields
- DO NOT run migrations yet
- ONLY modify: User model in
packages/database/prisma/schema.prisma
Task
Update User model to add optional Employee relation:
model User {
id String @id @default(cuid())
tenantId String?
email String
emailVerified DateTime?
name String?
image String?
systemRole SystemRole @default(EMPLOYEE)
status UserStatus @default(ACTIVE)
employeeId String? @unique // ADD THIS LINE
lastLoginAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade)
employee Employee? @relation(fields: [employeeId], references: [id]) // ADD THIS LINE
accounts Account[]
sessions Session[]
@@unique([email])
@@index([tenantId])
@@map("users")
}Update Employee model to add User back-reference:
model Employee {
// ... existing fields ...
// Relations
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
user User? // ADD THIS LINE - back-reference from User.employee
// ... existing constraints ...
}Gate
# Validate schema compiles
cd packages/database && npx prisma validate
# Should succeed with no errors
# Check User has employeeId
cat packages/database/prisma/schema.prisma | grep "employeeId"
# Should show: employeeId String? @uniqueCommon Errors
| Error | Cause | Fix |
|---|---|---|
Prisma validation error | Missing back-reference | Add user User? to Employee |
Ambiguous relation | Multiple relations without name | Add explicit relation names if needed |
Rollback
# Remove employeeId from User
# Remove user from EmployeeLock
- No files locked yet
Checkpoint
Before proceeding to Step 30:
- User.employeeId exists (String? @unique)
- User.employee relation exists
- Employee.user back-reference exists
-
npx prisma validatesucceeds - Type "GATE 29 PASSED" to continue
Files Created/Modified This Step
| Action | File |
|---|---|
| Modified | packages/database/prisma/schema.prisma |
Step 30: Run Migration
Input
- Step 29 complete
- Schema validates successfully
- PostgreSQL running
Constraints
- DO NOT modify schema after push
- DO NOT add seed data yet
- ONLY run: database commands
Task
cd packages/database
# Push schema to database
npm run db:push
# Regenerate Prisma client
npm run db:generate
# Verify tables exist
npm run db:studioGate
# Check Employee table exists
cd packages/database && npx prisma db execute --stdin <<< "SELECT table_name FROM information_schema.tables WHERE table_name = 'employees';"
# Should return: employees
# Or open Prisma Studio and verify employees table is visible
npm run db:studioCommon Errors
| Error | Cause | Fix |
|---|---|---|
P1001: Can't reach database | PostgreSQL not running | docker compose up -d |
Unique constraint failed | Data conflicts | docker compose down -v && docker compose up -d |
Migration failed | Schema syntax error | Fix schema, re-run push |
Rollback
# Reset database completely
docker compose down -v
docker compose up -d
cd packages/database
git checkout -- prisma/schema.prisma
npm run db:pushLock
After this step, these files are locked:
packages/database/prisma/schema.prisma(Employee model, enums)
Checkpoint
Before proceeding to Step 31:
-
npm run db:pushsucceeded -
npm run db:generatesucceeded - Prisma Studio shows
employeestable - Users table has
employeeIdcolumn - Type "GATE 30 PASSED" to continue
Files Created/Modified This Step
| Action | File |
|---|---|
| Modified | packages/database/prisma/schema.prisma (locked) |
| Generated | packages/database/node_modules/.prisma/client/ |
Step 31: Create CreateEmployeeDto
Input
- Step 30 complete
- Employee model exists in database
- NestJS API running
Constraints
- DO NOT create service or controller yet
- DO NOT add org-related fields (departmentId, managerId)
- ONLY create: DTO file with class-validator decorators
Task
cd apps/api
# Install validation dependencies
npm install class-validator class-transformer
# Create DTO directory
mkdir -p src/employees/dtoCreate apps/api/src/employees/dto/create-employee.dto.ts:
import {
IsString,
IsEmail,
IsOptional,
IsEnum,
IsDateString,
IsObject,
MinLength,
MaxLength,
} from 'class-validator';
export enum EmploymentType {
FULL_TIME = 'FULL_TIME',
PART_TIME = 'PART_TIME',
CONTRACTOR = 'CONTRACTOR',
INTERN = 'INTERN',
TEMPORARY = 'TEMPORARY',
}
export enum WorkMode {
ONSITE = 'ONSITE',
REMOTE = 'REMOTE',
HYBRID = 'HYBRID',
}
export enum EmployeeStatus {
ACTIVE = 'ACTIVE',
INACTIVE = 'INACTIVE',
ON_LEAVE = 'ON_LEAVE',
TERMINATED = 'TERMINATED',
}
export class CreateEmployeeDto {
@IsOptional()
@IsString()
@MaxLength(50)
employeeNumber?: string;
@IsString()
@MinLength(1)
@MaxLength(100)
firstName: string;
@IsString()
@MinLength(1)
@MaxLength(100)
lastName: string;
@IsEmail()
email: string;
@IsOptional()
@IsString()
@MaxLength(20)
phone?: string;
@IsOptional()
@IsString()
pictureUrl?: string;
@IsOptional()
@IsString()
@MaxLength(100)
jobTitle?: string;
@IsOptional()
@IsString()
@MaxLength(50)
jobFamily?: string;
@IsOptional()
@IsString()
@MaxLength(20)
jobLevel?: string;
@IsOptional()
@IsEnum(EmploymentType)
employmentType?: EmploymentType;
@IsOptional()
@IsEnum(WorkMode)
workMode?: WorkMode;
@IsOptional()
@IsEnum(EmployeeStatus)
status?: EmployeeStatus;
@IsOptional()
@IsDateString()
hireDate?: string;
@IsOptional()
@IsDateString()
terminationDate?: string;
@IsOptional()
@IsObject()
customFields?: Record<string, unknown>;
}Gate
# Verify file exists with correct exports
cat apps/api/src/employees/dto/create-employee.dto.ts | grep "export class CreateEmployeeDto"
# Should show: export class CreateEmployeeDto
# Verify all required fields have decorators
cat apps/api/src/employees/dto/create-employee.dto.ts | grep -c "@Is"
# Should show at least 10 decorator usagesCommon Errors
| Error | Cause | Fix |
|---|---|---|
Cannot find module 'class-validator' | Not installed | cd apps/api && npm install class-validator class-transformer |
Duplicate identifier | Enum already imported | Use Prisma-generated enums or local enums |
Rollback
rm apps/api/src/employees/dto/create-employee.dto.tsLock
- No files locked yet
Checkpoint
Before proceeding to Step 32:
- CreateEmployeeDto file exists
- All required fields have @IsString/@IsEmail decorators
- Optional fields have @IsOptional
- Enums defined with @IsEnum
- Type "GATE 31 PASSED" to continue
Files Created/Modified This Step
| Action | File |
|---|---|
| Created | apps/api/src/employees/dto/create-employee.dto.ts |
Step 32: Create UpdateEmployeeDto
Input
- Step 31 complete
- CreateEmployeeDto exists
Constraints
- DO NOT create service or controller yet
- DO NOT duplicate validation logic
- ONLY create: UpdateEmployeeDto using PartialType
Task
cd apps/api
# Install mapped-types for PartialType
npm install @nestjs/mapped-typesCreate apps/api/src/employees/dto/update-employee.dto.ts:
import { PartialType } from '@nestjs/mapped-types';
import { CreateEmployeeDto } from './create-employee.dto';
export class UpdateEmployeeDto extends PartialType(CreateEmployeeDto) {}Create apps/api/src/employees/dto/index.ts (barrel export):
export * from './create-employee.dto';
export * from './update-employee.dto';Gate
# Verify both DTOs export correctly
cat apps/api/src/employees/dto/index.ts
# Should show exports for both DTOs
# Verify UpdateEmployeeDto extends PartialType
cat apps/api/src/employees/dto/update-employee.dto.ts | grep "PartialType"
# Should show: extends PartialType(CreateEmployeeDto)Common Errors
| Error | Cause | Fix |
|---|---|---|
Cannot find module '@nestjs/mapped-types' | Not installed | cd apps/api && npm install @nestjs/mapped-types |
Rollback
rm apps/api/src/employees/dto/update-employee.dto.ts
rm apps/api/src/employees/dto/index.tsLock
- No files locked yet
Checkpoint
Before proceeding to Step 33:
- UpdateEmployeeDto extends PartialType
- index.ts exports both DTOs
- No TypeScript errors
- Type "GATE 32 PASSED" to continue
Files Created/Modified This Step
| Action | File |
|---|---|
| Created | apps/api/src/employees/dto/update-employee.dto.ts |
| Created | apps/api/src/employees/dto/index.ts |
Step 33: Create EmployeeRepository
Input
- Step 32 complete
- DTOs exist
- PrismaService available from Phase 00/01
Constraints
- DO NOT create service or controller yet
- DO NOT add org filtering (department, team)
- ALL queries MUST filter by tenantId
- ONLY create: Repository with tenant-isolated queries
Task
Create apps/api/src/employees/employee.repository.ts:
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateEmployeeDto, UpdateEmployeeDto } from './dto';
import { Prisma, EmployeeStatus } from '@prisma/client';
@Injectable()
export class EmployeeRepository {
constructor(private prisma: PrismaService) {}
async create(tenantId: string, data: CreateEmployeeDto) {
return this.prisma.employee.create({
data: {
...data,
tenantId,
hireDate: data.hireDate ? new Date(data.hireDate) : null,
terminationDate: data.terminationDate ? new Date(data.terminationDate) : null,
},
});
}
async findAll(
tenantId: string,
options?: {
status?: EmployeeStatus;
search?: string;
skip?: number;
take?: number;
},
) {
const where: Prisma.EmployeeWhereInput = {
tenantId,
// Exclude TERMINATED by default (soft delete)
status: options?.status ?? { not: EmployeeStatus.TERMINATED },
};
if (options?.search) {
where.OR = [
{ firstName: { contains: options.search, mode: 'insensitive' } },
{ lastName: { contains: options.search, mode: 'insensitive' } },
{ email: { contains: options.search, mode: 'insensitive' } },
{ employeeNumber: { contains: options.search, mode: 'insensitive' } },
];
}
const [employees, total] = await Promise.all([
this.prisma.employee.findMany({
where,
skip: options?.skip,
take: options?.take,
orderBy: { createdAt: 'desc' },
}),
this.prisma.employee.count({ where }),
]);
return { employees, total };
}
async findById(tenantId: string, id: string) {
return this.prisma.employee.findFirst({
where: {
id,
tenantId,
},
});
}
async findByEmail(tenantId: string, email: string) {
return this.prisma.employee.findFirst({
where: {
tenantId,
email,
},
});
}
async update(tenantId: string, id: string, data: UpdateEmployeeDto) {
return this.prisma.employee.updateMany({
where: {
id,
tenantId,
},
data: {
...data,
hireDate: data.hireDate ? new Date(data.hireDate) : undefined,
terminationDate: data.terminationDate ? new Date(data.terminationDate) : undefined,
},
});
}
async softDelete(tenantId: string, id: string) {
return this.prisma.employee.updateMany({
where: {
id,
tenantId,
},
data: {
status: EmployeeStatus.TERMINATED,
terminationDate: new Date(),
},
});
}
async hardDelete(tenantId: string, id: string) {
return this.prisma.employee.deleteMany({
where: {
id,
tenantId,
},
});
}
}Gate
# Verify file exists with all methods
cat apps/api/src/employees/employee.repository.ts | grep "async "
# Should show: create, findAll, findById, findByEmail, update, softDelete, hardDelete
# Verify ALL methods filter by tenantId
grep -c "tenantId" apps/api/src/employees/employee.repository.ts
# Should show at least 7 (one per method)Common Errors
| Error | Cause | Fix |
|---|---|---|
Cannot find module '../prisma/prisma.service' | Wrong import path | Verify PrismaService location |
Property 'employee' does not exist | Prisma client not regenerated | Run npm run db:generate |
Type 'string' is not assignable | Date conversion needed | Use new Date(data.hireDate) |
Rollback
rm apps/api/src/employees/employee.repository.tsLock
- No files locked yet
Checkpoint
Before proceeding to Step 34:
- Repository has all CRUD methods
- ALL methods filter by tenantId
- softDelete sets status to TERMINATED
- findAll excludes TERMINATED by default
- Type "GATE 33 PASSED" to continue
Files Created/Modified This Step
| Action | File |
|---|---|
| Created | apps/api/src/employees/employee.repository.ts |
Step 34: Create EmployeeService
Input
- Step 33 complete
- EmployeeRepository exists
Constraints
- DO NOT create controller yet
- DO NOT add complex business logic
- ONLY create: Service with basic CRUD operations
Task
Create apps/api/src/employees/employee.service.ts:
import {
Injectable,
NotFoundException,
ConflictException,
} from '@nestjs/common';
import { EmployeeRepository } from './employee.repository';
import { CreateEmployeeDto, UpdateEmployeeDto, EmployeeStatus } from './dto';
@Injectable()
export class EmployeeService {
constructor(private employeeRepository: EmployeeRepository) {}
async create(tenantId: string, dto: CreateEmployeeDto) {
// Check for duplicate email within tenant
const existing = await this.employeeRepository.findByEmail(tenantId, dto.email);
if (existing) {
throw new ConflictException('Employee with this email already exists');
}
return this.employeeRepository.create(tenantId, dto);
}
async findAll(
tenantId: string,
options?: {
status?: EmployeeStatus;
search?: string;
page?: number;
limit?: number;
},
) {
const page = options?.page ?? 1;
const limit = options?.limit ?? 20;
const skip = (page - 1) * limit;
const { employees, total } = await this.employeeRepository.findAll(tenantId, {
status: options?.status,
search: options?.search,
skip,
take: limit,
});
return {
data: employees,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
async findById(tenantId: string, id: string) {
const employee = await this.employeeRepository.findById(tenantId, id);
if (!employee) {
throw new NotFoundException('Employee not found');
}
return employee;
}
async update(tenantId: string, id: string, dto: UpdateEmployeeDto) {
// Verify employee exists
await this.findById(tenantId, id);
// Check email uniqueness if email is being updated
if (dto.email) {
const existing = await this.employeeRepository.findByEmail(tenantId, dto.email);
if (existing && existing.id !== id) {
throw new ConflictException('Employee with this email already exists');
}
}
await this.employeeRepository.update(tenantId, id, dto);
return this.findById(tenantId, id);
}
async delete(tenantId: string, id: string) {
// Verify employee exists
await this.findById(tenantId, id);
// Soft delete by default
await this.employeeRepository.softDelete(tenantId, id);
return { success: true };
}
}Gate
# Verify file exists with all methods
cat apps/api/src/employees/employee.service.ts | grep "async "
# Should show: create, findAll, findById, update, delete
# Verify proper exception handling
grep -c "throw new" apps/api/src/employees/employee.service.ts
# Should show at least 3 (NotFoundException, ConflictException)Common Errors
| Error | Cause | Fix |
|---|---|---|
Cannot find module './employee.repository' | File not created | Complete Step 33 |
Property 'findById' is private | Method visibility | Make repository methods public |
Rollback
rm apps/api/src/employees/employee.service.tsLock
- No files locked yet
Checkpoint
Before proceeding to Step 35:
- Service has all CRUD methods
- Proper NotFoundException for missing employees
- ConflictException for duplicate emails
- Pagination in findAll
- Type "GATE 34 PASSED" to continue
Files Created/Modified This Step
| Action | File |
|---|---|
| Created | apps/api/src/employees/employee.service.ts |
Step 35: Create EmployeeController
Input
- Step 34 complete
- EmployeeService exists
- TenantGuard exists from Phase 01
Constraints
- DO NOT add complex auth beyond TenantGuard
- DO NOT add file upload endpoints
- ONLY create: Controller with standard REST endpoints
Task
Create apps/api/src/employees/employee.controller.ts:
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import { EmployeeService } from './employee.service';
import { CreateEmployeeDto, UpdateEmployeeDto } from './dto';
import { EmployeeStatus } from '@prisma/client';
import { TenantGuard } from '../common/guards';
import { TenantId } from '../common/decorators';
@Controller('api/v1/employees')
@UseGuards(TenantGuard)
export class EmployeeController {
constructor(private employeeService: EmployeeService) {}
@Post()
async create(
@TenantId() tenantId: string,
@Body() dto: CreateEmployeeDto,
) {
const employee = await this.employeeService.create(tenantId, dto);
return { data: employee, error: null };
}
@Get()
async findAll(
@TenantId() tenantId: string,
@Query('page') page?: string,
@Query('limit') limit?: string,
@Query('status') status?: EmployeeStatus,
@Query('search') search?: string,
) {
const result = await this.employeeService.findAll(tenantId, {
page: page ? parseInt(page, 10) : undefined,
limit: limit ? parseInt(limit, 10) : undefined,
status,
search,
});
return { data: result.data, meta: result.meta, error: null };
}
@Get(':id')
async findById(
@TenantId() tenantId: string,
@Param('id') id: string,
) {
const employee = await this.employeeService.findById(tenantId, id);
return { data: employee, error: null };
}
@Patch(':id')
async update(
@TenantId() tenantId: string,
@Param('id') id: string,
@Body() dto: UpdateEmployeeDto,
) {
const employee = await this.employeeService.update(tenantId, id, dto);
return { data: employee, error: null };
}
@Delete(':id')
async delete(
@TenantId() tenantId: string,
@Param('id') id: string,
) {
await this.employeeService.delete(tenantId, id);
return { data: null, error: null };
}
}Gate
# Verify file exists with all endpoints
cat apps/api/src/employees/employee.controller.ts | grep "@"
# Should show: @Controller, @UseGuards, @Post, @Get (x2), @Patch, @Delete
# Verify TenantGuard is applied
grep "TenantGuard" apps/api/src/employees/employee.controller.ts
# Should show: @UseGuards(TenantGuard)Common Errors
| Error | Cause | Fix |
|---|---|---|
Cannot find module '../guards/tenant.guard' | Wrong path | Verify TenantGuard location from Phase 01 |
No response mapped | Missing return | Add return statement to all methods |
Rollback
rm apps/api/src/employees/employee.controller.tsLock
- No files locked yet
Checkpoint
Before proceeding to Step 36:
- Controller has POST/GET/PATCH/DELETE endpoints
- TenantGuard applied at controller level
- tenantId via @TenantId() decorator (consistent with Phase 01)
- Consistent response format:
{ data, error } - Type "GATE 35 PASSED" to continue
Files Created/Modified This Step
| Action | File |
|---|---|
| Created | apps/api/src/employees/employee.controller.ts |
Step 36: Register EmployeeModule
Input
- Step 35 complete
- All Employee files created
Constraints
- DO NOT modify existing modules beyond imports
- ONLY create: Module file and update AppModule
Task
Create apps/api/src/employees/employee.module.ts:
import { Module } from '@nestjs/common';
import { EmployeeController } from './employee.controller';
import { EmployeeService } from './employee.service';
import { EmployeeRepository } from './employee.repository';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [EmployeeController],
providers: [EmployeeService, EmployeeRepository],
exports: [EmployeeService],
})
export class EmployeeModule {}Create apps/api/src/employees/index.ts (barrel export):
export * from './employee.module';
export * from './employee.service';
export * from './employee.repository';
export * from './dto';Update apps/api/src/app.module.ts:
import { Module } from '@nestjs/common';
// ... existing imports ...
import { EmployeeModule } from './employees';
@Module({
imports: [
// ... existing modules ...
EmployeeModule,
],
// ...
})
export class AppModule {}IMPORTANT: Enable ValidationPipe in apps/api/src/main.ts:
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Enable CORS for frontend
app.enableCors({
origin: 'http://localhost:3000',
credentials: true,
});
// Enable validation - REQUIRED for DTO decorators to work
app.useGlobalPipes(new ValidationPipe({
whitelist: true, // Strip properties not in DTO
transform: true, // Auto-transform payloads to DTO instances
forbidNonWhitelisted: true, // Throw error on extra properties
}));
await app.listen(3001);
}
bootstrap();Without ValidationPipe, the @IsString/@IsEmail decorators in DTOs will not validate anything!
Gate
# Build the API
cd apps/api && npm run build
# Should succeed with no errors
# Verify module registered
cat apps/api/src/app.module.ts | grep "EmployeeModule"
# Should show: EmployeeModule in importsCommon Errors
| Error | Cause | Fix |
|---|---|---|
Cannot find module './employees' | Missing index.ts | Create barrel export |
Nest can't resolve dependencies | PrismaModule not imported | Add PrismaModule to imports |
Build failed | TypeScript errors | Fix errors shown in output |
Rollback
rm apps/api/src/employees/employee.module.ts
rm apps/api/src/employees/index.ts
# Remove EmployeeModule from app.module.ts importsLock
After this step, these files are locked:
apps/api/src/employees/*(all employee module files)
Checkpoint
Before proceeding to Step 37:
- EmployeeModule created
- Module registered in AppModule
-
npm run buildsucceeds - No TypeScript errors
- Type "GATE 36 PASSED" to continue
Files Created/Modified This Step
| Action | File |
|---|---|
| Created | apps/api/src/employees/employee.module.ts |
| Created | apps/api/src/employees/index.ts |
| Modified | apps/api/src/app.module.ts |
Step 37: Test Employee CRUD via curl
Input
- Step 36 complete
- API builds and runs
- You have a valid tenant ID from Phase 01
Constraints
- DO NOT modify code (testing only)
- DO NOT proceed if any test fails
- ONLY run: curl commands to test endpoints
Task
Start the API if not running:
cd apps/api && npm run devGet a tenant ID (from Phase 01 - check via Prisma Studio or previous login):
# Replace YOUR_TENANT_ID with actual tenant ID
export TENANT_ID="YOUR_TENANT_ID"Test CRUD operations:
# 1. Create Employee
curl -X POST http://localhost:3001/api/v1/employees \
-H "Content-Type: application/json" \
-H "x-tenant-id: $TENANT_ID" \
-d '{
"firstName": "John",
"lastName": "Doe",
"email": "john.doe@example.com",
"jobTitle": "Software Engineer",
"employmentType": "FULL_TIME",
"workMode": "HYBRID"
}'
# Should return: { "data": { "id": "...", ... }, "error": null }
# Save the employee ID
export EMPLOYEE_ID="<id-from-response>"
# 2. List Employees
curl -X GET "http://localhost:3001/api/v1/employees" \
-H "x-tenant-id: $TENANT_ID"
# Should return: { "data": [...], "meta": { "total": 1, ... }, "error": null }
# 3. Get Single Employee
curl -X GET "http://localhost:3001/api/v1/employees/$EMPLOYEE_ID" \
-H "x-tenant-id: $TENANT_ID"
# Should return: { "data": { "id": "...", ... }, "error": null }
# 4. Update Employee
curl -X PATCH "http://localhost:3001/api/v1/employees/$EMPLOYEE_ID" \
-H "Content-Type: application/json" \
-H "x-tenant-id: $TENANT_ID" \
-d '{
"jobTitle": "Senior Software Engineer"
}'
# Should return updated employee
# 5. Delete Employee (soft delete)
curl -X DELETE "http://localhost:3001/api/v1/employees/$EMPLOYEE_ID" \
-H "x-tenant-id: $TENANT_ID"
# Should return: { "data": null, "error": null }
# 6. Verify soft delete (employee still exists but TERMINATED)
curl -X GET "http://localhost:3001/api/v1/employees?status=TERMINATED" \
-H "x-tenant-id: $TENANT_ID"
# Should return the deleted employee with status: TERMINATEDGate
All 6 curl commands must succeed:
- POST creates employee, returns data
- GET list returns employees array with meta
- GET single returns employee data
- PATCH updates and returns updated data
- DELETE returns success
- GET with status=TERMINATED shows deleted employee
Common Errors
| Error | Cause | Fix |
|---|---|---|
401 Unauthorized | TenantGuard blocking | Verify x-tenant-id header |
403 Forbidden | Invalid tenant ID | Use valid tenant ID from database |
404 Not Found | Wrong endpoint URL | Check URL path matches controller |
500 Internal Server Error | Code bug | Check API logs for error details |
Rollback
# No rollback needed - testing only
# If tests fail, debug and fix code in previous stepsLock
- No new files locked
Checkpoint
Before proceeding to Step 38:
- All 6 curl tests pass
- Response format consistent:
{ data, meta?, error } - Soft delete works (status becomes TERMINATED)
- Type "GATE 37 PASSED" to continue
Files Created/Modified This Step
| Action | File |
|---|---|
| None | Testing only |
Step 38: Create Employee List Page (Frontend)
Input
- Step 37 complete
- API endpoints working
- Next.js app running
Prerequisites
1. Add API URL to environment:
# Add to apps/web/.env.local (create if doesn't exist)
echo 'NEXT_PUBLIC_API_URL=http://localhost:3001' >> apps/web/.env.local2. Ensure Tailwind CSS is configured: If Tailwind wasn't set up in Phase 00, install now:
cd apps/web
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -pUpdate apps/web/tailwind.config.js:
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {},
},
plugins: [],
}Add to apps/web/app/globals.css:
@tailwind base;
@tailwind components;
@tailwind utilities;Constraints
- DO NOT add complex state management
- DO NOT add sorting/filtering UI yet
- ONLY create: Basic list page with table
Task
Create apps/web/app/dashboard/employees/page.tsx:
import { auth } from "@/auth"
import { redirect } from 'next/navigation';
import Link from 'next/link';
interface Employee {
id: string;
firstName: string;
lastName: string;
email: string;
jobTitle: string | null;
status: string;
employmentType: string;
}
interface EmployeesResponse {
data: Employee[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
error: null | { code: string; message: string };
}
async function getEmployees(tenantId: string): Promise<EmployeesResponse> {
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees`,
{
headers: {
'x-tenant-id': tenantId,
},
cache: 'no-store',
}
);
if (!res.ok) {
throw new Error('Failed to fetch employees');
}
return res.json();
}
export default async function EmployeesPage() {
const session = await auth()
if (!session?.user?.tenantId) {
redirect('/login');
}
const { data: employees, meta } = await getEmployees(session.user.tenantId);
return (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Employees</h1>
<Link
href="/dashboard/employees/new"
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
Add Employee
</Link>
</div>
<div className="bg-white shadow-lg shadow-gray-200/50 rounded-2xl overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Job Title
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{employees.map((employee) => (
<tr key={employee.id}>
<td className="px-6 py-4 whitespace-nowrap">
{employee.firstName} {employee.lastName}
</td>
<td className="px-6 py-4 whitespace-nowrap">{employee.email}</td>
<td className="px-6 py-4 whitespace-nowrap">
{employee.jobTitle || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 py-1 text-xs rounded ${
employee.status === 'ACTIVE'
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{employee.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<Link
href={`/dashboard/employees/${employee.id}`}
className="text-blue-600 hover:text-blue-900"
>
Edit
</Link>
</td>
</tr>
))}
{employees.length === 0 && (
<tr>
<td colSpan={5} className="px-6 py-4 text-center text-gray-500">
No employees found
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="mt-4 text-sm text-gray-500">
Showing {employees.length} of {meta.total} employees
</div>
</div>
);
}Gate
# Navigate to employees page in browser
# URL: http://localhost:3000/dashboard/employees
# Should show:
# - "Employees" heading
# - "Add Employee" button
# - Table with columns: Name, Email, Job Title, Status, Actions
# - Either employee data or "No employees found"Common Errors
| Error | Cause | Fix |
|---|---|---|
NEXT_PUBLIC_API_URL not defined | Missing env var | Add to .env.local: NEXT_PUBLIC_API_URL=http://localhost:3001 |
Failed to fetch employees | API not running | Start API: cd apps/api && npm run dev |
tenantId undefined | Session not configured | Verify Phase 01 auth working |
Rollback
rm apps/web/app/dashboard/employees/page.tsxLock
- No files locked yet
Checkpoint
Before proceeding to Step 39:
- Page renders at
/dashboard/employees - Shows table with employee data (or empty state)
- "Add Employee" button visible
- Edit links work (even if edit page doesn't exist yet)
- Type "GATE 38 PASSED" to continue
Files Created/Modified This Step
| Action | File |
|---|---|
| Created | apps/web/app/dashboard/employees/page.tsx |
Step 39: Create Employee Form Component
Input
- Step 38 complete
- List page working
Constraints
- DO NOT use form libraries (just native HTML + React)
- DO NOT add file upload
- ONLY create: Reusable form component with all fields
Task
Create apps/web/app/dashboard/employees/components/employee-form.tsx:
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
interface EmployeeFormData {
firstName: string;
lastName: string;
email: string;
phone?: string;
employeeNumber?: string;
jobTitle?: string;
jobFamily?: string;
jobLevel?: string;
employmentType: string;
workMode: string;
status: string;
hireDate?: string;
}
interface EmployeeFormProps {
initialData?: Partial<EmployeeFormData>;
onSubmit: (data: EmployeeFormData) => Promise<void>;
submitLabel: string;
}
const EMPLOYMENT_TYPES = [
{ value: 'FULL_TIME', label: 'Full Time' },
{ value: 'PART_TIME', label: 'Part Time' },
{ value: 'CONTRACTOR', label: 'Contractor' },
{ value: 'INTERN', label: 'Intern' },
{ value: 'TEMPORARY', label: 'Temporary' },
];
const WORK_MODES = [
{ value: 'ONSITE', label: 'Onsite' },
{ value: 'REMOTE', label: 'Remote' },
{ value: 'HYBRID', label: 'Hybrid' },
];
const STATUSES = [
{ value: 'ACTIVE', label: 'Active' },
{ value: 'INACTIVE', label: 'Inactive' },
{ value: 'ON_LEAVE', label: 'On Leave' },
];
export function EmployeeForm({
initialData,
onSubmit,
submitLabel,
}: EmployeeFormProps) {
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [formData, setFormData] = useState<EmployeeFormData>({
firstName: initialData?.firstName ?? '',
lastName: initialData?.lastName ?? '',
email: initialData?.email ?? '',
phone: initialData?.phone ?? '',
employeeNumber: initialData?.employeeNumber ?? '',
jobTitle: initialData?.jobTitle ?? '',
jobFamily: initialData?.jobFamily ?? '',
jobLevel: initialData?.jobLevel ?? '',
employmentType: initialData?.employmentType ?? 'FULL_TIME',
workMode: initialData?.workMode ?? 'ONSITE',
status: initialData?.status ?? 'ACTIVE',
hireDate: initialData?.hireDate ?? '',
});
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
setError(null);
try {
await onSubmit(formData);
router.push('/dashboard/employees');
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="bg-red-50 shadow-lg shadow-red-100/50 text-red-700 px-6 py-3 rounded-2xl">
{error}
</div>
)}
{/* Personal Information */}
<div className="bg-white shadow-lg shadow-gray-200/50 rounded-2xl p-6">
<h2 className="text-lg font-medium mb-4">Personal Information</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">
First Name *
</label>
<input
type="text"
name="firstName"
value={formData.firstName}
onChange={handleChange}
required
className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Last Name *
</label>
<input
type="text"
name="lastName"
value={formData.lastName}
onChange={handleChange}
required
className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Email *
</label>
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
required
className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Phone
</label>
<input
type="tel"
name="phone"
value={formData.phone}
onChange={handleChange}
className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
/>
</div>
</div>
</div>
{/* Job Information */}
<div className="bg-white shadow-lg shadow-gray-200/50 rounded-2xl p-6">
<h2 className="text-lg font-medium mb-4">Job Information</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">
Employee Number
</label>
<input
type="text"
name="employeeNumber"
value={formData.employeeNumber}
onChange={handleChange}
className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Job Title
</label>
<input
type="text"
name="jobTitle"
value={formData.jobTitle}
onChange={handleChange}
className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Job Family
</label>
<input
type="text"
name="jobFamily"
value={formData.jobFamily}
onChange={handleChange}
className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Job Level
</label>
<input
type="text"
name="jobLevel"
value={formData.jobLevel}
onChange={handleChange}
className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
/>
</div>
</div>
</div>
{/* Employment Details */}
<div className="bg-white shadow-lg shadow-gray-200/50 rounded-2xl p-6">
<h2 className="text-lg font-medium mb-4">Employment Details</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">
Employment Type
</label>
<select
name="employmentType"
value={formData.employmentType}
onChange={handleChange}
className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
>
{EMPLOYMENT_TYPES.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Work Mode
</label>
<select
name="workMode"
value={formData.workMode}
onChange={handleChange}
className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
>
{WORK_MODES.map((mode) => (
<option key={mode.value} value={mode.value}>
{mode.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Status
</label>
<select
name="status"
value={formData.status}
onChange={handleChange}
className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
>
{STATUSES.map((status) => (
<option key={status.value} value={status.value}>
{status.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Hire Date
</label>
<input
type="date"
name="hireDate"
value={formData.hireDate}
onChange={handleChange}
className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
/>
</div>
</div>
</div>
{/* Actions */}
<div className="flex justify-end space-x-4">
<button
type="button"
onClick={() => router.back()}
className="px-6 py-2.5 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-full transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="px-4 py-2 rounded-2xl shadow-lg shadow-blue-600/30 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50"
>
{isSubmitting ? 'Saving...' : submitLabel}
</button>
</div>
</form>
);
}Gate
# Verify file exists
cat apps/web/app/dashboard/employees/components/employee-form.tsx | head -20
# Should show: 'use client' directive and imports
# Verify all form fields present
grep -c "name=" apps/web/app/dashboard/employees/components/employee-form.tsx
# Should show at least 10 (one per field)Common Errors
| Error | Cause | Fix |
|---|---|---|
'use client' must be first | Other code before directive | Move 'use client' to line 1 |
useRouter not found | Wrong import | Use next/navigation not next/router |
Rollback
rm -rf apps/web/app/dashboard/employees/componentsLock
- No files locked yet
Checkpoint
Before proceeding to Step 40:
- Form component created
- All fields from Employee model included
- Select dropdowns for enums
- Submit/Cancel buttons work
- Type "GATE 39 PASSED" to continue
Files Created/Modified This Step
| Action | File |
|---|---|
| Created | apps/web/app/dashboard/employees/components/employee-form.tsx |
Step 40: Create New Employee Page
Input
- Step 39 complete
- EmployeeForm component exists
Prerequisites
Ensure SessionProvider wraps your app. If not already done:
- Create
apps/web/app/providers.tsx:
'use client';
import { SessionProvider } from 'next-auth/react';
export function Providers({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
}- Update
apps/web/app/layout.tsxto wrap children:
import { Providers } from './providers';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}Constraints
- DO NOT add complex validation
- DO NOT add multi-step wizard
- ONLY create: New employee page using EmployeeForm
Task
Create apps/web/app/dashboard/employees/new/page.tsx:
'use client';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { EmployeeForm } from '../components/employee-form';
export default function NewEmployeePage() {
const { data: session, status } = useSession();
const router = useRouter();
if (status === 'loading') {
return <div className="p-6">Loading...</div>;
}
if (!session?.user?.tenantId) {
router.push('/login');
return null;
}
const handleSubmit = async (data: Record<string, unknown>) => {
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-tenant-id': session.user.tenantId!,
},
body: JSON.stringify(data),
}
);
if (!res.ok) {
const error = await res.json();
throw new Error(error.error?.message || 'Failed to create employee');
}
};
return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-6">Add New Employee</h1>
<EmployeeForm onSubmit={handleSubmit} submitLabel="Create Employee" />
</div>
);
}Gate
# Navigate to new employee page in browser
# URL: http://localhost:3000/dashboard/employees/new
# Should show:
# - "Add New Employee" heading
# - Employee form with all fields
# - "Create Employee" button
# Test form submission:
# 1. Fill required fields (firstName, lastName, email)
# 2. Click "Create Employee"
# 3. Should redirect to /dashboard/employees
# 4. New employee should appear in listCommon Errors
| Error | Cause | Fix |
|---|---|---|
useSession must be wrapped | Missing SessionProvider | Wrap app in SessionProvider |
tenantId undefined | Session not extended | Verify Phase 01 session callback |
CORS error | API blocking frontend | Check API CORS config |
Rollback
rm -rf apps/web/app/dashboard/employees/newLock
- No files locked yet
Checkpoint
Before proceeding to Step 41:
- Page renders at
/dashboard/employees/new - Form displays all fields
- Submit creates employee in database
- Redirects to list after success
- Type "GATE 40 PASSED" to continue
Files Created/Modified This Step
| Action | File |
|---|---|
| Created | apps/web/app/dashboard/employees/new/page.tsx |
Step 41: Create Edit Employee Page
Input
- Step 40 complete
- Create page working
Constraints
- DO NOT add complex state management
- ONLY create: Edit page using EmployeeForm with initial data
Task
Create apps/web/app/dashboard/employees/[id]/page.tsx:
Note: In Next.js 15, params is a Promise. Use React.use() to unwrap it.
'use client';
import { use, useEffect, useState } from 'react';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { EmployeeForm } from '../components/employee-form';
interface Employee {
id: string;
firstName: string;
lastName: string;
email: string;
phone: string | null;
employeeNumber: string | null;
jobTitle: string | null;
jobFamily: string | null;
jobLevel: string | null;
employmentType: string;
workMode: string;
status: string;
hireDate: string | null;
}
export default function EditEmployeePage({
params,
}: {
params: Promise<{ id: string }>;
}) {
// Next.js 15: params is a Promise, use React.use() to unwrap
const { id } = use(params);
const { data: session, status } = useSession();
const router = useRouter();
const [employee, setEmployee] = useState<Employee | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (status === 'authenticated' && session?.user?.tenantId) {
fetchEmployee();
}
}, [status, session]);
const fetchEmployee = async () => {
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees/${id}`,
{
headers: {
'x-tenant-id': session!.user.tenantId!,
},
}
);
if (!res.ok) {
throw new Error('Employee not found');
}
const { data } = await res.json();
setEmployee(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load employee');
} finally {
setLoading(false);
}
};
if (status === 'loading' || loading) {
return <div className="p-6">Loading...</div>;
}
if (!session?.user?.tenantId) {
router.push('/login');
return null;
}
if (error) {
return (
<div className="p-6">
<div className="bg-red-50 shadow-lg shadow-red-100/50 text-red-700 px-6 py-3 rounded-2xl">
{error}
</div>
</div>
);
}
if (!employee) {
return <div className="p-6">Employee not found</div>;
}
const handleSubmit = async (data: Record<string, unknown>) => {
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees/${id}`,
{
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'x-tenant-id': session.user.tenantId!,
},
body: JSON.stringify(data),
}
);
if (!res.ok) {
const error = await res.json();
throw new Error(error.error?.message || 'Failed to update employee');
}
};
return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-6">
Edit Employee: {employee.firstName} {employee.lastName}
</h1>
<EmployeeForm
initialData={{
firstName: employee.firstName,
lastName: employee.lastName,
email: employee.email,
phone: employee.phone ?? undefined,
employeeNumber: employee.employeeNumber ?? undefined,
jobTitle: employee.jobTitle ?? undefined,
jobFamily: employee.jobFamily ?? undefined,
jobLevel: employee.jobLevel ?? undefined,
employmentType: employee.employmentType,
workMode: employee.workMode,
status: employee.status,
hireDate: employee.hireDate
? new Date(employee.hireDate).toISOString().split('T')[0]
: undefined,
}}
onSubmit={handleSubmit}
submitLabel="Save Changes"
/>
</div>
);
}Gate
# Navigate to edit employee page in browser
# URL: http://localhost:3000/dashboard/employees/{employee-id}
# Should show:
# - "Edit Employee: [Name]" heading
# - Form pre-filled with employee data
# - "Save Changes" button
# Test form submission:
# 1. Modify a field (e.g., jobTitle)
# 2. Click "Save Changes"
# 3. Should redirect to /dashboard/employees
# 4. Changes should be reflected in listCommon Errors
| Error | Cause | Fix |
|---|---|---|
Employee not found | Invalid ID or wrong tenant | Verify ID in URL is correct |
Cannot read property 'id' | params not awaited (Next.js 15) | Use await params if Next.js 15 |
Date formatting error | Invalid date | Handle null dates |
Rollback
rm -rf apps/web/app/dashboard/employees/[id]Lock
- No files locked yet
Checkpoint
Before proceeding to Step 42:
- Page renders at
/dashboard/employees/[id] - Form pre-filled with employee data
- Save updates employee in database
- Redirects to list after success
- Type "GATE 41 PASSED" to continue
Files Created/Modified This Step
| Action | File |
|---|---|
| Created | apps/web/app/dashboard/employees/[id]/page.tsx |
Step 42: Add Delete Functionality
Input
- Step 41 complete
- Edit page working
Constraints
- DO NOT add hard delete
- DO NOT add bulk delete
- ONLY add: Delete button with confirmation
Task
Update apps/web/app/dashboard/employees/[id]/page.tsx to add delete button:
// Add this state near the top of the component
const [isDeleting, setIsDeleting] = useState(false);
// Add this function before the return statement
const handleDelete = async () => {
if (!confirm('Are you sure you want to delete this employee? This action will mark them as terminated.')) {
return;
}
setIsDeleting(true);
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees/${id}`,
{
method: 'DELETE',
headers: {
'x-tenant-id': session!.user.tenantId!,
},
}
);
if (!res.ok) {
throw new Error('Failed to delete employee');
}
router.push('/dashboard/employees');
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete employee');
setIsDeleting(false);
}
};
// Add this button in the return statement, after the heading:
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">
Edit Employee: {employee.firstName} {employee.lastName}
</h1>
<button
onClick={handleDelete}
disabled={isDeleting}
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50"
>
{isDeleting ? 'Deleting...' : 'Delete Employee'}
</button>
</div>Full updated component with delete:
'use client';
import { use, useEffect, useState } from 'react';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { EmployeeForm } from '../components/employee-form';
interface Employee {
id: string;
firstName: string;
lastName: string;
email: string;
phone: string | null;
employeeNumber: string | null;
jobTitle: string | null;
jobFamily: string | null;
jobLevel: string | null;
employmentType: string;
workMode: string;
status: string;
hireDate: string | null;
}
export default function EditEmployeePage({
params,
}: {
params: Promise<{ id: string }>;
}) {
// Next.js 15: params is a Promise, use React.use() to unwrap
const { id } = use(params);
const { data: session, status } = useSession();
const router = useRouter();
const [employee, setEmployee] = useState<Employee | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
useEffect(() => {
if (status === 'authenticated' && session?.user?.tenantId) {
fetchEmployee();
}
}, [status, session]);
const fetchEmployee = async () => {
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees/${id}`,
{
headers: {
'x-tenant-id': session!.user.tenantId!,
},
}
);
if (!res.ok) {
throw new Error('Employee not found');
}
const { data } = await res.json();
setEmployee(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load employee');
} finally {
setLoading(false);
}
};
const handleDelete = async () => {
if (
!confirm(
'Are you sure you want to delete this employee? This action will mark them as terminated.'
)
) {
return;
}
setIsDeleting(true);
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees/${id}`,
{
method: 'DELETE',
headers: {
'x-tenant-id': session!.user.tenantId!,
},
}
);
if (!res.ok) {
throw new Error('Failed to delete employee');
}
router.push('/dashboard/employees');
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete employee');
setIsDeleting(false);
}
};
if (status === 'loading' || loading) {
return <div className="p-6">Loading...</div>;
}
if (!session?.user?.tenantId) {
router.push('/login');
return null;
}
if (error) {
return (
<div className="p-6">
<div className="bg-red-50 shadow-lg shadow-red-100/50 text-red-700 px-6 py-3 rounded-2xl">
{error}
</div>
</div>
);
}
if (!employee) {
return <div className="p-6">Employee not found</div>;
}
const handleSubmit = async (data: Record<string, unknown>) => {
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees/${id}`,
{
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'x-tenant-id': session.user.tenantId!,
},
body: JSON.stringify(data),
}
);
if (!res.ok) {
const error = await res.json();
throw new Error(error.error?.message || 'Failed to update employee');
}
};
return (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">
Edit Employee: {employee.firstName} {employee.lastName}
</h1>
<button
onClick={handleDelete}
disabled={isDeleting}
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50"
>
{isDeleting ? 'Deleting...' : 'Delete Employee'}
</button>
</div>
<EmployeeForm
initialData={{
firstName: employee.firstName,
lastName: employee.lastName,
email: employee.email,
phone: employee.phone ?? undefined,
employeeNumber: employee.employeeNumber ?? undefined,
jobTitle: employee.jobTitle ?? undefined,
jobFamily: employee.jobFamily ?? undefined,
jobLevel: employee.jobLevel ?? undefined,
employmentType: employee.employmentType,
workMode: employee.workMode,
status: employee.status,
hireDate: employee.hireDate
? new Date(employee.hireDate).toISOString().split('T')[0]
: undefined,
}}
onSubmit={handleSubmit}
submitLabel="Save Changes"
/>
</div>
);
}Gate
# Navigate to edit employee page in browser
# URL: http://localhost:3000/dashboard/employees/{employee-id}
# Should show:
# - "Delete Employee" button (red)
# - Clicking shows confirmation dialog
# - Confirming deletes employee and redirects to list
# - Employee should no longer appear in list (soft deleted)
# Verify soft delete:
curl -X GET "http://localhost:3001/api/v1/employees?status=TERMINATED" \
-H "x-tenant-id: $TENANT_ID"
# Should show deleted employee with status: TERMINATEDCommon Errors
| Error | Cause | Fix |
|---|---|---|
Failed to delete | API error | Check API logs |
Employee still showing | Cache not refreshed | Add router.refresh() |
Rollback
# Revert to Step 41 version of the file
# Remove handleDelete function and Delete buttonLock
After this step, these files are locked:
apps/web/app/dashboard/employees/*(all frontend employee files)
Checkpoint
Phase 02 complete! Verify:
- Delete button visible on edit page
- Confirmation dialog appears
- Employee removed from list after delete
- Employee has status TERMINATED in database
- Type "PHASE 02 COMPLETE" to continue
Files Created/Modified This Step
| Action | File |
|---|---|
| Modified | apps/web/app/dashboard/employees/[id]/page.tsx |
Phase 02 Summary
What Was Built
| Component | Files |
|---|---|
| Database | Employee model, 3 enums, User relation |
| Backend | EmployeeRepository, EmployeeService, EmployeeController, EmployeeModule |
| Frontend | List page, Form component, Create page, Edit page with delete |
Locked Files
After Phase 02, these files should not be modified without good reason:
packages/database/prisma/schema.prisma(Employee model, enums)apps/api/src/employees/*apps/web/app/dashboard/employees/*
Key Patterns Established
- Tenant Isolation: All repository methods filter by tenantId
- Soft Delete: Use status TERMINATED, not hard delete
- Response Format:
{ data, meta?, error } - Form Component: Reusable form with initialData prop
- Error Handling: NotFoundException, ConflictException in service
Next Phase
Phase 03: Org Structure (Steps 43-62)
- Department model and CRUD
- Team model and CRUD
- Manager assignment
- Org chart visualization (Phase 04)
Anti-Patterns to Avoid in Future Phases
- DO NOT add generic base classes (keep explicit)
- DO NOT add complex state management (simple useState is fine)
- DO NOT add real-time updates (not in MVP)
- DO NOT add bulk operations (keep it simple)
Step 43: Add Address and Emergency Contact Fields (UO-07)
Input
- Phase 02 core steps complete
- Employee model exists
Constraints
- Add fields to existing Employee model
- DO NOT create separate address table
- ONLY modify schema and DTOs
Task
1. Update Employee model in packages/database/prisma/schema.prisma:
model Employee {
// ... existing fields ...
// Address fields
addressLine1 String?
addressLine2 String?
city String?
state String?
postalCode String?
country String?
// Emergency contact
emergencyContactName String?
emergencyContactPhone String?
emergencyContactRelation String?
}2. Run migration:
cd packages/database
npm run db:push
npm run db:generate3. Update CreateEmployeeDto in apps/api/src/employees/dto/create-employee.dto.ts:
// Add to existing DTO
@IsOptional()
@IsString()
addressLine1?: string;
@IsOptional()
@IsString()
addressLine2?: string;
@IsOptional()
@IsString()
city?: string;
@IsOptional()
@IsString()
state?: string;
@IsOptional()
@IsString()
postalCode?: string;
@IsOptional()
@IsString()
country?: string;
@IsOptional()
@IsString()
emergencyContactName?: string;
@IsOptional()
@IsString()
emergencyContactPhone?: string;
@IsOptional()
@IsString()
emergencyContactRelation?: string;4. Update EmployeeForm - Add address and emergency contact sections in apps/web/app/dashboard/employees/components/employee-form.tsx:
{/* Address Section */}
<div className="bg-white shadow-lg shadow-gray-200/50 rounded-2xl p-6">
<h2 className="text-lg font-medium mb-4">Address</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700">
Address Line 1
</label>
<input
type="text"
name="addressLine1"
value={formData.addressLine1 || ''}
onChange={handleChange}
className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700">
Address Line 2
</label>
<input
type="text"
name="addressLine2"
value={formData.addressLine2 || ''}
onChange={handleChange}
className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">City</label>
<input
type="text"
name="city"
value={formData.city || ''}
onChange={handleChange}
className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
State / Province
</label>
<input
type="text"
name="state"
value={formData.state || ''}
onChange={handleChange}
className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Postal Code
</label>
<input
type="text"
name="postalCode"
value={formData.postalCode || ''}
onChange={handleChange}
className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Country</label>
<input
type="text"
name="country"
value={formData.country || ''}
onChange={handleChange}
className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
/>
</div>
</div>
</div>
{/* Emergency Contact Section */}
<div className="bg-white shadow-lg shadow-gray-200/50 rounded-2xl p-6">
<h2 className="text-lg font-medium mb-4">Emergency Contact</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">
Contact Name
</label>
<input
type="text"
name="emergencyContactName"
value={formData.emergencyContactName || ''}
onChange={handleChange}
className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Phone Number
</label>
<input
type="tel"
name="emergencyContactPhone"
value={formData.emergencyContactPhone || ''}
onChange={handleChange}
className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Relationship
</label>
<input
type="text"
name="emergencyContactRelation"
value={formData.emergencyContactRelation || ''}
onChange={handleChange}
placeholder="e.g., Spouse, Parent, Sibling"
className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
/>
</div>
</div>
</div>Gate
# Verify schema updated
cat packages/database/prisma/schema.prisma | grep -A 3 "addressLine1"
# Should show address fields
# Test API
curl -X POST http://localhost:3001/api/v1/employees \
-H "Content-Type: application/json" \
-H "x-tenant-id: YOUR_TENANT_ID" \
-d '{
"firstName": "Jane",
"lastName": "Doe",
"email": "jane.doe@example.com",
"city": "San Francisco",
"state": "CA",
"emergencyContactName": "John Doe",
"emergencyContactPhone": "+1-555-0123"
}'
# Should return employee with address and emergency contact fieldsCheckpoint
- Address fields added to schema
- Emergency contact fields added to schema
- Form shows both sections
- Data saves correctly
- Type "GATE 43 PASSED" to continue
Step 44: Add Profile Picture Upload (UO-04)
Input
- Step 43 complete
- Employee has pictureUrl field
Constraints
- Use existing upload infrastructure from Phase 06
- Store URL in Employee.pictureUrl
- ONLY add picture upload endpoint and component
Task
1. Add Picture Upload Endpoint to apps/api/src/employees/employees.controller.ts:
import { FileInterceptor } from '@nestjs/platform-express';
import { UploadedFile, UseInterceptors } from '@nestjs/common';
@Post(':id/picture')
@UseInterceptors(FileInterceptor('file'))
async uploadPicture(
@Param('id') id: string,
@UploadedFile() file: Express.Multer.File,
@TenantId() tenantId: string,
) {
// Use upload service (from Phase 06) or store locally for MVP
const url = await this.employeesService.uploadPicture(id, file, tenantId);
return { data: { pictureUrl: url }, error: null };
}
@Delete(':id/picture')
async deletePicture(
@Param('id') id: string,
@TenantId() tenantId: string,
) {
await this.employeesService.deletePicture(id, tenantId);
return { success: true, error: null };
}2. Add Picture Methods to EmployeesService:
// Add to apps/api/src/employees/employees.service.ts
import { writeFile, unlink, mkdir } from 'fs/promises';
import { join } from 'path';
import { existsSync } from 'fs';
async uploadPicture(employeeId: string, file: Express.Multer.File, tenantId: string) {
const employee = await this.repository.findById(tenantId, employeeId);
if (!employee) {
throw new NotFoundException(`Employee with ID ${employeeId} not found`);
}
// Create uploads directory if it doesn't exist
const uploadsDir = join(process.cwd(), 'uploads', 'profile-pictures');
if (!existsSync(uploadsDir)) {
await mkdir(uploadsDir, { recursive: true });
}
// Generate unique filename
const ext = file.originalname.split('.').pop();
const filename = `${employeeId}-${Date.now()}.${ext}`;
const filepath = join(uploadsDir, filename);
// Save file
await writeFile(filepath, file.buffer);
// Update employee with picture URL
const pictureUrl = `/uploads/profile-pictures/${filename}`;
await this.repository.update(tenantId, employeeId, { pictureUrl });
return pictureUrl;
}
async deletePicture(employeeId: string, tenantId: string) {
const employee = await this.repository.findById(tenantId, employeeId);
if (!employee) {
throw new NotFoundException(`Employee with ID ${employeeId} not found`);
}
if (employee.pictureUrl) {
// Delete file from disk
const filepath = join(process.cwd(), employee.pictureUrl);
if (existsSync(filepath)) {
await unlink(filepath);
}
// Clear picture URL
await this.repository.update(tenantId, employeeId, { pictureUrl: null });
}
}3. Serve Static Files - Update apps/api/src/main.ts:
import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path';
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
// Serve uploaded files
app.useStaticAssets(join(__dirname, '..', 'uploads'), {
prefix: '/uploads/',
});
// ... rest of bootstrap
}4. Create Profile Picture Upload Component at apps/web/components/profile-picture-upload.tsx:
'use client';
import { useState, useRef } from 'react';
import { useSession } from 'next-auth/react';
interface ProfilePictureUploadProps {
employeeId: string;
currentUrl?: string | null;
onUpload?: (url: string) => void;
}
export function ProfilePictureUpload({
employeeId,
currentUrl,
onUpload,
}: ProfilePictureUploadProps) {
const { data: session } = useSession();
const [uploading, setUploading] = useState(false);
const [preview, setPreview] = useState<string | null>(currentUrl || null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Validate file type
if (!file.type.startsWith('image/')) {
alert('Please select an image file');
return;
}
// Validate file size (max 5MB)
if (file.size > 5 * 1024 * 1024) {
alert('File size must be less than 5MB');
return;
}
setUploading(true);
try {
const formData = new FormData();
formData.append('file', file);
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees/${employeeId}/picture`,
{
method: 'POST',
headers: {
'x-tenant-id': session?.user?.tenantId || '',
},
body: formData,
}
);
if (!response.ok) {
throw new Error('Failed to upload picture');
}
const { data } = await response.json();
setPreview(data.pictureUrl);
onUpload?.(data.pictureUrl);
} catch (error) {
console.error('Upload error:', error);
alert('Failed to upload picture');
} finally {
setUploading(false);
}
};
const handleDelete = async () => {
if (!confirm('Delete profile picture?')) return;
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees/${employeeId}/picture`,
{
method: 'DELETE',
headers: {
'x-tenant-id': session?.user?.tenantId || '',
},
}
);
if (response.ok) {
setPreview(null);
onUpload?.('');
}
} catch (error) {
console.error('Delete error:', error);
}
};
return (
<div className="flex flex-col items-center gap-4">
<div className="relative">
{preview ? (
<img
src={`${process.env.NEXT_PUBLIC_API_URL}${preview}`}
alt="Profile"
className="w-32 h-32 rounded-full object-cover shadow-lg shadow-gray-200/50"
/>
) : (
<div className="w-32 h-32 rounded-full bg-gray-200 flex items-center justify-center text-gray-400">
<svg
className="w-12 h-12"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
</div>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileChange}
className="hidden"
/>
<div className="flex gap-2">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
>
{uploading ? 'Uploading...' : preview ? 'Change Photo' : 'Upload Photo'}
</button>
{preview && (
<button
type="button"
onClick={handleDelete}
className="px-4 py-2 border border-red-200 text-red-600 rounded-2xl hover:bg-red-50 shadow-sm shadow-red-100/50"
>
Remove
</button>
)}
</div>
</div>
);
}Gate
# Test picture upload
curl -X POST http://localhost:3001/api/v1/employees/EMPLOYEE_ID/picture \
-H "x-tenant-id: YOUR_TENANT_ID" \
-F "file=@/path/to/image.jpg"
# Should return { data: { pictureUrl: "/uploads/profile-pictures/..." } }
# Verify file exists
ls uploads/profile-pictures/
# Should show uploaded fileCheckpoint
- Picture upload endpoint works
- File saved to uploads directory
- Picture URL stored in employee record
- Component shows preview
- Type "GATE 44 PASSED" to continue
Step 45: Add Self-Profile Endpoint (EMP-02)
Input
- Step 44 complete
- Employee model has all fields
Constraints
- Employees can only update personal fields (not job title, department, etc.)
- Use CurrentUser decorator to get employee
- ONLY add /me endpoints
Task
1. Add Self-Profile Endpoints to apps/api/src/employees/employees.controller.ts:
import { CurrentUser } from '../auth/current-user.decorator';
@Get('me')
async getMyProfile(@CurrentUser() user: any) {
if (!user?.employeeId) {
throw new NotFoundException('No employee profile linked to this user');
}
const data = await this.employeesService.findByUserId(user.id);
return { data, error: null };
}
@Patch('me')
async updateMyProfile(
@CurrentUser() user: any,
@Body() dto: UpdateMyProfileDto,
) {
if (!user?.employeeId) {
throw new NotFoundException('No employee profile linked to this user');
}
const data = await this.employeesService.updateSelfProfile(user.id, dto);
return { data, error: null };
}2. Create UpdateMyProfileDto at apps/api/src/employees/dto/update-my-profile.dto.ts:
import { IsOptional, IsString, IsEmail } from 'class-validator';
// Only fields an employee can update themselves
export class UpdateMyProfileDto {
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
addressLine1?: string;
@IsOptional()
@IsString()
addressLine2?: string;
@IsOptional()
@IsString()
city?: string;
@IsOptional()
@IsString()
state?: string;
@IsOptional()
@IsString()
postalCode?: string;
@IsOptional()
@IsString()
country?: string;
@IsOptional()
@IsString()
emergencyContactName?: string;
@IsOptional()
@IsString()
emergencyContactPhone?: string;
@IsOptional()
@IsString()
emergencyContactRelation?: string;
}3. Add Self-Profile Methods to EmployeesService:
// Add to apps/api/src/employees/employees.service.ts
async findByUserId(userId: string) {
const employee = await this.prisma.employee.findFirst({
where: { userId, deletedAt: null },
include: {
orgRelations: {
include: {
department: true,
team: true,
primaryManager: {
select: {
id: true,
firstName: true,
lastName: true,
jobTitle: true,
},
},
},
},
},
});
if (!employee) {
throw new NotFoundException('Employee profile not found');
}
return employee;
}
async updateSelfProfile(userId: string, dto: UpdateMyProfileDto) {
const employee = await this.prisma.employee.findFirst({
where: { userId, deletedAt: null },
});
if (!employee) {
throw new NotFoundException('Employee profile not found');
}
// Only update allowed fields
return this.prisma.employee.update({
where: { id: employee.id },
data: {
phone: dto.phone,
addressLine1: dto.addressLine1,
addressLine2: dto.addressLine2,
city: dto.city,
state: dto.state,
postalCode: dto.postalCode,
country: dto.country,
emergencyContactName: dto.emergencyContactName,
emergencyContactPhone: dto.emergencyContactPhone,
emergencyContactRelation: dto.emergencyContactRelation,
},
});
}4. Create My Profile Page at apps/web/app/dashboard/profile/page.tsx:
'use client';
import { useState, useEffect } from 'react';
import { useSession } from 'next-auth/react';
import { ProfilePictureUpload } from '@/components/profile-picture-upload';
export default function MyProfilePage() {
const { data: session } = useSession();
const [profile, setProfile] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [formData, setFormData] = useState({
phone: '',
addressLine1: '',
addressLine2: '',
city: '',
state: '',
postalCode: '',
country: '',
emergencyContactName: '',
emergencyContactPhone: '',
emergencyContactRelation: '',
});
useEffect(() => {
if (session?.user?.tenantId) {
fetchProfile();
}
}, [session]);
const fetchProfile = async () => {
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees/me`,
{
headers: {
'x-tenant-id': session!.user.tenantId!,
},
}
);
if (response.ok) {
const { data } = await response.json();
setProfile(data);
setFormData({
phone: data.phone || '',
addressLine1: data.addressLine1 || '',
addressLine2: data.addressLine2 || '',
city: data.city || '',
state: data.state || '',
postalCode: data.postalCode || '',
country: data.country || '',
emergencyContactName: data.emergencyContactName || '',
emergencyContactPhone: data.emergencyContactPhone || '',
emergencyContactRelation: data.emergencyContactRelation || '',
});
}
} catch (error) {
console.error('Failed to fetch profile:', error);
} finally {
setLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees/me`,
{
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'x-tenant-id': session!.user.tenantId!,
},
body: JSON.stringify(formData),
}
);
if (response.ok) {
alert('Profile updated successfully!');
}
} catch (error) {
console.error('Failed to update profile:', error);
alert('Failed to update profile');
} finally {
setSaving(false);
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData(prev => ({
...prev,
[e.target.name]: e.target.value,
}));
};
if (loading) return <div className="p-6">Loading...</div>;
if (!profile) return <div className="p-6">Profile not found</div>;
return (
<div className="p-6 max-w-4xl">
<h1 className="text-2xl font-bold mb-6">My Profile</h1>
{/* Read-only Info */}
<div className="bg-gray-50 rounded-2xl p-6 mb-6">
<div className="flex items-start gap-6">
<ProfilePictureUpload
employeeId={profile.id}
currentUrl={profile.pictureUrl}
/>
<div>
<h2 className="text-xl font-semibold">
{profile.firstName} {profile.lastName}
</h2>
<p className="text-gray-600">{profile.jobTitle || 'No title'}</p>
<p className="text-gray-500">{profile.email}</p>
<p className="text-sm text-gray-400 mt-2">
Employee #{profile.employeeNumber || 'N/A'}
</p>
</div>
</div>
</div>
{/* Editable Form */}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Contact Info */}
<div className="bg-white shadow-lg shadow-gray-200/50 rounded-2xl p-6">
<h2 className="text-lg font-medium mb-4">Contact Information</h2>
<div>
<label className="block text-sm font-medium text-gray-700">Phone</label>
<input
type="tel"
name="phone"
value={formData.phone}
onChange={handleChange}
className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200 focus:border-violet-500 focus:ring-2 focus:ring-violet-500/20"
/>
</div>
</div>
{/* Address */}
<div className="bg-white shadow-lg shadow-gray-200/50 rounded-2xl p-6">
<h2 className="text-lg font-medium mb-4">Address</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700">Address Line 1</label>
<input
type="text"
name="addressLine1"
value={formData.addressLine1}
onChange={handleChange}
className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700">Address Line 2</label>
<input
type="text"
name="addressLine2"
value={formData.addressLine2}
onChange={handleChange}
className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">City</label>
<input type="text" name="city" value={formData.city} onChange={handleChange}
className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700">State/Province</label>
<input type="text" name="state" value={formData.state} onChange={handleChange}
className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Postal Code</label>
<input type="text" name="postalCode" value={formData.postalCode} onChange={handleChange}
className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Country</label>
<input type="text" name="country" value={formData.country} onChange={handleChange}
className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200" />
</div>
</div>
</div>
{/* Emergency Contact */}
<div className="bg-white shadow-lg shadow-gray-200/50 rounded-2xl p-6">
<h2 className="text-lg font-medium mb-4">Emergency Contact</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">Contact Name</label>
<input type="text" name="emergencyContactName" value={formData.emergencyContactName}
onChange={handleChange}
className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Phone</label>
<input type="tel" name="emergencyContactPhone" value={formData.emergencyContactPhone}
onChange={handleChange}
className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Relationship</label>
<input type="text" name="emergencyContactRelation" value={formData.emergencyContactRelation}
onChange={handleChange}
className="mt-1 block w-full rounded-2xl bg-gray-50 border border-gray-200" />
</div>
</div>
</div>
{/* Submit */}
<div className="flex justify-end">
<button
type="submit"
disabled={saving}
className="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
</div>
);
}Gate
# Test self-profile endpoint
curl -H "x-tenant-id: YOUR_TENANT_ID" \
http://localhost:3001/api/v1/employees/me
# Should return current user's employee profile
# Test update
curl -X PATCH http://localhost:3001/api/v1/employees/me \
-H "Content-Type: application/json" \
-H "x-tenant-id: YOUR_TENANT_ID" \
-d '{"phone":"+1-555-0199","city":"Austin"}'
# Should return updated profileCheckpoint
- GET /employees/me returns own profile
- PATCH /employees/me updates personal fields only
- Cannot update job title, department via /me
- My Profile page works
- Type "GATE 45 PASSED" to continue
Step 46: Add Global Search Endpoint (SRCH-01)
Input
- Step 45 complete
- Employees, documents, teams exist
Constraints
- Search across employees, documents, teams
- Case-insensitive search
- ONLY create search module
Task
1. Create Search Module at apps/api/src/search/:
mkdir -p apps/api/src/searchCreate apps/api/src/search/search.service.ts:
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
interface SearchResults {
employees: any[];
documents: any[];
teams: any[];
}
@Injectable()
export class SearchService {
constructor(private prisma: PrismaService) {}
async search(
query: string,
type: 'all' | 'employees' | 'documents' | 'teams',
tenantId: string,
): Promise<SearchResults> {
const results: SearchResults = {
employees: [],
documents: [],
teams: [],
};
if (type === 'all' || type === 'employees') {
results.employees = await this.prisma.employee.findMany({
where: {
tenantId,
deletedAt: null,
OR: [
{ firstName: { contains: query, mode: 'insensitive' } },
{ lastName: { contains: query, mode: 'insensitive' } },
{ email: { contains: query, mode: 'insensitive' } },
{ jobTitle: { contains: query, mode: 'insensitive' } },
{ employeeNumber: { contains: query, mode: 'insensitive' } },
],
},
select: {
id: true,
firstName: true,
lastName: true,
email: true,
jobTitle: true,
pictureUrl: true,
},
take: 10,
orderBy: { lastName: 'asc' },
});
}
if (type === 'all' || type === 'documents') {
results.documents = await this.prisma.document.findMany({
where: {
tenantId,
deletedAt: null,
OR: [
{ title: { contains: query, mode: 'insensitive' } },
{ description: { contains: query, mode: 'insensitive' } },
],
},
select: {
id: true,
title: true,
description: true,
fileType: true,
createdAt: true,
},
take: 10,
orderBy: { createdAt: 'desc' },
});
}
if (type === 'all' || type === 'teams') {
results.teams = await this.prisma.team.findMany({
where: {
tenantId,
name: { contains: query, mode: 'insensitive' },
},
select: {
id: true,
name: true,
description: true,
type: true,
},
take: 10,
orderBy: { name: 'asc' },
});
}
return results;
}
}Create apps/api/src/search/search.controller.ts:
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { TenantGuard } from '../common/guards';
import { TenantId } from '../common/decorators';
import { SearchService } from './search.service';
@Controller('api/v1/search')
@UseGuards(TenantGuard)
export class SearchController {
constructor(private readonly searchService: SearchService) {}
@Get()
async search(
@Query('q') query: string,
@Query('type') type: 'all' | 'employees' | 'documents' | 'teams' = 'all',
@TenantId() tenantId: string,
) {
if (!query || query.length < 2) {
return { data: { employees: [], documents: [], teams: [] }, error: null };
}
const data = await this.searchService.search(query, type, tenantId);
return { data, error: null };
}
}Create apps/api/src/search/search.module.ts:
import { Module } from '@nestjs/common';
import { SearchController } from './search.controller';
import { SearchService } from './search.service';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [SearchController],
providers: [SearchService],
exports: [SearchService],
})
export class SearchModule {}Create apps/api/src/search/index.ts:
export * from './search.module';
export * from './search.service';
export * from './search.controller';2. Register SearchModule in apps/api/src/app.module.ts:
import { SearchModule } from './search/search.module';
@Module({
imports: [
// ... existing imports
SearchModule,
],
})
export class AppModule {}3. Create Global Search Component at apps/web/components/global-search.tsx:
'use client';
import { useState, useEffect, useRef } from 'react';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { useDebounce } from '@/hooks/use-debounce';
export function GlobalSearch() {
const { data: session } = useSession();
const router = useRouter();
const [query, setQuery] = useState('');
const [results, setResults] = useState<any>(null);
const [isOpen, setIsOpen] = useState(false);
const [loading, setLoading] = useState(false);
const debouncedQuery = useDebounce(query, 300);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (debouncedQuery.length >= 2) {
search(debouncedQuery);
} else {
setResults(null);
}
}, [debouncedQuery]);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const search = async (q: string) => {
setLoading(true);
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/search?q=${encodeURIComponent(q)}`,
{
headers: {
'x-tenant-id': session?.user?.tenantId || '',
},
}
);
if (response.ok) {
const { data } = await response.json();
setResults(data);
setIsOpen(true);
}
} catch (error) {
console.error('Search error:', error);
} finally {
setLoading(false);
}
};
const handleSelect = (type: string, id: string) => {
setIsOpen(false);
setQuery('');
switch (type) {
case 'employee':
router.push(`/dashboard/employees/${id}`);
break;
case 'document':
router.push(`/dashboard/documents/${id}`);
break;
case 'team':
router.push(`/dashboard/teams/${id}`);
break;
}
};
const totalResults = results
? results.employees.length + results.documents.length + results.teams.length
: 0;
return (
<div ref={containerRef} className="relative w-full max-w-md">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => results && setIsOpen(true)}
placeholder="Search employees, documents, teams..."
className="w-full px-4 py-3 bg-gray-100 rounded-2xl focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{loading && (
<div className="absolute right-3 top-2.5">
<div className="w-5 h-5 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
</div>
)}
{isOpen && results && (
<div className="absolute top-full left-0 right-0 mt-1 bg-white rounded-2xl shadow-lg shadow-gray-200/50 z-50 max-h-96 overflow-y-auto">
{totalResults === 0 ? (
<div className="p-4 text-center text-gray-500">No results found</div>
) : (
<>
{results.employees.length > 0 && (
<div className="p-2">
<div className="px-2 py-1 text-xs font-semibold text-gray-500 uppercase">
Employees
</div>
{results.employees.map((emp: any) => (
<button
key={emp.id}
onClick={() => handleSelect('employee', emp.id)}
className="w-full px-2 py-2 text-left hover:bg-gray-100 rounded flex items-center gap-3"
>
{emp.pictureUrl ? (
<img src={emp.pictureUrl} className="w-8 h-8 rounded-full" alt="" />
) : (
<div className="w-8 h-8 rounded-full bg-gray-200 flex items-center justify-center text-gray-500 text-sm">
{emp.firstName[0]}{emp.lastName[0]}
</div>
)}
<div>
<div className="font-medium">{emp.firstName} {emp.lastName}</div>
<div className="text-sm text-gray-500">{emp.jobTitle || emp.email}</div>
</div>
</button>
))}
</div>
)}
{results.documents.length > 0 && (
<div className="p-2 border-t">
<div className="px-2 py-1 text-xs font-semibold text-gray-500 uppercase">
Documents
</div>
{results.documents.map((doc: any) => (
<button
key={doc.id}
onClick={() => handleSelect('document', doc.id)}
className="w-full px-2 py-2 text-left hover:bg-gray-100 rounded"
>
<div className="font-medium">{doc.title}</div>
<div className="text-sm text-gray-500">{doc.fileType}</div>
</button>
))}
</div>
)}
{results.teams.length > 0 && (
<div className="p-2 border-t">
<div className="px-2 py-1 text-xs font-semibold text-gray-500 uppercase">
Teams
</div>
{results.teams.map((team: any) => (
<button
key={team.id}
onClick={() => handleSelect('team', team.id)}
className="w-full px-2 py-2 text-left hover:bg-gray-100 rounded"
>
<div className="font-medium">{team.name}</div>
<div className="text-sm text-gray-500">{team.type}</div>
</button>
))}
</div>
)}
</>
)}
</div>
)}
</div>
);
}4. Create useDebounce hook at apps/web/hooks/use-debounce.ts:
import { useState, useEffect } from 'react';
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}5. Add Global Search to Dashboard Layout - Update header in apps/web/app/dashboard/layout.tsx:
import { GlobalSearch } from '@/components/global-search';
// In the header section:
<header className="...">
<h1>HRMS Dashboard</h1>
<div className="flex-1 max-w-md mx-4">
<GlobalSearch />
</div>
<div>...</div>
</header>Gate
# Test search endpoint
curl "http://localhost:3001/api/v1/search?q=john" \
-H "x-tenant-id: YOUR_TENANT_ID"
# Should return { data: { employees: [...], documents: [...], teams: [...] } }
# Test with type filter
curl "http://localhost:3001/api/v1/search?q=john&type=employees" \
-H "x-tenant-id: YOUR_TENANT_ID"
# Should return only employeesCheckpoint
- Search endpoint returns results from all types
- Type filter works (employees, documents, teams)
- Global search component shows in header
- Clicking result navigates to detail page
- Type "GATE 46 PASSED" to continue
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
- Employee CRUD working (create, read, update, soft delete)
- Profile picture upload functional
- Global search working
2. Update PROJECT_STATE.md
- Mark Phase 02 as COMPLETED with timestamp
- Add locked files to "Locked Files" section
- Update "Current Phase" to Phase 03
- Add session log entry3. Update WHAT_EXISTS.md
## Database Models
- Employee (with all org relations)
## API Endpoints
- CRUD for /api/v1/employees
- GET /api/v1/search
## Frontend Routes
- /dashboard/employees/*
## Established Patterns
- EmployeesModule: apps/api/src/employees/
- EmployeeList component pattern4. Git Tag & Commit
git add PROJECT_STATE.md WHAT_EXISTS.md
git commit -m "chore: complete Phase 02 - Employee Entity"
git tag phase-02-employee-entityNext Phase
After verification, proceed to Phase 03: Org Structure
Last Updated: 2025-11-30