Bluewoo HRMS
AI Development GuideEntity References

Reference - Employee Implementation

Complete NestJS employee module with org relationships

Reference: Employee Implementation

This is the complete Employee module implementation, demonstrating the more complex org relationships pattern. This extends the basic Controller → Repository pattern with the OrgService for managing organizational relationships.

Module Structure

apps/api/src/modules/employee/
├── employee.module.ts
├── employee.controller.ts
├── employee.repository.ts
├── employee.service.ts          # Service for complex business logic
├── org.service.ts               # Org relationship management
├── dto/
│   ├── create-employee.dto.ts
│   ├── update-employee.dto.ts
│   ├── employee-response.dto.ts
│   ├── assign-manager.dto.ts
│   └── assign-org.dto.ts
└── employee.controller.spec.ts

Employee Module

// employee.module.ts
import { Module } from '@nestjs/common';
import { EmployeeController } from './employee.controller';
import { EmployeeRepository } from './employee.repository';
import { EmployeeService } from './employee.service';
import { OrgService } from './org.service';
import { PrismaModule } from '../../prisma/prisma.module';

@Module({
  imports: [PrismaModule],
  controllers: [EmployeeController],
  providers: [EmployeeRepository, EmployeeService, OrgService],
  exports: [EmployeeRepository, EmployeeService, OrgService],
})
export class EmployeeModule {}

Employee Controller

// employee.controller.ts
import {
  Controller,
  Get,
  Post,
  Put,
  Delete,
  Body,
  Param,
  Query,
  HttpCode,
  HttpStatus,
  ParseUUIDPipe,
} from '@nestjs/common';
import { EmployeeRepository } from './employee.repository';
import { EmployeeService } from './employee.service';
import { OrgService } from './org.service';
import { CreateEmployeeDto } from './dto/create-employee.dto';
import { UpdateEmployeeDto } from './dto/update-employee.dto';
import { EmployeeResponseDto } from './dto/employee-response.dto';
import { AssignManagerDto } from './dto/assign-manager.dto';
import { AssignOrgDto } from './dto/assign-org.dto';
import { TenantId } from '../../common/decorators/tenant.decorator';
import { RequirePermissions } from '../../common/decorators/permissions.decorator';

@Controller('employees')
export class EmployeeController {
  constructor(
    private readonly employeeRepository: EmployeeRepository,
    private readonly employeeService: EmployeeService,
    private readonly orgService: OrgService,
  ) {}

  /**
   * List employees with pagination and filters
   */
  @Get()
  @RequirePermissions('employees:read')
  async findAll(
    @TenantId() tenantId: string,
    @Query('page') page: number = 1,
    @Query('limit') limit: number = 20,
    @Query('search') search?: string,
    @Query('departmentId') departmentId?: string,
    @Query('teamId') teamId?: string,
    @Query('status') status?: string,
  ): Promise<{ data: EmployeeResponseDto[]; total: number; page: number; limit: number }> {
    const { employees, total } = await this.employeeRepository.findAll(tenantId, {
      page,
      limit,
      search,
      departmentId,
      teamId,
      status,
    });

    return {
      data: employees.map((emp) => new EmployeeResponseDto(emp)),
      total,
      page,
      limit,
    };
  }

  /**
   * Get single employee with org relations
   */
  @Get(':id')
  @RequirePermissions('employees:read')
  async findOne(
    @TenantId() tenantId: string,
    @Param('id', ParseUUIDPipe) id: string,
  ): Promise<EmployeeResponseDto> {
    const employee = await this.employeeRepository.findByIdWithOrgRelations(tenantId, id);
    return new EmployeeResponseDto(employee);
  }

  /**
   * Create new employee
   */
  @Post()
  @RequirePermissions('employees:create')
  @HttpCode(HttpStatus.CREATED)
  async create(
    @TenantId() tenantId: string,
    @Body() createDto: CreateEmployeeDto,
  ): Promise<EmployeeResponseDto> {
    const employee = await this.employeeService.createEmployee(tenantId, createDto);
    return new EmployeeResponseDto(employee);
  }

