# PerformanceReviews — Calibration Calibration detail for the PerformanceReviews module. Read in conjunction with [./AGENTS.md](./AGENTS.md). --- ## `PerformanceReviewCalibrationService` Calibration of subordinate evaluations. Only applies when the cycle has a `PerformanceReviewFormTemplate` of type `SUBORDINATE_REVIEW` with `formReviewConfig.withCalibration: true` and status `IN_PROGRESS`. **Prerequisite always validated**: cycle must be `IN_PROGRESS` and the subordinate template must have `withCalibration: true`. Otherwise throws `BadRequestError` with `ErrorCodes.CALIBRATION_NOT_ENABLED`. **Calibration Rules (backoffice)** - `createCalibrationRule()` — Creates a rule with calibrators audience + calibrated audience; upserts both segmentable entities in audiences (`CALIBRATION_RULE_CALIBRATORS`, `CALIBRATION_RULE_CALIBRATED`); creates `FormReview` instances of type `CALIBRATION_REVIEW` for all eligible calibrated users (intersection with target users) and their junction rows - `getCalibrationRules()` — Lists all rules for the cycle; resolves up to 6 calibrator preview users via audiences port with signed URLs - `getCalibrationRule()` — Gets a single rule by ID; throws `NotFoundError` if it does not belong to the cycle - `updateCalibrationRule()` — Updates audiences; re-upserts both segmentable entities; re-syncs FormReviews and junction rows; deletes unclaimed orphans; idempotent if the new audience has the same calibrated users - `deleteCalibrationRule()` — Deletes junction rows, the rule, and both segmentable entities; cleans up orphans; idempotent if the rule does not exist **Calibration on Target User Addition** - `createCalibrationFormReviewsForUser()` — Called by `PerformanceReviewsService.addTargetUsers()` when the cycle is `IN_PROGRESS`; uses `getRuleIdsWhereUserIsCalibrated` to see whether the new target user matches any calibrated rule for the instance, then creates the calibration `FormReview` + junction rows when applicable **Calibration Membership Sync (segmentation change)** - `syncCalibratedRulesForUser()` — Public. Declarative reconciliation of the user's `CALIBRATION_REVIEW` `FormReview`s and junctions across every `IN_PROGRESS` cycle whose subordinate template has `withCalibration: true`. Reads post-change state from the `CALIBRATION_RULE_CALIBRATED` audiences index; per cycle, diffs new vs. existing junctions, creates the missing FormReview if needed, removes stale junctions, and runs `deleteOrphanedFormReviews` to clean up unclaimed orphans. Claimed/filled FormReviews are preserved when the user no longer matches any rule. Idempotent. **App Calibration** - `listVisibleCalibratedUsers()` — Returns calibrated users visible to the logged-in calibrator (filtered by rules that include them as a calibrator); **only users whose `SUBORDINATE_REVIEW` FormReview for the cycle has `status = FINISHED` are returned**; paginated; supports `q` (name search) and `answered` (whether calibration was already filled) - `getCalibrationDetail()` — Returns calibration FormReview detail: validates access (rule via audiences), reads the finished subordinate review; if the subordinate is complete and calibration is not yet answered, returns a snapshot of the subordinate form without prior answers so the calibrator can fill it; if calibration was already answered, returns the calibration answers - `fillCalibration()` — Completes calibration: validates access via audience, performs atomic claim (`UPDATE ... WHERE reviewerId IS NULL`); if already claimed by another calibrator throws `RequestConflictError` with `ErrorCodes.CALIBRATION_ALREADY_CLAIMED`; delegates the actual fill to `PerformanceReviewsService.fillReview()` - `getClaimedCalibrationReviewForReviewed()` — Given a `cycleId` + `reviewedId`, finds the `CALIBRATION_REVIEW` FormReview for that reviewed user in that cycle that has already been claimed (`reviewerId IS NOT NULL`). Returns `null` if calibration is not enabled on the template, or if no calibrator has claimed it yet. Used in three places: (1) `getReview` / `getVisibleReview` controller methods to include the calibration form in the review detail response; (2) `PerformanceReviewsService.fillReview()` guard — blocks re-fill when `type === SUBORDINATE_REVIEW && status === EDIT_PENDING` and calibration is already claimed; (3) `PerformanceReviewsService.requestReviewEdit()` guard — blocks creating an edit request for any `SUBORDINATE_REVIEW` when calibration is already claimed. **Key Private Methods** - `validateCycleForRuleCreation()` — Guard reused by create/update/delete: validates `IN_PROGRESS` status + `withCalibration` - `validateCalibrationAccess()` — Verifies that the logged user is a calibrator for the FormReview via audiences port + junction table - `buildCalibrationReviewFromSubordinateSnapshot()` — Builds a calibration `PerformanceReviewFormReview` by copying the subordinate's structure (goals, competencies) without answers, to present the calibrator with context - `deleteOrphanedFormReviews()` — Deletes calibration FormReviews that have no junction rows and no assigned reviewer (orphans) - `syncFormReviewsForUpdatedRule()` — When a rule is updated: deletes current junctions, recreates with new audience, cleans up orphans --- ## Dangerous Zones 1. **Atomic claim race condition (calibration).** `fillCalibration` does `UPDATE ... WHERE reviewerId IS NULL`. If two calibrators call `fillCalibration` concurrently, only one succeeds. The loser gets `RequestConflictError (CALIBRATION_ALREADY_CLAIMED)`. This is intentional. Never pre-check `reviewerId` and then update in a separate query — that removes the atomicity guarantee. 2. **Orphaned `CALIBRATION_REVIEW` FormReviews.** A calibration FormReview with no junction rows in `PerformanceReviewCalibrationFormReviewRules` and `reviewerId = null` is an orphan. These accumulate silently if `deleteOrphanedFormReviews()` is not called after every rule update/delete. Always call it after mutating rules or junction rows. 3. **Most `PerformanceReviewQueueService` handlers are stubs, but `USER_ITEMS_CHANGE` and `SEGMENTATION_ITEM_USERS_CHANGE` are wired.** Both delegate to `PerformanceReviewCalibrationService.syncCalibratedRulesForUser` to reconcile calibration membership. The other three (`USER_CREATION`, `USER_DELETION`, `SEGMENTATION_ITEM_DELETION`) still return `Promise.resolve()`. Do not assume side effects from those. 4. **`withCalibration` is a config field on the subordinate `FormTemplate`, not on the cycle.** Accessing it requires: `cycle.getFormTemplate(SUBORDINATE_REVIEW)` → `isSubordinateReviewConfig(template.formReviewConfig)` → `template.formReviewConfig.withCalibration`. Skipping the type guard silently gives `undefined`. 5. **Calibration only works in `IN_PROGRESS` cycles.** All rule mutations (create/update/delete) are guarded by `validateCycleForRuleCreation()`, which throws `REVIEW_CYCLE_INVALID_STATUS` if the cycle is not `IN_PROGRESS`. Do not attempt to backfill calibration data in other cycle states. 6. **Unit test gotcha: always mock `getClaimedCalibrationReviewForReviewed` when the `reviewForm.type` can be `SUBORDINATE_REVIEW`.** `jest-mock-extended` returns `undefined` for unmocked methods. When `fillReview` or `requestReviewEdit` runs with a subordinate review and calibration has been claimed, the guard does `await calibrationService.getClaimedCalibrationReviewForReviewed(...)`. If the mock is missing, `await undefined` returns `undefined`, and `undefined !== null` is `true` → throws `BadRequestError`. In the `beforeEach` of any test describe that can produce a `SUBORDINATE_REVIEW` form, add: `calibrationServiceMock.getClaimedCalibrationReviewForReviewed.mockResolvedValue(null)`. Tests that explicitly cover the blocking scenario override it with `mockResolvedValueOnce(someClaimedReview)`. --- ## Key Patterns ### Calibration Prerequisite Calibration **only exists** if: 1. The cycle has a `PerformanceReviewFormTemplate` with `evaluationType === SUBORDINATE_REVIEW` 2. That template has `formReviewConfig.withCalibration: true` 3. The cycle is in `IN_PROGRESS` status Always verify with `isSubordinateReviewConfig(template.formReviewConfig) && template.formReviewConfig.withCalibration`. ### Atomic Claim The `reviewerId` of a `CALIBRATION_REVIEW` starts as `null`. The first calibrator to call `fillCalibration` performs `UPDATE ... WHERE reviewerId IS NULL RETURNING *`. If no row was updated → another calibrator already claimed it → throw `RequestConflictError`. This avoids race conditions without table-level locking. ### Orphaned FormReviews A `CALIBRATION_REVIEW` is an orphan when it has no junction rows in `PerformanceReviewCalibrationFormReviewRules` AND its `reviewerId` is `null`. These must be cleaned up with `deleteOrphanedFormReviews()` after every rule update/delete to avoid accumulating ownerless reviews. ### getCalibratedUsers Visibility Filter A calibrated user only appears in the `GET /calibrations` list when **both** conditions hold: 1. Their `SUBORDINATE_REVIEW` FormReview for the same `cycleId` has `status = FINISHED`. 2. Their `CALIBRATION_REVIEW` FormReview's `reviewerId` satisfies the caller's `answered` filter: `null` (unclaimed), `calibratorId` (claimed by this calibrator), or either (default). The `SUBORDINATE_REVIEW` condition is enforced via an inline SQL subquery in `getCalibratedUsers` — no separate query runs. A user whose boss hasn't completed the subordinate review is silently excluded even if a calibration FormReview and junction row already exist for them. ### reviewedFormReviews Filter for Calibrated Users `PerformanceReviewsService.findVisibleCycleByPk()` (used by `GET /app/performance-reviews/:id`) filters the `reviewedFormReviews` list visible to the reviewed user when calibration applies: - If a `CALIBRATION_REVIEW` for the user has been claimed (`reviewerId IS NOT NULL`) → show **only** the `CALIBRATION_REVIEW`, hide the `SUBORDINATE_REVIEW`. - If no `CALIBRATION_REVIEW` has been claimed yet → show **only** the `SUBORDINATE_REVIEW`. - If `withCalibration: false` or no calibration rule covers the user → no change, all FormReviews pass through unchanged. This prevents leaking unclaimed `CALIBRATION_REVIEW` entries into the app list and ensures the reviewed user always sees exactly one review for the subordinate direction. ### Audiences and Segmentable Entity Each calibration rule registers **two** segmentable entities in the audiences module (via `PerformanceReviewCalibrationAudiencesPort`): - `SEGMENTABLE_TYPES.CALIBRATION_RULE_CALIBRATORS` — used by `getRuleIdsWhereUserIsCalibrator(userId, instanceId)` to find which rules grant a user calibrator access. - `SEGMENTABLE_TYPES.CALIBRATION_RULE_CALIBRATED` — used by `getRuleIdsWhereUserIsCalibrated(userId, instanceId)` to find which rules currently include a user as a calibrated subordinate. Both indices avoid scanning all rules. They are upserted on rule create/update and deleted on rule delete; the membership sync logic (`syncCalibratedRulesForUser`) reads only the post-change state of the calibrated index — it does not consume the queue message diff payload. ### Calibration Score Override in Final Score Calculation When computing a target user's final score, if a `CALIBRATION_REVIEW` FormReview with a non-null score exists for that user, it **replaces** the `SUBORDINATE_REVIEW` score while keeping the subordinate's `directionWeight`. The calibration score wins regardless of its relative value. This logic lives in **`PerformanceReviewsService`** (not in `PerformanceReviewCalibrationService`): - `calculateFinalScoreFromStatsOnly` — canonical path, called from all three persist flows: `recalculateAndPersistTargetUserFinalScore`, `getPersistedScoresWithFallback`, `recalculateAndPersistAllTargetUsersFinalScoresForCycle` - `calculateFinalScoreFromTargetUser` — deprecated legacy read path only (`findVisibleCycleByPk`); delegates to private helper `resolveReviewScoresForDirection` **Invariant:** if no `CALIBRATION_REVIEW` score is present (never filled, or `withCalibration: false`), the calculation is unchanged and only the subordinate score is used. ### Permissions | Action | Required guard | |--------|---------------| | Calibration rule CRUD (backoffice) | `permissionsService.validateManageReviews(loggedUser)` | | List calibrated users / detail / fill (app) | `permissionsService.validateViewReviews(loggedUser)` | A calibrator's access to a specific `formReviewId` is validated in the service via `validateCalibrationAccess()` (audiences port + junction table) — separate from the instance-level permission. --- ## HTTP Endpoints ### Backoffice — Calibration Rules (`{BACKOFFICE}/performance-reviews/:id/calibration/rules`) | Method | Path | Controller method | |--------|------|-------------------| | POST | `/` | `createCalibrationRule` | | GET | `/` | `listCalibrationRules` | | GET | `/:ruleId` | `getCalibrationRule` | | PUT | `/:ruleId` | `updateCalibrationRule` | | DELETE | `/:ruleId` | `removeCalibrationRule` | ### App — Calibration (`{APP}/performance-reviews/:id/calibrations`) | Method | Path | Controller method | |--------|------|-------------------| | GET | `/` | `listCalibratedUsers` | | GET | `/:formReviewId` | `getReviewDetail` | | POST | `/:formReviewId/fill` | `fillReview` | --- ## Calibration DAOs | DAO | Table | Description | |-----|-------|-------------| | `performanceReviewCalibrationRuleDAO.ts` | `PerformanceReviewCalibrationRules` | Rule with `calibratorsAudience` and `calibratedAudience` (JSONB); UUID v7 PK | | `performanceReviewCalibrationFormReviewRuleDAO.ts` | `PerformanceReviewCalibrationFormReviewRules` | N:M junction between `formReviewId` and `calibrationRuleId` | --- ## Domain Models | Model | Description | |-------|-------------| | `PerformanceReviewCalibrationRule` / `ForCreation` | Rule: `instanceId`, `cycleId`, `calibratorsAudience`, `calibratedAudience` (JSON `SegmentationExpressionConditions`) | | `PerformanceReviewCalibrationFormReviewRule` / `ForCreation` | Junction: `formReviewId`, `calibrationRuleId`, `instanceId` | | `CalibrationRuleWithUsers` | Rule + calibrator preview (up to 6 users) + `calibratorsCount` total | | `CalibratedUser` | App list row: `formReviewId`, `reviewerId` (null = unclaimed), basic data of the reviewed user | --- ## Calibration Ports | Port | Adapter | Responsibility | |------|---------|----------------| | `PerformanceReviewCalibrationRepositoryPort` | `performanceReviewCalibrationRepositoryAdapter.ts` | CRUD rules, junction rows, calibration FormReview reads, atomic claim, subordinate review by cycle/reviewed, orphan cleanup; `getInProgressCalibrationCyclesForUser`; `deleteFormReviewRules`; `getCalibratedUsers` filters via SQL subquery — only `reviewedId`s with a finished `SUBORDINATE_REVIEW` for the cycle are included | | `PerformanceReviewCalibrationAudiencesPort` | `performanceReviewCalibrationAudiencesAdapter.ts` | Upsert/delete two segmentable entities per rule (`CALIBRATION_RULE_CALIBRATORS`, `CALIBRATION_RULE_CALIBRATED`); resolve calibrator user IDs (`getCalibratorUserIds`), calibrated user IDs (`getCollaboratorUserIds`), rules where a user is a calibrator (`getRuleIdsWhereUserIsCalibrator`) or where a user is calibrated (`getRuleIdsWhereUserIsCalibrated`) | --- ## Integration Tests ``` humand-packages/monolith/test-integration/api/performanceReviews/ ``` | File | Covers | |------|--------| | `calibrationRules.test.ts` | Rules CRUD, audience validation, cycle status constraints, permissions/capabilities | | `calibrationReview.test.ts` | List calibrated users, detail (subordinate snapshot), fill, claim conflict, flow with completed subordinate | | `calibrationFormSync.test.ts` | `syncCalibratedRulesForUser` end-to-end: user becomes/stops being calibrated via segmentation change, claimed FormReview preservation, no-op on non-IN_PROGRESS cycles or when `withCalibration: false` | | `performanceReviewsCalibration.test.ts` | `withCalibration` flag on subordinate template with/without scoring (PUT/PATCH); calibration form in review detail for app and backoffice (before fill, after fill); `reviewedFormReviews` filtering for calibrated user in cycle detail | | `performanceReviewsSubordinate.test.ts` *(ver también AGENTS.md)* | `reviewedFormReviews` filtering: subordinate review visible before calibration is claimed, calibration review replaces it after claim | | `performanceReviewsScore.test.ts` *(ver también AGENTS.md)* | Calibration score override: verifica que el score de calibración reemplaza al del subordinado en el score final del target user | --- ## Keeping This Document Up to Date - **New calibration rule or change to the claim flow** → update Key Patterns - **New calibration DAO or port** → update Calibration DAOs / Calibration Ports sections - **New calibration endpoint** → update HTTP Endpoints - **New calibration integration test file** → add a row to Integration Tests The document is wrong if it describes code that no longer exists, or omits code that does. Keep it exact.