Files
edr-platform/apps/edr-hr-api/src/modules/employees/employees.controller.ts
2026-08-25 00:11:39 +03:00

275 lines
9.2 KiB
TypeScript

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));
}
}