  /**
   * Update employee
   */
  @Put(':id')
  @RequirePermissions('employees:update')
  async update(
    @TenantId() tenantId: string,
    @Param('id', ParseUUIDPipe) id: string,
    @Body() updateDto: UpdateEmployeeDto,
  ): Promise<EmployeeResponseDto> {
    const employee = await this.employeeService.updateEmployee(tenantId, id, updateDto);
    return new EmployeeResponseDto(employee);
  }

  /**
   * Terminate employee (soft delete)
   */
  @Delete(':id')
  @RequirePermissions('employees:delete')
  @HttpCode(HttpStatus.NO_CONTENT)
  async terminate(
    @TenantId() tenantId: string,
    @Param('id', ParseUUIDPipe) id: string,
  ): Promise<void> {
    await this.employeeService.terminateEmployee(tenantId, id);
  }

  // ==================== ORG RELATIONSHIP ENDPOINTS ====================

  /**
   * Get org chart data (hierarchical structure)
   */
  @Get('org-chart')
  @RequirePermissions('employees:read')
  async getOrgChart(@TenantId() tenantId: string) {
    return this.orgService.getOrgChart(tenantId);
  }

  /**
   * Get employee's direct reports
   */
  @Get(':id/direct-reports')
  @RequirePermissions('employees:read')
  async getDirectReports(
    @TenantId() tenantId: string,
    @Param('id', ParseUUIDPipe) id: string,
  ): Promise<EmployeeResponseDto[]> {
    const employees = await this.orgService.getDirectReports(tenantId, id);
    return employees.map((emp) => new EmployeeResponseDto(emp));
  }

  /**
   * Assign primary manager
   */
  @Put(':id/manager')
  @RequirePermissions('employees:update')
  async assignPrimaryManager(
    @TenantId() tenantId: string,
    @Param('id', ParseUUIDPipe) employeeId: string,
    @Body() dto: AssignManagerDto,
  ): Promise<EmployeeResponseDto> {
    const employee = await this.orgService.assignPrimaryManager(
      tenantId,
      employeeId,
      dto.managerId,
    );
    return new EmployeeResponseDto(employee);
  }

  /**
   * Add dotted-line manager
   */
  @Post(':id/dotted-line-managers')
  @RequirePermissions('employees:update')
  async addDottedLineManager(
    @TenantId() tenantId: string,
    @Param('id', ParseUUIDPipe) employeeId: string,
    @Body() dto: AssignManagerDto,
  ): Promise<void> {
    await this.orgService.addDottedLineManager(tenantId, employeeId, dto.managerId);
  }

  /**
   * Remove dotted-line manager
   */
  @Delete(':id/dotted-line-managers/:managerId')
  @RequirePermissions('employees:update')
  @HttpCode(HttpStatus.NO_CONTENT)
  async removeDottedLineManager(
    @TenantId() tenantId: string,
    @Param('id', ParseUUIDPipe) employeeId: string,
    @Param('managerId', ParseUUIDPipe) managerId: string,
  ): Promise<void> {
    await this.orgService.removeDottedLineManager(tenantId, employeeId, managerId);
  }

  /**
   * Assign to department
   */
  @Post(':id/departments')
  @RequirePermissions('employees:update')
  async assignToDepartment(
    @TenantId() tenantId: string,
    @Param('id', ParseUUIDPipe) employeeId: string,
    @Body() dto: AssignOrgDto,
  ): Promise<void> {
    await this.orgService.assignToDepartment(
      tenantId,
      employeeId,
      dto.targetId,
      dto.isPrimary,
    );
  }

  /**
   * Remove from department
   */
  @Delete(':id/departments/:departmentId')
  @RequirePermissions('employees:update')
  @HttpCode(HttpStatus.NO_CONTENT)
  async removeFromDepartment(
    @TenantId() tenantId: string,
    @Param('id', ParseUUIDPipe) employeeId: string,
    @Param('departmentId', ParseUUIDPipe) departmentId: string,
  ): Promise<void> {
    await this.orgService.removeFromDepartment(tenantId, employeeId, departmentId);
  }

