Bluewoo HRMS
Micro-Step Build PlanBuilding Blocks

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).

AttributeValue
Steps43-62
Estimated Time8-10 hours
DependenciesPhase 02 complete (Employee model with tenant isolation)
Completion GateEmployees can have primary manager, dotted-line managers, belong to multiple departments and teams

Step Timing Estimates

StepTaskEst. Time
43Add Department model15 min
44Add Team model15 min
45Add OrgRole model10 min
46Add EmployeeOrgRelations model15 min
47Add EmployeeDottedLine model10 min
48Add EmployeeAdditionalManager model10 min
49Add EmployeeDepartment junction10 min
50Add EmployeeTeam junction10 min
51Add EmployeeOrgRole junction10 min
52Run migration for org structure10 min
53Create DepartmentRepository20 min
54Create DepartmentService25 min
55Create DepartmentController15 min
56Create Team Repository/Service/Controller30 min
57Create OrgService getDirectReports20 min
58Create OrgService getManagers20 min
59Create OrgService setManager25 min
60Create manager assignment endpoint15 min
61Create department assignment endpoint15 min
62Create team assignment endpoint15 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-relation

Common Errors

ErrorCauseFix
Unknown type "DepartmentStatus"Enum not definedAdd enum before model
Unknown type "Employee"Employee model not foundCheck Phase 02 complete
Field "departments" references missing modelCircular referenceEnsure Department defined after Employee

Rollback

# Remove Department model, DepartmentStatus enum, and Tenant.departments
git checkout packages/database/prisma/schema.prisma

Lock

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-relation

Common Errors

ErrorCauseFix
Unknown type "TeamType"Enum not definedAdd enum before model
Unknown type "TeamStatus"Enum not definedAdd enum before model

Rollback

# Remove Team model, TeamType, TeamStatus from schema
git checkout packages/database/prisma/schema.prisma

Lock

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

ErrorCauseFix
Unique constraint violationDuplicate role name per tenantname is unique per tenant, use different name

Rollback

git checkout packages/database/prisma/schema.prisma

Lock

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

ErrorCauseFix
Unknown type "EmployeeOrgRelations"Model not definedEnsure model is in schema
Ambiguous relationMultiple relations to EmployeeUse named relations like @relation("PrimaryManager")

Rollback

git checkout packages/database/prisma/schema.prisma

Lock

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

ErrorCauseFix
Unknown type "EmployeeOrgRelations"Model not definedComplete Step 46 first
Duplicate unique constraintSame manager added twiceUnique constraint prevents this

Rollback

git checkout packages/database/prisma/schema.prisma

Lock

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 junction

Common Errors

ErrorCauseFix
Ambiguous relationMissing relation nameEnsure @relation("AdditionalManager") is specified

Rollback

git checkout packages/database/prisma/schema.prisma

Lock

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 Boolean

Common Errors

ErrorCauseFix
Unknown type "Department"Department model not foundComplete Step 43 first

Rollback

git checkout packages/database/prisma/schema.prisma

Lock

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

ErrorCauseFix
Unknown type "Team"Team model not foundComplete Step 44 first

Rollback

git checkout packages/database/prisma/schema.prisma

Lock

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 Boolean

Common Errors

ErrorCauseFix
Unknown type "OrgRole"OrgRole model not foundComplete Step 45 first

Rollback

git checkout packages/database/prisma/schema.prisma

Lock

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 generate

Gate

# 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 studio

Common Errors

ErrorCauseFix
Validation errorSchema syntax errorRun npx prisma format and fix errors
Foreign key constraintReferenced model missingEnsure all models defined
Relation cycleCircular self-referencesUse named relations with @relation()

Rollback

# Reset database (WARNING: deletes all data)
npx prisma migrate reset

# Or restore from backup if in production

Lock

packages/database/prisma/schema.prisma (entire org structure - do not modify models after migration)

Checkpoint

  • npx prisma validate passes
  • npx prisma db push succeeds
  • 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/departments

Create apps/api/src/departments/index.ts:

export * from './departments.repository';

Gate

cd apps/api
npx tsc --noEmit
# Should compile without errors

Common Errors

ErrorCauseFix
Cannot find module '../prisma/prisma.service'PrismaService not availableImport from correct location or create
Type 'DepartmentStatus' not foundPrisma client not generatedRun npx prisma generate in packages/database

Rollback

rm -rf apps/api/src/departments

Lock

apps/api/src/departments/departments.repository.ts

Checkpoint

  • 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 errors

Common Errors

ErrorCauseFix
Cannot find module './departments.repository'Repository not exportedCheck index.ts exports
Circular dependencyImports cycleCheck import structure

Rollback

