Muluhabt ERP modules

This commit is contained in:
Mulu Mehari
2026-08-25 00:11:39 +03:00
parent 5c2100e76d
commit 70171fa9d8
441 changed files with 68587 additions and 214 deletions

View File

@@ -30,7 +30,7 @@
| Backoffice | `@edr/freight-backoffice` | 5183 | axios `auth/http.ts`, baseURL `${VITE_BASE_API_URL}/api`, React Query |
| Freight API | `@edr/freight-api` | 3001 | NestJS, global prefix `/api`, Postgres schema `freight` |
| Payment API | `@edr/payment-api` | 3003 | NestJS, separate schema `edr_payment`, providers in `@edr/payment-providers` |
| Datastores | — | 5433 | Postgres `edr_freight`; MinIO (files); RabbitMQ (SMS/email/payment events) |
| Datastores | — | 5432 | Postgres `edr_database`, schema `freight` (one platform DB, schema-separated); MinIO (files); RabbitMQ (SMS/email/payment events) |
```mermaid
flowchart LR

View File

@@ -150,10 +150,15 @@ function buildConnectionOptions() {
return {
type: "postgres" as const,
host: process.env.DB_HOST ?? "localhost",
port: parseInt(process.env.DB_PORT ?? "5433", 10),
username: process.env.DB_USER ?? "postgres",
password: process.env.DB_PASSWORD ?? "",
database: process.env.DB_NAME ?? "edr_freight",
port: parseInt(process.env.DB_PORT ?? "5432", 10),
username: process.env.DB_USER ?? "edr",
password: process.env.DB_PASSWORD ?? "edr_secret",
// Single database, schema-separated: freight, passenger, iam, edr_payment and
// audit are schemas inside ONE Postgres database — the deployed topology (the
// production dump `dump-smart_office_prod-*.sql` is the `freight` schema of a
// Smart Office database). Freight owns the `freight` and `iam` schemas here;
// passenger-api and payment-api connect to the same database on their own.
database: process.env.DB_NAME ?? "edr_database",
// NOTE: do NOT pass `extra.options: '-c search_path=...'`. That sends the
// Postgres startup `options` parameter, which connection poolers (PgBouncer /
// proxies fronting the remote edr_dev DB) reject with

View File

@@ -1,12 +1,16 @@
import 'dotenv/config';
import { Client } from 'pg';
async function createSchema() {
// Connects to the single platform database — the same one the app uses — and
// ensures only the schema this app owns. Reads the standard DB_* env so it can
// never drift from config/database.config.ts.
const client = new Client({
host: 'localhost',
port: 5432,
user: 'postgres',
password: '',
database: 'edr_freight',
host: process.env.DB_HOST ?? 'localhost',
port: parseInt(process.env.DB_PORT ?? '5432', 10),
user: process.env.DB_USER ?? 'edr',
password: process.env.DB_PASSWORD ?? 'edr_secret',
database: process.env.DB_NAME ?? 'edr_database',
});
try {

View File

@@ -2,11 +2,12 @@
GT06_TCP_PORT=5023
GT06_TCP_HOST=0.0.0.0
# Shared freight database (same DB as @edr/freight-api). This app only writes
# freight.gps_devices / freight.gps_positions and NEVER runs migrations.
# The single, schema-separated platform database (same DB as @edr/freight-api).
# This app only writes freight.gps_devices / freight.gps_positions — both
# schema-qualified on the entities — and NEVER runs migrations.
DB_HOST=localhost
DB_PORT=5433
DB_NAME=edr_freight
DB_USER=postgres
DB_PASSWORD=postgres
DB_PORT=5432
DB_NAME=edr_database
DB_USER=edr
DB_PASSWORD=edr_secret
# TYPEORM_LOGGING=true # uncomment for SQL logging

View File

@@ -15,10 +15,11 @@ export default registerAs("database", (): TypeOrmModuleOptions => {
return {
type: "postgres",
host: process.env.DB_HOST ?? "localhost",
port: parseInt(process.env.DB_PORT ?? "5433", 10),
username: process.env.DB_USER ?? "postgres",
password: process.env.DB_PASSWORD ?? "",
database: process.env.DB_NAME ?? "edr_freight",
port: parseInt(process.env.DB_PORT ?? "5432", 10),
username: process.env.DB_USER ?? "edr",
password: process.env.DB_PASSWORD ?? "edr_secret",
// Same single, schema-separated platform database as every other app.
database: process.env.DB_NAME ?? "edr_database",
entities: [GpsDevice, GpsPosition],
migrations: [],
migrationsRun: false,

View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": false
}
}

View File

@@ -0,0 +1,74 @@
{
"name": "@edr/hr-api",
"version": "0.0.0",
"private": true,
"description": "EDR HR API \u2014 extends IAM with employee profiles, HR org structure (job titles, job positions, department profiles) and employee documents",
"scripts": {
"clean": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true}); fs.rmSync('.tsbuildinfo',{force:true});\"",
"predev": "pnpm run clean",
"dev": "nest start --watch",
"prebuild": "pnpm run clean",
"build": "nest build && node -e \"require('fs').cpSync('src/scripts/iam-ddl','dist/scripts/iam-ddl',{recursive:true})\"",
"start": "node dist/main.js",
"lint": "eslint src",
"test": "jest",
"type-check": "tsc --noEmit",
"migration:run": "node dist/scripts/migrate.js",
"seed:holidays": "node dist/scripts/seed-holidays.js",
"seed:hr": "APP_MODULE_PATH=./dist/app.module node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js",
"repair:iam-schema": "node dist/scripts/repair-iam-schema.js"
},
"dependencies": {
"@edr/api-common": "workspace:*",
"@edr/types": "workspace:*",
"@nestjs/common": "^11.0.0",
"@nestjs/config": "^4.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/jwt": "^11.0.2",
"@nestjs/platform-express": "^11.0.0",
"@nestjs/swagger": "^11.4.2",
"@nestjs/typeorm": "^11.0.1",
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.6.0.tgz",
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-1.3.4.tgz",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^17.4.2",
"pg": "^8.13.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "0.3.30"
},
"devDependencies": {
"@edr/eslint-config": "workspace:*",
"@edr/tsconfig": "workspace:*",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.0",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.13",
"@types/node": "^20.14.0",
"@types/pg": "^8.6.7",
"jest": "^29.7.0",
"ts-jest": "^29.2.5",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.5.4"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

View File

@@ -0,0 +1,113 @@
import { Module } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
import { DataSource, DataSourceOptions } from "typeorm";
import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module";
import { IamModule } from "@tria-plc/iamapi-common/iam.module";
import databaseConfig from "./config/database.config";
import {
APPLICATION_SEARCH_PATH,
ensurePostgresSchemas,
} from "./config/ensure-postgres-schemas";
import { IamDirectoryModule } from "./iam-directory/iam-directory.module";
import { EmployeesModule } from "./modules/employees/employees.module";
import { MeModule } from "./modules/me/me.module";
import { OrgExplorerModule } from "./modules/org-explorer/org-explorer.module";
import { LeaveModule } from "./modules/leave/leave.module";
import { AttendanceModule } from "./modules/attendance/attendance.module";
import { PayrollModule } from "./modules/payroll/payroll.module";
import { RecruitmentModule } from "./modules/recruitment/recruitment.module";
import { AppraisalModule } from "./modules/appraisal/appraisal.module";
import { ReportsModule } from "./modules/reports/reports.module";
import {
HR_APPLICATION,
HR_PERMISSIONS,
HR_ROLES,
HR_ROLE_PERMISSIONS,
} from "./seed/hr-permissions.registry";
import { JobTitlesModule } from "./modules/job-titles/job-titles.module";
import { JobPositionsModule } from "./modules/job-positions/job-positions.module";
import { UnitHrProfilesModule } from "./modules/unit-hr-profiles/unit-hr-profiles.module";
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true, load: [databaseConfig] }),
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
config.get<TypeOrmModuleOptions>("database")!,
dataSourceFactory: async (options) => {
if (!options) throw new Error("Missing TypeORM DataSource options");
await ensurePostgresSchemas(options as DataSourceOptions);
const dataSource = new DataSource(options as DataSourceOptions);
await dataSource.initialize();
// The database sits behind a connection pooler that rejects the Postgres
// `options` startup parameter (08P01), so search_path is set per physical
// connection: the pg Pool emits `connect` for every new client (initial
// fill, growth, reconnect). Without it, IamDirectoryService's schema-
// qualified reads still work, but IAM's own unqualified SQL would not.
const pool = (dataSource.driver as { master?: unknown }).master as
| { on?: (event: string, cb: (client: unknown) => void) => void }
| undefined;
if (pool?.on) {
pool.on("connect", (client) => {
(client as { query: (sql: string) => Promise<unknown> })
.query(`SET search_path TO ${APPLICATION_SEARCH_PATH}`)
.catch(() => {
/* connection is validated on its first real query */
});
});
}
return dataSource;
},
}),
// Authentication is IAM's, unchanged: SharedAuthModule provides JwtGuard,
// which validates the session against `iam.sessions` directly (schema-
// qualified SQL, verified). HR issues no tokens and stores no credentials.
SharedAuthModule,
// IAM, embedded — the platform pattern: every service takes the IAM package
// as a dependency, declares its own application + permissions, and IAM gates
// each action by the caller's position. Same shape as edr-passenger-api and
// edr-freight-api.
//
// This also brings IAM's own controllers (/units, /positions, /employees,
// /employee-positions, …) onto this app, which is intended: HR is a second
// front end to IAM's capabilities, not a fork of them.
//
// In-process matters for the hire flow specifically: creating an IAM user,
// an employee, an employee-position and an HR profile is ONE transaction
// here. Over HTTP it could not be, and a partial failure would strand an IAM
// user with no HR profile.
//
// Requires the entity globs in config/database.config.ts and
// `autoLoadEntities: false` — the partial forFeature set is what previously
// broke boot with `Entity metadata for User#sessions was not found`.
IamModule.forRoot({
applications: [HR_APPLICATION],
permissions: HR_PERMISSIONS,
roles: HR_ROLES,
rolePermissions: HR_ROLE_PERMISSIONS,
}),
IamDirectoryModule,
MeModule,
OrgExplorerModule,
JobTitlesModule,
JobPositionsModule,
UnitHrProfilesModule,
EmployeesModule,
LeaveModule,
AttendanceModule,
PayrollModule,
RecruitmentModule,
AppraisalModule,
ReportsModule,
],
})
export class AppModule {}

View File

@@ -0,0 +1,42 @@
import { ForbiddenException } from "@nestjs/common";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import {
currentEmployeeId,
isSuperAdmin,
type MeLikeUser,
} from "./hr-permission.util";
import type { ActorContext } from "../modules/employees/employees.service";
/**
* The one place a request's actor is derived from the token.
*
* `organizationId` comes from the token's employee block, never from the request
* body or a query parameter — it is the row-scoping key for every HR read and
* write, so letting a client supply it would be a tenancy hole.
*/
export function actorFrom(user: TCurrentUser): ActorContext {
const me = user as unknown as MeLikeUser;
const employee = me.employee;
const organizationId = Array.isArray(employee)
? employee[0]?.organizationId
: employee?.organizationId;
const superAdmin = isSuperAdmin(me);
// A super admin reads across organizations, so a missing employee record is
// not disqualifying for them. Everyone else must have one: it is the scoping
// key for every HR read and write.
if (!organizationId && !superAdmin) {
throw new ForbiddenException(
"This account has no organization context — HR requires a staff account",
);
}
return {
employeeId: currentEmployeeId(me),
userId: me.id ?? "",
organizationId: organizationId ?? "",
isSuperAdmin: superAdmin,
};
}

View File

@@ -0,0 +1,20 @@
import { applyDecorators, UseGuards } from "@nestjs/common";
import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard";
import { HrPermissionGuard } from "./hr-permission.guard";
/**
* The single route decorator for this app: authenticate, then require one of the
* given HR permission keys.
*
* Self-service routes (an employee reading their own profile) still go through
* it — they carry `can:view_own:*`, and the ownership check itself lives in the
* service, never in the guard.
*/
export const HrStaff = (permission: string | string[]) =>
applyDecorators(
UseGuards(
JwtGuard,
HrPermissionGuard(Array.isArray(permission) ? permission : [permission]),
),
);

View File

@@ -0,0 +1,57 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
Type,
UnauthorizedException,
} from "@nestjs/common";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { hasHrPermission, isSuperAdmin } from "./hr-permission.util";
// String literals on purpose, matching freight's guard: these are wire-format
// values of `iam.users.user_type`, and importing the vendored enum would couple
// this file to the package's internal layout for no gain.
const EMPLOYEE_USER_TYPE = "employee";
const userTypeOf = (user: TCurrentUser): string | undefined =>
(user as { userType?: string }).userType;
/** HR is staff-only end to end. A missing userType (stale session) also fails. */
const isEmployee = (user: TCurrentUser): boolean =>
userTypeOf(user) === EMPLOYEE_USER_TYPE || isSuperAdmin(user);
/**
* Guard factory. Passing several keys means "any one of these" — used on the
* class gate, which must list every key its routes use: Nest runs class AND
* method guards, so a key missing from the class list denies before the route's
* own key is ever evaluated.
*/
export function HrPermissionGuard(permissions: string[]): Type<CanActivate> {
@Injectable()
class HrPermissionsGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context
.switchToHttp()
.getRequest<{ user?: TCurrentUser }>();
const user = request.user;
if (!user) {
throw new UnauthorizedException("Authentication required");
}
if (!isEmployee(user)) {
throw new ForbiddenException("Staff account required");
}
if (!permissions?.length) return true;
if (permissions.some((p) => hasHrPermission(user, p))) return true;
throw new ForbiddenException(
`Missing permission. Required one of: ${permissions.join(", ")}`,
);
}
}
return HrPermissionsGuard;
}

View File

@@ -0,0 +1,108 @@
import { ForbiddenException } from "@nestjs/common";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
const SUPER_ADMIN_ROLE = "super_admin";
const ORGANIZATION_ADMIN_ROLE = "organization_admin";
type PermissionLike = { key?: string };
type PositionLike = { permissions?: PermissionLike[] };
/**
* The two token shapes IAM issues. `employee` is an object on a session token
* (`TCurrentUser`) and an array on the raw payload (`TCurrentTokenUser`); both
* reach controllers depending on how the session was minted, so every reader has
* to handle both. Mirrors freight's `collectPermissionKeys`.
*/
export type MeLikeUser = {
id?: string;
roles?: { key?: string }[];
permissions?: PermissionLike[];
employee?:
| {
id?: string;
organizationId?: string;
position?: PositionLike;
delegatedPositions?: PositionLike[];
}
| { id?: string; organizationId?: string; positions?: PositionLike[] }[]
| null;
};
export function isSuperAdmin(user: MeLikeUser | null | undefined): boolean {
return Boolean(user?.roles?.some((r) => r.key === SUPER_ADMIN_ROLE));
}
export function isOrganizationAdmin(
user: MeLikeUser | null | undefined,
): boolean {
return Boolean(user?.roles?.some((r) => r.key === ORGANIZATION_ADMIN_ROLE));
}
/** Flat permission keys from roles and every position the token carries. */
export function collectPermissionKeys(
user: MeLikeUser | null | undefined,
): string[] {
if (!user) return [];
const keys = new Set<string>();
for (const p of user.permissions ?? []) {
if (p.key) keys.add(p.key);
}
const employee = user.employee;
if (!employee) return [...keys];
if (Array.isArray(employee)) {
for (const emp of employee) {
for (const pos of emp.positions ?? []) {
for (const p of pos.permissions ?? []) {
if (p.key) keys.add(p.key);
}
}
}
return [...keys];
}
for (const p of employee.position?.permissions ?? []) {
if (p.key) keys.add(p.key);
}
for (const delegated of employee.delegatedPositions ?? []) {
for (const p of delegated.permissions ?? []) {
if (p.key) keys.add(p.key);
}
}
return [...keys];
}
export function hasHrPermission(
user: MeLikeUser | null | undefined,
permissionKey: string,
): boolean {
if (!user) return false;
if (isSuperAdmin(user)) return true;
return collectPermissionKeys(user).includes(permissionKey);
}
export function assertHrPermission(
user: TCurrentUser | MeLikeUser | null | undefined,
permissionKey: string,
): void {
if (hasHrPermission(user, permissionKey)) return;
throw new ForbiddenException(`Missing permission: ${permissionKey}`);
}
/**
* The caller's `iam.employees.id` — the value HR profiles hang off.
*
* Returns null for a token with no employee (a customer/portal account, or a
* super admin who is not staff). Self-service routes treat null as "no own
* record", never as "unrestricted".
*/
export function currentEmployeeId(
user: MeLikeUser | null | undefined,
): string | null {
const employee = user?.employee;
if (!employee) return null;
if (Array.isArray(employee)) return employee[0]?.id ?? null;
return employee.id ?? null;
}

View File

@@ -0,0 +1,54 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsIn, IsInt, IsOptional, IsString, Max, Min } from "class-validator";
/** Envelope every list endpoint returns, so the frontend table is one component. */
export type Paginated<T> = {
items: T[];
total: number;
page: number;
limit: number;
pageCount: number;
};
export class PaginationQueryDto {
@ApiPropertyOptional({ default: 1, minimum: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({ default: 25, minimum: 1, maximum: 200 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(200)
limit?: number = 25;
@ApiPropertyOptional()
@IsOptional()
@IsString()
sortBy?: string;
@ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "DESC" })
@IsOptional()
@IsIn(["ASC", "DESC"])
sortOrder?: "ASC" | "DESC" = "DESC";
}
export function paginate<T>(
items: T[],
total: number,
page: number,
limit: number,
): Paginated<T> {
return {
items,
total,
page,
limit,
pageCount: limit > 0 ? Math.ceil(total / limit) : 0,
};
}

View File

@@ -0,0 +1,109 @@
import { dirname } from "path";
import { registerAs } from "@nestjs/config";
import { TypeOrmModuleOptions } from "@nestjs/typeorm";
import { DataSourceOptions } from "typeorm";
/**
* HR migrations are recorded in `hr.migrations`, alongside `iam.typeorm_migrations`
* and `freight.migrations` in the same database. Each app owns exactly one history
* table; HR never writes the IAM one.
*/
export const HR_MIGRATIONS = {
schema: "hr",
table: "migrations",
} as const;
/**
* IAM entity registration.
*
* `IamModule` is embedded in this app (the platform pattern: every service takes
* the IAM package as a dependency and IAM gates each action by the caller's
* position), so its entities must be on this DataSource.
*
* Registered as GLOBS over the two package dists rather than as a hand-written
* class list. freight-api lists ~50 classes by hand and its own CLAUDE.md flags
* every package bump as a candidate to break it; globs cannot go stale.
*
* BOTH dists are required. Some IAM entities relate to notification entities
* that physically live in `@tria-plc/api-common` — the IAM barrel only
* re-exports them — so registering only the IAM dist throws
* `Entity metadata for User#sessions was not found` at boot. This is copied from
* `apps/edr-passenger-api/src/common/iam-typeorm.config.ts`, which documents the
* same failure.
*/
function resolvePackageDist(pkg: string): string {
// Node honours each package's `exports` map at runtime even though TypeScript's
// resolution does not, so require.resolve on the barrel lands in dist/.
return dirname(require.resolve(pkg)).replace(/\\/g, "/");
}
const IAM_ENTITY_GLOBS = [
`${resolvePackageDist("@tria-plc/iamapi-common")}/entities/**/*.entity.{ts,js}`,
`${resolvePackageDist("@tria-plc/api-common")}/entities/**/*.entity.{ts,js}`,
];
function buildConnectionOptions() {
return {
type: "postgres" as const,
host: process.env.DB_HOST ?? "localhost",
port: parseInt(process.env.DB_PORT ?? "5432", 10),
username: process.env.DB_USER ?? "edr",
password: process.env.DB_PASSWORD ?? "edr_secret",
database: process.env.DB_NAME ?? "edr_database",
// Do NOT pass `extra.options: '-c search_path=...'` — the connection pooler
// fronting the dev database rejects the Postgres `options` startup parameter
// with `08P01`. search_path is applied per physical connection in a pool
// `connect` handler instead (see app.module.ts).
synchronize: false,
logging:
process.env.TYPEORM_LOGGING === "true"
? true
: (["error", "warn"] as DataSourceOptions["logging"]),
};
}
/**
* Runtime options for the API. Carries no migrations — see `migration:run`.
*
* One connection serves both schemas: HR entities are `@Entity({schema:"hr"})`
* and IAM's are `schema:"iam"`, so they cannot collide. `autoLoadEntities` is
* OFF because everything is listed explicitly here — leaving it on lets
* IamModule's `forFeature` registrations add a PARTIAL entity set, which is the
* exact configuration that failed at boot.
*/
export function buildDataSourceOptions(): DataSourceOptions {
return {
...buildConnectionOptions(),
schema: HR_MIGRATIONS.schema,
entities: [__dirname + "/../**/*.entity.{ts,js}", ...IAM_ENTITY_GLOBS],
migrations: [],
};
}
/**
* HR migrations only, recorded in `hr.migrations`.
*
* Deliberately no entities: this DataSource exists to run HR's own DDL. HR never
* migrates the `iam` schema — that belongs to the IAM owner.
*/
export function buildHrMigrationDataSourceOptions(): DataSourceOptions {
return {
...buildConnectionOptions(),
schema: HR_MIGRATIONS.schema,
entities: [],
migrations: [__dirname + "/../migrations/*.js"],
migrationsTableName: HR_MIGRATIONS.table,
migrationsTransactionMode: "each",
};
}
export default registerAs(
"database",
(): TypeOrmModuleOptions => ({
...buildDataSourceOptions(),
autoLoadEntities: false,
// Migrations run as a separate one-shot step, never on API boot (house rule).
migrationsRun: false,
}),
);

View File

@@ -0,0 +1,62 @@
import { DataSource, DataSourceOptions } from "typeorm";
/**
* Schemas this app touches. `hr` is the only one it owns and migrates; `iam` is
* read-only (owned by edr-freight-api's migrations) and is listed so the search
* path resolves unqualified IAM lookups, and so a brand-new database is usable
* before freight has ever booted against it.
*/
export const APPLICATION_SCHEMAS = ["public", "iam", "hr", "audit"] as const;
export const APPLICATION_SEARCH_PATH = APPLICATION_SCHEMAS.join(",");
/**
* Extensions the migrations call into but never create themselves — the HR
* baseline defaults primary keys to `gen_random_uuid()`.
*/
export const APPLICATION_EXTENSIONS = ["uuid-ossp", "pgcrypto"] as const;
/**
* TypeORM creates the migrations table before any migration runs, so the schema
* that table lives in has to exist first. Mirrors
* `apps/edr-freight-api/src/config/ensure-postgres-schemas.ts` — deliberately, so
* the two apps cannot disagree about how the shared database is prepared.
*/
export async function ensurePostgresSchemas(
options: DataSourceOptions,
): Promise<void> {
const bootstrap = new DataSource({
...options,
entities: [],
migrations: [],
migrationsRun: false,
synchronize: false,
});
await bootstrap.initialize();
for (const schema of APPLICATION_SCHEMAS) {
if (schema === "public") {
await bootstrap.query(`CREATE SCHEMA IF NOT EXISTS public`);
await bootstrap.query(`GRANT ALL ON SCHEMA public TO public`);
} else {
await bootstrap.query(`CREATE SCHEMA IF NOT EXISTS "${schema}"`);
await bootstrap.query(`GRANT USAGE ON SCHEMA "${schema}" TO public`);
await bootstrap.query(`GRANT CREATE ON SCHEMA "${schema}" TO public`);
}
}
// Best effort: creating an extension needs rights the app user may not have.
// On an established database they are already installed and this is a no-op.
for (const extension of APPLICATION_EXTENSIONS) {
try {
await bootstrap.query(`CREATE EXTENSION IF NOT EXISTS "${extension}"`);
} catch (err) {
console.warn(
`could not ensure extension "${extension}": ${(err as Error).message}`,
);
}
}
await bootstrap.destroy();
}

View File

@@ -0,0 +1,8 @@
import "dotenv/config";
import { DataSource } from "typeorm";
import { buildHrMigrationDataSourceOptions } from "./config/database.config";
/** Standalone DataSource for the TypeORM CLI and the migrate script. */
export const AppDataSource = new DataSource(buildHrMigrationDataSourceOptions());
export default AppDataSource;

View File

@@ -0,0 +1,16 @@
import { Global, Module } from "@nestjs/common";
import { IamDirectoryService } from "./iam-directory.service";
import { IamOperationsService } from "./iam-operations.service";
/**
* Global because every HR feature module needs to resolve an employee, and
* routing that through per-module imports would spread knowledge of IAM across
* the app — the opposite of what this module exists for.
*/
@Global()
@Module({
providers: [IamDirectoryService, IamOperationsService],
exports: [IamDirectoryService, IamOperationsService],
})
export class IamDirectoryModule {}

View File

@@ -0,0 +1,686 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { InjectDataSource } from "@nestjs/typeorm";
import { DataSource } from "typeorm";
/** `iam.employees` as HR reads it. Never written by this app. */
export type IamEmployee = {
id: string;
name: { am: string; en: string } | null;
status: string;
isCurrent: boolean;
unitId: string | null;
organizationId: string | null;
userId: string | null;
username: string | null;
email: string | null;
phoneNumber: string | null;
/**
* When IAM first recorded this employee — the auto-provision hire-date proxy.
* `Date`, not string: node-postgres hydrates timestamptz into a Date object.
*/
createdAt: Date | string | null;
};
/** One row of the employee directory — IAM identity plus HR onboarding state. */
export type DirectoryRow = {
employeeId: string;
name: { am: string; en: string } | null;
unitId: string | null;
organizationId: string | null;
username: string | null;
email: string | null;
profileId: string | null;
employeeNumber: string | null;
employmentState: string | null;
employmentType: string | null;
hireDate: string | null;
isOnboarded: boolean;
};
export type IamUnit = {
id: string;
name: { am: string; en: string } | null;
key: string;
parentUnitId: string | null;
organizationId: string;
};
/** A unit as the org explorer draws it: IAM facts + counts + the HR overlay. */
export type UnitTreeNode = {
id: string;
name: { am: string; en: string } | null;
key: string;
parentUnitId: string | null;
organizationId: string;
organizationName: { am: string; en: string } | null;
employeeCount: number;
positionCount: number;
hrProfileId: string | null;
costCentreCode: string | null;
headcountBudget: number | null;
headEmployeeId: string | null;
isHrActive: boolean | null;
};
/** Someone currently holding a position — substantively or by delegation. */
export type PositionHolder = {
employeePositionId: string;
employeeId: string;
name: { am: string; en: string } | null;
username: string | null;
isDelegate: boolean;
status: string;
startDate: string | null;
endDate: string | null;
delegatorId: string | null;
delegatorName: { am: string; en: string } | null;
isOnboarded: boolean;
profileId: string | null;
employeeNumber: string | null;
};
/** One node of the position hierarchy, flat with a depth marker. */
export type PositionTreeRow = {
id: string;
name: { am: string; en: string } | null;
key: string;
rank: number;
parentPositionId: string | null;
unitId: string | null;
unitName: { am: string; en: string } | null;
depth: number;
holderCount: number;
jobPositionId: string | null;
budgetedCount: number | null;
isOpen: boolean | null;
};
/** A position as the unit detail panel shows it. */
export type UnitPositionRow = {
id: string;
name: { am: string; en: string } | null;
key: string;
rank: number;
parentPositionId: string | null;
holderCount: number;
jobPositionId: string | null;
budgetedCount: number | null;
isOpen: boolean | null;
};
export type IamPosition = {
id: string;
name: { am: string; en: string } | null;
key: string;
rank: number;
parentPositionId: string | null;
unitId: string;
organizationId: string;
};
/**
* The ONLY place in this app that knows where IAM lives or what its tables are
* called.
*
* READ PROJECTIONS ONLY — and no longer the only way HR touches IAM.
*
* IamModule is now embedded in this app, so IAM's entities and services are
* available in-process. The division is deliberate:
*
* WRITES → IAM's own services, through IamModule. They run IAM's validation,
* hooks, notifications and audit. HR must never insert into `iam.*`
* itself; doing so would fork the behaviour one silent row at a time.
* READS → these hand-written projections, where a list needs three columns
* from two tables. Hydrating IAM's entity graph per row to render a
* table is wasteful, and the batched lookups below exist to keep
* list endpoints at one query per page instead of one per row.
*
* Use IAM's repositories directly when you need a whole IAM aggregate; use this
* when you need a projection.
*
* SOFT DELETES: `iam.units`, `iam.positions` and `iam.organizations` extend
* `SoftDeleteAudit`, so every query here filters `deleted_at IS NULL` — without
* it HR would list units IAM considers deleted. `iam.employees` and `iam.users`
* extend plain `Audit` and have no such column; do NOT add the predicate there.
*
* History worth keeping: those columns were absent from this database until
* 2026-08-20 (a dump predating the package's `1785530375522-IAMUpdate`), which
* made every IAM query against them 500 and briefly forced these filters out.
* `pnpm repair:iam-schema` restores them. If you see
* `column X.deleted_at does not exist`, the environment is drifted — run it.
*
* Every write path in HR stores IAM ids as plain UUIDs with no foreign key — the
* platform's soft-reference stance. That means nothing at the database level
* stops HR from pointing at an employee that no longer exists, so callers that
* accept an IAM id from a client MUST validate it here first.
*/
@Injectable()
export class IamDirectoryService {
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
private static readonly EMPLOYEE_SELECT = `
SELECT e.id,
e.name,
e.status,
e.is_current AS "isCurrent",
e.unit_id AS "unitId",
e.organization_id AS "organizationId",
e.user_id AS "userId",
u.username,
u.email,
u.phone_number AS "phoneNumber",
e.created_at AS "createdAt"
FROM iam.employees e
LEFT JOIN iam.users u ON u.id = e.user_id`;
async findEmployee(employeeId: string): Promise<IamEmployee | null> {
const rows = await this.dataSource.query<IamEmployee[]>(
`${IamDirectoryService.EMPLOYEE_SELECT} WHERE e.id = $1 LIMIT 1`,
[employeeId],
);
return rows[0] ?? null;
}
/**
* Validating variant for request handling: a client-supplied employee id that
* does not resolve is a 404, not a dangling row written into `hr`.
*/
async requireEmployee(employeeId: string): Promise<IamEmployee> {
const employee = await this.findEmployee(employeeId);
if (!employee) {
throw new NotFoundException(
`No IAM employee ${employeeId}. Create the employee in IAM before giving them an HR profile.`,
);
}
return employee;
}
/** Batch lookup for list endpoints — one query per page, never one per row. */
async findEmployees(employeeIds: string[]): Promise<Map<string, IamEmployee>> {
if (!employeeIds.length) return new Map();
const rows = await this.dataSource.query<IamEmployee[]>(
`${IamDirectoryService.EMPLOYEE_SELECT} WHERE e.id = ANY($1::uuid[])`,
[employeeIds],
);
return new Map(rows.map((row) => [row.id, row]));
}
/**
* Employees matching a free-text term, for pickers. Searches both locales of
* the jsonb name plus username/email, and returns only current employees.
*/
async searchEmployees(term: string, limit = 20): Promise<IamEmployee[]> {
return this.dataSource.query<IamEmployee[]>(
`${IamDirectoryService.EMPLOYEE_SELECT}
WHERE e.is_current = true
AND (
e.name->>'en' ILIKE $1
OR e.name->>'am' ILIKE $1
OR u.username ILIKE $1
OR u.email ILIKE $1
)
ORDER BY e.name->>'en'
LIMIT $2`,
[`%${term}%`, limit],
);
}
/**
* Current employees attached to any of the given units. Backs the department
* filter on the employee list: HR does not store the unit (IAM owns it), so a
* unit filter is resolved here and intersected with the HR page.
*/
async findEmployeesByUnits(unitIds: string[]): Promise<IamEmployee[]> {
if (!unitIds.length) return [];
return this.dataSource.query<IamEmployee[]>(
`${IamDirectoryService.EMPLOYEE_SELECT}
WHERE e.unit_id = ANY($1::uuid[])`,
[unitIds],
);
}
/**
* The employee DIRECTORY: every IAM employee, left-joined to their HR profile.
*
* This is the list HR actually needs. Querying `hr.employee_profiles` alone
* shows only people somebody has already onboarded — on this database that
* was 0 rows against 2,431 real employees, with no way to see or reach the
* rest. Driving from `iam.employees` instead makes everyone visible and marks
* who is onboarded, which is also what makes auto-provisioning possible: you
* cannot lazily create a profile for someone you cannot find.
*
* Scoping joins IAM rather than reading a copied `organization_id` from `hr` —
* the organization is IAM's fact and is deliberately not duplicated.
*/
async findEmployeeDirectoryPage(filters: {
organizationId?: string | null;
search?: string;
unitIds?: string[] | null;
onboarded?: boolean;
employmentState?: string;
limit: number;
offset: number;
}): Promise<{ items: DirectoryRow[]; total: number }> {
const where: string[] = ["e.is_current = true"];
const params: unknown[] = [];
if (filters.organizationId) {
params.push(filters.organizationId);
where.push(`e.organization_id = $${params.length}`);
}
if (filters.unitIds) {
if (!filters.unitIds.length) return { items: [], total: 0 };
params.push(filters.unitIds);
where.push(`e.unit_id = ANY($${params.length}::uuid[])`);
}
if (filters.search) {
params.push(`%${filters.search}%`);
const p = `$${params.length}`;
where.push(
`(e.name->>'en' ILIKE ${p} OR e.name->>'am' ILIKE ${p} ` +
`OR u.username ILIKE ${p} OR u.email ILIKE ${p} ` +
`OR p.employee_number ILIKE ${p})`,
);
}
if (filters.onboarded === true) where.push("p.id IS NOT NULL");
if (filters.onboarded === false) where.push("p.id IS NULL");
if (filters.employmentState) {
params.push(filters.employmentState);
where.push(`p.employment_state = $${params.length}`);
}
const from = `
FROM iam.employees e
LEFT JOIN iam.users u ON u.id = e.user_id
LEFT JOIN hr.employee_profiles p
ON p.employee_id = e.id AND p.deleted_at IS NULL
WHERE ${where.join(" AND ")}`;
const [count] = await this.dataSource.query<{ total: string }[]>(
`SELECT count(*)::text AS total ${from}`,
params,
);
const items = await this.dataSource.query<DirectoryRow[]>(
`SELECT e.id AS "employeeId",
e.name AS "name",
e.unit_id AS "unitId",
e.organization_id AS "organizationId",
u.username, u.email,
p.id AS "profileId",
p.employee_number AS "employeeNumber",
p.employment_state AS "employmentState",
p.employment_type AS "employmentType",
-- ::text on purpose. node-postgres hydrates a date column into a
-- Date at UTC midnight, which renders as the PREVIOUS day in any
-- timezone behind UTC -- Ethiopia is UTC+3, so 2026-05-16 came
-- back as 2026-05-15T21:00Z. Keep it a plain YYYY-MM-DD string,
-- matching what the TypeORM-mapped profile returns.
p.hire_date::text AS "hireDate",
(p.id IS NOT NULL) AS "isOnboarded"
${from}
ORDER BY (p.id IS NOT NULL) DESC, e.name->>'en' ASC
LIMIT ${filters.limit} OFFSET ${filters.offset}`,
params,
);
return { items, total: parseInt(count?.total ?? "0", 10) };
}
/**
* The whole unit tree for an organization, with the counts each node needs.
*
* One recursive query rather than a request per node: the tree is rendered
* whole, and 35 units × (staff count + position count) as separate round
* trips would be 70 queries to draw one screen.
*
* Counts come from IAM (staff, positions); the HR overlay is joined on so a
* node can show its cost centre and headcount budget without a second pass.
*/
async findUnitTree(organizationId: string | null): Promise<UnitTreeNode[]> {
const params: unknown[] = [];
let scope = "";
if (organizationId) {
params.push(organizationId);
scope = `WHERE u.organization_id = $${params.length}`;
}
return this.dataSource.query<UnitTreeNode[]>(
`SELECT u.id,
u.name,
u.key,
u.parent_unit_id AS "parentUnitId",
u.organization_id AS "organizationId",
o.name AS "organizationName",
(SELECT count(*)::int FROM iam.employees e
WHERE e.unit_id = u.id AND e.is_current = true) AS "employeeCount",
(SELECT count(*)::int FROM iam.positions p
WHERE p.unit_id = u.id AND p.deleted_at IS NULL) AS "positionCount",
hp.id AS "hrProfileId",
hp.cost_centre_code AS "costCentreCode",
hp.headcount_budget AS "headcountBudget",
hp.head_employee_id AS "headEmployeeId",
hp.is_hr_active AS "isHrActive"
FROM iam.units u
LEFT JOIN iam.organizations o ON o.id = u.organization_id
LEFT JOIN hr.unit_hr_profiles hp
ON hp.unit_id = u.id AND hp.deleted_at IS NULL
${scope}
ORDER BY u.name->>'en' ASC`,
params,
);
}
/** Fallback lookup after a hire, when IAM's response shape is unclear. */
async findEmployeeByUsername(username: string): Promise<IamEmployee | null> {
const rows = await this.dataSource.query<IamEmployee[]>(
`${IamDirectoryService.EMPLOYEE_SELECT}
WHERE u.username = $1
ORDER BY e.created_at DESC
LIMIT 1`,
[username],
);
return rows[0] ?? null;
}
async findUnit(unitId: string): Promise<IamUnit | null> {
const rows = await this.dataSource.query<IamUnit[]>(
`SELECT id,
name,
key,
parent_unit_id AS "parentUnitId",
organization_id AS "organizationId"
FROM iam.units
WHERE id = $1 AND deleted_at IS NULL
LIMIT 1`,
[unitId],
);
return rows[0] ?? null;
}
async requireUnit(unitId: string): Promise<IamUnit> {
const unit = await this.findUnit(unitId);
if (!unit) throw new NotFoundException(`No IAM unit ${unitId}`);
return unit;
}
async findUnits(unitIds: string[]): Promise<Map<string, IamUnit>> {
if (!unitIds.length) return new Map();
const rows = await this.dataSource.query<IamUnit[]>(
`SELECT id,
name,
key,
parent_unit_id AS "parentUnitId",
organization_id AS "organizationId"
FROM iam.units
WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL`,
[unitIds],
);
return new Map(rows.map((row) => [row.id, row]));
}
/**
* The POSITION hierarchy — which is where this platform's org chart actually
* lives.
*
* Verified on the production replica: 0 of 35 units have a parent, while
* 1,019 of 1,043 positions do, nested 7 levels deep. A unit-first tree would
* render a flat list of 35 and hide the real structure entirely.
*
* Returned flat with a depth marker and nested by the caller; one recursive
* CTE beats a query per node for a 1,000-node tree. Holder counts and the HR
* headcount overlay come along so a node needs no follow-up request.
*/
async findPositionTree(
organizationId: string | null,
unitId?: string,
): Promise<PositionTreeRow[]> {
const params: unknown[] = [];
const filters: string[] = ["p.deleted_at IS NULL"];
if (organizationId) {
params.push(organizationId);
filters.push(`p.organization_id = $${params.length}`);
}
if (unitId) {
params.push(unitId);
filters.push(`p.unit_id = $${params.length}`);
}
const where = filters.join(" AND ");
return this.dataSource.query<PositionTreeRow[]>(
`WITH RECURSIVE tree AS (
SELECT p.id, p.name, p.key, p.rank, p.parent_position_id, p.unit_id,
1 AS depth
FROM iam.positions p
WHERE ${where}
AND (p.parent_position_id IS NULL
OR NOT EXISTS (SELECT 1 FROM iam.positions par
WHERE par.id = p.parent_position_id
AND par.deleted_at IS NULL))
UNION ALL
SELECT c.id, c.name, c.key, c.rank, c.parent_position_id, c.unit_id,
tree.depth + 1
FROM iam.positions c
JOIN tree ON c.parent_position_id = tree.id
WHERE c.deleted_at IS NULL
AND tree.depth < 20
)
SELECT t.id,
t.name,
t.key,
t.rank,
t.parent_position_id AS "parentPositionId",
t.unit_id AS "unitId",
t.depth,
u.name AS "unitName",
(SELECT count(*)::int FROM iam.employee_positions ep
WHERE ep.position_id = t.id
AND ep.is_current = true
AND ep.is_delegate = false
AND ep.status = 'APPROVED') AS "holderCount",
jp.id AS "jobPositionId",
jp.budgeted_count AS "budgetedCount",
jp.is_open AS "isOpen"
FROM tree t
LEFT JOIN iam.units u ON u.id = t.unit_id
LEFT JOIN hr.job_positions jp
ON jp.position_id = t.id AND jp.deleted_at IS NULL
ORDER BY t.depth, t.rank, t.name->>'en'`,
params,
);
}
/**
* Who holds a position right now — substantive holders and delegates alike.
*
* Delegates are included but flagged. Delegation here is a RECORD MANAGEMENT
* arrangement — the holder has handed their record duties to a colleague for
* a period — not an HR assignment. So a delegate acts on the post's paperwork
* today while NOT filling it: excluded from headcount, and excluded from HR's
* line-manager resolution. Anything showing a post's people must distinguish
* the two rather than sum them.
*/
async findPositionHolders(positionId: string): Promise<PositionHolder[]> {
return this.dataSource.query<PositionHolder[]>(
`SELECT ep.id AS "employeePositionId",
ep.employee_id AS "employeeId",
e.name AS "name",
u.username,
ep.is_delegate AS "isDelegate",
ep.status,
ep.start_date::text AS "startDate",
ep.end_date::text AS "endDate",
ep.delegator_id AS "delegatorId",
del.name AS "delegatorName",
(hp.id IS NOT NULL) AS "isOnboarded",
hp.id AS "profileId",
hp.employee_number AS "employeeNumber"
FROM iam.employee_positions ep
JOIN iam.employees e ON e.id = ep.employee_id
LEFT JOIN iam.users u ON u.id = e.user_id
LEFT JOIN iam.employee_positions dep ON dep.id = ep.delegator_id
LEFT JOIN iam.employees del ON del.id = dep.employee_id
LEFT JOIN hr.employee_profiles hp
ON hp.employee_id = e.id AND hp.deleted_at IS NULL
WHERE ep.position_id = $1
AND ep.is_current = true
AND ep.status = 'APPROVED'
ORDER BY ep.is_delegate ASC, e.name->>'en' ASC`,
[positionId],
);
}
/** Immediate children of a unit. */
async findChildUnits(unitId: string): Promise<IamUnit[]> {
return this.dataSource.query<IamUnit[]>(
`SELECT id,
name,
key,
parent_unit_id AS "parentUnitId",
organization_id AS "organizationId"
FROM iam.units
WHERE parent_unit_id = $1 AND deleted_at IS NULL
ORDER BY name->>'en'`,
[unitId],
);
}
/**
* Positions in a unit, with their current holder count and the HR headcount
* overlay. Drives the unit detail panel — one query rather than one per post.
*/
async findUnitPositions(unitId: string): Promise<UnitPositionRow[]> {
return this.dataSource.query<UnitPositionRow[]>(
`SELECT p.id,
p.name,
p.key,
p.rank,
p.parent_position_id AS "parentPositionId",
(SELECT count(*)::int FROM iam.employee_positions ep
WHERE ep.position_id = p.id
AND ep.is_current = true
AND ep.is_delegate = false
AND ep.status = 'APPROVED') AS "holderCount",
jp.id AS "jobPositionId",
jp.budgeted_count AS "budgetedCount",
jp.is_open AS "isOpen"
FROM iam.positions p
LEFT JOIN hr.job_positions jp
ON jp.position_id = p.id AND jp.deleted_at IS NULL
WHERE p.unit_id = $1 AND p.deleted_at IS NULL
ORDER BY p.rank ASC, p.name->>'en' ASC`,
[unitId],
);
}
async findPosition(positionId: string): Promise<IamPosition | null> {
const rows = await this.dataSource.query<IamPosition[]>(
`SELECT id,
name,
key,
rank,
parent_position_id AS "parentPositionId",
unit_id AS "unitId",
organization_id AS "organizationId"
FROM iam.positions
WHERE id = $1 AND deleted_at IS NULL
LIMIT 1`,
[positionId],
);
return rows[0] ?? null;
}
async requirePosition(positionId: string): Promise<IamPosition> {
const position = await this.findPosition(positionId);
if (!position) throw new NotFoundException(`No IAM position ${positionId}`);
return position;
}
/**
* The employee's line manager, derived from the IAM position hierarchy:
* the employee's current position → its `parent_position_id` → whoever
* currently holds that parent position.
*
* This is the default approver for leave (3.2) and the default reviewer for
* appraisals (3.6). `hr.employee_profiles.manager_employee_id` overrides it
* when set, which is why this returns null rather than throwing — a unit head
* legitimately has no parent position.
*
* Delegated positions are excluded, and the reason is sharper than it looks:
* delegation on this platform belongs to the RECORD MANAGEMENT SYSTEM — a
* holder hands their record duties to a colleague for a period. It says
* nothing about the HR reporting line. Treating an RMS delegate as somebody's
* line manager would route leave approvals to a person who was only ever
* asked to handle paperwork.
*
* A parent position can have MANY current holders — verified against the live
* database, where "Ticket Officer" has 20 and "Passenger Director" 9. "The"
* line manager is therefore ambiguous, and an unordered LIMIT 1 would return a
* different person between two calls. The tiebreak below makes the answer
* deterministic by picking the longest-serving holder (earliest start date,
* then earliest row, then lowest id). Where that is not the right approver,
* `hr.employee_profiles.manager_employee_id` is the override — this is the
* case it exists for.
*/
async findLineManagerEmployeeId(employeeId: string): Promise<string | null> {
const rows = await this.dataSource.query<{ managerEmployeeId: string }[]>(
`SELECT parent_holder.employee_id AS "managerEmployeeId"
FROM iam.employee_positions ep
JOIN iam.positions p
ON p.id = ep.position_id
AND p.deleted_at IS NULL
JOIN iam.employee_positions parent_holder
ON parent_holder.position_id = p.parent_position_id
AND parent_holder.is_current = true
AND parent_holder.is_delegate = false
AND parent_holder.status = 'APPROVED'
WHERE ep.employee_id = $1
AND ep.is_current = true
AND ep.is_delegate = false
AND ep.status = 'APPROVED'
AND parent_holder.employee_id <> $1
ORDER BY p.rank ASC,
parent_holder.start_date ASC NULLS LAST,
parent_holder.created_at ASC,
parent_holder.employee_id ASC
LIMIT 1`,
[employeeId],
);
return rows[0]?.managerEmployeeId ?? null;
}
/**
* How many current, non-delegate holders a position has. Feeds
* `hr.job_positions.current_count`, which is a cache of this number — IAM
* remains the source of truth for who holds what.
*/
async countCurrentPositionHolders(positionId: string): Promise<number> {
const rows = await this.dataSource.query<{ count: string }[]>(
`SELECT COUNT(*)::text AS count
FROM iam.employee_positions
WHERE position_id = $1
AND is_current = true
AND is_delegate = false
AND status = 'APPROVED'`,
[positionId],
);
return parseInt(rows[0]?.count ?? "0", 10);
}
/** Every unit id at or beneath `unitId`, for department-scoped listings. */
async findUnitSubtreeIds(unitId: string): Promise<string[]> {
const rows = await this.dataSource.query<{ id: string }[]>(
`WITH RECURSIVE subtree AS (
SELECT id FROM iam.units WHERE id = $1 AND deleted_at IS NULL
UNION ALL
SELECT u.id
FROM iam.units u
JOIN subtree s ON u.parent_unit_id = s.id
WHERE u.deleted_at IS NULL
)
SELECT id FROM subtree`,
[unitId],
);
return rows.map((row) => row.id);
}
}

View File

@@ -0,0 +1,168 @@
import { Injectable, OnModuleInit } from "@nestjs/common";
import { ModuleRef } from "@nestjs/core";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import type { TCurrentUser as IamCurrentUser } from "@tria-plc/iamapi-common/types/current-user.type";
import { PositionService } from "@tria-plc/iamapi-common/module/organization-structure/services/position.service";
import { UnitService } from "@tria-plc/iamapi-common/module/organization-structure/services/unit.service";
import { EmployeePositionService } from "@tria-plc/iamapi-common/module/organization-structure/services/employee-position.service";
import { EmployeeService } from "@tria-plc/iamapi-common/module/organization-structure/services/employee.service";
/**
* The WRITE counterpart to IamDirectoryService.
*
* Every change HR makes to IAM data goes through IAM's own services, so IAM's
* validation, hooks, notifications and audit all run. HR never INSERTs into
* `iam.*` itself — doing so would fork the behaviour one silent row at a time.
*
* Resolved with `ModuleRef({ strict: false })` rather than constructor
* injection, because `OrganizationStructureModule` exports only
* `OrganizationService`; the rest are providers in the app graph but not
* exported, so they are unreachable by ordinary injection. Verified working
* against the package at 1.3.4.
*
* If a future version exports them, this whole indirection collapses into
* normal constructor injection and nothing else changes.
*
* VERSION SKEW, contained here on purpose: `iamapi-common@1.3.4` ships its own
* `TCurrentUser` whose `employee.position` carries two fields (`applications`,
* `positionTypes`) that `api-common@1.6.0`'s type does not. 1.6.0 is the newest
* api-common artifact that exists — its source repo is still on 1.4.3 — so the
* gap cannot be closed by upgrading. The value is the same object at runtime;
* only the declarations disagree. `asIamUser` marks every crossing so the day
* api-common catches up, the casts are trivial to find and delete.
*/
const asIamUser = (user: TCurrentUser): IamCurrentUser =>
user as unknown as IamCurrentUser;
@Injectable()
export class IamOperationsService implements OnModuleInit {
private units!: UnitService;
private positions!: PositionService;
private employeePositions!: EmployeePositionService;
private employees!: EmployeeService;
constructor(private readonly moduleRef: ModuleRef) {}
onModuleInit(): void {
// Resolved once at boot, not per call: a missing provider should fail the
// app at startup, not the first time an HR officer clicks "create unit".
this.units = this.moduleRef.get(UnitService, { strict: false });
this.positions = this.moduleRef.get(PositionService, { strict: false });
this.employeePositions = this.moduleRef.get(EmployeePositionService, {
strict: false,
});
this.employees = this.moduleRef.get(EmployeeService, { strict: false });
}
// ── Units ────────────────────────────────────────────────────────────────
async createUnit(
payload: {
name: { am: string; en: string };
key: string;
organizationId?: string;
parentUnitId?: string;
},
user: TCurrentUser,
) {
return this.units.create(payload as never, asIamUser(user));
}
/**
* Re-parent a unit. IAM moves the whole subtree with it — the callers's UI
* must say so before running this.
*/
async moveUnit(unitId: string, newParentUnitId: string): Promise<void> {
await this.units.relateUnitToUnit({ unitId, newParentUnitId } as never);
}
// ── Positions ────────────────────────────────────────────────────────────
async createPosition(
payload: {
name: { am: string; en: string };
key: string;
unitId: string;
organizationId?: string;
parentPositionId?: string;
rank: number;
positionTypeId?: string;
},
user: TCurrentUser,
) {
return this.positions.create(payload as never, asIamUser(user));
}
/** `null` promotes the position to a root of its unit's chart. */
async changePositionParent(
positionId: string,
newParentId: string | null,
): Promise<void> {
await this.positions.changeParentPosition(positionId, newParentId);
}
async softDeletePosition(positionId: string, user: TCurrentUser) {
return this.positions.softDelete(positionId, asIamUser(user));
}
// ── Assignment ───────────────────────────────────────────────────────────
async assignEmployeeToPosition(positionId: string, employeeId: string) {
return this.employeePositions.assignNewEmployee({
positionId,
employeeId,
} as never);
}
async removeEmployeeFromPosition(positionId: string, employeeId: string) {
return this.employeePositions.removeAssignedEmployee({
positionId,
employeeId,
} as never);
}
/**
* Create an IAM user + employee + position assignment in one call. This is
* IAM's own hire path — HR adds only its profile on top.
*/
async inviteEmployeeToPosition(
payload: {
positionId: string;
username: string;
phoneNumber: string;
email: string;
name: { am: string; en: string };
},
user: TCurrentUser,
) {
return this.employeePositions.inviteNewEmployee(payload as never, asIamUser(user));
}
async delegateToPosition(
payload: {
positionId: string;
unitId: string;
employeeId?: string;
startDate?: Date;
endDate?: Date;
},
user: TCurrentUser,
) {
return this.employeePositions.delegateEmployeeToPosition(
payload as never,
asIamUser(user),
);
}
// ── Employee lifecycle (IAM's, distinct from HR's employment state) ──────
// IAM's activate/deactivate take the employee id alone — they read the actor
// from the request context themselves, so no user argument is passed here.
async activateEmployee(employeeId: string) {
return this.employees.activateEmployee(employeeId);
}
async deactivateEmployee(employeeId: string) {
return this.employees.deactivateEmployee(employeeId);
}
}

View File

@@ -0,0 +1,69 @@
import "reflect-metadata";
import "dotenv/config";
import { NestFactory } from "@nestjs/core";
import { ValidationPipe } from "@nestjs/common";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import { AppModule } from "./app.module";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix("api/v1");
// The HR frontend is a separate origin (its own Vite dev server, its own host
// in production), so every call from it is cross-origin and dies at the
// preflight without this. `credentials: true` is required because the client
// sends the session cookie alongside the bearer token.
//
// Origins come from one comma-separated env var, matching how edr-passenger-api
// does it; the fallback is the HR web dev port.
const corsOrigins = (process.env.CORS_ORIGINS ?? "http://localhost:5185")
.split(",")
.map((origin) => origin.trim())
.filter((origin) => origin.length > 0);
app.enableCors({
origin: corsOrigins,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: [
"Content-Type",
"Authorization",
"Accept-Language",
"X-Client-App",
"X-Request-ID",
],
credentials: true,
});
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidUnknownValues: false,
}),
);
const config = new DocumentBuilder()
.setTitle("EDR HR API")
.setDescription(
"Human Resources — the HR extension of IAM. Employee profiles hang off " +
"iam.employees; departments are iam.units with an HR satellite; job " +
"positions extend iam.positions. HR never duplicates IAM: no auth, no " +
"users, no org hierarchy of its own — it imports IamModule and calls " +
"IAM's own services for the writes it needs (org changes, hiring, " +
"account activation), reading through raw SQL projections.",
)
.setVersion("1.0.0")
.addBearerAuth()
.build();
SwaggerModule.setup("api-docs", app, SwaggerModule.createDocument(app, config), {
customSiteTitle: "EDR HR API",
swaggerOptions: { persistAuthorization: true },
});
const port = process.env.PORT ?? 3005;
await app.listen(port);
console.log(`🚀 EDR HR API running on port ${port}`);
console.log(`📚 Swagger: http://localhost:${port}/api-docs`);
}
bootstrap();

View File

@@ -0,0 +1,252 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* HR baseline — Module 3.1 (Employee Management).
*
* Creates the four tables the HR extension of IAM needs plus the employee
* document index. All DDL is idempotent (`IF NOT EXISTS`), per the house rule,
* so a partially-applied run can be repeated safely.
*
* Deliberate omissions:
* - No foreign keys into `iam.*`. Cross-schema references are soft UUIDs with
* denormalized display data — the platform's existing stance (there are zero
* FKs from `freight.*` into `iam.*`). Validation happens in the service layer
* via IamDirectoryService.
* - No `hr.departments` table. The department hierarchy is `iam.units`;
* `hr.unit_hr_profiles` only adds the HR/Finance attributes on top of it.
*
* Timestamp 3600000000000 continues the freight convention of a 36xx.. prefix and
* is unique within this app's (currently empty) migration folder.
*/
export class HrBaseline3600000000000 implements MigrationInterface {
name = "HrBaseline3600000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE SCHEMA IF NOT EXISTS "hr"`);
await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS "pgcrypto"`);
// ── job_titles ────────────────────────────────────────────────────────
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "hr"."job_titles" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"organization_id" uuid NOT NULL,
"name" jsonb NOT NULL,
"code" varchar(32) NOT NULL,
"grade_level" integer NOT NULL DEFAULT 1,
"salary_band_min" numeric(14,2),
"salary_band_max" numeric(14,2),
"description" jsonb,
"is_active" boolean NOT NULL DEFAULT true,
"created_by" uuid,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
"deleted_at" timestamptz,
CONSTRAINT "ck_job_titles_salary_band"
CHECK ("salary_band_min" IS NULL OR "salary_band_max" IS NULL
OR "salary_band_max" >= "salary_band_min")
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_job_titles_org_code"
ON "hr"."job_titles" ("organization_id", "code")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_job_titles_organization_id"
ON "hr"."job_titles" ("organization_id")
`);
// ── unit_hr_profiles ──────────────────────────────────────────────────
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "hr"."unit_hr_profiles" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"unit_id" uuid NOT NULL,
"organization_id" uuid NOT NULL,
"cost_centre_code" varchar(32),
"headcount_budget" integer NOT NULL DEFAULT 0,
"head_employee_id" uuid,
"finance_expense_account_id" uuid,
"is_hr_active" boolean NOT NULL DEFAULT true,
"created_by" uuid,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
"deleted_at" timestamptz,
CONSTRAINT "ck_unit_hr_profiles_headcount_budget"
CHECK ("headcount_budget" >= 0)
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_unit_hr_profiles_unit_id"
ON "hr"."unit_hr_profiles" ("unit_id")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_unit_hr_profiles_organization_id"
ON "hr"."unit_hr_profiles" ("organization_id")
`);
// ── job_positions ─────────────────────────────────────────────────────
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "hr"."job_positions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"organization_id" uuid NOT NULL,
"position_id" uuid NOT NULL,
"job_title_id" uuid,
"unit_id" uuid,
"budgeted_count" integer NOT NULL DEFAULT 1,
"current_count" integer NOT NULL DEFAULT 0,
"is_open" boolean NOT NULL DEFAULT false,
"created_by" uuid,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
"deleted_at" timestamptz,
CONSTRAINT "ck_job_positions_budgeted_count" CHECK ("budgeted_count" >= 0),
CONSTRAINT "ck_job_positions_current_count" CHECK ("current_count" >= 0),
CONSTRAINT "fk_job_positions_job_title"
FOREIGN KEY ("job_title_id") REFERENCES "hr"."job_titles"("id")
ON DELETE SET NULL
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_job_positions_position_id"
ON "hr"."job_positions" ("position_id")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_job_positions_organization_id"
ON "hr"."job_positions" ("organization_id")
`);
// ── employee_profiles ─────────────────────────────────────────────────
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "hr"."employee_profiles" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"employee_id" uuid NOT NULL,
"organization_id" uuid NOT NULL,
"employee_number" varchar(64) NOT NULL,
"date_of_birth" date,
"amharic_date_of_birth" varchar(32),
"gender" varchar(16),
"nationality" varchar(64) DEFAULT 'Ethiopian',
"national_id" varchar(64),
"tin_number" varchar(32),
"marital_status" varchar(16),
"children_count" integer NOT NULL DEFAULT 0,
"work_phone" varchar(32),
"work_email" varchar(128),
"personal_address" jsonb,
"emergency_contact_name" jsonb,
"emergency_contact_phone" varchar(32),
"emergency_contact_relation" varchar(64),
"job_title_id" uuid,
"work_location_id" uuid,
"employment_type" varchar(16) NOT NULL DEFAULT 'PERMANENT',
"hire_date" date NOT NULL,
"amharic_hire_date" varchar(32),
"contract_end_date" date,
"probation_end_date" date,
"employment_state" varchar(16) NOT NULL DEFAULT 'PROBATION',
"termination_date" date,
"termination_reason" varchar(128),
"manager_employee_id" uuid,
"salary_mode" varchar(16) NOT NULL DEFAULT 'BANK',
"bank_name" varchar(128),
"bank_account_number" varchar(64),
"bank_account_name" varchar(128),
"is_pension_eligible" boolean NOT NULL DEFAULT true,
"profile_photo_document_id" uuid,
"created_by" uuid,
"updated_by" uuid,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
"deleted_at" timestamptz,
CONSTRAINT "ck_employee_profiles_children_count" CHECK ("children_count" >= 0),
CONSTRAINT "ck_employee_profiles_probation_after_hire"
CHECK ("probation_end_date" IS NULL OR "probation_end_date" >= "hire_date"),
CONSTRAINT "ck_employee_profiles_contract_after_hire"
CHECK ("contract_end_date" IS NULL OR "contract_end_date" >= "hire_date"),
CONSTRAINT "ck_employee_profiles_termination_after_hire"
CHECK ("termination_date" IS NULL OR "termination_date" >= "hire_date"),
CONSTRAINT "ck_employee_profiles_not_own_manager"
CHECK ("manager_employee_id" IS NULL OR "manager_employee_id" <> "employee_id"),
CONSTRAINT "fk_employee_profiles_job_title"
FOREIGN KEY ("job_title_id") REFERENCES "hr"."job_titles"("id")
ON DELETE SET NULL
)
`);
// Unique on employee_id, ignoring soft-deleted rows, so a profile can be
// re-created after one is removed without dropping the guarantee that a live
// employee has at most one.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_employee_profiles_employee_id"
ON "hr"."employee_profiles" ("employee_id")
WHERE "deleted_at" IS NULL
`);
// Employee numbers are NOT filtered on deleted_at: a number must never be
// reissued, or an archived payslip would point at two different people.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_employee_profiles_org_employee_number"
ON "hr"."employee_profiles" ("organization_id", "employee_number")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_employee_profiles_organization_id"
ON "hr"."employee_profiles" ("organization_id")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_employee_profiles_org_state"
ON "hr"."employee_profiles" ("organization_id", "employment_state")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_employee_profiles_manager"
ON "hr"."employee_profiles" ("manager_employee_id")
WHERE "manager_employee_id" IS NOT NULL
`);
// ── employee_documents ────────────────────────────────────────────────
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "hr"."employee_documents" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"employee_profile_id" uuid NOT NULL,
"organization_id" uuid NOT NULL,
"document_type" varchar(32) NOT NULL,
"document_id" uuid NOT NULL,
"document_number" varchar(128),
"issue_date" date,
"expiry_date" date,
"issuing_authority" varchar(128),
"notes" jsonb,
"created_by" uuid,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
"deleted_at" timestamptz,
CONSTRAINT "ck_employee_documents_expiry_after_issue"
CHECK ("issue_date" IS NULL OR "expiry_date" IS NULL
OR "expiry_date" >= "issue_date"),
CONSTRAINT "fk_employee_documents_profile"
FOREIGN KEY ("employee_profile_id")
REFERENCES "hr"."employee_profiles"("id") ON DELETE CASCADE
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_employee_documents_profile"
ON "hr"."employee_documents" ("employee_profile_id")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_employee_documents_expiry"
ON "hr"."employee_documents" ("organization_id", "expiry_date")
WHERE "expiry_date" IS NOT NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Reverse dependency order. The schema itself is left in place — dropping it
// would take any later HR migration's tables with it.
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."employee_documents"`);
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."employee_profiles"`);
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."job_positions"`);
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."unit_hr_profiles"`);
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."job_titles"`);
}
}

View File

@@ -0,0 +1,106 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Removes the denormalized `organization_id` from the two employee-scoped HR
* tables.
*
* An employee's organization is IAM's fact. Copying it into `hr` created a
* second answer to "which tenant is this person in", which can go stale the
* moment IAM moves someone. Scoping now joins `iam.employees` instead — cheap,
* because the platform is one database and the join is on a primary key.
*
* Consequence, deliberately accepted: employee numbers become GLOBALLY unique
* rather than unique per organization. Per-org uniqueness needs the column back
* (Postgres cannot enforce uniqueness across a join), and a global number also
* removes a genuine confusion — with per-org numbering a cross-organization
* list shows two different people both labelled EMP-00001.
*/
export class HrDropDenormalizedOrganization3600000000001
implements MigrationInterface
{
name = "HrDropDenormalizedOrganization3600000000001";
public async up(queryRunner: QueryRunner): Promise<void> {
// Guard against a collision before adding the global constraint: if two
// organizations already issued the same number, fail loudly here rather
// than half-apply.
const clashes = await queryRunner.query(
`SELECT employee_number, count(*) AS n
FROM "hr"."employee_profiles"
GROUP BY employee_number HAVING count(*) > 1`,
);
if (clashes.length) {
throw new Error(
`Cannot make employee_number globally unique: ${clashes.length} duplicate(s) ` +
`across organizations — ${clashes
.map((c: { employee_number: string }) => c.employee_number)
.join(", ")}. Renumber them first.`,
);
}
await queryRunner.query(
`DROP INDEX IF EXISTS "hr"."uq_employee_profiles_org_employee_number"`,
);
await queryRunner.query(
`DROP INDEX IF EXISTS "hr"."idx_employee_profiles_organization_id"`,
);
await queryRunner.query(
`DROP INDEX IF EXISTS "hr"."idx_employee_profiles_org_state"`,
);
await queryRunner.query(
`DROP INDEX IF EXISTS "hr"."idx_employee_documents_expiry"`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX IF NOT EXISTS "uq_employee_profiles_employee_number"
ON "hr"."employee_profiles" ("employee_number")`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "idx_employee_profiles_state"
ON "hr"."employee_profiles" ("employment_state")`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "idx_employee_documents_expiry"
ON "hr"."employee_documents" ("expiry_date")
WHERE "expiry_date" IS NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "hr"."employee_profiles" DROP COLUMN IF EXISTS "organization_id"`,
);
await queryRunner.query(
`ALTER TABLE "hr"."employee_documents" DROP COLUMN IF EXISTS "organization_id"`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Restores the columns and backfills them from IAM, so a revert lands on
// correct values rather than nulls.
await queryRunner.query(
`ALTER TABLE "hr"."employee_profiles" ADD COLUMN IF NOT EXISTS "organization_id" uuid`,
);
await queryRunner.query(
`UPDATE "hr"."employee_profiles" p
SET organization_id = e.organization_id
FROM "iam"."employees" e
WHERE e.id = p.employee_id`,
);
await queryRunner.query(
`ALTER TABLE "hr"."employee_documents" ADD COLUMN IF NOT EXISTS "organization_id" uuid`,
);
await queryRunner.query(
`UPDATE "hr"."employee_documents" d
SET organization_id = e.organization_id
FROM "hr"."employee_profiles" p
JOIN "iam"."employees" e ON e.id = p.employee_id
WHERE p.id = d.employee_profile_id`,
);
await queryRunner.query(
`DROP INDEX IF EXISTS "hr"."uq_employee_profiles_employee_number"`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX IF NOT EXISTS "uq_employee_profiles_org_employee_number"
ON "hr"."employee_profiles" ("organization_id", "employee_number")`,
);
}
}

View File

@@ -0,0 +1,39 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Adds `hr.employee_profiles.is_provisional`.
*
* Set when a profile was created by auto-provisioning rather than by someone
* completing the form: its hire date is a proxy (the IAM record's creation date)
* and its employment type a default. Payroll and leave accrual both key off hire
* date, so a provisional row must be visibly distinguishable from a recorded
* one — otherwise a guessed date silently becomes a salary calculation.
*/
export class HrEmployeeProvisionalFlag3600000000002
implements MigrationInterface
{
name = "HrEmployeeProvisionalFlag3600000000002";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "hr"."employee_profiles"
ADD COLUMN IF NOT EXISTS "is_provisional" boolean NOT NULL DEFAULT false`,
);
// Partial index: the "needs real data" queue is a small subset of a table
// that will grow, and it is the only thing this column is filtered on.
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "idx_employee_profiles_provisional"
ON "hr"."employee_profiles" ("is_provisional")
WHERE "is_provisional" = true`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS "hr"."idx_employee_profiles_provisional"`,
);
await queryRunner.query(
`ALTER TABLE "hr"."employee_profiles" DROP COLUMN IF EXISTS "is_provisional"`,
);
}
}

View File

@@ -0,0 +1,68 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Finishes what 3600000000001 started: removes the last IAM facts copied into
* `hr`.
*
* hr.job_positions.unit_id → iam.positions.unit_id
* hr.job_positions.organization_id → iam.positions.organization_id
* hr.unit_hr_profiles.organization_id → iam.units.organization_id
*
* Each was a denormalized cache, and each could disagree with IAM the moment a
* position or unit was moved between units or organizations. Scoping and
* grouping now join IAM instead — cheap, since it is one database and the join
* is on a primary key.
*
* `hr.job_titles.organization_id` deliberately STAYS: a job title has no IAM
* parent to inherit an organization from, so that column is HR's own fact, not
* a copy of anyone else's.
*/
export class HrDropRemainingDenormalizedIam3600000000003
implements MigrationInterface
{
name = "HrDropRemainingDenormalizedIam3600000000003";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS "hr"."idx_job_positions_organization_id"`,
);
await queryRunner.query(
`DROP INDEX IF EXISTS "hr"."idx_unit_hr_profiles_organization_id"`,
);
await queryRunner.query(
`ALTER TABLE "hr"."job_positions" DROP COLUMN IF EXISTS "unit_id"`,
);
await queryRunner.query(
`ALTER TABLE "hr"."job_positions" DROP COLUMN IF EXISTS "organization_id"`,
);
await queryRunner.query(
`ALTER TABLE "hr"."unit_hr_profiles" DROP COLUMN IF EXISTS "organization_id"`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Restore and backfill from IAM, so a revert lands on correct values.
await queryRunner.query(
`ALTER TABLE "hr"."job_positions" ADD COLUMN IF NOT EXISTS "unit_id" uuid`,
);
await queryRunner.query(
`ALTER TABLE "hr"."job_positions" ADD COLUMN IF NOT EXISTS "organization_id" uuid`,
);
await queryRunner.query(
`UPDATE "hr"."job_positions" jp
SET unit_id = p.unit_id, organization_id = p.organization_id
FROM "iam"."positions" p
WHERE p.id = jp.position_id`,
);
await queryRunner.query(
`ALTER TABLE "hr"."unit_hr_profiles" ADD COLUMN IF NOT EXISTS "organization_id" uuid`,
);
await queryRunner.query(
`UPDATE "hr"."unit_hr_profiles" up
SET organization_id = u.organization_id
FROM "iam"."units" u
WHERE u.id = up.unit_id`,
);
}
}

View File

@@ -0,0 +1,156 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Leave foundation — Module 3.2, slice 1.
*
* Three tables, none of which decide policy in code:
*
* - `leave_settings` one row per organization: what a leave year is, which
* days are not working days, how carry-over expires.
* - `leave_types` the catalogue. Every statutory number (16 days annual,
* 120 days maternity, …) is a COLUMN here, not a constant
* in a service, because the two statutes in play disagree:
* Labour Proclamation 1156/2019 gives 16 annual days +1 per
* 2 years of service, while Federal Civil Servants
* Proclamation 1064/2017 gives 20 +1 per year. Which one
* applies is a seeding decision, not a code change.
* - `holidays` concrete observed dates. Ethiopian public holidays cannot
* be stored as a fixed Gregorian month/day — the Ethiopian
* calendar drifts against Gregorian by a day around leap
* years, and the Islamic ones move ~11 days a year — so
* each occurrence is its own row and lunar ones are flagged
* estimated until confirmed.
*
* All DDL idempotent, per the house rule. No foreign keys into `iam.*`.
*/
export class HrLeaveFoundation3600000000004 implements MigrationInterface {
name = "HrLeaveFoundation3600000000004";
public async up(queryRunner: QueryRunner): Promise<void> {
// ── leave_settings ────────────────────────────────────────────────────
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "hr"."leave_settings" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"organization_id" uuid NOT NULL,
"leave_year_basis" varchar(20) NOT NULL DEFAULT 'FISCAL_YEAR',
"fiscal_year_start_month" smallint NOT NULL DEFAULT 7,
"fiscal_year_start_day" smallint NOT NULL DEFAULT 8,
"weekend_days" smallint[] NOT NULL DEFAULT '{0}',
"carry_over_deadline_months" smallint NOT NULL DEFAULT 6,
"allow_negative_balance" boolean NOT NULL DEFAULT false,
"statute_reference" varchar(64),
"created_by" uuid,
"updated_by" uuid,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
"deleted_at" timestamptz,
CONSTRAINT "ck_leave_settings_year_basis"
CHECK ("leave_year_basis" IN ('FISCAL_YEAR','CALENDAR_YEAR','HIRE_ANNIVERSARY')),
CONSTRAINT "ck_leave_settings_fiscal_month"
CHECK ("fiscal_year_start_month" BETWEEN 1 AND 12),
CONSTRAINT "ck_leave_settings_fiscal_day"
CHECK ("fiscal_year_start_day" BETWEEN 1 AND 31),
CONSTRAINT "ck_leave_settings_carry_over"
CHECK ("carry_over_deadline_months" BETWEEN 0 AND 24)
)
`);
// Partial unique: a soft-deleted settings row must not block a new one.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_leave_settings_organization"
ON "hr"."leave_settings" ("organization_id") WHERE "deleted_at" IS NULL
`);
// ── leave_types ───────────────────────────────────────────────────────
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "hr"."leave_types" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"organization_id" uuid NOT NULL,
"code" varchar(32) NOT NULL,
"name" jsonb NOT NULL,
"description" jsonb,
"is_paid" boolean NOT NULL DEFAULT true,
"accrual_method" varchar(24) NOT NULL DEFAULT 'ANNUAL_ENTITLEMENT',
"base_days_per_year" numeric(6,2) NOT NULL DEFAULT 0,
"extra_days_per_period" numeric(6,2) NOT NULL DEFAULT 0,
"service_period_years" smallint NOT NULL DEFAULT 0,
"max_days_per_year" numeric(6,2),
"max_carry_over_days" numeric(6,2) NOT NULL DEFAULT 0,
"max_consecutive_days" numeric(6,2),
"min_service_months" smallint NOT NULL DEFAULT 0,
"gender_restriction" varchar(8) NOT NULL DEFAULT 'ANY',
"counts_working_days_only" boolean NOT NULL DEFAULT true,
"allows_half_day" boolean NOT NULL DEFAULT false,
"requires_attachment_after" numeric(6,2),
"requires_approval" boolean NOT NULL DEFAULT true,
"statute_reference" varchar(64),
"sort_order" smallint NOT NULL DEFAULT 100,
"is_active" boolean NOT NULL DEFAULT true,
"created_by" uuid,
"updated_by" uuid,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
"deleted_at" timestamptz,
CONSTRAINT "ck_leave_types_accrual_method"
CHECK ("accrual_method" IN ('NONE','ANNUAL_ENTITLEMENT','MONTHLY_ACCRUAL')),
CONSTRAINT "ck_leave_types_gender"
CHECK ("gender_restriction" IN ('ANY','MALE','FEMALE')),
CONSTRAINT "ck_leave_types_base_days" CHECK ("base_days_per_year" >= 0),
CONSTRAINT "ck_leave_types_extra_days" CHECK ("extra_days_per_period" >= 0),
CONSTRAINT "ck_leave_types_carry_over" CHECK ("max_carry_over_days" >= 0),
CONSTRAINT "ck_leave_types_min_service" CHECK ("min_service_months" >= 0),
-- An accrual that grows with service needs a period to grow over;
-- "+1 day every 0 years" would divide by zero at entitlement time.
CONSTRAINT "ck_leave_types_service_period"
CHECK ("extra_days_per_period" = 0 OR "service_period_years" > 0)
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_leave_types_org_code"
ON "hr"."leave_types" ("organization_id", "code") WHERE "deleted_at" IS NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_leave_types_organization_id"
ON "hr"."leave_types" ("organization_id")
`);
// ── holidays ──────────────────────────────────────────────────────────
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "hr"."holidays" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"organization_id" uuid,
"observed_on" date NOT NULL,
"name" jsonb NOT NULL,
"holiday_type" varchar(16) NOT NULL DEFAULT 'PUBLIC',
"is_estimated" boolean NOT NULL DEFAULT false,
"is_working_day" boolean NOT NULL DEFAULT false,
"created_by" uuid,
"updated_by" uuid,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
"deleted_at" timestamptz,
CONSTRAINT "ck_holidays_type"
CHECK ("holiday_type" IN ('PUBLIC','RELIGIOUS','ORGANIZATION'))
)
`);
// organization_id is nullable — NULL means "national, applies to everyone".
// COALESCE keeps the unique index working across that NULL, which a plain
// multi-column unique index would not (NULLs never collide).
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_holidays_scope_date"
ON "hr"."holidays" (
COALESCE("organization_id", '00000000-0000-0000-0000-000000000000'::uuid),
"observed_on"
) WHERE "deleted_at" IS NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_holidays_observed_on"
ON "hr"."holidays" ("observed_on")
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."holidays"`);
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."leave_types"`);
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."leave_settings"`);
}
}

View File

@@ -0,0 +1,124 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Leave balances — Module 3.2, slice 2.
*
* A balance is NOT a column that gets incremented. It is the sum of an
* append-only ledger, because the question people actually ask is never "how
* many days are left" on its own — it is "why is it that number", asked months
* later, usually in a dispute. A mutable counter cannot answer that, and a
* counter plus a separate audit log drifts the first time a write half-fails.
*
* Two tables:
*
* - `leave_entitlements` — one row per employee, per leave type, per leave
* year. The envelope: which year, what was granted, what carried in. Balance
* is not stored on it.
* - `leave_ledger_entries` — every movement, immutable. Grants, carry-over,
* deductions, expiry, adjustments and reversals are all entries; nothing is
* ever updated or deleted, and a mistake is corrected by posting the
* opposite entry so the record of the mistake survives.
*
* `days` is signed: positive adds, negative removes. Summing the column IS the
* balance, which means no code path can produce a balance that disagrees with
* its own history.
*/
export class HrLeaveBalances3600000000005 implements MigrationInterface {
name = "HrLeaveBalances3600000000005";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "hr"."leave_entitlements" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"organization_id" uuid NOT NULL,
"employee_id" uuid NOT NULL,
"leave_type_id" uuid NOT NULL,
"leave_year_start" date NOT NULL,
"leave_year_end" date NOT NULL,
"entitled_days" numeric(7,2) NOT NULL DEFAULT 0,
"carried_over_days" numeric(7,2) NOT NULL DEFAULT 0,
"carry_over_expires_on" date,
"service_years_at_grant" numeric(5,2) NOT NULL DEFAULT 0,
"created_by" uuid,
"updated_by" uuid,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
"deleted_at" timestamptz,
CONSTRAINT "fk_leave_entitlements_type"
FOREIGN KEY ("leave_type_id") REFERENCES "hr"."leave_types" ("id"),
CONSTRAINT "ck_leave_entitlements_year"
CHECK ("leave_year_end" > "leave_year_start"),
CONSTRAINT "ck_leave_entitlements_days"
CHECK ("entitled_days" >= 0 AND "carried_over_days" >= 0)
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_leave_entitlements_employee_type_year"
ON "hr"."leave_entitlements"
("employee_id", "leave_type_id", "leave_year_start")
WHERE "deleted_at" IS NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_leave_entitlements_employee"
ON "hr"."leave_entitlements" ("employee_id")
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "hr"."leave_ledger_entries" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"entitlement_id" uuid NOT NULL,
"entry_type" varchar(16) NOT NULL,
"days" numeric(7,2) NOT NULL,
"effective_on" date NOT NULL,
"reason" varchar(256),
"source_type" varchar(24),
"source_id" uuid,
"reverses_id" uuid,
"created_by" uuid,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "fk_leave_ledger_entitlement"
FOREIGN KEY ("entitlement_id")
REFERENCES "hr"."leave_entitlements" ("id") ON DELETE CASCADE,
CONSTRAINT "fk_leave_ledger_reverses"
FOREIGN KEY ("reverses_id")
REFERENCES "hr"."leave_ledger_entries" ("id"),
CONSTRAINT "ck_leave_ledger_entry_type"
CHECK ("entry_type" IN
('GRANT','CARRY_OVER','DEDUCTION','EXPIRY','ADJUSTMENT','REVERSAL')),
-- Sign is fixed by type so a "deduction" can never quietly add days.
CONSTRAINT "ck_leave_ledger_sign" CHECK (
("entry_type" IN ('GRANT','CARRY_OVER') AND "days" > 0) OR
("entry_type" IN ('DEDUCTION','EXPIRY') AND "days" < 0) OR
("entry_type" IN ('ADJUSTMENT','REVERSAL'))
),
-- A reversal exists only to undo one specific entry.
CONSTRAINT "ck_leave_ledger_reversal_target" CHECK (
("entry_type" = 'REVERSAL' AND "reverses_id" IS NOT NULL) OR
("entry_type" <> 'REVERSAL' AND "reverses_id" IS NULL)
)
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_leave_ledger_entitlement"
ON "hr"."leave_ledger_entries" ("entitlement_id")
`);
// Looking up "was this request already deducted?" when a request is
// cancelled or amended.
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_leave_ledger_source"
ON "hr"."leave_ledger_entries" ("source_type", "source_id")
WHERE "source_id" IS NOT NULL
`);
// One reversal per entry: reversing twice would credit the days back twice.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_leave_ledger_one_reversal"
ON "hr"."leave_ledger_entries" ("reverses_id")
WHERE "reverses_id" IS NOT NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."leave_ledger_entries"`);
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."leave_entitlements"`);
}
}

View File

@@ -0,0 +1,96 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Leave requests — Module 3.2, slice 3.
*
* The request is a document with a state machine; the ledger remains the only
* place days move. An approval writes both in one transaction, so a request can
* never be approved without its deduction (leave taken and not counted) nor
* deducted without being approved (days lost to nothing).
*
* `working_days` and `calendar_days` are BOTH stored, computed at submission
* against the working week and holiday calendar as they stood that day. They are
* not recomputed on read: an employer who moves from a six-day to a five-day
* week must not silently rewrite the length of every absence already taken.
*
* `decided_by_employee_id` is a soft reference to `iam.employees`, like the rest
* of this schema.
*/
export class HrLeaveRequests3600000000006 implements MigrationInterface {
name = "HrLeaveRequests3600000000006";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "hr"."leave_requests" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"organization_id" uuid NOT NULL,
"employee_id" uuid NOT NULL,
"leave_type_id" uuid NOT NULL,
"status" varchar(16) NOT NULL DEFAULT 'SUBMITTED',
"start_date" date NOT NULL,
"end_date" date NOT NULL,
"is_half_day" boolean NOT NULL DEFAULT false,
"working_days" numeric(7,2) NOT NULL,
"calendar_days" numeric(7,2) NOT NULL,
"charged_days" numeric(7,2) NOT NULL,
"reason" varchar(512),
"contact_during_leave" varchar(64),
"attachment_document_id" uuid,
"approver_employee_id" uuid,
"decided_by_employee_id" uuid,
"decided_at" timestamptz,
"decision_note" varchar(512),
"cancelled_reason" varchar(512),
"created_by" uuid,
"updated_by" uuid,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
"deleted_at" timestamptz,
CONSTRAINT "fk_leave_requests_type"
FOREIGN KEY ("leave_type_id") REFERENCES "hr"."leave_types" ("id"),
CONSTRAINT "ck_leave_requests_status"
CHECK ("status" IN
('DRAFT','SUBMITTED','APPROVED','REJECTED','CANCELLED','WITHDRAWN')),
CONSTRAINT "ck_leave_requests_dates" CHECK ("end_date" >= "start_date"),
CONSTRAINT "ck_leave_requests_days"
CHECK ("working_days" >= 0 AND "calendar_days" >= 0 AND "charged_days" >= 0),
-- A half day is a single date by definition.
CONSTRAINT "ck_leave_requests_half_day"
CHECK ("is_half_day" = false OR "start_date" = "end_date"),
-- A decided request must say who decided it and when.
CONSTRAINT "ck_leave_requests_decision" CHECK (
"status" NOT IN ('APPROVED','REJECTED')
OR ("decided_by_employee_id" IS NOT NULL AND "decided_at" IS NOT NULL)
)
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_leave_requests_employee"
ON "hr"."leave_requests" ("employee_id", "start_date")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_leave_requests_approver"
ON "hr"."leave_requests" ("approver_employee_id")
WHERE "status" = 'SUBMITTED'
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_leave_requests_org_status"
ON "hr"."leave_requests" ("organization_id", "status")
`);
// Overlap detection reads by employee across a date range; a GiST exclusion
// constraint was considered and rejected — a rejected or cancelled request
// may legitimately overlap a later approved one, and the predicate for
// "only the live ones" is not expressible in an exclusion constraint
// without a partial index over a range type this schema does not otherwise
// use. The check lives in the service, which also has to explain itself.
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_leave_requests_live_range"
ON "hr"."leave_requests" ("employee_id", "start_date", "end_date")
WHERE "status" IN ('SUBMITTED','APPROVED') AND "deleted_at" IS NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."leave_requests"`);
}
}

View File

@@ -0,0 +1,174 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Attendance foundation — Module 3.3, slice 1.
*
* - `work_schedules` shift patterns: when the day starts, how long
* the break is, how late is late.
* - `work_schedule_assignments` who is on which pattern, with effective dates,
* so a shift change does not rewrite the
* attendance already recorded under the old one.
* - `attendance_records` one row per employee per date. `work_date` is
* a DATE, not a timestamp: a night shift that
* ends at 02:00 belongs to the day it started,
* and keying on a timestamp would file it under
* the wrong day and double-count the boundary.
*
* Every timing rule (grace period, hours for a full day) is a column, following
* the same principle as leave: the numbers are an employer's policy, not the
* developer's.
*
* Times are stored as `time` and `timestamptz` deliberately. The clock time a
* shift starts is a wall-clock fact with no date and no zone; a punch is an
* instant and needs both.
*/
export class HrAttendanceFoundation3600000000007 implements MigrationInterface {
name = "HrAttendanceFoundation3600000000007";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "hr"."work_schedules" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"organization_id" uuid NOT NULL,
"code" varchar(32) NOT NULL,
"name" jsonb NOT NULL,
"start_time" time NOT NULL DEFAULT '08:30',
"end_time" time NOT NULL DEFAULT '17:30',
"break_minutes" smallint NOT NULL DEFAULT 60,
"grace_period_minutes" smallint NOT NULL DEFAULT 10,
"working_days" smallint[],
"min_minutes_full_day" smallint NOT NULL DEFAULT 420,
"min_minutes_half_day" smallint NOT NULL DEFAULT 210,
"crosses_midnight" boolean NOT NULL DEFAULT false,
"is_default" boolean NOT NULL DEFAULT false,
"is_active" boolean NOT NULL DEFAULT true,
"created_by" uuid,
"updated_by" uuid,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
"deleted_at" timestamptz,
CONSTRAINT "ck_work_schedules_break"
CHECK ("break_minutes" >= 0 AND "break_minutes" < 1440),
CONSTRAINT "ck_work_schedules_grace"
CHECK ("grace_period_minutes" >= 0 AND "grace_period_minutes" <= 240),
CONSTRAINT "ck_work_schedules_thresholds"
CHECK ("min_minutes_half_day" >= 0
AND "min_minutes_full_day" >= "min_minutes_half_day"),
-- A shift that ends at or before it starts must say it crosses midnight,
-- or its duration computes as zero or negative.
CONSTRAINT "ck_work_schedules_span"
CHECK ("crosses_midnight" = true OR "end_time" > "start_time")
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_work_schedules_org_code"
ON "hr"."work_schedules" ("organization_id", "code")
WHERE "deleted_at" IS NULL
`);
// At most one default per organization — two would make "which applies?"
// depend on row order.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_work_schedules_one_default"
ON "hr"."work_schedules" ("organization_id")
WHERE "is_default" = true AND "deleted_at" IS NULL
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "hr"."work_schedule_assignments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"organization_id" uuid NOT NULL,
"employee_id" uuid NOT NULL,
"work_schedule_id" uuid NOT NULL,
"effective_from" date NOT NULL,
"effective_to" date,
"created_by" uuid,
"updated_by" uuid,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
"deleted_at" timestamptz,
CONSTRAINT "fk_schedule_assignments_schedule"
FOREIGN KEY ("work_schedule_id")
REFERENCES "hr"."work_schedules" ("id"),
CONSTRAINT "ck_schedule_assignments_range"
CHECK ("effective_to" IS NULL OR "effective_to" >= "effective_from")
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_schedule_assignments_employee"
ON "hr"."work_schedule_assignments" ("employee_id", "effective_from")
`);
// One open-ended assignment per employee: two would make "current schedule"
// ambiguous, which is the thing this table exists to answer.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_schedule_assignments_open"
ON "hr"."work_schedule_assignments" ("employee_id")
WHERE "effective_to" IS NULL AND "deleted_at" IS NULL
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "hr"."attendance_records" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"organization_id" uuid NOT NULL,
"employee_id" uuid NOT NULL,
"work_date" date NOT NULL,
"work_schedule_id" uuid,
"check_in" timestamptz,
"check_out" timestamptz,
"status" varchar(16) NOT NULL DEFAULT 'ABSENT',
"worked_minutes" integer NOT NULL DEFAULT 0,
"late_minutes" integer NOT NULL DEFAULT 0,
"early_leave_minutes" integer NOT NULL DEFAULT 0,
"source" varchar(16) NOT NULL DEFAULT 'WEB',
"leave_request_id" uuid,
"notes" varchar(512),
"is_regularized" boolean NOT NULL DEFAULT false,
"created_by" uuid,
"updated_by" uuid,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
"deleted_at" timestamptz,
CONSTRAINT "fk_attendance_schedule"
FOREIGN KEY ("work_schedule_id") REFERENCES "hr"."work_schedules" ("id"),
CONSTRAINT "fk_attendance_leave_request"
FOREIGN KEY ("leave_request_id") REFERENCES "hr"."leave_requests" ("id"),
CONSTRAINT "ck_attendance_status"
CHECK ("status" IN
('PRESENT','LATE','ABSENT','HALF_DAY','ON_LEAVE','HOLIDAY','REST_DAY')),
CONSTRAINT "ck_attendance_source"
CHECK ("source" IN ('WEB','MOBILE','BIOMETRIC','MANUAL','IMPORT','SYSTEM')),
CONSTRAINT "ck_attendance_minutes"
CHECK ("worked_minutes" >= 0 AND "late_minutes" >= 0
AND "early_leave_minutes" >= 0),
-- Cannot leave before arriving.
CONSTRAINT "ck_attendance_punch_order"
CHECK ("check_out" IS NULL OR "check_in" IS NULL
OR "check_out" >= "check_in"),
-- ON_LEAVE must point at the leave that explains it, so an absence can
-- never be excused by a leave request nobody can find.
CONSTRAINT "ck_attendance_leave_link"
CHECK ("status" <> 'ON_LEAVE' OR "leave_request_id" IS NOT NULL)
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_attendance_employee_date"
ON "hr"."attendance_records" ("employee_id", "work_date")
WHERE "deleted_at" IS NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_attendance_org_date"
ON "hr"."attendance_records" ("organization_id", "work_date")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_attendance_status"
ON "hr"."attendance_records" ("work_date", "status")
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."attendance_records"`);
await queryRunner.query(
`DROP TABLE IF EXISTS "hr"."work_schedule_assignments"`,
);
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."work_schedules"`);
}
}

View File

@@ -0,0 +1,168 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Regularization and overtime — Module 3.3, slice 2.
*
* - `attendance_regularizations` a request to correct a punch that was missed
* or wrong. The correction is applied only on
* approval, and the original values are kept on
* the row, so "what did it say before?" is
* answerable without an audit table.
* - `overtime_rates` the multipliers, per organization. Data, not
* constants: Proclamation 1156/2019 Art. 68
* sets 1.5× ordinary, 1.75× night, 2× on the
* weekly rest day and 2.5× on a public holiday,
* and an employer may pay more but not less.
* - `overtime_requests` hours claimed, with the category and the
* multiplier FROZEN at approval — a rate change
* next year must not silently reprice work
* already done and already paid.
*
* Art. 67 caps overtime at two hours a day; that limit is a column on the rate
* table rather than a constant, for the same reason.
*/
export class HrAttendanceOvertime3600000000008 implements MigrationInterface {
name = "HrAttendanceOvertime3600000000008";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "hr"."attendance_regularizations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"organization_id" uuid NOT NULL,
"employee_id" uuid NOT NULL,
"attendance_record_id" uuid,
"work_date" date NOT NULL,
"requested_check_in" timestamptz,
"requested_check_out" timestamptz,
"original_check_in" timestamptz,
"original_check_out" timestamptz,
"original_status" varchar(16),
"reason" varchar(512) NOT NULL,
"status" varchar(16) NOT NULL DEFAULT 'SUBMITTED',
"approver_employee_id" uuid,
"decided_by_employee_id" uuid,
"decided_at" timestamptz,
"decision_note" varchar(512),
"created_by" uuid,
"updated_by" uuid,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
"deleted_at" timestamptz,
CONSTRAINT "fk_regularizations_record"
FOREIGN KEY ("attendance_record_id")
REFERENCES "hr"."attendance_records" ("id"),
CONSTRAINT "ck_regularizations_status"
CHECK ("status" IN ('SUBMITTED','APPROVED','REJECTED','WITHDRAWN')),
CONSTRAINT "ck_regularizations_punch_order"
CHECK ("requested_check_out" IS NULL OR "requested_check_in" IS NULL
OR "requested_check_out" >= "requested_check_in"),
-- A correction that corrects nothing is not a correction.
CONSTRAINT "ck_regularizations_something"
CHECK ("requested_check_in" IS NOT NULL
OR "requested_check_out" IS NOT NULL),
CONSTRAINT "ck_regularizations_decision" CHECK (
"status" NOT IN ('APPROVED','REJECTED')
OR ("decided_by_employee_id" IS NOT NULL AND "decided_at" IS NOT NULL)
)
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_regularizations_employee"
ON "hr"."attendance_regularizations" ("employee_id", "work_date")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_regularizations_approver"
ON "hr"."attendance_regularizations" ("approver_employee_id")
WHERE "status" = 'SUBMITTED'
`);
// One open request per day: two competing corrections for the same date
// would make "which one did we apply?" depend on approval order.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_regularizations_open_per_day"
ON "hr"."attendance_regularizations" ("employee_id", "work_date")
WHERE "status" = 'SUBMITTED' AND "deleted_at" IS NULL
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "hr"."overtime_rates" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"organization_id" uuid NOT NULL,
"category" varchar(24) NOT NULL,
"multiplier" numeric(5,2) NOT NULL,
"max_hours_per_day" numeric(5,2),
"statute_reference" varchar(64),
"created_by" uuid,
"updated_by" uuid,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
"deleted_at" timestamptz,
CONSTRAINT "ck_overtime_rates_category"
CHECK ("category" IN ('DAY','NIGHT','REST_DAY','PUBLIC_HOLIDAY')),
-- Below 1.0 the "overtime" would pay less than ordinary time.
CONSTRAINT "ck_overtime_rates_multiplier"
CHECK ("multiplier" >= 1.00 AND "multiplier" <= 10.00),
CONSTRAINT "ck_overtime_rates_max_hours"
CHECK ("max_hours_per_day" IS NULL OR "max_hours_per_day" > 0)
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_overtime_rates_org_category"
ON "hr"."overtime_rates" ("organization_id", "category")
WHERE "deleted_at" IS NULL
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "hr"."overtime_requests" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"organization_id" uuid NOT NULL,
"employee_id" uuid NOT NULL,
"work_date" date NOT NULL,
"started_at" timestamptz NOT NULL,
"ended_at" timestamptz NOT NULL,
"hours" numeric(6,2) NOT NULL,
"category" varchar(24) NOT NULL,
"multiplier" numeric(5,2) NOT NULL,
"status" varchar(16) NOT NULL DEFAULT 'SUBMITTED',
"reason" varchar(512),
"approver_employee_id" uuid,
"decided_by_employee_id" uuid,
"decided_at" timestamptz,
"decision_note" varchar(512),
"created_by" uuid,
"updated_by" uuid,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
"deleted_at" timestamptz,
CONSTRAINT "ck_overtime_requests_category"
CHECK ("category" IN ('DAY','NIGHT','REST_DAY','PUBLIC_HOLIDAY')),
CONSTRAINT "ck_overtime_requests_status"
CHECK ("status" IN ('SUBMITTED','APPROVED','REJECTED','WITHDRAWN')),
CONSTRAINT "ck_overtime_requests_span" CHECK ("ended_at" > "started_at"),
CONSTRAINT "ck_overtime_requests_hours"
CHECK ("hours" > 0 AND "hours" <= 24),
CONSTRAINT "ck_overtime_requests_multiplier" CHECK ("multiplier" >= 1.00),
CONSTRAINT "ck_overtime_requests_decision" CHECK (
"status" NOT IN ('APPROVED','REJECTED')
OR ("decided_by_employee_id" IS NOT NULL AND "decided_at" IS NOT NULL)
)
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_overtime_requests_employee"
ON "hr"."overtime_requests" ("employee_id", "work_date")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_overtime_requests_approver"
ON "hr"."overtime_requests" ("approver_employee_id")
WHERE "status" = 'SUBMITTED'
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."overtime_requests"`);
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."overtime_rates"`);
await queryRunner.query(
`DROP TABLE IF EXISTS "hr"."attendance_regularizations"`,
);
}
}

View File

@@ -0,0 +1,225 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Payroll foundation — Module 3.4, slice 1.
*
* Money is `numeric(14,2)` everywhere and never a float. Rates are
* `numeric(7,4)` so a 7% pension contribution is 0.0700 exactly rather than a
* binary approximation that drifts over 2,400 payslips.
*
* - `salary_components` the catalogue: basic, allowances, deductions and
* employer contributions, each saying how it is
* computed and whether it is taxable and pensionable.
* - `salary_structures` a named set of components, usually per grade.
* - `salary_structure_lines` the components in a structure, with their amounts.
* - `employee_salaries` what an employee is actually on, effective-dated —
* a raise must not rewrite last month's payslip.
* - `income_tax_brackets` Schedule B, effective-dated. The bands are DATA:
* Ethiopia has revised them before and will again,
* and a payslip recomputed under a later schedule
* than the month it belongs to is simply wrong.
* - `statutory_rates` pension and anything else expressed as a rate.
*
* No foreign keys into `iam.*`, as everywhere else in this schema.
*/
export class HrPayrollFoundation3600000000009 implements MigrationInterface {
name = "HrPayrollFoundation3600000000009";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "hr"."salary_components" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"organization_id" uuid NOT NULL,
"code" varchar(32) NOT NULL,
"name" jsonb NOT NULL,
"component_type" varchar(24) NOT NULL,
"calculation" varchar(24) NOT NULL DEFAULT 'FIXED',
"default_amount" numeric(14,2),
"default_rate" numeric(7,4),
"is_taxable" boolean NOT NULL DEFAULT true,
"is_pensionable" boolean NOT NULL DEFAULT false,
"tax_exempt_amount" numeric(14,2),
"tax_exempt_rate_of_basic" numeric(7,4),
"affects_net_pay" boolean NOT NULL DEFAULT true,
"statute_reference" varchar(64),
"sort_order" smallint NOT NULL DEFAULT 100,
"is_active" boolean NOT NULL DEFAULT true,
"created_by" uuid,
"updated_by" uuid,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
"deleted_at" timestamptz,
CONSTRAINT "ck_salary_components_type"
CHECK ("component_type" IN
('BASIC','ALLOWANCE','DEDUCTION','EMPLOYER_CONTRIBUTION')),
CONSTRAINT "ck_salary_components_calculation"
CHECK ("calculation" IN
('FIXED','PERCENT_OF_BASIC','PERCENT_OF_GROSS','PERCENT_OF_TAXABLE','STATUTORY')),
CONSTRAINT "ck_salary_components_amounts"
CHECK ("default_amount" IS NULL OR "default_amount" >= 0),
CONSTRAINT "ck_salary_components_rate"
CHECK ("default_rate" IS NULL OR ("default_rate" >= 0 AND "default_rate" <= 1)),
-- A partial exemption is expressed as a cap, a fraction of basic, or
-- both (the lower wins). Negative values would create phantom income.
CONSTRAINT "ck_salary_components_exempt"
CHECK (("tax_exempt_amount" IS NULL OR "tax_exempt_amount" >= 0)
AND ("tax_exempt_rate_of_basic" IS NULL
OR ("tax_exempt_rate_of_basic" >= 0 AND "tax_exempt_rate_of_basic" <= 1)))
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_salary_components_org_code"
ON "hr"."salary_components" ("organization_id", "code")
WHERE "deleted_at" IS NULL
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "hr"."salary_structures" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"organization_id" uuid NOT NULL,
"code" varchar(32) NOT NULL,
"name" jsonb NOT NULL,
"description" jsonb,
"job_title_id" uuid,
"is_active" boolean NOT NULL DEFAULT true,
"created_by" uuid,
"updated_by" uuid,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
"deleted_at" timestamptz,
CONSTRAINT "fk_salary_structures_job_title"
FOREIGN KEY ("job_title_id") REFERENCES "hr"."job_titles" ("id")
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_salary_structures_org_code"
ON "hr"."salary_structures" ("organization_id", "code")
WHERE "deleted_at" IS NULL
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "hr"."salary_structure_lines" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"salary_structure_id" uuid NOT NULL,
"salary_component_id" uuid NOT NULL,
"amount" numeric(14,2),
"rate" numeric(7,4),
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
CONSTRAINT "fk_structure_lines_structure"
FOREIGN KEY ("salary_structure_id")
REFERENCES "hr"."salary_structures" ("id") ON DELETE CASCADE,
CONSTRAINT "fk_structure_lines_component"
FOREIGN KEY ("salary_component_id")
REFERENCES "hr"."salary_components" ("id"),
CONSTRAINT "ck_structure_lines_amount"
CHECK ("amount" IS NULL OR "amount" >= 0),
CONSTRAINT "ck_structure_lines_rate"
CHECK ("rate" IS NULL OR ("rate" >= 0 AND "rate" <= 1))
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_structure_lines_component"
ON "hr"."salary_structure_lines"
("salary_structure_id", "salary_component_id")
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "hr"."employee_salaries" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"organization_id" uuid NOT NULL,
"employee_id" uuid NOT NULL,
"salary_structure_id" uuid,
"basic_salary" numeric(14,2) NOT NULL,
"currency" varchar(3) NOT NULL DEFAULT 'ETB',
"effective_from" date NOT NULL,
"effective_to" date,
"reason" varchar(256),
"created_by" uuid,
"updated_by" uuid,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
"deleted_at" timestamptz,
CONSTRAINT "fk_employee_salaries_structure"
FOREIGN KEY ("salary_structure_id")
REFERENCES "hr"."salary_structures" ("id"),
CONSTRAINT "ck_employee_salaries_basic" CHECK ("basic_salary" >= 0),
CONSTRAINT "ck_employee_salaries_range"
CHECK ("effective_to" IS NULL OR "effective_to" >= "effective_from")
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_employee_salaries_employee"
ON "hr"."employee_salaries" ("employee_id", "effective_from")
`);
// One open salary per employee, or "what are they paid?" has two answers.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_employee_salaries_open"
ON "hr"."employee_salaries" ("employee_id")
WHERE "effective_to" IS NULL AND "deleted_at" IS NULL
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "hr"."income_tax_brackets" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"organization_id" uuid,
"effective_from" date NOT NULL,
"effective_to" date,
"lower_bound" numeric(14,2) NOT NULL,
"upper_bound" numeric(14,2),
"rate" numeric(7,4) NOT NULL,
"deduction" numeric(14,2) NOT NULL DEFAULT 0,
"statute_reference" varchar(64),
"created_by" uuid,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
"deleted_at" timestamptz,
CONSTRAINT "ck_tax_brackets_bounds"
CHECK ("upper_bound" IS NULL OR "upper_bound" > "lower_bound"),
CONSTRAINT "ck_tax_brackets_rate"
CHECK ("rate" >= 0 AND "rate" <= 1),
CONSTRAINT "ck_tax_brackets_deduction" CHECK ("deduction" >= 0),
CONSTRAINT "ck_tax_brackets_range"
CHECK ("effective_to" IS NULL OR "effective_to" >= "effective_from")
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_tax_brackets_effective"
ON "hr"."income_tax_brackets" ("effective_from", "lower_bound")
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "hr"."statutory_rates" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"organization_id" uuid,
"code" varchar(48) NOT NULL,
"name" jsonb NOT NULL,
"rate" numeric(7,4) NOT NULL,
"effective_from" date NOT NULL,
"effective_to" date,
"statute_reference" varchar(64),
"created_by" uuid,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
"deleted_at" timestamptz,
CONSTRAINT "ck_statutory_rates_rate"
CHECK ("rate" >= 0 AND "rate" <= 1),
CONSTRAINT "ck_statutory_rates_range"
CHECK ("effective_to" IS NULL OR "effective_to" >= "effective_from")
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_statutory_rates_code"
ON "hr"."statutory_rates" ("code", "effective_from")
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."statutory_rates"`);
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."income_tax_brackets"`);
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."employee_salaries"`);
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."salary_structure_lines"`);
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."salary_structures"`);
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."salary_components"`);
}
}

View File

@@ -0,0 +1,146 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Payroll runs and payslips — Module 3.4, slice 2.
*
* A payslip stores COMPUTED AMOUNTS, never formulas. Once a run is approved the
* numbers are what was paid, reported to tax and contributed to pension; a
* payslip that recalculates on read would silently restate the past the first
* time a rate, a structure or a salary changed.
*
* `payslip_lines` is deliberately fine-grained — one row per component per
* employee — because the tax and pension reports read it, and reconstructing
* "how much transport allowance did we pay in Hamle" from a total is not
* possible after the fact.
*
* The run's state machine is DRAFT → CALCULATED → APPROVED → PAID. Calculation
* is repeatable while DRAFT or CALCULATED and refused afterwards.
*/
export class HrPayrollRuns3600000000010 implements MigrationInterface {
name = "HrPayrollRuns3600000000010";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "hr"."payroll_runs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"organization_id" uuid NOT NULL,
"period_start" date NOT NULL,
"period_end" date NOT NULL,
"payment_date" date,
"status" varchar(16) NOT NULL DEFAULT 'DRAFT',
"note" varchar(512),
"employee_count" integer NOT NULL DEFAULT 0,
"total_gross" numeric(16,2) NOT NULL DEFAULT 0,
"total_deductions" numeric(16,2) NOT NULL DEFAULT 0,
"total_net" numeric(16,2) NOT NULL DEFAULT 0,
"total_income_tax" numeric(16,2) NOT NULL DEFAULT 0,
"total_pension_employee" numeric(16,2) NOT NULL DEFAULT 0,
"total_pension_employer" numeric(16,2) NOT NULL DEFAULT 0,
"calculated_at" timestamptz,
"approved_by_employee_id" uuid,
"approved_at" timestamptz,
"created_by" uuid,
"updated_by" uuid,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
"deleted_at" timestamptz,
CONSTRAINT "ck_payroll_runs_status"
CHECK ("status" IN ('DRAFT','CALCULATED','APPROVED','PAID','CANCELLED')),
CONSTRAINT "ck_payroll_runs_period"
CHECK ("period_end" >= "period_start"),
CONSTRAINT "ck_payroll_runs_approval" CHECK (
"status" NOT IN ('APPROVED','PAID')
OR ("approved_by_employee_id" IS NOT NULL AND "approved_at" IS NOT NULL)
)
)
`);
// One run per organization per period. A second would double-pay everyone.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_payroll_runs_org_period"
ON "hr"."payroll_runs" ("organization_id", "period_start", "period_end")
WHERE "deleted_at" IS NULL AND "status" <> 'CANCELLED'
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "hr"."payslips" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"payroll_run_id" uuid NOT NULL,
"organization_id" uuid NOT NULL,
"employee_id" uuid NOT NULL,
"employee_number" varchar(32),
"basic_salary" numeric(14,2) NOT NULL DEFAULT 0,
"gross_pay" numeric(14,2) NOT NULL DEFAULT 0,
"taxable_income" numeric(14,2) NOT NULL DEFAULT 0,
"pensionable_income" numeric(14,2) NOT NULL DEFAULT 0,
"income_tax" numeric(14,2) NOT NULL DEFAULT 0,
"pension_employee" numeric(14,2) NOT NULL DEFAULT 0,
"pension_employer" numeric(14,2) NOT NULL DEFAULT 0,
"total_deductions" numeric(14,2) NOT NULL DEFAULT 0,
"net_pay" numeric(14,2) NOT NULL DEFAULT 0,
"worked_days" numeric(6,2),
"absent_days" numeric(6,2),
"overtime_hours" numeric(8,2) NOT NULL DEFAULT 0,
"salary_mode" varchar(16),
"bank_account" varchar(64),
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
CONSTRAINT "fk_payslips_run"
FOREIGN KEY ("payroll_run_id")
REFERENCES "hr"."payroll_runs" ("id") ON DELETE CASCADE,
CONSTRAINT "ck_payslips_amounts" CHECK (
"gross_pay" >= 0 AND "total_deductions" >= 0
AND "income_tax" >= 0 AND "pension_employee" >= 0
AND "pension_employer" >= 0
)
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_payslips_run_employee"
ON "hr"."payslips" ("payroll_run_id", "employee_id")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_payslips_employee"
ON "hr"."payslips" ("employee_id")
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "hr"."payslip_lines" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"payslip_id" uuid NOT NULL,
"salary_component_id" uuid,
"code" varchar(32) NOT NULL,
"name" jsonb NOT NULL,
"component_type" varchar(24) NOT NULL,
"amount" numeric(14,2) NOT NULL,
"taxable_amount" numeric(14,2) NOT NULL DEFAULT 0,
"is_taxable" boolean NOT NULL DEFAULT true,
"is_pensionable" boolean NOT NULL DEFAULT false,
"affects_net_pay" boolean NOT NULL DEFAULT true,
"basis" varchar(256),
"sort_order" smallint NOT NULL DEFAULT 100,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "fk_payslip_lines_payslip"
FOREIGN KEY ("payslip_id")
REFERENCES "hr"."payslips" ("id") ON DELETE CASCADE,
CONSTRAINT "ck_payslip_lines_type"
CHECK ("component_type" IN
('BASIC','ALLOWANCE','DEDUCTION','EMPLOYER_CONTRIBUTION'))
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_payslip_lines_payslip"
ON "hr"."payslip_lines" ("payslip_id")
`);
// The tax and pension reports read by code across a period.
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_payslip_lines_code"
ON "hr"."payslip_lines" ("code")
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."payslip_lines"`);
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."payslips"`);
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."payroll_runs"`);
}
}

View File

@@ -0,0 +1,264 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Recruitment — Module 3.5.
*
* - `job_openings` a vacancy: which IAM position it fills, how many, and
* where it is in its own approval lifecycle.
* - `applicants` a person outside the organization. Deliberately NOT an
* `iam.users` row: most applicants are never hired, and
* creating accounts for them would fill IAM with people
* who have no relationship to the employer. An IAM
* account is created only at hire, through the same
* endpoint HR uses to hire anyone else.
* - `applications` one applicant against one opening, with the stage they
* have reached.
* - `interviews` scheduled sessions and their scores.
* - `job_offers` what was offered, and what happened to it.
*
* `applications.stage` is a single column rather than a table of transitions:
* the history that matters is captured by the interviews and the offer, and a
* separate audit of stage moves would be a lot of rows nobody reads.
*/
export class HrRecruitment3600000000011 implements MigrationInterface {
name = "HrRecruitment3600000000011";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "hr"."job_openings" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"organization_id" uuid NOT NULL,
"reference" varchar(32) NOT NULL,
"title" jsonb NOT NULL,
"description" jsonb,
"position_id" uuid,
"unit_id" uuid,
"job_title_id" uuid,
"employment_type" varchar(16) NOT NULL DEFAULT 'PERMANENT',
"openings" smallint NOT NULL DEFAULT 1,
"filled" smallint NOT NULL DEFAULT 0,
"status" varchar(16) NOT NULL DEFAULT 'DRAFT',
"visibility" varchar(16) NOT NULL DEFAULT 'INTERNAL',
"min_experience_years" smallint,
"education_requirement" varchar(128),
"salary_range_min" numeric(14,2),
"salary_range_max" numeric(14,2),
"posted_on" date,
"closes_on" date,
"hiring_manager_employee_id" uuid,
"created_by" uuid,
"updated_by" uuid,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
"deleted_at" timestamptz,
CONSTRAINT "fk_job_openings_job_title"
FOREIGN KEY ("job_title_id") REFERENCES "hr"."job_titles" ("id"),
CONSTRAINT "ck_job_openings_status"
CHECK ("status" IN ('DRAFT','OPEN','ON_HOLD','CLOSED','CANCELLED','FILLED')),
CONSTRAINT "ck_job_openings_visibility"
CHECK ("visibility" IN ('INTERNAL','EXTERNAL','BOTH')),
CONSTRAINT "ck_job_openings_counts"
CHECK ("openings" > 0 AND "filled" >= 0 AND "filled" <= "openings"),
CONSTRAINT "ck_job_openings_salary_range"
CHECK ("salary_range_min" IS NULL OR "salary_range_max" IS NULL
OR "salary_range_max" >= "salary_range_min"),
CONSTRAINT "ck_job_openings_dates"
CHECK ("closes_on" IS NULL OR "posted_on" IS NULL
OR "closes_on" >= "posted_on")
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_job_openings_org_reference"
ON "hr"."job_openings" ("organization_id", "reference")
WHERE "deleted_at" IS NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_job_openings_status"
ON "hr"."job_openings" ("organization_id", "status")
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "hr"."applicants" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"organization_id" uuid NOT NULL,
"full_name" jsonb NOT NULL,
"email" varchar(128),
"phone_number" varchar(32) NOT NULL,
"gender" varchar(8),
"date_of_birth" date,
"nationality" varchar(64),
"education_level" varchar(64),
"years_experience" numeric(5,2),
"current_employer" varchar(128),
"source" varchar(24) NOT NULL DEFAULT 'DIRECT',
"notes" varchar(1024),
"created_by" uuid,
"updated_by" uuid,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
"deleted_at" timestamptz,
CONSTRAINT "ck_applicants_gender"
CHECK ("gender" IS NULL OR "gender" IN ('MALE','FEMALE')),
CONSTRAINT "ck_applicants_source"
CHECK ("source" IN
('DIRECT','REFERRAL','AGENCY','WEBSITE','NEWSPAPER','INTERNAL','OTHER')),
CONSTRAINT "ck_applicants_experience"
CHECK ("years_experience" IS NULL OR "years_experience" >= 0)
)
`);
// Phone is the reliable identifier here — many applicants have no email.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_applicants_org_phone"
ON "hr"."applicants" ("organization_id", "phone_number")
WHERE "deleted_at" IS NULL
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "hr"."applications" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"organization_id" uuid NOT NULL,
"job_opening_id" uuid NOT NULL,
"applicant_id" uuid NOT NULL,
"stage" varchar(24) NOT NULL DEFAULT 'APPLIED',
"applied_on" date NOT NULL,
"screening_score" numeric(5,2),
"rejection_reason" varchar(256),
"withdrawn_reason" varchar(256),
"cv_document_id" uuid,
"notes" varchar(1024),
"created_by" uuid,
"updated_by" uuid,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
"deleted_at" timestamptz,
CONSTRAINT "fk_applications_opening"
FOREIGN KEY ("job_opening_id") REFERENCES "hr"."job_openings" ("id"),
CONSTRAINT "fk_applications_applicant"
FOREIGN KEY ("applicant_id") REFERENCES "hr"."applicants" ("id"),
CONSTRAINT "ck_applications_stage"
CHECK ("stage" IN
('APPLIED','SCREENING','SHORTLISTED','INTERVIEW','OFFER',
'HIRED','REJECTED','WITHDRAWN')),
CONSTRAINT "ck_applications_score"
CHECK ("screening_score" IS NULL
OR ("screening_score" >= 0 AND "screening_score" <= 100))
)
`);
// One application per person per opening. Re-applying is the same
// application moving stage, not a second row competing with the first.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_applications_opening_applicant"
ON "hr"."applications" ("job_opening_id", "applicant_id")
WHERE "deleted_at" IS NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_applications_stage"
ON "hr"."applications" ("job_opening_id", "stage")
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "hr"."interviews" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"organization_id" uuid NOT NULL,
"application_id" uuid NOT NULL,
"round" smallint NOT NULL DEFAULT 1,
"interview_type" varchar(24) NOT NULL DEFAULT 'PANEL',
"scheduled_at" timestamptz NOT NULL,
"duration_minutes" smallint NOT NULL DEFAULT 60,
"location" varchar(256),
"interviewer_employee_ids" uuid[],
"status" varchar(16) NOT NULL DEFAULT 'SCHEDULED',
"score" numeric(5,2),
"recommendation" varchar(16),
"feedback" varchar(2048),
"created_by" uuid,
"updated_by" uuid,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
"deleted_at" timestamptz,
CONSTRAINT "fk_interviews_application"
FOREIGN KEY ("application_id")
REFERENCES "hr"."applications" ("id") ON DELETE CASCADE,
CONSTRAINT "ck_interviews_type"
CHECK ("interview_type" IN
('PHONE','PANEL','TECHNICAL','WRITTEN','PRACTICAL','FINAL')),
CONSTRAINT "ck_interviews_status"
CHECK ("status" IN ('SCHEDULED','COMPLETED','CANCELLED','NO_SHOW')),
CONSTRAINT "ck_interviews_score"
CHECK ("score" IS NULL OR ("score" >= 0 AND "score" <= 100)),
CONSTRAINT "ck_interviews_recommendation"
CHECK ("recommendation" IS NULL
OR "recommendation" IN ('ADVANCE','HOLD','REJECT')),
CONSTRAINT "ck_interviews_round" CHECK ("round" > 0),
-- A completed interview must say how it went, or the record is useless
-- to the person deciding on the offer.
CONSTRAINT "ck_interviews_completed"
CHECK ("status" <> 'COMPLETED' OR "recommendation" IS NOT NULL)
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_interviews_application_round"
ON "hr"."interviews" ("application_id", "round")
WHERE "deleted_at" IS NULL
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "hr"."job_offers" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"organization_id" uuid NOT NULL,
"application_id" uuid NOT NULL,
"offered_basic_salary" numeric(14,2) NOT NULL,
"salary_structure_id" uuid,
"employment_type" varchar(16) NOT NULL DEFAULT 'PERMANENT',
"proposed_start_date" date NOT NULL,
"probation_end_date" date,
"expires_on" date,
"status" varchar(16) NOT NULL DEFAULT 'DRAFT',
"decline_reason" varchar(256),
"responded_on" date,
"hired_employee_id" uuid,
"notes" varchar(1024),
"created_by" uuid,
"updated_by" uuid,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
"deleted_at" timestamptz,
CONSTRAINT "fk_job_offers_application"
FOREIGN KEY ("application_id") REFERENCES "hr"."applications" ("id"),
CONSTRAINT "fk_job_offers_structure"
FOREIGN KEY ("salary_structure_id")
REFERENCES "hr"."salary_structures" ("id"),
CONSTRAINT "ck_job_offers_status"
CHECK ("status" IN
('DRAFT','SENT','ACCEPTED','DECLINED','WITHDRAWN','EXPIRED')),
CONSTRAINT "ck_job_offers_salary" CHECK ("offered_basic_salary" > 0),
CONSTRAINT "ck_job_offers_probation"
CHECK ("probation_end_date" IS NULL
OR "probation_end_date" >= "proposed_start_date"),
-- An accepted offer must record who it turned into, or the link between
-- recruitment and the employee record is lost.
CONSTRAINT "ck_job_offers_declined"
CHECK ("status" <> 'DECLINED' OR "decline_reason" IS NOT NULL)
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_job_offers_application"
ON "hr"."job_offers" ("application_id")
`);
// One live offer per application. Two outstanding offers to one candidate
// is how somebody ends up hired on the wrong terms.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_job_offers_live"
ON "hr"."job_offers" ("application_id")
WHERE "status" IN ('DRAFT','SENT') AND "deleted_at" IS NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."job_offers"`);
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."interviews"`);
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."applications"`);
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."applicants"`);
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."job_openings"`);
}
}

View File

@@ -0,0 +1,227 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Performance appraisal — Module 3.6.
*
* - `appraisal_templates` the form: how it is scored and what the bands mean.
* - `appraisal_criteria` what is assessed, each with a weight. Weights must
* total 100 — enforced in the service, because a form
* whose weights sum to 87 produces scores that cannot
* be compared with anything.
* - `appraisal_cycles` a period everyone is appraised for. The template is
* COPIED into each appraisal when it opens, so editing
* a template later cannot restate a completed review.
* - `appraisals` one per employee per cycle, moving through self
* assessment, manager assessment, acknowledgement.
* - `appraisal_ratings` a score per criterion, self and manager side by side.
* - `appraisal_goals` objectives agreed at the start and rated at the end.
*
* Scores are `numeric(6,2)`. Percentages are stored as 0100, weights the same,
* so a reader never has to guess whether 0.85 means 85% or 0.85%.
*/
export class HrAppraisal3600000000012 implements MigrationInterface {
name = "HrAppraisal3600000000012";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "hr"."appraisal_templates" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"organization_id" uuid NOT NULL,
"code" varchar(32) NOT NULL,
"name" jsonb NOT NULL,
"description" jsonb,
"max_score" numeric(6,2) NOT NULL DEFAULT 5,
"rating_bands" jsonb NOT NULL DEFAULT '[]'::jsonb,
"requires_self_assessment" boolean NOT NULL DEFAULT true,
"requires_acknowledgement" boolean NOT NULL DEFAULT true,
"is_active" boolean NOT NULL DEFAULT true,
"created_by" uuid,
"updated_by" uuid,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
"deleted_at" timestamptz,
CONSTRAINT "ck_appraisal_templates_max_score" CHECK ("max_score" > 0)
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_appraisal_templates_org_code"
ON "hr"."appraisal_templates" ("organization_id", "code")
WHERE "deleted_at" IS NULL
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "hr"."appraisal_criteria" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"template_id" uuid NOT NULL,
"code" varchar(32) NOT NULL,
"name" jsonb NOT NULL,
"description" jsonb,
"category" varchar(32),
"weight" numeric(6,2) NOT NULL,
"sort_order" smallint NOT NULL DEFAULT 100,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
CONSTRAINT "fk_appraisal_criteria_template"
FOREIGN KEY ("template_id")
REFERENCES "hr"."appraisal_templates" ("id") ON DELETE CASCADE,
CONSTRAINT "ck_appraisal_criteria_weight"
CHECK ("weight" > 0 AND "weight" <= 100)
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_appraisal_criteria_template_code"
ON "hr"."appraisal_criteria" ("template_id", "code")
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "hr"."appraisal_cycles" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"organization_id" uuid NOT NULL,
"code" varchar(32) NOT NULL,
"name" jsonb NOT NULL,
"template_id" uuid NOT NULL,
"period_start" date NOT NULL,
"period_end" date NOT NULL,
"self_due_on" date,
"manager_due_on" date,
"status" varchar(16) NOT NULL DEFAULT 'DRAFT',
"created_by" uuid,
"updated_by" uuid,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
"deleted_at" timestamptz,
CONSTRAINT "fk_appraisal_cycles_template"
FOREIGN KEY ("template_id") REFERENCES "hr"."appraisal_templates" ("id"),
CONSTRAINT "ck_appraisal_cycles_status"
CHECK ("status" IN ('DRAFT','OPEN','CLOSED','CANCELLED')),
CONSTRAINT "ck_appraisal_cycles_period"
CHECK ("period_end" > "period_start")
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_appraisal_cycles_org_code"
ON "hr"."appraisal_cycles" ("organization_id", "code")
WHERE "deleted_at" IS NULL
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "hr"."appraisals" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"organization_id" uuid NOT NULL,
"cycle_id" uuid NOT NULL,
"employee_id" uuid NOT NULL,
"appraiser_employee_id" uuid,
"status" varchar(24) NOT NULL DEFAULT 'PENDING_SELF',
"self_score" numeric(6,2),
"manager_score" numeric(6,2),
"final_score" numeric(6,2),
"final_rating" varchar(32),
"self_comment" varchar(2048),
"manager_comment" varchar(2048),
"employee_comment" varchar(2048),
"self_submitted_at" timestamptz,
"manager_submitted_at" timestamptz,
"acknowledged_at" timestamptz,
"created_by" uuid,
"updated_by" uuid,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
"deleted_at" timestamptz,
CONSTRAINT "fk_appraisals_cycle"
FOREIGN KEY ("cycle_id")
REFERENCES "hr"."appraisal_cycles" ("id") ON DELETE CASCADE,
CONSTRAINT "ck_appraisals_status"
CHECK ("status" IN
('PENDING_SELF','PENDING_MANAGER','PENDING_ACKNOWLEDGEMENT',
'COMPLETED','CANCELLED')),
CONSTRAINT "ck_appraisals_scores" CHECK (
("self_score" IS NULL OR ("self_score" >= 0 AND "self_score" <= 100))
AND ("manager_score" IS NULL OR ("manager_score" >= 0 AND "manager_score" <= 100))
AND ("final_score" IS NULL OR ("final_score" >= 0 AND "final_score" <= 100))
),
-- A completed appraisal must carry the score it concluded with.
CONSTRAINT "ck_appraisals_completed"
CHECK ("status" <> 'COMPLETED' OR "final_score" IS NOT NULL)
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_appraisals_cycle_employee"
ON "hr"."appraisals" ("cycle_id", "employee_id")
WHERE "deleted_at" IS NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_appraisals_appraiser"
ON "hr"."appraisals" ("appraiser_employee_id", "status")
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_appraisals_employee"
ON "hr"."appraisals" ("employee_id")
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "hr"."appraisal_ratings" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"appraisal_id" uuid NOT NULL,
"criterion_id" uuid,
"code" varchar(32) NOT NULL,
"name" jsonb NOT NULL,
"weight" numeric(6,2) NOT NULL,
"max_score" numeric(6,2) NOT NULL,
"self_score" numeric(6,2),
"manager_score" numeric(6,2),
"self_comment" varchar(1024),
"manager_comment" varchar(1024),
"sort_order" smallint NOT NULL DEFAULT 100,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
CONSTRAINT "fk_appraisal_ratings_appraisal"
FOREIGN KEY ("appraisal_id")
REFERENCES "hr"."appraisals" ("id") ON DELETE CASCADE,
CONSTRAINT "ck_appraisal_ratings_scores" CHECK (
("self_score" IS NULL OR ("self_score" >= 0 AND "self_score" <= "max_score"))
AND ("manager_score" IS NULL
OR ("manager_score" >= 0 AND "manager_score" <= "max_score"))
)
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_appraisal_ratings_appraisal_code"
ON "hr"."appraisal_ratings" ("appraisal_id", "code")
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "hr"."appraisal_goals" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"appraisal_id" uuid NOT NULL,
"title" varchar(256) NOT NULL,
"description" varchar(2048),
"target" varchar(256),
"weight" numeric(6,2),
"achievement_percent" numeric(6,2),
"manager_comment" varchar(1024),
"sort_order" smallint NOT NULL DEFAULT 100,
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamptz,
CONSTRAINT "fk_appraisal_goals_appraisal"
FOREIGN KEY ("appraisal_id")
REFERENCES "hr"."appraisals" ("id") ON DELETE CASCADE,
CONSTRAINT "ck_appraisal_goals_achievement"
CHECK ("achievement_percent" IS NULL
OR ("achievement_percent" >= 0 AND "achievement_percent" <= 200))
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_appraisal_goals_appraisal"
ON "hr"."appraisal_goals" ("appraisal_id")
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."appraisal_goals"`);
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."appraisal_ratings"`);
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."appraisals"`);
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."appraisal_cycles"`);
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."appraisal_criteria"`);
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."appraisal_templates"`);
}
}

View File

@@ -0,0 +1,40 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import {
Appraisal,
AppraisalCriterion,
AppraisalCycle,
AppraisalGoal,
AppraisalRating,
AppraisalTemplate,
} from "./entities/appraisal.entity";
import { AppraisalService } from "./services/appraisal.service";
import { AppraisalController } from "./controllers/appraisal.controller";
import { EmployeesModule } from "../employees/employees.module";
/**
* Module 3.6 Performance appraisal.
*
* Reuses the line-manager resolution built for leave: the person who approves
* your leave is the person who appraises you, and having two answers to "who is
* their manager" is how an organization ends up with reviews signed by the wrong
* person.
*/
@Module({
imports: [
TypeOrmModule.forFeature([
AppraisalTemplate,
AppraisalCriterion,
AppraisalCycle,
Appraisal,
AppraisalRating,
AppraisalGoal,
]),
EmployeesModule,
],
controllers: [AppraisalController],
providers: [AppraisalService],
exports: [AppraisalService],
})
export class AppraisalModule {}

View File

@@ -0,0 +1,238 @@
import {
Body,
Controller,
Get,
Param,
ParseIntPipe,
ParseUUIDPipe,
Patch,
Post,
Query,
} from "@nestjs/common";
import {
ApiBearerAuth,
ApiOperation,
ApiQuery,
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 } from "../../../seed/hr-permissions.registry";
import { AppraisalService } from "../services/appraisal.service";
import { EAppraisalStatus } from "../entities/appraisal.entity";
import {
AcknowledgeDto,
CreateCycleDto,
CreateGoalDto,
CreateTemplateDto,
OpenCycleDto,
SubmitManagerDto,
SubmitSelfDto,
} from "../dto/appraisal.dto";
@ApiTags("appraisal")
@ApiBearerAuth()
@Controller("appraisal")
@HrStaff([
HR_PERMS.appraisal.manageCycle,
HR_PERMS.appraisal.manageTemplate,
HR_PERMS.appraisal.submitSelf,
HR_PERMS.appraisal.submitManager,
HR_PERMS.appraisal.viewAll,
])
export class AppraisalController {
constructor(private readonly appraisal: AppraisalService) {}
// ── Templates ─────────────────────────────────────────────────────────────
@Get("templates")
@HrStaff(HR_PERMS.appraisal.manageTemplate)
@ApiOperation({ summary: "Appraisal forms" })
templates(@CurrentUser() user: TCurrentUser) {
return this.appraisal.listTemplates(actorFrom(user));
}
@Post("templates")
@HrStaff(HR_PERMS.appraisal.manageTemplate)
@ApiOperation({
summary: "Create an appraisal form",
description:
"Criterion weights must total 100 — otherwise a score cannot be read as a " +
"percentage or compared between employees.",
})
@ApiResponse({ status: 400, description: "Weights do not total 100" })
createTemplate(
@Body() dto: CreateTemplateDto,
@CurrentUser() user: TCurrentUser,
) {
return this.appraisal.createTemplate(dto, actorFrom(user));
}
@Get("templates/:id")
@HrStaff(HR_PERMS.appraisal.manageTemplate)
@ApiOperation({ summary: "One form, with its criteria" })
template(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
return this.appraisal.getTemplate(id, actorFrom(user));
}
// ── Cycles ────────────────────────────────────────────────────────────────
@Get("cycles")
@HrStaff(HR_PERMS.appraisal.submitSelf)
@ApiOperation({ summary: "Appraisal cycles" })
cycles(@CurrentUser() user: TCurrentUser) {
return this.appraisal.listCycles(actorFrom(user));
}
@Post("cycles")
@HrStaff(HR_PERMS.appraisal.manageCycle)
@ApiOperation({ summary: "Create a cycle" })
createCycle(@Body() dto: CreateCycleDto, @CurrentUser() user: TCurrentUser) {
return this.appraisal.createCycle(dto, actorFrom(user));
}
@Post("cycles/:id/open")
@HrStaff(HR_PERMS.appraisal.manageCycle)
@ApiOperation({
summary: "Open a cycle and create everyone's appraisal",
description:
"The form's criteria are copied onto each appraisal, so editing the " +
"template later cannot restate a review that has been signed. Safe to " +
"re-run: existing appraisals are left alone.",
})
@ApiResponse({ status: 201, description: "{ cycle, created, skipped }" })
openCycle(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: OpenCycleDto,
@CurrentUser() user: TCurrentUser,
) {
return this.appraisal.openCycle(id, dto.employeeIds, actorFrom(user));
}
@Patch("cycles/:id/close")
@HrStaff(HR_PERMS.appraisal.manageCycle)
@ApiOperation({ summary: "Close a cycle" })
closeCycle(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
return this.appraisal.closeCycle(id, actorFrom(user));
}
@Get("cycles/:id/progress")
@HrStaff(HR_PERMS.appraisal.viewAll)
@ApiOperation({ summary: "How far a cycle has got — counts by status" })
progress(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
return this.appraisal.cycleProgress(id, actorFrom(user));
}
// ── Appraisals ────────────────────────────────────────────────────────────
@Get("mine")
@HrStaff(HR_PERMS.appraisal.submitSelf)
@ApiOperation({ summary: "My appraisals" })
mine(@CurrentUser() user: TCurrentUser) {
return this.appraisal.findMine(actorFrom(user));
}
@Get("awaiting-me")
@HrStaff(HR_PERMS.appraisal.submitManager)
@ApiOperation({ summary: "Appraisals waiting on my assessment" })
awaitingMe(@CurrentUser() user: TCurrentUser) {
return this.appraisal.findAwaitingMe(actorFrom(user));
}
@Get()
@HrStaff(HR_PERMS.appraisal.viewAll)
@ApiOperation({ summary: "All appraisals" })
@ApiQuery({ name: "cycleId", required: false })
@ApiQuery({ name: "status", required: false, enum: EAppraisalStatus })
findAll(
@CurrentUser() user: TCurrentUser,
@Query("cycleId") cycleId?: string,
@Query("status") status?: EAppraisalStatus,
@Query("page", new ParseIntPipe({ optional: true })) page?: number,
@Query("limit", new ParseIntPipe({ optional: true })) limit?: number,
) {
return this.appraisal.findAll({ cycleId, status, page, limit }, actorFrom(user));
}
@Get(":id")
@HrStaff(HR_PERMS.appraisal.submitSelf)
@ApiOperation({ summary: "One appraisal, with its ratings and goals" })
findOne(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
return this.appraisal.findOne(id, actorFrom(user));
}
@Post(":id/self")
@HrStaff(HR_PERMS.appraisal.submitSelf)
@ApiOperation({
summary: "Submit my own assessment",
description:
"Recorded for comparison; it does not decide the outcome. Every criterion " +
"must be scored.",
})
submitSelf(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: SubmitSelfDto,
@CurrentUser() user: TCurrentUser,
) {
return this.appraisal.submitSelf(id, dto, actorFrom(user));
}
@Post(":id/manager")
@HrStaff(HR_PERMS.appraisal.submitManager)
@ApiOperation({
summary: "Submit the manager's assessment",
description:
"This decides the final score — it is NOT averaged with the self " +
"assessment, or anyone could raise their result by rating themselves " +
"highly. Nobody may appraise themselves.",
})
@ApiResponse({ status: 403, description: "You cannot appraise yourself" })
submitManager(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: SubmitManagerDto,
@CurrentUser() user: TCurrentUser,
) {
return this.appraisal.submitManager(id, dto, actorFrom(user));
}
@Patch(":id/acknowledge")
@HrStaff(HR_PERMS.appraisal.submitSelf)
@ApiOperation({
summary: "Acknowledge the result",
description: "The employee's right of reply. It cannot change the score.",
})
acknowledge(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: AcknowledgeDto,
@CurrentUser() user: TCurrentUser,
) {
return this.appraisal.acknowledge(id, dto.comment, actorFrom(user));
}
@Post(":id/goals")
@HrStaff(HR_PERMS.appraisal.submitManager)
@ApiOperation({ summary: "Add an objective to an appraisal" })
addGoal(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: CreateGoalDto,
@CurrentUser() user: TCurrentUser,
) {
return this.appraisal.addGoal(id, dto, actorFrom(user));
}
}

View File

@@ -0,0 +1,282 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsArray,
IsBoolean,
IsDateString,
IsNotEmpty,
IsNumber,
IsNumberString,
IsObject,
IsOptional,
IsString,
IsUUID,
Max,
MaxLength,
Min,
ValidateNested,
} from "class-validator";
import { LocalizedTextDto } from "../../leave/dto/leave-type.dto";
export class RatingBandDto {
@ApiProperty({ minimum: 0, maximum: 100, example: 90 })
@IsNumber()
@Min(0)
@Max(100)
min!: number;
@ApiProperty({ type: LocalizedTextDto })
@IsObject()
@ValidateNested()
@Type(() => LocalizedTextDto)
label!: LocalizedTextDto;
}
export class CriterionDto {
@ApiProperty({ maxLength: 32, example: "QUALITY" })
@IsString()
@IsNotEmpty()
@MaxLength(32)
code!: string;
@ApiProperty({ type: LocalizedTextDto })
@IsObject()
@ValidateNested()
@Type(() => LocalizedTextDto)
name!: LocalizedTextDto;
@ApiPropertyOptional({ type: LocalizedTextDto })
@IsOptional()
@IsObject()
@ValidateNested()
@Type(() => LocalizedTextDto)
description?: LocalizedTextDto;
@ApiPropertyOptional({ maxLength: 32, example: "RESULTS" })
@IsOptional()
@IsString()
@MaxLength(32)
category?: string;
@ApiProperty({
example: "25.00",
description: "Percentage points. All criteria in a template must total 100.",
})
@IsNumberString()
weight!: string;
}
export class CreateTemplateDto {
@ApiProperty({ maxLength: 32, example: "ANNUAL-2026" })
@IsString()
@IsNotEmpty()
@MaxLength(32)
code!: string;
@ApiProperty({ type: LocalizedTextDto })
@IsObject()
@ValidateNested()
@Type(() => LocalizedTextDto)
name!: LocalizedTextDto;
@ApiPropertyOptional({ type: LocalizedTextDto })
@IsOptional()
@IsObject()
@ValidateNested()
@Type(() => LocalizedTextDto)
description?: LocalizedTextDto;
@ApiPropertyOptional({ example: "5.00", description: "Top of the per-criterion scale." })
@IsOptional()
@IsNumberString()
maxScore?: string;
@ApiPropertyOptional({ type: [RatingBandDto] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => RatingBandDto)
ratingBands?: RatingBandDto[];
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
requiresSelfAssessment?: boolean;
@ApiPropertyOptional({
default: true,
description: "Whether the employee must acknowledge the result before it closes.",
})
@IsOptional()
@IsBoolean()
requiresAcknowledgement?: boolean;
@ApiProperty({ type: [CriterionDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => CriterionDto)
criteria!: CriterionDto[];
}
export class CreateCycleDto {
@ApiProperty({ maxLength: 32, example: "FY2026" })
@IsString()
@IsNotEmpty()
@MaxLength(32)
code!: string;
@ApiProperty({ type: LocalizedTextDto })
@IsObject()
@ValidateNested()
@Type(() => LocalizedTextDto)
name!: LocalizedTextDto;
@ApiProperty({ format: "uuid" })
@IsUUID()
templateId!: string;
@ApiProperty({ format: "date" })
@IsDateString()
periodStart!: string;
@ApiProperty({ format: "date" })
@IsDateString()
periodEnd!: string;
@ApiPropertyOptional({ format: "date" })
@IsOptional()
@IsDateString()
selfDueOn?: string;
@ApiPropertyOptional({ format: "date" })
@IsOptional()
@IsDateString()
managerDueOn?: string;
}
export class OpenCycleDto {
@ApiPropertyOptional({
type: [String],
format: "uuid",
description:
"Who to appraise. Omit for everyone employed in the organization with an " +
"HR profile — terminated and retired staff are excluded either way.",
})
@IsOptional()
@IsArray()
@IsUUID(undefined, { each: true })
employeeIds?: string[];
}
export class RatingInputDto {
@ApiProperty({ maxLength: 32 })
@IsString()
@IsNotEmpty()
code!: string;
@ApiProperty({ example: 4, description: "0 to the template's maxScore." })
@IsNumber()
@Min(0)
score!: number;
@ApiPropertyOptional({ maxLength: 1024 })
@IsOptional()
@IsString()
@MaxLength(1024)
comment?: string;
}
export class SubmitSelfDto {
@ApiProperty({ type: [RatingInputDto], description: "Every criterion is required." })
@IsArray()
@ValidateNested({ each: true })
@Type(() => RatingInputDto)
ratings!: RatingInputDto[];
@ApiPropertyOptional({ maxLength: 2048 })
@IsOptional()
@IsString()
@MaxLength(2048)
comment?: string;
}
export class GoalOutcomeDto {
@ApiProperty({ format: "uuid" })
@IsUUID()
id!: string;
@ApiPropertyOptional({
minimum: 0,
maximum: 200,
description: "Over 100 is allowed — exceeding a target is a real outcome.",
})
@IsOptional()
@IsNumber()
@Min(0)
@Max(200)
achievementPercent?: number;
@ApiPropertyOptional({ maxLength: 1024 })
@IsOptional()
@IsString()
@MaxLength(1024)
comment?: string;
}
export class SubmitManagerDto {
@ApiProperty({ type: [RatingInputDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => RatingInputDto)
ratings!: RatingInputDto[];
@ApiPropertyOptional({ type: [GoalOutcomeDto] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => GoalOutcomeDto)
goals?: GoalOutcomeDto[];
@ApiPropertyOptional({ maxLength: 2048 })
@IsOptional()
@IsString()
@MaxLength(2048)
comment?: string;
}
export class AcknowledgeDto {
@ApiPropertyOptional({
maxLength: 2048,
description: "The employee's right of reply. It cannot change the score.",
})
@IsOptional()
@IsString()
@MaxLength(2048)
comment?: string;
}
export class CreateGoalDto {
@ApiProperty({ maxLength: 256 })
@IsString()
@IsNotEmpty()
@MaxLength(256)
title!: string;
@ApiPropertyOptional({ maxLength: 2048 })
@IsOptional()
@IsString()
@MaxLength(2048)
description?: string;
@ApiPropertyOptional({ maxLength: 256, example: "Reduce turnaround to 3 days" })
@IsOptional()
@IsString()
@MaxLength(256)
target?: string;
@ApiPropertyOptional({ example: "20.00" })
@IsOptional()
@IsNumberString()
weight?: string;
}

View File

@@ -0,0 +1,361 @@
import { SoftDeleteAudit } from "@tria-plc/api-common/modules/typeorm/audit.entity";
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
} from "typeorm";
export enum ECycleStatus {
DRAFT = "DRAFT",
OPEN = "OPEN",
CLOSED = "CLOSED",
CANCELLED = "CANCELLED",
}
export enum EAppraisalStatus {
/** Waiting on the employee's own assessment. */
PENDING_SELF = "PENDING_SELF",
PENDING_MANAGER = "PENDING_MANAGER",
/** Scored and shared; waiting for the employee to acknowledge it. */
PENDING_ACKNOWLEDGEMENT = "PENDING_ACKNOWLEDGEMENT",
COMPLETED = "COMPLETED",
CANCELLED = "CANCELLED",
}
/** `[{ min: 90, label: { am, en } }, …]`, highest first. */
export interface RatingBand {
min: number;
label: { am: string; en: string };
}
@Entity({ schema: "hr", name: "appraisal_templates" })
@Index("idx_appraisal_templates_org", ["organizationId"])
export class AppraisalTemplate extends SoftDeleteAudit {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid", name: "organization_id" })
organizationId!: string;
@Column({ type: "varchar", length: 32, name: "code" })
code!: string;
@Column({ type: "jsonb", name: "name" })
name!: { am: string; en: string };
@Column({ type: "jsonb", name: "description", nullable: true })
description?: { am: string; en: string } | null;
/** The top of the per-criterion scale — 5 for a five-point scale. */
@Column({ type: "numeric", precision: 6, scale: 2, name: "max_score", default: 5 })
maxScore!: string;
/**
* What a percentage means in words. Data rather than constants because every
* employer words these differently, and the wording is what appears on the
* review the employee signs.
*/
@Column({ type: "jsonb", name: "rating_bands", default: () => `'[]'::jsonb` })
ratingBands!: RatingBand[];
@Column({ type: "boolean", name: "requires_self_assessment", default: true })
requiresSelfAssessment!: boolean;
@Column({ type: "boolean", name: "requires_acknowledgement", default: true })
requiresAcknowledgement!: boolean;
@Column({ type: "boolean", name: "is_active", default: true })
isActive!: boolean;
@OneToMany(() => AppraisalCriterion, (criterion) => criterion.template)
criteria?: AppraisalCriterion[];
@Column({ type: "uuid", name: "created_by", nullable: true })
createdBy?: string | null;
@Column({ type: "uuid", name: "updated_by", nullable: true })
updatedBy?: string | null;
}
@Entity({ schema: "hr", name: "appraisal_criteria" })
export class AppraisalCriterion {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid", name: "template_id" })
templateId!: string;
@ManyToOne(() => AppraisalTemplate, (template) => template.criteria)
@JoinColumn({ name: "template_id" })
template?: AppraisalTemplate;
@Column({ type: "varchar", length: 32, name: "code" })
code!: string;
@Column({ type: "jsonb", name: "name" })
name!: { am: string; en: string };
@Column({ type: "jsonb", name: "description", nullable: true })
description?: { am: string; en: string } | null;
/** e.g. `RESULTS`, `BEHAVIOUR` — for grouping on the form. */
@Column({ type: "varchar", length: 32, name: "category", nullable: true })
category?: string | null;
/** Percentage points. All criteria in a template must total 100. */
@Column({ type: "numeric", precision: 6, scale: 2, name: "weight" })
weight!: string;
@Column({ type: "smallint", name: "sort_order", default: 100 })
sortOrder!: number;
@CreateDateColumn({ type: "timestamptz", name: "created_at" })
createdAt!: Date;
}
@Entity({ schema: "hr", name: "appraisal_cycles" })
@Index("idx_appraisal_cycles_org", ["organizationId"])
export class AppraisalCycle extends SoftDeleteAudit {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid", name: "organization_id" })
organizationId!: string;
@Column({ type: "varchar", length: 32, name: "code" })
code!: string;
@Column({ type: "jsonb", name: "name" })
name!: { am: string; en: string };
@Column({ type: "uuid", name: "template_id" })
templateId!: string;
@ManyToOne(() => AppraisalTemplate)
@JoinColumn({ name: "template_id" })
template?: AppraisalTemplate;
@Column({ type: "date", name: "period_start" })
periodStart!: string;
@Column({ type: "date", name: "period_end" })
periodEnd!: string;
@Column({ type: "date", name: "self_due_on", nullable: true })
selfDueOn?: string | null;
@Column({ type: "date", name: "manager_due_on", nullable: true })
managerDueOn?: string | null;
@Column({
type: "varchar",
length: 16,
name: "status",
default: ECycleStatus.DRAFT,
})
status!: ECycleStatus;
@Column({ type: "uuid", name: "created_by", nullable: true })
createdBy?: string | null;
@Column({ type: "uuid", name: "updated_by", nullable: true })
updatedBy?: string | null;
}
/**
* One employee's review for one cycle.
*
* The criteria are copied onto the appraisal as ratings when it opens, rather
* than joined from the template. Editing a template a year later must not
* restate a review that has already been signed.
*/
@Entity({ schema: "hr", name: "appraisals" })
@Index("idx_appraisals_employee", ["employeeId"])
export class Appraisal extends SoftDeleteAudit {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid", name: "organization_id" })
organizationId!: string;
@Column({ type: "uuid", name: "cycle_id" })
cycleId!: string;
@ManyToOne(() => AppraisalCycle)
@JoinColumn({ name: "cycle_id" })
cycle?: AppraisalCycle;
@Column({ type: "uuid", name: "employee_id" })
employeeId!: string;
/** The line manager, resolved the same way leave approvals are. */
@Column({ type: "uuid", name: "appraiser_employee_id", nullable: true })
appraiserEmployeeId?: string | null;
@Column({
type: "varchar",
length: 24,
name: "status",
default: EAppraisalStatus.PENDING_SELF,
})
status!: EAppraisalStatus;
/** All three are percentages, 0100. */
@Column({ type: "numeric", precision: 6, scale: 2, name: "self_score", nullable: true })
selfScore?: string | null;
@Column({
type: "numeric",
precision: 6,
scale: 2,
name: "manager_score",
nullable: true,
})
managerScore?: string | null;
@Column({ type: "numeric", precision: 6, scale: 2, name: "final_score", nullable: true })
finalScore?: string | null;
@Column({ type: "varchar", length: 32, name: "final_rating", nullable: true })
finalRating?: string | null;
@Column({ type: "varchar", length: 2048, name: "self_comment", nullable: true })
selfComment?: string | null;
@Column({ type: "varchar", length: 2048, name: "manager_comment", nullable: true })
managerComment?: string | null;
/** The employee's right of reply, recorded at acknowledgement. */
@Column({ type: "varchar", length: 2048, name: "employee_comment", nullable: true })
employeeComment?: string | null;
@Column({ type: "timestamptz", name: "self_submitted_at", nullable: true })
selfSubmittedAt?: Date | null;
@Column({ type: "timestamptz", name: "manager_submitted_at", nullable: true })
managerSubmittedAt?: Date | null;
@Column({ type: "timestamptz", name: "acknowledged_at", nullable: true })
acknowledgedAt?: Date | null;
@OneToMany(() => AppraisalRating, (rating) => rating.appraisal)
ratings?: AppraisalRating[];
@OneToMany(() => AppraisalGoal, (goal) => goal.appraisal)
goals?: AppraisalGoal[];
@Column({ type: "uuid", name: "created_by", nullable: true })
createdBy?: string | null;
@Column({ type: "uuid", name: "updated_by", nullable: true })
updatedBy?: string | null;
}
/** One criterion on one appraisal, scored from both sides. */
@Entity({ schema: "hr", name: "appraisal_ratings" })
export class AppraisalRating {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid", name: "appraisal_id" })
appraisalId!: string;
@ManyToOne(() => Appraisal, (appraisal) => appraisal.ratings)
@JoinColumn({ name: "appraisal_id" })
appraisal?: Appraisal;
@Column({ type: "uuid", name: "criterion_id", nullable: true })
criterionId?: string | null;
@Column({ type: "varchar", length: 32, name: "code" })
code!: string;
@Column({ type: "jsonb", name: "name" })
name!: { am: string; en: string };
/** Copied from the criterion, so the form the review used is preserved. */
@Column({ type: "numeric", precision: 6, scale: 2, name: "weight" })
weight!: string;
@Column({ type: "numeric", precision: 6, scale: 2, name: "max_score" })
maxScore!: string;
@Column({ type: "numeric", precision: 6, scale: 2, name: "self_score", nullable: true })
selfScore?: string | null;
@Column({
type: "numeric",
precision: 6,
scale: 2,
name: "manager_score",
nullable: true,
})
managerScore?: string | null;
@Column({ type: "varchar", length: 1024, name: "self_comment", nullable: true })
selfComment?: string | null;
@Column({ type: "varchar", length: 1024, name: "manager_comment", nullable: true })
managerComment?: string | null;
@Column({ type: "smallint", name: "sort_order", default: 100 })
sortOrder!: number;
@CreateDateColumn({ type: "timestamptz", name: "created_at" })
createdAt!: Date;
}
/**
* An objective agreed at the start of the cycle and rated at the end.
*
* `achievementPercent` allows up to 200: exceeding a target is a real outcome,
* and capping it at 100 would erase the difference between meeting a goal and
* doubling it.
*/
@Entity({ schema: "hr", name: "appraisal_goals" })
export class AppraisalGoal {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid", name: "appraisal_id" })
appraisalId!: string;
@ManyToOne(() => Appraisal, (appraisal) => appraisal.goals)
@JoinColumn({ name: "appraisal_id" })
appraisal?: Appraisal;
@Column({ type: "varchar", length: 256, name: "title" })
title!: string;
@Column({ type: "varchar", length: 2048, name: "description", nullable: true })
description?: string | null;
@Column({ type: "varchar", length: 256, name: "target", nullable: true })
target?: string | null;
@Column({ type: "numeric", precision: 6, scale: 2, name: "weight", nullable: true })
weight?: string | null;
@Column({
type: "numeric",
precision: 6,
scale: 2,
name: "achievement_percent",
nullable: true,
})
achievementPercent?: string | null;
@Column({ type: "varchar", length: 1024, name: "manager_comment", nullable: true })
managerComment?: string | null;
@Column({ type: "smallint", name: "sort_order", default: 100 })
sortOrder!: number;
@CreateDateColumn({ type: "timestamptz", name: "created_at" })
createdAt!: Date;
}

View File

@@ -0,0 +1,635 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { InjectDataSource, InjectRepository } from "@nestjs/typeorm";
import { DataSource, Repository } from "typeorm";
import { ActorContext, orgScope } from "../../employees/employees.service";
import { EmployeesRepository } from "../../employees/employees.repository";
import { IamDirectoryService } from "../../../iam-directory/iam-directory.service";
import { Paginated, paginate } from "../../../common/pagination.dto";
import {
Appraisal,
AppraisalCriterion,
AppraisalCycle,
AppraisalGoal,
AppraisalRating,
AppraisalTemplate,
EAppraisalStatus,
ECycleStatus,
RatingBand,
} from "../entities/appraisal.entity";
import {
CreateCycleDto,
CreateTemplateDto,
SubmitManagerDto,
SubmitSelfDto,
} from "../dto/appraisal.dto";
const round2 = (value: number): number =>
Math.round((value + Number.EPSILON) * 100) / 100;
/** Weights must total this. Anything else makes scores incomparable. */
const REQUIRED_WEIGHT_TOTAL = 100;
@Injectable()
export class AppraisalService {
constructor(
@InjectDataSource() private readonly dataSource: DataSource,
@InjectRepository(AppraisalTemplate)
private readonly templates: Repository<AppraisalTemplate>,
@InjectRepository(AppraisalCriterion)
private readonly criteria: Repository<AppraisalCriterion>,
@InjectRepository(AppraisalCycle)
private readonly cycles: Repository<AppraisalCycle>,
@InjectRepository(Appraisal)
private readonly appraisals: Repository<Appraisal>,
@InjectRepository(AppraisalGoal)
private readonly goals: Repository<AppraisalGoal>,
private readonly employees: EmployeesRepository,
private readonly iamDirectory: IamDirectoryService,
) {}
// ── Templates ─────────────────────────────────────────────────────────────
async createTemplate(
dto: CreateTemplateDto,
actor: ActorContext,
): Promise<AppraisalTemplate> {
const organizationId = this.requireOrg(actor);
const clash = await this.templates.findOne({
where: { organizationId, code: dto.code },
});
if (clash) {
throw new ConflictException(`Template ${dto.code} already exists`);
}
AppraisalService.assertWeights(dto.criteria.map((c) => Number(c.weight)));
AppraisalService.assertBands(dto.ratingBands ?? []);
const template = await this.templates.save(
this.templates.create({
organizationId,
code: dto.code,
name: dto.name,
description: dto.description,
maxScore: dto.maxScore ?? "5.00",
ratingBands: dto.ratingBands ?? [],
requiresSelfAssessment: dto.requiresSelfAssessment ?? true,
requiresAcknowledgement: dto.requiresAcknowledgement ?? true,
createdBy: actor.userId,
} as unknown as Partial<AppraisalTemplate>),
);
let order = 10;
for (const criterion of dto.criteria) {
await this.criteria.save(
this.criteria.create({
templateId: template.id,
code: criterion.code,
name: criterion.name,
description: criterion.description,
category: criterion.category ?? null,
weight: criterion.weight,
sortOrder: order,
} as unknown as Partial<AppraisalCriterion>),
);
order += 10;
}
return this.getTemplate(template.id, actor);
}
async getTemplate(id: string, actor: ActorContext): Promise<AppraisalTemplate> {
const template = await this.templates.findOne({
where: { id },
relations: { criteria: true },
});
if (!template) throw new NotFoundException(`Template ${id} not found`);
const scope = orgScope(actor);
if (scope && template.organizationId !== scope) {
throw new NotFoundException(`Template ${id} not found`);
}
template.criteria?.sort((a, b) => a.sortOrder - b.sortOrder);
return template;
}
listTemplates(actor: ActorContext): Promise<AppraisalTemplate[]> {
const scope = orgScope(actor) ?? actor.organizationId;
return this.templates.find({
where: scope ? { organizationId: scope } : {},
relations: { criteria: true },
order: { code: "ASC" },
});
}
// ── Cycles ────────────────────────────────────────────────────────────────
async createCycle(dto: CreateCycleDto, actor: ActorContext): Promise<AppraisalCycle> {
const organizationId = this.requireOrg(actor);
const template = await this.getTemplate(dto.templateId, actor);
const clash = await this.cycles.findOne({
where: { organizationId, code: dto.code },
});
if (clash) throw new ConflictException(`Cycle ${dto.code} already exists`);
if (!template.criteria?.length) {
throw new BadRequestException(
`${template.code} has no criteria, so nothing could be scored.`,
);
}
return this.cycles.save(
this.cycles.create({
...dto,
organizationId,
status: ECycleStatus.DRAFT,
createdBy: actor.userId,
} as unknown as Partial<AppraisalCycle>),
);
}
/**
* Open a cycle and create an appraisal for everyone in scope.
*
* The template's criteria are COPIED onto each appraisal as ratings. Joining
* to the template instead would mean editing it a year later silently restated
* reviews that had already been signed.
*/
async openCycle(
id: string,
employeeIds: string[] | undefined,
actor: ActorContext,
): Promise<{ cycle: AppraisalCycle; created: number; skipped: number }> {
const cycle = await this.requireCycle(id, actor);
if (cycle.status !== ECycleStatus.DRAFT && cycle.status !== ECycleStatus.OPEN) {
throw new BadRequestException(
`This cycle is ${cycle.status.toLowerCase()} and cannot be opened.`,
);
}
const template = await this.getTemplate(cycle.templateId, actor);
// Default population: everyone with an HR profile in this organization who
// is actually employed. A terminated employee is not appraised.
const targets = employeeIds ?? (await this.eligibleEmployees(cycle.organizationId));
let created = 0;
let skipped = 0;
for (const employeeId of targets) {
const existing = await this.appraisals.findOne({
where: { cycleId: cycle.id, employeeId },
});
if (existing) {
skipped += 1;
continue;
}
const profile = await this.employees.findByEmployeeId(employeeId);
if (!profile) {
skipped += 1;
continue;
}
const appraiser =
profile.managerEmployeeId ??
(await this.iamDirectory.findLineManagerEmployeeId(employeeId));
await this.dataSource.transaction(async (manager) => {
const appraisal = await manager.getRepository(Appraisal).save(
manager.getRepository(Appraisal).create({
organizationId: cycle.organizationId,
cycleId: cycle.id,
employeeId,
appraiserEmployeeId: appraiser,
status: template.requiresSelfAssessment
? EAppraisalStatus.PENDING_SELF
: EAppraisalStatus.PENDING_MANAGER,
createdBy: actor.userId,
} as Partial<Appraisal>),
);
for (const criterion of template.criteria ?? []) {
await manager.getRepository(AppraisalRating).save(
manager.getRepository(AppraisalRating).create({
appraisalId: appraisal.id,
criterionId: criterion.id,
code: criterion.code,
name: criterion.name,
weight: criterion.weight,
maxScore: template.maxScore,
sortOrder: criterion.sortOrder,
} as Partial<AppraisalRating>),
);
}
});
created += 1;
}
await this.cycles.update(cycle.id, {
status: ECycleStatus.OPEN,
updatedBy: actor.userId,
});
return { cycle: await this.requireCycle(id, actor), created, skipped };
}
async closeCycle(id: string, actor: ActorContext): Promise<AppraisalCycle> {
const cycle = await this.requireCycle(id, actor);
if (cycle.status !== ECycleStatus.OPEN) {
throw new BadRequestException(
`Only an open cycle can be closed; this one is ${cycle.status.toLowerCase()}.`,
);
}
await this.cycles.update(id, {
status: ECycleStatus.CLOSED,
updatedBy: actor.userId,
});
return this.requireCycle(id, actor);
}
listCycles(actor: ActorContext): Promise<AppraisalCycle[]> {
const scope = orgScope(actor) ?? actor.organizationId;
return this.cycles.find({
where: scope ? { organizationId: scope } : {},
relations: { template: true },
order: { periodStart: "DESC" },
});
}
/** How far a cycle has got — counts by status. */
async cycleProgress(id: string, actor: ActorContext): Promise<Record<string, number>> {
const cycle = await this.requireCycle(id, actor);
const rows = await this.appraisals
.createQueryBuilder("appraisal")
.select("appraisal.status", "status")
.addSelect("COUNT(*)", "count")
.where("appraisal.cycle_id = :id", { id: cycle.id })
.groupBy("appraisal.status")
.getRawMany<{ status: string; count: string }>();
return Object.fromEntries(rows.map((row) => [row.status, Number(row.count)]));
}
// ── Appraisals ────────────────────────────────────────────────────────────
async findOne(id: string, actor: ActorContext): Promise<Appraisal> {
const appraisal = await this.appraisals.findOne({
where: { id },
relations: { ratings: true, goals: true, cycle: true },
});
if (!appraisal) throw new NotFoundException(`Appraisal ${id} not found`);
const scope = orgScope(actor);
if (scope && appraisal.organizationId !== scope) {
throw new NotFoundException(`Appraisal ${id} not found`);
}
appraisal.ratings?.sort((a, b) => a.sortOrder - b.sortOrder);
appraisal.goals?.sort((a, b) => a.sortOrder - b.sortOrder);
return appraisal;
}
async findMine(actor: ActorContext): Promise<Appraisal[]> {
if (!actor.employeeId) return [];
return this.appraisals.find({
where: { employeeId: actor.employeeId },
relations: { cycle: true },
order: { createdAt: "DESC" },
});
}
async findAwaitingMe(actor: ActorContext): Promise<Appraisal[]> {
if (!actor.employeeId) return [];
return this.appraisals.find({
where: {
appraiserEmployeeId: actor.employeeId,
status: EAppraisalStatus.PENDING_MANAGER,
},
relations: { cycle: true },
order: { createdAt: "ASC" },
});
}
async findAll(
filters: { cycleId?: string; status?: EAppraisalStatus; page?: number; limit?: number },
actor: ActorContext,
): Promise<Paginated<Appraisal>> {
const page = filters.page ?? 1;
const limit = filters.limit ?? 25;
const qb = this.appraisals
.createQueryBuilder("appraisal")
.leftJoinAndSelect("appraisal.cycle", "cycle");
const scope = orgScope(actor);
if (scope) qb.andWhere("appraisal.organization_id = :scope", { scope });
if (filters.cycleId) {
qb.andWhere("appraisal.cycle_id = :cycleId", { cycleId: filters.cycleId });
}
if (filters.status) {
qb.andWhere("appraisal.status = :status", { status: filters.status });
}
const [items, total] = await qb
.orderBy("appraisal.createdAt", "DESC")
.skip((page - 1) * limit)
.take(limit)
.getManyAndCount();
return paginate(items, total, page, limit);
}
/**
* The employee's own assessment.
*
* Scores are recorded but do NOT decide the outcome — the manager's do. Self
* assessment exists so the two views can be compared, which is the point of
* asking for it at all.
*/
async submitSelf(
id: string,
dto: SubmitSelfDto,
actor: ActorContext,
): Promise<Appraisal> {
const appraisal = await this.findOne(id, actor);
if (appraisal.employeeId !== actor.employeeId) {
throw new ForbiddenException("This is not your appraisal.");
}
if (appraisal.status !== EAppraisalStatus.PENDING_SELF) {
throw new BadRequestException(
`Self assessment is not open — this appraisal is ` +
`${appraisal.status.toLowerCase().replace(/_/g, " ")}.`,
);
}
const byCode = new Map(dto.ratings.map((rating) => [rating.code, rating]));
const missing = (appraisal.ratings ?? []).filter(
(rating) => !byCode.has(rating.code),
);
if (missing.length > 0) {
throw new BadRequestException(
`Every criterion needs a score. Missing: ${missing
.map((rating) => rating.code)
.join(", ")}.`,
);
}
await this.dataSource.transaction(async (manager) => {
for (const rating of appraisal.ratings ?? []) {
const input = byCode.get(rating.code)!;
AppraisalService.assertScore(input.score, Number(rating.maxScore), rating.code);
await manager.getRepository(AppraisalRating).update(rating.id, {
selfScore: input.score.toFixed(2),
selfComment: input.comment ?? null,
});
}
const selfScore = AppraisalService.weightedScore(
(appraisal.ratings ?? []).map((rating) => ({
weight: Number(rating.weight),
maxScore: Number(rating.maxScore),
score: byCode.get(rating.code)!.score,
})),
);
await manager.getRepository(Appraisal).update(appraisal.id, {
selfScore: selfScore.toFixed(2),
selfComment: dto.comment ?? null,
selfSubmittedAt: new Date(),
status: EAppraisalStatus.PENDING_MANAGER,
updatedBy: actor.userId,
});
});
return this.findOne(id, actor);
}
/**
* The manager's assessment, which decides the outcome.
*
* Nobody appraises themselves, whatever permission they hold — that is the one
* rule an appraisal process exists to enforce.
*/
async submitManager(
id: string,
dto: SubmitManagerDto,
actor: ActorContext,
): Promise<Appraisal> {
const appraisal = await this.findOne(id, actor);
if (appraisal.status !== EAppraisalStatus.PENDING_MANAGER) {
throw new BadRequestException(
`Manager assessment is not open — this appraisal is ` +
`${appraisal.status.toLowerCase().replace(/_/g, " ")}.`,
);
}
if (appraisal.employeeId === actor.employeeId) {
throw new ForbiddenException("You cannot appraise yourself.");
}
const cycle = await this.requireCycle(appraisal.cycleId, actor);
const template = await this.getTemplate(cycle.templateId, actor);
const byCode = new Map(dto.ratings.map((rating) => [rating.code, rating]));
const missing = (appraisal.ratings ?? []).filter(
(rating) => !byCode.has(rating.code),
);
if (missing.length > 0) {
throw new BadRequestException(
`Every criterion needs a score. Missing: ${missing
.map((rating) => rating.code)
.join(", ")}.`,
);
}
const managerScore = AppraisalService.weightedScore(
(appraisal.ratings ?? []).map((rating) => {
const input = byCode.get(rating.code)!;
AppraisalService.assertScore(input.score, Number(rating.maxScore), rating.code);
return {
weight: Number(rating.weight),
maxScore: Number(rating.maxScore),
score: input.score,
};
}),
);
const rating = AppraisalService.bandFor(managerScore, template.ratingBands);
// Acknowledgement is where the employee gets to reply. Skipping it when the
// template does not require one completes the review immediately.
const nextStatus = template.requiresAcknowledgement
? EAppraisalStatus.PENDING_ACKNOWLEDGEMENT
: EAppraisalStatus.COMPLETED;
await this.dataSource.transaction(async (manager) => {
for (const existing of appraisal.ratings ?? []) {
const input = byCode.get(existing.code)!;
await manager.getRepository(AppraisalRating).update(existing.id, {
managerScore: input.score.toFixed(2),
managerComment: input.comment ?? null,
});
}
for (const goal of dto.goals ?? []) {
if (!goal.id) continue;
await manager.getRepository(AppraisalGoal).update(goal.id, {
achievementPercent:
goal.achievementPercent !== undefined
? goal.achievementPercent.toFixed(2)
: null,
managerComment: goal.comment ?? null,
});
}
await manager.getRepository(Appraisal).update(appraisal.id, {
managerScore: managerScore.toFixed(2),
// The manager's score IS the final score. Averaging it with the
// employee's own would let anyone raise their result by rating
// themselves highly.
finalScore: managerScore.toFixed(2),
finalRating: rating,
managerComment: dto.comment ?? null,
managerSubmittedAt: new Date(),
acknowledgedAt: nextStatus === EAppraisalStatus.COMPLETED ? new Date() : null,
status: nextStatus,
updatedBy: actor.userId,
});
});
return this.findOne(id, actor);
}
/** The employee's right of reply. Cannot change the score. */
async acknowledge(
id: string,
comment: string | undefined,
actor: ActorContext,
): Promise<Appraisal> {
const appraisal = await this.findOne(id, actor);
if (appraisal.employeeId !== actor.employeeId) {
throw new ForbiddenException("This is not your appraisal.");
}
if (appraisal.status !== EAppraisalStatus.PENDING_ACKNOWLEDGEMENT) {
throw new BadRequestException(
`There is nothing to acknowledge — this appraisal is ` +
`${appraisal.status.toLowerCase().replace(/_/g, " ")}.`,
);
}
await this.appraisals.update(id, {
employeeComment: comment ?? null,
acknowledgedAt: new Date(),
status: EAppraisalStatus.COMPLETED,
updatedBy: actor.userId,
});
return this.findOne(id, actor);
}
async addGoal(
appraisalId: string,
dto: { title: string; description?: string; target?: string; weight?: string },
actor: ActorContext,
): Promise<AppraisalGoal> {
const appraisal = await this.findOne(appraisalId, actor);
if (appraisal.status === EAppraisalStatus.COMPLETED) {
throw new BadRequestException(
"This appraisal is complete; goals cannot be added to it now.",
);
}
const count = await this.goals.count({ where: { appraisalId } });
return this.goals.save(
this.goals.create({
appraisalId,
...dto,
sortOrder: (count + 1) * 10,
} as unknown as Partial<AppraisalGoal>),
);
}
// ────────────────────────────────────────────────────────────────────────
/**
* The weighted percentage.
*
* Each criterion contributes `score / maxScore × weight`. With weights totalling
* 100 the result is directly a percentage, which is why the total is enforced.
*/
static weightedScore(
entries: { weight: number; maxScore: number; score: number }[],
): number {
const total = entries.reduce((sum, entry) => {
if (entry.maxScore <= 0) return sum;
return sum + (entry.score / entry.maxScore) * entry.weight;
}, 0);
return round2(Math.min(100, Math.max(0, total)));
}
/** The band a percentage falls in. Highest matching band wins. */
static bandFor(score: number, bands: RatingBand[]): string | null {
const sorted = [...(bands ?? [])].sort((a, b) => b.min - a.min);
const band = sorted.find((entry) => score >= entry.min);
return band?.label?.en ?? null;
}
private static assertWeights(weights: number[]): void {
if (weights.length === 0) {
throw new BadRequestException("A template needs at least one criterion.");
}
const total = round2(weights.reduce((sum, weight) => sum + weight, 0));
if (total !== REQUIRED_WEIGHT_TOTAL) {
throw new BadRequestException(
`Criterion weights must total ${REQUIRED_WEIGHT_TOTAL}, not ${total}. ` +
"Otherwise a score cannot be read as a percentage or compared between " +
"employees.",
);
}
}
private static assertBands(bands: RatingBand[]): void {
for (const band of bands) {
if (band.min < 0 || band.min > 100) {
throw new BadRequestException(
`Rating band "${band.label?.en}" starts at ${band.min}; bands are ` +
"percentages between 0 and 100.",
);
}
}
}
private static assertScore(score: number, maxScore: number, code: string): void {
if (score < 0 || score > maxScore) {
throw new BadRequestException(
`${code} scored ${score}, but the scale runs 0 to ${maxScore}.`,
);
}
}
/** Employed people with an HR profile — a terminated employee is not appraised. */
private async eligibleEmployees(organizationId: string): Promise<string[]> {
const rows = await this.dataSource.query<{ employeeId: string }[]>(
`SELECT p."employee_id" AS "employeeId"
FROM hr.employee_profiles p
JOIN iam.employees e ON e.id = p."employee_id"
WHERE p."deleted_at" IS NULL
AND e."organization_id" = $1::uuid
AND p."employment_state" NOT IN ('TERMINATED','RETIRED')`,
[organizationId],
);
return rows.map((row) => row.employeeId);
}
private requireOrg(actor: ActorContext): string {
const organizationId = orgScope(actor) ?? actor.organizationId;
if (!organizationId) {
throw new BadRequestException(
"This account has no organization, and an appraisal template belongs to one.",
);
}
return organizationId;
}
private async requireCycle(id: string, actor: ActorContext): Promise<AppraisalCycle> {
const cycle = await this.cycles.findOne({ where: { id } });
if (!cycle) throw new NotFoundException(`Cycle ${id} not found`);
const scope = orgScope(actor);
if (scope && cycle.organizationId !== scope) {
throw new NotFoundException(`Cycle ${id} not found`);
}
return cycle;
}
}

View File

@@ -0,0 +1,71 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { WorkSchedule } from "./entities/work-schedule.entity";
import { WorkScheduleAssignment } from "./entities/work-schedule-assignment.entity";
import { AttendanceRecord } from "./entities/attendance-record.entity";
import { AttendanceRegularization } from "./entities/attendance-regularization.entity";
import { OvertimeRate, OvertimeRequest } from "./entities/overtime.entity";
import {
WorkScheduleAssignmentsRepository,
WorkSchedulesRepository,
} from "./repositories/work-schedules.repository";
import { AttendanceRepository } from "./repositories/attendance.repository";
import { WorkSchedulesService } from "./services/work-schedules.service";
import { AttendanceService } from "./services/attendance.service";
import { AttendanceDayService } from "./services/attendance-day.service";
import { OvertimeService } from "./services/overtime.service";
import { RegularizationsService } from "./services/regularizations.service";
import { WorkSchedulesController } from "./controllers/work-schedules.controller";
import { AttendanceController } from "./controllers/attendance.controller";
import { OvertimeController } from "./controllers/overtime.controller";
import { RegularizationsController } from "./controllers/regularizations.controller";
import { LeaveModule } from "../leave/leave.module";
import { EmployeesModule } from "../employees/employees.module";
/**
* Module 3.3 slice 1.
*
* Depends on LeaveModule rather than re-deriving the calendar: the working week,
* the holidays and approved leave all live there, and a second implementation
* would eventually disagree with the first about whether a given day was
* workable.
*/
@Module({
imports: [
TypeOrmModule.forFeature([
WorkSchedule,
WorkScheduleAssignment,
AttendanceRecord,
AttendanceRegularization,
OvertimeRate,
OvertimeRequest,
]),
LeaveModule,
EmployeesModule,
],
controllers: [
WorkSchedulesController,
AttendanceController,
OvertimeController,
RegularizationsController,
],
providers: [
WorkSchedulesRepository,
WorkScheduleAssignmentsRepository,
AttendanceRepository,
WorkSchedulesService,
AttendanceService,
AttendanceDayService,
OvertimeService,
RegularizationsService,
],
// Payroll (3.4) prices overtime and absence from these records.
exports: [
AttendanceService,
WorkSchedulesService,
AttendanceRepository,
OvertimeService,
],
})
export class AttendanceModule {}

View File

@@ -0,0 +1,228 @@
import {
Body,
Controller,
ForbiddenException,
Get,
Param,
ParseIntPipe,
ParseUUIDPipe,
Post,
Query,
} from "@nestjs/common";
import {
ApiBearerAuth,
ApiOperation,
ApiQuery,
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 } from "../../../seed/hr-permissions.registry";
import { hasHrPermission } from "../../../common/hr-permission.util";
import { AttendanceService } from "../services/attendance.service";
import {
EAttendanceSource,
EAttendanceStatus,
} from "../entities/attendance-record.entity";
import { PunchDto, RecordAttendanceDto } from "../dto/attendance.dto";
@ApiTags("attendance")
@ApiBearerAuth()
@Controller("attendance")
@HrStaff([
HR_PERMS.attendance.record,
HR_PERMS.attendance.viewOwn,
HR_PERMS.attendance.viewAll,
])
export class AttendanceController {
constructor(private readonly attendance: AttendanceService) {}
@Post("check-in")
@HrStaff(HR_PERMS.attendance.viewOwn)
@ApiOperation({
summary: "Clock in",
description:
"Refuses a second check-in for the same day rather than overwriting the " +
"first — the earliest punch decides lateness. Also refuses a day already " +
"covered by approved leave.",
})
@ApiResponse({ status: 409, description: "Already checked in, or on leave" })
checkIn(@Body() dto: PunchDto, @CurrentUser() user: TCurrentUser) {
const actor = actorFrom(user);
const employeeId = this.resolveTarget(dto.employeeId, actor, user);
return this.attendance.checkIn(
employeeId,
dto.at ? new Date(dto.at) : new Date(),
dto.source ?? EAttendanceSource.WEB,
actor,
dto.notes,
);
}
@Post("check-out")
@HrStaff(HR_PERMS.attendance.viewOwn)
@ApiOperation({
summary: "Clock out",
description:
"Settles the day: worked minutes less the scheduled break, lateness past " +
"the grace period, and the resulting status.",
})
checkOut(@Body() dto: PunchDto, @CurrentUser() user: TCurrentUser) {
const actor = actorFrom(user);
const employeeId = this.resolveTarget(dto.employeeId, actor, user);
return this.attendance.checkOut(
employeeId,
dto.at ? new Date(dto.at) : new Date(),
dto.source ?? EAttendanceSource.WEB,
actor,
dto.notes,
);
}
@Get("today")
@HrStaff(HR_PERMS.attendance.viewOwn)
@ApiOperation({ summary: "My record for today — what the clock widget shows" })
today(@CurrentUser() user: TCurrentUser) {
const actor = actorFrom(user);
if (!actor.employeeId) {
throw new ForbiddenException(
"This account has no employee record, so it has no attendance.",
);
}
return this.attendance.today(actor.employeeId, new Date());
}
@Get("mine")
@HrStaff(HR_PERMS.attendance.viewOwn)
@ApiOperation({ summary: "My attendance over a range" })
@ApiQuery({ name: "from", required: true })
@ApiQuery({ name: "to", required: true })
mine(
@CurrentUser() user: TCurrentUser,
@Query("from") from: string,
@Query("to") to: string,
) {
const actor = actorFrom(user);
if (!actor.employeeId) {
throw new ForbiddenException(
"This account has no employee record, so it has no attendance.",
);
}
return this.attendance.findRange(actor.employeeId, from, to);
}
@Get("summary")
@HrStaff(HR_PERMS.attendance.viewOwn)
@ApiOperation({
summary: "Counts by status over a range",
description: "Omit employeeId for the whole organisation (needs view-all).",
})
@ApiQuery({ name: "employeeId", required: false })
summary(
@CurrentUser() user: TCurrentUser,
@Query("from") from: string,
@Query("to") to: string,
@Query("employeeId") employeeId?: string,
) {
const actor = actorFrom(user);
// Without view-all, the summary is scoped to the caller whatever they ask
// for — otherwise it is an aggregate leak of everyone else's attendance.
const scoped = hasHrPermission(user, HR_PERMS.attendance.viewAll)
? employeeId
: (actor.employeeId ?? undefined);
return this.attendance.summary(from, to, actor, scoped);
}
@Get()
@HrStaff(HR_PERMS.attendance.viewAll)
@ApiOperation({ summary: "All attendance records" })
@ApiQuery({ name: "employeeId", required: false })
@ApiQuery({ name: "status", required: false, enum: EAttendanceStatus })
findAll(
@CurrentUser() user: TCurrentUser,
@Query("employeeId") employeeId?: string,
@Query("status") status?: EAttendanceStatus,
@Query("from") from?: string,
@Query("to") to?: string,
@Query("page", new ParseIntPipe({ optional: true })) page?: number,
@Query("limit", new ParseIntPipe({ optional: true })) limit?: number,
) {
return this.attendance.findAll(
{ employeeId, status, from, to, page, limit },
actorFrom(user),
);
}
@Get("employee/:employeeId")
@HrStaff(HR_PERMS.attendance.viewAll)
@ApiOperation({ summary: "One employee's attendance over a range" })
forEmployee(
@Param("employeeId", ParseUUIDPipe) employeeId: string,
@Query("from") from: string,
@Query("to") to: string,
) {
return this.attendance.findRange(employeeId, from, to);
}
@Post("record")
@HrStaff(HR_PERMS.attendance.record)
@ApiOperation({
summary: "Record a day by hand",
description:
"For an administrator filling a gap. Recorded with source MANUAL so a " +
"report can tell asserted attendance from observed attendance.",
})
record(@Body() dto: RecordAttendanceDto, @CurrentUser() user: TCurrentUser) {
return this.attendance.recordManually(dto, actorFrom(user));
}
@Post("backfill/:employeeId")
@HrStaff(HR_PERMS.attendance.record)
@ApiOperation({
summary: "Fill in the days nobody punched for",
description:
"Classifies each missing day as a rest day, holiday, approved leave or " +
"absence. Never touches an existing record, so it is safe to re-run.",
})
@ApiResponse({ status: 201, description: "{ created, skipped, byStatus }" })
backfill(
@Param("employeeId", ParseUUIDPipe) employeeId: string,
@Query("from") from: string,
@Query("to") to: string,
@CurrentUser() user: TCurrentUser,
) {
return this.attendance.backfill(employeeId, from, to, actorFrom(user));
}
/**
* Punching for somebody else is an administrative act, not self-service.
* Without the record permission the target is always the caller — supplying
* another id is refused rather than quietly ignored, since silently punching
* for the wrong person is worse than an error.
*/
private resolveTarget(
requested: string | undefined,
actor: ReturnType<typeof actorFrom>,
user: TCurrentUser,
): string {
if (!requested || requested === actor.employeeId) {
if (!actor.employeeId) {
throw new ForbiddenException(
"This account has no employee record, so it cannot clock in or out.",
);
}
return actor.employeeId;
}
if (!hasHrPermission(user, HR_PERMS.attendance.record)) {
throw new ForbiddenException(
"Recording attendance for another employee needs the attendance-record " +
"permission.",
);
}
return requested;
}
}

View File

@@ -0,0 +1,199 @@
import {
Body,
Controller,
Get,
Param,
ParseIntPipe,
ParseUUIDPipe,
Patch,
Post,
Query,
} from "@nestjs/common";
import {
ApiBearerAuth,
ApiOperation,
ApiQuery,
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 } from "../../../seed/hr-permissions.registry";
import { OvertimeService } from "../services/overtime.service";
import { EOvertimeStatus } from "../entities/overtime.entity";
import {
CreateOvertimeDto,
DecideDto,
RejectDto,
UpdateOvertimeRateDto,
} from "../dto/overtime.dto";
@ApiTags("overtime")
@ApiBearerAuth()
@Controller("overtime")
@HrStaff([
HR_PERMS.attendance.requestOvertime,
HR_PERMS.attendance.approveOvertime,
HR_PERMS.attendance.viewOwn,
HR_PERMS.attendance.viewAll,
])
export class OvertimeController {
constructor(private readonly overtime: OvertimeService) {}
@Get("rates")
@HrStaff(HR_PERMS.attendance.viewOwn)
@ApiOperation({
summary: "The premiums paid for each category",
description:
"Visible to anyone who can claim overtime — the multiplier is what their " +
"claim is worth.",
})
rates(@CurrentUser() user: TCurrentUser) {
return this.overtime.listRates(actorFrom(user));
}
@Post("rates/seed-statutory")
@HrStaff(HR_PERMS.attendance.approveOvertime)
@ApiOperation({
summary: "Seed the statutory premiums",
description:
"Proclamation 1156/2019 Art. 68 — 1.5× ordinary, 1.75× night, 2× on a " +
"rest day, 2.5× on a public holiday. Idempotent; an existing rate is " +
"never lowered back to the floor.",
})
seed(@CurrentUser() user: TCurrentUser) {
return this.overtime.seedStatutory(actorFrom(user));
}
@Patch("rates/:id")
@HrStaff(HR_PERMS.attendance.approveOvertime)
@ApiOperation({
summary: "Change a premium",
description: "Refused below the statutory floor for that category.",
})
updateRate(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateOvertimeRateDto,
@CurrentUser() user: TCurrentUser,
) {
return this.overtime.updateRate(id, dto, actorFrom(user));
}
@Post()
@HrStaff(HR_PERMS.attendance.requestOvertime)
@ApiOperation({
summary: "Claim overtime",
description:
"The category is derived from when the work happened, not chosen — a " +
"public holiday beats a rest day, which beats night, which beats " +
"ordinary. The multiplier is frozen on the claim at this moment.",
})
@ApiResponse({
status: 400,
description: "Over the daily, monthly or annual cap in Art. 67",
})
create(@Body() dto: CreateOvertimeDto, @CurrentUser() user: TCurrentUser) {
return this.overtime.create(dto, actorFrom(user));
}
@Get("mine")
@HrStaff(HR_PERMS.attendance.viewOwn)
@ApiOperation({ summary: "My overtime claims" })
mine(
@CurrentUser() user: TCurrentUser,
@Query("status") status?: EOvertimeStatus,
@Query("page", new ParseIntPipe({ optional: true })) page?: number,
@Query("limit", new ParseIntPipe({ optional: true })) limit?: number,
) {
const actor = actorFrom(user);
return this.overtime.findAll(
{ employeeId: actor.employeeId ?? "", status, page, limit },
actor,
);
}
@Get("awaiting-me")
@HrStaff(HR_PERMS.attendance.approveOvertime)
@ApiOperation({ summary: "Claims waiting on my decision" })
awaitingMe(
@CurrentUser() user: TCurrentUser,
@Query("page", new ParseIntPipe({ optional: true })) page?: number,
@Query("limit", new ParseIntPipe({ optional: true })) limit?: number,
) {
return this.overtime.findAwaitingMe(actorFrom(user), page, limit);
}
@Get()
@HrStaff(HR_PERMS.attendance.viewAll)
@ApiOperation({ summary: "All overtime claims" })
@ApiQuery({ name: "employeeId", required: false })
@ApiQuery({ name: "status", required: false, enum: EOvertimeStatus })
findAll(
@CurrentUser() user: TCurrentUser,
@Query("employeeId") employeeId?: string,
@Query("status") status?: EOvertimeStatus,
@Query("from") from?: string,
@Query("to") to?: string,
@Query("page", new ParseIntPipe({ optional: true })) page?: number,
@Query("limit", new ParseIntPipe({ optional: true })) limit?: number,
) {
return this.overtime.findAll(
{ employeeId, status, from, to, page, limit },
actorFrom(user),
);
}
@Get("totals/:employeeId")
@HrStaff(HR_PERMS.attendance.viewAll)
@ApiOperation({
summary: "Approved hours by category for a period",
description: "What payroll multiplies. Approved claims only.",
})
totals(
@Param("employeeId", ParseUUIDPipe) employeeId: string,
@Query("from") from: string,
@Query("to") to: string,
) {
return this.overtime.approvedTotals(employeeId, from, to);
}
@Patch(":id/approve")
@HrStaff(HR_PERMS.attendance.approveOvertime)
@ApiOperation({
summary: "Approve a claim",
description:
"Caps are re-checked here: other claims may have been approved since this " +
"one was filed, and the monthly ceiling is about the total.",
})
approve(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: DecideDto,
@CurrentUser() user: TCurrentUser,
) {
return this.overtime.approve(id, dto.note, actorFrom(user));
}
@Patch(":id/reject")
@HrStaff(HR_PERMS.attendance.approveOvertime)
@ApiOperation({ summary: "Reject a claim, with a reason" })
reject(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: RejectDto,
@CurrentUser() user: TCurrentUser,
) {
return this.overtime.reject(id, dto.note, actorFrom(user));
}
@Patch(":id/withdraw")
@HrStaff(HR_PERMS.attendance.requestOvertime)
@ApiOperation({ summary: "Withdraw a claim before a decision" })
withdraw(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
return this.overtime.withdraw(id, actorFrom(user));
}
}

View File

@@ -0,0 +1,138 @@
import {
Body,
Controller,
Get,
Param,
ParseIntPipe,
ParseUUIDPipe,
Patch,
Post,
Query,
} from "@nestjs/common";
import {
ApiBearerAuth,
ApiOperation,
ApiQuery,
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 } from "../../../seed/hr-permissions.registry";
import { RegularizationsService } from "../services/regularizations.service";
import { ERegularizationStatus } from "../entities/attendance-regularization.entity";
import { CreateRegularizationDto } from "../dto/regularization.dto";
import { DecideDto, RejectDto } from "../dto/overtime.dto";
@ApiTags("attendance-regularizations")
@ApiBearerAuth()
@Controller("attendance-regularizations")
@HrStaff([
HR_PERMS.attendance.regularize,
HR_PERMS.attendance.approveRegularization,
HR_PERMS.attendance.viewOwn,
HR_PERMS.attendance.viewAll,
])
export class RegularizationsController {
constructor(private readonly regularizations: RegularizationsService) {}
@Post()
@HrStaff(HR_PERMS.attendance.regularize)
@ApiOperation({
summary: "Ask to correct a punch",
description:
"For a forgotten clock-in, a mis-clock, or a device that was down. " +
"Nothing changes until it is approved.",
})
create(
@Body() dto: CreateRegularizationDto,
@CurrentUser() user: TCurrentUser,
) {
return this.regularizations.create(dto, actorFrom(user));
}
@Get("mine")
@HrStaff(HR_PERMS.attendance.viewOwn)
@ApiOperation({ summary: "My corrections" })
mine(
@CurrentUser() user: TCurrentUser,
@Query("status") status?: ERegularizationStatus,
@Query("page", new ParseIntPipe({ optional: true })) page?: number,
@Query("limit", new ParseIntPipe({ optional: true })) limit?: number,
) {
const actor = actorFrom(user);
return this.regularizations.findAll(
{ employeeId: actor.employeeId ?? "", status, page, limit },
actor,
);
}
@Get("awaiting-me")
@HrStaff(HR_PERMS.attendance.approveRegularization)
@ApiOperation({ summary: "Corrections waiting on my decision" })
awaitingMe(
@CurrentUser() user: TCurrentUser,
@Query("page", new ParseIntPipe({ optional: true })) page?: number,
@Query("limit", new ParseIntPipe({ optional: true })) limit?: number,
) {
return this.regularizations.findAwaitingMe(actorFrom(user), page, limit);
}
@Get()
@HrStaff(HR_PERMS.attendance.viewAll)
@ApiOperation({ summary: "All corrections" })
@ApiQuery({ name: "employeeId", required: false })
@ApiQuery({ name: "status", required: false, enum: ERegularizationStatus })
findAll(
@CurrentUser() user: TCurrentUser,
@Query("employeeId") employeeId?: string,
@Query("status") status?: ERegularizationStatus,
@Query("page", new ParseIntPipe({ optional: true })) page?: number,
@Query("limit", new ParseIntPipe({ optional: true })) limit?: number,
) {
return this.regularizations.findAll(
{ employeeId, status, page, limit },
actorFrom(user),
);
}
@Patch(":id/approve")
@HrStaff(HR_PERMS.attendance.approveRegularization)
@ApiOperation({
summary: "Approve and apply the correction",
description:
"The record is updated in the same transaction, and the values being " +
"replaced are copied onto the request first — so what it said before " +
"stays answerable.",
})
approve(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: DecideDto,
@CurrentUser() user: TCurrentUser,
) {
return this.regularizations.approve(id, dto.note, actorFrom(user));
}
@Patch(":id/reject")
@HrStaff(HR_PERMS.attendance.approveRegularization)
@ApiOperation({ summary: "Reject, with a reason" })
reject(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: RejectDto,
@CurrentUser() user: TCurrentUser,
) {
return this.regularizations.reject(id, dto.note, actorFrom(user));
}
@Patch(":id/withdraw")
@HrStaff(HR_PERMS.attendance.regularize)
@ApiOperation({ summary: "Withdraw before a decision" })
withdraw(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
return this.regularizations.withdraw(id, actorFrom(user));
}
}

View File

@@ -0,0 +1,103 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
} from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, 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 } from "../../../seed/hr-permissions.registry";
import { WorkSchedulesService } from "../services/work-schedules.service";
import {
AssignScheduleDto,
CreateWorkScheduleDto,
UpdateWorkScheduleDto,
} from "../dto/work-schedule.dto";
@ApiTags("work-schedules")
@ApiBearerAuth()
@Controller("work-schedules")
@HrStaff([HR_PERMS.attendance.manageWorkSchedule, HR_PERMS.attendance.viewOwn])
export class WorkSchedulesController {
constructor(private readonly schedules: WorkSchedulesService) {}
@Get()
// Readable by anyone who can see their own attendance: the schedule is what
// decides whether they were late, so hiding it makes the verdict arbitrary.
@HrStaff(HR_PERMS.attendance.viewOwn)
@ApiOperation({ summary: "Shift patterns" })
findAll(@CurrentUser() user: TCurrentUser) {
return this.schedules.findAll(actorFrom(user));
}
@Get(":id")
@HrStaff(HR_PERMS.attendance.viewOwn)
@ApiOperation({ summary: "One shift pattern" })
findOne(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
return this.schedules.findOne(id, actorFrom(user));
}
@Post()
@HrStaff(HR_PERMS.attendance.manageWorkSchedule)
@ApiOperation({ summary: "Add a shift pattern" })
create(@Body() dto: CreateWorkScheduleDto, @CurrentUser() user: TCurrentUser) {
return this.schedules.create(dto, actorFrom(user));
}
@Patch(":id")
@HrStaff(HR_PERMS.attendance.manageWorkSchedule)
@ApiOperation({ summary: "Change a shift pattern" })
update(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateWorkScheduleDto,
@CurrentUser() user: TCurrentUser,
) {
return this.schedules.update(id, dto, actorFrom(user));
}
@Delete(":id")
@HrStaff(HR_PERMS.attendance.manageWorkSchedule)
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({
summary: "Retire a shift pattern",
description: "Refused while anyone is still assigned to it.",
})
remove(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
return this.schedules.remove(id, actorFrom(user));
}
@Post("assign")
@HrStaff(HR_PERMS.attendance.manageWorkSchedule)
@ApiOperation({
summary: "Put an employee on a shift pattern",
description:
"Closes their current assignment the day before, so attendance already " +
"recorded keeps being judged against the schedule that actually applied.",
})
assign(@Body() dto: AssignScheduleDto, @CurrentUser() user: TCurrentUser) {
return this.schedules.assign(dto, actorFrom(user));
}
@Get("assignments/:employeeId")
@HrStaff(HR_PERMS.attendance.viewAll)
@ApiOperation({ summary: "An employee's shift history" })
history(@Param("employeeId", ParseUUIDPipe) employeeId: string) {
return this.schedules.history(employeeId);
}
}

View File

@@ -0,0 +1,78 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import {
IsDateString,
IsEnum,
IsOptional,
IsString,
IsUUID,
MaxLength,
} from "class-validator";
import {
EAttendanceSource,
EAttendanceStatus,
} from "../entities/attendance-record.entity";
export class PunchDto {
@ApiPropertyOptional({
format: "uuid",
description: "Omit to punch for yourself. Supplying it needs the record permission.",
})
@IsOptional()
@IsUUID()
employeeId?: string;
@ApiPropertyOptional({
description: "ISO instant. Defaults to now — supply it only for a device sync.",
})
@IsOptional()
@IsDateString()
at?: string;
@ApiPropertyOptional({ enum: EAttendanceSource, default: EAttendanceSource.WEB })
@IsOptional()
@IsEnum(EAttendanceSource)
source?: EAttendanceSource;
@ApiPropertyOptional({ maxLength: 512 })
@IsOptional()
@IsString()
@MaxLength(512)
notes?: string;
}
export class RecordAttendanceDto {
@ApiProperty({ format: "uuid" })
@IsUUID()
employeeId!: string;
@ApiProperty({ format: "date" })
@IsDateString()
workDate!: string;
@ApiPropertyOptional({ description: "ISO instant." })
@IsOptional()
@IsDateString()
checkIn?: string;
@ApiPropertyOptional({ description: "ISO instant." })
@IsOptional()
@IsDateString()
checkOut?: string;
@ApiPropertyOptional({
enum: EAttendanceStatus,
description:
"Override the derived status. ON_LEAVE is refused unless approved leave " +
"actually covers the date.",
})
@IsOptional()
@IsEnum(EAttendanceStatus)
status?: EAttendanceStatus;
@ApiPropertyOptional({ maxLength: 512 })
@IsOptional()
@IsString()
@MaxLength(512)
notes?: string;
}

View File

@@ -0,0 +1,70 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import {
IsDateString,
IsNumberString,
IsOptional,
IsString,
IsUUID,
MaxLength,
} from "class-validator";
export class CreateOvertimeDto {
@ApiPropertyOptional({ format: "uuid", description: "Omit to claim for yourself." })
@IsOptional()
@IsUUID()
employeeId?: string;
@ApiProperty({ description: "ISO instant the extra work began." })
@IsDateString()
startedAt!: string;
@ApiProperty({ description: "ISO instant it ended." })
@IsDateString()
endedAt!: string;
@ApiPropertyOptional({
format: "date",
description:
"The day the work belongs to. Defaults to the day it began, which is what " +
"night work should use — a shift starting 22:00 Monday is Monday's overtime.",
})
@IsOptional()
@IsDateString()
workDate?: string;
@ApiPropertyOptional({ maxLength: 512 })
@IsOptional()
@IsString()
@MaxLength(512)
reason?: string;
}
export class UpdateOvertimeRateDto {
@ApiPropertyOptional({
example: "2.00",
description: "Cannot be set below the statutory floor for the category.",
})
@IsOptional()
@IsNumberString()
multiplier?: string;
@ApiPropertyOptional({ example: "2.00", nullable: true })
@IsOptional()
@IsNumberString()
maxHoursPerDay?: string | null;
}
export class DecideDto {
@ApiPropertyOptional({ maxLength: 512 })
@IsOptional()
@IsString()
@MaxLength(512)
note?: string;
}
export class RejectDto {
@ApiProperty({ maxLength: 512, description: "Required." })
@IsString()
@MaxLength(512)
note!: string;
}

View File

@@ -0,0 +1,39 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import {
IsDateString,
IsOptional,
IsString,
IsUUID,
MaxLength,
} from "class-validator";
export class CreateRegularizationDto {
@ApiPropertyOptional({ format: "uuid", description: "Omit to file for yourself." })
@IsOptional()
@IsUUID()
employeeId?: string;
@ApiProperty({ format: "date", description: "The day being corrected." })
@IsDateString()
workDate!: string;
@ApiPropertyOptional({ description: "ISO instant. Omit to leave the recorded one." })
@IsOptional()
@IsDateString()
requestedCheckIn?: string;
@ApiPropertyOptional({ description: "ISO instant. Omit to leave the recorded one." })
@IsOptional()
@IsDateString()
requestedCheckOut?: string;
@ApiProperty({
maxLength: 512,
description:
"Required — a correction with no explanation is not reviewable, and this " +
"is the record an approver decides on.",
})
@IsString()
@MaxLength(512)
reason!: string;
}

View File

@@ -0,0 +1,138 @@
import { ApiProperty, ApiPropertyOptional, PartialType } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsDateString,
IsInt,
IsNotEmpty,
IsObject,
IsOptional,
IsString,
IsUUID,
Matches,
Max,
MaxLength,
Min,
ValidateNested,
} from "class-validator";
import { LocalizedTextDto } from "../../leave/dto/leave-type.dto";
/** `HH:MM` or `HH:MM:SS`, 24-hour. */
const TIME = /^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/;
export class CreateWorkScheduleDto {
@ApiProperty({ maxLength: 32, example: "STANDARD" })
@IsString()
@IsNotEmpty()
@MaxLength(32)
code!: string;
@ApiProperty({ type: LocalizedTextDto })
@IsObject()
@ValidateNested()
@Type(() => LocalizedTextDto)
name!: LocalizedTextDto;
@ApiPropertyOptional({ example: "08:30", default: "08:30" })
@IsOptional()
@Matches(TIME, { message: "startTime must be HH:MM or HH:MM:SS" })
startTime?: string;
@ApiPropertyOptional({ example: "17:30", default: "17:30" })
@IsOptional()
@Matches(TIME, { message: "endTime must be HH:MM or HH:MM:SS" })
endTime?: string;
@ApiPropertyOptional({ default: 60, minimum: 0, maximum: 480 })
@IsOptional()
@IsInt()
@Min(0)
@Max(480)
breakMinutes?: number;
@ApiPropertyOptional({
default: 10,
description: "Arrive within this many minutes and nothing is recorded.",
})
@IsOptional()
@IsInt()
@Min(0)
@Max(240)
gracePeriodMinutes?: number;
@ApiPropertyOptional({
type: [Number],
example: [1, 2, 3, 4, 5],
description:
"Days this shift works, 0 = Sunday. Omit to follow the organisation's " +
"working week from leave settings.",
})
@IsOptional()
@IsArray()
@ArrayMaxSize(7)
@IsInt({ each: true })
@Min(0, { each: true })
@Max(6, { each: true })
workingDays?: number[];
@ApiPropertyOptional({ default: 420, description: "7 hours." })
@IsOptional()
@IsInt()
@Min(0)
@Max(1440)
minMinutesFullDay?: number;
@ApiPropertyOptional({ default: 210 })
@IsOptional()
@IsInt()
@Min(0)
@Max(1440)
minMinutesHalfDay?: number;
@ApiPropertyOptional({
default: false,
description: "A night shift ending the following morning.",
})
@IsOptional()
@IsBoolean()
crossesMidnight?: boolean;
@ApiPropertyOptional({
default: false,
description:
"Applies to anyone with no explicit assignment. Setting it moves the " +
"default off whichever schedule currently holds it.",
})
@IsOptional()
@IsBoolean()
isDefault?: boolean;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class UpdateWorkScheduleDto extends PartialType(CreateWorkScheduleDto) {}
export class AssignScheduleDto {
@ApiProperty({ format: "uuid", description: "iam.employees.id" })
@IsUUID()
employeeId!: string;
@ApiProperty({ format: "uuid" })
@IsUUID()
workScheduleId!: string;
@ApiProperty({
format: "date",
description:
"The current assignment is closed the day before this, so history stays " +
"intact.",
})
@IsDateString()
effectiveFrom!: string;
}

View File

@@ -0,0 +1,116 @@
import { SoftDeleteAudit } from "@tria-plc/api-common/modules/typeorm/audit.entity";
import {
Column,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from "typeorm";
import { WorkSchedule } from "./work-schedule.entity";
export enum EAttendanceStatus {
PRESENT = "PRESENT",
LATE = "LATE",
/** Expected in, never arrived, and no leave explains it. */
ABSENT = "ABSENT",
HALF_DAY = "HALF_DAY",
/** Covered by an approved leave request — `leaveRequestId` says which. */
ON_LEAVE = "ON_LEAVE",
HOLIDAY = "HOLIDAY",
/** A weekend, or a day this shift does not work. */
REST_DAY = "REST_DAY",
}
export enum EAttendanceSource {
WEB = "WEB",
MOBILE = "MOBILE",
BIOMETRIC = "BIOMETRIC",
/** Entered by hand — an administrator filling a gap. */
MANUAL = "MANUAL",
IMPORT = "IMPORT",
/** Derived, not observed: rest days, holidays and leave days. */
SYSTEM = "SYSTEM",
}
/**
* One employee, one day.
*
* `workDate` is a DATE and not a timestamp on purpose. A night shift clocking
* out at 02:00 belongs to the day it began; keying on the punch instant would
* file it under the following day and split one shift across two rows.
*/
@Entity({ schema: "hr", name: "attendance_records" })
@Index("idx_attendance_org_date", ["organizationId", "workDate"])
export class AttendanceRecord extends SoftDeleteAudit {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid", name: "organization_id" })
organizationId!: string;
@Column({ type: "uuid", name: "employee_id" })
employeeId!: string;
@Column({ type: "date", name: "work_date" })
workDate!: string;
/** The schedule in force on this date — frozen, not looked up on read. */
@Column({ type: "uuid", name: "work_schedule_id", nullable: true })
workScheduleId?: string | null;
@ManyToOne(() => WorkSchedule)
@JoinColumn({ name: "work_schedule_id" })
workSchedule?: WorkSchedule;
@Column({ type: "timestamptz", name: "check_in", nullable: true })
checkIn?: Date | null;
@Column({ type: "timestamptz", name: "check_out", nullable: true })
checkOut?: Date | null;
@Column({
type: "varchar",
length: 16,
name: "status",
default: EAttendanceStatus.ABSENT,
})
status!: EAttendanceStatus;
/** Between the punches, less the scheduled break. */
@Column({ type: "integer", name: "worked_minutes", default: 0 })
workedMinutes!: number;
/** Past the start time AND past the grace period. Zero when within grace. */
@Column({ type: "integer", name: "late_minutes", default: 0 })
lateMinutes!: number;
@Column({ type: "integer", name: "early_leave_minutes", default: 0 })
earlyLeaveMinutes!: number;
@Column({
type: "varchar",
length: 16,
name: "source",
default: EAttendanceSource.WEB,
})
source!: EAttendanceSource;
/** Set when ON_LEAVE — which approved request excuses this day. */
@Column({ type: "uuid", name: "leave_request_id", nullable: true })
leaveRequestId?: string | null;
@Column({ type: "varchar", length: 512, name: "notes", nullable: true })
notes?: string | null;
/** True once a regularization has corrected the punches. */
@Column({ type: "boolean", name: "is_regularized", default: false })
isRegularized!: boolean;
@Column({ type: "uuid", name: "created_by", nullable: true })
createdBy?: string | null;
@Column({ type: "uuid", name: "updated_by", nullable: true })
updatedBy?: string | null;
}

View File

@@ -0,0 +1,90 @@
import { SoftDeleteAudit } from "@tria-plc/api-common/modules/typeorm/audit.entity";
import { Column, Entity, Index, PrimaryGeneratedColumn } from "typeorm";
import { EAttendanceStatus } from "./attendance-record.entity";
export enum ERegularizationStatus {
SUBMITTED = "SUBMITTED",
APPROVED = "APPROVED",
REJECTED = "REJECTED",
WITHDRAWN = "WITHDRAWN",
}
/**
* A request to correct a punch — forgotten, mis-clocked, or a device that was
* down.
*
* The correction is applied only on approval. The values that were there before
* are copied onto this row at that moment, so "what did the record say before
* somebody changed it?" is answerable from the request itself, without a
* separate audit table.
*/
@Entity({ schema: "hr", name: "attendance_regularizations" })
@Index("idx_regularizations_employee", ["employeeId", "workDate"])
export class AttendanceRegularization extends SoftDeleteAudit {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid", name: "organization_id" })
organizationId!: string;
@Column({ type: "uuid", name: "employee_id" })
employeeId!: string;
/** Null when no record exists yet — a day that was never punched at all. */
@Column({ type: "uuid", name: "attendance_record_id", nullable: true })
attendanceRecordId?: string | null;
@Column({ type: "date", name: "work_date" })
workDate!: string;
@Column({ type: "timestamptz", name: "requested_check_in", nullable: true })
requestedCheckIn?: Date | null;
@Column({ type: "timestamptz", name: "requested_check_out", nullable: true })
requestedCheckOut?: Date | null;
/** Captured at approval, not at submission — the record may change meanwhile. */
@Column({ type: "timestamptz", name: "original_check_in", nullable: true })
originalCheckIn?: Date | null;
@Column({ type: "timestamptz", name: "original_check_out", nullable: true })
originalCheckOut?: Date | null;
@Column({
type: "varchar",
length: 16,
name: "original_status",
nullable: true,
})
originalStatus?: EAttendanceStatus | null;
@Column({ type: "varchar", length: 512, name: "reason" })
reason!: string;
@Column({
type: "varchar",
length: 16,
name: "status",
default: ERegularizationStatus.SUBMITTED,
})
status!: ERegularizationStatus;
@Column({ type: "uuid", name: "approver_employee_id", nullable: true })
approverEmployeeId?: string | null;
@Column({ type: "uuid", name: "decided_by_employee_id", nullable: true })
decidedByEmployeeId?: string | null;
@Column({ type: "timestamptz", name: "decided_at", nullable: true })
decidedAt?: Date | null;
@Column({ type: "varchar", length: 512, name: "decision_note", nullable: true })
decisionNote?: string | null;
@Column({ type: "uuid", name: "created_by", nullable: true })
createdBy?: string | null;
@Column({ type: "uuid", name: "updated_by", nullable: true })
updatedBy?: string | null;
}

View File

@@ -0,0 +1,135 @@
import { SoftDeleteAudit } from "@tria-plc/api-common/modules/typeorm/audit.entity";
import { Column, Entity, Index, PrimaryGeneratedColumn } from "typeorm";
/**
* Which premium applies. Derived from when the work happened, never chosen by
* the person claiming it — the category decides the money.
*/
export enum EOvertimeCategory {
/** Ordinary overtime, 06:0022:00 on a normal working day. */
DAY = "DAY",
/** Night work, 22:0006:00. */
NIGHT = "NIGHT",
/** The weekly rest day. */
REST_DAY = "REST_DAY",
PUBLIC_HOLIDAY = "PUBLIC_HOLIDAY",
}
export enum EOvertimeStatus {
SUBMITTED = "SUBMITTED",
APPROVED = "APPROVED",
REJECTED = "REJECTED",
WITHDRAWN = "WITHDRAWN",
}
/**
* The premium paid for each category, per organization.
*
* Proclamation 1156/2019 Art. 68 sets the statutory floor — 1.5× ordinary,
* 1.75× night, 2× on the weekly rest day, 2.5× on a public holiday. An employer
* may pay more, never less, which is why these are rows an employer can raise
* rather than constants only a developer can change.
*/
@Entity({ schema: "hr", name: "overtime_rates" })
@Index("idx_overtime_rates_organization_id", ["organizationId"])
export class OvertimeRate extends SoftDeleteAudit {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid", name: "organization_id" })
organizationId!: string;
@Column({ type: "varchar", length: 24, name: "category" })
category!: EOvertimeCategory;
@Column({ type: "numeric", precision: 5, scale: 2, name: "multiplier" })
multiplier!: string;
/** Art. 67 caps ordinary overtime at two hours a day. Null = uncapped. */
@Column({
type: "numeric",
precision: 5,
scale: 2,
name: "max_hours_per_day",
nullable: true,
})
maxHoursPerDay?: string | null;
@Column({ type: "varchar", length: 64, name: "statute_reference", nullable: true })
statuteReference?: string | null;
@Column({ type: "uuid", name: "created_by", nullable: true })
createdBy?: string | null;
@Column({ type: "uuid", name: "updated_by", nullable: true })
updatedBy?: string | null;
}
/**
* A claim for extra hours.
*
* `category` and `multiplier` are both frozen on the row. The category is
* derived at submission from when the work happened; the multiplier is copied
* from the rate table at that moment. A rate rise next year must not silently
* reprice work already done — and, worse, already paid.
*/
@Entity({ schema: "hr", name: "overtime_requests" })
@Index("idx_overtime_requests_employee", ["employeeId", "workDate"])
export class OvertimeRequest extends SoftDeleteAudit {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid", name: "organization_id" })
organizationId!: string;
@Column({ type: "uuid", name: "employee_id" })
employeeId!: string;
/** The day the work belongs to — night work belongs to the day it began. */
@Column({ type: "date", name: "work_date" })
workDate!: string;
@Column({ type: "timestamptz", name: "started_at" })
startedAt!: Date;
@Column({ type: "timestamptz", name: "ended_at" })
endedAt!: Date;
@Column({ type: "numeric", precision: 6, scale: 2, name: "hours" })
hours!: string;
@Column({ type: "varchar", length: 24, name: "category" })
category!: EOvertimeCategory;
@Column({ type: "numeric", precision: 5, scale: 2, name: "multiplier" })
multiplier!: string;
@Column({
type: "varchar",
length: 16,
name: "status",
default: EOvertimeStatus.SUBMITTED,
})
status!: EOvertimeStatus;
@Column({ type: "varchar", length: 512, name: "reason", nullable: true })
reason?: string | null;
@Column({ type: "uuid", name: "approver_employee_id", nullable: true })
approverEmployeeId?: string | null;
@Column({ type: "uuid", name: "decided_by_employee_id", nullable: true })
decidedByEmployeeId?: string | null;
@Column({ type: "timestamptz", name: "decided_at", nullable: true })
decidedAt?: Date | null;
@Column({ type: "varchar", length: 512, name: "decision_note", nullable: true })
decisionNote?: string | null;
@Column({ type: "uuid", name: "created_by", nullable: true })
createdBy?: string | null;
@Column({ type: "uuid", name: "updated_by", nullable: true })
updatedBy?: string | null;
}

View File

@@ -0,0 +1,52 @@
import { SoftDeleteAudit } from "@tria-plc/api-common/modules/typeorm/audit.entity";
import {
Column,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from "typeorm";
import { WorkSchedule } from "./work-schedule.entity";
/**
* Who is on which shift, and since when.
*
* Dated rather than a plain column on the profile, because attendance already
* recorded must keep being judged against the schedule that applied on the day.
* Moving someone to a later shift should not retroactively make last month's
* punctual arrivals late.
*/
@Entity({ schema: "hr", name: "work_schedule_assignments" })
@Index("idx_schedule_assignments_employee", ["employeeId", "effectiveFrom"])
export class WorkScheduleAssignment extends SoftDeleteAudit {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid", name: "organization_id" })
organizationId!: string;
@Column({ type: "uuid", name: "employee_id" })
employeeId!: string;
@Column({ type: "uuid", name: "work_schedule_id" })
workScheduleId!: string;
@ManyToOne(() => WorkSchedule)
@JoinColumn({ name: "work_schedule_id" })
workSchedule?: WorkSchedule;
@Column({ type: "date", name: "effective_from" })
effectiveFrom!: string;
/** Null = current. Only one open assignment per employee. */
@Column({ type: "date", name: "effective_to", nullable: true })
effectiveTo?: string | null;
@Column({ type: "uuid", name: "created_by", nullable: true })
createdBy?: string | null;
@Column({ type: "uuid", name: "updated_by", nullable: true })
updatedBy?: string | null;
}

View File

@@ -0,0 +1,72 @@
import { SoftDeleteAudit } from "@tria-plc/api-common/modules/typeorm/audit.entity";
import { Column, Entity, Index, PrimaryGeneratedColumn } from "typeorm";
/**
* A shift pattern.
*
* Proclamation 1156/2019 Art. 61 caps normal hours at eight a day and 48 a week;
* the defaults here (08:3017:30 with an hour's break) are one common way to sit
* inside that, not the only one. Every threshold is a column because employers
* differ and none of this is the developer's decision.
*/
@Entity({ schema: "hr", name: "work_schedules" })
@Index("idx_work_schedules_organization_id", ["organizationId"])
export class WorkSchedule extends SoftDeleteAudit {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid", name: "organization_id" })
organizationId!: string;
@Column({ type: "varchar", length: 32, name: "code" })
code!: string;
@Column({ type: "jsonb", name: "name" })
name!: { am: string; en: string };
/** Wall-clock, no date and no zone — `HH:MM:SS`. */
@Column({ type: "time", name: "start_time", default: "08:30" })
startTime!: string;
@Column({ type: "time", name: "end_time", default: "17:30" })
endTime!: string;
@Column({ type: "smallint", name: "break_minutes", default: 60 })
breakMinutes!: number;
/** How late someone can arrive before it counts as late. */
@Column({ type: "smallint", name: "grace_period_minutes", default: 10 })
gracePeriodMinutes!: number;
/**
* Days this shift is worked, `0` = Sunday. `null` falls back to the
* organization's `leave_settings.weekend_days`, so the two cannot drift for
* the ordinary case where a shift simply follows the company week.
*/
@Column({ type: "smallint", array: true, name: "working_days", nullable: true })
workingDays?: number[] | null;
/** Minutes actually worked before the day counts as full. Default 7 hours. */
@Column({ type: "smallint", name: "min_minutes_full_day", default: 420 })
minMinutesFullDay!: number;
@Column({ type: "smallint", name: "min_minutes_half_day", default: 210 })
minMinutesHalfDay!: number;
/** A night shift ending the following morning. Changes duration arithmetic. */
@Column({ type: "boolean", name: "crosses_midnight", default: false })
crossesMidnight!: boolean;
/** Applies to anyone with no explicit assignment. One per organization. */
@Column({ type: "boolean", name: "is_default", default: false })
isDefault!: boolean;
@Column({ type: "boolean", name: "is_active", default: true })
isActive!: boolean;
@Column({ type: "uuid", name: "created_by", nullable: true })
createdBy?: string | null;
@Column({ type: "uuid", name: "updated_by", nullable: true })
updatedBy?: string | null;
}

View File

@@ -0,0 +1,112 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { BaseRepository } from "@edr/api-common";
import { Repository } from "typeorm";
import {
AttendanceRecord,
EAttendanceStatus,
} from "../entities/attendance-record.entity";
@Injectable()
export class AttendanceRepository extends BaseRepository<AttendanceRecord> {
constructor(
@InjectRepository(AttendanceRecord) repository: Repository<AttendanceRecord>,
) {
super(repository);
}
findForDate(
employeeId: string,
workDate: string,
): Promise<AttendanceRecord | null> {
return this.repository.findOne({
where: { employeeId, workDate },
relations: { workSchedule: true },
});
}
findRange(
employeeId: string,
from: string,
to: string,
): Promise<AttendanceRecord[]> {
return this.repository
.createQueryBuilder("record")
.leftJoinAndSelect("record.workSchedule", "schedule")
.where("record.employee_id = :employeeId", { employeeId })
.andWhere("record.work_date BETWEEN :from AND :to", { from, to })
.orderBy("record.workDate", "DESC")
.getMany();
}
async findPage(filters: {
organizationId?: string | null;
employeeId?: string;
status?: EAttendanceStatus;
from?: string;
to?: string;
page?: number;
limit?: number;
}): Promise<[AttendanceRecord[], number]> {
const page = filters.page ?? 1;
const limit = filters.limit ?? 25;
const qb = this.repository
.createQueryBuilder("record")
.leftJoinAndSelect("record.workSchedule", "schedule");
if (filters.organizationId) {
qb.andWhere("record.organization_id = :organizationId", {
organizationId: filters.organizationId,
});
}
if (filters.employeeId) {
qb.andWhere("record.employee_id = :employeeId", {
employeeId: filters.employeeId,
});
}
if (filters.status) {
qb.andWhere("record.status = :status", { status: filters.status });
}
if (filters.from) {
qb.andWhere("record.work_date >= :from", { from: filters.from });
}
if (filters.to) {
qb.andWhere("record.work_date <= :to", { to: filters.to });
}
// Property names, not columns — orderBy resolves through entity metadata and
// a snake_case name throws once a join is present.
return qb
.orderBy("record.workDate", "DESC")
.skip((page - 1) * limit)
.take(limit)
.getManyAndCount();
}
/** Counts by status for a range — the summary strip on the attendance page. */
async summarize(
organizationId: string | null,
employeeId: string | undefined,
from: string,
to: string,
): Promise<Record<string, number>> {
const qb = this.repository
.createQueryBuilder("record")
.select("record.status", "status")
.addSelect("COUNT(*)", "count")
.where("record.work_date BETWEEN :from AND :to", { from, to })
.groupBy("record.status");
if (organizationId) {
qb.andWhere("record.organization_id = :organizationId", { organizationId });
}
if (employeeId) {
qb.andWhere("record.employee_id = :employeeId", { employeeId });
}
const rows = await qb.getRawMany<{ status: string; count: string }>();
return Object.fromEntries(rows.map((row) => [row.status, Number(row.count)]));
}
}

View File

@@ -0,0 +1,94 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { BaseRepository } from "@edr/api-common";
import { IsNull, LessThanOrEqual, Repository } from "typeorm";
import { WorkSchedule } from "../entities/work-schedule.entity";
import { WorkScheduleAssignment } from "../entities/work-schedule-assignment.entity";
@Injectable()
export class WorkSchedulesRepository extends BaseRepository<WorkSchedule> {
constructor(@InjectRepository(WorkSchedule) repository: Repository<WorkSchedule>) {
super(repository);
}
findByCode(organizationId: string, code: string): Promise<WorkSchedule | null> {
return this.repository.findOne({ where: { organizationId, code } });
}
findDefault(organizationId: string): Promise<WorkSchedule | null> {
return this.repository.findOne({
where: { organizationId, isDefault: true, isActive: true },
});
}
findAllFor(organizationId: string | null): Promise<WorkSchedule[]> {
return this.repository.find({
where: organizationId ? { organizationId } : {},
order: { code: "ASC" },
});
}
/** Clear the current default before setting a new one — the index allows one. */
async clearDefault(organizationId: string): Promise<void> {
await this.repository.update({ organizationId, isDefault: true }, {
isDefault: false,
});
}
}
@Injectable()
export class WorkScheduleAssignmentsRepository extends BaseRepository<WorkScheduleAssignment> {
constructor(
@InjectRepository(WorkScheduleAssignment)
repository: Repository<WorkScheduleAssignment>,
) {
super(repository);
}
/**
* The assignment in force on a date.
*
* Ordered newest-first so a re-assignment supersedes an older open one even if
* the older was never closed — the unique index prevents that, but a query
* that depends on data being clean is a query that breaks when it is not.
*/
async findOnDate(
employeeId: string,
date: string,
): Promise<WorkScheduleAssignment | null> {
return this.repository
.createQueryBuilder("assignment")
.leftJoinAndSelect("assignment.workSchedule", "schedule")
.where("assignment.employee_id = :employeeId", { employeeId })
.andWhere("assignment.effective_from <= :date", { date })
.andWhere(
"(assignment.effective_to IS NULL OR assignment.effective_to >= :date)",
{ date },
)
.orderBy("assignment.effectiveFrom", "DESC")
.getOne();
}
findOpenFor(employeeId: string): Promise<WorkScheduleAssignment | null> {
return this.repository.findOne({
where: { employeeId, effectiveTo: IsNull() },
relations: { workSchedule: true },
});
}
/** How many employees are currently on a schedule — the delete guard. */
findOpenForSchedule(workScheduleId: string): Promise<number> {
return this.repository.count({
where: { workScheduleId, effectiveTo: IsNull() },
});
}
findHistory(employeeId: string): Promise<WorkScheduleAssignment[]> {
return this.repository.find({
where: { employeeId, effectiveFrom: LessThanOrEqual("9999-12-31") },
relations: { workSchedule: true },
order: { effectiveFrom: "DESC" },
});
}
}

View File

@@ -0,0 +1,211 @@
import { Injectable } from "@nestjs/common";
import { InjectDataSource } from "@nestjs/typeorm";
import { DataSource } from "typeorm";
import {
WorkingDaysService,
dayOfWeek,
} from "../../leave/services/working-days.service";
import { LeaveSettingsService } from "../../leave/services/leave-settings.service";
import { WorkSchedule } from "../entities/work-schedule.entity";
import { EAttendanceStatus } from "../entities/attendance-record.entity";
/** Why a day is not workable, when it is not. */
export interface DayContext {
workDate: string;
isWorkingDay: boolean;
isHoliday: boolean;
holidayName: string | null;
/** The approved leave covering this date, if any. */
leave: { requestId: string; leaveTypeCode: string; isHalfDay: boolean } | null;
}
const MINUTES = 60_000;
/** `HH:MM[:SS]` on a date, as an instant. Times are wall-clock; dates are dates. */
export const atTime = (workDate: string, time: string): Date =>
new Date(`${workDate}T${time.length === 5 ? `${time}:00` : time}`);
@Injectable()
export class AttendanceDayService {
constructor(
@InjectDataSource() private readonly dataSource: DataSource,
private readonly settingsService: LeaveSettingsService,
) {}
/**
* What kind of day this is for this employee.
*
* Attendance cannot be judged without it: the same absence is a rest day, an
* excused leave day or an unexplained absence depending on facts that live in
* three different places. Getting this wrong in the direction of ABSENT is the
* expensive one — it turns a legitimate holiday into a disciplinary record.
*/
async contextFor(
employeeId: string,
organizationId: string,
workDate: string,
schedule: WorkSchedule | null,
): Promise<DayContext> {
const settings = await this.settingsService.resolve(organizationId);
// A schedule may name its own working days; otherwise the company week
// applies, so the two cannot drift for the ordinary case.
const nonWorking = schedule?.workingDays
? [0, 1, 2, 3, 4, 5, 6].filter((day) => !schedule.workingDays!.includes(day))
: settings.weekendDays;
const [holiday] = await this.dataSource.query<
{ name: { en: string; am: string } }[]
>(
`SELECT "name" FROM hr.holidays
WHERE "deleted_at" IS NULL
AND "is_working_day" = false
AND "observed_on" = $1::date
AND ("organization_id" IS NULL OR "organization_id" = $2::uuid)
LIMIT 1`,
[workDate, organizationId],
);
// Only APPROVED leave excuses an absence. A submitted request has not been
// agreed to, and treating it as cover would let anyone excuse themselves by
// filing a request nobody ever looks at.
const [leave] = await this.dataSource.query<
{ requestId: string; leaveTypeCode: string; isHalfDay: boolean }[]
>(
`SELECT r."id" AS "requestId", t."code" AS "leaveTypeCode",
r."is_half_day" AS "isHalfDay"
FROM hr.leave_requests r
JOIN hr.leave_types t ON t."id" = r."leave_type_id"
WHERE r."deleted_at" IS NULL
AND r."employee_id" = $1::uuid
AND r."status" = 'APPROVED'
AND $2::date BETWEEN r."start_date" AND r."end_date"
LIMIT 1`,
[employeeId, workDate],
);
return {
workDate,
isWorkingDay: !nonWorking.includes(dayOfWeek(workDate)),
isHoliday: Boolean(holiday),
holidayName: holiday?.name?.en ?? null,
leave: leave ?? null,
};
}
/**
* The status of a day nobody punched for.
*
* Order matters and is not arbitrary. Leave wins over a holiday: somebody on a
* fortnight's annual leave that contains Meskel has that day charged against
* their balance by the leave module, and calling it HOLIDAY here would make
* attendance and the leave ledger tell different stories about the same day.
*/
static statusForUnworkedDay(context: DayContext): {
status: EAttendanceStatus;
leaveRequestId: string | null;
notes: string | null;
} {
if (context.leave) {
return {
status: EAttendanceStatus.ON_LEAVE,
leaveRequestId: context.leave.requestId,
notes: `${context.leave.leaveTypeCode} leave`,
};
}
if (context.isHoliday) {
return {
status: EAttendanceStatus.HOLIDAY,
leaveRequestId: null,
notes: context.holidayName,
};
}
if (!context.isWorkingDay) {
return {
status: EAttendanceStatus.REST_DAY,
leaveRequestId: null,
notes: null,
};
}
return { status: EAttendanceStatus.ABSENT, leaveRequestId: null, notes: null };
}
/**
* Minutes and lateness from a pair of punches.
*
* The break is deducted only from a day long enough to have taken one —
* subtracting an hour's lunch from a two-hour morning would report negative
* work, and clamping that to zero would silently erase the two hours.
*/
static measure(
schedule: WorkSchedule | null,
workDate: string,
checkIn: Date,
checkOut: Date | null,
): { workedMinutes: number; lateMinutes: number; earlyLeaveMinutes: number } {
if (!checkOut) {
return { workedMinutes: 0, lateMinutes: 0, earlyLeaveMinutes: 0 };
}
const gross = Math.max(
0,
Math.round((checkOut.getTime() - checkIn.getTime()) / MINUTES),
);
if (!schedule) {
return { workedMinutes: gross, lateMinutes: 0, earlyLeaveMinutes: 0 };
}
const breakMinutes = gross > schedule.breakMinutes ? schedule.breakMinutes : 0;
const workedMinutes = gross - breakMinutes;
const scheduledStart = atTime(workDate, schedule.startTime);
const scheduledEnd = schedule.crossesMidnight
? new Date(atTime(workDate, schedule.endTime).getTime() + 86_400_000)
: atTime(workDate, schedule.endTime);
const lateBy = Math.round(
(checkIn.getTime() - scheduledStart.getTime()) / MINUTES,
);
// Grace is all-or-nothing: inside it, nothing is recorded. Recording the
// minutes anyway would make the grace period cosmetic, and someone would
// eventually report on the raw figure.
const lateMinutes = lateBy > schedule.gracePeriodMinutes ? lateBy : 0;
const earlyBy = Math.round(
(scheduledEnd.getTime() - checkOut.getTime()) / MINUTES,
);
const earlyLeaveMinutes = earlyBy > 0 ? earlyBy : 0;
return { workedMinutes, lateMinutes, earlyLeaveMinutes };
}
/** PRESENT, LATE or HALF_DAY, from what was actually worked. */
static statusForWorkedDay(
schedule: WorkSchedule | null,
measured: { workedMinutes: number; lateMinutes: number },
): EAttendanceStatus {
if (schedule && measured.workedMinutes < schedule.minMinutesHalfDay) {
// Under even the half-day threshold. Not marked ABSENT — they were here,
// and a record showing a short day is more useful than one denying it.
return EAttendanceStatus.HALF_DAY;
}
if (schedule && measured.workedMinutes < schedule.minMinutesFullDay) {
return EAttendanceStatus.HALF_DAY;
}
return measured.lateMinutes > 0
? EAttendanceStatus.LATE
: EAttendanceStatus.PRESENT;
}
/** Every date in a range, inclusive — for the daily backfill. */
static datesBetween(start: string, end: string): string[] {
const dates: string[] = [];
const total = WorkingDaysService.countCalendarDays({ start, end });
for (let index = 0; index < total; index += 1) {
const cursor = new Date(Date.parse(`${start}T00:00:00Z`) + index * 86_400_000);
dates.push(cursor.toISOString().slice(0, 10));
}
return dates;
}
}

View File

@@ -0,0 +1,434 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { ActorContext, orgScope } from "../../employees/employees.service";
import { EmployeesRepository } from "../../employees/employees.repository";
import { IamDirectoryService } from "../../../iam-directory/iam-directory.service";
import { Paginated, paginate } from "../../../common/pagination.dto";
import { AttendanceRepository } from "../repositories/attendance.repository";
import {
AttendanceRecord,
EAttendanceSource,
EAttendanceStatus,
} from "../entities/attendance-record.entity";
import { AttendanceDayService } from "./attendance-day.service";
import { WorkSchedulesService } from "./work-schedules.service";
import { RecordAttendanceDto } from "../dto/attendance.dto";
import { daysBetween } from "../../leave/services/working-days.service";
/** Backfilling more than this in one call is a job, not a request. */
const MAX_BACKFILL_DAYS = 92;
@Injectable()
export class AttendanceService {
constructor(
private readonly attendance: AttendanceRepository,
private readonly schedules: WorkSchedulesService,
private readonly days: AttendanceDayService,
private readonly employees: EmployeesRepository,
private readonly iamDirectory: IamDirectoryService,
) {}
/**
* Clock in.
*
* Refuses a second check-in for the same day rather than overwriting the
* first: the earliest punch is the one that decides lateness, and letting a
* later one replace it would quietly erase a late arrival.
*/
async checkIn(
employeeId: string,
at: Date,
source: EAttendanceSource,
actor: ActorContext,
notes?: string,
): Promise<AttendanceRecord> {
const { organizationId, workDate } = await this.resolveContext(
employeeId,
at,
actor,
);
const existing = await this.attendance.findForDate(employeeId, workDate);
if (existing?.checkIn) {
throw new ConflictException(
`Already checked in at ${existing.checkIn.toISOString()}. Use a ` +
"regularization to correct it.",
);
}
const schedule = await this.schedules.resolveFor(
employeeId,
organizationId,
workDate,
);
const context = await this.days.contextFor(
employeeId,
organizationId,
workDate,
schedule,
);
// Working on a rest day or a holiday is allowed and recorded — it is how
// overtime on those days gets evidenced (3.3 slice 2). What is NOT allowed
// is punching in on a day already covered by approved leave: one of the two
// records is wrong, and silently accepting both leaves payroll to guess.
if (context.leave && !context.leave.isHalfDay) {
throw new ConflictException(
`${workDate} is covered by approved ${context.leave.leaveTypeCode} ` +
"leave. Cancel the leave first if they are in fact working.",
);
}
const measured = AttendanceDayService.measure(schedule, workDate, at, null);
if (existing) {
return (
(await this.attendance.update(existing.id, {
checkIn: at,
source,
notes: notes ?? existing.notes,
status: EAttendanceStatus.PRESENT,
lateMinutes: 0,
updatedBy: actor.userId,
})) ?? existing
);
}
return this.attendance.create({
organizationId,
employeeId,
workDate,
workScheduleId: schedule?.id ?? null,
checkIn: at,
source,
// Not final: lateness needs the schedule's start time, and the status is
// recomputed on check-out from what was actually worked.
status: EAttendanceStatus.PRESENT,
lateMinutes: measured.lateMinutes,
notes: notes ?? null,
createdBy: actor.userId,
} as Partial<AttendanceRecord>);
}
/** Clock out, and settle the day's numbers. */
async checkOut(
employeeId: string,
at: Date,
source: EAttendanceSource,
actor: ActorContext,
notes?: string,
): Promise<AttendanceRecord> {
const { workDate } = await this.resolveContext(employeeId, at, actor);
// A night shift clocks out on the following calendar day, so an open record
// from yesterday is the one being closed.
const record =
(await this.attendance.findForDate(employeeId, workDate)) ??
(await this.findOpenPreviousDay(employeeId, workDate));
if (!record?.checkIn) {
throw new BadRequestException(
"No check-in to close. Record the arrival first, or raise a " +
"regularization if it was missed.",
);
}
if (record.checkOut) {
throw new ConflictException(
`Already checked out at ${record.checkOut.toISOString()}.`,
);
}
if (at < record.checkIn) {
throw new BadRequestException("Check-out cannot be before check-in.");
}
const schedule = record.workSchedule ?? null;
const measured = AttendanceDayService.measure(
schedule,
record.workDate,
record.checkIn,
at,
);
return (
(await this.attendance.update(record.id, {
checkOut: at,
source,
notes: notes ?? record.notes,
workedMinutes: measured.workedMinutes,
lateMinutes: measured.lateMinutes,
earlyLeaveMinutes: measured.earlyLeaveMinutes,
status: AttendanceDayService.statusForWorkedDay(schedule, measured),
updatedBy: actor.userId,
})) ?? record
);
}
/**
* Record a day by hand — an administrator filling a gap.
*
* Kept separate from check-in/check-out because it is a different act: those
* assert "this is happening now", this asserts "this is what happened". The
* source records which, so a report can distinguish observed attendance from
* asserted attendance.
*/
async recordManually(
dto: RecordAttendanceDto,
actor: ActorContext,
): Promise<AttendanceRecord> {
const employee = await this.iamDirectory.requireEmployee(dto.employeeId);
const organizationId = employee.organizationId;
if (!organizationId) {
throw new BadRequestException(
`Cannot resolve an organization for employee ${dto.employeeId}`,
);
}
this.assertInScope(organizationId, actor);
const checkIn = dto.checkIn ? new Date(dto.checkIn) : null;
const checkOut = dto.checkOut ? new Date(dto.checkOut) : null;
if (checkIn && checkOut && checkOut < checkIn) {
throw new BadRequestException("checkOut cannot be before checkIn.");
}
const schedule = await this.schedules.resolveFor(
dto.employeeId,
organizationId,
dto.workDate,
);
const context = await this.days.contextFor(
dto.employeeId,
organizationId,
dto.workDate,
schedule,
);
let status: EAttendanceStatus;
let leaveRequestId: string | null = null;
let measured = { workedMinutes: 0, lateMinutes: 0, earlyLeaveMinutes: 0 };
if (checkIn) {
measured = AttendanceDayService.measure(
schedule,
dto.workDate,
checkIn,
checkOut,
);
status =
dto.status ?? AttendanceDayService.statusForWorkedDay(schedule, measured);
} else {
const derived = AttendanceDayService.statusForUnworkedDay(context);
status = dto.status ?? derived.status;
leaveRequestId = derived.leaveRequestId;
}
// ON_LEAVE without the leave to back it is refused by a CHECK constraint;
// catching it here says why instead of surfacing a constraint name.
if (status === EAttendanceStatus.ON_LEAVE && !leaveRequestId) {
throw new BadRequestException(
`No approved leave covers ${dto.workDate}, so this day cannot be marked ` +
"ON_LEAVE. Approve the leave request first.",
);
}
const existing = await this.attendance.findForDate(
dto.employeeId,
dto.workDate,
);
const payload = {
organizationId,
employeeId: dto.employeeId,
workDate: dto.workDate,
workScheduleId: schedule?.id ?? null,
checkIn,
checkOut,
status,
leaveRequestId,
...measured,
source: EAttendanceSource.MANUAL,
notes: dto.notes ?? null,
};
if (existing) {
return (
(await this.attendance.update(existing.id, {
...payload,
updatedBy: actor.userId,
})) ?? existing
);
}
return this.attendance.create({
...payload,
createdBy: actor.userId,
} as Partial<AttendanceRecord>);
}
/**
* Fill in the days nobody punched for.
*
* Only creates what is missing — an existing record is never touched, because
* this runs repeatedly and must not overwrite a punch or a correction. Days
* are classified as rest, holiday, leave or absence.
*/
async backfill(
employeeId: string,
from: string,
to: string,
actor: ActorContext,
): Promise<{ created: number; skipped: number; byStatus: Record<string, number> }> {
if (daysBetween(from, to) < 0) {
throw new BadRequestException("`to` cannot be before `from`.");
}
if (daysBetween(from, to) > MAX_BACKFILL_DAYS) {
throw new BadRequestException(
`Backfilling is limited to ${MAX_BACKFILL_DAYS} days at a time.`,
);
}
const employee = await this.iamDirectory.requireEmployee(employeeId);
const organizationId = employee.organizationId;
if (!organizationId) {
throw new BadRequestException(
`Cannot resolve an organization for employee ${employeeId}`,
);
}
this.assertInScope(organizationId, actor);
const profile = await this.employees.findByEmployeeId(employeeId);
if (!profile) {
throw new NotFoundException(
`${employeeId} has no HR profile, so there is nothing to attend.`,
);
}
const existing = await this.attendance.findRange(employeeId, from, to);
const have = new Set(existing.map((record) => record.workDate));
const byStatus: Record<string, number> = {};
let created = 0;
for (const workDate of AttendanceDayService.datesBetween(from, to)) {
if (have.has(workDate)) continue;
// Nothing before they joined — those days are not absences, they are days
// this person did not work here.
if (daysBetween(profile.hireDate, workDate) < 0) continue;
const schedule = await this.schedules.resolveFor(
employeeId,
organizationId,
workDate,
);
const context = await this.days.contextFor(
employeeId,
organizationId,
workDate,
schedule,
);
const derived = AttendanceDayService.statusForUnworkedDay(context);
await this.attendance.create({
organizationId,
employeeId,
workDate,
workScheduleId: schedule?.id ?? null,
status: derived.status,
leaveRequestId: derived.leaveRequestId,
notes: derived.notes,
source: EAttendanceSource.SYSTEM,
createdBy: actor.userId,
} as Partial<AttendanceRecord>);
byStatus[derived.status] = (byStatus[derived.status] ?? 0) + 1;
created += 1;
}
return { created, skipped: have.size, byStatus };
}
findRange(employeeId: string, from: string, to: string) {
return this.attendance.findRange(employeeId, from, to);
}
async findAll(
filters: {
employeeId?: string;
status?: EAttendanceStatus;
from?: string;
to?: string;
page?: number;
limit?: number;
},
actor: ActorContext,
): Promise<Paginated<AttendanceRecord>> {
const [items, total] = await this.attendance.findPage({
...filters,
organizationId: orgScope(actor),
});
return paginate(items, total, filters.page ?? 1, filters.limit ?? 25);
}
summary(
from: string,
to: string,
actor: ActorContext,
employeeId?: string,
): Promise<Record<string, number>> {
return this.attendance.summarize(orgScope(actor), employeeId, from, to);
}
/** Today's record for one employee — what the clock widget shows. */
async today(employeeId: string, at: Date): Promise<AttendanceRecord | null> {
return this.attendance.findForDate(employeeId, at.toISOString().slice(0, 10));
}
// ────────────────────────────────────────────────────────────────────────
private async resolveContext(
employeeId: string,
at: Date,
actor: ActorContext,
): Promise<{ organizationId: string; workDate: string }> {
const employee = await this.iamDirectory.requireEmployee(employeeId);
if (!employee.organizationId) {
throw new BadRequestException(
`Cannot resolve an organization for employee ${employeeId}`,
);
}
this.assertInScope(employee.organizationId, actor);
return {
organizationId: employee.organizationId,
workDate: at.toISOString().slice(0, 10),
};
}
/**
* An open record from the previous day, for a shift that ran past midnight.
* Only the immediately preceding day: anything older is a missed check-out
* and belongs in a regularization, not silently closed days later.
*/
private async findOpenPreviousDay(
employeeId: string,
workDate: string,
): Promise<AttendanceRecord | null> {
const previous = await this.attendance.findForDate(
employeeId,
new Date(Date.parse(`${workDate}T00:00:00Z`) - 86_400_000)
.toISOString()
.slice(0, 10),
);
if (!previous?.checkIn || previous.checkOut) return null;
return previous.workSchedule?.crossesMidnight ? previous : null;
}
private assertInScope(organizationId: string, actor: ActorContext): void {
const scope = orgScope(actor);
if (scope && organizationId !== scope) {
throw new NotFoundException("Employee not found");
}
}
}

View File

@@ -0,0 +1,510 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { ActorContext, orgScope } from "../../employees/employees.service";
import { EmployeesRepository } from "../../employees/employees.repository";
import { IamDirectoryService } from "../../../iam-directory/iam-directory.service";
import { Paginated, paginate } from "../../../common/pagination.dto";
import {
EOvertimeCategory,
EOvertimeStatus,
OvertimeRate,
OvertimeRequest,
} from "../entities/overtime.entity";
import { AttendanceDayService } from "./attendance-day.service";
import { WorkSchedulesService } from "./work-schedules.service";
import { STATUTORY_OVERTIME_RATES } from "../statutory-overtime-rates";
import { addDays } from "../../leave/services/working-days.service";
import { CreateOvertimeDto } from "../dto/overtime.dto";
/** Art. 67 ceilings, applied across requests rather than per request. */
const MAX_HOURS_PER_MONTH = 20;
const MAX_HOURS_PER_YEAR = 100;
/** Night work under Art. 68(1)(b). */
const NIGHT_STARTS_HOUR = 22;
const NIGHT_ENDS_HOUR = 6;
@Injectable()
export class OvertimeService {
constructor(
@InjectRepository(OvertimeRate)
private readonly rates: Repository<OvertimeRate>,
@InjectRepository(OvertimeRequest)
private readonly requests: Repository<OvertimeRequest>,
private readonly schedules: WorkSchedulesService,
private readonly days: AttendanceDayService,
private readonly employees: EmployeesRepository,
private readonly iamDirectory: IamDirectoryService,
) {}
/**
* Seed the statutory premiums. Idempotent by category, and an existing rate is
* never lowered back to the statutory floor — an employer who pays 2× for
* night work keeps paying it.
*/
async seedStatutory(
actor: ActorContext,
): Promise<{ created: string[]; skipped: string[] }> {
const organizationId = this.requireOrg(actor);
const created: string[] = [];
const skipped: string[] = [];
for (const seed of STATUTORY_OVERTIME_RATES) {
const existing = await this.rates.findOne({
where: { organizationId, category: seed.category },
});
if (existing) {
skipped.push(seed.category);
continue;
}
await this.rates.save(
this.rates.create({
...seed,
organizationId,
createdBy: actor.userId,
} as Partial<OvertimeRate>),
);
created.push(seed.category);
}
return { created, skipped };
}
listRates(actor: ActorContext): Promise<OvertimeRate[]> {
const organizationId = orgScope(actor) ?? actor.organizationId;
return this.rates.find({
where: organizationId ? { organizationId } : {},
order: { category: "ASC" },
});
}
async updateRate(
id: string,
dto: { multiplier?: string; maxHoursPerDay?: string | null },
actor: ActorContext,
): Promise<OvertimeRate> {
const rate = await this.rates.findOne({ where: { id } });
if (!rate) throw new NotFoundException(`Overtime rate ${id} not found`);
const scope = orgScope(actor);
if (scope && rate.organizationId !== scope) {
throw new NotFoundException(`Overtime rate ${id} not found`);
}
// The statute is a floor. Refusing to go below it here is cheaper than
// discovering it in a labour dispute.
const statutory = STATUTORY_OVERTIME_RATES.find(
(seed) => seed.category === rate.category,
);
if (dto.multiplier && statutory && Number(dto.multiplier) < Number(statutory.multiplier)) {
throw new BadRequestException(
`${rate.category} overtime cannot be paid below ${statutory.multiplier}× ` +
`— that is the statutory minimum (${statutory.statuteReference}).`,
);
}
await this.rates.update(id, { ...dto, updatedBy: actor.userId });
return (await this.rates.findOne({ where: { id } })) ?? rate;
}
/**
* Which premium applies to a stretch of work.
*
* A public holiday beats a rest day, which beats night, which beats ordinary
* overtime — most generous first, because a shift can satisfy several and the
* employee is owed the highest that applies.
*/
async categorize(
employeeId: string,
organizationId: string,
workDate: string,
startedAt: Date,
endedAt: Date,
): Promise<EOvertimeCategory> {
const schedule = await this.schedules.resolveFor(
employeeId,
organizationId,
workDate,
);
const context = await this.days.contextFor(
employeeId,
organizationId,
workDate,
schedule,
);
if (context.isHoliday) return EOvertimeCategory.PUBLIC_HOLIDAY;
if (!context.isWorkingDay) return EOvertimeCategory.REST_DAY;
return OvertimeService.touchesNight(startedAt, endedAt)
? EOvertimeCategory.NIGHT
: EOvertimeCategory.DAY;
}
/**
* Whether any part of the work falls in the night window.
*
* Any overlap counts as night, rather than splitting the claim in two. That is
* the employee-favourable reading and it keeps one stretch of work as one
* record; splitting would be defensible too, but it must then be done
* consistently in payroll, and one rule in one place is worth more than a
* marginally more precise rule in two.
*/
private static touchesNight(startedAt: Date, endedAt: Date): boolean {
const cursor = new Date(startedAt);
while (cursor < endedAt) {
const hour = cursor.getHours();
if (hour >= NIGHT_STARTS_HOUR || hour < NIGHT_ENDS_HOUR) return true;
cursor.setMinutes(cursor.getMinutes() + 30);
}
return false;
}
async create(
dto: CreateOvertimeDto,
actor: ActorContext,
): Promise<OvertimeRequest> {
const employeeId = dto.employeeId ?? actor.employeeId;
if (!employeeId) {
throw new BadRequestException(
"No employee to file this against — this account has no employee record.",
);
}
const employee = await this.iamDirectory.requireEmployee(employeeId);
const organizationId = employee.organizationId;
if (!organizationId) {
throw new BadRequestException(
`Cannot resolve an organization for employee ${employeeId}`,
);
}
const scope = orgScope(actor);
if (scope && organizationId !== scope) {
throw new NotFoundException(`Employee ${employeeId} not found`);
}
const startedAt = new Date(dto.startedAt);
const endedAt = new Date(dto.endedAt);
if (endedAt <= startedAt) {
throw new BadRequestException("endedAt must be after startedAt.");
}
const hours = Number(
((endedAt.getTime() - startedAt.getTime()) / 3_600_000).toFixed(2),
);
if (hours > 24) {
throw new BadRequestException("A single claim cannot exceed 24 hours.");
}
// The day the work belongs to — night work belongs to the day it began, so
// a shift starting 22:00 Monday is Monday's overtime, not Tuesday's.
const workDate = dto.workDate ?? startedAt.toISOString().slice(0, 10);
const category = await this.categorize(
employeeId,
organizationId,
workDate,
startedAt,
endedAt,
);
const rate = await this.rates.findOne({
where: { organizationId, category },
});
if (!rate) {
throw new BadRequestException(
`No ${category} overtime rate is configured for this organization. ` +
"Seed the statutory rates first.",
);
}
await this.assertWithinLimits(
employeeId,
workDate,
hours,
category,
rate,
);
const profile = await this.employees.findByEmployeeId(employeeId);
const approverEmployeeId =
profile?.managerEmployeeId ??
(await this.iamDirectory.findLineManagerEmployeeId(employeeId));
return this.requests.save(
this.requests.create({
organizationId,
employeeId,
workDate,
startedAt,
endedAt,
hours: hours.toFixed(2),
category,
// Frozen here: a rate change next year must not reprice this claim.
multiplier: rate.multiplier,
status: EOvertimeStatus.SUBMITTED,
reason: dto.reason ?? null,
approverEmployeeId,
createdBy: actor.userId,
} as Partial<OvertimeRequest>),
);
}
async approve(
id: string,
note: string | undefined,
actor: ActorContext,
): Promise<OvertimeRequest> {
const request = await this.requireRequest(id, actor);
this.assertPending(request);
if (request.employeeId === actor.employeeId) {
throw new ForbiddenException("You cannot approve your own overtime.");
}
// Re-checked at approval: other claims may have been approved since, and the
// monthly ceiling is about the total, not about any one request.
const rate = await this.rates.findOne({
where: { organizationId: request.organizationId, category: request.category },
});
await this.assertWithinLimits(
request.employeeId,
request.workDate,
Number(request.hours),
request.category,
rate ?? null,
request.id,
);
await this.requests.update(id, {
status: EOvertimeStatus.APPROVED,
decidedByEmployeeId: actor.employeeId ?? null,
decidedAt: new Date(),
decisionNote: note ?? null,
updatedBy: actor.userId,
});
return { ...request, status: EOvertimeStatus.APPROVED };
}
async reject(
id: string,
note: string,
actor: ActorContext,
): Promise<OvertimeRequest> {
const request = await this.requireRequest(id, actor);
this.assertPending(request);
await this.requests.update(id, {
status: EOvertimeStatus.REJECTED,
decidedByEmployeeId: actor.employeeId ?? null,
decidedAt: new Date(),
decisionNote: note,
updatedBy: actor.userId,
});
return { ...request, status: EOvertimeStatus.REJECTED };
}
async withdraw(id: string, actor: ActorContext): Promise<OvertimeRequest> {
const request = await this.requireRequest(id, actor);
this.assertPending(request);
await this.requests.update(id, {
status: EOvertimeStatus.WITHDRAWN,
updatedBy: actor.userId,
});
return { ...request, status: EOvertimeStatus.WITHDRAWN };
}
async findAll(
filters: {
employeeId?: string;
status?: EOvertimeStatus;
from?: string;
to?: string;
page?: number;
limit?: number;
},
actor: ActorContext,
): Promise<Paginated<OvertimeRequest>> {
const page = filters.page ?? 1;
const limit = filters.limit ?? 25;
const qb = this.requests.createQueryBuilder("request");
const scope = orgScope(actor);
if (scope) {
qb.andWhere("request.organization_id = :scope", { scope });
}
if (filters.employeeId) {
qb.andWhere("request.employee_id = :employeeId", {
employeeId: filters.employeeId,
});
}
if (filters.status) {
qb.andWhere("request.status = :status", { status: filters.status });
}
if (filters.from) qb.andWhere("request.work_date >= :from", { from: filters.from });
if (filters.to) qb.andWhere("request.work_date <= :to", { to: filters.to });
const [items, total] = await qb
.orderBy("request.workDate", "DESC")
.skip((page - 1) * limit)
.take(limit)
.getManyAndCount();
return paginate(items, total, page, limit);
}
async findAwaitingMe(
actor: ActorContext,
page = 1,
limit = 25,
): Promise<Paginated<OvertimeRequest>> {
if (!actor.employeeId) return paginate([], 0, page, limit);
const [items, total] = await this.requests.findAndCount({
where: {
approverEmployeeId: actor.employeeId,
status: EOvertimeStatus.SUBMITTED,
},
order: { workDate: "DESC" },
skip: (page - 1) * limit,
take: limit,
});
return paginate(items, total, page, limit);
}
/**
* Approved-hours totals for a period — what payroll multiplies.
* Grouped by category because each carries a different multiplier.
*/
async approvedTotals(
employeeId: string,
from: string,
to: string,
): Promise<{ category: string; hours: number; multiplier: number }[]> {
const rows = await this.requests
.createQueryBuilder("request")
.select("request.category", "category")
.addSelect("SUM(request.hours)", "hours")
.addSelect("MAX(request.multiplier)", "multiplier")
.where("request.employee_id = :employeeId", { employeeId })
.andWhere("request.status = :status", { status: EOvertimeStatus.APPROVED })
.andWhere("request.work_date BETWEEN :from AND :to", { from, to })
.groupBy("request.category")
.getRawMany<{ category: string; hours: string; multiplier: string }>();
return rows.map((row) => ({
category: row.category,
hours: Number(row.hours),
multiplier: Number(row.multiplier),
}));
}
// ────────────────────────────────────────────────────────────────────────
/** Art. 67: two hours a day on an ordinary day, 20 a month, 100 a year. */
private async assertWithinLimits(
employeeId: string,
workDate: string,
hours: number,
category: EOvertimeCategory,
rate: OvertimeRate | null,
excludeRequestId?: string,
): Promise<void> {
const live = [EOvertimeStatus.SUBMITTED, EOvertimeStatus.APPROVED];
/**
* Half-open [from, until) rather than an inclusive BETWEEN.
*
* The inclusive form needs a real last-day-of-period, and constructing one
* by string — `${month}-31` — is invalid in every 30-day month and in
* February. It failed in September and passed in October, which is exactly
* the sort of bug that reaches production.
*/
const sumOver = async (from: string, until: string): Promise<number> => {
const qb = this.requests
.createQueryBuilder("request")
.select("COALESCE(SUM(request.hours), 0)", "total")
.where("request.employee_id = :employeeId", { employeeId })
.andWhere("request.status IN (:...live)", { live })
.andWhere("request.work_date >= :from", { from })
.andWhere("request.work_date < :until", { until });
if (excludeRequestId) {
qb.andWhere("request.id != :excludeRequestId", { excludeRequestId });
}
const row = await qb.getRawOne<{ total: string }>();
return Number(row?.total ?? 0);
};
if (rate?.maxHoursPerDay) {
const sameDay = await sumOver(workDate, addDays(workDate, 1));
const cap = Number(rate.maxHoursPerDay);
if (sameDay + hours > cap) {
throw new BadRequestException(
`${category} overtime is capped at ${cap} hours a day` +
(sameDay > 0 ? ` and ${sameDay} are already claimed` : "") +
`. This claim of ${hours} would exceed it. (Proc. 1156/2019 Art. 67)`,
);
}
}
const month = workDate.slice(0, 7);
const monthTotal = await sumOver(
`${month}-01`,
OvertimeService.firstOfNextMonth(month),
);
if (monthTotal + hours > MAX_HOURS_PER_MONTH) {
throw new BadRequestException(
`Overtime is capped at ${MAX_HOURS_PER_MONTH} hours a month; ` +
`${monthTotal} are already claimed for ${month}. (Art. 67)`,
);
}
const year = Number(workDate.slice(0, 4));
const yearTotal = await sumOver(`${year}-01-01`, `${year + 1}-01-01`);
if (yearTotal + hours > MAX_HOURS_PER_YEAR) {
throw new BadRequestException(
`Overtime is capped at ${MAX_HOURS_PER_YEAR} hours a year; ` +
`${yearTotal} are already claimed for ${year}. (Art. 67)`,
);
}
}
/** `2026-09` → `2026-10-01`. Rolls the year over at December. */
private static firstOfNextMonth(month: string): string {
const [year, monthNumber] = month.split("-").map(Number);
return monthNumber === 12
? `${year + 1}-01-01`
: `${year}-${String(monthNumber + 1).padStart(2, "0")}-01`;
}
private assertPending(request: OvertimeRequest): void {
if (request.status !== EOvertimeStatus.SUBMITTED) {
throw new BadRequestException(
`This claim is already ${request.status.toLowerCase()} and cannot be ` +
"decided again.",
);
}
}
private requireOrg(actor: ActorContext): string {
const organizationId = orgScope(actor) ?? actor.organizationId;
if (!organizationId) {
throw new BadRequestException(
"This account has no organization, and overtime rates belong to one.",
);
}
return organizationId;
}
private async requireRequest(
id: string,
actor: ActorContext,
): Promise<OvertimeRequest> {
const request = await this.requests.findOne({ where: { id } });
if (!request) throw new NotFoundException(`Overtime claim ${id} not found`);
const scope = orgScope(actor);
if (scope && request.organizationId !== scope) {
throw new NotFoundException(`Overtime claim ${id} not found`);
}
return request;
}
}

View File

@@ -0,0 +1,314 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { InjectDataSource, InjectRepository } from "@nestjs/typeorm";
import { DataSource, Repository } from "typeorm";
import { ActorContext, orgScope } from "../../employees/employees.service";
import { EmployeesRepository } from "../../employees/employees.repository";
import { IamDirectoryService } from "../../../iam-directory/iam-directory.service";
import { Paginated, paginate } from "../../../common/pagination.dto";
import {
AttendanceRegularization,
ERegularizationStatus,
} from "../entities/attendance-regularization.entity";
import {
AttendanceRecord,
EAttendanceSource,
} from "../entities/attendance-record.entity";
import { AttendanceRepository } from "../repositories/attendance.repository";
import { AttendanceDayService } from "./attendance-day.service";
import { WorkSchedulesService } from "./work-schedules.service";
import { CreateRegularizationDto } from "../dto/regularization.dto";
@Injectable()
export class RegularizationsService {
constructor(
@InjectDataSource() private readonly dataSource: DataSource,
@InjectRepository(AttendanceRegularization)
private readonly regularizations: Repository<AttendanceRegularization>,
private readonly attendance: AttendanceRepository,
private readonly schedules: WorkSchedulesService,
private readonly employees: EmployeesRepository,
private readonly iamDirectory: IamDirectoryService,
) {}
async create(
dto: CreateRegularizationDto,
actor: ActorContext,
): Promise<AttendanceRegularization> {
const employeeId = dto.employeeId ?? actor.employeeId;
if (!employeeId) {
throw new BadRequestException(
"No employee to file this against — this account has no employee record.",
);
}
const employee = await this.iamDirectory.requireEmployee(employeeId);
const organizationId = employee.organizationId;
if (!organizationId) {
throw new BadRequestException(
`Cannot resolve an organization for employee ${employeeId}`,
);
}
const scope = orgScope(actor);
if (scope && organizationId !== scope) {
throw new NotFoundException(`Employee ${employeeId} not found`);
}
if (!dto.requestedCheckIn && !dto.requestedCheckOut) {
throw new BadRequestException(
"A correction must propose a check-in, a check-out, or both.",
);
}
if (
dto.requestedCheckIn &&
dto.requestedCheckOut &&
new Date(dto.requestedCheckOut) < new Date(dto.requestedCheckIn)
) {
throw new BadRequestException(
"The proposed check-out is before the proposed check-in.",
);
}
const open = await this.regularizations.findOne({
where: {
employeeId,
workDate: dto.workDate,
status: ERegularizationStatus.SUBMITTED,
},
});
if (open) {
throw new ConflictException(
`A correction for ${dto.workDate} is already awaiting a decision.`,
);
}
const record = await this.attendance.findForDate(employeeId, dto.workDate);
const profile = await this.employees.findByEmployeeId(employeeId);
const approverEmployeeId =
profile?.managerEmployeeId ??
(await this.iamDirectory.findLineManagerEmployeeId(employeeId));
return this.regularizations.save(
this.regularizations.create({
organizationId,
employeeId,
attendanceRecordId: record?.id ?? null,
workDate: dto.workDate,
requestedCheckIn: dto.requestedCheckIn
? new Date(dto.requestedCheckIn)
: null,
requestedCheckOut: dto.requestedCheckOut
? new Date(dto.requestedCheckOut)
: null,
reason: dto.reason,
status: ERegularizationStatus.SUBMITTED,
approverEmployeeId,
createdBy: actor.userId,
} as Partial<AttendanceRegularization>),
);
}
/**
* Approve, and apply the correction in the same transaction.
*
* The values being replaced are copied onto the request first. Without that,
* approving destroys the only evidence of what the record said — and "the
* system says I was late but I corrected it" is precisely the conversation
* this feature exists to settle.
*/
async approve(
id: string,
note: string | undefined,
actor: ActorContext,
): Promise<AttendanceRegularization> {
const request = await this.requireRequest(id, actor);
this.assertPending(request);
if (request.employeeId === actor.employeeId) {
throw new ForbiddenException(
"You cannot approve your own attendance correction.",
);
}
const schedule = await this.schedules.resolveFor(
request.employeeId,
request.organizationId,
request.workDate,
);
return this.dataSource.transaction(async (manager) => {
const records = manager.getRepository(AttendanceRecord);
const existing = await records.findOne({
where: { employeeId: request.employeeId, workDate: request.workDate },
});
const checkIn =
request.requestedCheckIn ?? existing?.checkIn ?? null;
const checkOut =
request.requestedCheckOut ?? existing?.checkOut ?? null;
if (!checkIn) {
throw new BadRequestException(
"This correction sets only a check-out, and there is no check-in on " +
"the record for it to close. Propose both.",
);
}
const measured = AttendanceDayService.measure(
schedule,
request.workDate,
checkIn,
checkOut,
);
const status = checkOut
? AttendanceDayService.statusForWorkedDay(schedule, measured)
: existing?.status ?? AttendanceDayService.statusForWorkedDay(schedule, measured);
const applied = {
checkIn,
checkOut,
status,
...measured,
isRegularized: true,
source: EAttendanceSource.MANUAL,
workScheduleId: schedule?.id ?? null,
updatedBy: actor.userId,
};
if (existing) {
await records.update(existing.id, applied);
} else {
await records.save(
records.create({
organizationId: request.organizationId,
employeeId: request.employeeId,
workDate: request.workDate,
...applied,
createdBy: actor.userId,
} as Partial<AttendanceRecord>),
);
}
await manager.getRepository(AttendanceRegularization).update(request.id, {
status: ERegularizationStatus.APPROVED,
// Captured now, not at submission — the record may have moved since.
originalCheckIn: existing?.checkIn ?? null,
originalCheckOut: existing?.checkOut ?? null,
originalStatus: existing?.status ?? null,
decidedByEmployeeId: actor.employeeId ?? null,
decidedAt: new Date(),
decisionNote: note ?? null,
updatedBy: actor.userId,
});
return { ...request, status: ERegularizationStatus.APPROVED };
});
}
async reject(
id: string,
note: string,
actor: ActorContext,
): Promise<AttendanceRegularization> {
const request = await this.requireRequest(id, actor);
this.assertPending(request);
await this.regularizations.update(id, {
status: ERegularizationStatus.REJECTED,
decidedByEmployeeId: actor.employeeId ?? null,
decidedAt: new Date(),
decisionNote: note,
updatedBy: actor.userId,
});
return { ...request, status: ERegularizationStatus.REJECTED };
}
async withdraw(
id: string,
actor: ActorContext,
): Promise<AttendanceRegularization> {
const request = await this.requireRequest(id, actor);
this.assertPending(request);
await this.regularizations.update(id, {
status: ERegularizationStatus.WITHDRAWN,
updatedBy: actor.userId,
});
return { ...request, status: ERegularizationStatus.WITHDRAWN };
}
async findAll(
filters: {
employeeId?: string;
status?: ERegularizationStatus;
page?: number;
limit?: number;
},
actor: ActorContext,
): Promise<Paginated<AttendanceRegularization>> {
const page = filters.page ?? 1;
const limit = filters.limit ?? 25;
const qb = this.regularizations.createQueryBuilder("request");
const scope = orgScope(actor);
if (scope) qb.andWhere("request.organization_id = :scope", { scope });
if (filters.employeeId) {
qb.andWhere("request.employee_id = :employeeId", {
employeeId: filters.employeeId,
});
}
if (filters.status) {
qb.andWhere("request.status = :status", { status: filters.status });
}
const [items, total] = await qb
.orderBy("request.workDate", "DESC")
.skip((page - 1) * limit)
.take(limit)
.getManyAndCount();
return paginate(items, total, page, limit);
}
async findAwaitingMe(
actor: ActorContext,
page = 1,
limit = 25,
): Promise<Paginated<AttendanceRegularization>> {
if (!actor.employeeId) return paginate([], 0, page, limit);
const [items, total] = await this.regularizations.findAndCount({
where: {
approverEmployeeId: actor.employeeId,
status: ERegularizationStatus.SUBMITTED,
},
order: { workDate: "DESC" },
skip: (page - 1) * limit,
take: limit,
});
return paginate(items, total, page, limit);
}
private assertPending(request: AttendanceRegularization): void {
if (request.status !== ERegularizationStatus.SUBMITTED) {
throw new BadRequestException(
`This correction is already ${request.status.toLowerCase()} and cannot ` +
"be decided again.",
);
}
}
private async requireRequest(
id: string,
actor: ActorContext,
): Promise<AttendanceRegularization> {
const request = await this.regularizations.findOne({ where: { id } });
if (!request) throw new NotFoundException(`Correction ${id} not found`);
const scope = orgScope(actor);
if (scope && request.organizationId !== scope) {
throw new NotFoundException(`Correction ${id} not found`);
}
return request;
}
}

View File

@@ -0,0 +1,250 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { ActorContext, orgScope } from "../../employees/employees.service";
import { IamDirectoryService } from "../../../iam-directory/iam-directory.service";
import {
WorkScheduleAssignmentsRepository,
WorkSchedulesRepository,
} from "../repositories/work-schedules.repository";
import { WorkSchedule } from "../entities/work-schedule.entity";
import { WorkScheduleAssignment } from "../entities/work-schedule-assignment.entity";
import {
AssignScheduleDto,
CreateWorkScheduleDto,
UpdateWorkScheduleDto,
} from "../dto/work-schedule.dto";
import { addDays } from "../../leave/services/working-days.service";
@Injectable()
export class WorkSchedulesService {
constructor(
private readonly schedules: WorkSchedulesRepository,
private readonly assignments: WorkScheduleAssignmentsRepository,
private readonly iamDirectory: IamDirectoryService,
) {}
async create(
dto: CreateWorkScheduleDto,
actor: ActorContext,
): Promise<WorkSchedule> {
const organizationId = this.requireOrg(actor);
const clash = await this.schedules.findByCode(organizationId, dto.code);
if (clash) {
throw new ConflictException(`Work schedule ${dto.code} already exists`);
}
WorkSchedulesService.assertCoherent(dto);
if (dto.isDefault) await this.schedules.clearDefault(organizationId);
return this.schedules.create({
...dto,
organizationId,
createdBy: actor.userId,
} as Partial<WorkSchedule>);
}
async update(
id: string,
dto: UpdateWorkScheduleDto,
actor: ActorContext,
): Promise<WorkSchedule> {
const schedule = await this.requireSchedule(id, orgScope(actor));
WorkSchedulesService.assertCoherent({ ...schedule, ...dto });
if (dto.isDefault) await this.schedules.clearDefault(schedule.organizationId);
return (
(await this.schedules.update(id, { ...dto, updatedBy: actor.userId })) ??
schedule
);
}
findAll(actor: ActorContext): Promise<WorkSchedule[]> {
return this.schedules.findAllFor(orgScope(actor));
}
findOne(id: string, actor: ActorContext): Promise<WorkSchedule> {
return this.requireSchedule(id, orgScope(actor));
}
async remove(id: string, actor: ActorContext): Promise<void> {
const schedule = await this.requireSchedule(id, orgScope(actor));
const open = await this.assignments.findOpenForSchedule(schedule.id);
if (open > 0) {
throw new ConflictException(
`${open} employee(s) are currently on this schedule. Move them to ` +
"another one first, or deactivate this schedule instead of deleting it.",
);
}
await this.schedules.softDelete(id);
}
/**
* Put an employee on a schedule from a date.
*
* The previous assignment is closed the day before rather than deleted, so
* attendance already recorded keeps resolving to the schedule that actually
* applied — moving someone to a later shift must not retroactively make last
* month's punctual arrivals late.
*/
async assign(
dto: AssignScheduleDto,
actor: ActorContext,
): Promise<WorkScheduleAssignment> {
const schedule = await this.requireSchedule(dto.workScheduleId, orgScope(actor));
const employee = await this.iamDirectory.requireEmployee(dto.employeeId);
if (
!actor.isSuperAdmin &&
employee.organizationId !== schedule.organizationId
) {
throw new BadRequestException(
"That employee and that schedule belong to different organizations.",
);
}
const open = await this.assignments.findOpenFor(dto.employeeId);
if (open) {
if (open.workScheduleId === schedule.id) {
throw new ConflictException(
`They are already on ${schedule.code}, since ${open.effectiveFrom}.`,
);
}
if (dto.effectiveFrom <= open.effectiveFrom) {
throw new BadRequestException(
`The new schedule must start after the current one began ` +
`(${open.effectiveFrom}).`,
);
}
await this.assignments.update(open.id, {
effectiveTo: addDays(dto.effectiveFrom, -1),
updatedBy: actor.userId,
});
}
return this.assignments.create({
organizationId: schedule.organizationId,
employeeId: dto.employeeId,
workScheduleId: schedule.id,
effectiveFrom: dto.effectiveFrom,
createdBy: actor.userId,
} as Partial<WorkScheduleAssignment>);
}
/**
* The schedule that applied to an employee on a date.
*
* Falls back to the organization's default, and then to null. Null is a valid
* answer, not an error: attendance can still record punches without a
* schedule, it simply cannot judge lateness — better than refusing to record
* that somebody came to work.
*/
async resolveFor(
employeeId: string,
organizationId: string,
date: string,
): Promise<WorkSchedule | null> {
const assignment = await this.assignments.findOnDate(employeeId, date);
if (assignment?.workSchedule) return assignment.workSchedule;
return this.schedules.findDefault(organizationId);
}
history(employeeId: string): Promise<WorkScheduleAssignment[]> {
return this.assignments.findHistory(employeeId);
}
/**
* Rules the CHECK constraints cannot express on their own.
*
* Typed on the fields rather than on the DTO class so the same check can run
* over an entity merged with a partial update — the update path has to
* validate the RESULT, not just the fields that changed.
*/
private static assertCoherent(dto: {
startTime?: string;
endTime?: string;
breakMinutes?: number;
crossesMidnight?: boolean;
workingDays?: number[] | null;
minMinutesFullDay?: number;
}): void {
if (dto.workingDays) {
if (dto.workingDays.length === 0) {
throw new BadRequestException(
"A schedule must work at least one day of the week.",
);
}
if (new Set(dto.workingDays).size !== dto.workingDays.length) {
throw new BadRequestException("workingDays contains duplicates");
}
}
if (dto.startTime && dto.endTime && !dto.crossesMidnight) {
if (dto.endTime <= dto.startTime) {
throw new BadRequestException(
"endTime must be after startTime. If this is a night shift, set " +
"crossesMidnight instead.",
);
}
}
// A day that cannot reach its own full-day threshold would mark every
// attendance HALF_DAY, however long the employee stayed.
const start = dto.startTime;
const end = dto.endTime;
if (start && end && dto.minMinutesFullDay !== undefined) {
const span = WorkSchedulesService.spanMinutes(
start,
end,
dto.crossesMidnight ?? false,
);
const payable = span - (dto.breakMinutes ?? 0);
if (dto.minMinutesFullDay > payable) {
throw new BadRequestException(
`A full day needs ${dto.minMinutesFullDay} minutes, but this shift ` +
`only offers ${payable} after its break. Every day would be counted ` +
"as a half day.",
);
}
}
}
private static spanMinutes(
start: string,
end: string,
crossesMidnight: boolean,
): number {
const toMinutes = (time: string) => {
const [hours, minutes] = time.split(":").map(Number);
return hours * 60 + minutes;
};
const raw = toMinutes(end) - toMinutes(start);
return crossesMidnight && raw <= 0 ? raw + 1440 : raw;
}
private requireOrg(actor: ActorContext): string {
const organizationId = orgScope(actor) ?? actor.organizationId;
if (!organizationId) {
throw new BadRequestException(
"This account has no organization, and work schedules belong to one.",
);
}
return organizationId;
}
private async requireSchedule(
id: string,
organizationId: string | null,
): Promise<WorkSchedule> {
const schedule = await this.schedules.findById(id);
if (!schedule || (organizationId && schedule.organizationId !== organizationId)) {
throw new NotFoundException(`Work schedule ${id} not found`);
}
return schedule;
}
}

View File

@@ -0,0 +1,45 @@
import { EOvertimeCategory } from "./entities/overtime.entity";
/**
* The statutory overtime premiums under **Labour Proclamation No. 1156/2019**,
* Art. 68, with the daily cap from Art. 67.
*
* A seed, not a rule. Every figure lands in a column an employer can raise —
* the statute is a floor, and paying more is lawful while paying less is not.
* `statuteReference` records where each default came from, so a later edit reads
* as a deliberate decision rather than a typo.
*
* NOT modelled here: the annual and monthly ceilings (Art. 67 also limits
* overtime to 20 hours a month and 100 a year). Those are checked against the
* accumulated total across requests, which is the overtime service's job, not a
* property of a rate.
*/
export const STATUTORY_OVERTIME_RATES = [
{
category: EOvertimeCategory.DAY,
multiplier: "1.50",
// Art. 67: two hours a day, on an ordinary working day.
maxHoursPerDay: "2.00",
statuteReference: "Proc. 1156/2019 Art. 68(1)(a)",
},
{
category: EOvertimeCategory.NIGHT,
multiplier: "1.75",
maxHoursPerDay: "2.00",
statuteReference: "Proc. 1156/2019 Art. 68(1)(b)",
},
{
category: EOvertimeCategory.REST_DAY,
multiplier: "2.00",
// A rest day has no ordinary shift to extend, so the two-hour cap on
// *extending a day* does not apply. Left uncapped rather than guessed at.
maxHoursPerDay: null,
statuteReference: "Proc. 1156/2019 Art. 68(1)(c)",
},
{
category: EOvertimeCategory.PUBLIC_HOLIDAY,
multiplier: "2.50",
maxHoursPerDay: null,
statuteReference: "Proc. 1156/2019 Art. 68(1)(d)",
},
] as const;

View File

@@ -0,0 +1,55 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsDateString,
IsEnum,
IsOptional,
IsString,
IsUUID,
MaxLength,
ValidateNested,
} from "class-validator";
import { EEmployeeDocumentType } from "../entities/employee-document.entity";
import { LocalizedNameDto } from "./create-employee-profile.dto";
export class CreateEmployeeDocumentDto {
@ApiProperty({ enum: EEmployeeDocumentType })
@IsEnum(EEmployeeDocumentType)
documentType!: EEmployeeDocumentType;
@ApiProperty({
format: "uuid",
description: "DMS document id — upload to DMS first, then file it here.",
})
@IsUUID()
documentId!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(128)
documentNumber?: string;
@ApiPropertyOptional({ format: "date" })
@IsOptional()
@IsDateString()
issueDate?: string;
@ApiPropertyOptional({ format: "date" })
@IsOptional()
@IsDateString()
expiryDate?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(128)
issuingAuthority?: string;
@ApiPropertyOptional({ type: LocalizedNameDto })
@IsOptional()
@ValidateNested()
@Type(() => LocalizedNameDto)
notes?: LocalizedNameDto;
}

View File

@@ -0,0 +1,213 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsBoolean,
IsDateString,
IsEmail,
IsEnum,
IsInt,
IsNotEmpty,
IsObject,
IsOptional,
IsString,
IsUUID,
Max,
MaxLength,
Min,
ValidateNested,
} from "class-validator";
import {
EEmploymentState,
EEmploymentType,
EGender,
EMaritalStatus,
ESalaryMode,
} from "../entities/employee-profile.entity";
export class PersonalAddressDto {
@ApiPropertyOptional() @IsOptional() @IsString() @MaxLength(64) region?: string;
@ApiPropertyOptional() @IsOptional() @IsString() @MaxLength(64) subCity?: string;
@ApiPropertyOptional() @IsOptional() @IsString() @MaxLength(64) zone?: string;
@ApiPropertyOptional() @IsOptional() @IsString() @MaxLength(64) woreda?: string;
@ApiPropertyOptional() @IsOptional() @IsString() @MaxLength(64) kebele?: string;
@ApiPropertyOptional() @IsOptional() @IsString() @MaxLength(32) houseNumber?: string;
}
/** The `{am, en}` shape every localized field on the platform uses. */
export class LocalizedNameDto {
@ApiProperty() @IsString() @IsNotEmpty() @MaxLength(128) am!: string;
@ApiProperty() @IsString() @IsNotEmpty() @MaxLength(128) en!: string;
}
export class CreateEmployeeProfileDto {
/**
* The `iam.employees.id` this profile extends. The employee must already exist
* in IAM — HR never creates one (3.5 hires through the IAM user API, then
* creates the profile).
*/
@ApiProperty({ format: "uuid" })
@IsUUID()
employeeId!: string;
/**
* Optional: when omitted the service generates the next number for the
* organization. Supplied explicitly when migrating existing staff whose
* numbers must be preserved.
*/
@ApiPropertyOptional({ maxLength: 64 })
@IsOptional()
@IsString()
@MaxLength(64)
employeeNumber?: string;
// ── Personal ──
@ApiPropertyOptional({ format: "date" })
@IsOptional()
@IsDateString()
dateOfBirth?: string;
@ApiPropertyOptional({ enum: EGender })
@IsOptional()
@IsEnum(EGender)
gender?: EGender;
@ApiPropertyOptional({ default: "Ethiopian" })
@IsOptional()
@IsString()
@MaxLength(64)
nationality?: string;
@ApiPropertyOptional() @IsOptional() @IsString() @MaxLength(64) nationalId?: string;
@ApiPropertyOptional() @IsOptional() @IsString() @MaxLength(32) tinNumber?: string;
@ApiPropertyOptional({ enum: EMaritalStatus })
@IsOptional()
@IsEnum(EMaritalStatus)
maritalStatus?: EMaritalStatus;
@ApiPropertyOptional({ minimum: 0, maximum: 30 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(30)
childrenCount?: number;
@ApiPropertyOptional() @IsOptional() @IsString() @MaxLength(32) workPhone?: string;
@ApiPropertyOptional()
@IsOptional()
@IsEmail()
@MaxLength(128)
workEmail?: string;
@ApiPropertyOptional({ type: PersonalAddressDto })
@IsOptional()
@ValidateNested()
@Type(() => PersonalAddressDto)
personalAddress?: PersonalAddressDto;
@ApiPropertyOptional({ type: LocalizedNameDto })
@IsOptional()
@ValidateNested()
@Type(() => LocalizedNameDto)
emergencyContactName?: LocalizedNameDto;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(32)
emergencyContactPhone?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(64)
emergencyContactRelation?: string;
// ── Employment ──
@ApiPropertyOptional({ format: "uuid" })
@IsOptional()
@IsUUID()
jobTitleId?: string;
@ApiPropertyOptional({ format: "uuid", description: "iam.locations.id" })
@IsOptional()
@IsUUID()
workLocationId?: string;
@ApiProperty({ enum: EEmploymentType })
@IsEnum(EEmploymentType)
employmentType!: EEmploymentType;
@ApiProperty({ format: "date" })
@IsDateString()
hireDate!: string;
@ApiPropertyOptional({ format: "date" })
@IsOptional()
@IsDateString()
contractEndDate?: string;
@ApiPropertyOptional({ format: "date" })
@IsOptional()
@IsDateString()
probationEndDate?: string;
@ApiPropertyOptional({
enum: EEmploymentState,
description:
"Defaults to PROBATION when a probation end date is set, ACTIVE otherwise.",
})
@IsOptional()
@IsEnum(EEmploymentState)
employmentState?: EEmploymentState;
@ApiPropertyOptional({
format: "uuid",
description:
"Manager OVERRIDE (iam.employees.id). Omit to use the IAM position hierarchy.",
})
@IsOptional()
@IsUUID()
managerEmployeeId?: string;
// ── Payroll ──
@ApiPropertyOptional({ enum: ESalaryMode })
@IsOptional()
@IsEnum(ESalaryMode)
salaryMode?: ESalaryMode;
@ApiPropertyOptional() @IsOptional() @IsString() @MaxLength(128) bankName?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(64)
bankAccountNumber?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(128)
bankAccountName?: string;
@ApiPropertyOptional({
description:
"Defaults from employmentType — contract, intern and part-time are excluded.",
})
@IsOptional()
@IsBoolean()
isPensionEligible?: boolean;
@ApiPropertyOptional({ format: "uuid", description: "DMS document id" })
@IsOptional()
@IsUUID()
profilePhotoDocumentId?: string;
@ApiPropertyOptional({ description: "Reserved for future structured metadata" })
@IsOptional()
@IsObject()
metadata?: Record<string, unknown>;
}

View File

@@ -0,0 +1,108 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import {
EEmploymentState,
EmployeeProfile,
} from "../entities/employee-profile.entity";
import type { IamEmployee } from "../../../iam-directory/iam-directory.service";
/**
* What the API returns for a profile: the HR row plus the IAM facts HR does not
* store. The `iam` block is read-through — it is never persisted here, so a name
* change in IAM is reflected immediately rather than needing an HR update.
*/
export type AccessAlignment =
| "OK"
/** Employment is over but the account still works. The dangerous one. */
| "ACCESS_OUTLIVES_EMPLOYMENT"
/** Still employed, but the account is deactivated — they cannot work. */
| "EMPLOYED_WITHOUT_ACCESS";
/** HR states after which nobody should still be able to log in. */
const ENDED_STATES = new Set<EEmploymentState>([
EEmploymentState.TERMINATED,
EEmploymentState.RETIRED,
]);
export const accessAlignmentOf = (
employmentState: EEmploymentState,
iam: { isCurrent: boolean } | null,
): AccessAlignment => {
// No IAM row to compare against — nothing to report either way.
if (!iam) return "OK";
if (ENDED_STATES.has(employmentState)) {
return iam.isCurrent ? "ACCESS_OUTLIVES_EMPLOYMENT" : "OK";
}
// SUSPENDED is deliberately not flagged: suspending someone and cutting their
// access is a normal pairing, and so is suspending them while they keep it.
if (employmentState === EEmploymentState.SUSPENDED) return "OK";
return iam.isCurrent ? "OK" : "EMPLOYED_WITHOUT_ACCESS";
};
export class EmployeeProfileResponseDto {
@ApiProperty({ format: "uuid" }) id!: string;
@ApiProperty({ format: "uuid" }) employeeId!: string;
@ApiProperty() employeeNumber!: string;
@ApiProperty({
description: "Read-through from iam.employees / iam.users. Never stored in hr.",
})
iam!: {
name: { am: string; en: string } | null;
status: string;
isCurrent: boolean;
unitId: string | null;
username: string | null;
email: string | null;
phoneNumber: string | null;
} | null;
@ApiProperty({
enum: ["OK", "ACCESS_OUTLIVES_EMPLOYMENT", "EMPLOYED_WITHOUT_ACCESS"],
description:
"How HR's employment state lines up with the IAM account. HR and IAM are " +
"separate systems and drift apart silently — ending an employment here " +
"does not revoke the login, and deactivating in IAM does not end the " +
"employment. This names the mismatch instead of leaving it invisible.",
})
accessAlignment!: AccessAlignment;
@ApiPropertyOptional({
format: "uuid",
description:
"Effective manager: the override when set, otherwise resolved from the IAM position hierarchy.",
})
effectiveManagerEmployeeId?: string | null;
@ApiPropertyOptional({
description: "True when effectiveManagerEmployeeId came from the override column.",
})
isManagerOverridden?: boolean;
[key: string]: unknown;
static from(
profile: EmployeeProfile,
iam: IamEmployee | null,
manager?: { employeeId: string | null; overridden: boolean },
): EmployeeProfileResponseDto {
const { documents: _documents, ...rest } = profile;
return {
...rest,
iam: iam
? {
name: iam.name,
status: iam.status,
isCurrent: iam.isCurrent,
unitId: iam.unitId,
username: iam.username,
email: iam.email,
phoneNumber: iam.phoneNumber,
}
: null,
accessAlignment: accessAlignmentOf(profile.employmentState, iam),
effectiveManagerEmployeeId: manager?.employeeId ?? null,
isManagerOverridden: manager?.overridden ?? false,
} as unknown as EmployeeProfileResponseDto;
}
}

View File

@@ -0,0 +1,61 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { IsBoolean, IsEnum, IsOptional, IsString, IsUUID } from "class-validator";
import { PaginationQueryDto } from "../../../common/pagination.dto";
import {
EEmploymentState,
EEmploymentType,
} from "../entities/employee-profile.entity";
export class FindEmployeeProfilesDto extends PaginationQueryDto {
@ApiPropertyOptional({
description: "Matches employee number, and IAM name / username / email.",
})
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional({ enum: EEmploymentState })
@IsOptional()
@IsEnum(EEmploymentState)
employmentState?: EEmploymentState;
@ApiPropertyOptional({ enum: EEmploymentType })
@IsOptional()
@IsEnum(EEmploymentType)
employmentType?: EEmploymentType;
@ApiPropertyOptional({ format: "uuid" })
@IsOptional()
@IsUUID()
jobTitleId?: string;
@ApiPropertyOptional({
format: "uuid",
description:
"iam.units.id. Includes every sub-unit beneath it, so a division filter covers its departments.",
})
@IsOptional()
@IsUUID()
unitId?: string;
@ApiPropertyOptional({
description:
"true = only people with an HR profile, false = only those still to be onboarded.",
})
@IsOptional()
@Transform(({ value }) =>
value === "true" ? true : value === "false" ? false : value,
)
@IsBoolean()
onboarded?: boolean;
@ApiPropertyOptional({
format: "uuid",
description: "Direct reports of this iam.employees.id (override or hierarchy).",
})
@IsOptional()
@IsUUID()
managerEmployeeId?: string;
}

View File

@@ -0,0 +1,66 @@
import { ApiPropertyOptional, OmitType, PartialType } from "@nestjs/swagger";
import {
IsBoolean,
IsDateString,
IsEnum,
IsOptional,
IsString,
MaxLength,
} from "class-validator";
import { CreateEmployeeProfileDto } from "./create-employee-profile.dto";
import { EEmploymentState } from "../entities/employee-profile.entity";
/**
* `employeeId` is omitted: re-pointing a profile at a different IAM employee
* would silently move somebody's payslips, leave balance and documents. Creating
* a new profile is the only way to attach to a different person.
*
* `employmentState` is omitted too — it moves through the dedicated transition
* endpoints (deactivate / reinstate / terminate), which enforce the state machine
* and record why. A free-form PATCH would let a terminated employee be set back
* to ACTIVE with no termination date cleanup and no audit reason.
*/
export class UpdateEmployeeProfileDto extends PartialType(
OmitType(CreateEmployeeProfileDto, [
"employeeId",
"employmentState",
] as const),
) {}
export class TerminateEmployeeDto {
@ApiPropertyOptional({
format: "date",
description: "Defaults to today when omitted.",
})
@IsOptional()
@IsDateString()
terminationDate?: string;
@ApiPropertyOptional({ maxLength: 128 })
@IsOptional()
@IsString()
@MaxLength(128)
terminationReason?: string;
@ApiPropertyOptional({
enum: [EEmploymentState.TERMINATED, EEmploymentState.RETIRED],
default: EEmploymentState.TERMINATED,
description: "RETIRED keeps pension reporting distinct from a termination.",
})
@IsOptional()
@IsEnum(EEmploymentState)
finalState?: EEmploymentState.TERMINATED | EEmploymentState.RETIRED;
@ApiPropertyOptional({
default: false,
description:
"Also deactivate their IAM account, revoking the login and ending every " +
"position they hold. Off by default because it is not reversible in one " +
"step: reactivating restores the account but NOT the positions. Left off, " +
"the profile comes back flagged ACCESS_OUTLIVES_EMPLOYMENT until somebody acts.",
})
@IsOptional()
@IsBoolean()
revokeSystemAccess?: boolean;
}

View File

@@ -0,0 +1,58 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { BaseRepository } from "@edr/api-common";
import { Repository } from "typeorm";
import { EmployeeDocument } from "./entities/employee-document.entity";
@Injectable()
export class EmployeeDocumentsRepository extends BaseRepository<EmployeeDocument> {
constructor(
@InjectRepository(EmployeeDocument)
repository: Repository<EmployeeDocument>,
) {
super(repository);
}
findByProfile(employeeProfileId: string): Promise<EmployeeDocument[]> {
return this.repository.find({
where: { employeeProfileId },
order: { createdAt: "DESC" },
});
}
/**
* Documents expiring within `days`. Feeds the renewal reminder and the
* expiring-documents report (3.7) — a lapsed work permit or contract is an HR
* compliance failure, so this is a first-class query rather than a report-time
* scan.
*/
findExpiringWithin(
organizationId: string | null,
days: number,
): Promise<EmployeeDocument[]> {
const qb = this.repository.createQueryBuilder("document").where("1 = 1");
// Documents store no organization. Scope by joining the profile through to
// `iam.employees`, which is where the organization actually lives.
// `null` = every organization (super admin), so no join is needed at all.
if (organizationId) {
qb.andWhere(
`EXISTS (
SELECT 1 FROM hr.employee_profiles p
JOIN iam.employees e ON e.id = p.employee_id
WHERE p.id = document.employee_profile_id
AND e.organization_id = :organizationId
)`,
{ organizationId },
);
}
return qb
.andWhere("document.expiry_date IS NOT NULL")
.andWhere(
`document.expiry_date BETWEEN CURRENT_DATE AND (CURRENT_DATE + make_interval(days => :days))`,
{ days },
)
.orderBy("document.expiryDate", "ASC")
.getMany();
}
}

View File

@@ -0,0 +1,79 @@
import {
ForbiddenException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { EmployeeDocumentsRepository } from "./employee-documents.repository";
import { EmployeeDocument } from "./entities/employee-document.entity";
import { CreateEmployeeDocumentDto } from "./dto/create-employee-document.dto";
import { ActorContext, EmployeesService, orgScope } from "./employees.service";
@Injectable()
export class EmployeeDocumentsService {
constructor(
private readonly documentsRepository: EmployeeDocumentsRepository,
private readonly employeesService: EmployeesService,
) {}
async list(
employeeProfileId: string,
actor: ActorContext,
options: { ownOnly?: boolean } = {},
): Promise<EmployeeDocument[]> {
const profile = await this.employeesService.requireProfile(
employeeProfileId,
orgScope(actor),
);
// Self-service callers reach this route with `can:view:employee_document`
// too, so the ownership check is what separates "my file" from "anyone's".
if (options.ownOnly && profile.employeeId !== actor.employeeId) {
throw new ForbiddenException("You can only read your own documents");
}
return this.documentsRepository.findByProfile(profile.id);
}
async add(
employeeProfileId: string,
dto: CreateEmployeeDocumentDto,
actor: ActorContext,
): Promise<EmployeeDocument> {
const profile = await this.employeesService.requireProfile(
employeeProfileId,
orgScope(actor),
);
// No organization stored on the document: it belongs to the profile, which
// belongs to an IAM employee, which is where the organization lives.
return this.documentsRepository.create({
...dto,
employeeProfileId: profile.id,
createdBy: actor.userId,
});
}
/**
* Soft-deletes the HR index row. The DMS object is deliberately left alone:
* DMS owns its own retention, and a document can be referenced by more than one
* record (a contract filed against both the employee and a recruitment offer).
*/
async remove(
employeeProfileId: string,
documentId: string,
actor: ActorContext,
): Promise<void> {
await this.employeesService.requireProfile(
employeeProfileId,
orgScope(actor),
);
const document = await this.documentsRepository.findById(documentId);
if (!document || document.employeeProfileId !== employeeProfileId) {
throw new NotFoundException(`Document ${documentId} not found`);
}
await this.documentsRepository.softDelete(documentId);
}
/** Documents expiring in the next `days`, for the compliance reminder. */
expiring(days: number, actor: ActorContext): Promise<EmployeeDocument[]> {
return this.documentsRepository.findExpiringWithin(orgScope(actor), days);
}
}

View File

@@ -0,0 +1,274 @@
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));
}
}

View File

@@ -0,0 +1,32 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { EmployeeProfile } from "./entities/employee-profile.entity";
import { EmployeeDocument } from "./entities/employee-document.entity";
import { EmployeesRepository } from "./employees.repository";
import { EmployeeDocumentsRepository } from "./employee-documents.repository";
import { EmployeesService } from "./employees.service";
import { EmployeeDocumentsService } from "./employee-documents.service";
import { EmployeesController } from "./employees.controller";
import { JobTitlesModule } from "../job-titles/job-titles.module";
@Module({
imports: [
TypeOrmModule.forFeature([EmployeeProfile, EmployeeDocument]),
// For the job-title validation on create/update. Imported rather than
// reaching into the repository directly, so the ownership stays with the
// job-titles module.
JobTitlesModule,
],
controllers: [EmployeesController],
providers: [
EmployeesRepository,
EmployeeDocumentsRepository,
EmployeesService,
EmployeeDocumentsService,
],
// Leave (3.2), payroll (3.4) and appraisals (3.6) all need to resolve a
// profile and its effective manager.
exports: [EmployeesService, EmployeesRepository],
})
export class EmployeesModule {}

View File

@@ -0,0 +1,185 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { BaseRepository } from "@edr/api-common";
import { In, Repository, SelectQueryBuilder } from "typeorm";
import { EmployeeProfile } from "./entities/employee-profile.entity";
import { FindEmployeeProfilesDto } from "./dto/find-employee-profiles.dto";
/** Columns a client may sort by. Anything else is a 400 rather than SQL injected. */
const SORTABLE = new Set([
"employeeNumber",
"hireDate",
"employmentState",
"employmentType",
"createdAt",
"updatedAt",
]);
@Injectable()
export class EmployeesRepository extends BaseRepository<EmployeeProfile> {
constructor(
@InjectRepository(EmployeeProfile)
repository: Repository<EmployeeProfile>,
) {
super(repository);
}
static isSortable(column: string): boolean {
return SORTABLE.has(column);
}
findByEmployeeId(employeeId: string): Promise<EmployeeProfile | null> {
return this.repository.findOne({ where: { employeeId } });
}
findByEmployeeIds(employeeIds: string[]): Promise<EmployeeProfile[]> {
if (!employeeIds.length) return Promise.resolve([]);
return this.repository.find({ where: { employeeId: In(employeeIds) } });
}
findByEmployeeNumber(employeeNumber: string): Promise<EmployeeProfile | null> {
return this.repository.findOne({ where: { employeeNumber } });
}
/**
* The highest numeric suffix issued platform-wide.
*
* Reads through soft-deleted rows (`withDeleted`) on purpose: an employee
* number must never be reissued, or a payslip archive would point at two
* different people.
*/
async findHighestEmployeeNumberSuffix(prefix: string): Promise<number> {
const row = await this.repository
.createQueryBuilder("profile")
.withDeleted()
.select(
`MAX(NULLIF(regexp_replace(profile.employee_number, '^' || :prefix, ''), '')::bigint)`,
"max",
)
.where("profile.employee_number ~ :pattern", {
pattern: `^${prefix}[0-9]+$`,
})
.setParameter("prefix", prefix)
.getRawOne<{ max: string | null }>();
return row?.max ? parseInt(row.max, 10) : 0;
}
/** Profiles whose manager override points at this employee. */
findDirectReportsByOverride(
managerEmployeeId: string,
): Promise<EmployeeProfile[]> {
return this.repository.find({ where: { managerEmployeeId } });
}
/**
* Filtered, paginated page of profiles.
*
* `search` deliberately matches only `employee_number` here. Name, username and
* email live in `iam` and are resolved separately by the service through
* `IamDirectoryService`, then intersected — this repository never reaches
* across the schema boundary itself.
*/
async findPage(
organizationId: string | null,
filters: FindEmployeeProfilesDto,
options: {
employeeIdWhitelist?: string[] | null;
unitEmployeeIds?: string[] | null;
} = {},
): Promise<[EmployeeProfile[], number]> {
const page = filters.page ?? 1;
const limit = filters.limit ?? 25;
// `null` = every organization (super admin). Starting from a always-true
// predicate keeps the andWhere chain below identical in both cases.
const qb: SelectQueryBuilder<EmployeeProfile> = this.repository
.createQueryBuilder("profile")
.leftJoinAndSelect("profile.jobTitle", "jobTitle")
.where("1 = 1");
// The profile stores no organization — scope through IAM. EXISTS rather
// than a JOIN so the row count is unaffected and paging stays correct.
if (organizationId) {
qb.andWhere(
`EXISTS (SELECT 1 FROM iam.employees e
WHERE e.id = profile.employee_id
AND e.organization_id = :organizationId)`,
{ organizationId },
);
}
if (filters.employmentState) {
qb.andWhere("profile.employment_state = :employmentState", {
employmentState: filters.employmentState,
});
}
if (filters.employmentType) {
qb.andWhere("profile.employment_type = :employmentType", {
employmentType: filters.employmentType,
});
}
if (filters.jobTitleId) {
qb.andWhere("profile.job_title_id = :jobTitleId", {
jobTitleId: filters.jobTitleId,
});
}
if (filters.managerEmployeeId) {
qb.andWhere("profile.manager_employee_id = :managerEmployeeId", {
managerEmployeeId: filters.managerEmployeeId,
});
}
if (filters.search) {
qb.andWhere("profile.employee_number ILIKE :search", {
search: `%${filters.search}%`,
});
}
// An empty whitelist means "the IAM-side filter matched nobody" — that is a
// genuine empty page, not an absent filter, so it must not be skipped.
if (options.employeeIdWhitelist) {
if (!options.employeeIdWhitelist.length) return [[], 0];
qb.andWhere("profile.employee_id IN (:...whitelist)", {
whitelist: options.employeeIdWhitelist,
});
}
if (options.unitEmployeeIds) {
if (!options.unitEmployeeIds.length) return [[], 0];
qb.andWhere("profile.employee_id IN (:...unitEmployeeIds)", {
unitEmployeeIds: options.unitEmployeeIds,
});
}
const sortBy =
filters.sortBy && EmployeesRepository.isSortable(filters.sortBy)
? filters.sortBy
: "createdAt";
qb.orderBy(`profile.${sortBy}`, filters.sortOrder ?? "DESC")
.skip((page - 1) * limit)
.take(limit);
return qb.getManyAndCount();
}
/** Headcount by employment state, for the HR dashboard. One query, not six. */
countByEmploymentState(
organizationId: string | null,
): Promise<{ employmentState: string; count: string }[]> {
const qb = this.repository
.createQueryBuilder("profile")
.select("profile.employment_state", "employmentState")
.addSelect("COUNT(*)::text", "count");
if (organizationId) {
qb.where(
`EXISTS (SELECT 1 FROM iam.employees e
WHERE e.id = profile.employee_id
AND e.organization_id = :organizationId)`,
{ organizationId },
);
}
return qb
.groupBy("profile.employment_state")
.getRawMany<{ employmentState: string; count: string }>();
}
}

View File

@@ -0,0 +1,822 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { formatEthiopianDateAmharic } from "@tria-plc/api-common/utils/date-convertor";
import { EmployeesRepository } from "./employees.repository";
import { EmployeeProfile } from "./entities/employee-profile.entity";
import {
EEmploymentState,
EEmploymentType,
} from "./entities/employee-profile.entity";
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 { EmployeeProfileResponseDto } from "./dto/employee-profile-response.dto";
import {
DirectoryRow,
IamDirectoryService,
} from "../../iam-directory/iam-directory.service";
import { IamOperationsService } from "../../iam-directory/iam-operations.service";
import { JobTitlesRepository } from "../job-titles/job-titles.repository";
import { Paginated, paginate } from "../../common/pagination.dto";
/** Employment types the pension scheme excludes by default (§3.4). */
const PENSION_EXCLUDED_TYPES = new Set<EEmploymentType>([
EEmploymentType.CONTRACT,
EEmploymentType.INTERN,
EEmploymentType.PART_TIME,
]);
/**
* States a profile may move to. TERMINATED and RETIRED are terminal — reversing
* one would leave a termination date and a live employment on the same row, and
* the payroll run reads both.
*/
/** Employment states after which no login should survive. */
const ENDED_STATES = new Set<EEmploymentState>([
EEmploymentState.TERMINATED,
EEmploymentState.RETIRED,
]);
const ALLOWED_TRANSITIONS: Record<EEmploymentState, EEmploymentState[]> = {
[EEmploymentState.PROBATION]: [
EEmploymentState.ACTIVE,
EEmploymentState.SUSPENDED,
EEmploymentState.ON_LEAVE,
EEmploymentState.TERMINATED,
],
[EEmploymentState.ACTIVE]: [
EEmploymentState.SUSPENDED,
EEmploymentState.ON_LEAVE,
EEmploymentState.TERMINATED,
EEmploymentState.RETIRED,
],
[EEmploymentState.SUSPENDED]: [
EEmploymentState.ACTIVE,
EEmploymentState.TERMINATED,
],
[EEmploymentState.ON_LEAVE]: [
EEmploymentState.ACTIVE,
EEmploymentState.SUSPENDED,
EEmploymentState.TERMINATED,
],
[EEmploymentState.TERMINATED]: [],
[EEmploymentState.RETIRED]: [],
};
const EMPLOYEE_NUMBER_PREFIX = "EMP-";
/**
* Cap on the id set a search resolves before the real page runs. A term matching
* more people than this silently loses the tail, so it is set well above any
* realistic organization's headcount rather than at a page size.
*/
const SEARCH_FANOUT_LIMIT = 2000;
const MAX_MANAGER_CHAIN_DEPTH = 32;
export type ActorContext = {
/** `iam.employees.id` of the caller, or null for a non-staff token. */
employeeId: string | null;
/** `iam.users.id` of the caller — what audit columns record. */
userId: string;
/**
* The caller's own organization. Still recorded for super admins, because a
* row they CREATE has to belong somewhere — it just no longer limits reads.
*/
organizationId: string;
/**
* Super admins read and write across every organization.
*
* Tenancy is normally independent of permissions here: `HrPermissionGuard`
* waives permission checks for a super admin, but scoping is a separate rule
* and waiving it is a deliberate product decision, not a side effect. This
* flag is the single place that decision lives.
*/
isSuperAdmin: boolean;
};
/**
* The organization filter for a request: `null` means "every organization",
* which repositories translate into omitting the WHERE clause entirely.
*
* Every scoped query goes through this rather than reading `actor.organizationId`
* directly, so a new query cannot forget the super-admin case.
*/
export const orgScope = (actor: ActorContext): string | null =>
actor.isSuperAdmin ? null : actor.organizationId;
@Injectable()
export class EmployeesService {
constructor(
private readonly employeesRepository: EmployeesRepository,
private readonly jobTitlesRepository: JobTitlesRepository,
private readonly iamDirectory: IamDirectoryService,
private readonly iamOperations: IamOperationsService,
) {}
// ────────────────────────────────────────────────────────────────────────
// Derivations
// ────────────────────────────────────────────────────────────────────────
/**
* Pension default: permanent and seconded staff contribute, everyone else does
* not (§3.4). An explicit value from the caller always wins — the exclusion is
* organizational policy, not statute, and payroll must be able to record the
* exception rather than work around it.
*/
static derivePensionEligibility(
employmentType: EEmploymentType,
explicit?: boolean,
): boolean {
if (explicit !== undefined) return explicit;
return !PENSION_EXCLUDED_TYPES.has(employmentType);
}
/**
* A new profile starts on PROBATION when a probation end date is set, and
* ACTIVE otherwise. Stated explicitly rather than defaulted in the entity so
* that "no probation period" is a real, recorded decision.
*/
static deriveInitialState(
probationEndDate?: string | null,
explicit?: EEmploymentState,
): EEmploymentState {
if (explicit) return explicit;
return probationEndDate
? EEmploymentState.PROBATION
: EEmploymentState.ACTIVE;
}
/** Ethiopian-calendar rendering, or null for an absent date. */
private static async toAmharicDate(
isoDate?: string | null,
): Promise<string | null> {
if (!isoDate) return null;
const timestamp = new Date(isoDate).getTime();
if (Number.isNaN(timestamp)) return null;
return formatEthiopianDateAmharic(timestamp, true);
}
/**
* The next employee number for an organization.
*
* Globally unique, not per organization — see the entity for why. Derived
* from the highest suffix already issued INCLUDING soft-deleted rows, so a
* number is never reused. Two concurrent creates can still pick the same
* value; the unique index on `employee_number` is what actually guarantees
* uniqueness.
*/
private async nextEmployeeNumber(): Promise<string> {
const highest =
await this.employeesRepository.findHighestEmployeeNumberSuffix(
EMPLOYEE_NUMBER_PREFIX,
);
return `${EMPLOYEE_NUMBER_PREFIX}${String(highest + 1).padStart(5, "0")}`;
}
// ────────────────────────────────────────────────────────────────────────
// Validation
// ────────────────────────────────────────────────────────────────────────
private static assertDateOrder(dto: {
hireDate?: string;
probationEndDate?: string | null;
contractEndDate?: string | null;
}): void {
const { hireDate, probationEndDate, contractEndDate } = dto;
if (!hireDate) return;
if (probationEndDate && probationEndDate < hireDate) {
throw new BadRequestException(
"probationEndDate cannot be before hireDate",
);
}
if (contractEndDate && contractEndDate < hireDate) {
throw new BadRequestException("contractEndDate cannot be before hireDate");
}
}
private async assertJobTitleBelongsToOrg(
jobTitleId: string,
organizationId: string | null,
): Promise<void> {
const jobTitle = await this.jobTitlesRepository.findById(jobTitleId);
if (!jobTitle || (organizationId && jobTitle.organizationId !== organizationId)) {
throw new BadRequestException(
`Job title ${jobTitleId} does not exist in this organization`,
);
}
}
/**
* A manager override must name a real IAM employee, must not be the employee
* themselves, and must not close a reporting cycle.
*
* The cycle walk follows overrides only. A cycle through the IAM position
* hierarchy is IAM's invariant to keep, and re-validating it here would make
* HR reject profiles over a structure it does not own.
*/
private async assertManagerOverrideIsSane(
employeeId: string,
managerEmployeeId: string,
): Promise<void> {
if (managerEmployeeId === employeeId) {
throw new BadRequestException("An employee cannot be their own manager");
}
await this.iamDirectory.requireEmployee(managerEmployeeId);
const seen = new Set<string>([employeeId]);
let cursor: string | null = managerEmployeeId;
for (let depth = 0; depth < MAX_MANAGER_CHAIN_DEPTH && cursor; depth += 1) {
if (seen.has(cursor)) {
throw new BadRequestException(
`Manager override would create a reporting cycle at employee ${cursor}`,
);
}
seen.add(cursor);
const managerProfile: EmployeeProfile | null =
await this.employeesRepository.findByEmployeeId(cursor);
cursor = managerProfile?.managerEmployeeId ?? null;
}
}
// ────────────────────────────────────────────────────────────────────────
// Commands
// ────────────────────────────────────────────────────────────────────────
async create(
dto: CreateEmployeeProfileDto,
actor: ActorContext,
): Promise<EmployeeProfileResponseDto> {
// The IAM employee is the extension point, so it has to exist and it decides
// which organization the profile belongs to — the caller does not get to say.
//
// It must ALSO be the caller's own organization. Falling back to the actor's
// org when IAM disagreed would let someone create a profile scoped to another
// tenant, which every read here then filters out — a row the creator can
// never see again. (Observed against the live database, which has three
// organizations and units belonging to each.)
const iamEmployee = await this.iamDirectory.requireEmployee(dto.employeeId);
// The organization is read from IAM for the tenancy check and then thrown
// away — it is deliberately NOT stored on the profile. IAM owns that fact.
const organizationId = iamEmployee.organizationId;
if (!organizationId) {
throw new ForbiddenException(
`IAM employee ${dto.employeeId} has no organization`,
);
}
if (!actor.isSuperAdmin && organizationId !== actor.organizationId) {
throw new ForbiddenException(
`Employee ${dto.employeeId} belongs to another organization`,
);
}
const existing = await this.employeesRepository.findByEmployeeId(
dto.employeeId,
);
if (existing) {
throw new ConflictException(
`Employee ${dto.employeeId} already has HR profile ${existing.id}`,
);
}
EmployeesService.assertDateOrder(dto);
if (dto.jobTitleId) {
await this.assertJobTitleBelongsToOrg(dto.jobTitleId, organizationId);
}
if (dto.managerEmployeeId) {
await this.assertManagerOverrideIsSane(
dto.employeeId,
dto.managerEmployeeId,
);
}
let employeeNumber = dto.employeeNumber;
if (employeeNumber) {
const clash =
await this.employeesRepository.findByEmployeeNumber(employeeNumber);
if (clash) {
throw new ConflictException(
`Employee number ${employeeNumber} is already in use`,
);
}
} else {
employeeNumber = await this.nextEmployeeNumber();
}
const profile = await this.employeesRepository.create({
...dto,
employeeNumber,
employmentState: EmployeesService.deriveInitialState(
dto.probationEndDate,
dto.employmentState,
),
isPensionEligible: EmployeesService.derivePensionEligibility(
dto.employmentType,
dto.isPensionEligible,
),
amharicHireDate: await EmployeesService.toAmharicDate(dto.hireDate),
amharicDateOfBirth: await EmployeesService.toAmharicDate(dto.dateOfBirth),
createdBy: actor.userId,
updatedBy: actor.userId,
});
return this.toResponse(profile, iamEmployee);
}
async update(
id: string,
dto: UpdateEmployeeProfileDto,
actor: ActorContext,
): Promise<EmployeeProfileResponseDto> {
const profile = await this.requireProfile(id, orgScope(actor));
EmployeesService.assertDateOrder({
hireDate: dto.hireDate ?? profile.hireDate,
probationEndDate: dto.probationEndDate ?? profile.probationEndDate,
contractEndDate: dto.contractEndDate ?? profile.contractEndDate,
});
if (dto.jobTitleId) {
// The profile no longer stores an organization; read the employee's from
// IAM so a job title from another tenant is still refused.
const iam = await this.iamDirectory.findEmployee(profile.employeeId);
await this.assertJobTitleBelongsToOrg(
dto.jobTitleId,
iam?.organizationId ?? null,
);
}
if (dto.managerEmployeeId) {
await this.assertManagerOverrideIsSane(
profile.employeeId,
dto.managerEmployeeId,
);
}
if (
dto.employeeNumber &&
dto.employeeNumber !== profile.employeeNumber
) {
const clash = await this.employeesRepository.findByEmployeeNumber(
dto.employeeNumber,
);
if (clash) {
throw new ConflictException(
`Employee number ${dto.employeeNumber} is already in use`,
);
}
}
// Pension eligibility follows a changed employment type unless the caller
// states otherwise in the same request — otherwise moving someone from
// CONTRACT to PERMANENT would silently leave them out of the pension.
const employmentType = dto.employmentType ?? profile.employmentType;
const isPensionEligible =
dto.isPensionEligible !== undefined
? dto.isPensionEligible
: dto.employmentType
? EmployeesService.derivePensionEligibility(employmentType)
: profile.isPensionEligible;
const updated = await this.employeesRepository.update(id, {
...dto,
isPensionEligible,
...(dto.hireDate
? { amharicHireDate: await EmployeesService.toAmharicDate(dto.hireDate) }
: {}),
...(dto.dateOfBirth
? {
amharicDateOfBirth: await EmployeesService.toAmharicDate(
dto.dateOfBirth,
),
}
: {}),
updatedBy: actor.userId,
});
return this.toResponse(updated ?? profile);
}
/**
* Move a profile between employment states.
*
* Every state change goes through here so the transition table is enforced in
* exactly one place — the deactivate/reinstate/terminate endpoints are thin
* wrappers over it rather than three separate implementations.
*/
private async transition(
profile: EmployeeProfile,
next: EEmploymentState,
actor: ActorContext,
extra: Partial<EmployeeProfile> = {},
): Promise<EmployeeProfile> {
const allowed = ALLOWED_TRANSITIONS[profile.employmentState] ?? [];
if (!allowed.includes(next)) {
throw new BadRequestException(
`Cannot move employment state from ${profile.employmentState} to ${next}` +
(allowed.length
? `. Allowed: ${allowed.join(", ")}`
: ". This state is terminal."),
);
}
const updated = await this.employeesRepository.update(profile.id, {
...extra,
employmentState: next,
updatedBy: actor.userId,
});
return updated ?? profile;
}
async suspend(
id: string,
actor: ActorContext,
): Promise<EmployeeProfileResponseDto> {
const profile = await this.requireProfile(id, orgScope(actor));
return this.toResponse(
await this.transition(profile, EEmploymentState.SUSPENDED, actor),
);
}
async reinstate(
id: string,
actor: ActorContext,
): Promise<EmployeeProfileResponseDto> {
const profile = await this.requireProfile(id, orgScope(actor));
return this.toResponse(
await this.transition(profile, EEmploymentState.ACTIVE, actor),
);
}
/**
* End an employment.
*
* Terminal, and it deliberately does NOT soft-delete the row: payroll history,
* the tax report and the pension report all read terminated employees for the
* period they worked. Removal, if it ever happens, is a retention decision made
* separately.
*/
async terminate(
id: string,
dto: TerminateEmployeeDto,
actor: ActorContext,
): Promise<EmployeeProfileResponseDto> {
const profile = await this.requireProfile(id, orgScope(actor));
const terminationDate =
dto.terminationDate ?? new Date().toISOString().slice(0, 10);
if (terminationDate < profile.hireDate) {
throw new BadRequestException(
"terminationDate cannot be before hireDate",
);
}
const finalState = dto.finalState ?? EEmploymentState.TERMINATED;
// Anyone whose manager override points at this person is now reporting to
// someone who has left. Clearing it falls back to the IAM position
// hierarchy, which is the correct answer far more often than a dangling id.
const reports =
await this.employeesRepository.findDirectReportsByOverride(
profile.employeeId,
);
for (const report of reports) {
await this.employeesRepository.update(report.id, {
managerEmployeeId: null,
updatedBy: actor.userId,
});
}
const terminated = await this.transition(profile, finalState, actor, {
terminationDate,
terminationReason: dto.terminationReason ?? null,
});
// Revoking access is a separate, opt-in step. The HR state change is already
// committed by this point, so a failure here must not undo it — the profile
// comes back flagged ACCESS_OUTLIVES_EMPLOYMENT and the account can be
// deactivated from the detail page instead.
if (dto.revokeSystemAccess) {
await this.setSystemAccess(terminated, false).catch(() => undefined);
}
return this.toResponse(terminated);
}
/**
* Turn the IAM account behind a profile on or off.
*
* IAM owns the login; HR owns the employment. Neither writes the other's
* state, so this is the one place HR is allowed to reach across — and only
* when a human asks for it explicitly.
*/
async setIamAccountActive(
id: string,
active: boolean,
actor: ActorContext,
): Promise<EmployeeProfileResponseDto> {
const profile = await this.requireProfile(id, orgScope(actor));
// Restoring a login to somebody HR says has left is precisely the mistake
// this pairing exists to prevent. Reinstate the employment first; that is a
// decision with a record behind it.
if (active && ENDED_STATES.has(profile.employmentState)) {
throw new BadRequestException(
`Cannot restore system access: this employment is ${profile.employmentState}. ` +
"Employment must be reinstated before the account is.",
);
}
await this.setSystemAccess(profile, active);
return this.toResponse(profile);
}
/**
* The IAM call itself, with its one asymmetry made explicit.
*
* Deactivating ends every position the employee holds (`end_date = now`,
* `is_current = false`); activating restores the account and its status but
* NOT those assignments. Reactivating therefore leaves somebody logged in with
* no post — the caller is warned, and the org explorer is where they are put
* back.
*/
private async setSystemAccess(
profile: EmployeeProfile,
active: boolean,
): Promise<void> {
if (active) {
await this.iamOperations.activateEmployee(profile.employeeId);
return;
}
await this.iamOperations.deactivateEmployee(profile.employeeId);
}
// ────────────────────────────────────────────────────────────────────────
// Queries
// ────────────────────────────────────────────────────────────────────────
/**
* The employee list: EVERY current IAM employee, with their onboarding state.
*
* Driven from IAM rather than from `hr.employee_profiles`, because a list of
* profiles alone showed 0 people against 2,427 real employees and offered no
* way to reach the rest. `isOnboarded` distinguishes the two, and
* `ensureProfile` turns an un-onboarded row into a real one.
*/
async findDirectory(
filters: FindEmployeeProfilesDto,
actor: ActorContext,
): Promise<Paginated<DirectoryRow>> {
const page = filters.page ?? 1;
const limit = filters.limit ?? 25;
const unitIds = filters.unitId
? await this.iamDirectory.findUnitSubtreeIds(filters.unitId)
: null;
const { items, total } = await this.iamDirectory.findEmployeeDirectoryPage({
organizationId: orgScope(actor),
search: filters.search,
unitIds,
onboarded: filters.onboarded,
employmentState: filters.employmentState,
limit,
offset: (page - 1) * limit,
});
return paginate(items, total, page, limit);
}
/** Profiles only — kept for callers that genuinely need HR rows. */
async findAll(
filters: FindEmployeeProfilesDto,
actor: ActorContext,
): Promise<Paginated<EmployeeProfileResponseDto>> {
// A search term has to reach IAM: name, username and email live there, while
// the employee number lives here. Resolve both sides into one id whitelist,
// then run the real page against it — that keeps the schema boundary intact
// and still gives a single paginated result set.
//
// `pageFilters` drops `search` once the whitelist exists: leaving it in would
// re-apply the employee-number LIKE on top of the whitelist, and an employee
// matched by NAME in IAM would be filtered straight back out.
let employeeIdWhitelist: string[] | null = null;
let pageFilters = filters;
if (filters.search) {
const [iamMatches, [numberMatches]] = await Promise.all([
this.iamDirectory.searchEmployees(filters.search, SEARCH_FANOUT_LIMIT),
this.employeesRepository.findPage(orgScope(actor), {
...filters,
page: 1,
limit: SEARCH_FANOUT_LIMIT,
}),
]);
employeeIdWhitelist = [
...new Set([
...iamMatches.map((match) => match.id),
...numberMatches.map((profile) => profile.employeeId),
]),
];
const { search: _search, ...withoutSearch } = filters;
pageFilters = withoutSearch;
}
let unitEmployeeIds: string[] | null = null;
if (filters.unitId) {
const unitIds = await this.iamDirectory.findUnitSubtreeIds(
filters.unitId,
);
const employees = await this.iamDirectory.findEmployeesByUnits(unitIds);
unitEmployeeIds = employees.map((e) => e.id);
}
const [profiles, total] = await this.employeesRepository.findPage(
orgScope(actor),
pageFilters,
{ employeeIdWhitelist, unitEmployeeIds },
);
// One IAM round trip for the whole page, never one per row.
const iamById = await this.iamDirectory.findEmployees(
profiles.map((p) => p.employeeId),
);
return paginate(
profiles.map((profile) =>
EmployeeProfileResponseDto.from(
profile,
iamById.get(profile.employeeId) ?? null,
),
),
total,
filters.page ?? 1,
filters.limit ?? 25,
);
}
async findOne(
id: string,
actor: ActorContext,
): Promise<EmployeeProfileResponseDto> {
const profile = await this.requireProfile(id, orgScope(actor));
return this.toResponse(profile);
}
/**
* The caller's own profile. Distinct from `findOne` because self-service holds
* `can:view_own:employee_profile` and must not be able to read anyone else by
* guessing an id.
*/
async findOwn(actor: ActorContext): Promise<EmployeeProfileResponseDto> {
if (!actor.employeeId) {
throw new ForbiddenException(
"This account is not linked to an employee record",
);
}
const profile = await this.employeesRepository.findByEmployeeId(
actor.employeeId,
);
if (!profile) {
throw new NotFoundException("You do not have an HR profile yet");
}
return this.toResponse(profile);
}
/** Headcount by state, for the HR dashboard KPI row. */
async headcountSummary(
actor: ActorContext,
): Promise<{ total: number; byState: Record<string, number> }> {
const rows = await this.employeesRepository.countByEmploymentState(
orgScope(actor),
);
const byState: Record<string, number> = {};
let total = 0;
for (const row of rows) {
const count = parseInt(row.count, 10);
byState[row.employmentState] = count;
total += count;
}
return { total, byState };
}
/**
* The manager who actually approves for this employee: the override when set,
* otherwise the holder of the parent position in IAM. Leave (3.2) and
* appraisals (3.6) both route through this, so the rule lives here rather than
* being re-derived per module.
*/
async resolveEffectiveManager(
profile: EmployeeProfile,
): Promise<{ employeeId: string | null; overridden: boolean }> {
if (profile.managerEmployeeId) {
return { employeeId: profile.managerEmployeeId, overridden: true };
}
return {
employeeId: await this.iamDirectory.findLineManagerEmployeeId(
profile.employeeId,
),
overridden: false,
};
}
/** Loads a profile and enforces organization scoping in one place. */
/**
* Loads a profile and enforces organization scoping in one place.
*
* `organizationId: null` skips the check — a super admin reads across
* organizations. Deliberately a 404 rather than a 403 for everyone else: an
* out-of-scope id should be indistinguishable from a missing one, or the
* endpoint becomes a way to probe which ids exist in other tenants.
*/
async requireProfile(
id: string,
organizationId: string | null,
): Promise<EmployeeProfile> {
const profile = await this.employeesRepository.findById(id);
if (!profile) throw new NotFoundException(`Employee profile ${id} not found`);
// Tenancy is checked against IAM, since the profile no longer stores an
// organization. One extra lookup by primary key, and it cannot go stale.
if (organizationId) {
const iam = await this.iamDirectory.findEmployee(profile.employeeId);
if (iam?.organizationId !== organizationId) {
throw new NotFoundException(`Employee profile ${id} not found`);
}
}
return profile;
}
/**
* Auto-provision: return this employee's HR profile, creating a minimal one
* if they do not have it yet.
*
* Mirrors how the passenger app lazily provisions `passenger.Passenger` on
* first login. It exists so the IAM/HR split is invisible in daily use — an
* HR officer opening someone's record should not first have to "create a
* profile", and no screen should dead-end on a person who plainly exists.
*
* Only the facts HR can know without asking are set: employment type defaults
* to PERMANENT and the hire date to the IAM record's creation date, which is
* the closest defensible proxy. Both are flagged by `isProvisional` so the UI
* can prompt for the real values rather than presenting a guess as fact.
*/
async ensureProfile(
employeeId: string,
actor: ActorContext,
): Promise<EmployeeProfile> {
const existing = await this.employeesRepository.findByEmployeeId(employeeId);
if (existing) return existing;
const iamEmployee = await this.iamDirectory.requireEmployee(employeeId);
if (
!actor.isSuperAdmin &&
iamEmployee.organizationId !== actor.organizationId
) {
throw new ForbiddenException(
`Employee ${employeeId} belongs to another organization`,
);
}
// node-postgres hydrates timestamptz into a Date, so normalise before
// slicing to the YYYY-MM-DD the column expects.
const createdAt = iamEmployee.createdAt
? new Date(iamEmployee.createdAt)
: new Date();
const hireDate = Number.isNaN(createdAt.getTime())
? new Date().toISOString().slice(0, 10)
: createdAt.toISOString().slice(0, 10);
return this.employeesRepository.create({
employeeId,
employeeNumber: await this.nextEmployeeNumber(),
employmentType: EEmploymentType.PERMANENT,
hireDate,
amharicHireDate: await EmployeesService.toAmharicDate(hireDate),
employmentState: EEmploymentState.ACTIVE,
isPensionEligible: EmployeesService.derivePensionEligibility(
EEmploymentType.PERMANENT,
),
isProvisional: true,
createdBy: actor.userId,
updatedBy: actor.userId,
});
}
private async toResponse(
profile: EmployeeProfile,
iamEmployee?: Awaited<ReturnType<IamDirectoryService["findEmployee"]>>,
): Promise<EmployeeProfileResponseDto> {
const iam =
iamEmployee ?? (await this.iamDirectory.findEmployee(profile.employeeId));
const manager = await this.resolveEffectiveManager(profile);
return EmployeeProfileResponseDto.from(profile, iam, manager);
}
}

View File

@@ -0,0 +1,89 @@
import { SoftDeleteAudit } from "@tria-plc/api-common/modules/typeorm/audit.entity";
import {
Column,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from "typeorm";
import { EmployeeProfile } from "./employee-profile.entity";
export enum EEmployeeDocumentType {
CONTRACT = "CONTRACT",
CERTIFICATE = "CERTIFICATE",
EDUCATION = "EDUCATION",
NATIONAL_ID = "NATIONAL_ID",
PASSPORT = "PASSPORT",
DRIVING_LICENCE = "DRIVING_LICENCE",
CV = "CV",
MEDICAL = "MEDICAL",
DISCIPLINARY = "DISCIPLINARY",
OTHER = "OTHER",
}
/**
* An employee's HR file. The bytes live in DMS/MinIO; this row is the HR-side
* index — what the document is, when it expires, and who it belongs to.
*
* `documentId` is a soft reference to DMS, deliberately without an FK (DMS is a
* separate service). A row whose DMS object has been deleted still tells HR that
* a contract was once filed and when it expired, which is the auditable fact.
*/
@Entity({ schema: "hr", name: "employee_documents" })
@Index("idx_employee_documents_profile", ["employeeProfileId"])
@Index("idx_employee_documents_expiry", ["expiryDate"])
export class EmployeeDocument extends SoftDeleteAudit {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid", name: "employee_profile_id" })
employeeProfileId!: string;
@ManyToOne(() => EmployeeProfile, (profile) => profile.documents, {
onDelete: "CASCADE",
})
@JoinColumn({ name: "employee_profile_id" })
employeeProfile?: EmployeeProfile;
@Column({ type: "varchar", length: 32, name: "document_type" })
documentType!: EEmployeeDocumentType;
/** Soft reference → the DMS document id. */
@Column({ type: "uuid", name: "document_id" })
documentId!: string;
@Column({
type: "varchar",
length: 128,
name: "document_number",
nullable: true,
})
documentNumber?: string | null;
@Column({ type: "date", name: "issue_date", nullable: true })
issueDate?: string | null;
/**
* Drives the expiring-documents report (3.7) and the renewal reminder. The
* organization is not stored here — the report scopes by joining through
* `hr.employee_profiles` to `iam.employees`.
*/
@Column({ type: "date", name: "expiry_date", nullable: true })
expiryDate?: string | null;
@Column({
type: "varchar",
length: 128,
name: "issuing_authority",
nullable: true,
})
issuingAuthority?: string | null;
@Column({ type: "jsonb", name: "notes", nullable: true })
notes?: { am: string; en: string } | null;
@Column({ type: "uuid", name: "created_by", nullable: true })
createdBy?: string | null;
}

View File

@@ -0,0 +1,314 @@
import { SoftDeleteAudit } from "@tria-plc/api-common/modules/typeorm/audit.entity";
import {
Column,
Entity,
Index,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
Unique,
} from "typeorm";
import { EmployeeDocument } from "./employee-document.entity";
import { JobTitle } from "../../job-titles/entities/job-title.entity";
export enum EEmploymentType {
PERMANENT = "PERMANENT",
CONTRACT = "CONTRACT",
INTERN = "INTERN",
PART_TIME = "PART_TIME",
SECONDED = "SECONDED",
}
export enum EEmploymentState {
PROBATION = "PROBATION",
ACTIVE = "ACTIVE",
SUSPENDED = "SUSPENDED",
ON_LEAVE = "ON_LEAVE",
TERMINATED = "TERMINATED",
RETIRED = "RETIRED",
}
export enum EGender {
MALE = "MALE",
FEMALE = "FEMALE",
}
export enum EMaritalStatus {
SINGLE = "SINGLE",
MARRIED = "MARRIED",
DIVORCED = "DIVORCED",
WIDOWED = "WIDOWED",
}
/** How net pay reaches the employee. Drives the payroll disbursement file (3.4). */
export enum ESalaryMode {
BANK = "BANK",
CASH = "CASH",
CHEQUE = "CHEQUE",
}
/** Ethiopian postal addressing: region → sub-city/zone → woreda → kebele. */
export type PersonalAddress = {
region?: string;
subCity?: string;
zone?: string;
woreda?: string;
kebele?: string;
houseNumber?: string;
};
/**
* The HR extension of `iam.employees`.
*
* `employeeId` is a soft reference — a plain UUID with NO foreign key into
* `iam.employees`, matching the platform stance (there are zero FKs from
* `freight.*` into `iam.*` either). Co-location in one database would allow a
* real FK; not taking it keeps IAM independently deployable and keeps HR rows
* readable when an IAM row is removed. Validation happens in
* `IamDirectoryService.requireEmployee` on the way in.
*
* NOTHING IAM stores is duplicated here — not name, unit, organization, user
* account or position. Every one of those is read by joining `iam.employees`
* and `iam.employee_positions` at query time. The organization in particular is
* deliberately NOT copied: it is IAM's fact, and a stale copy here would be a
* second answer to "which tenant is this person in".
*
* The only column that is HR's own identifier rather than an HR attribute is
* `employeeNumber`, which HR issues and IAM has no equivalent of.
*/
@Entity({ schema: "hr", name: "employee_profiles" })
@Unique("uq_employee_profiles_employee_id", ["employeeId"])
@Unique("uq_employee_profiles_employee_number", ["employeeNumber"])
@Index("idx_employee_profiles_state", ["employmentState"])
export class EmployeeProfile extends SoftDeleteAudit {
@PrimaryGeneratedColumn("uuid")
id!: string;
/** Soft reference → `iam.employees.id`. The extension point. */
@Column({ type: "uuid", name: "employee_id" })
employeeId!: string;
/**
* Human-facing payroll identifier. Globally unique, NOT per-organization.
*
* Per-org uniqueness would need `organization_id` stored here, and the
* organization is IAM's fact, not HR's — it is read by joining
* `iam.employees`. A global number also removes a real confusion: with
* per-org numbering a cross-organization list shows two different people
* both labelled EMP-00001.
*/
@Column({ type: "varchar", length: 64, name: "employee_number" })
employeeNumber!: string;
// ── Personal ──────────────────────────────────────────────────────────────
@Column({ type: "date", name: "date_of_birth", nullable: true })
dateOfBirth?: string | null;
/** Ethiopian-calendar rendering of `dateOfBirth`, stored so reports and IDs
* do not have to re-derive it (and cannot disagree about the conversion). */
@Column({
type: "varchar",
length: 32,
name: "amharic_date_of_birth",
nullable: true,
})
amharicDateOfBirth?: string | null;
@Column({ type: "varchar", length: 16, name: "gender", nullable: true })
gender?: EGender | null;
@Column({
type: "varchar",
length: 64,
name: "nationality",
nullable: true,
default: "Ethiopian",
})
nationality?: string | null;
/** Fayda / national ID number. */
@Column({ type: "varchar", length: 64, name: "national_id", nullable: true })
nationalId?: string | null;
/** Taxpayer Identification Number — required on the ERCA income tax report. */
@Column({ type: "varchar", length: 32, name: "tin_number", nullable: true })
tinNumber?: string | null;
@Column({
type: "varchar",
length: 16,
name: "marital_status",
nullable: true,
})
maritalStatus?: EMaritalStatus | null;
@Column({ type: "int", name: "children_count", default: 0 })
childrenCount!: number;
@Column({ type: "varchar", length: 32, name: "work_phone", nullable: true })
workPhone?: string | null;
/**
* Only populated when the organization issues a work address distinct from
* `iam.users.email`. Left null otherwise rather than copying the IAM value.
*/
@Column({ type: "varchar", length: 128, name: "work_email", nullable: true })
workEmail?: string | null;
@Column({ type: "jsonb", name: "personal_address", nullable: true })
personalAddress?: PersonalAddress | null;
@Column({ type: "jsonb", name: "emergency_contact_name", nullable: true })
emergencyContactName?: { am: string; en: string } | null;
@Column({
type: "varchar",
length: 32,
name: "emergency_contact_phone",
nullable: true,
})
emergencyContactPhone?: string | null;
@Column({
type: "varchar",
length: 64,
name: "emergency_contact_relation",
nullable: true,
})
emergencyContactRelation?: string | null;
// ── Employment ────────────────────────────────────────────────────────────
@Column({ type: "uuid", name: "job_title_id", nullable: true })
jobTitleId?: string | null;
@ManyToOne(() => JobTitle, { nullable: true, onDelete: "SET NULL" })
@JoinColumn({ name: "job_title_id" })
jobTitle?: JobTitle | null;
/** Soft reference → `iam.locations.id`. IAM already models work locations. */
@Column({ type: "uuid", name: "work_location_id", nullable: true })
workLocationId?: string | null;
@Column({
type: "varchar",
length: 16,
name: "employment_type",
default: EEmploymentType.PERMANENT,
})
employmentType!: EEmploymentType;
@Column({ type: "date", name: "hire_date" })
hireDate!: string;
@Column({
type: "varchar",
length: 32,
name: "amharic_hire_date",
nullable: true,
})
amharicHireDate?: string | null;
@Column({ type: "date", name: "contract_end_date", nullable: true })
contractEndDate?: string | null;
@Column({ type: "date", name: "probation_end_date", nullable: true })
probationEndDate?: string | null;
@Column({
type: "varchar",
length: 16,
name: "employment_state",
default: EEmploymentState.PROBATION,
})
employmentState!: EEmploymentState;
@Column({ type: "date", name: "termination_date", nullable: true })
terminationDate?: string | null;
@Column({
type: "varchar",
length: 128,
name: "termination_reason",
nullable: true,
})
terminationReason?: string | null;
/**
* Manager OVERRIDE only. Leave it null and the approval chain resolves through
* the IAM position hierarchy (`IamDirectoryService.findLineManagerEmployeeId`),
* which is the structure HR must not duplicate. Set it only where the reporting
* line genuinely differs from the org chart — a secondment, say.
*
* Soft reference → `iam.employees.id`, for symmetry with `employeeId`: the
* override names a person, not an HR profile, so it stays valid even before
* that manager has been given an HR profile of their own.
*/
@Column({ type: "uuid", name: "manager_employee_id", nullable: true })
managerEmployeeId?: string | null;
// ── Payroll-critical ──────────────────────────────────────────────────────
@Column({
type: "varchar",
length: 16,
name: "salary_mode",
default: ESalaryMode.BANK,
})
salaryMode!: ESalaryMode;
@Column({ type: "varchar", length: 128, name: "bank_name", nullable: true })
bankName?: string | null;
@Column({
type: "varchar",
length: 64,
name: "bank_account_number",
nullable: true,
})
bankAccountNumber?: string | null;
@Column({
type: "varchar",
length: 128,
name: "bank_account_name",
nullable: true,
})
bankAccountName?: string | null;
/**
* Whether the 7%/11% pension contributions apply (§3.4). Defaulted from
* `employmentType` — contract, intern and part-time staff are excluded — and
* overridable, because the exclusion is a policy default rather than a law
* that holds for every contract type.
*/
@Column({ type: "boolean", name: "is_pension_eligible", default: true })
isPensionEligible!: boolean;
// ── Media ─────────────────────────────────────────────────────────────────
/** Soft reference → the DMS document id. */
@Column({ type: "uuid", name: "profile_photo_document_id", nullable: true })
profilePhotoDocumentId?: string | null;
/**
* True when the row was created by auto-provisioning rather than by someone
* filling in the form. Its hire date and employment type are defaults, not
* recorded facts — payroll and leave accrual both key off hire date, so the
* UI must prompt for the real values before either is trusted.
*/
@Column({ type: "boolean", name: "is_provisional", default: false })
isProvisional!: boolean;
@Column({ type: "uuid", name: "created_by", nullable: true })
createdBy?: string | null;
@Column({ type: "uuid", name: "updated_by", nullable: true })
updatedBy?: string | null;
@OneToMany(() => EmployeeDocument, (document) => document.employeeProfile)
documents?: EmployeeDocument[];
}

View File

@@ -0,0 +1,41 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsBoolean,
IsInt,
IsOptional,
IsUUID,
Max,
Min,
} from "class-validator";
export class CreateJobPositionDto {
@ApiProperty({
format: "uuid",
description:
"iam.positions.id this extends. One headcount record per position.",
})
@IsUUID()
positionId!: string;
@ApiPropertyOptional({ format: "uuid" })
@IsOptional()
@IsUUID()
jobTitleId?: string;
@ApiPropertyOptional({ minimum: 0, maximum: 10000, default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(10000)
budgetedCount?: number;
@ApiPropertyOptional({
default: false,
description: "Open for recruitment. Independent of whether it is vacant.",
})
@IsOptional()
@IsBoolean()
isOpen?: boolean;
}

View File

@@ -0,0 +1,11 @@
import { OmitType, PartialType } from "@nestjs/swagger";
import { CreateJobPositionDto } from "./create-job-position.dto";
/**
* `positionId` is omitted: re-pointing a headcount record at a different IAM
* position would move a budget line silently. Delete and re-create instead.
*/
export class UpdateJobPositionDto extends PartialType(
OmitType(CreateJobPositionDto, ["positionId"] as const),
) {}

View File

@@ -0,0 +1,65 @@
import { SoftDeleteAudit } from "@tria-plc/api-common/modules/typeorm/audit.entity";
import {
Check,
Column,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
} from "typeorm";
import { JobTitle } from "../../job-titles/entities/job-title.entity";
/**
* The headcount / vacancy layer over `iam.positions`.
*
* Stores NOTHING IAM already holds — not the unit, not the organization. Both
* come from `iam.positions` and are read by joining. A copy here would be a
* second answer that goes stale the moment a position is moved.
*
* It EXTENDS a position, never replaces it: `positionId` is a soft reference to
* the IAM slot, unique here so a position cannot acquire two competing headcount
* records. IAM keeps owning who holds the position; HR adds what it is graded as,
* how many bodies it is budgeted for, and whether it is open for recruitment
* (which 3.5 reads when creating a job opening).
*/
@Entity({ schema: "hr", name: "job_positions" })
@Unique("uq_job_positions_position_id", ["positionId"])
@Check("ck_job_positions_budgeted_count", `"budgeted_count" >= 0`)
@Check("ck_job_positions_current_count", `"current_count" >= 0`)
export class JobPosition extends SoftDeleteAudit {
@PrimaryGeneratedColumn("uuid")
id!: string;
/** Soft reference → `iam.positions.id`. */
@Column({ type: "uuid", name: "position_id" })
positionId!: string;
@Column({ type: "uuid", name: "job_title_id", nullable: true })
jobTitleId?: string | null;
@ManyToOne(() => JobTitle, { nullable: true, onDelete: "SET NULL" })
@JoinColumn({ name: "job_title_id" })
jobTitle?: JobTitle | null;
@Column({ type: "int", name: "budgeted_count", default: 1 })
budgetedCount!: number;
/**
* Cache of the current holder count in IAM. Refreshed from
* `IamDirectoryService.countCurrentPositionHolders` whenever the position is
* read or written through HR — IAM stays the source of truth, this column just
* makes "which posts are under-filled" answerable in one query.
*/
@Column({ type: "int", name: "current_count", default: 0 })
currentCount!: number;
/** Open for recruitment. Independent of vacancy: a post can be under-filled
* and still frozen, which is exactly what a hiring freeze is. */
@Column({ type: "boolean", name: "is_open", default: false })
isOpen!: boolean;
@Column({ type: "uuid", name: "created_by", nullable: true })
createdBy?: string | null;
}

View File

@@ -0,0 +1,105 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseBoolPipe,
ParseUUIDPipe,
Patch,
Post,
Query,
} from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, 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 } from "../../seed/hr-permissions.registry";
import { PaginationQueryDto } from "../../common/pagination.dto";
import { JobPositionsService } from "./job-positions.service";
import { CreateJobPositionDto } from "./dto/create-job-position.dto";
import { UpdateJobPositionDto } from "./dto/update-job-position.dto";
@ApiTags("job-positions")
@ApiBearerAuth()
@Controller("job-positions")
@HrStaff([HR_PERMS.org.manageJobPosition, HR_PERMS.org.viewOrg])
export class JobPositionsController {
constructor(private readonly jobPositionsService: JobPositionsService) {}
@Post()
@HrStaff(HR_PERMS.org.manageJobPosition)
@ApiOperation({
summary: "Add headcount/vacancy data to an existing IAM position",
})
create(@Body() dto: CreateJobPositionDto, @CurrentUser() user: TCurrentUser) {
return this.jobPositionsService.create(dto, actorFrom(user));
}
@Get()
@HrStaff(HR_PERMS.org.viewOrg)
@ApiOperation({ summary: "List job positions" })
findAll(
@Query() pagination: PaginationQueryDto,
@Query("unitId") unitId: string | undefined,
@Query("jobTitleId") jobTitleId: string | undefined,
@Query("isOpen", new ParseBoolPipe({ optional: true }))
isOpen: boolean | undefined,
@Query("vacantOnly", new ParseBoolPipe({ optional: true }))
vacantOnly: boolean | undefined,
@CurrentUser() user: TCurrentUser,
) {
return this.jobPositionsService.findAll(
{ ...pagination, unitId, jobTitleId, isOpen, vacantOnly },
actorFrom(user),
);
}
@Get("headcount-totals")
@HrStaff(HR_PERMS.org.viewOrg)
@ApiOperation({ summary: "Budgeted vs current headcount, and the gap" })
headcountTotals(@CurrentUser() user: TCurrentUser) {
return this.jobPositionsService.headcountTotals(actorFrom(user));
}
@Get(":id")
@HrStaff(HR_PERMS.org.viewOrg)
@ApiOperation({
summary: "One job position, with the holder count re-read from IAM",
})
findOne(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
return this.jobPositionsService.findOne(id, actorFrom(user));
}
@Patch(":id")
@HrStaff(HR_PERMS.org.manageJobPosition)
@ApiOperation({ summary: "Update budget, grade or recruitment status" })
update(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateJobPositionDto,
@CurrentUser() user: TCurrentUser,
) {
return this.jobPositionsService.update(id, dto, actorFrom(user));
}
@Delete(":id")
@HrStaff(HR_PERMS.org.manageJobPosition)
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({
summary: "Delete a headcount record",
description: "Refused while anyone still holds the position in IAM.",
})
remove(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
return this.jobPositionsService.remove(id, actorFrom(user));
}
}

View File

@@ -0,0 +1,17 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { JobPosition } from "./entities/job-position.entity";
import { JobPositionsRepository } from "./job-positions.repository";
import { JobPositionsService } from "./job-positions.service";
import { JobPositionsController } from "./job-positions.controller";
import { JobTitlesModule } from "../job-titles/job-titles.module";
@Module({
imports: [TypeOrmModule.forFeature([JobPosition]), JobTitlesModule],
controllers: [JobPositionsController],
providers: [JobPositionsRepository, JobPositionsService],
// Recruitment (3.5) creates job openings against an open position.
exports: [JobPositionsRepository, JobPositionsService],
})
export class JobPositionsModule {}

View File

@@ -0,0 +1,103 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { BaseRepository } from "@edr/api-common";
import { Repository } from "typeorm";
import { JobPosition } from "./entities/job-position.entity";
@Injectable()
export class JobPositionsRepository extends BaseRepository<JobPosition> {
constructor(
@InjectRepository(JobPosition) repository: Repository<JobPosition>,
) {
super(repository);
}
findByPositionId(positionId: string): Promise<JobPosition | null> {
return this.repository.findOne({ where: { positionId } });
}
async findPage(
organizationId: string | null,
filters: {
unitId?: string;
jobTitleId?: string;
isOpen?: boolean;
vacantOnly?: boolean;
page?: number;
limit?: number;
sortOrder?: "ASC" | "DESC";
},
): Promise<[JobPosition[], number]> {
const page = filters.page ?? 1;
const limit = filters.limit ?? 25;
const qb = this.repository
.createQueryBuilder("position")
.leftJoinAndSelect("position.jobTitle", "jobTitle")
.where("1 = 1");
// `null` = every organization (super admin). `hr.job_positions` stores no
// copy of the organization — it lives on `iam.positions`, joined here the
// same way `headcountTotals` below already does it. (A direct
// `position.organization_id` reference doesn't exist on this table and
// 500s for any non-super-admin actor.)
if (organizationId) {
qb.andWhere(
`EXISTS (SELECT 1 FROM iam.positions p
WHERE p.id = position.position_id
AND p.organization_id = :organizationId)`,
{ organizationId },
);
}
if (filters.unitId) {
qb.andWhere("position.unit_id = :unitId", { unitId: filters.unitId });
}
if (filters.jobTitleId) {
qb.andWhere("position.job_title_id = :jobTitleId", {
jobTitleId: filters.jobTitleId,
});
}
if (filters.isOpen !== undefined) {
qb.andWhere("position.is_open = :isOpen", { isOpen: filters.isOpen });
}
// NOTE on naming: `where`/`select` fragments are raw SQL, so they use the
// COLUMN name (organization_id). `orderBy` is resolved through entity
// metadata instead, so it needs the PROPERTY name (createdAt) — passing a
// column there throws `Cannot read properties of undefined (reading
// 'databaseName')` at query build time, not a SQL error.
if (filters.vacantOnly) {
qb.andWhere("position.current_count < position.budgeted_count");
}
return qb
.orderBy("position.createdAt", filters.sortOrder ?? "DESC")
.skip((page - 1) * limit)
.take(limit)
.getManyAndCount();
}
/** Budget vs actual headcount for the organization, in one query. */
async headcountTotals(
organizationId: string | null,
): Promise<{ budgeted: number; current: number; vacant: number }> {
const qb = this.repository
.createQueryBuilder("position")
.select("COALESCE(SUM(position.budgeted_count), 0)::text", "budgeted")
.addSelect("COALESCE(SUM(position.current_count), 0)::text", "current");
if (organizationId) {
qb.where(
`EXISTS (SELECT 1 FROM iam.positions p
WHERE p.id = position.position_id
AND p.organization_id = :organizationId)`,
{ organizationId },
);
}
const row = await qb.getRawOne<{ budgeted: string; current: string }>();
const budgeted = parseInt(row?.budgeted ?? "0", 10);
const current = parseInt(row?.current ?? "0", 10);
return { budgeted, current, vacant: Math.max(budgeted - current, 0) };
}
}

View File

@@ -0,0 +1,168 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { JobPositionsRepository } from "./job-positions.repository";
import { JobPosition } from "./entities/job-position.entity";
import { CreateJobPositionDto } from "./dto/create-job-position.dto";
import { UpdateJobPositionDto } from "./dto/update-job-position.dto";
import { IamDirectoryService } from "../../iam-directory/iam-directory.service";
import { JobTitlesRepository } from "../job-titles/job-titles.repository";
import { ActorContext, orgScope } from "../employees/employees.service";
import { Paginated, paginate } from "../../common/pagination.dto";
@Injectable()
export class JobPositionsService {
constructor(
private readonly jobPositionsRepository: JobPositionsRepository,
private readonly jobTitlesRepository: JobTitlesRepository,
private readonly iamDirectory: IamDirectoryService,
) {}
async create(
dto: CreateJobPositionDto,
actor: ActorContext,
): Promise<JobPosition> {
const position = await this.iamDirectory.requirePosition(dto.positionId);
// The IAM position decides which organization the record belongs to, and it
// must be the caller's own unless they are a super admin. Taking the org
// from the actor instead would file the record under the wrong tenant when
// an admin creates across organizations.
if (!actor.isSuperAdmin && position.organizationId !== actor.organizationId) {
throw new ForbiddenException(
`IAM position ${dto.positionId} belongs to another organization`,
);
}
const existing = await this.jobPositionsRepository.findByPositionId(
dto.positionId,
);
if (existing) {
throw new ConflictException(
`IAM position ${dto.positionId} already has headcount record ${existing.id}`,
);
}
if (dto.jobTitleId) {
await this.assertJobTitle(dto.jobTitleId, orgScope(actor));
}
return this.jobPositionsRepository.create({
...dto,
// Seeded from IAM at creation so the record is correct before anyone opens
// the list; kept fresh by `refreshCurrentCount` on every read of one.
currentCount: await this.iamDirectory.countCurrentPositionHolders(
dto.positionId,
),
createdBy: actor.userId,
});
}
async update(
id: string,
dto: UpdateJobPositionDto,
actor: ActorContext,
): Promise<JobPosition> {
const position = await this.requirePosition(id, orgScope(actor));
if (dto.jobTitleId) {
await this.assertJobTitle(dto.jobTitleId, orgScope(actor));
}
return (await this.jobPositionsRepository.update(id, dto)) ?? position;
}
async findAll(
filters: {
unitId?: string;
jobTitleId?: string;
isOpen?: boolean;
vacantOnly?: boolean;
page?: number;
limit?: number;
sortOrder?: "ASC" | "DESC";
},
actor: ActorContext,
): Promise<Paginated<JobPosition>> {
const [items, total] = await this.jobPositionsRepository.findPage(
orgScope(actor),
filters,
);
return paginate(items, total, filters.page ?? 1, filters.limit ?? 25);
}
/** One position, with its holder count re-read from IAM first. */
async findOne(id: string, actor: ActorContext): Promise<JobPosition> {
const position = await this.requirePosition(id, orgScope(actor));
return this.refreshCurrentCount(position);
}
async headcountTotals(actor: ActorContext) {
return this.jobPositionsRepository.headcountTotals(orgScope(actor));
}
async remove(id: string, actor: ActorContext): Promise<void> {
const position = await this.requirePosition(id, orgScope(actor));
const fresh = await this.refreshCurrentCount(position);
if (fresh.currentCount > 0) {
throw new BadRequestException(
`${fresh.currentCount} employee(s) currently hold this position in IAM. Move them first.`,
);
}
await this.jobPositionsRepository.softDelete(id);
}
/**
* Re-read the holder count from IAM and persist it when it has moved.
*
* `current_count` is a cache, not a second source of truth — IAM owns who
* holds a position, and writes happen there without HR ever being told. The
* write is conditional so a read does not churn `updated_at` on every request.
*/
private async refreshCurrentCount(
position: JobPosition,
): Promise<JobPosition> {
const actual = await this.iamDirectory.countCurrentPositionHolders(
position.positionId,
);
if (actual === position.currentCount) return position;
return (
(await this.jobPositionsRepository.update(position.id, {
currentCount: actual,
})) ?? position
);
}
private async assertJobTitle(
jobTitleId: string,
organizationId: string | null,
): Promise<void> {
const title = await this.jobTitlesRepository.findById(jobTitleId);
if (!title || (organizationId && title.organizationId !== organizationId)) {
throw new BadRequestException(
`Job title ${jobTitleId} does not exist in this organization`,
);
}
}
/** `organizationId: null` = no scoping (super admin). */
private async requirePosition(
id: string,
organizationId: string | null,
): Promise<JobPosition> {
const record = await this.jobPositionsRepository.findById(id);
if (!record) throw new NotFoundException(`Job position ${id} not found`);
// Tenancy comes from the IAM position, not from a copy stored here.
if (organizationId) {
const iam = await this.iamDirectory.findPosition(record.positionId);
if (iam?.organizationId !== organizationId) {
throw new NotFoundException(`Job position ${id} not found`);
}
}
return record;
}
}

View File

@@ -0,0 +1,63 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsBoolean,
IsInt,
IsNumberString,
IsOptional,
IsString,
Matches,
Max,
MaxLength,
Min,
ValidateNested,
} from "class-validator";
import { LocalizedNameDto } from "../../employees/dto/create-employee-profile.dto";
export class CreateJobTitleDto {
@ApiProperty({ type: LocalizedNameDto })
@ValidateNested()
@Type(() => LocalizedNameDto)
name!: LocalizedNameDto;
@ApiProperty({
maxLength: 32,
description: "Unique within the organization. Upper-case letters, digits, dash.",
})
@IsString()
@MaxLength(32)
@Matches(/^[A-Z0-9-]+$/, {
message: "code must contain only A-Z, 0-9 and -",
})
code!: string;
@ApiPropertyOptional({ minimum: 1, maximum: 30, default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(30)
gradeLevel?: number;
@ApiPropertyOptional({ description: "ETB, 2 decimal places" })
@IsOptional()
@IsNumberString({ no_symbols: false })
salaryBandMin?: string;
@ApiPropertyOptional({ description: "ETB, 2 decimal places" })
@IsOptional()
@IsNumberString({ no_symbols: false })
salaryBandMax?: string;
@ApiPropertyOptional({ type: LocalizedNameDto })
@IsOptional()
@ValidateNested()
@Type(() => LocalizedNameDto)
description?: LocalizedNameDto;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
}

View File

@@ -0,0 +1,5 @@
import { PartialType } from "@nestjs/swagger";
import { CreateJobTitleDto } from "./create-job-title.dto";
export class UpdateJobTitleDto extends PartialType(CreateJobTitleDto) {}

View File

@@ -0,0 +1,67 @@
import { SoftDeleteAudit } from "@tria-plc/api-common/modules/typeorm/audit.entity";
import {
Check,
Column,
Entity,
Index,
PrimaryGeneratedColumn,
Unique,
} from "typeorm";
/**
* The grade / salary-band catalogue. IAM has no equivalent: `iam.positions` is a
* slot in the org chart ("Warehouse Supervisor, Dire Dawa"), whereas a job title
* is the graded role that slot is an instance of ("Supervisor, Grade 7"). Salary
* structures (3.4) attach to the title, not the slot.
*/
@Entity({ schema: "hr", name: "job_titles" })
@Unique("uq_job_titles_org_code", ["organizationId", "code"])
@Index("idx_job_titles_organization_id", ["organizationId"])
@Check(
"ck_job_titles_salary_band",
`"salary_band_min" IS NULL OR "salary_band_max" IS NULL OR "salary_band_max" >= "salary_band_min"`,
)
export class JobTitle extends SoftDeleteAudit {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid", name: "organization_id" })
organizationId!: string;
@Column({ type: "jsonb", name: "name" })
name!: { am: string; en: string };
@Column({ type: "varchar", length: 32, name: "code" })
code!: string;
/** Higher is more senior. Ordering key for grade-based reports and bands. */
@Column({ type: "int", name: "grade_level", default: 1 })
gradeLevel!: number;
@Column({
type: "numeric",
precision: 14,
scale: 2,
name: "salary_band_min",
nullable: true,
})
salaryBandMin?: string | null;
@Column({
type: "numeric",
precision: 14,
scale: 2,
name: "salary_band_max",
nullable: true,
})
salaryBandMax?: string | null;
@Column({ type: "jsonb", name: "description", nullable: true })
description?: { am: string; en: string } | null;
@Column({ type: "boolean", name: "is_active", default: true })
isActive!: boolean;
@Column({ type: "uuid", name: "created_by", nullable: true })
createdBy?: string | null;
}

View File

@@ -0,0 +1,91 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseBoolPipe,
ParseUUIDPipe,
Patch,
Post,
Query,
} from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, 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 } from "../../seed/hr-permissions.registry";
import { PaginationQueryDto } from "../../common/pagination.dto";
import { JobTitlesService } from "./job-titles.service";
import { CreateJobTitleDto } from "./dto/create-job-title.dto";
import { UpdateJobTitleDto } from "./dto/update-job-title.dto";
@ApiTags("job-titles")
@ApiBearerAuth()
@Controller("job-titles")
@HrStaff([HR_PERMS.org.manageJobTitle, HR_PERMS.org.viewOrg])
export class JobTitlesController {
constructor(private readonly jobTitlesService: JobTitlesService) {}
@Post()
@HrStaff(HR_PERMS.org.manageJobTitle)
@ApiOperation({ summary: "Create a job title (grade / salary band)" })
create(@Body() dto: CreateJobTitleDto, @CurrentUser() user: TCurrentUser) {
return this.jobTitlesService.create(dto, actorFrom(user));
}
@Get()
@HrStaff(HR_PERMS.org.viewOrg)
@ApiOperation({ summary: "List job titles" })
findAll(
@Query() pagination: PaginationQueryDto,
@Query("search") search: string | undefined,
@Query("isActive", new ParseBoolPipe({ optional: true }))
isActive: boolean | undefined,
@CurrentUser() user: TCurrentUser,
) {
return this.jobTitlesService.findAll(
{ ...pagination, search, isActive },
actorFrom(user),
);
}
@Get(":id")
@HrStaff(HR_PERMS.org.viewOrg)
@ApiOperation({ summary: "One job title" })
findOne(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
return this.jobTitlesService.findOne(id, actorFrom(user));
}
@Patch(":id")
@HrStaff(HR_PERMS.org.manageJobTitle)
@ApiOperation({ summary: "Update a job title" })
update(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateJobTitleDto,
@CurrentUser() user: TCurrentUser,
) {
return this.jobTitlesService.update(id, dto, actorFrom(user));
}
@Delete(":id")
@HrStaff(HR_PERMS.org.manageJobTitle)
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({
summary: "Delete a job title",
description: "Refused while employees still hold it — deactivate instead.",
})
remove(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
return this.jobTitlesService.remove(id, actorFrom(user));
}
}

View File

@@ -0,0 +1,17 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { JobTitle } from "./entities/job-title.entity";
import { JobTitlesRepository } from "./job-titles.repository";
import { JobTitlesService } from "./job-titles.service";
import { JobTitlesController } from "./job-titles.controller";
@Module({
imports: [TypeOrmModule.forFeature([JobTitle])],
controllers: [JobTitlesController],
providers: [JobTitlesRepository, JobTitlesService],
// The employees module validates a profile's job title, and payroll (3.4)
// reads the salary band off it.
exports: [JobTitlesRepository, JobTitlesService],
})
export class JobTitlesModule {}

View File

@@ -0,0 +1,70 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { BaseRepository } from "@edr/api-common";
import { Repository } from "typeorm";
import { JobTitle } from "./entities/job-title.entity";
@Injectable()
export class JobTitlesRepository extends BaseRepository<JobTitle> {
constructor(
@InjectRepository(JobTitle) repository: Repository<JobTitle>,
) {
super(repository);
}
findByCode(organizationId: string, code: string): Promise<JobTitle | null> {
return this.repository.findOne({ where: { organizationId, code } });
}
async findPage(
organizationId: string | null,
filters: {
search?: string;
isActive?: boolean;
page?: number;
limit?: number;
sortOrder?: "ASC" | "DESC";
},
): Promise<[JobTitle[], number]> {
const page = filters.page ?? 1;
const limit = filters.limit ?? 25;
const qb = this.repository.createQueryBuilder("title").where("1 = 1");
// `null` = every organization (super admin).
if (organizationId) {
qb.andWhere("title.organization_id = :organizationId", { organizationId });
}
if (filters.isActive !== undefined) {
qb.andWhere("title.is_active = :isActive", { isActive: filters.isActive });
}
if (filters.search) {
// Both locales of the jsonb name, plus the code.
qb.andWhere(
`(title.code ILIKE :search OR title.name->>'en' ILIKE :search OR title.name->>'am' ILIKE :search)`,
{ search: `%${filters.search}%` },
);
}
return qb
.orderBy("title.gradeLevel", filters.sortOrder ?? "ASC")
.addOrderBy("title.code", "ASC")
.skip((page - 1) * limit)
.take(limit)
.getManyAndCount();
}
/** How many profiles reference this title — the guard against deleting a
* title that salary structures and employees still point at. */
async countEmployeesUsing(jobTitleId: string): Promise<number> {
const row = await this.repository.manager.query<{ count: string }[]>(
`SELECT COUNT(*)::text AS count
FROM hr.employee_profiles
WHERE job_title_id = $1 AND deleted_at IS NULL`,
[jobTitleId],
);
return parseInt(row[0]?.count ?? "0", 10);
}
}

View File

@@ -0,0 +1,135 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { JobTitlesRepository } from "./job-titles.repository";
import { JobTitle } from "./entities/job-title.entity";
import { CreateJobTitleDto } from "./dto/create-job-title.dto";
import { UpdateJobTitleDto } from "./dto/update-job-title.dto";
import { ActorContext, orgScope } from "../employees/employees.service";
import { Paginated, paginate } from "../../common/pagination.dto";
@Injectable()
export class JobTitlesService {
constructor(private readonly jobTitlesRepository: JobTitlesRepository) {}
private static assertBand(min?: string | null, max?: string | null): void {
if (min && max && Number(max) < Number(min)) {
throw new BadRequestException(
"salaryBandMax cannot be lower than salaryBandMin",
);
}
}
async create(
dto: CreateJobTitleDto,
actor: ActorContext,
): Promise<JobTitle> {
JobTitlesService.assertBand(dto.salaryBandMin, dto.salaryBandMax);
// A job title has no IAM parent to inherit an organization from, so it is
// created in the caller's own. A super admin with no employee record has
// none — better an explicit error than a row filed under an empty id.
if (!actor.organizationId) {
throw new BadRequestException(
"Cannot create a job title: this account has no organization. " +
"Job titles belong to a specific organization.",
);
}
const clash = await this.jobTitlesRepository.findByCode(
actor.organizationId,
dto.code,
);
if (clash) {
throw new ConflictException(`Job title code ${dto.code} already exists`);
}
return this.jobTitlesRepository.create({
...dto,
organizationId: actor.organizationId,
createdBy: actor.userId,
});
}
async update(
id: string,
dto: UpdateJobTitleDto,
actor: ActorContext,
): Promise<JobTitle> {
const title = await this.requireTitle(id, orgScope(actor));
JobTitlesService.assertBand(
dto.salaryBandMin ?? title.salaryBandMin,
dto.salaryBandMax ?? title.salaryBandMax,
);
if (dto.code && dto.code !== title.code) {
// Uniqueness is per organization, so check against the TITLE's own org —
// not the caller's. For a super admin editing another organization's
// title those differ, and using the caller's would compare against the
// wrong set of codes.
const clash = await this.jobTitlesRepository.findByCode(
title.organizationId,
dto.code,
);
if (clash) {
throw new ConflictException(`Job title code ${dto.code} already exists`);
}
}
return (await this.jobTitlesRepository.update(id, dto)) ?? title;
}
async findAll(
filters: {
search?: string;
isActive?: boolean;
page?: number;
limit?: number;
sortOrder?: "ASC" | "DESC";
},
actor: ActorContext,
): Promise<Paginated<JobTitle>> {
const [items, total] = await this.jobTitlesRepository.findPage(
orgScope(actor),
filters,
);
return paginate(items, total, filters.page ?? 1, filters.limit ?? 25);
}
findOne(id: string, actor: ActorContext): Promise<JobTitle> {
return this.requireTitle(id, orgScope(actor));
}
/**
* Removing a title that employees still hold would orphan their grade and,
* from 3.4, their salary structure. Deactivating is the supported way to
* retire one: it disappears from pickers but existing references keep resolving.
*/
async remove(id: string, actor: ActorContext): Promise<void> {
const title = await this.requireTitle(id, orgScope(actor));
const inUse = await this.jobTitlesRepository.countEmployeesUsing(title.id);
if (inUse > 0) {
throw new ConflictException(
`${inUse} employee(s) hold this job title. Deactivate it instead of deleting it.`,
);
}
await this.jobTitlesRepository.softDelete(id);
}
/** `organizationId: null` = no scoping (super admin). */
private async requireTitle(
id: string,
organizationId: string | null,
): Promise<JobTitle> {
const title = await this.jobTitlesRepository.findById(id);
if (!title || (organizationId && title.organizationId !== organizationId)) {
throw new NotFoundException(`Job title ${id} not found`);
}
return title;
}
}

View File

@@ -0,0 +1,106 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from "@nestjs/common";
import {
ApiBearerAuth,
ApiOperation,
ApiQuery,
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 } from "../../../seed/hr-permissions.registry";
import { HolidaysService } from "../services/holidays.service";
import { CreateHolidayDto, UpdateHolidayDto } from "../dto/holiday.dto";
@ApiTags("holidays")
@ApiBearerAuth()
@Controller("holidays")
@HrStaff([HR_PERMS.leave.managePublicHoliday, HR_PERMS.leave.viewOwn])
export class HolidaysController {
constructor(private readonly holidaysService: HolidaysService) {}
@Get()
@HrStaff(HR_PERMS.leave.viewOwn)
@ApiOperation({
summary: "Holidays in a range",
description:
"National holidays plus this organization's own. Defaults to the current " +
"Gregorian year.",
})
@ApiQuery({ name: "start", required: false, example: "2026-01-01" })
@ApiQuery({ name: "end", required: false, example: "2026-12-31" })
findAll(
@CurrentUser() user: TCurrentUser,
@Query("start") start?: string,
@Query("end") end?: string,
) {
return this.holidaysService.findAll({ start, end }, actorFrom(user));
}
@Get("calendar")
@HrStaff(HR_PERMS.leave.viewOwn)
@ApiOperation({
summary: "Working-day breakdown for a range",
description:
"Returns the holidays, the weekend definition, and both day counts — so a " +
"leave form can explain why five requested dates became three days, " +
"rather than just showing the number.",
})
@ApiQuery({ name: "start", required: true, example: "2026-09-01" })
@ApiQuery({ name: "end", required: true, example: "2026-09-14" })
calendar(
@CurrentUser() user: TCurrentUser,
@Query("start") start: string,
@Query("end") end: string,
) {
return this.holidaysService.calendar({ start, end }, actorFrom(user));
}
@Post()
@HrStaff(HR_PERMS.leave.managePublicHoliday)
@ApiOperation({ summary: "Add an organization holiday" })
create(@Body() dto: CreateHolidayDto, @CurrentUser() user: TCurrentUser) {
return this.holidaysService.create(dto, actorFrom(user));
}
@Patch(":id")
@HrStaff(HR_PERMS.leave.managePublicHoliday)
@ApiOperation({ summary: "Change a holiday" })
update(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateHolidayDto,
@CurrentUser() user: TCurrentUser,
) {
return this.holidaysService.update(id, dto, actorFrom(user));
}
@Delete(":id")
@HrStaff(HR_PERMS.leave.managePublicHoliday)
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({
summary: "Remove an organization holiday",
description:
"National holidays cannot be removed by an individual employer — set " +
"isWorkingDay on it instead if this organization works that day.",
})
remove(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
return this.holidaysService.remove(id, actorFrom(user));
}
}

View File

@@ -0,0 +1,168 @@
import {
Body,
Controller,
ForbiddenException,
Get,
NotFoundException,
Param,
ParseUUIDPipe,
Post,
Query,
} from "@nestjs/common";
import {
ApiBearerAuth,
ApiOperation,
ApiQuery,
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 } from "../../../seed/hr-permissions.registry";
import { hasHrPermission } from "../../../common/hr-permission.util";
import { LeaveBalancesService } from "../services/leave-balances.service";
import { ELedgerEntryType } from "../entities/leave-ledger-entry.entity";
import { AdjustBalanceDto, ReverseEntryDto } from "../dto/leave-balance.dto";
@ApiTags("leave-balances")
@ApiBearerAuth()
@Controller("leave-balances")
@HrStaff([
HR_PERMS.leave.viewOwn,
HR_PERMS.leave.viewAll,
HR_PERMS.leave.manageAllocation,
])
export class LeaveBalancesController {
constructor(private readonly balances: LeaveBalancesService) {}
@Get("me")
@HrStaff(HR_PERMS.leave.viewOwn)
@ApiOperation({
summary: "My leave balances",
description:
"Balances for the leave year containing today. Entitlements are created " +
"on first read, so a new joiner sees their allowance immediately rather " +
"than waiting for a nightly job.",
})
@ApiQuery({ name: "on", required: false, description: "Defaults to today." })
async mine(@CurrentUser() user: TCurrentUser, @Query("on") on?: string) {
const actor = actorFrom(user);
if (!actor.employeeId) {
throw new ForbiddenException(
"This account has no employee record, so it holds no leave balance.",
);
}
try {
return await this.balances.balancesFor(actor.employeeId, actor, on);
} catch (error) {
// The service message is written for HR looking someone up, and quotes an
// employee id. Addressed to the person themselves that reads as a fault
// in the system rather than a step somebody still owes them.
if (error instanceof NotFoundException) {
throw new NotFoundException(
"You do not have an HR profile yet, so there is nothing to show. " +
"Ask HR to complete your onboarding.",
);
}
throw error;
}
}
@Get("employee/:employeeId")
@HrStaff(HR_PERMS.leave.viewAll)
@ApiOperation({ summary: "Another employee's leave balances" })
@ApiQuery({ name: "on", required: false })
forEmployee(
@Param("employeeId", ParseUUIDPipe) employeeId: string,
@CurrentUser() user: TCurrentUser,
@Query("on") on?: string,
) {
return this.balances.balancesFor(employeeId, actorFrom(user), on);
}
@Get(":entitlementId/ledger")
@HrStaff(HR_PERMS.leave.viewOwn)
@ApiOperation({
summary: "Every movement behind a balance",
description:
"The append-only history. This is the answer to 'why is my balance that " +
"number' — grants, carry-over, deductions, expiries and corrections, " +
"newest first.",
})
async ledger(
@Param("entitlementId", ParseUUIDPipe) entitlementId: string,
@CurrentUser() user: TCurrentUser,
) {
// Own ledger always; anyone else's needs the view-all key. Checked here
// rather than by a route guard because which rule applies depends on whose
// entitlement it is, and the guard cannot know that before loading it.
const actor = actorFrom(user);
const entries = await this.balances.ledgerFor(entitlementId);
await this.assertLedgerVisible(entitlementId, actor, user);
return entries;
}
@Post(":entitlementId/adjust")
@HrStaff(HR_PERMS.leave.manageAllocation)
@ApiOperation({
summary: "Correct a balance",
description:
"Posts a signed ADJUSTMENT entry. Nothing is overwritten and the reason " +
"is mandatory — the ledger is what an argument about a balance is settled " +
"with months later.",
})
@ApiResponse({ status: 201, description: "The entry that was posted" })
adjust(
@Param("entitlementId", ParseUUIDPipe) entitlementId: string,
@Body() dto: AdjustBalanceDto,
@CurrentUser() user: TCurrentUser,
) {
return this.balances.post(
entitlementId,
{
entryType: ELedgerEntryType.ADJUSTMENT,
days: dto.days,
effectiveOn: dto.effectiveOn ?? new Date().toISOString().slice(0, 10),
reason: dto.reason,
},
actorFrom(user),
);
}
@Post("entries/:entryId/reverse")
@HrStaff(HR_PERMS.leave.manageAllocation)
@ApiOperation({
summary: "Undo one entry",
description:
"Posts the opposite entry rather than deleting. An entry can only be " +
"reversed once — a second attempt is refused by the database, so a " +
"double-click cannot credit the days twice.",
})
reverse(
@Param("entryId", ParseUUIDPipe) entryId: string,
@Body() dto: ReverseEntryDto,
@CurrentUser() user: TCurrentUser,
) {
return this.balances.reverse(entryId, dto.reason, actorFrom(user));
}
private async assertLedgerVisible(
entitlementId: string,
actor: ReturnType<typeof actorFrom>,
user: TCurrentUser,
): Promise<void> {
if (hasHrPermission(user, HR_PERMS.leave.viewAll)) return;
const owned = await this.balances.entitlementBelongsTo(
entitlementId,
actor.employeeId,
);
if (!owned) {
throw new ForbiddenException(
"You can only see the history behind your own balances.",
);
}
}
}

View File

@@ -0,0 +1,222 @@
import {
Body,
Controller,
Get,
Param,
ParseIntPipe,
ParseUUIDPipe,
Patch,
Post,
Query,
} from "@nestjs/common";
import {
ApiBearerAuth,
ApiOperation,
ApiQuery,
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 } from "../../../seed/hr-permissions.registry";
import { LeaveRequestsService } from "../services/leave-requests.service";
import { ELeaveRequestStatus } from "../entities/leave-request.entity";
import {
CancelLeaveRequestDto,
CreateLeaveRequestDto,
DecideLeaveRequestDto,
RejectLeaveRequestDto,
} from "../dto/leave-request.dto";
@ApiTags("leave-requests")
@ApiBearerAuth()
@Controller("leave-requests")
@HrStaff([
HR_PERMS.leave.create,
HR_PERMS.leave.viewOwn,
HR_PERMS.leave.viewAll,
HR_PERMS.leave.approveL1,
HR_PERMS.leave.approveL2,
HR_PERMS.leave.cancel,
])
export class LeaveRequestsController {
constructor(private readonly requests: LeaveRequestsService) {}
@Get("quote")
@HrStaff(HR_PERMS.leave.create)
@ApiOperation({
summary: "What a range would cost, before requesting it",
description:
"Returns the working-day and calendar-day counts, which holidays fell " +
"inside the range, what would be charged and what would be left. The " +
"leave form calls this as the dates change so the number is never a " +
"surprise.",
})
@ApiQuery({ name: "employeeId", required: false })
quote(
@CurrentUser() user: TCurrentUser,
@Query("leaveTypeId", ParseUUIDPipe) leaveTypeId: string,
@Query("startDate") startDate: string,
@Query("endDate") endDate: string,
@Query("isHalfDay") isHalfDay?: string,
@Query("employeeId") employeeId?: string,
) {
const actor = actorFrom(user);
return this.requests.quote(
employeeId ?? actor.employeeId ?? "",
leaveTypeId,
startDate,
endDate,
isHalfDay === "true",
actor,
);
}
@Post()
@HrStaff(HR_PERMS.leave.create)
@ApiOperation({ summary: "Request leave" })
@ApiResponse({ status: 201, description: "Submitted, awaiting a decision" })
@ApiResponse({
status: 400,
description:
"A rule refused it — overlapping dates, insufficient balance, missing " +
"evidence, or a type the employee is not eligible for. The message says which.",
})
create(@Body() dto: CreateLeaveRequestDto, @CurrentUser() user: TCurrentUser) {
return this.requests.create(dto, actorFrom(user));
}
@Get("mine")
@HrStaff(HR_PERMS.leave.viewOwn)
@ApiOperation({ summary: "My leave requests" })
mine(
@CurrentUser() user: TCurrentUser,
@Query("status") status?: ELeaveRequestStatus,
@Query("page", new ParseIntPipe({ optional: true })) page?: number,
@Query("limit", new ParseIntPipe({ optional: true })) limit?: number,
) {
const actor = actorFrom(user);
return this.requests.findAll(
{ employeeId: actor.employeeId ?? "", status, page, limit },
actor,
);
}
@Get("awaiting-me")
// L1 (line manager) or L2 (HR's role grant, e.g. hr_manager) — approveL1 is
// deliberately never granted to any role/position/position-type (it is
// resolved dynamically below), so gating on it alone made this endpoint,
// and the approvals screen it feeds, unreachable by anyone but a super
// admin. An L2-only caller with no direct reports just gets an empty list.
@HrStaff([HR_PERMS.leave.approveL1, HR_PERMS.leave.approveL2])
@ApiOperation({
summary: "Requests waiting on my decision",
description:
"Submitted requests whose approver is this employee — resolved from the " +
"IAM position hierarchy, or the manager override on the HR profile.",
})
awaitingMe(
@CurrentUser() user: TCurrentUser,
@Query("page", new ParseIntPipe({ optional: true })) page?: number,
@Query("limit", new ParseIntPipe({ optional: true })) limit?: number,
) {
return this.requests.findAwaitingMe(actorFrom(user), page, limit);
}
@Get()
@HrStaff(HR_PERMS.leave.viewAll)
@ApiOperation({ summary: "All leave requests" })
@ApiQuery({ name: "employeeId", required: false })
@ApiQuery({ name: "status", required: false, enum: ELeaveRequestStatus })
@ApiQuery({ name: "from", required: false })
@ApiQuery({ name: "to", required: false })
findAll(
@CurrentUser() user: TCurrentUser,
@Query("employeeId") employeeId?: string,
@Query("status") status?: ELeaveRequestStatus,
@Query("leaveTypeId") leaveTypeId?: string,
@Query("from") from?: string,
@Query("to") to?: string,
@Query("page", new ParseIntPipe({ optional: true })) page?: number,
@Query("limit", new ParseIntPipe({ optional: true })) limit?: number,
) {
return this.requests.findAll(
{ employeeId, status, leaveTypeId, from, to, page, limit },
actorFrom(user),
);
}
@Get(":id")
@HrStaff(HR_PERMS.leave.viewOwn)
@ApiOperation({ summary: "One leave request" })
findOne(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
return this.requests.findOne(id, actorFrom(user));
}
@Patch(":id/approve")
// L1 or L2 — see the guard on "awaiting-me" above. The service's own
// assertCanDecide() already expects an L2 holder to arrive here ("anyone
// else arriving is HR acting above the line manager — allowed").
@HrStaff([HR_PERMS.leave.approveL1, HR_PERMS.leave.approveL2])
@ApiOperation({
summary: "Approve",
description:
"Deducts the days in the same transaction. The balance is re-checked " +
"first, because other requests may have been approved since this one was " +
"submitted. Nobody can approve their own request.",
})
approve(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: DecideLeaveRequestDto,
@CurrentUser() user: TCurrentUser,
) {
return this.requests.approve(id, dto.note, actorFrom(user));
}
@Patch(":id/reject")
@HrStaff([HR_PERMS.leave.approveL1, HR_PERMS.leave.approveL2])
@ApiOperation({ summary: "Reject, with a reason" })
reject(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: RejectLeaveRequestDto,
@CurrentUser() user: TCurrentUser,
) {
return this.requests.reject(id, dto.note, actorFrom(user));
}
@Patch(":id/withdraw")
@HrStaff(HR_PERMS.leave.create)
@ApiOperation({
summary: "Withdraw before a decision",
description: "Nothing was deducted, so nothing comes back.",
})
withdraw(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
return this.requests.withdraw(id, actorFrom(user));
}
@Patch(":id/cancel")
@HrStaff(HR_PERMS.leave.cancel)
@ApiOperation({
summary: "Cancel approved leave",
description:
"Reverses the deduction so the days return. The reversal is posted, not " +
"deleted — the balance history keeps showing that the leave was booked " +
"and then called off.",
})
cancel(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: CancelLeaveRequestDto,
@CurrentUser() user: TCurrentUser,
) {
return this.requests.cancel(id, dto.reason, actorFrom(user));
}
}

View File

@@ -0,0 +1,49 @@
import { Body, Controller, Get, Patch } from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, 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 } from "../../../seed/hr-permissions.registry";
import { LeaveSettingsService } from "../services/leave-settings.service";
import { UpdateLeaveSettingsDto } from "../dto/leave-settings.dto";
@ApiTags("leave-settings")
@ApiBearerAuth()
@Controller("leave-settings")
@HrStaff([HR_PERMS.leave.manageType, HR_PERMS.leave.viewOwn])
export class LeaveSettingsController {
constructor(private readonly settingsService: LeaveSettingsService) {}
@Get()
// Readable by anyone who can request leave: the weekend definition is what
// turns their dates into a day count, so hiding it makes the number arbitrary.
@HrStaff(HR_PERMS.leave.viewOwn)
@ApiOperation({
summary: "This organization's leave policy",
description:
"Returns defaults when nothing has been configured — a row is only written " +
"once somebody changes something, so an unconfigured organization stays " +
"distinguishable from one that happens to agree with the defaults.",
})
find(@CurrentUser() user: TCurrentUser) {
return this.settingsService.find(actorFrom(user));
}
@Patch()
@HrStaff(HR_PERMS.leave.manageType)
@ApiOperation({
summary: "Change the leave policy",
description:
"Creates the row on first change. Note weekendDays: the statutory day " +
"figures assume a six-day week, so an employer working MondayFriday must " +
"set [0,6] here for the counts to mean what the statute intends.",
})
update(
@Body() dto: UpdateLeaveSettingsDto,
@CurrentUser() user: TCurrentUser,
) {
return this.settingsService.update(dto, actorFrom(user));
}
}

View File

@@ -0,0 +1,121 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseIntPipe,
ParseUUIDPipe,
Patch,
Post,
Query,
} from "@nestjs/common";
import {
ApiBearerAuth,
ApiOperation,
ApiQuery,
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 } from "../../../seed/hr-permissions.registry";
import { LeaveTypesService } from "../services/leave-types.service";
import { CreateLeaveTypeDto, UpdateLeaveTypeDto } from "../dto/leave-type.dto";
@ApiTags("leave-types")
@ApiBearerAuth()
@Controller("leave-types")
@HrStaff([HR_PERMS.leave.manageType, HR_PERMS.leave.viewOwn])
export class LeaveTypesController {
constructor(private readonly leaveTypesService: LeaveTypesService) {}
@Post()
@HrStaff(HR_PERMS.leave.manageType)
@ApiOperation({ summary: "Add a leave type" })
create(@Body() dto: CreateLeaveTypeDto, @CurrentUser() user: TCurrentUser) {
return this.leaveTypesService.create(dto, actorFrom(user));
}
@Post("seed-statutory")
@HrStaff(HR_PERMS.leave.manageType)
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: "Seed the statutory catalogue",
description:
"Creates the leave types set out in Labour Proclamation 1156/2019. " +
"Idempotent by code, and existing types are never modified — an employer " +
"who has edited a figure keeps their edit.",
})
@ApiResponse({ status: 200, description: "{ created: [], skipped: [] }" })
seed(@CurrentUser() user: TCurrentUser) {
return this.leaveTypesService.seedStatutory(actorFrom(user));
}
@Get()
// Anyone who can request leave needs the catalogue to choose from.
@HrStaff(HR_PERMS.leave.viewOwn)
@ApiOperation({ summary: "List leave types" })
@ApiQuery({ name: "search", required: false })
@ApiQuery({ name: "isActive", required: false, type: Boolean })
findAll(
@CurrentUser() user: TCurrentUser,
@Query("search") search?: string,
@Query("isActive") isActive?: string,
@Query("page", new ParseIntPipe({ optional: true })) page?: number,
@Query("limit", new ParseIntPipe({ optional: true })) limit?: number,
) {
return this.leaveTypesService.findAll(
{
search,
isActive: isActive === undefined ? undefined : isActive === "true",
page,
limit,
},
actorFrom(user),
);
}
@Get(":id")
@HrStaff(HR_PERMS.leave.viewOwn)
@ApiOperation({ summary: "One leave type" })
findOne(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
return this.leaveTypesService.findOne(id, actorFrom(user));
}
@Patch(":id")
@HrStaff(HR_PERMS.leave.manageType)
@ApiOperation({ summary: "Change a leave type" })
update(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateLeaveTypeDto,
@CurrentUser() user: TCurrentUser,
) {
return this.leaveTypesService.update(id, dto, actorFrom(user));
}
@Delete(":id")
@HrStaff(HR_PERMS.leave.manageType)
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({
summary: "Retire a leave type",
description:
"Soft delete. Requests and balances already recorded against it keep " +
"resolving — deactivating (isActive: false) is the gentler option and " +
"removes it from pickers without touching history.",
})
remove(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
return this.leaveTypesService.remove(id, actorFrom(user));
}
}

View File

@@ -0,0 +1,49 @@
import { ApiProperty, ApiPropertyOptional, PartialType } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsBoolean,
IsDateString,
IsEnum,
IsObject,
IsOptional,
ValidateNested,
} from "class-validator";
import { EHolidayType } from "../entities/holiday.entity";
import { LocalizedTextDto } from "./leave-type.dto";
export class CreateHolidayDto {
@ApiProperty({ format: "date", example: "2026-09-11" })
@IsDateString()
observedOn!: string;
@ApiProperty({ type: LocalizedTextDto })
@IsObject()
@ValidateNested()
@Type(() => LocalizedTextDto)
name!: LocalizedTextDto;
@ApiPropertyOptional({ enum: EHolidayType, default: EHolidayType.PUBLIC })
@IsOptional()
@IsEnum(EHolidayType)
holidayType?: EHolidayType;
@ApiPropertyOptional({
default: false,
description:
"True for lunar holidays whose date is not confirmed until close to the day.",
})
@IsOptional()
@IsBoolean()
isEstimated?: boolean;
@ApiPropertyOptional({
default: false,
description: "Observed but still worked — the day does not close the office.",
})
@IsOptional()
@IsBoolean()
isWorkingDay?: boolean;
}
export class UpdateHolidayDto extends PartialType(CreateHolidayDto) {}

View File

@@ -0,0 +1,52 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import {
IsDateString,
IsNumber,
IsOptional,
IsString,
MaxLength,
} from "class-validator";
import { ELedgerEntryType } from "../entities/leave-ledger-entry.entity";
export class AdjustBalanceDto {
@ApiProperty({
description:
"Signed. Positive credits days, negative removes them. Posted as an " +
"ADJUSTMENT entry — nothing is overwritten.",
example: -2.5,
})
@IsNumber()
days!: number;
@ApiProperty({
maxLength: 256,
description: "Required. An unexplained adjustment is indefensible later.",
})
@IsString()
@MaxLength(256)
reason!: string;
@ApiPropertyOptional({ format: "date", description: "Defaults to today." })
@IsOptional()
@IsDateString()
effectiveOn?: string;
}
export class ReverseEntryDto {
@ApiProperty({ maxLength: 256 })
@IsString()
@MaxLength(256)
reason!: string;
}
export class LedgerEntryResponseDto {
@ApiProperty() id!: string;
@ApiProperty({ enum: ELedgerEntryType }) entryType!: ELedgerEntryType;
@ApiProperty({ description: "Signed days." }) days!: string;
@ApiProperty({ format: "date" }) effectiveOn!: string;
@ApiPropertyOptional() reason?: string | null;
@ApiPropertyOptional() sourceType?: string | null;
@ApiPropertyOptional() sourceId?: string | null;
@ApiPropertyOptional() reversesId?: string | null;
}

View File

@@ -0,0 +1,88 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import {
IsBoolean,
IsDateString,
IsOptional,
IsString,
IsUUID,
MaxLength,
} from "class-validator";
export class CreateLeaveRequestDto {
@ApiPropertyOptional({
format: "uuid",
description:
"iam.employees.id. Omit to file for yourself; supplying someone else's " +
"is an HR action and needs a staff account.",
})
@IsOptional()
@IsUUID()
employeeId?: string;
@ApiProperty({ format: "uuid" })
@IsUUID()
leaveTypeId!: string;
@ApiProperty({ format: "date", example: "2026-09-14" })
@IsDateString()
startDate!: string;
@ApiProperty({ format: "date", example: "2026-09-18" })
@IsDateString()
endDate!: string;
@ApiPropertyOptional({
default: false,
description: "Only for a single date, and only if the type allows it.",
})
@IsOptional()
@IsBoolean()
isHalfDay?: boolean;
@ApiPropertyOptional({ maxLength: 512 })
@IsOptional()
@IsString()
@MaxLength(512)
reason?: string;
@ApiPropertyOptional({ maxLength: 64, description: "Phone number while away." })
@IsOptional()
@IsString()
@MaxLength(64)
contactDuringLeave?: string;
@ApiPropertyOptional({
format: "uuid",
description:
"hr.employee_documents.id — the medical certificate or equivalent. " +
"Required when the type demands evidence beyond a threshold.",
})
@IsOptional()
@IsUUID()
attachmentDocumentId?: string;
}
export class DecideLeaveRequestDto {
@ApiPropertyOptional({ maxLength: 512 })
@IsOptional()
@IsString()
@MaxLength(512)
note?: string;
}
export class RejectLeaveRequestDto {
@ApiProperty({
maxLength: 512,
description: "Required — a refusal without a reason is not actionable.",
})
@IsString()
@MaxLength(512)
note!: string;
}
export class CancelLeaveRequestDto {
@ApiProperty({ maxLength: 512 })
@IsString()
@MaxLength(512)
reason!: string;
}

View File

@@ -0,0 +1,74 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsEnum,
IsInt,
IsOptional,
IsString,
Max,
MaxLength,
Min,
} from "class-validator";
import { ELeaveYearBasis } from "../entities/leave-settings.entity";
export class UpdateLeaveSettingsDto {
@ApiPropertyOptional({ enum: ELeaveYearBasis })
@IsOptional()
@IsEnum(ELeaveYearBasis)
leaveYearBasis?: ELeaveYearBasis;
@ApiPropertyOptional({ minimum: 1, maximum: 12, default: 7 })
@IsOptional()
@IsInt()
@Min(1)
@Max(12)
fiscalYearStartMonth?: number;
@ApiPropertyOptional({ minimum: 1, maximum: 31, default: 8 })
@IsOptional()
@IsInt()
@Min(1)
@Max(31)
fiscalYearStartDay?: number;
@ApiPropertyOptional({
type: [Number],
example: [0, 6],
description:
"Non-working days of the week, 0 = Sunday … 6 = Saturday. Defaults to [0], " +
"the six-day week the statutory leave figures assume. Set [0,6] for a " +
"MondayFriday employer.",
})
@IsOptional()
@IsArray()
@ArrayMaxSize(6)
@IsInt({ each: true })
@Min(0, { each: true })
@Max(6, { each: true })
weekendDays?: number[];
@ApiPropertyOptional({ minimum: 0, maximum: 24, default: 6 })
@IsOptional()
@IsInt()
@Min(0)
@Max(24)
carryOverDeadlineMonths?: number;
@ApiPropertyOptional({
default: false,
description:
"Whether a request may be approved that takes a balance below zero.",
})
@IsOptional()
@IsBoolean()
allowNegativeBalance?: boolean;
@ApiPropertyOptional({ maxLength: 64 })
@IsOptional()
@IsString()
@MaxLength(64)
statuteReference?: string;
}

View File

@@ -0,0 +1,151 @@
import { ApiProperty, ApiPropertyOptional, PartialType } from "@nestjs/swagger";
import { Type } from "class-transformer";
import {
IsBoolean,
IsEnum,
IsInt,
IsNotEmpty,
IsNumberString,
IsObject,
IsOptional,
IsString,
Max,
MaxLength,
Min,
ValidateNested,
} from "class-validator";
import {
EAccrualMethod,
EGenderRestriction,
} from "../entities/leave-type.entity";
export class LocalizedTextDto {
@ApiProperty() @IsString() @IsNotEmpty() am!: string;
@ApiProperty() @IsString() @IsNotEmpty() en!: string;
}
export class CreateLeaveTypeDto {
@ApiProperty({ maxLength: 32, example: "ANNUAL" })
@IsString()
@IsNotEmpty()
@MaxLength(32)
code!: string;
@ApiProperty({ type: LocalizedTextDto })
@IsObject()
@ValidateNested()
@Type(() => LocalizedTextDto)
name!: LocalizedTextDto;
@ApiPropertyOptional({ type: LocalizedTextDto })
@IsOptional()
@IsObject()
@ValidateNested()
@Type(() => LocalizedTextDto)
description?: LocalizedTextDto;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
isPaid?: boolean;
@ApiPropertyOptional({ enum: EAccrualMethod, default: EAccrualMethod.ANNUAL_ENTITLEMENT })
@IsOptional()
@IsEnum(EAccrualMethod)
accrualMethod?: EAccrualMethod;
// Day figures are numeric strings, matching how pg returns numeric — passing
// them as JS numbers is how a 0.5-day balance becomes 0.49999999999999994.
@ApiPropertyOptional({ example: "16.00" })
@IsOptional()
@IsNumberString()
baseDaysPerYear?: string;
@ApiPropertyOptional({ example: "1.00" })
@IsOptional()
@IsNumberString()
extraDaysPerPeriod?: string;
@ApiPropertyOptional({ minimum: 0, maximum: 50, example: 2 })
@IsOptional()
@IsInt()
@Min(0)
@Max(50)
servicePeriodYears?: number;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@IsNumberString()
maxDaysPerYear?: string | null;
@ApiPropertyOptional({ example: "16.00" })
@IsOptional()
@IsNumberString()
maxCarryOverDays?: string;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@IsNumberString()
maxConsecutiveDays?: string | null;
@ApiPropertyOptional({ minimum: 0, maximum: 240, default: 0 })
@IsOptional()
@IsInt()
@Min(0)
@Max(240)
minServiceMonths?: number;
@ApiPropertyOptional({ enum: EGenderRestriction, default: EGenderRestriction.ANY })
@IsOptional()
@IsEnum(EGenderRestriction)
genderRestriction?: EGenderRestriction;
@ApiPropertyOptional({
default: true,
description:
"False for types counted in consecutive calendar days, such as maternity.",
})
@IsOptional()
@IsBoolean()
countsWorkingDaysOnly?: boolean;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
allowsHalfDay?: boolean;
@ApiPropertyOptional({
nullable: true,
description: "Days beyond which evidence is mandatory. Null = never.",
})
@IsOptional()
@IsNumberString()
requiresAttachmentAfter?: string | null;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
requiresApproval?: boolean;
@ApiPropertyOptional({ maxLength: 64, example: "Proc. 1156/2019 Art. 77" })
@IsOptional()
@IsString()
@MaxLength(64)
statuteReference?: string | null;
@ApiPropertyOptional({ default: 100 })
@IsOptional()
@IsInt()
@Min(0)
@Max(1000)
sortOrder?: number;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
/** `code` stays editable: unlike an employee id it identifies nothing external. */
export class UpdateLeaveTypeDto extends PartialType(CreateLeaveTypeDto) {}

View File

@@ -0,0 +1,61 @@
import { SoftDeleteAudit } from "@tria-plc/api-common/modules/typeorm/audit.entity";
import { Column, Entity, Index, PrimaryGeneratedColumn } from "typeorm";
export enum EHolidayType {
PUBLIC = "PUBLIC",
RELIGIOUS = "RELIGIOUS",
/** An employer's own closure — not a national holiday. */
ORGANIZATION = "ORGANIZATION",
}
/**
* One observed holiday on one date.
*
* Stored as concrete dates rather than a recurring month/day rule, because most
* Ethiopian holidays are not fixed in the Gregorian calendar: Ethiopian-calendar
* dates shift by a day around leap years, and the Islamic holidays move roughly
* eleven days earlier each year. A rule engine here would be wrong every year;
* a row per occurrence is simply right.
*/
@Entity({ schema: "hr", name: "holidays" })
@Index("idx_holidays_observed_on", ["observedOn"])
export class Holiday extends SoftDeleteAudit {
@PrimaryGeneratedColumn("uuid")
id!: string;
/** `null` = national, applying to every organization. */
@Column({ type: "uuid", name: "organization_id", nullable: true })
organizationId?: string | null;
@Column({ type: "date", name: "observed_on" })
observedOn!: string;
@Column({ type: "jsonb", name: "name" })
name!: { am: string; en: string };
@Column({
type: "varchar",
length: 16,
name: "holiday_type",
default: EHolidayType.PUBLIC,
})
holidayType!: EHolidayType;
/**
* True for the lunar holidays, whose date depends on a sighting and is only
* confirmed close to the day. Shown to users so a leave calculation that
* straddles one is not mistaken for settled.
*/
@Column({ type: "boolean", name: "is_estimated", default: false })
isEstimated!: boolean;
/** Recorded but still worked — the day is observed without closing. */
@Column({ type: "boolean", name: "is_working_day", default: false })
isWorkingDay!: boolean;
@Column({ type: "uuid", name: "created_by", nullable: true })
createdBy?: string | null;
@Column({ type: "uuid", name: "updated_by", nullable: true })
updatedBy?: string | null;
}

View File

@@ -0,0 +1,89 @@
import { SoftDeleteAudit } from "@tria-plc/api-common/modules/typeorm/audit.entity";
import {
Column,
Entity,
Index,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
} from "typeorm";
import { LeaveType } from "./leave-type.entity";
import { LeaveLedgerEntry } from "./leave-ledger-entry.entity";
/**
* One employee's envelope for one leave type in one leave year.
*
* Deliberately carries no `balance` column. The balance is the sum of the
* ledger; storing it as well would create two answers to the same question and
* guarantee that one day they disagree.
*
* `employeeId` is `iam.employees.id` — a soft reference, like everywhere else in
* this schema. `leaveTypeId` IS a real foreign key: both tables live in `hr`.
*/
@Entity({ schema: "hr", name: "leave_entitlements" })
@Index("idx_leave_entitlements_employee", ["employeeId"])
export class LeaveEntitlement extends SoftDeleteAudit {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid", name: "organization_id" })
organizationId!: string;
@Column({ type: "uuid", name: "employee_id" })
employeeId!: string;
@Column({ type: "uuid", name: "leave_type_id" })
leaveTypeId!: string;
@ManyToOne(() => LeaveType)
@JoinColumn({ name: "leave_type_id" })
leaveType?: LeaveType;
@Column({ type: "date", name: "leave_year_start" })
leaveYearStart!: string;
@Column({ type: "date", name: "leave_year_end" })
leaveYearEnd!: string;
/** What the rules said they were owed for this year, at the time of granting. */
@Column({ type: "numeric", precision: 7, scale: 2, name: "entitled_days", default: 0 })
entitledDays!: string;
@Column({
type: "numeric",
precision: 7,
scale: 2,
name: "carried_over_days",
default: 0,
})
carriedOverDays!: string;
/** Carried days lapse on this date if unused. Null = they do not lapse. */
@Column({ type: "date", name: "carry_over_expires_on", nullable: true })
carryOverExpiresOn?: string | null;
/**
* Service length used to compute `entitledDays`, recorded so a later argument
* about the figure can be settled without re-deriving it from a hire date
* that may itself have been corrected since.
*/
@Column({
type: "numeric",
precision: 5,
scale: 2,
name: "service_years_at_grant",
default: 0,
})
serviceYearsAtGrant!: string;
@OneToMany(() => LeaveLedgerEntry, (entry) => entry.entitlement)
entries?: LeaveLedgerEntry[];
@Column({ type: "uuid", name: "created_by", nullable: true })
createdBy?: string | null;
@Column({ type: "uuid", name: "updated_by", nullable: true })
updatedBy?: string | null;
}

View File

@@ -0,0 +1,87 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from "typeorm";
import { LeaveEntitlement } from "./leave-entitlement.entity";
export enum ELedgerEntryType {
/** The year's entitlement, posted when the year opens. */
GRANT = "GRANT",
/** Unused days brought forward from the previous year. */
CARRY_OVER = "CARRY_OVER",
/** Days consumed by an approved leave request. */
DEDUCTION = "DEDUCTION",
/** Carried days that lapsed unused. */
EXPIRY = "EXPIRY",
/** A manual correction by an administrator. Signed either way. */
ADJUSTMENT = "ADJUSTMENT",
/** Undoes one earlier entry — a cancelled request, a mistaken grant. */
REVERSAL = "REVERSAL",
}
/**
* One movement in one entitlement. Append-only: never updated, never deleted.
*
* `days` is signed and the database constrains the sign per type, so a
* DEDUCTION cannot add days however the calling code is written. The balance is
* `SUM(days)`, which makes it impossible for a balance to disagree with the
* history that produced it.
*
* There is no `updated_at` or `deleted_at` on purpose. An entry that turns out
* to be wrong is corrected by posting a REVERSAL against it — the record of the
* mistake is part of the audit trail, not something to be tidied away.
*/
@Entity({ schema: "hr", name: "leave_ledger_entries" })
@Index("idx_leave_ledger_entitlement", ["entitlementId"])
export class LeaveLedgerEntry {
@PrimaryGeneratedColumn("uuid")
id!: string;
@Column({ type: "uuid", name: "entitlement_id" })
entitlementId!: string;
@ManyToOne(() => LeaveEntitlement, (entitlement) => entitlement.entries)
@JoinColumn({ name: "entitlement_id" })
entitlement?: LeaveEntitlement;
@Column({ type: "varchar", length: 16, name: "entry_type" })
entryType!: ELedgerEntryType;
/** Signed: positive adds days, negative removes them. */
@Column({ type: "numeric", precision: 7, scale: 2, name: "days" })
days!: string;
/**
* The date the movement belongs to, which is not always the date it was
* recorded — a deduction is effective on the first day of the absence even if
* the approval came later.
*/
@Column({ type: "date", name: "effective_on" })
effectiveOn!: string;
@Column({ type: "varchar", length: 256, name: "reason", nullable: true })
reason?: string | null;
/** What caused this, e.g. `LEAVE_REQUEST`. Null for a manual adjustment. */
@Column({ type: "varchar", length: 24, name: "source_type", nullable: true })
sourceType?: string | null;
@Column({ type: "uuid", name: "source_id", nullable: true })
sourceId?: string | null;
/** Set only on a REVERSAL, and only once — enforced by a unique index. */
@Column({ type: "uuid", name: "reverses_id", nullable: true })
reversesId?: string | null;
@Column({ type: "uuid", name: "created_by", nullable: true })
createdBy?: string | null;
@CreateDateColumn({ type: "timestamptz", name: "created_at" })
createdAt!: Date;
}

Some files were not shown because too many files have changed in this diff Show More