  /**
   * Assign to team
   */
  @Post(':id/teams')
  @RequirePermissions('employees:update')
  async assignToTeam(
    @TenantId() tenantId: string,
    @Param('id', ParseUUIDPipe) employeeId: string,
    @Body() dto: AssignOrgDto,
  ): Promise<void> {
    await this.orgService.assignToTeam(tenantId, employeeId, dto.targetId, dto.role);
  }

  /**
   * Remove from team
   */
  @Delete(':id/teams/:teamId')
  @RequirePermissions('employees:update')
  @HttpCode(HttpStatus.NO_CONTENT)
  async removeFromTeam(
    @TenantId() tenantId: string,
    @Param('id', ParseUUIDPipe) employeeId: string,
    @Param('teamId', ParseUUIDPipe) teamId: string,
  ): Promise<void> {
    await this.orgService.removeFromTeam(tenantId, employeeId, teamId);
  }
}

Employee Repository

// employee.repository.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { Employee, EmployeeStatus, Prisma } from '@prisma/client';
import { CreateEmployeeDto } from './dto/create-employee.dto';
import { UpdateEmployeeDto } from './dto/update-employee.dto';

interface FindAllOptions {
  page: number;
  limit: number;
  search?: string;
  departmentId?: string;
  teamId?: string;
  status?: string;
}

// Type for employee with org relations
export type EmployeeWithOrgRelations = Employee & {
  orgRelations?: {
    primaryManager?: Employee | null;
    dottedLineManagers?: { manager: Employee }[];
    departments?: { department: { id: string; name: string }; isPrimary: boolean }[];
    teams?: { team: { id: string; name: string }; role?: string }[];
  } | null;
};

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

  async findAll(
    tenantId: string,
    options: FindAllOptions,
  ): Promise<{ employees: Employee[]; total: number }> {
    const { page, limit, search, departmentId, teamId, status } = options;
    const skip = (page - 1) * limit;

    const where: Prisma.EmployeeWhereInput = { tenantId };

    if (search) {
      where.OR = [
        { firstName: { contains: search, mode: 'insensitive' } },
        { lastName: { contains: search, mode: 'insensitive' } },
        { email: { contains: search, mode: 'insensitive' } },
        { employeeNumber: { contains: search, mode: 'insensitive' } },
      ];
    }

    if (status) {
      where.status = status as EmployeeStatus;
    }

    if (departmentId) {
      where.orgRelations = {
        departments: {
          some: { departmentId },
        },
      };
    }

    if (teamId) {
      where.orgRelations = {
        ...where.orgRelations,
        teams: {
          some: { teamId },
        },
      };
    }

    const [employees, total] = await this.prisma.$transaction([
      this.prisma.employee.findMany({
        where,
        skip,
        take: limit,
        orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }],
      }),
      this.prisma.employee.count({ where }),
    ]);

    return { employees, total };
  }

  async findById(tenantId: string, id: string): Promise<Employee> {
    const employee = await this.prisma.employee.findFirst({
      where: { id, tenantId },
    });

    if (!employee) {
      throw new NotFoundException(`Employee with ID ${id} not found`);
    }

    return employee;
  }

  async findByIdWithOrgRelations(
    tenantId: string,
    id: string,
  ): Promise<EmployeeWithOrgRelations> {
    const employee = await this.prisma.employee.findFirst({
      where: { id, tenantId },
      include: {
        orgRelations: {
          include: {
            primaryManager: true,
            dottedLineManagers: {
              include: { manager: true },
            },
            departments: {
              include: { department: true },
            },
            teams: {
              include: { team: true },
            },
          },
        },
      },
    });

    if (!employee) {
      throw new NotFoundException(`Employee with ID ${id} not found`);
    }

    return employee;
  }

  async create(tenantId: string, dto: CreateEmployeeDto): Promise<Employee> {
    return this.prisma.employee.create({
      data: {
        tenantId,
        employeeNumber: dto.employeeNumber,
        firstName: dto.firstName,
        lastName: dto.lastName,
        email: dto.email,
        phone: dto.phone,
        jobTitle: dto.jobTitle,
        employmentType: dto.employmentType || 'FULL_TIME',
        workMode: dto.workMode || 'ONSITE',
        status: 'ACTIVE',
        hireDate: dto.hireDate ? new Date(dto.hireDate) : null,
        // Create empty org relations record
        orgRelations: {
          create: {},
        },
      },
    });
  }

  async update(tenantId: string, id: string, dto: UpdateEmployeeDto): Promise<Employee> {
    await this.findById(tenantId, id);

    return this.prisma.employee.update({
      where: { id },
      data: {
        ...(dto.firstName && { firstName: dto.firstName }),
        ...(dto.lastName && { lastName: dto.lastName }),
        ...(dto.email && { email: dto.email }),
        ...(dto.phone !== undefined && { phone: dto.phone }),
        ...(dto.jobTitle && { jobTitle: dto.jobTitle }),
        ...(dto.employmentType && { employmentType: dto.employmentType }),
        ...(dto.workMode && { workMode: dto.workMode }),
        ...(dto.status && { status: dto.status }),
      },
    });
  }

  async terminate(tenantId: string, id: string): Promise<Employee> {
    await this.findById(tenantId, id);

    return this.prisma.employee.update({
      where: { id },
      data: {
        status: 'TERMINATED',
        terminationDate: new Date(),
      },
    });
  }
}

