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).

TablePurpose
appraisal_kpi_templatesHR-editable departmental KPI forms — name, description, isArchived (archive, never delete).
appraisal_kpi_template_itemsTemplate 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_itemsThe four org-wide lists in one table — kindrisk_compliance_kpi | competency | managerial_competency | behaviour, label, description, weight, targetGuidance (nullable; only the risk list has one), sortOrder.
appraisalsThe 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_itemsOne scored row: sectiondept_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_narrativesFree-text questions — sectionself_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_rowsTwo grids in one table — kinddevelopment | pip; the other kind's columns are NULL.
appraisal_reviewsThe 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)

IndexDefinitionGuards
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
StatusActorAuthorization
draftNever produced by any endpoint; a spare slot the column can carry without a migration.
pending_selfThe subjectappraisal.staffId === caller.id
pending_lmSnapshot line managerappraisal.lineManagerStaffId === caller.id
pending_dept_headSnapshot department headdepartmentHeadStaffId !== null && === caller.id. Skipped entirely when the snapshot is NULL — submitReview routes straight to pending_md.
pending_mdThe MDcaller.role === ROLES.MD (a role, not a snapshot)
pending_hrThe HR deskappraisals:hr_manage (a permission), or admin
finalizedTerminal; 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

  • detail and 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=all answer 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) is deleted from the serialized payload for any caller without hr_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 stepperStepperRail (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:

  1. canManage (403) before any load.
  2. appraisal.staffId === staff.id ⇒ 403 SELF_FINALIZE — this is what makes the confirmation a second person's act (StaffService.updateStaff refuses a self-confirmation directly; here the workflow supplies the second person).
  3. assertStage('hr'), assertOutcome (outcome required iff reviewType === 'probationary', refused otherwise), assertScored('supervisorScore').
  4. One transaction: UPDATE appraisals … WHERE id AND status='pending_hr' stamping the four scores + band + finalizedAt/ById; then confirmEmployment when probationary + outcome: 'confirm'; then the trail row.

confirmEmployment is a direct model write, not a StaffService call:

  • Org-filteredStaff is AUTO_SCOPE_EXEMPT and RLS-exempt, so where: { 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.assertEmploymentDates rejects employmentConfirmedAt < employmentStartDate on 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 == null and dto.type !== 'casual' ⇒ 400 "Until your employment is confirmed, only casual leave can be requested."
  • applyProbation(rows) overlays the balances: casual computes against 5 (5 + adjustments − consumed, so HR corrections still apply); every other type is returned entitlementDays: 0, available: 0, locked: true, with consumed still reported. Applied in balances (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 short withTenantRls per staff item, try/catch outside each transaction.
  • Query (explicitly organizationId-filtered — Staff is hooks- and RLS-exempt): employmentConfirmedAt IS NULL, confirmationDueNotifiedAt IS NULL, employmentStartDate <= cutoff, isBlocked IS NOT TRUE.
  • CONFIRMATION_DUE_MONTHS = 6; monthsAgoDateOnly clamps 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 the confirmationDueNotifiedAt stamp commit together — once per staff member, ever. The ERP derives its persistent "Due for confirmation" badge client-side from employmentStartDate + employmentConfirmedAt, so a missed notification cannot hide anyone.

Endpoints

staff/appraisals (AppraisalsController)

MethodPathPermissionNotes
POST/appraisals:hr_manageInitiation: 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-countappraisals:respondSidebar badge; same membership as scope=queue, counted. Declared above :id so the literal segment is not parsed as a uuid.
GET/:idappraisals:respondFull graph; 404 for a caller with no part in it; HR block stripped for non-HR.
PATCH/:id/selfappraisals:respondselfScore, actualAchievement, self_assessment answers only.
POST/:id/self/submitappraisals:respondRequires every row self-scored and all six self questions answered.
PATCH/:id/reviewappraisals:respondsupervisorScore, supervisorComment, supervisor + career-discussion answers, plan rows (replace-set per kind).
POST/:id/review/submitappraisals:respondRequires every row supervisor-scored; routes to pending_dept_head or pending_md.
POST/:id/dept-head/approveappraisals:respondOptional comment.
POST/:id/md/approveappraisals:respondMD role enforced in the service; comment also stored as mdComment.
PATCH/:id/hrappraisals:hr_manageThe six HR-Use-Only fields.
POST/:id/finalizeappraisals:hr_manageSee 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. ReviewAction is 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-confirm probationary outcomes (extend probation, do not confirm).
  • Email notifications and PDF export.
  • The employee_comment narrative section — the enum slot exists, no rows are instantiated and nothing writes it.
  • draft status — in the union, produced by nothing.
  • N/A weight redistribution — HR edits template weights instead.