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.tsEmployee 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,EmployeeTeamfor flexible org structure- Supports
isPrimaryflag for primary department - Supports
rolefield for team role
3. Recursive Org Chart
- Top-down tree building starting from employees without managers
- Recursive
buildTreefunction for hierarchy
4. Circular Reference Prevention
checkCircularReferencemethod 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
| Constraint | Value | Reason |
|---|---|---|
| Max file size | 5MB | Performance |
| Allowed formats | JPG, PNG, WebP | Browser compatibility |
| Storage path | /uploads/profile-pictures/{employeeId} | Organized storage |
| Image processing | Resize to 400x400 max | Consistent 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/summaryResponse 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
primaryManagerIdchain 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
}