OrgService (Complex Business Logic)

// org.service.ts
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { Employee } from '@prisma/client';
import { EmployeeRepository, EmployeeWithOrgRelations } from './employee.repository';

interface OrgChartNode {
  id: string;
  name: string;
  jobTitle: string;
  pictureUrl?: string;
  children: OrgChartNode[];
}

@Injectable()
export class OrgService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly employeeRepository: EmployeeRepository,
  ) {}

  /**
   * Get org chart starting from top-level employees (no manager)
   */
  async getOrgChart(tenantId: string): Promise<OrgChartNode[]> {
    // Find employees with no primary manager (top level)
    const topLevelEmployees = await this.prisma.employee.findMany({
      where: {
        tenantId,
        status: 'ACTIVE',
        orgRelations: {
          primaryManagerId: null,
        },
      },
    });

    // Build tree recursively
    const buildTree = async (employee: Employee): Promise<OrgChartNode> => {
      const directReports = await this.getDirectReports(tenantId, employee.id);

      return {
        id: employee.id,
        name: `${employee.firstName} ${employee.lastName}`,
        jobTitle: employee.jobTitle || '',
        pictureUrl: employee.pictureUrl || undefined,
        children: await Promise.all(directReports.map(buildTree)),
      };
    };

    return Promise.all(topLevelEmployees.map(buildTree));
  }

  /**
   * Get all direct reports for an employee
   */
  async getDirectReports(tenantId: string, managerId: string): Promise<Employee[]> {
    // Verify manager exists
    await this.employeeRepository.findById(tenantId, managerId);

    return this.prisma.employee.findMany({
      where: {
        tenantId,
        status: 'ACTIVE',
        orgRelations: {
          primaryManagerId: managerId,
        },
      },
      orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }],
    });
  }

  /**
   * Assign primary manager to employee
   */
  async assignPrimaryManager(
    tenantId: string,
    employeeId: string,
    managerId: string | null,
  ): Promise<EmployeeWithOrgRelations> {
    // Verify employee exists
    await this.employeeRepository.findById(tenantId, employeeId);

    // If assigning a manager, verify they exist and aren't the same person
    if (managerId) {
      if (managerId === employeeId) {
        throw new BadRequestException('Employee cannot be their own manager');
      }

      await this.employeeRepository.findById(tenantId, managerId);

      // Check for circular reference
      await this.checkCircularReference(tenantId, employeeId, managerId);
    }

    // Update org relations
    await this.prisma.employeeOrgRelations.upsert({
      where: { employeeId },
      create: {
        employeeId,
        primaryManagerId: managerId,
      },
      update: {
        primaryManagerId: managerId,
      },
    });

    return this.employeeRepository.findByIdWithOrgRelations(tenantId, employeeId);
  }

  /**
   * Check for circular manager reference
   */
  private async checkCircularReference(
    tenantId: string,
    employeeId: string,
    newManagerId: string,
  ): Promise<void> {
    let currentManagerId: string | null = newManagerId;
    const visited = new Set<string>();

    while (currentManagerId) {
      if (visited.has(currentManagerId)) {
        throw new BadRequestException('Circular manager reference detected');
      }

      if (currentManagerId === employeeId) {
        throw new BadRequestException(
          'Circular reference: new manager reports to this employee',
        );
      }

      visited.add(currentManagerId);

      const orgRelations = await this.prisma.employeeOrgRelations.findUnique({
        where: { employeeId: currentManagerId },
        select: { primaryManagerId: true },
      });

      currentManagerId = orgRelations?.primaryManagerId || null;
    }
  }

  /**
   * Add dotted-line manager
   */
  async addDottedLineManager(
    tenantId: string,
    employeeId: string,
    managerId: string,
  ): Promise<void> {
    // Verify both exist
    await this.employeeRepository.findById(tenantId, employeeId);
    await this.employeeRepository.findById(tenantId, managerId);

    if (managerId === employeeId) {
      throw new BadRequestException('Employee cannot be their own dotted-line manager');
    }

    // Get or create org relations
    const orgRelations = await this.prisma.employeeOrgRelations.upsert({
      where: { employeeId },
      create: { employeeId },
      update: {},
    });

    // Add dotted line manager
    await this.prisma.employeeDottedLine.create({
      data: {
        orgRelationsId: orgRelations.id,
        managerId,
      },
    });
  }

  /**
   * Remove dotted-line manager
   */
  async removeDottedLineManager(
    tenantId: string,
    employeeId: string,
    managerId: string,
  ): Promise<void> {
    const orgRelations = await this.prisma.employeeOrgRelations.findUnique({
      where: { employeeId },
    });

    if (!orgRelations) {
      throw new NotFoundException('Employee org relations not found');
    }

    await this.prisma.employeeDottedLine.deleteMany({
      where: {
        orgRelationsId: orgRelations.id,
        managerId,
      },
    });
  }

  /**
   * Assign employee to department
   */
  async assignToDepartment(
    tenantId: string,
    employeeId: string,
    departmentId: string,
    isPrimary: boolean = false,
  ): Promise<void> {
    // Verify employee exists
    await this.employeeRepository.findById(tenantId, employeeId);

    // Verify department exists and belongs to tenant
    const department = await this.prisma.department.findFirst({
      where: { id: departmentId, tenantId },
    });

    if (!department) {
      throw new NotFoundException(`Department with ID ${departmentId} not found`);
    }

    // Get or create org relations
    const orgRelations = await this.prisma.employeeOrgRelations.upsert({
      where: { employeeId },
      create: { employeeId },
      update: {},
    });

    // If setting as primary, unset other primary departments
    if (isPrimary) {
      await this.prisma.employeeDepartment.updateMany({
        where: {
          orgRelationsId: orgRelations.id,
          isPrimary: true,
        },
        data: { isPrimary: false },
      });
    }

    // Add or update department assignment
    await this.prisma.employeeDepartment.upsert({
      where: {
        orgRelationsId_departmentId: {
          orgRelationsId: orgRelations.id,
          departmentId,
        },
      },
      create: {
        orgRelationsId: orgRelations.id,
        departmentId,
        isPrimary,
      },
      update: { isPrimary },
    });
  }

  /**
   * Remove employee from department
   */
  async removeFromDepartment(
    tenantId: string,
    employeeId: string,
    departmentId: string,
  ): Promise<void> {
    const orgRelations = await this.prisma.employeeOrgRelations.findUnique({
      where: { employeeId },
    });

    if (!orgRelations) {
      throw new NotFoundException('Employee org relations not found');
    }

    await this.prisma.employeeDepartment.deleteMany({
      where: {
        orgRelationsId: orgRelations.id,
        departmentId,
      },
    });
  }

  /**
   * Assign employee to team
   */
  async assignToTeam(
    tenantId: string,
    employeeId: string,
    teamId: string,
    role?: string,
  ): Promise<void> {
    // Verify employee exists
    await this.employeeRepository.findById(tenantId, employeeId);

    // Verify team exists and belongs to tenant
    const team = await this.prisma.team.findFirst({
      where: { id: teamId, tenantId },
    });

    if (!team) {
      throw new NotFoundException(`Team with ID ${teamId} not found`);
    }

    // Get or create org relations
    const orgRelations = await this.prisma.employeeOrgRelations.upsert({
      where: { employeeId },
      create: { employeeId },
      update: {},
    });

    // Add or update team assignment
    await this.prisma.employeeTeam.upsert({
      where: {
        orgRelationsId_teamId: {
          orgRelationsId: orgRelations.id,
          teamId,
        },
      },
      create: {
        orgRelationsId: orgRelations.id,
        teamId,
        role,
      },
      update: { role },
    });
  }

  /**
   * Remove employee from team
   */
  async removeFromTeam(
    tenantId: string,
    employeeId: string,
    teamId: string,
  ): Promise<void> {
    const orgRelations = await this.prisma.employeeOrgRelations.findUnique({
      where: { employeeId },
    });

    if (!orgRelations) {
      throw new NotFoundException('Employee org relations not found');
    }

    await this.prisma.employeeTeam.deleteMany({
      where: {
        orgRelationsId: orgRelations.id,
        teamId,
      },
    });
  }
}