rm apps/api/src/departments/departments.service.ts

Lock

apps/api/src/departments/departments.service.ts

Checkpoint

  • 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/dto

Create 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

ErrorCauseFix
Cannot find module '../common/guards'Guards not at expected pathCheck Phase 01 guard location
Module not foundModule not registeredAdd 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/dto

Lock

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/dto

Create 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

ErrorCauseFix
Unknown type "TeamType"Prisma client not generatedRun npx prisma generate
Module not foundModule not registeredAdd TeamsModule to AppModule

Rollback

rm -rf apps/api/src/teams

Lock

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/org

Create 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 errors

Common Errors

ErrorCauseFix
Cannot find name 'Employee'Wrong importUse Prisma client types
orgRelations property not foundSchema not migratedRun db push in Step 52

Rollback

rm -rf apps/api/src/org

Lock

apps/api/src/org/org.repository.ts
apps/api/src/org/org.service.ts

Checkpoint

  • 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 errors

Common Errors

ErrorCauseFix
dottedLineManagers not found on typeRelation not added to schemaCheck Step 47 complete

Rollback

# Remove the new methods from org.repository.ts and org.service.ts

Lock

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 errors

Common Errors

ErrorCauseFix
Unique constraint violationManager already assignedCheck before adding
Circular reference not detectedLogic errorTest with A->B->A scenario

Rollback

# Remove the new methods from org.repository.ts and org.service.ts

Lock

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

ErrorCauseFix
Route not foundController not registeredCheck OrgModule in AppModule
Invalid enum valueWrong type valueUse '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/dto

Lock

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

ErrorCauseFix
Unique constraint violationAlready assigned to departmentUse upsert pattern
Department not foundWrong tenantVerify department exists in tenant

Rollback

# Remove department assignment methods and endpoints

Lock

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.ts

Checkpoint

  • 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

ErrorCauseFix
Team not foundWrong tenantVerify team exists in tenant
Unique constraint violationAlready in teamUse upsert pattern

Rollback

# Remove team assignment methods and endpoints

Lock

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.ts

Checkpoint

  • 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/teams

Step 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 profile

Checkpoint

  • 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:generate

3. 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 object

Checkpoint

  • 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 role

Checkpoint

  • 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 errors

Common Errors

ErrorCauseFix
reportingChain is emptyEmployee has no managerExpected for top-level employees
Circular reference not handledInfinite loopvisited Set prevents this
null in dottedLine arrayRelation deletedFilter nulls in map

Rollback

# Remove the new endpoint and method from controller and service

Lock

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 entry

3. 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 pattern

4. 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-structure

Next Phase

After verification, proceed to Phase 04: Org Visualization


Last Updated: 2025-11-30

On this page

Phase 03: Organization StructureStep Timing EstimatesPhase Context (READ FIRST)What This Phase AccomplishesWhat This Phase Does NOT IncludeBluewoo Anti-Pattern ReminderImplementation GuidanceStep 43: Add Department ModelInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 44: Add Team ModelInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 45: Add OrgRole ModelInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 46: Add EmployeeOrgRelations ModelInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 47: Add EmployeeDottedLine JunctionInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 48: Add EmployeeAdditionalManager JunctionInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 49: Add EmployeeDepartment JunctionInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 50: Add EmployeeTeam JunctionInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 51: Add EmployeeOrgRole JunctionInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 52: Run Migration for Org StructureInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 53: Create DepartmentRepositoryInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 54: Create DepartmentServiceInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 55: Create DepartmentControllerInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 56: Create Team Repository/Service/ControllerInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 57: Create OrgService getDirectReportsInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 58: Create OrgService getManagersInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 59: Create OrgService setManagerInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 60: Create Manager Assignment EndpointInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 61: Create Department Assignment EndpointInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointStep 62: Create Team Assignment EndpointInputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointPhase 03 Complete ChecklistSchema (Steps 43-52)API (Steps 53-62)Locked Files After Phase 03Quick Reference: API EndpointsStep 63: Add Manager/Reports UI Component (EMP-04)InputConstraintsTaskGateCheckpointStep 64: Add Department Head Assignment (ORG-07)InputConstraintsTaskGateCheckpointStep 65: Multiple Roles Assignment UI (EMP-03, EMP-04)InputConstraintsTaskGateCheckpointStep 66: Create Employee Org Summary Endpoint (ORG-08)InputConstraintsTaskGateCommon ErrorsRollbackLockCheckpointPhase Completion Checklist (MANDATORY)1. Gate Verification2. Update PROJECT_STATE.md3. Update WHAT_EXISTS.md4. Git Tag & CommitNext Phase