Phase 03: Organization Structure
Departments, teams, roles, and flexible manager relationships
Phase 03: Organization Structure
Goal: Implement flexible organizational relationships supporting multi-manager structures, teams, and departments.
Architecture Note
This phase uses the Service pattern because it includes relationship management logic. For simpler CRUD without complex relationships, use the Controller → Prisma pattern directly (see patterns.mdx).
| Attribute | Value |
|---|---|
| Steps | 43-62 |
| Estimated Time | 8-10 hours |
| Dependencies | Phase 02 complete (Employee model with tenant isolation) |
| Completion Gate | Employees can have primary manager, dotted-line managers, belong to multiple departments and teams |
Step Timing Estimates
| Step | Task | Est. Time |
|---|---|---|
| 43 | Add Department model | 15 min |
| 44 | Add Team model | 15 min |
| 45 | Add OrgRole model | 10 min |
| 46 | Add EmployeeOrgRelations model | 15 min |
| 47 | Add EmployeeDottedLine model | 10 min |
| 48 | Add EmployeeAdditionalManager model | 10 min |
| 49 | Add EmployeeDepartment junction | 10 min |
| 50 | Add EmployeeTeam junction | 10 min |
| 51 | Add EmployeeOrgRole junction | 10 min |
| 52 | Run migration for org structure | 10 min |
| 53 | Create DepartmentRepository | 20 min |
| 54 | Create DepartmentService | 25 min |
| 55 | Create DepartmentController | 15 min |
| 56 | Create Team Repository/Service/Controller | 30 min |
| 57 | Create OrgService getDirectReports | 20 min |
| 58 | Create OrgService getManagers | 20 min |
| 59 | Create OrgService setManager | 25 min |
| 60 | Create manager assignment endpoint | 15 min |
| 61 | Create department assignment endpoint | 15 min |
| 62 | Create team assignment endpoint | 15 min |
Phase Context (READ FIRST)
What This Phase Accomplishes
- Department model with hierarchy (parent/children) and department heads
- Team model with team leads and team types
- OrgRole model for job roles within tenant
- EmployeeOrgRelations hub for organizational context
- Junction tables for many-to-many relationships
- Manager relationships: primary, dotted-line, and additional
- Department and team membership with roles
- API endpoints for all org structure operations
What This Phase Does NOT Include
- Org chart visualization - that's Phase 04
- Time-off policies (uses departments) - Phase 05
- Document folder permissions - Phase 06
- Custom fields on org entities - Phase 07
- Dashboard widgets - Phase 08
Bluewoo Anti-Pattern Reminder
This phase intentionally has NO:
- Single manager field (we support multiple manager types)
- Hard-coded org levels (flexible hierarchy via parent references)
- Complex graph database (simple junction tables)
- Event-driven org changes (direct queries)
- Automatic org chart generation (that's Phase 04)
If the AI suggests adding any of these, REJECT and continue with the spec.
Implementation Guidance
This phase creates a flexible org model. For early UI and features, focus on:
- Primary manager (skip dotted-line and additional managers initially)
- Primary department (one department per employee in most views)
- Team membership (simple list, no roles initially)
Advanced features (dotted-line managers, additional managers, org roles) can be exposed later in settings or advanced views.
Consider adding a summary endpoint that aggregates an employee's full org context:
{
"employee": {...},
"managers": { "primary": {...}, "dottedLine": [...], "additional": [...] },
"departments": [...],
"teams": [...]
}This prevents UI from making 4-5 calls per screen.
Step 43: Add Department Model
Input
- Phase 02 complete
- Employee model exists in schema
- Schema at:
packages/database/prisma/schema.prisma
Constraints
- DO NOT add any relations to Employee model yet (will be via EmployeeDepartment junction)
- DO NOT create any service or controller files
- ONLY modify schema.prisma to add Department model and DepartmentStatus enum
- Must add
departments Department[]to Tenant model
Task
Add to packages/database/prisma/schema.prisma:
// Add DepartmentStatus enum
enum DepartmentStatus {
ACTIVE
INACTIVE
}
// Add Department model
model Department {
id String @id @default(cuid())
tenantId String
name String
code String?
description String?
parentId String?
headId String?
status DepartmentStatus @default(ACTIVE)
customFields Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
parent Department? @relation("DepartmentHierarchy", fields: [parentId], references: [id])
children Department[] @relation("DepartmentHierarchy")
head Employee? @relation("DepartmentHead", fields: [headId], references: [id])
@@unique([tenantId, code])
@@index([tenantId, status])
@@map("departments")
}Also add to Employee model:
model Employee {
// ... existing fields ...
// Add leadership positions
ledDepartments Department[] @relation("DepartmentHead")
// ... rest of model ...
}Also add to Tenant model:
model Tenant {
// ... existing fields ...
departments Department[]
}Gate
cat packages/database/prisma/schema.prisma | grep -A 25 "model Department"
# Should show Department model with all fields
# Should show: tenantId, name, code, parentId, headId, status
# Should show: DepartmentHierarchy self-relationCommon Errors
| Error | Cause | Fix |
|---|---|---|
Unknown type "DepartmentStatus" | Enum not defined | Add enum before model |
Unknown type "Employee" | Employee model not found | Check Phase 02 complete |
Field "departments" references missing model | Circular reference | Ensure Department defined after Employee |
Rollback
# Remove Department model, DepartmentStatus enum, and Tenant.departments
git checkout packages/database/prisma/schema.prismaLock
packages/database/prisma/schema.prisma (Department model only - do not modify other models)Checkpoint
- DepartmentStatus enum exists
- Department model has all fields
- Self-referencing hierarchy relation works
- Tenant has departments relation
- Employee has ledDepartments relation
Step 44: Add Team Model
Input
- Step 43 complete
- Department model exists
Constraints
- DO NOT add EmployeeTeam junction yet (Step 50)
- DO NOT create any service files
- ONLY add Team model, TeamType enum, and TeamStatus enum
Task
Add to packages/database/prisma/schema.prisma:
// Add TeamType enum
enum TeamType {
PERMANENT
PROJECT
SQUAD
GUILD
TRIBE
}
// Add TeamStatus enum
enum TeamStatus {
ACTIVE
INACTIVE
ARCHIVED
}
// Add Team model
model Team {
id String @id @default(cuid())
tenantId String
name String
description String?
type TeamType @default(PERMANENT)
parentId String?
leadId String?
status TeamStatus @default(ACTIVE)
customFields Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
parent Team? @relation("TeamHierarchy", fields: [parentId], references: [id])
children Team[] @relation("TeamHierarchy")
lead Employee? @relation("TeamLead", fields: [leadId], references: [id])
@@index([tenantId, status])
@@map("teams")
}Also add to Employee model:
model Employee {
// ... existing fields ...
ledTeams Team[] @relation("TeamLead")
// ... rest of model ...
}Also add to Tenant model:
model Tenant {
// ... existing fields ...
teams Team[]
}Gate
cat packages/database/prisma/schema.prisma | grep -A 25 "model Team"
# Should show Team model with all fields
# Should show: TeamHierarchy self-relationCommon Errors
| Error | Cause | Fix |
|---|---|---|
Unknown type "TeamType" | Enum not defined | Add enum before model |
Unknown type "TeamStatus" | Enum not defined | Add enum before model |
Rollback
# Remove Team model, TeamType, TeamStatus from schema
git checkout packages/database/prisma/schema.prismaLock
packages/database/prisma/schema.prisma (Team model only)Checkpoint
- TeamType enum exists with all values
- TeamStatus enum exists
- Team model has all fields
- Self-referencing hierarchy works
- Employee.ledTeams relation added
Step 45: Add OrgRole Model
Input
- Steps 43-44 complete
- Department and Team models exist
Constraints
- DO NOT add EmployeeOrgRole junction yet (Step 51)
- ONLY add OrgRole model
Task
Add to packages/database/prisma/schema.prisma:
model OrgRole {
id String @id @default(cuid())
tenantId String
name String
category String?
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@unique([tenantId, name])
@@map("org_roles")
}Also add to Tenant model:
model Tenant {
// ... existing fields ...
orgRoles OrgRole[]
}Gate
cat packages/database/prisma/schema.prisma | grep -A 15 "model OrgRole"
# Should show OrgRole model
# Should show: @@unique([tenantId, name])Common Errors
| Error | Cause | Fix |
|---|---|---|
Unique constraint violation | Duplicate role name per tenant | name is unique per tenant, use different name |
Rollback
git checkout packages/database/prisma/schema.prismaLock
packages/database/prisma/schema.prisma (OrgRole model only)Checkpoint
- OrgRole model exists
- Unique constraint on [tenantId, name]
- Tenant.orgRoles relation added
Step 46: Add EmployeeOrgRelations Model
Input
- Steps 43-45 complete
- All org entity models exist
Constraints
- DO NOT add junction tables yet (Steps 47-51)
- This is the hub model that connects employee to all org relations
- One EmployeeOrgRelations per Employee (1:1)
Task
Add to packages/database/prisma/schema.prisma:
model EmployeeOrgRelations {
id String @id @default(cuid())
employeeId String @unique
// Primary (direct) manager - 0..1
primaryManagerId String?
updatedAt DateTime @updatedAt
// Relations
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
primaryManager Employee? @relation("PrimaryManager", fields: [primaryManagerId], references: [id])
@@map("employee_org_relations")
}Also add to Employee model:
model Employee {
// ... existing fields ...
// This employee's org relations (1:1)
orgRelations EmployeeOrgRelations?
// As a manager (receiving reports)
primaryReports EmployeeOrgRelations[] @relation("PrimaryManager")
// ... rest of model ...
}Gate
cat packages/database/prisma/schema.prisma | grep -A 20 "model EmployeeOrgRelations"
# Should show EmployeeOrgRelations model
# Should show: employeeId @unique (1:1 with Employee)
# Should show: primaryManagerId (optional manager reference)Common Errors
| Error | Cause | Fix |
|---|---|---|
Unknown type "EmployeeOrgRelations" | Model not defined | Ensure model is in schema |
| Ambiguous relation | Multiple relations to Employee | Use named relations like @relation("PrimaryManager") |
Rollback
git checkout packages/database/prisma/schema.prismaLock
packages/database/prisma/schema.prisma (EmployeeOrgRelations model only)Checkpoint
- EmployeeOrgRelations model exists
- 1:1 relation with Employee via @unique
- primaryManagerId field exists
- Employee.orgRelations relation added
- Employee.primaryReports relation added
Step 47: Add EmployeeDottedLine Junction
Input
- Step 46 complete
- EmployeeOrgRelations model exists
Constraints
- DO NOT modify EmployeeOrgRelations primaryManagerId
- This table is for dotted-line (secondary) managers only
Task
Add to packages/database/prisma/schema.prisma:
// Junction: Dotted Line Managers (0..N)
model EmployeeDottedLine {
id String @id @default(cuid())
orgRelationsId String
managerId String
createdAt DateTime @default(now())
orgRelations EmployeeOrgRelations @relation(fields: [orgRelationsId], references: [id], onDelete: Cascade)
manager Employee @relation("DottedLineManager", fields: [managerId], references: [id])
@@unique([orgRelationsId, managerId])
@@map("employee_dotted_lines")
}Also add to EmployeeOrgRelations model:
model EmployeeOrgRelations {
// ... existing fields ...
dottedLineManagers EmployeeDottedLine[]
}Also add to Employee model:
model Employee {
// ... existing fields ...
dottedLineReports EmployeeDottedLine[] @relation("DottedLineManager")
}Gate
cat packages/database/prisma/schema.prisma | grep -A 15 "model EmployeeDottedLine"
# Should show EmployeeDottedLine junction
# Should show: @@unique([orgRelationsId, managerId])Common Errors
| Error | Cause | Fix |
|---|---|---|
Unknown type "EmployeeOrgRelations" | Model not defined | Complete Step 46 first |
| Duplicate unique constraint | Same manager added twice | Unique constraint prevents this |
Rollback
git checkout packages/database/prisma/schema.prismaLock
packages/database/prisma/schema.prisma (EmployeeDottedLine model only)Checkpoint
- EmployeeDottedLine junction exists
- Unique constraint prevents duplicate entries
- EmployeeOrgRelations.dottedLineManagers relation added
- Employee.dottedLineReports relation added
Step 48: Add EmployeeAdditionalManager Junction
Input
- Step 47 complete
Constraints
- Same pattern as EmployeeDottedLine
- This is for additional real managers (not dotted-line)
Task
Add to packages/database/prisma/schema.prisma:
// Junction: Additional Real Managers (0..N)
model EmployeeAdditionalManager {
id String @id @default(cuid())
orgRelationsId String
managerId String
createdAt DateTime @default(now())
orgRelations EmployeeOrgRelations @relation(fields: [orgRelationsId], references: [id], onDelete: Cascade)
manager Employee @relation("AdditionalManager", fields: [managerId], references: [id])
@@unique([orgRelationsId, managerId])
@@map("employee_additional_managers")
}Also add to EmployeeOrgRelations model:
model EmployeeOrgRelations {
// ... existing fields ...
additionalManagers EmployeeAdditionalManager[]
}Also add to Employee model:
model Employee {
// ... existing fields ...
additionalReports EmployeeAdditionalManager[] @relation("AdditionalManager")
}Gate
cat packages/database/prisma/schema.prisma | grep -A 15 "model EmployeeAdditionalManager"
# Should show EmployeeAdditionalManager junctionCommon Errors
| Error | Cause | Fix |
|---|---|---|
Ambiguous relation | Missing relation name | Ensure @relation("AdditionalManager") is specified |
Rollback
git checkout packages/database/prisma/schema.prismaLock
packages/database/prisma/schema.prisma (EmployeeAdditionalManager model only)Checkpoint
- EmployeeAdditionalManager junction exists
- EmployeeOrgRelations.additionalManagers relation added
- Employee.additionalReports relation added
Step 49: Add EmployeeDepartment Junction
Input
- Step 48 complete
Constraints
- Include isPrimary flag for primary department designation
- Link to EmployeeOrgRelations (not directly to Employee)
Task
Add to packages/database/prisma/schema.prisma:
// Junction: Employee <-> Department (0..N)
model EmployeeDepartment {
id String @id @default(cuid())
orgRelationsId String
departmentId String
isPrimary Boolean @default(false)
createdAt DateTime @default(now())
orgRelations EmployeeOrgRelations @relation(fields: [orgRelationsId], references: [id], onDelete: Cascade)
department Department @relation(fields: [departmentId], references: [id])
@@unique([orgRelationsId, departmentId])
@@map("employee_departments")
}Also add to EmployeeOrgRelations model:
model EmployeeOrgRelations {
// ... existing fields ...
departments EmployeeDepartment[]
}Also add to Department model:
model Department {
// ... existing fields ...
employeeDepartments EmployeeDepartment[]
}Gate
cat packages/database/prisma/schema.prisma | grep -A 15 "model EmployeeDepartment"
# Should show EmployeeDepartment junction
# Should show: isPrimary BooleanCommon Errors
| Error | Cause | Fix |
|---|---|---|
Unknown type "Department" | Department model not found | Complete Step 43 first |
Rollback
git checkout packages/database/prisma/schema.prismaLock
packages/database/prisma/schema.prisma (EmployeeDepartment model only)Checkpoint
- EmployeeDepartment junction exists
- isPrimary flag available
- Department.employeeDepartments relation added
- EmployeeOrgRelations.departments relation added
Step 50: Add EmployeeTeam Junction
Input
- Step 49 complete
Constraints
- Include role field for team-specific role (e.g., "Tech Lead", "Member")
- Link to EmployeeOrgRelations
Task
Add to packages/database/prisma/schema.prisma:
// Junction: Employee <-> Team (0..N)
model EmployeeTeam {
id String @id @default(cuid())
orgRelationsId String
teamId String
role String? // Role within team: "Tech Lead", "Member", etc.
createdAt DateTime @default(now())
orgRelations EmployeeOrgRelations @relation(fields: [orgRelationsId], references: [id], onDelete: Cascade)
team Team @relation(fields: [teamId], references: [id])
@@unique([orgRelationsId, teamId])
@@map("employee_teams")
}Also add to EmployeeOrgRelations model:
model EmployeeOrgRelations {
// ... existing fields ...
teams EmployeeTeam[]
}Also add to Team model:
model Team {
// ... existing fields ...
employeeTeams EmployeeTeam[]
}Gate
cat packages/database/prisma/schema.prisma | grep -A 15 "model EmployeeTeam"
# Should show EmployeeTeam junction
# Should show: role String?Common Errors
| Error | Cause | Fix |
|---|---|---|
Unknown type "Team" | Team model not found | Complete Step 44 first |
Rollback
git checkout packages/database/prisma/schema.prismaLock
packages/database/prisma/schema.prisma (EmployeeTeam model only)Checkpoint
- EmployeeTeam junction exists
- role field available
- Team.employeeTeams relation added
- EmployeeOrgRelations.teams relation added
Step 51: Add EmployeeOrgRole Junction
Input
- Step 50 complete
Constraints
- Include isPrimary flag for primary role designation
- Link to EmployeeOrgRelations
Task
Add to packages/database/prisma/schema.prisma:
// Junction: Employee <-> OrgRole (0..N)
model EmployeeOrgRole {
id String @id @default(cuid())
orgRelationsId String
roleId String
isPrimary Boolean @default(false)
createdAt DateTime @default(now())
orgRelations EmployeeOrgRelations @relation(fields: [orgRelationsId], references: [id], onDelete: Cascade)
role OrgRole @relation(fields: [roleId], references: [id])
@@unique([orgRelationsId, roleId])
@@map("employee_org_roles")
}Also add to EmployeeOrgRelations model:
model EmployeeOrgRelations {
// ... existing fields ...
roles EmployeeOrgRole[]
}Also add to OrgRole model:
model OrgRole {
// ... existing fields ...
employeeRoles EmployeeOrgRole[]
}Gate
cat packages/database/prisma/schema.prisma | grep -A 15 "model EmployeeOrgRole"
# Should show EmployeeOrgRole junction
# Should show: isPrimary BooleanCommon Errors
| Error | Cause | Fix |
|---|---|---|
Unknown type "OrgRole" | OrgRole model not found | Complete Step 45 first |
Rollback
git checkout packages/database/prisma/schema.prismaLock
packages/database/prisma/schema.prisma (EmployeeOrgRole model only)Checkpoint
- EmployeeOrgRole junction exists
- isPrimary flag available
- OrgRole.employeeRoles relation added
- EmployeeOrgRelations.roles relation added
Step 52: Run Migration for Org Structure
Input
- Steps 43-51 complete
- All org models defined in schema
Constraints
- DO NOT modify any models during migration
- Run prisma format first to validate schema
Task
cd packages/database
# Format and validate schema
npx prisma format
npx prisma validate
# Push schema changes to database
npx prisma db push
# Generate Prisma client
npx prisma generateGate
# Verify all tables exist
npx prisma db execute --stdin <<< "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name IN ('departments', 'teams', 'org_roles', 'employee_org_relations', 'employee_dotted_lines', 'employee_additional_managers', 'employee_departments', 'employee_teams', 'employee_org_roles');"
# Should return 9 rows (one for each table)
# Or use Prisma Studio to verify
npx prisma studioCommon Errors
| Error | Cause | Fix |
|---|---|---|
Validation error | Schema syntax error | Run npx prisma format and fix errors |
Foreign key constraint | Referenced model missing | Ensure all models defined |
Relation cycle | Circular self-references | Use named relations with @relation() |
Rollback
# Reset database (WARNING: deletes all data)
npx prisma migrate reset
# Or restore from backup if in productionLock
packages/database/prisma/schema.prisma (entire org structure - do not modify models after migration)Checkpoint
-
npx prisma validatepasses -
npx prisma db pushsucceeds - All 9 new tables exist in database
- Prisma Studio shows new models
Step 53: Create DepartmentRepository
Input
- Step 52 complete
- Database tables exist
- Prisma client generated
Constraints
- DO NOT include employee assignment methods (that goes in OrgService)
- ONLY CRUD operations with tenant filtering
- Follow same pattern as EmployeeRepository from Phase 02
Task
Create apps/api/src/departments/departments.repository.ts:
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { Prisma, DepartmentStatus } from '@prisma/client';
@Injectable()
export class DepartmentsRepository {
constructor(private prisma: PrismaService) {}
async findAll(tenantId: string, options?: {
status?: DepartmentStatus;
parentId?: string | null;
includeChildren?: boolean;
}) {
return this.prisma.department.findMany({
where: {
tenantId,
...(options?.status && { status: options.status }),
...(options?.parentId !== undefined && { parentId: options.parentId }),
},
include: {
head: {
select: { id: true, firstName: true, lastName: true },
},
parent: options?.includeChildren ? undefined : {
select: { id: true, name: true },
},
children: options?.includeChildren ? true : undefined,
_count: {
select: { employeeDepartments: true },
},
},
orderBy: { name: 'asc' },
});
}
async findOne(tenantId: string, id: string) {
return this.prisma.department.findFirst({
where: { id, tenantId },
include: {
head: {
select: { id: true, firstName: true, lastName: true, email: true },
},
parent: {
select: { id: true, name: true },
},
children: {
select: { id: true, name: true },
},
_count: {
select: { employeeDepartments: true },
},
},
});
}
async findByCode(tenantId: string, code: string) {
return this.prisma.department.findFirst({
where: { tenantId, code },
});
}
async create(tenantId: string, data: {
name: string;
code?: string;
description?: string;
parentId?: string;
headId?: string;
status?: DepartmentStatus;
customFields?: Prisma.JsonValue;
}) {
return this.prisma.department.create({
data: {
...data,
tenant: { connect: { id: tenantId } },
...(data.parentId && { parent: { connect: { id: data.parentId } } }),
...(data.headId && { head: { connect: { id: data.headId } } }),
},
include: {
head: {
select: { id: true, firstName: true, lastName: true },
},
parent: {
select: { id: true, name: true },
},
},
});
}
async update(tenantId: string, id: string, data: {
name?: string;
code?: string;
description?: string;
parentId?: string | null;
headId?: string | null;
status?: DepartmentStatus;
customFields?: Prisma.JsonValue;
}) {
return this.prisma.department.updateMany({
where: { id, tenantId },
data: {
...data,
updatedAt: new Date(),
},
}).then(async () => {
return this.findOne(tenantId, id);
});
}
async delete(tenantId: string, id: string) {
// Soft delete by setting status to INACTIVE
return this.prisma.department.updateMany({
where: { id, tenantId },
data: { status: DepartmentStatus.INACTIVE },
});
}
async hardDelete(tenantId: string, id: string) {
return this.prisma.department.deleteMany({
where: { id, tenantId },
});
}
/**
* Gets all ancestor departments (for hierarchy display).
*
* Performance: O(depth) Prisma calls. Acceptable for < 10 levels.
* For large orgs, consider batched ID fetch or recursive CTE.
*/
async getAncestors(tenantId: string, departmentId: string): Promise<string[]> {
const ancestors: string[] = [];
let currentId: string | null = departmentId;
while (currentId) {
const dept = await this.prisma.department.findFirst({
where: { id: currentId, tenantId },
select: { parentId: true },
});
if (dept?.parentId) {
ancestors.push(dept.parentId);
currentId = dept.parentId;
} else {
currentId = null;
}
}
return ancestors;
}
// Check if making dept a child of newParent would create a cycle
async wouldCreateCycle(tenantId: string, departmentId: string, newParentId: string): Promise<boolean> {
if (departmentId === newParentId) return true;
const ancestors = await this.getAncestors(tenantId, newParentId);
return ancestors.includes(departmentId);
}
}Create directory and index:
cd apps/api
mkdir -p src/departmentsCreate apps/api/src/departments/index.ts:
export * from './departments.repository';Gate
cd apps/api
npx tsc --noEmit
# Should compile without errorsCommon Errors
| Error | Cause | Fix |
|---|---|---|
Cannot find module '../prisma/prisma.service' | PrismaService not available | Import from correct location or create |
Type 'DepartmentStatus' not found | Prisma client not generated | Run npx prisma generate in packages/database |
Rollback
rm -rf apps/api/src/departmentsLock
apps/api/src/departments/departments.repository.tsCheckpoint
- DepartmentsRepository file exists
- All CRUD methods implemented
- Tenant filtering on all queries
- Cycle detection method exists
- TypeScript compiles
Step 54: Create DepartmentService
Input
- Step 53 complete
- DepartmentsRepository exists
Constraints
- DO NOT include employee assignment (that's in OrgService)
- Include hierarchy validation (no cycles)
- Use NotFoundException from @nestjs/common
Task
Create apps/api/src/departments/departments.service.ts:
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { DepartmentsRepository } from './departments.repository';
import { DepartmentStatus, Prisma } from '@prisma/client';
@Injectable()
export class DepartmentsService {
constructor(private repository: DepartmentsRepository) {}
async findAll(tenantId: string, options?: {
status?: DepartmentStatus;
parentId?: string | null;
includeChildren?: boolean;
}) {
return this.repository.findAll(tenantId, options);
}
async findOne(tenantId: string, id: string) {
const department = await this.repository.findOne(tenantId, id);
if (!department) {
throw new NotFoundException(`Department with ID ${id} not found`);
}
return department;
}
async findByCode(tenantId: string, code: string) {
return this.repository.findByCode(tenantId, code);
}
async create(tenantId: string, data: {
name: string;
code?: string;
description?: string;
parentId?: string;
headId?: string;
status?: DepartmentStatus;
customFields?: Prisma.JsonValue;
}) {
// Validate parent exists if provided
if (data.parentId) {
const parent = await this.repository.findOne(tenantId, data.parentId);
if (!parent) {
throw new BadRequestException(`Parent department with ID ${data.parentId} not found`);
}
}
// Check code uniqueness
if (data.code) {
const existing = await this.repository.findByCode(tenantId, data.code);
if (existing) {
throw new BadRequestException(`Department with code ${data.code} already exists`);
}
}
return this.repository.create(tenantId, data);
}
async update(tenantId: string, id: string, data: {
name?: string;
code?: string;
description?: string;
parentId?: string | null;
headId?: string | null;
status?: DepartmentStatus;
customFields?: Prisma.JsonValue;
}) {
// Verify department exists
await this.findOne(tenantId, id);
// Validate new parent if changing
if (data.parentId !== undefined && data.parentId !== null) {
// Check parent exists
const parent = await this.repository.findOne(tenantId, data.parentId);
if (!parent) {
throw new BadRequestException(`Parent department with ID ${data.parentId} not found`);
}
// Check for cycle
const wouldCycle = await this.repository.wouldCreateCycle(tenantId, id, data.parentId);
if (wouldCycle) {
throw new BadRequestException('Cannot set parent: would create circular reference');
}
}
// Check code uniqueness if changing
if (data.code) {
const existing = await this.repository.findByCode(tenantId, data.code);
if (existing && existing.id !== id) {
throw new BadRequestException(`Department with code ${data.code} already exists`);
}
}
return this.repository.update(tenantId, id, data);
}
async delete(tenantId: string, id: string) {
await this.findOne(tenantId, id);
return this.repository.delete(tenantId, id);
}
// Get hierarchy starting from a department
async getHierarchy(tenantId: string, rootId?: string) {
if (rootId) {
await this.findOne(tenantId, rootId);
}
return this.repository.findAll(tenantId, {
parentId: rootId ?? null,
includeChildren: true,
});
}
}Update apps/api/src/departments/index.ts:
export * from './departments.repository';
export * from './departments.service';Gate
cd apps/api
npx tsc --noEmit
# Should compile without errorsCommon Errors
| Error | Cause | Fix |
|---|---|---|
Cannot find module './departments.repository' | Repository not exported | Check index.ts exports |
Circular dependency | Imports cycle | Check import structure |
Rollback
rm apps/api/src/departments/departments.service.tsLock
apps/api/src/departments/departments.service.tsCheckpoint
- DepartmentsService file exists
- Hierarchy validation prevents cycles
- NotFoundException thrown for missing resources
- Code uniqueness validated
- TypeScript compiles
Step 55: Create DepartmentController
Input
- Step 54 complete
- DepartmentsService exists
Constraints
- Use TenantGuard from
../common/guards - Follow REST conventions from API Design doc
- Return
{ data, error }format
Task
Create DTOs in apps/api/src/departments/dto/:
mkdir -p apps/api/src/departments/dtoCreate apps/api/src/departments/dto/create-department.dto.ts:
import { IsString, IsOptional, IsEnum, IsObject } from 'class-validator';
import { DepartmentStatus } from '@prisma/client';
export class CreateDepartmentDto {
@IsString()
name: string;
@IsOptional()
@IsString()
code?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsString()
parentId?: string;
@IsOptional()
@IsString()
headId?: string;
@IsOptional()
@IsEnum(DepartmentStatus)
status?: DepartmentStatus;
@IsOptional()
@IsObject()
customFields?: Record<string, unknown>;
}Create apps/api/src/departments/dto/update-department.dto.ts:
import { PartialType } from '@nestjs/mapped-types';
import { CreateDepartmentDto } from './create-department.dto';
export class UpdateDepartmentDto extends PartialType(CreateDepartmentDto) {}Create apps/api/src/departments/dto/index.ts:
export * from './create-department.dto';
export * from './update-department.dto';Create apps/api/src/departments/departments.controller.ts:
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
UseGuards,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { DepartmentsService } from './departments.service';
import { CreateDepartmentDto, UpdateDepartmentDto } from './dto';
import { TenantGuard } from '../common/guards';
import { TenantId } from '../common/decorators';
import { DepartmentStatus } from '@prisma/client';
@Controller('api/v1/departments')
@UseGuards(TenantGuard)
export class DepartmentsController {
constructor(private readonly service: DepartmentsService) {}
@Get()
async findAll(
@TenantId() tenantId: string,
@Query('status') status?: DepartmentStatus,
@Query('parentId') parentId?: string,
) {
const data = await this.service.findAll(tenantId, {
status,
parentId: parentId === 'null' ? null : parentId,
});
return { data, error: null };
}
@Get('hierarchy')
async getHierarchy(
@TenantId() tenantId: string,
@Query('rootId') rootId?: string,
) {
const data = await this.service.getHierarchy(tenantId, rootId);
return { data, error: null };
}
@Get(':id')
async findOne(
@TenantId() tenantId: string,
@Param('id') id: string,
) {
const data = await this.service.findOne(tenantId, id);
return { data, error: null };
}
@Post()
@HttpCode(HttpStatus.CREATED)
async create(
@TenantId() tenantId: string,
@Body() dto: CreateDepartmentDto,
) {
const data = await this.service.create(tenantId, dto);
return { data, error: null };
}
@Patch(':id')
async update(
@TenantId() tenantId: string,
@Param('id') id: string,
@Body() dto: UpdateDepartmentDto,
) {
const data = await this.service.update(tenantId, id, dto);
return { data, error: null };
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async delete(
@TenantId() tenantId: string,
@Param('id') id: string,
) {
await this.service.delete(tenantId, id);
}
}Create apps/api/src/departments/departments.module.ts:
import { Module } from '@nestjs/common';
import { DepartmentsController } from './departments.controller';
import { DepartmentsService } from './departments.service';
import { DepartmentsRepository } from './departments.repository';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [DepartmentsController],
providers: [DepartmentsService, DepartmentsRepository],
exports: [DepartmentsService, DepartmentsRepository],
})
export class DepartmentsModule {}Update apps/api/src/departments/index.ts:
export * from './departments.repository';
export * from './departments.service';
export * from './departments.controller';
export * from './departments.module';
export * from './dto';Register module in apps/api/src/app.module.ts:
import { DepartmentsModule } from './departments/departments.module';
@Module({
imports: [
// ... existing imports
DepartmentsModule,
],
// ...
})
export class AppModule {}Gate
cd apps/api
npm run build
# Should build without errors
# Test endpoint (after starting API)
curl http://localhost:3001/api/v1/departments \
-H "x-tenant-id: <your-tenant-id>"
# Should return { data: [], error: null }Common Errors
| Error | Cause | Fix |
|---|---|---|
Cannot find module '../common/guards' | Guards not at expected path | Check Phase 01 guard location |
Module not found | Module not registered | Add to AppModule imports |
Rollback
rm apps/api/src/departments/departments.controller.ts
rm apps/api/src/departments/departments.module.ts
rm -rf apps/api/src/departments/dtoLock
apps/api/src/departments/departments.controller.ts
apps/api/src/departments/departments.module.ts
apps/api/src/departments/dto/*Checkpoint
- DTOs created with validation
- Controller created with all endpoints
- Module created and registered
- API builds successfully
- Endpoints respond correctly
Step 56: Create Team Repository/Service/Controller
Input
- Step 55 complete
- Department module pattern established
Constraints
- Follow same pattern as Department module
- Include team type filtering
- DO NOT include employee assignment (that's in OrgService)
Task
Create team module following the same pattern as departments:
mkdir -p apps/api/src/teams/dtoCreate apps/api/src/teams/teams.repository.ts:
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { Prisma, TeamStatus, TeamType } from '@prisma/client';
@Injectable()
export class TeamsRepository {
constructor(private prisma: PrismaService) {}
async findAll(tenantId: string, options?: {
status?: TeamStatus;
type?: TeamType;
parentId?: string | null;
}) {
return this.prisma.team.findMany({
where: {
tenantId,
...(options?.status && { status: options.status }),
...(options?.type && { type: options.type }),
...(options?.parentId !== undefined && { parentId: options.parentId }),
},
include: {
lead: {
select: { id: true, firstName: true, lastName: true },
},
parent: {
select: { id: true, name: true },
},
_count: {
select: { employeeTeams: true },
},
},
orderBy: { name: 'asc' },
});
}
async findOne(tenantId: string, id: string) {
return this.prisma.team.findFirst({
where: { id, tenantId },
include: {
lead: {
select: { id: true, firstName: true, lastName: true, email: true },
},
parent: {
select: { id: true, name: true },
},
children: {
select: { id: true, name: true },
},
_count: {
select: { employeeTeams: true },
},
},
});
}
async create(tenantId: string, data: {
name: string;
description?: string;
type?: TeamType;
parentId?: string;
leadId?: string;
status?: TeamStatus;
customFields?: Prisma.JsonValue;
}) {
return this.prisma.team.create({
data: {
...data,
tenant: { connect: { id: tenantId } },
...(data.parentId && { parent: { connect: { id: data.parentId } } }),
...(data.leadId && { lead: { connect: { id: data.leadId } } }),
},
include: {
lead: {
select: { id: true, firstName: true, lastName: true },
},
},
});
}
async update(tenantId: string, id: string, data: {
name?: string;
description?: string;
type?: TeamType;
parentId?: string | null;
leadId?: string | null;
status?: TeamStatus;
customFields?: Prisma.JsonValue;
}) {
return this.prisma.team.updateMany({
where: { id, tenantId },
data: {
...data,
updatedAt: new Date(),
},
}).then(async () => {
return this.findOne(tenantId, id);
});
}
async delete(tenantId: string, id: string) {
return this.prisma.team.updateMany({
where: { id, tenantId },
data: { status: TeamStatus.INACTIVE },
});
}
/**
* Checks if setting newParentId as parent would create a cycle.
*
* Performance: O(depth) Prisma calls. Acceptable for < 10 levels.
* For deep hierarchies, consider caching or batch queries.
*/
async wouldCreateCycle(tenantId: string, teamId: string, newParentId: string): Promise<boolean> {
if (teamId === newParentId) return true;
let currentId: string | null = newParentId;
while (currentId) {
const team = await this.prisma.team.findFirst({
where: { id: currentId, tenantId },
select: { parentId: true },
});
if (team?.parentId === teamId) return true;
currentId = team?.parentId ?? null;
}
return false;
}
}Create apps/api/src/teams/teams.service.ts:
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { TeamsRepository } from './teams.repository';
import { TeamStatus, TeamType, Prisma } from '@prisma/client';
@Injectable()
export class TeamsService {
constructor(private repository: TeamsRepository) {}
async findAll(tenantId: string, options?: {
status?: TeamStatus;
type?: TeamType;
parentId?: string | null;
}) {
return this.repository.findAll(tenantId, options);
}
async findOne(tenantId: string, id: string) {
const team = await this.repository.findOne(tenantId, id);
if (!team) {
throw new NotFoundException(`Team with ID ${id} not found`);
}
return team;
}
async create(tenantId: string, data: {
name: string;
description?: string;
type?: TeamType;
parentId?: string;
leadId?: string;
status?: TeamStatus;
customFields?: Prisma.JsonValue;
}) {
if (data.parentId) {
const parent = await this.repository.findOne(tenantId, data.parentId);
if (!parent) {
throw new BadRequestException(`Parent team with ID ${data.parentId} not found`);
}
}
return this.repository.create(tenantId, data);
}
async update(tenantId: string, id: string, data: {
name?: string;
description?: string;
type?: TeamType;
parentId?: string | null;
leadId?: string | null;
status?: TeamStatus;
customFields?: Prisma.JsonValue;
}) {
await this.findOne(tenantId, id);
if (data.parentId !== undefined && data.parentId !== null) {
const parent = await this.repository.findOne(tenantId, data.parentId);
if (!parent) {
throw new BadRequestException(`Parent team with ID ${data.parentId} not found`);
}
const wouldCycle = await this.repository.wouldCreateCycle(tenantId, id, data.parentId);
if (wouldCycle) {
throw new BadRequestException('Cannot set parent: would create circular reference');
}
}
return this.repository.update(tenantId, id, data);
}
async delete(tenantId: string, id: string) {
await this.findOne(tenantId, id);
return this.repository.delete(tenantId, id);
}
}Create DTOs:
apps/api/src/teams/dto/create-team.dto.ts:
import { IsString, IsOptional, IsEnum, IsObject } from 'class-validator';
import { TeamStatus, TeamType } from '@prisma/client';
export class CreateTeamDto {
@IsString()
name: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsEnum(TeamType)
type?: TeamType;
@IsOptional()
@IsString()
parentId?: string;
@IsOptional()
@IsString()
leadId?: string;
@IsOptional()
@IsEnum(TeamStatus)
status?: TeamStatus;
@IsOptional()
@IsObject()
customFields?: Record<string, unknown>;
}apps/api/src/teams/dto/update-team.dto.ts:
import { PartialType } from '@nestjs/mapped-types';
import { CreateTeamDto } from './create-team.dto';
export class UpdateTeamDto extends PartialType(CreateTeamDto) {}apps/api/src/teams/dto/index.ts:
export * from './create-team.dto';
export * from './update-team.dto';Create apps/api/src/teams/teams.controller.ts:
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
UseGuards,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { TeamsService } from './teams.service';
import { CreateTeamDto, UpdateTeamDto } from './dto';
import { TenantGuard } from '../common/guards';
import { TenantId } from '../common/decorators';
import { TeamStatus, TeamType } from '@prisma/client';
@Controller('api/v1/teams')
@UseGuards(TenantGuard)
export class TeamsController {
constructor(private readonly service: TeamsService) {}
@Get()
async findAll(
@TenantId() tenantId: string,
@Query('status') status?: TeamStatus,
@Query('type') type?: TeamType,
@Query('parentId') parentId?: string,
) {
const data = await this.service.findAll(tenantId, {
status,
type,
parentId: parentId === 'null' ? null : parentId,
});
return { data, error: null };
}
@Get(':id')
async findOne(
@TenantId() tenantId: string,
@Param('id') id: string,
) {
const data = await this.service.findOne(tenantId, id);
return { data, error: null };
}
@Post()
@HttpCode(HttpStatus.CREATED)
async create(
@TenantId() tenantId: string,
@Body() dto: CreateTeamDto,
) {
const data = await this.service.create(tenantId, dto);
return { data, error: null };
}
@Patch(':id')
async update(
@TenantId() tenantId: string,
@Param('id') id: string,
@Body() dto: UpdateTeamDto,
) {
const data = await this.service.update(tenantId, id, dto);
return { data, error: null };
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async delete(
@TenantId() tenantId: string,
@Param('id') id: string,
) {
await this.service.delete(tenantId, id);
}
}Create apps/api/src/teams/teams.module.ts:
import { Module } from '@nestjs/common';
import { TeamsController } from './teams.controller';
import { TeamsService } from './teams.service';
import { TeamsRepository } from './teams.repository';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [TeamsController],
providers: [TeamsService, TeamsRepository],
exports: [TeamsService, TeamsRepository],
})
export class TeamsModule {}Create apps/api/src/teams/index.ts:
export * from './teams.repository';
export * from './teams.service';
export * from './teams.controller';
export * from './teams.module';
export * from './dto';Register module in apps/api/src/app.module.ts:
import { TeamsModule } from './teams/teams.module';
@Module({
imports: [
// ... existing imports
TeamsModule,
],
// ...
})
export class AppModule {}Gate
cd apps/api
npm run build
# Should build without errors
# Test endpoint
curl http://localhost:3001/api/v1/teams \
-H "x-tenant-id: <your-tenant-id>"
# Should return { data: [], error: null }Common Errors
| Error | Cause | Fix |
|---|---|---|
Unknown type "TeamType" | Prisma client not generated | Run npx prisma generate |
Module not found | Module not registered | Add TeamsModule to AppModule |
Rollback
rm -rf apps/api/src/teamsLock
apps/api/src/teams/*Checkpoint
- TeamsRepository created
- TeamsService created
- TeamsController created
- TeamsModule registered
- API builds and endpoints work
Step 57: Create OrgService getDirectReports
Input
- Steps 53-56 complete
- Department and Team modules exist
Constraints
- Create new org module for cross-cutting org operations
- getDirectReports returns employees where primaryManagerId matches
- DO NOT modify department or team modules
Task
mkdir -p apps/api/src/orgCreate apps/api/src/org/org.repository.ts:
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { EmployeeStatus } from '@prisma/client';
@Injectable()
export class OrgRepository {
constructor(private prisma: PrismaService) {}
// Get employees who report directly to a manager
async getDirectReports(tenantId: string, managerId: string) {
return this.prisma.employee.findMany({
where: {
tenantId,
status: { not: EmployeeStatus.TERMINATED },
orgRelations: {
primaryManagerId: managerId,
},
},
select: {
id: true,
firstName: true,
lastName: true,
email: true,
jobTitle: true,
status: true,
orgRelations: {
select: {
primaryManagerId: true,
},
},
},
orderBy: [
{ lastName: 'asc' },
{ firstName: 'asc' },
],
});
}
// Get or create org relations for an employee
async getOrCreateOrgRelations(employeeId: string) {
let orgRelations = await this.prisma.employeeOrgRelations.findUnique({
where: { employeeId },
});
if (!orgRelations) {
orgRelations = await this.prisma.employeeOrgRelations.create({
data: { employeeId },
});
}
return orgRelations;
}
// Get employee with tenant validation
async getEmployee(tenantId: string, employeeId: string) {
return this.prisma.employee.findFirst({
where: { id: employeeId, tenantId },
});
}
}Create apps/api/src/org/org.service.ts:
import { Injectable, NotFoundException } from '@nestjs/common';
import { OrgRepository } from './org.repository';
@Injectable()
export class OrgService {
constructor(private repository: OrgRepository) {}
async getDirectReports(tenantId: string, managerId: string) {
// Verify manager exists in tenant
const manager = await this.repository.getEmployee(tenantId, managerId);
if (!manager) {
throw new NotFoundException(`Manager with ID ${managerId} not found`);
}
return this.repository.getDirectReports(tenantId, managerId);
}
}Create apps/api/src/org/index.ts:
export * from './org.repository';
export * from './org.service';Gate
cd apps/api
npx tsc --noEmit
# Should compile without errorsCommon Errors
| Error | Cause | Fix |
|---|---|---|
Cannot find name 'Employee' | Wrong import | Use Prisma client types |
orgRelations property not found | Schema not migrated | Run db push in Step 52 |
Rollback
rm -rf apps/api/src/orgLock
apps/api/src/org/org.repository.ts
apps/api/src/org/org.service.tsCheckpoint
- OrgRepository created
- OrgService.getDirectReports works
- Returns employees with that primaryManagerId
- TypeScript compiles
Step 58: Create OrgService getManagers
Input
- Step 57 complete
- OrgService exists
Constraints
- Return all manager types: primary, dotted-line, additional
- Include manager details (name, email, title)
Task
Add to apps/api/src/org/org.repository.ts:
// Add this method to OrgRepository class
// Get all managers for an employee
async getManagers(tenantId: string, employeeId: string) {
const orgRelations = await this.prisma.employeeOrgRelations.findUnique({
where: { employeeId },
include: {
primaryManager: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
jobTitle: true,
},
},
dottedLineManagers: {
include: {
manager: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
jobTitle: true,
},
},
},
},
additionalManagers: {
include: {
manager: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
jobTitle: true,
},
},
},
},
},
});
if (!orgRelations) {
return {
primary: null,
dottedLine: [],
additional: [],
};
}
return {
primary: orgRelations.primaryManager,
dottedLine: orgRelations.dottedLineManagers.map(dl => dl.manager),
additional: orgRelations.additionalManagers.map(am => am.manager),
};
}Add to apps/api/src/org/org.service.ts:
// Add this method to OrgService class
async getManagers(tenantId: string, employeeId: string) {
// Verify employee exists in tenant
const employee = await this.repository.getEmployee(tenantId, employeeId);
if (!employee) {
throw new NotFoundException(`Employee with ID ${employeeId} not found`);
}
return this.repository.getManagers(tenantId, employeeId);
}Gate
cd apps/api
npx tsc --noEmit
# Should compile without errorsCommon Errors
| Error | Cause | Fix |
|---|---|---|
dottedLineManagers not found on type | Relation not added to schema | Check Step 47 complete |
Rollback
# Remove the new methods from org.repository.ts and org.service.tsLock
apps/api/src/org/org.repository.ts (getManagers method)
apps/api/src/org/org.service.ts (getManagers method)Checkpoint
- getManagers returns primary, dottedLine, additional arrays
- Each manager includes id, name, email, title
- Returns empty arrays if no org relations exist
Step 59: Create OrgService setManager
Input
- Step 58 complete
- getManagers method exists
Constraints
- Include circular reference prevention
- Support setting/clearing primary manager
- Support adding/removing dotted-line and additional managers
Task
Add to apps/api/src/org/org.repository.ts:
// Add these methods to OrgRepository class
// Set primary manager
async setPrimaryManager(employeeId: string, managerId: string | null) {
const orgRelations = await this.getOrCreateOrgRelations(employeeId);
return this.prisma.employeeOrgRelations.update({
where: { id: orgRelations.id },
data: { primaryManagerId: managerId },
});
}
// Add dotted-line manager
async addDottedLineManager(employeeId: string, managerId: string) {
const orgRelations = await this.getOrCreateOrgRelations(employeeId);
return this.prisma.employeeDottedLine.create({
data: {
orgRelationsId: orgRelations.id,
managerId,
},
});
}
// Remove dotted-line manager
async removeDottedLineManager(employeeId: string, managerId: string) {
const orgRelations = await this.prisma.employeeOrgRelations.findUnique({
where: { employeeId },
});
if (!orgRelations) return null;
return this.prisma.employeeDottedLine.deleteMany({
where: {
orgRelationsId: orgRelations.id,
managerId,
},
});
}
// Add additional manager
async addAdditionalManager(employeeId: string, managerId: string) {
const orgRelations = await this.getOrCreateOrgRelations(employeeId);
return this.prisma.employeeAdditionalManager.create({
data: {
orgRelationsId: orgRelations.id,
managerId,
},
});
}
// Remove additional manager
async removeAdditionalManager(employeeId: string, managerId: string) {
const orgRelations = await this.prisma.employeeOrgRelations.findUnique({
where: { employeeId },
});
if (!orgRelations) return null;
return this.prisma.employeeAdditionalManager.deleteMany({
where: {
orgRelationsId: orgRelations.id,
managerId,
},
});
}
/**
* Checks if setting manager would create circular reference.
*
* Performance: O(chain depth) Prisma calls. Acceptable for < 20 levels.
* For very deep org structures, consider caching manager chains.
*/
async wouldCreateCircularReference(tenantId: string, employeeId: string, managerId: string): Promise<boolean> {
// An employee cannot be their own manager
if (employeeId === managerId) return true;
// Check if employee is already in the manager's chain
let currentManagerId: string | null = managerId;
const visited = new Set<string>();
while (currentManagerId) {
if (visited.has(currentManagerId)) {
// Detected a cycle in existing data
return true;
}
visited.add(currentManagerId);
if (currentManagerId === employeeId) {
return true;
}
const orgRelations = await this.prisma.employeeOrgRelations.findUnique({
where: { employeeId: currentManagerId },
select: { primaryManagerId: true },
});
currentManagerId = orgRelations?.primaryManagerId ?? null;
}
return false;
}Add to apps/api/src/org/org.service.ts:
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
// Add these methods to OrgService class
async setManager(
tenantId: string,
employeeId: string,
managerId: string | null,
type: 'primary' | 'dotted-line' | 'additional',
) {
// Verify employee exists
const employee = await this.repository.getEmployee(tenantId, employeeId);
if (!employee) {
throw new NotFoundException(`Employee with ID ${employeeId} not found`);
}
// If setting a manager (not clearing), verify manager exists
if (managerId) {
const manager = await this.repository.getEmployee(tenantId, managerId);
if (!manager) {
throw new NotFoundException(`Manager with ID ${managerId} not found`);
}
// Check for circular reference (only for primary manager)
if (type === 'primary') {
const wouldCycle = await this.repository.wouldCreateCircularReference(
tenantId,
employeeId,
managerId,
);
if (wouldCycle) {
throw new BadRequestException('Cannot set manager: would create circular reference');
}
}
}
switch (type) {
case 'primary':
return this.repository.setPrimaryManager(employeeId, managerId);
case 'dotted-line':
if (!managerId) {
throw new BadRequestException('Manager ID required for dotted-line manager');
}
return this.repository.addDottedLineManager(employeeId, managerId);
case 'additional':
if (!managerId) {
throw new BadRequestException('Manager ID required for additional manager');
}
return this.repository.addAdditionalManager(employeeId, managerId);
default:
throw new BadRequestException(`Invalid manager type: ${type}`);
}
}
async removeManager(
tenantId: string,
employeeId: string,
managerId: string,
type: 'dotted-line' | 'additional',
) {
// Verify employee exists
const employee = await this.repository.getEmployee(tenantId, employeeId);
if (!employee) {
throw new NotFoundException(`Employee with ID ${employeeId} not found`);
}
switch (type) {
case 'dotted-line':
return this.repository.removeDottedLineManager(employeeId, managerId);
case 'additional':
return this.repository.removeAdditionalManager(employeeId, managerId);
default:
throw new BadRequestException(`Invalid manager type: ${type}`);
}
}Gate
cd apps/api
npx tsc --noEmit
# Should compile without errorsCommon Errors
| Error | Cause | Fix |
|---|---|---|
Unique constraint violation | Manager already assigned | Check before adding |
| Circular reference not detected | Logic error | Test with A->B->A scenario |
Rollback
# Remove the new methods from org.repository.ts and org.service.tsLock
apps/api/src/org/org.repository.ts (manager methods)
apps/api/src/org/org.service.ts (setManager, removeManager methods)Checkpoint
- setPrimaryManager works
- addDottedLineManager works
- addAdditionalManager works
- Circular reference prevention works
- Remove methods work
Step 60: Create Manager Assignment Endpoint
Input
- Step 59 complete
- OrgService manager methods exist
Constraints
- Create OrgController for org-related endpoints
- Create module and register
Task
Create apps/api/src/org/dto/set-manager.dto.ts:
import { IsString, IsOptional, IsEnum } from 'class-validator';
export enum ManagerType {
PRIMARY = 'primary',
DOTTED_LINE = 'dotted-line',
ADDITIONAL = 'additional',
}
export class SetManagerDto {
@IsOptional()
@IsString()
managerId?: string;
@IsEnum(ManagerType)
type: ManagerType;
}
export class RemoveManagerDto {
@IsString()
managerId: string;
@IsEnum(ManagerType)
type: ManagerType;
}Create apps/api/src/org/dto/index.ts:
export * from './set-manager.dto';Create apps/api/src/org/org.controller.ts:
import {
Controller,
Get,
Post,
Delete,
Body,
Param,
Query,
UseGuards,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { OrgService } from './org.service';
import { SetManagerDto, ManagerType } from './dto/set-manager.dto';
import { TenantGuard } from '../common/guards';
import { TenantId } from '../common/decorators';
@Controller('api/v1/org/employees')
@UseGuards(TenantGuard)
export class OrgController {
constructor(private readonly service: OrgService) {}
@Get(':id/reports')
async getDirectReports(
@TenantId() tenantId: string,
@Param('id') managerId: string,
) {
const data = await this.service.getDirectReports(tenantId, managerId);
return { data, error: null };
}
@Get(':id/managers')
async getManagers(
@TenantId() tenantId: string,
@Param('id') employeeId: string,
) {
const data = await this.service.getManagers(tenantId, employeeId);
return { data, error: null };
}
@Post(':id/manager')
@HttpCode(HttpStatus.OK)
async setManager(
@TenantId() tenantId: string,
@Param('id') employeeId: string,
@Body() dto: SetManagerDto,
) {
const data = await this.service.setManager(
tenantId,
employeeId,
dto.managerId ?? null,
dto.type as 'primary' | 'dotted-line' | 'additional',
);
return { data, error: null };
}
// Using query params instead of body for DELETE (better HTTP client compatibility)
@Delete(':id/manager')
@HttpCode(HttpStatus.NO_CONTENT)
async removeManager(
@TenantId() tenantId: string,
@Param('id') employeeId: string,
@Query('type') type: ManagerType,
@Query('managerId') managerId?: string,
) {
if (type === ManagerType.PRIMARY) {
// Clear primary manager
await this.service.setManager(tenantId, employeeId, null, 'primary');
} else {
await this.service.removeManager(
tenantId,
employeeId,
managerId!,
type as 'dotted-line' | 'additional',
);
}
}
}Create apps/api/src/org/org.module.ts:
import { Module } from '@nestjs/common';
import { OrgController } from './org.controller';
import { OrgService } from './org.service';
import { OrgRepository } from './org.repository';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [OrgController],
providers: [OrgService, OrgRepository],
exports: [OrgService, OrgRepository],
})
export class OrgModule {}Update apps/api/src/org/index.ts:
export * from './org.repository';
export * from './org.service';
export * from './org.controller';
export * from './org.module';
export * from './dto';Register module in apps/api/src/app.module.ts:
import { OrgModule } from './org/org.module';
@Module({
imports: [
// ... existing imports
OrgModule,
],
// ...
})
export class AppModule {}Gate
cd apps/api
npm run build
# Should build without errors
# Test endpoints (after starting API and getting a tenant ID and employee IDs)
TENANT_ID=<your-tenant-id>
EMPLOYEE_ID=<employee-id>
MANAGER_ID=<manager-id>
# Get managers
curl "http://localhost:3001/api/v1/org/employees/${EMPLOYEE_ID}/managers" \
-H "x-tenant-id: ${TENANT_ID}"
# Set primary manager
curl -X POST "http://localhost:3001/api/v1/org/employees/${EMPLOYEE_ID}/manager" \
-H "x-tenant-id: ${TENANT_ID}" \
-H "Content-Type: application/json" \
-d '{"managerId": "'"${MANAGER_ID}"'", "type": "primary"}'
# Get direct reports
curl "http://localhost:3001/api/v1/org/employees/${MANAGER_ID}/reports" \
-H "x-tenant-id: ${TENANT_ID}"Common Errors
| Error | Cause | Fix |
|---|---|---|
Route not found | Controller not registered | Check OrgModule in AppModule |
Invalid enum value | Wrong type value | Use 'primary', 'dotted-line', or 'additional' |
Rollback
rm apps/api/src/org/org.controller.ts
rm apps/api/src/org/org.module.ts
rm -rf apps/api/src/org/dtoLock
apps/api/src/org/org.controller.ts
apps/api/src/org/org.module.ts
apps/api/src/org/dto/*Checkpoint
- GET /employees/:id/managers works
- GET /employees/:id/reports works
- POST /employees/:id/manager works
- DELETE /employees/:id/manager works
- Circular reference prevention tested
Step 61: Create Department Assignment Endpoint
Input
- Step 60 complete
- OrgController exists
Constraints
- Add methods to OrgRepository and OrgService
- Add endpoints to OrgController
- Support isPrimary flag
Task
Add to apps/api/src/org/org.repository.ts:
// Add these methods to OrgRepository class
// Assign employee to department
async assignToDepartment(employeeId: string, departmentId: string, isPrimary: boolean = false) {
const orgRelations = await this.getOrCreateOrgRelations(employeeId);
// If setting as primary, clear other primary flags first
if (isPrimary) {
await this.prisma.employeeDepartment.updateMany({
where: { orgRelationsId: orgRelations.id, isPrimary: true },
data: { isPrimary: false },
});
}
// Upsert the assignment
return this.prisma.employeeDepartment.upsert({
where: {
orgRelationsId_departmentId: {
orgRelationsId: orgRelations.id,
departmentId,
},
},
update: { isPrimary },
create: {
orgRelationsId: orgRelations.id,
departmentId,
isPrimary,
},
});
}
// Remove employee from department
async removeFromDepartment(employeeId: string, departmentId: string) {
const orgRelations = await this.prisma.employeeOrgRelations.findUnique({
where: { employeeId },
});
if (!orgRelations) return null;
return this.prisma.employeeDepartment.deleteMany({
where: {
orgRelationsId: orgRelations.id,
departmentId,
},
});
}
// Get employee's departments
async getEmployeeDepartments(employeeId: string) {
const orgRelations = await this.prisma.employeeOrgRelations.findUnique({
where: { employeeId },
include: {
departments: {
include: {
department: {
select: {
id: true,
name: true,
code: true,
},
},
},
},
},
});
return orgRelations?.departments.map(ed => ({
...ed.department,
isPrimary: ed.isPrimary,
})) ?? [];
}
// Get department
async getDepartment(tenantId: string, departmentId: string) {
return this.prisma.department.findFirst({
where: { id: departmentId, tenantId },
});
}Add to apps/api/src/org/org.service.ts:
// Add these methods to OrgService class
async assignToDepartment(
tenantId: string,
employeeId: string,
departmentId: string,
isPrimary: boolean = false,
) {
// Verify employee exists
const employee = await this.repository.getEmployee(tenantId, employeeId);
if (!employee) {
throw new NotFoundException(`Employee with ID ${employeeId} not found`);
}
// Verify department exists
const department = await this.repository.getDepartment(tenantId, departmentId);
if (!department) {
throw new NotFoundException(`Department with ID ${departmentId} not found`);
}
return this.repository.assignToDepartment(employeeId, departmentId, isPrimary);
}
async removeFromDepartment(tenantId: string, employeeId: string, departmentId: string) {
const employee = await this.repository.getEmployee(tenantId, employeeId);
if (!employee) {
throw new NotFoundException(`Employee with ID ${employeeId} not found`);
}
return this.repository.removeFromDepartment(employeeId, departmentId);
}
async getEmployeeDepartments(tenantId: string, employeeId: string) {
const employee = await this.repository.getEmployee(tenantId, employeeId);
if (!employee) {
throw new NotFoundException(`Employee with ID ${employeeId} not found`);
}
return this.repository.getEmployeeDepartments(employeeId);
}Create apps/api/src/org/dto/department-assignment.dto.ts:
import { IsString, IsBoolean, IsOptional } from 'class-validator';
export class AssignDepartmentDto {
@IsString()
departmentId: string;
@IsOptional()
@IsBoolean()
isPrimary?: boolean;
}
export class RemoveDepartmentDto {
@IsString()
departmentId: string;
}Update apps/api/src/org/dto/index.ts:
export * from './set-manager.dto';
export * from './department-assignment.dto';Add to apps/api/src/org/org.controller.ts:
import { AssignDepartmentDto } from './dto/department-assignment.dto';
// Add these methods to OrgController class
@Get(':id/departments')
async getEmployeeDepartments(
@TenantId() tenantId: string,
@Param('id') employeeId: string,
) {
const data = await this.service.getEmployeeDepartments(tenantId, employeeId);
return { data, error: null };
}
@Post(':id/departments')
@HttpCode(HttpStatus.OK)
async assignToDepartment(
@TenantId() tenantId: string,
@Param('id') employeeId: string,
@Body() dto: AssignDepartmentDto,
) {
const data = await this.service.assignToDepartment(
tenantId,
employeeId,
dto.departmentId,
dto.isPrimary ?? false,
);
return { data, error: null };
}
// Using query params instead of body for DELETE (better HTTP client compatibility)
@Delete(':id/departments')
@HttpCode(HttpStatus.NO_CONTENT)
async removeFromDepartment(
@TenantId() tenantId: string,
@Param('id') employeeId: string,
@Query('departmentId') departmentId: string,
) {
await this.service.removeFromDepartment(tenantId, employeeId, departmentId);
}Gate
cd apps/api
npm run build
# Should build without errors
# Test endpoints
TENANT_ID=<your-tenant-id>
EMPLOYEE_ID=<employee-id>
DEPT_ID=<department-id>
# Assign to department
curl -X POST "http://localhost:3001/api/v1/org/employees/${EMPLOYEE_ID}/departments" \
-H "x-tenant-id: ${TENANT_ID}" \
-H "Content-Type: application/json" \
-d '{"departmentId": "'"${DEPT_ID}"'", "isPrimary": true}'
# Get employee's departments
curl "http://localhost:3001/api/v1/org/employees/${EMPLOYEE_ID}/departments" \
-H "x-tenant-id: ${TENANT_ID}"Common Errors
| Error | Cause | Fix |
|---|---|---|
Unique constraint violation | Already assigned to department | Use upsert pattern |
Department not found | Wrong tenant | Verify department exists in tenant |
Rollback
# Remove department assignment methods and endpointsLock
apps/api/src/org/org.repository.ts (department methods)
apps/api/src/org/org.service.ts (department methods)
apps/api/src/org/org.controller.ts (department endpoints)
apps/api/src/org/dto/department-assignment.dto.tsCheckpoint
- GET /employees/:id/departments works
- POST /employees/:id/departments works
- DELETE /employees/:id/departments works
- isPrimary flag works correctly
- Only one primary department allowed
Step 62: Create Team Assignment Endpoint
Input
- Step 61 complete
- Department assignment works
Constraints
- Same pattern as department assignment
- Include role field for team-specific role
Task
Add to apps/api/src/org/org.repository.ts:
// Add these methods to OrgRepository class
// Assign employee to team
async assignToTeam(employeeId: string, teamId: string, role?: string) {
const orgRelations = await this.getOrCreateOrgRelations(employeeId);
return this.prisma.employeeTeam.upsert({
where: {
orgRelationsId_teamId: {
orgRelationsId: orgRelations.id,
teamId,
},
},
update: { role },
create: {
orgRelationsId: orgRelations.id,
teamId,
role,
},
});
}
// Remove employee from team
async removeFromTeam(employeeId: string, teamId: string) {
const orgRelations = await this.prisma.employeeOrgRelations.findUnique({
where: { employeeId },
});
if (!orgRelations) return null;
return this.prisma.employeeTeam.deleteMany({
where: {
orgRelationsId: orgRelations.id,
teamId,
},
});
}
// Get employee's teams
async getEmployeeTeams(employeeId: string) {
const orgRelations = await this.prisma.employeeOrgRelations.findUnique({
where: { employeeId },
include: {
teams: {
include: {
team: {
select: {
id: true,
name: true,
type: true,
},
},
},
},
},
});
return orgRelations?.teams.map(et => ({
...et.team,
role: et.role,
})) ?? [];
}
// Get team
async getTeam(tenantId: string, teamId: string) {
return this.prisma.team.findFirst({
where: { id: teamId, tenantId },
});
}Add to apps/api/src/org/org.service.ts:
// Add these methods to OrgService class
async assignToTeam(
tenantId: string,
employeeId: string,
teamId: string,
role?: string,
) {
const employee = await this.repository.getEmployee(tenantId, employeeId);
if (!employee) {
throw new NotFoundException(`Employee with ID ${employeeId} not found`);
}
const team = await this.repository.getTeam(tenantId, teamId);
if (!team) {
throw new NotFoundException(`Team with ID ${teamId} not found`);
}
return this.repository.assignToTeam(employeeId, teamId, role);
}
async removeFromTeam(tenantId: string, employeeId: string, teamId: string) {
const employee = await this.repository.getEmployee(tenantId, employeeId);
if (!employee) {
throw new NotFoundException(`Employee with ID ${employeeId} not found`);
}
return this.repository.removeFromTeam(employeeId, teamId);
}
async getEmployeeTeams(tenantId: string, employeeId: string) {
const employee = await this.repository.getEmployee(tenantId, employeeId);
if (!employee) {
throw new NotFoundException(`Employee with ID ${employeeId} not found`);
}
return this.repository.getEmployeeTeams(employeeId);
}Create apps/api/src/org/dto/team-assignment.dto.ts:
import { IsString, IsOptional } from 'class-validator';
export class AssignTeamDto {
@IsString()
teamId: string;
@IsOptional()
@IsString()
role?: string;
}
export class RemoveTeamDto {
@IsString()
teamId: string;
}Update apps/api/src/org/dto/index.ts:
export * from './set-manager.dto';
export * from './department-assignment.dto';
export * from './team-assignment.dto';Add to apps/api/src/org/org.controller.ts:
import { AssignTeamDto } from './dto/team-assignment.dto';
// Add these methods to OrgController class
@Get(':id/teams')
async getEmployeeTeams(
@TenantId() tenantId: string,
@Param('id') employeeId: string,
) {
const data = await this.service.getEmployeeTeams(tenantId, employeeId);
return { data, error: null };
}
@Post(':id/teams')
@HttpCode(HttpStatus.OK)
async assignToTeam(
@TenantId() tenantId: string,
@Param('id') employeeId: string,
@Body() dto: AssignTeamDto,
) {
const data = await this.service.assignToTeam(
tenantId,
employeeId,
dto.teamId,
dto.role,
);
return { data, error: null };
}
// Using query params instead of body for DELETE (better HTTP client compatibility)
@Delete(':id/teams')
@HttpCode(HttpStatus.NO_CONTENT)
async removeFromTeam(
@TenantId() tenantId: string,
@Param('id') employeeId: string,
@Query('teamId') teamId: string,
) {
await this.service.removeFromTeam(tenantId, employeeId, teamId);
}Gate
cd apps/api
npm run build
# Should build without errors
# Test endpoints
TENANT_ID=<your-tenant-id>
EMPLOYEE_ID=<employee-id>
TEAM_ID=<team-id>
# Assign to team with role
curl -X POST "http://localhost:3001/api/v1/org/employees/${EMPLOYEE_ID}/teams" \
-H "x-tenant-id: ${TENANT_ID}" \
-H "Content-Type: application/json" \
-d '{"teamId": "'"${TEAM_ID}"'", "role": "Tech Lead"}'
# Get employee's teams
curl "http://localhost:3001/api/v1/org/employees/${EMPLOYEE_ID}/teams" \
-H "x-tenant-id: ${TENANT_ID}"
# Remove from team
curl -X DELETE "http://localhost:3001/api/v1/org/employees/${EMPLOYEE_ID}/teams" \
-H "x-tenant-id: ${TENANT_ID}" \
-H "Content-Type: application/json" \
-d '{"teamId": "'"${TEAM_ID}"'"}'Common Errors
| Error | Cause | Fix |
|---|---|---|
Team not found | Wrong tenant | Verify team exists in tenant |
Unique constraint violation | Already in team | Use upsert pattern |
Rollback
# Remove team assignment methods and endpointsLock
apps/api/src/org/org.repository.ts (team methods)
apps/api/src/org/org.service.ts (team methods)
apps/api/src/org/org.controller.ts (team endpoints)
apps/api/src/org/dto/team-assignment.dto.tsCheckpoint
- GET /employees/:id/teams works
- POST /employees/:id/teams works
- DELETE /employees/:id/teams works
- Team role field works
- Phase 03 complete!
Phase 03 Complete Checklist
Schema (Steps 43-52)
- Department model with hierarchy
- Team model with types
- OrgRole model
- EmployeeOrgRelations hub
- EmployeeDottedLine junction
- EmployeeAdditionalManager junction
- EmployeeDepartment junction
- EmployeeTeam junction
- EmployeeOrgRole junction
- All migrations applied
API (Steps 53-62)
- DepartmentsModule complete
- TeamsModule complete
- OrgModule complete
- Manager assignment endpoints
- Department assignment endpoints
- Team assignment endpoints
- Circular reference prevention
- Tenant isolation on all queries
Locked Files After Phase 03
- All Phase 02 locks, plus:
- All org models in
schema.prisma apps/api/src/departments/*apps/api/src/teams/*apps/api/src/org/*
Quick Reference: API Endpoints
All endpoints require header: x-tenant-id: <tenant-id>
# Departments
GET /api/v1/departments
GET /api/v1/departments/:id
GET /api/v1/departments/hierarchy?rootId=...
POST /api/v1/departments
PATCH /api/v1/departments/:id
DELETE /api/v1/departments/:id
# Teams
GET /api/v1/teams?type=...
GET /api/v1/teams/:id
POST /api/v1/teams
PATCH /api/v1/teams/:id
DELETE /api/v1/teams/:id
# Employee Org Relations (via OrgController)
GET /api/v1/org/employees/:id/managers
GET /api/v1/org/employees/:id/reports
POST /api/v1/org/employees/:id/manager
DELETE /api/v1/org/employees/:id/manager
GET /api/v1/org/employees/:id/departments
POST /api/v1/org/employees/:id/departments
DELETE /api/v1/org/employees/:id/departments
GET /api/v1/org/employees/:id/teams
POST /api/v1/org/employees/:id/teams
DELETE /api/v1/org/employees/:id/teamsStep 63: Add Manager/Reports UI Component (EMP-04)
Input
- Step 62 complete
- Manager/reports API endpoints exist
Constraints
- Display on employee profile page
- Show manager chain upward
- Show direct reports downward
- ONLY create UI component
Task
1. Create Manager Chain Component at apps/web/components/org/manager-chain.tsx:
'use client';
import { useQuery } from '@tanstack/react-query';
import { useSession } from 'next-auth/react';
import Link from 'next/link';
interface Manager {
id: string;
firstName: string;
lastName: string;
email: string;
jobTitle: string | null;
pictureUrl?: string;
}
interface ManagerChainProps {
employeeId: string;
}
export function ManagerChain({ employeeId }: ManagerChainProps) {
const { data: session } = useSession();
const { data, isLoading } = useQuery({
queryKey: ['managers', employeeId],
queryFn: async () => {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/org/employees/${employeeId}/managers`,
{
headers: {
'x-tenant-id': session?.user?.tenantId || '',
},
}
);
const result = await response.json();
return result.data as { primary: Manager | null; dottedLine: Manager[]; additional: Manager[] };
},
enabled: !!session?.user?.tenantId && !!employeeId,
});
if (isLoading) {
return <div className="animate-pulse h-20 bg-gray-100 rounded" />;
}
if (!data?.primary && !data?.dottedLine?.length) {
return (
<div className="text-gray-500 text-sm">
No managers assigned
</div>
);
}
return (
<div className="space-y-4">
{/* Primary Manager */}
{data.primary && (
<div>
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-2">
Reports To
</h4>
<ManagerCard manager={data.primary} />
</div>
)}
{/* Dotted Line Managers */}
{data.dottedLine && data.dottedLine.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-2">
Dotted Line
</h4>
<div className="space-y-2">
{data.dottedLine.map((manager) => (
<ManagerCard key={manager.id} manager={manager} isDotted />
))}
</div>
</div>
)}
{/* Additional Managers */}
{data.additional && data.additional.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-2">
Additional Managers
</h4>
<div className="space-y-2">
{data.additional.map((manager) => (
<ManagerCard key={manager.id} manager={manager} />
))}
</div>
</div>
)}
</div>
);
}
function ManagerCard({ manager, isDotted = false }: { manager: Manager; isDotted?: boolean }) {
return (
<Link
href={`/dashboard/employees/${manager.id}`}
className={`flex items-center gap-3 p-4 rounded-2xl hover:bg-gray-50 transition-colors ${
isDotted ? 'border-2 border-dashed border-gray-300' : 'shadow-lg shadow-gray-200/50'
}`}
>
{manager.pictureUrl ? (
<img
src={manager.pictureUrl}
alt=""
className="w-10 h-10 rounded-full object-cover"
/>
) : (
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-medium">
{manager.firstName[0]}{manager.lastName[0]}
</div>
)}
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900 truncate">
{manager.firstName} {manager.lastName}
</div>
<div className="text-sm text-gray-500 truncate">
{manager.jobTitle || manager.email}
</div>
</div>
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Link>
);
}2. Create Direct Reports Component at apps/web/components/org/direct-reports.tsx:
'use client';
import { useQuery } from '@tanstack/react-query';
import { useSession } from 'next-auth/react';
import Link from 'next/link';
interface Report {
id: string;
firstName: string;
lastName: string;
email: string;
jobTitle: string | null;
status: string;
pictureUrl?: string;
}
interface DirectReportsProps {
employeeId: string;
}
export function DirectReports({ employeeId }: DirectReportsProps) {
const { data: session } = useSession();
const { data: reports, isLoading } = useQuery({
queryKey: ['directReports', employeeId],
queryFn: async () => {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/org/employees/${employeeId}/reports`,
{
headers: {
'x-tenant-id': session?.user?.tenantId || '',
},
}
);
const result = await response.json();
return result.data as Report[];
},
enabled: !!session?.user?.tenantId && !!employeeId,
});
if (isLoading) {
return <div className="animate-pulse h-32 bg-gray-100 rounded" />;
}
if (!reports || reports.length === 0) {
return (
<div className="text-gray-500 text-sm">
No direct reports
</div>
);
}
return (
<div className="space-y-2">
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-2">
Direct Reports ({reports.length})
</h4>
<div className="grid gap-2">
{reports.map((report) => (
<Link
key={report.id}
href={`/dashboard/employees/${report.id}`}
className="flex items-center gap-3 p-4 rounded-2xl hover:bg-gray-50 hover:shadow-md transition-all"
>
{report.pictureUrl ? (
<img
src={report.pictureUrl}
alt=""
className="w-10 h-10 rounded-full object-cover"
/>
) : (
<div className="w-10 h-10 rounded-full bg-green-100 flex items-center justify-center text-green-600 font-medium">
{report.firstName[0]}{report.lastName[0]}
</div>
)}
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900 truncate">
{report.firstName} {report.lastName}
</div>
<div className="text-sm text-gray-500 truncate">
{report.jobTitle || report.email}
</div>
</div>
<span
className={`text-xs px-2 py-1 rounded-full ${
report.status === 'ACTIVE'
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-600'
}`}
>
{report.status}
</span>
</Link>
))}
</div>
</div>
);
}3. Update Employee Profile Page - Add org section to apps/web/app/dashboard/employees/[id]/page.tsx:
import { ManagerChain } from '@/components/org/manager-chain';
import { DirectReports } from '@/components/org/direct-reports';
// Add after employee details in the page:
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-6">
<div className="bg-white shadow-lg shadow-gray-200/50 rounded-2xl p-6">
<h3 className="text-lg font-semibold mb-4">Reporting Structure</h3>
<ManagerChain employeeId={employee.id} />
</div>
<div className="bg-white shadow-lg shadow-gray-200/50 rounded-2xl p-6">
<h3 className="text-lg font-semibold mb-4">Team</h3>
<DirectReports employeeId={employee.id} />
</div>
</div>Gate
cd apps/web && npm run dev
# Navigate to /dashboard/employees/{id}
# Should show:
# - "Reports To" section with primary manager
# - "Dotted Line" section if applicable
# - "Direct Reports" section with team members
# Clicking on a person should navigate to their profileCheckpoint
- Manager chain displays correctly
- Dotted line managers shown with dashed border
- Direct reports list shows count
- Links navigate to profile pages
- Type "GATE 63 PASSED" to continue
Step 64: Add Department Head Assignment (ORG-07)
Input
- Step 63 complete
- Department model exists
Constraints
- Add headId field to Department
- Create endpoint to set department head
- Show department head in UI
- ONLY modify existing department code
Task
1. Update Department Schema (if not already done) in packages/database/prisma/schema.prisma:
model Department {
// ... existing fields ...
headId String? // Department head (employee who leads this department)
head Employee? @relation("DepartmentHead", fields: [headId], references: [id])
}
// Update Employee model to add reverse relation
model Employee {
// ... existing relations ...
headOfDepartments Department[] @relation("DepartmentHead")
}2. Run migration:
cd packages/database
npm run db:push
npm run db:generate3. Add Set Head Endpoint to apps/api/src/departments/departments.controller.ts:
@Patch(':id/head')
async setDepartmentHead(
@TenantId() tenantId: string,
@Param('id') departmentId: string,
@Body() dto: { headId: string | null },
) {
const data = await this.service.setDepartmentHead(tenantId, departmentId, dto.headId);
return { data, error: null };
}4. Add Service Method to apps/api/src/departments/departments.service.ts:
async setDepartmentHead(tenantId: string, departmentId: string, headId: string | null) {
// Verify department exists
const department = await this.repository.findById(tenantId, departmentId);
if (!department) {
throw new NotFoundException(`Department with ID ${departmentId} not found`);
}
// If setting a head, verify employee exists
if (headId) {
const employee = await this.prisma.employee.findFirst({
where: { id: headId, tenantId, deletedAt: null },
});
if (!employee) {
throw new NotFoundException(`Employee with ID ${headId} not found`);
}
}
return this.repository.update(tenantId, departmentId, { headId });
}5. Update Repository - Include head in queries in apps/api/src/departments/departments.repository.ts:
// Update findById to include head
async findById(tenantId: string, id: string) {
return this.prisma.department.findFirst({
where: { id, tenantId },
include: {
head: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
jobTitle: true,
pictureUrl: true,
},
},
parent: true,
children: true,
},
});
}6. Create Department Head Component at apps/web/components/org/department-head.tsx:
'use client';
import { useState } from 'react';
import { useSession } from 'next-auth/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface Employee {
id: string;
firstName: string;
lastName: string;
email: string;
jobTitle: string | null;
pictureUrl?: string;
}
interface DepartmentHeadProps {
departmentId: string;
currentHead: Employee | null;
employees: Employee[];
canEdit?: boolean;
}
export function DepartmentHead({
departmentId,
currentHead,
employees,
canEdit = false,
}: DepartmentHeadProps) {
const { data: session } = useSession();
const queryClient = useQueryClient();
const [isEditing, setIsEditing] = useState(false);
const [selectedId, setSelectedId] = useState(currentHead?.id || '');
const mutation = useMutation({
mutationFn: async (headId: string | null) => {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/departments/${departmentId}/head`,
{
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'x-tenant-id': session?.user?.tenantId || '',
},
body: JSON.stringify({ headId }),
}
);
if (!response.ok) throw new Error('Failed to update');
return response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['department', departmentId] });
setIsEditing(false);
},
});
const handleSave = () => {
mutation.mutate(selectedId || null);
};
if (isEditing) {
return (
<div className="space-y-3">
<select
value={selectedId}
onChange={(e) => setSelectedId(e.target.value)}
className="w-full p-3 rounded-2xl bg-gray-50 border border-gray-200"
>
<option value="">No department head</option>
{employees.map((emp) => (
<option key={emp.id} value={emp.id}>
{emp.firstName} {emp.lastName} - {emp.jobTitle || emp.email}
</option>
))}
</select>
<div className="flex gap-2">
<button
onClick={handleSave}
disabled={mutation.isPending}
className="px-3 py-1 bg-blue-600 text-white rounded text-sm"
>
{mutation.isPending ? 'Saving...' : 'Save'}
</button>
<button
onClick={() => setIsEditing(false)}
className="px-3 py-1 rounded-2xl text-sm hover:bg-gray-50 shadow-sm"
>
Cancel
</button>
</div>
</div>
);
}
if (!currentHead) {
return (
<div className="flex items-center justify-between">
<span className="text-gray-500 text-sm">No department head assigned</span>
{canEdit && (
<button
onClick={() => setIsEditing(true)}
className="text-blue-600 text-sm hover:underline"
>
Assign
</button>
)}
</div>
);
}
return (
<div className="flex items-center gap-3">
{currentHead.pictureUrl ? (
<img
src={currentHead.pictureUrl}
alt=""
className="w-12 h-12 rounded-full object-cover"
/>
) : (
<div className="w-12 h-12 rounded-full bg-purple-100 flex items-center justify-center text-purple-600 font-medium">
{currentHead.firstName[0]}{currentHead.lastName[0]}
</div>
)}
<div className="flex-1">
<div className="font-medium">{currentHead.firstName} {currentHead.lastName}</div>
<div className="text-sm text-gray-500">{currentHead.jobTitle || 'Department Head'}</div>
</div>
{canEdit && (
<button
onClick={() => setIsEditing(true)}
className="text-blue-600 text-sm hover:underline"
>
Change
</button>
)}
</div>
);
}Gate
# Test set department head
curl -X PATCH http://localhost:3001/api/v1/departments/DEPT_ID/head \
-H "Content-Type: application/json" \
-H "x-tenant-id: YOUR_TENANT_ID" \
-d '{"headId":"EMPLOYEE_ID"}'
# Should return updated department with head
# Get department (should include head)
curl http://localhost:3001/api/v1/departments/DEPT_ID \
-H "x-tenant-id: YOUR_TENANT_ID"
# Should return department with head objectCheckpoint
- headId field added to Department
- PATCH /departments/:id/head works
- Department queries include head info
- UI component shows department head
- Type "GATE 64 PASSED" to continue
Step 65: Multiple Roles Assignment UI (EMP-03, EMP-04)
Input
- Step 64 complete
- EmployeeOrgRole junction table exists
- OrgRole model exists
Constraints
- Add role assignment component to employee edit form
- Support multiple roles per employee
- Mark one role as primary
- Follow design system patterns
Task
1. Create Role Assignment Component at apps/web/components/org/role-assignment.tsx:
'use client';
import { useState } from 'react';
import { useSession } from 'next-auth/react';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { X, Plus, Star, StarOff } from 'lucide-react';
interface OrgRole {
id: string;
name: string;
category: string | null;
description: string | null;
}
interface EmployeeRole {
id: string;
roleId: string;
isPrimary: boolean;
role: OrgRole;
}
interface RoleAssignmentProps {
employeeId: string;
currentRoles: EmployeeRole[];
canEdit?: boolean;
}
export function RoleAssignment({
employeeId,
currentRoles,
canEdit = false,
}: RoleAssignmentProps) {
const { data: session } = useSession();
const queryClient = useQueryClient();
const [showAddRole, setShowAddRole] = useState(false);
const [selectedRoleId, setSelectedRoleId] = useState('');
// Fetch all available roles
const { data: allRoles = [] } = useQuery<OrgRole[]>({
queryKey: ['org-roles'],
queryFn: async () => {
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/org-roles`,
{
headers: {
'x-tenant-id': session?.user?.tenantId || '',
},
}
);
const data = await res.json();
return data.data || [];
},
enabled: !!session?.user?.tenantId,
});
const assignMutation = useMutation({
mutationFn: async ({ roleId, isPrimary }: { roleId: string; isPrimary?: boolean }) => {
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees/${employeeId}/roles`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-tenant-id': session?.user?.tenantId || '',
},
body: JSON.stringify({ roleId, isPrimary }),
}
);
if (!res.ok) throw new Error('Failed to assign role');
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['employee', employeeId] });
setShowAddRole(false);
setSelectedRoleId('');
},
});
const removeMutation = useMutation({
mutationFn: async (roleId: string) => {
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees/${employeeId}/roles/${roleId}`,
{
method: 'DELETE',
headers: {
'x-tenant-id': session?.user?.tenantId || '',
},
}
);
if (!res.ok) throw new Error('Failed to remove role');
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['employee', employeeId] });
},
});
const setPrimaryMutation = useMutation({
mutationFn: async (roleId: string) => {
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees/${employeeId}/roles/${roleId}`,
{
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'x-tenant-id': session?.user?.tenantId || '',
},
body: JSON.stringify({ isPrimary: true }),
}
);
if (!res.ok) throw new Error('Failed to set primary');
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['employee', employeeId] });
},
});
// Filter out already assigned roles
const availableRoles = allRoles.filter(
(r) => !currentRoles.some((cr) => cr.roleId === r.id)
);
const handleAssign = () => {
if (selectedRoleId) {
assignMutation.mutate({ roleId: selectedRoleId, isPrimary: currentRoles.length === 0 });
}
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Organizational Roles</h3>
{canEdit && availableRoles.length > 0 && (
<button
onClick={() => setShowAddRole(!showAddRole)}
className="flex items-center gap-1 text-blue-600 hover:text-blue-700 text-sm"
>
<Plus className="w-4 h-4" />
Add Role
</button>
)}
</div>
{/* Add Role Form */}
{showAddRole && (
<div className="p-4 bg-gray-50 rounded-2xl space-y-3">
<select
value={selectedRoleId}
onChange={(e) => setSelectedRoleId(e.target.value)}
className="w-full p-3 rounded-xl bg-white border border-gray-200"
>
<option value="">Select a role...</option>
{availableRoles.map((role) => (
<option key={role.id} value={role.id}>
{role.name} {role.category ? `(${role.category})` : ''}
</option>
))}
</select>
<div className="flex gap-2">
<button
onClick={handleAssign}
disabled={!selectedRoleId || assignMutation.isPending}
className="px-4 py-2 bg-blue-600 text-white rounded-xl text-sm disabled:opacity-50"
>
{assignMutation.isPending ? 'Adding...' : 'Add Role'}
</button>
<button
onClick={() => setShowAddRole(false)}
className="px-4 py-2 rounded-xl text-sm hover:bg-gray-100"
>
Cancel
</button>
</div>
</div>
)}
{/* Current Roles List */}
{currentRoles.length === 0 ? (
<p className="text-gray-500 text-sm">No roles assigned</p>
) : (
<div className="space-y-2">
{currentRoles.map((er) => (
<div
key={er.id}
className="flex items-center justify-between p-3 bg-white rounded-xl border border-gray-100 shadow-sm"
>
<div className="flex items-center gap-3">
{er.isPrimary && (
<Star className="w-4 h-4 text-yellow-500 fill-yellow-500" />
)}
<div>
<span className="font-medium">{er.role.name}</span>
{er.role.category && (
<span className="ml-2 text-xs text-gray-500 bg-gray-100 px-2 py-0.5 rounded-full">
{er.role.category}
</span>
)}
</div>
</div>
{canEdit && (
<div className="flex items-center gap-2">
{!er.isPrimary && (
<button
onClick={() => setPrimaryMutation.mutate(er.roleId)}
disabled={setPrimaryMutation.isPending}
className="p-1 text-gray-400 hover:text-yellow-500"
title="Set as primary role"
>
<StarOff className="w-4 h-4" />
</button>
)}
<button
onClick={() => removeMutation.mutate(er.roleId)}
disabled={removeMutation.isPending}
className="p-1 text-gray-400 hover:text-red-500"
title="Remove role"
>
<X className="w-4 h-4" />
</button>
</div>
)}
</div>
))}
</div>
)}
</div>
);
}2. Add Role Assignment Endpoints to apps/api/src/employees/employees.controller.ts:
// Add to EmployeeController
/**
* Assign role to employee
*/
@Post(':id/roles')
@RequirePermissions('employees:update')
async assignRole(
@TenantId() tenantId: string,
@Param('id') employeeId: string,
@Body() dto: { roleId: string; isPrimary?: boolean },
) {
const data = await this.orgService.assignRole(tenantId, employeeId, dto.roleId, dto.isPrimary);
return { data, error: null };
}
/**
* Remove role from employee
*/
@Delete(':id/roles/:roleId')
@RequirePermissions('employees:update')
@HttpCode(HttpStatus.NO_CONTENT)
async removeRole(
@TenantId() tenantId: string,
@Param('id') employeeId: string,
@Param('roleId') roleId: string,
) {
await this.orgService.removeRole(tenantId, employeeId, roleId);
}
/**
* Set role as primary
*/
@Patch(':id/roles/:roleId')
@RequirePermissions('employees:update')
async updateRole(
@TenantId() tenantId: string,
@Param('id') employeeId: string,
@Param('roleId') roleId: string,
@Body() dto: { isPrimary: boolean },
) {
const data = await this.orgService.setRolePrimary(tenantId, employeeId, roleId, dto.isPrimary);
return { data, error: null };
}3. Add OrgService Methods to apps/api/src/employees/org.service.ts:
// Add to OrgService
async assignRole(tenantId: string, employeeId: string, roleId: string, isPrimary?: boolean) {
await this.verifyEmployeeExists(tenantId, employeeId);
// Verify role exists
const role = await this.prisma.orgRole.findFirst({
where: { id: roleId, tenantId },
});
if (!role) throw new NotFoundException(`Role ${roleId} not found`);
// Get or create org relations
const orgRelations = await this.prisma.employeeOrgRelations.upsert({
where: { employeeId },
create: { employeeId },
update: {},
});
// If setting as primary, unset others first
if (isPrimary) {
await this.prisma.employeeOrgRole.updateMany({
where: { orgRelationsId: orgRelations.id },
data: { isPrimary: false },
});
}
return this.prisma.employeeOrgRole.create({
data: {
orgRelationsId: orgRelations.id,
roleId,
isPrimary: isPrimary ?? false,
},
include: { role: true },
});
}
async removeRole(tenantId: string, employeeId: string, roleId: string) {
await this.verifyEmployeeExists(tenantId, employeeId);
const orgRelations = await this.prisma.employeeOrgRelations.findUnique({
where: { employeeId },
});
if (!orgRelations) throw new NotFoundException('Employee org relations not found');
await this.prisma.employeeOrgRole.deleteMany({
where: { orgRelationsId: orgRelations.id, roleId },
});
}
async setRolePrimary(tenantId: string, employeeId: string, roleId: string, isPrimary: boolean) {
await this.verifyEmployeeExists(tenantId, employeeId);
const orgRelations = await this.prisma.employeeOrgRelations.findUnique({
where: { employeeId },
});
if (!orgRelations) throw new NotFoundException('Employee org relations not found');
// If setting as primary, unset others first
if (isPrimary) {
await this.prisma.employeeOrgRole.updateMany({
where: { orgRelationsId: orgRelations.id },
data: { isPrimary: false },
});
}
return this.prisma.employeeOrgRole.update({
where: {
orgRelationsId_roleId: {
orgRelationsId: orgRelations.id,
roleId,
},
},
data: { isPrimary },
include: { role: true },
});
}4. Add RoleAssignment to Employee Detail Page
In apps/web/app/(dashboard)/employees/[id]/page.tsx:
import { RoleAssignment } from '@/components/org/role-assignment';
// In the component, add after other sections:
<Card className="p-6 rounded-2xl shadow-lg">
<RoleAssignment
employeeId={employee.id}
currentRoles={employee.orgRelations?.roles || []}
canEdit={hasPermission('employees:update')}
/>
</Card>Gate
# Test assign role
curl -X POST http://localhost:3001/api/v1/employees/EMP_ID/roles \
-H "Content-Type: application/json" \
-H "x-tenant-id: YOUR_TENANT_ID" \
-d '{"roleId":"ROLE_ID","isPrimary":true}'
# Should return assigned role
# Test remove role
curl -X DELETE http://localhost:3001/api/v1/employees/EMP_ID/roles/ROLE_ID \
-H "x-tenant-id: YOUR_TENANT_ID"
# Should return 204
# Test set primary
curl -X PATCH http://localhost:3001/api/v1/employees/EMP_ID/roles/ROLE_ID \
-H "Content-Type: application/json" \
-H "x-tenant-id: YOUR_TENANT_ID" \
-d '{"isPrimary":true}'
# Should update and return roleCheckpoint
- Can assign multiple roles to employee
- Primary role clearly indicated with star icon
- Can set any role as primary
- Can remove roles from employee
- UI component follows design system
- Type "GATE 65 PASSED" to continue
Step 66: Create Employee Org Summary Endpoint (ORG-08)
Input
- Step 62 complete (all org relation endpoints working)
- EmployeeOrgRelations model with all relationships
Constraints
- Single endpoint returns complete org context
- Used by org chart detail view and reporting chain
- Include reporting chain (walk up manager hierarchy)
Task
1. Add Summary Endpoint to apps/api/src/employees/employees.controller.ts:
/**
* Get complete org context for an employee
* Used by org chart detail view and reporting chain displays
*/
@Get(':id/summary')
@RequirePermissions('employees:read')
async getOrgSummary(
@TenantId() tenantId: string,
@Param('id') employeeId: string,
) {
const data = await this.orgService.getEmployeeOrgSummary(tenantId, employeeId);
return { data, error: null };
}2. Add OrgService Method to apps/api/src/employees/org.service.ts:
/**
* Get complete organizational context for an employee.
* Returns all managers, departments, teams, roles, and reporting chain.
*/
async getEmployeeOrgSummary(tenantId: string, employeeId: string) {
const employee = await this.prisma.employee.findFirst({
where: { id: employeeId, tenantId },
include: {
orgRelations: {
include: {
primaryManager: {
select: { id: true, firstName: true, lastName: true, jobTitle: true, pictureUrl: true },
},
dottedLineManagers: {
include: {
manager: { select: { id: true, firstName: true, lastName: true, jobTitle: true } },
},
},
additionalManagers: {
include: {
manager: { select: { id: true, firstName: true, lastName: true, jobTitle: true } },
},
},
departments: {
include: {
department: { select: { id: true, name: true } },
},
},
teams: {
include: {
team: { select: { id: true, name: true } },
},
},
roles: {
include: {
role: { select: { id: true, name: true, category: true } },
},
},
},
},
},
});
if (!employee) throw new NotFoundException('Employee not found');
// Build reporting chain (walk up manager hierarchy)
const reportingChain = await this.buildReportingChain(tenantId, employeeId);
return {
employee: {
id: employee.id,
firstName: employee.firstName,
lastName: employee.lastName,
email: employee.email,
jobTitle: employee.jobTitle,
pictureUrl: employee.pictureUrl,
},
managers: {
primary: employee.orgRelations?.primaryManager || null,
dottedLine: employee.orgRelations?.dottedLineManagers?.map(d => d.manager) || [],
additional: employee.orgRelations?.additionalManagers?.map(a => a.manager) || [],
},
departments: employee.orgRelations?.departments?.map(d => ({
...d.department,
isPrimary: d.isPrimary,
})) || [],
teams: employee.orgRelations?.teams?.map(t => ({
...t.team,
role: t.role,
})) || [],
roles: employee.orgRelations?.roles?.map(r => ({
...r.role,
isPrimary: r.isPrimary,
})) || [],
reportingChain,
};
}
/**
* Build the reporting chain by walking up the manager hierarchy.
* Returns array from immediate manager to top of org.
* Handles circular references gracefully.
*/
private async buildReportingChain(tenantId: string, employeeId: string) {
const chain: { id: string; name: string; jobTitle: string | null }[] = [];
let currentId: string | null = employeeId;
const visited = new Set<string>();
while (currentId && !visited.has(currentId)) {
visited.add(currentId);
const orgRelations = await this.prisma.employeeOrgRelations.findUnique({
where: { employeeId: currentId },
include: {
primaryManager: {
select: { id: true, firstName: true, lastName: true, jobTitle: true },
},
},
});
if (orgRelations?.primaryManager) {
chain.push({
id: orgRelations.primaryManager.id,
name: `${orgRelations.primaryManager.firstName} ${orgRelations.primaryManager.lastName}`,
jobTitle: orgRelations.primaryManager.jobTitle,
});
currentId = orgRelations.primaryManager.id;
} else {
currentId = null;
}
}
return chain;
}Gate
# Test org summary endpoint
curl http://localhost:3001/api/v1/employees/EMP_ID/summary \
-H "x-tenant-id: YOUR_TENANT_ID"
# Should return:
# {
# "data": {
# "employee": { "id": "...", "firstName": "...", ... },
# "managers": { "primary": {...}, "dottedLine": [...], "additional": [...] },
# "departments": [{ "id": "...", "name": "...", "isPrimary": true }],
# "teams": [{ "id": "...", "name": "...", "role": "..." }],
# "roles": [{ "id": "...", "name": "...", "isPrimary": true }],
# "reportingChain": [{ "id": "...", "name": "...", "jobTitle": "..." }]
# },
# "error": null
# }
# Test with employee that has a reporting chain
# Should return all managers from immediate to CEO
cd apps/api && npm run build
# Should compile without errorsCommon Errors
| Error | Cause | Fix |
|---|---|---|
reportingChain is empty | Employee has no manager | Expected for top-level employees |
Circular reference not handled | Infinite loop | visited Set prevents this |
null in dottedLine array | Relation deleted | Filter nulls in map |
Rollback
# Remove the new endpoint and method from controller and serviceLock
apps/api/src/employees/employees.controller.ts (summary endpoint)
apps/api/src/employees/org.service.ts (getEmployeeOrgSummary, buildReportingChain)Checkpoint
- GET /api/v1/employees/:id/summary returns complete org context
- Includes all manager types (primary, dotted-line, additional)
- Includes departments with isPrimary flag
- Includes teams with role
- Includes roles with isPrimary flag
- Includes reportingChain array walking up to top
- Type "GATE 66 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
- Manager assignment working (primary, dotted-line, additional)
- Department/team assignment working
- Role assignment working
- Employee org summary endpoint working
2. Update PROJECT_STATE.md
- Mark Phase 03 as COMPLETED with timestamp
- Add locked files to "Locked Files" section
- Update "Current Phase" to Phase 04
- Add session log entry3. Update WHAT_EXISTS.md
## Database Models
- Department, Team, Role
- Junction tables for org relations
## API Endpoints
- Manager assignment endpoints
- Department/team/role endpoints
- GET /api/v1/employees/:id/summary
## Established Patterns
- OrgService pattern
- Junction table pattern4. Git Tag & Commit
git add PROJECT_STATE.md WHAT_EXISTS.md
git commit -m "chore: complete Phase 03 - Org Structure"
git tag phase-03-org-structureNext Phase
After verification, proceed to Phase 04: Org Visualization
Last Updated: 2025-11-30