DTOs

Create Employee DTO

// dto/create-employee.dto.ts
import {
  IsString,
  IsOptional,
  IsEmail,
  IsEnum,
  IsDateString,
  MinLength,
  MaxLength,
} from 'class-validator';
import { EmploymentType, WorkMode } from '@prisma/client';

export class CreateEmployeeDto {
  @IsOptional()
  @IsString()
  employeeNumber?: string;

  @IsString()
  @MinLength(1)
  @MaxLength(100)
  firstName: string;

  @IsString()
  @MinLength(1)
  @MaxLength(100)
  lastName: string;

  @IsEmail()
  email: string;

  @IsOptional()
  @IsString()
  phone?: string;

  // Emergency Contact
  @IsOptional()
  @IsString()
  emergencyContactName?: string;

  @IsOptional()
  @IsString()
  emergencyContactPhone?: string;

  @IsOptional()
  @IsString()
  emergencyContactRelation?: string;

  @IsOptional()
  @IsString()
  jobTitle?: string;

  @IsOptional()
  @IsEnum(EmploymentType)
  employmentType?: EmploymentType;

  @IsOptional()
  @IsEnum(WorkMode)
  workMode?: WorkMode;

  @IsOptional()
  @IsDateString()
  hireDate?: string;
}

Employee Response DTO

