Appraisals module
Staff performance appraisals and the probation/confirmation workflow they close. Backend in ethica-api/src/appraisals/ (plus src/staff/staff-confirmation-due.service.ts and the probation rule in src/leaves/leaves.service.ts); ERP under ethica-erp/app/(auth)/u/admin/appraisals/.
Migrations: 0079 (Staff employment columns) and 0080 (the eight appraisal tables + departments.appraisalTemplateId).
Business-facing guide: Appraisals.
Data model
All eight tables are tenant-scoped (registered in TENANT_SCOPED_TABLES, RLS ENABLE + FORCE + <table>_tenant_isolation policy in 0080).
| Table | Purpose |
|---|---|
appraisal_kpi_templates | HR-editable departmental KPI forms — name, description, isArchived (archive, never delete). |
appraisal_kpi_template_items | Template rows — categoryLabel, categoryWeight (denormalised onto every row of the category), objective, kpiDescription, targetGuidance, weight, sortOrder (runs across the whole form, not per category). |
appraisal_catalog_items | The four org-wide lists in one table — kind ∈ risk_compliance_kpi | competency | managerial_competency | behaviour, label, description, weight, targetGuidance (nullable; only the risk list has one), sortOrder. |
appraisals | The parent row: staffId, reviewType, periodFrom/periodTo (DATEONLY), roleCategory, isManagerRole, the chain snapshot (lineManagerStaffId NOT NULL, departmentHeadStaffId nullable), templateId + templateName, status, per-stage …At/…ById stamps, mdComment, the six HR-Use-Only columns, and the five finalized summary columns. |
appraisal_items | One scored row: section ∈ dept_kpi | risk_kpi | competency | managerial | behaviour, the copied definition (categoryLabel, label, description, targetGuidance, weight, sortOrder) and the answers (actualAchievement, selfScore, supervisorScore, supervisorComment). DB CHECK on both scores: BETWEEN 1 AND 5, NULL = unrated. |
appraisal_narratives | Free-text questions — section ∈ self_assessment | supervisor_assessment | career_discussion | employee_comment, questionKey, snapshotted questionText, answer, sortOrder. employee_comment is instantiated with no rows (its flow is pinned out). |
appraisal_plan_rows | Two grids in one table — kind ∈ development | pip; the other kind's columns are NULL. |
appraisal_reviews | The sign-off trail — stage, actorStaffId, action ('approved' only in v1), comment, createdAt. |
departments.appraisalTemplateId (nullable FK) is the assignment; NULL means the General / Other fallback.
The snapshot design
Initiation copies the resolved template's rows and the four catalogs into appraisal_items, and the fixed question set (NARRATIVE_QUESTIONS in appraisals.service.ts) into appraisal_narratives. Nothing reads a template at scoring time. Consequences: template/catalog edits never touch an appraisal in flight and never restate a finalized one; templateName preserves provenance if the template row later goes away; the reviewing chain is likewise snapshotted, so a re-org cannot re-route work in flight.
DECIMAL weights are copied string-to-string from template to item (Sequelize returns DECIMAL as a string); they only go through Number() inside the scoring engine.
Uniques (three, two partial)
| Index | Definition | Guards |
|---|---|---|
appraisal_kpi_templates_org_live_name_key | ("organizationId", name) WHERE NOT "isArchived" | Two live templates may not share a name; archived names may repeat. Also closes the concurrent-boot double-seed race. |
appraisals_org_staff_open_key | ("organizationId", "staffId") WHERE status <> 'finalized' | One open appraisal per person; finalized history accumulates. |
appraisal_narratives_org_appraisal_key_key | ("organizationId", "appraisalId", "questionKey") | One row per key per appraisal, across sections — the save DTOs address narratives by key alone, so keys are globally unique. |
Each unique backs a rule the service also checks: the service owns the sentence a human reads, the index closes the SELECT-then-INSERT race. UniqueConstraintError on initiation is translated back into the same ALREADY_IN_PROGRESS 400.
Status machine
AppraisalStatus is a STRING column, not a Postgres enum (a decline/send-back path is expected; appraisal-models.spec.ts enforces the members instead).
draft (unused) → pending_self → pending_lm → [pending_dept_head] → pending_md → pending_hr → finalized
| Status | Actor | Authorization |
|---|---|---|
draft | — | Never produced by any endpoint; a spare slot the column can carry without a migration. |
pending_self | The subject | appraisal.staffId === caller.id |
pending_lm | Snapshot line manager | appraisal.lineManagerStaffId === caller.id |
pending_dept_head | Snapshot department head | departmentHeadStaffId !== null && === caller.id. Skipped entirely when the snapshot is NULL — submitReview routes straight to pending_md. |
pending_md | The MD | caller.role === ROLES.MD (a role, not a snapshot) |
pending_hr | The HR desk | appraisals:hr_manage (a permission), or admin |
finalized | — | Terminal; every stage guard fails a status precondition, so the form is read-only. |
Chain resolution at initiation (resolveChain): the subject's own id is nulled out of the chain first (a supervisor who IS the subject counts as nobody — head auto-enrollment makes every department head a member of their own department, so their own appraisal would otherwise resolve them as their own reviewer); then lm = staff.lineManagerId ?? department.supervisorStaffId; deptHead = department.supervisorStaffId only when it differs from the resolved LM. No resolvable reviewer — including a head with no line-manager override — ⇒ 400 (NO_REVIEWER).
404 vs 403 posture
detailand every snapshot-matched stage action answer 404 ('Appraisal not found') to a caller with no part in the appraisal — identical to a genuine miss, so the module is not an existence oracle for "whose review is running".- The HR stage and
scope=allanswer 403, and the permission is checked before the row is loaded, so a real id and an invented one are indistinguishable to a caller without the permission. - The HR-Use-Only block (
HR_ONLY_FIELDS) isdeleted from the serialized payload for any caller withouthr_manage— server-side, including from the subject's own line manager.
Concurrency and the trail
Every advance is an UPDATE … WHERE id = ? AND status = <from-status>; zero affected rows raises the same wrong-stage 400 a late click gets. The appraisal_reviews row is written in the same transaction as the status change, so a stage cannot advance without leaving a signature. Events (appraisal.started, appraisal.stage_advanced, appraisal.finalized) are emitted after a re-read, never before; AppraisalAlertsListener is @DetachedListener() and wraps its writes in withTenantRls. In-app notifications only — no email fan-out.
Scoring engine
src/appraisals/appraisal-scoring.ts — pure, no Nest, no models. Ported verbatim to the ERP as utils/appraisalScoring.ts (the browser computes the live preview; the server computes the finalized stamp).
The ERP form page (app/(auth)/u/admin/appraisals/[id]/) renders as a free-navigation stepper — StepperRail (per-desk completion counts), one section component per step, and a ReviewStep recap that hosts every advance (submit, approvals, and HR's HrFinalizePanel). The draft is page-level state, so step changes lose nothing; failed submit validation jumps to the step holding the first gap before scrolling to it. The score panel suppresses the preview entirely before pending_lm (no supervisor scores can exist yet).
contribution(item) = weight × (supervisorScore / 5) // NULL ⇒ 0
sectionScore(s) = Σ contribution over items of section s
kpiScore = 0.8 × deptKpi + 0.2 × riskKpi // KPI_BLEND
competencyScore = sectionScore(isManagerRole ? 'managerial' : 'competency')
behaviourScore = sectionScore('behaviour')
overallScore = round4(w.kpi×kpi + w.competency×competency + w.behaviour×behaviour)
overallBand = bandOf(overallScore) // read off the ROUNDED overall
SECTION_WEIGHTS: sales 0.7/0.2/0.1 · operations 0.5/0.3/0.2 · senior_executive 0.5/0.3/0.2 · other 0.6/0.25/0.15.
bandOf: ≥0.9 outstanding · ≥0.8 exceeds_expectations · ≥0.7 meets_expectations · ≥0.6 needs_improvement · else unsatisfactory.
Self-scores feed nothing. On a manager's form both competency sets are instantiated and both must be scored (assertScored covers every instantiated row) but only the managerial set participates in the arithmetic — hence itemCount/ratedCount (participating rows) differ from the submit precondition's count (all rows). The ERP SummaryPanel displays both counts for that reason.
Weight validation lives in appraisal-settings.service.ts: assertTemplateWeights (every weight > 0, categories sum to 1 ±0.001, each category's items sum to that category's weight ±0.001) and assertCatalogWeights (rows sum to 1 ±0.01 — the looser tolerance is required because DECIMAL(6,4) stores 1/15 as 0.0667 and the fifteen seeded competencies read back as 1.0005).
Probationary finalize → Staff.employmentConfirmedAt
AppraisalsService.finalize:
canManage(403) before any load.appraisal.staffId === staff.id⇒ 403SELF_FINALIZE— this is what makes the confirmation a second person's act (StaffService.updateStaffrefuses a self-confirmation directly; here the workflow supplies the second person).assertStage('hr'),assertOutcome(outcomerequired iffreviewType === 'probationary', refused otherwise),assertScored('supervisorScore').- One transaction:
UPDATE appraisals … WHERE id AND status='pending_hr'stamping the four scores + band +finalizedAt/ById; thenconfirmEmploymentwhen probationary +outcome: 'confirm'; then the trail row.
confirmEmployment is a direct model write, not a StaffService call:
- Org-filtered —
Staffis AUTO_SCOPE_EXEMPT and RLS-exempt, sowhere: { id, organizationId }is the only tenant protection. - In the finalizing transaction — "closed" and "confirmed" cannot come apart.
- Already confirmed ⇒ silent skip; the first confirmation date stands.
- Future start date ⇒
BadRequestException, which rolls the whole finalize back.StaffService.assertEmploymentDatesrejectsemploymentConfirmedAt < employmentStartDateon every later edit, so stamping today onto a not-yet-started hire would create a row the staff module then refuses to save.
FinalizeAppraisalDto.outcome is a union of one ('confirm'); the value is not a column — it drives the write and rides the appraisal.finalized event.
Probation rule (leave module)
src/leaves/leaves.service.ts:
PROBATION_CASUAL_DAYS = 5— a named constant, not a policy row.prepareSubmission(covers create and resubmit):employmentConfirmedAt == nullanddto.type !== 'casual'⇒ 400 "Until your employment is confirmed, only casual leave can be requested."applyProbation(rows)overlays the balances:casualcomputes against 5 (5 + adjustments − consumed, so HR corrections still apply); every other type is returnedentitlementDays: 0, available: 0, locked: true, withconsumedstill reported. Applied inbalances(self and HR-read-for-other) and in the internal balance check. It is exported and reused by the year-end carry-forward engine, so a probation year cannot turn locked entitlements into carried credits.- The casual override applies even where the policy leaves casual untracked.
Confirmation-due cron
StaffConfirmationDueService — @Cron('0 10 * * *', { name: 'staff-confirmation-due' }), in the 07:00–10:00 notification band, clear of every journal-posting job.
- House shape:
runWithTenant(systemBypass(), () => withCronLock(sequelize, JOB_NAME, …)), then a loop over organizations with a shortwithTenantRlsper staff item,try/catchoutside each transaction. - Query (explicitly
organizationId-filtered —Staffis hooks- and RLS-exempt):employmentConfirmedAt IS NULL,confirmationDueNotifiedAt IS NULL,employmentStartDate <= cutoff,isBlocked IS NOT TRUE. CONFIRMATION_DUE_MONTHS = 6;monthsAgoDateOnlyclamps to the last day of the target month (six months before Aug 31 is Feb 28/29, never rolled into March).- Notification (role-targeted
hr) and theconfirmationDueNotifiedAtstamp commit together — once per staff member, ever. The ERP derives its persistent "Due for confirmation" badge client-side fromemploymentStartDate+employmentConfirmedAt, so a missed notification cannot hide anyone.
Endpoints
staff/appraisals (AppraisalsController)
| Method | Path | Permission | Notes |
|---|---|---|---|
| POST | / | appraisals:hr_manage | Initiation: resolves template + chain, snapshots the form, lands in pending_self. |
| GET | / | appraisals:respond | ?scope= mine (default) | queue | all; all requires hr_manage (403) and accepts staffId/status. |
| GET | /pending-count | appraisals:respond | Sidebar badge; same membership as scope=queue, counted. Declared above :id so the literal segment is not parsed as a uuid. |
| GET | /:id | appraisals:respond | Full graph; 404 for a caller with no part in it; HR block stripped for non-HR. |
| PATCH | /:id/self | appraisals:respond | selfScore, actualAchievement, self_assessment answers only. |
| POST | /:id/self/submit | appraisals:respond | Requires every row self-scored and all six self questions answered. |
| PATCH | /:id/review | appraisals:respond | supervisorScore, supervisorComment, supervisor + career-discussion answers, plan rows (replace-set per kind). |
| POST | /:id/review/submit | appraisals:respond | Requires every row supervisor-scored; routes to pending_dept_head or pending_md. |
| POST | /:id/dept-head/approve | appraisals:respond | Optional comment. |
| POST | /:id/md/approve | appraisals:respond | MD role enforced in the service; comment also stored as mdComment. |
| PATCH | /:id/hr | appraisals:hr_manage | The six HR-Use-Only fields. |
| POST | /:id/finalize | appraisals:hr_manage | See above. |
staff/appraisal-settings (AppraisalSettingsController)
A sibling path, not a child: staff/appraisals/:id would parse appraisal-settings as a uuid. Every route, reads included, is appraisals:hr_manage — a person being appraised reads their form through the snapshot, never through settings.
GET|POST /templates · PATCH /templates/:id · POST /templates/:id/archive · GET|PUT /catalogs/:kind · GET /department-assignments · PATCH /department-assignments.
Every mutating route on both controllers carries @AuditLog (category: 'staff_management'). The HR-block save audits field names only, never values — a promotion or disciplinary recommendation must not be readable out of an activity feed.
Permission keys: appraisals:respond (every staff role) and appraisals:hr_manage (HR + admin), group Appraisals in src/config/roles.ts.
Seeding
AppraisalSeederService.seedOrganization(orgId) runs from InitDbService per organization, inside the boot system context and a withSavepoint (appraisal content is not worth failing a deploy over).
Idempotent and all-or-nothing: if the org has any template row, it returns without writing — deliberately not a reconciling "top up what is missing", which would resurrect a Driver form an org had removed on every restart. Every write names organizationId explicitly (under bypass the tenancy hooks stand down).
Content lives in appraisal-seed-content.ts: six templates (Finance, Compliance, HR/Admin, IT, Driver / Admin Support, General / Other — the first five a single Core KPIs category at 100%, the last four categories at 40/30/15/15) and the four catalogs (4 risk KPIs at 0.25 each, 15 competencies at 1/15, 6 managerial at 1/6, 8 behaviours at 0.125). GENERAL_OTHER_TEMPLATE_NAME is the fallback the resolver looks up by name.
Pinned / deferred
- Decline, send-back and cancellation at any stage.
ReviewActionis shaped to take them without surgery, and the statuses are text columns for the same reason, but v1 has no path back — a mis-started appraisal must be walked to the end. - Non-
confirmprobationary outcomes (extend probation, do not confirm). - Email notifications and PDF export.
- The
employee_commentnarrative section — the enum slot exists, no rows are instantiated and nothing writes it. draftstatus — in the union, produced by nothing.- N/A weight redistribution — HR edits template weights instead.