import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseIntPipe, ParseUUIDPipe, Patch, Post, Query, } from "@nestjs/common"; import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags, } from "@nestjs/swagger"; import { CurrentUser } from "@tria-plc/api-common/modules/auth/decorators/current-user.decorator"; import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; import { HrStaff } from "../../common/hr-guards"; import { actorFrom } from "../../common/current-actor.util"; import { HR_PERMS, IAM_PERMS } from "../../seed/hr-permissions.registry"; import { EmployeesService } from "./employees.service"; import { EmployeeDocumentsService } from "./employee-documents.service"; import { CreateEmployeeProfileDto } from "./dto/create-employee-profile.dto"; import { TerminateEmployeeDto, UpdateEmployeeProfileDto, } from "./dto/update-employee-profile.dto"; import { FindEmployeeProfilesDto } from "./dto/find-employee-profiles.dto"; import { CreateEmployeeDocumentDto } from "./dto/create-employee-document.dto"; @ApiTags("employee-profiles") @ApiBearerAuth() @Controller("employee-profiles") // The class gate lists every key its routes use: Nest runs class AND method // guards, so a key missing here denies before the route's own key is evaluated. @HrStaff([ HR_PERMS.employeeProfile.view, HR_PERMS.employeeProfile.viewOwn, HR_PERMS.employeeProfile.create, HR_PERMS.employeeProfile.update, HR_PERMS.employeeProfile.deactivate, HR_PERMS.employeeProfile.terminate, IAM_PERMS.employee.activate, IAM_PERMS.employee.deactivate, HR_PERMS.employeeDocument.view, HR_PERMS.employeeDocument.upload, HR_PERMS.employeeDocument.delete, ]) export class EmployeesController { constructor( private readonly employeesService: EmployeesService, private readonly documentsService: EmployeeDocumentsService, ) {} @Post() @HrStaff(HR_PERMS.employeeProfile.create) @ApiOperation({ summary: "Create the HR profile for an existing IAM employee", description: "The employee must already exist in iam.employees. Employee number is generated when omitted.", }) @ApiResponse({ status: 409, description: "This employee already has a profile" }) create( @Body() dto: CreateEmployeeProfileDto, @CurrentUser() user: TCurrentUser, ) { return this.employeesService.create(dto, actorFrom(user)); } @Get() @HrStaff(HR_PERMS.employeeProfile.view) @ApiOperation({ summary: "Employee directory — every IAM employee, with onboarding state", description: "Driven from iam.employees, left-joined to hr.employee_profiles. " + "`isOnboarded` says whether HR holds a profile. Filter with " + "?onboarded=false to get the onboarding queue.", }) findAll( @Query() filters: FindEmployeeProfilesDto, @CurrentUser() user: TCurrentUser, ) { return this.employeesService.findDirectory(filters, actorFrom(user)); } @Post("by-employee/:employeeId/ensure") @HrStaff(HR_PERMS.employeeProfile.create) @ApiOperation({ summary: "Onboard an IAM employee — return their HR profile, creating it if absent", description: "Idempotent. A newly created profile is flagged `isProvisional`: its hire " + "date is the IAM record's creation date and its employment type a default, " + "so both must be confirmed before payroll or leave accrual rely on them.", }) ensure( @Param("employeeId", ParseUUIDPipe) employeeId: string, @CurrentUser() user: TCurrentUser, ) { return this.employeesService.ensureProfile(employeeId, actorFrom(user)); } // Declared before `:id` so the literal path is not captured by the UUID route. @Get("me") @HrStaff(HR_PERMS.employeeProfile.viewOwn) @ApiOperation({ summary: "The signed-in employee's own profile" }) findOwn(@CurrentUser() user: TCurrentUser) { return this.employeesService.findOwn(actorFrom(user)); } @Get("headcount-summary") @HrStaff(HR_PERMS.employeeProfile.view) @ApiOperation({ summary: "Headcount by employment state, for the dashboard" }) headcountSummary(@CurrentUser() user: TCurrentUser) { return this.employeesService.headcountSummary(actorFrom(user)); } @Get("documents/expiring") @HrStaff(HR_PERMS.employeeDocument.view) @ApiOperation({ summary: "Employee documents expiring within the given number of days", }) expiringDocuments( @Query("days", new ParseIntPipe({ optional: true })) days = 30, @CurrentUser() user: TCurrentUser, ) { return this.documentsService.expiring(days, actorFrom(user)); } @Get(":id") @HrStaff(HR_PERMS.employeeProfile.view) @ApiOperation({ summary: "One employee profile, with IAM facts read through" }) findOne( @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { return this.employeesService.findOne(id, actorFrom(user)); } @Patch(":id") @HrStaff(HR_PERMS.employeeProfile.update) @ApiOperation({ summary: "Update an employee profile", description: "Employment state is not editable here — use the suspend / reinstate / terminate transitions.", }) update( @Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateEmployeeProfileDto, @CurrentUser() user: TCurrentUser, ) { return this.employeesService.update(id, dto, actorFrom(user)); } @Patch(":id/suspend") @HrStaff(HR_PERMS.employeeProfile.deactivate) @ApiOperation({ summary: "Suspend an employee" }) suspend( @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { return this.employeesService.suspend(id, actorFrom(user)); } @Patch(":id/reinstate") @HrStaff(HR_PERMS.employeeProfile.deactivate) @ApiOperation({ summary: "Return a suspended or on-leave employee to active" }) reinstate( @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { return this.employeesService.reinstate(id, actorFrom(user)); } @Patch(":id/terminate") @HrStaff(HR_PERMS.employeeProfile.terminate) @ApiOperation({ summary: "End an employment (terminal)", description: "The row is kept, not deleted — payroll, tax and pension reports read terminated employees for the period they worked.", }) terminate( @Param("id", ParseUUIDPipe) id: string, @Body() dto: TerminateEmployeeDto, @CurrentUser() user: TCurrentUser, ) { return this.employeesService.terminate(id, dto, actorFrom(user)); } // ── Documents ──────────────────────────────────────────────────────────── @Patch(":id/system-access/revoke") @HrStaff(IAM_PERMS.employee.deactivate) @ApiOperation({ summary: "Deactivate this employee's IAM account", description: "Revokes the login and ends every position they hold in IAM. Their HR " + "employment state is untouched — this is about access, not employment. " + "Not reversible in one step: restoring the account does not restore the " + "positions.", }) @ApiResponse({ status: 200, description: "Account deactivated" }) revokeSystemAccess( @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { return this.employeesService.setIamAccountActive(id, false, actorFrom(user)); } @Patch(":id/system-access/restore") @HrStaff(IAM_PERMS.employee.activate) @ApiOperation({ summary: "Reactivate this employee's IAM account", description: "Restores the login. Position assignments ended by a deactivation are NOT " + "restored — reassign them from the organisation explorer. Rejected while " + "the employment is TERMINATED or RETIRED.", }) @ApiResponse({ status: 200, description: "Account reactivated" }) @ApiResponse({ status: 400, description: "The employment has ended; reinstate it first", }) restoreSystemAccess( @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { return this.employeesService.setIamAccountActive(id, true, actorFrom(user)); } @Get(":id/documents") @HrStaff(HR_PERMS.employeeDocument.view) @ApiOperation({ summary: "Documents filed against an employee" }) listDocuments( @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { return this.documentsService.list(id, actorFrom(user)); } @Post(":id/documents") @HrStaff(HR_PERMS.employeeDocument.upload) @ApiOperation({ summary: "File a document against an employee", description: "Upload the file to DMS first, then record its id here.", }) addDocument( @Param("id", ParseUUIDPipe) id: string, @Body() dto: CreateEmployeeDocumentDto, @CurrentUser() user: TCurrentUser, ) { return this.documentsService.add(id, dto, actorFrom(user)); } @Delete(":id/documents/:documentId") @HrStaff(HR_PERMS.employeeDocument.delete) @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: "Remove a document from an employee's file", description: "Soft-deletes the HR index row; the DMS object is untouched.", }) removeDocument( @Param("id", ParseUUIDPipe) id: string, @Param("documentId", ParseUUIDPipe) documentId: string, @CurrentUser() user: TCurrentUser, ) { return this.documentsService.remove(id, documentId, actorFrom(user)); } }