// dto/employee-response.dto.ts
import { EmployeeWithOrgRelations } from '../employee.repository';
import { EmployeeStatus, EmploymentType, WorkMode } from '@prisma/client';

interface ManagerInfo {
  id: string;
  name: string;
  jobTitle: string | null;
}

interface DepartmentInfo {
  id: string;
  name: string;
  isPrimary: boolean;
}

interface TeamInfo {
  id: string;
  name: string;
  role: string | null;
}

export class EmployeeResponseDto {
  id: string;
  employeeNumber: string | null;
  firstName: string;
  lastName: string;
  fullName: string;
  email: string;
  phone: string | null;
  pictureUrl: string | null;

  // Emergency Contact
  emergencyContactName: string | null;
  emergencyContactPhone: string | null;
  emergencyContactRelation: string | null;

  jobTitle: string | null;
  employmentType: EmploymentType;
  workMode: WorkMode;
  status: EmployeeStatus;
  hireDate: Date | null;

  // Org relations (only populated if requested with relations)
  primaryManager?: ManagerInfo | null;
  dottedLineManagers?: ManagerInfo[];
  departments?: DepartmentInfo[];
  teams?: TeamInfo[];

  constructor(employee: EmployeeWithOrgRelations) {
    this.id = employee.id;
    this.employeeNumber = employee.employeeNumber;
    this.firstName = employee.firstName;
    this.lastName = employee.lastName;
    this.fullName = `${employee.firstName} ${employee.lastName}`;
    this.email = employee.email;
    this.phone = employee.phone;
    this.pictureUrl = employee.pictureUrl;
    this.emergencyContactName = employee.emergencyContactName;
    this.emergencyContactPhone = employee.emergencyContactPhone;
    this.emergencyContactRelation = employee.emergencyContactRelation;
    this.jobTitle = employee.jobTitle;
    this.employmentType = employee.employmentType;
    this.workMode = employee.workMode;
    this.status = employee.status;
    this.hireDate = employee.hireDate;

    // Map org relations if present
    if (employee.orgRelations) {
      const { orgRelations } = employee;

      this.primaryManager = orgRelations.primaryManager
        ? {
            id: orgRelations.primaryManager.id,
            name: `${orgRelations.primaryManager.firstName} ${orgRelations.primaryManager.lastName}`,
            jobTitle: orgRelations.primaryManager.jobTitle,
          }
        : null;

      this.dottedLineManagers = orgRelations.dottedLineManagers?.map((dl) => ({
        id: dl.manager.id,
        name: `${dl.manager.firstName} ${dl.manager.lastName}`,
        jobTitle: dl.manager.jobTitle,
      }));

      this.departments = orgRelations.departments?.map((d) => ({
        id: d.department.id,
        name: d.department.name,
        isPrimary: d.isPrimary,
      }));

      this.teams = orgRelations.teams?.map((t) => ({
        id: t.team.id,
        name: t.team.name,
        role: t.role || null,
      }));
    }
  }
}

Assign Manager DTO

// dto/assign-manager.dto.ts
import { IsUUID, IsOptional } from 'class-validator';

export class AssignManagerDto {
  @IsOptional()
  @IsUUID()
  managerId?: string | null;
}

Assign Org DTO

// dto/assign-org.dto.ts
import { IsUUID, IsOptional, IsBoolean, IsString } from 'class-validator';

export class AssignOrgDto {
  @IsUUID()
  targetId: string;

  @IsOptional()
  @IsBoolean()
  isPrimary?: boolean;

  @IsOptional()
  @IsString()
  role?: string;
}

Key Patterns

1. Service Layer for Complex Logic

  • OrgService handles complex org relationship operations
  • Validates circular references, tenant isolation
  • Repository stays focused on data access

2. Junction Tables for Many-to-Many

  • EmployeeDepartment, EmployeeTeam for flexible org structure
  • Supports isPrimary flag for primary department
  • Supports role field for team role

3. Recursive Org Chart

  • Top-down tree building starting from employees without managers
  • Recursive buildTree function for hierarchy

4. Circular Reference Prevention

  • checkCircularReference method prevents manager loops
  • Traverses up the manager chain to detect cycles

Profile Picture Upload

Add profile picture upload endpoint to EmployeeController:

// In EmployeeController - add profile picture upload
import { UseInterceptors, UploadedFile, BadRequestException } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';

// Validation constants
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
const MAX_SIZE = 5 * 1024 * 1024; // 5MB

/**
 * Upload profile picture
 */
@Post(':id/picture')
@RequirePermissions('employees:update')
@UseInterceptors(FileInterceptor('file', {
  limits: { fileSize: MAX_SIZE },
  fileFilter: (req, file, cb) => {
    if (!ALLOWED_TYPES.includes(file.mimetype)) {
      cb(new BadRequestException('Invalid file type. Allowed: JPG, PNG, WebP'), false);
    } else {
      cb(null, true);
    }
  },
}))
async uploadPicture(
  @TenantId() tenantId: string,
  @Param('id', ParseUUIDPipe) id: string,
  @UploadedFile() file: Express.Multer.File,
): Promise<{ pictureUrl: string }> {
  const pictureUrl = await this.employeeService.uploadPicture(tenantId, id, file);
  return { pictureUrl };
}

/**
 * Delete profile picture
 */
@Delete(':id/picture')
@RequirePermissions('employees:update')
@HttpCode(HttpStatus.NO_CONTENT)
async deletePicture(
  @TenantId() tenantId: string,
  @Param('id', ParseUUIDPipe) id: string,
): Promise<void> {
  await this.employeeService.deletePicture(tenantId, id);
}

Profile Picture Rules

ConstraintValueReason
Max file size5MBPerformance
Allowed formatsJPG, PNG, WebPBrowser compatibility
Storage path/uploads/profile-pictures/{employeeId}Organized storage
Image processingResize to 400x400 maxConsistent display

Employee Org Summary Endpoint

Returns complete organizational context for an employee in a single call. Used by org chart detail view and reporting chain displays.

Endpoint

GET /api/v1/employees/:id/summary

Response Type

interface EmployeeOrgSummary {
  employee: {
    id: string;
    firstName: string;
    lastName: string;
    email: string;
    jobTitle: string | null;
    pictureUrl: string | null;
  };
  managers: {
    primary: ManagerInfo | null;
    dottedLine: ManagerInfo[];
    additional: ManagerInfo[];
  };
  departments: Array<{ id: string; name: string; isPrimary: boolean }>;
  teams: Array<{ id: string; name: string; role: string | null }>;
  roles: Array<{ id: string; name: string; category: string | null; isPrimary: boolean }>;
  reportingChain: Array<{ id: string; name: string; jobTitle: string | null }>;
}

interface ManagerInfo {
  id: string;
  firstName: string;
  lastName: string;
  jobTitle: string | null;
  pictureUrl?: string | null;
}

Reporting Chain

The reportingChain field contains an array of managers from the employee's immediate supervisor up to the top of the organization hierarchy.

How it works:

  • Walks up the primaryManagerId chain starting from the employee
  • Stops when no manager is found (top of org reached)
  • Stops if a cycle is detected (prevents infinite loops)
  • Returns managers in order: [immediate manager, their manager, ..., CEO]

Use cases:

  • Breadcrumb display: "You → Jane (Manager) → Bob (Director) → CEO"
  • Org chart path highlighting
  • Approval chain visualization

Example response:

{
  "data": {
    "employee": {
      "id": "emp-123",
      "firstName": "Alice",
      "lastName": "Developer",
      "email": "alice@company.com",
      "jobTitle": "Software Engineer",
      "pictureUrl": "/uploads/profile-pictures/emp-123"
    },
    "managers": {
      "primary": {
        "id": "emp-456",
        "firstName": "Jane",
        "lastName": "Manager",
        "jobTitle": "Engineering Manager",
        "pictureUrl": "/uploads/profile-pictures/emp-456"
      },
      "dottedLine": [],
      "additional": []
    },
    "departments": [
      { "id": "dept-1", "name": "Engineering", "isPrimary": true }
    ],
    "teams": [
      { "id": "team-1", "name": "Platform", "role": "Developer" }
    ],
    "roles": [
      { "id": "role-1", "name": "Individual Contributor", "category": "Engineering", "isPrimary": true }
    ],
    "reportingChain": [
      { "id": "emp-456", "name": "Jane Manager", "jobTitle": "Engineering Manager" },
      { "id": "emp-789", "name": "Bob Director", "jobTitle": "VP Engineering" },
      { "id": "emp-001", "name": "Carol CEO", "jobTitle": "Chief Executive Officer" }
    ]
  },
  "error": null
}

Next Steps