From a21770e54eb20cb8c502189e7638ee4d2b8e32f6 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Thu, 27 Aug 2026 18:36:04 -0400 Subject: [PATCH] feat(evaluations): add evaluation service Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/lib/evaluations/index.ts | 396 +++++++++++++++++++++++++ tests/int/evaluations.int.spec.ts | 476 ++++++++++++++++++++++++++++++ 2 files changed, 872 insertions(+) create mode 100644 src/lib/evaluations/index.ts create mode 100644 tests/int/evaluations.int.spec.ts diff --git a/src/lib/evaluations/index.ts b/src/lib/evaluations/index.ts new file mode 100644 index 0000000..21d5af1 --- /dev/null +++ b/src/lib/evaluations/index.ts @@ -0,0 +1,396 @@ +import { + getPerformanceLevel, + isPerformanceLevelId, + PERFORMANCE_LEVELS, + type PerformanceLevel, + type PerformanceLevelId, +} from "./levels"; + +type PayloadType = Awaited>; + +/** The three evaluation kinds, per the leadership-evaluations feature. */ +export type EvaluationKind = "leader-direct" | "leader-indirect" | "subordinate"; + +/** Shape of an evaluations doc as consumed by this service (depth 0). */ +export interface EvaluationDoc { + id: number; + rater: number; + ratee: number; + mission: number; + kind: EvaluationKind; + level: PerformanceLevelId; + comment: string | null; + createdAt: string; + updatedAt: string; +} + +/** + * Minimum number of distinct raters before a leader aggregate is visible to anyone. + * Below this threshold the per-level breakdown and average are withheld so a small + * group of raters cannot be identified from the displayed distribution. + */ +export const ANONYMITY_THRESHOLD = 3; + +/** + * Pure eligibility check for whether a mission is "completed/past" enough to be + * evaluated. Draft statuses (Concept/Planning/Ready) and Cancelled are never + * eligible. Scheduled/Active missions become eligible once their scheduled start + * has passed. + */ +export function isMissionEvaluable( + status: string, + startDateTimeISO?: string | null, + now: Date = new Date(), +): boolean { + if (status === "Completed") return true; + if (status === "Scheduled" || status === "Active") { + if (!startDateTimeISO) return false; + const start = new Date(startDateTimeISO).getTime(); + if (Number.isNaN(start)) return false; + return start < now.getTime(); + } + return false; +} + +/** + * Documented participation proxy: this codebase has no actual participant list or + * mission-leader field on missions. A "yes" RSVP in `mission-attendances` is the + * evidence we treat as "this user took part in the mission". Used for leader + * ratings (direct and indirect). Subordinate ratings do not require it — the + * command relationship itself is the basis. + */ +export async function hasYesAttendance( + payload: PayloadType, + userId: number, + missionId: number, +): Promise { + const res = await payload.find({ + collection: "mission-attendances", + where: { + and: [ + { user: { equals: userId } }, + { mission: { equals: missionId } }, + { response: { equals: "yes" } }, + ], + }, + limit: 1, + depth: 0, + overrideAccess: true, + }); + return res.docs.length > 0; +} + +function extractIds(value: unknown): number[] { + if (!Array.isArray(value)) return []; + const ids: number[] = []; + for (const v of value) { + if (typeof v === "number") ids.push(v); + else if (v && typeof v === "object" && typeof (v as { id?: unknown }).id === "number") { + ids.push((v as { id: number }).id); + } + } + return ids; +} + +/** True when `leaderId` leads an assignment whose members include `subordinateId`. */ +export async function isDirectLeaderOf( + payload: PayloadType, + leaderId: number, + subordinateId: number, +): Promise { + const assignments = await payload.find({ + collection: "assignments", + where: { leader: { equals: leaderId } }, + depth: 0, + overrideAccess: true, + }); + return assignments.docs.some((a) => extractIds(a.members).includes(subordinateId)); +} + +/** True when `userId` leads at least one assignment. */ +export async function isAnyLeader(payload: PayloadType, userId: number): Promise { + const res = await payload.find({ + collection: "assignments", + where: { leader: { equals: userId } }, + limit: 1, + depth: 0, + overrideAccess: true, + }); + return res.docs.length > 0; +} + +export interface EligibilityResult { + eligible: boolean; + kind?: EvaluationKind; + reason?: string; +} + +/** + * Determines whether `raterId` may evaluate `rateeId` for `missionId`, and under + * which kind. Rules (leadership-evaluations feature): + * + * - You cannot evaluate yourself. + * - The mission must be completed/past (see {@link isMissionEvaluable}). + * - `leader-direct`: the ratee directly leads the rater's assignment AND the rater + * participated in the mission (yes-RSVP proxy). + * - `subordinate`: the rater directly leads the ratee's assignment. No RSVP + * required — the command relationship itself is the basis. + * - `leader-indirect`: the rater participated in the mission and the ratee leads at + * least one assignment, but did not directly lead the rater. + */ +export async function evaluateEligibility( + payload: PayloadType, + input: { raterId: number; rateeId: number; missionId: number }, +): Promise { + const { raterId, rateeId, missionId } = input; + + if (raterId === rateeId) { + return { eligible: false, reason: "You cannot evaluate yourself." }; + } + + const missions = await payload.find({ + collection: "missions", + where: { id: { equals: missionId } }, + limit: 1, + depth: 0, + overrideAccess: true, + }); + const mission = missions.docs[0] as + | { + ownershipAndStatus?: { status?: string }; + classification?: { startDateTime?: string | null }; + } + | undefined; + if (!mission) return { eligible: false, reason: "Mission not found." }; + + const status = mission.ownershipAndStatus?.status ?? ""; + const startDateTime = mission.classification?.startDateTime; + if (!isMissionEvaluable(status, startDateTime)) { + return { eligible: false, reason: "This mission is not completed or past yet." }; + } + + const participated = await hasYesAttendance(payload, raterId, missionId); + const rateeLeadsRater = await isDirectLeaderOf(payload, rateeId, raterId); + if (rateeLeadsRater) { + if (!participated) { + return { eligible: false, reason: "No attendance recorded for this mission." }; + } + return { eligible: true, kind: "leader-direct" }; + } + + const raterLeadsRatee = await isDirectLeaderOf(payload, raterId, rateeId); + if (raterLeadsRatee) { + return { eligible: true, kind: "subordinate" }; + } + + if (participated && (await isAnyLeader(payload, rateeId))) { + return { eligible: true, kind: "leader-indirect" }; + } + + return { eligible: false, reason: "No leadership relationship for this mission." }; +} + +export interface UpsertEvaluationInput { + raterId: number; + rateeId: number; + missionId: number; + kind: EvaluationKind; + level: PerformanceLevelId; + comment?: string; +} + +/** + * One row per (rater, ratee, mission, kind). An existing evaluation for that tuple + * is updated in place; otherwise a new one is created. Enforced here in the service + * layer — there is deliberately no DB constraint to hand-maintain. + */ +export async function upsertEvaluation( + payload: PayloadType, + input: UpsertEvaluationInput, +): Promise<{ evaluation: EvaluationDoc; created: boolean }> { + if (!isPerformanceLevelId(input.level)) { + throw new Error(`Unknown performance level: ${String(input.level)}`); + } + + const existing = await payload.find({ + collection: "evaluations", + where: { + and: [ + { rater: { equals: input.raterId } }, + { ratee: { equals: input.rateeId } }, + { mission: { equals: input.missionId } }, + { kind: { equals: input.kind } }, + ], + }, + limit: 1, + depth: 0, + overrideAccess: true, + }); + + if (existing.docs.length > 0) { + const updated = await payload.update({ + collection: "evaluations", + id: existing.docs[0].id, + data: { level: input.level, comment: input.comment ?? null }, + depth: 0, + overrideAccess: true, + }); + return { evaluation: updated as unknown as EvaluationDoc, created: false }; + } + + const created = await payload.create({ + collection: "evaluations", + data: { + rater: input.raterId, + ratee: input.rateeId, + mission: input.missionId, + kind: input.kind, + level: input.level, + comment: input.comment ?? null, + }, + depth: 0, + overrideAccess: true, + }); + return { evaluation: created as unknown as EvaluationDoc, created: true }; +} + +export interface LeaderEvaluationAggregate { + rateeId: number; + /** Distinct raters across all leader-kind evaluations. */ + totalRaters: number; + /** False while raters are below ANONYMITY_THRESHOLD — breakdown withheld. */ + visible: boolean; + levels: { + id: string; + label: string; + score: number; + color: string; + count: number; + percentage: number; + }[] | null; + averageScore: number | null; + missions: LeaderMissionAggregate[]; +} + +export interface LeaderMissionAggregate { + missionId: number; + missionName: string; + missionCodeName: string; + startDateTime: string | null; + totalRaters: number; + percentage: number | null; +} + +/** + * Anonymous aggregate of leader-kind evaluations (direct + indirect) for a ratee. + * The per-level breakdown and average are only exposed once at least + * ANONYMITY_THRESHOLD distinct raters have evaluated — below that, `visible` is + * false and the numbers are null so no rater can be identified. No rater identity + * or comment ever appears in the output. + */ +export async function getLeaderAggregate( + payload: PayloadType, + rateeId: number, +): Promise { + const res = await payload.find({ + collection: "evaluations", + where: { + and: [{ ratee: { equals: rateeId } }, { kind: { in: ["leader-direct", "leader-indirect"] } }], + }, + depth: 0, + overrideAccess: true, + }); + const docs = res.docs as unknown as EvaluationDoc[]; + const totalRaters = new Set(docs.map((d) => d.rater)).size; + + if (totalRaters < ANONYMITY_THRESHOLD) { + return { rateeId, totalRaters, visible: false, levels: null, averageScore: null, missions: [] }; + } + + const counts = new Map(); + for (const d of docs) { + counts.set(d.level, (counts.get(d.level) ?? 0) + 1); + } + const totalDocs = docs.length; + const levels = PERFORMANCE_LEVELS.map((l) => { + const count = counts.get(l.id) ?? 0; + return { + id: l.id, + label: l.label, + score: l.score, + color: l.color, + count, + percentage: totalDocs ? Math.round((count / totalDocs) * 10000) / 100 : 0, + }; + }).filter((l) => l.count > 0); + + const averageScore = totalDocs + ? Math.round( + (docs.reduce((sum, d) => sum + (getPerformanceLevel(d.level)?.score ?? 0), 0) / + totalDocs) * + 100, + ) / 100 + : 0; + + const missionIds = [...new Set(docs.map((doc) => doc.mission))]; + const missionRows = await Promise.all( + missionIds.map(async (missionId) => { + const missionResult = await payload.findByID({ + collection: "missions", + id: missionId, + depth: 0, + overrideAccess: true, + }); + const missionDocs = docs.filter((doc) => doc.mission === missionId); + const missionRaters = new Set(missionDocs.map((doc) => doc.rater)).size; + const missionScore = missionDocs.reduce( + (sum, doc) => sum + (getPerformanceLevel(doc.level)?.score ?? 0), + 0, + ); + return { + missionId, + missionName: missionResult.name, + missionCodeName: missionResult.codeName, + startDateTime: missionResult.classification?.startDateTime ?? null, + totalRaters: missionRaters, + percentage: + missionRaters >= ANONYMITY_THRESHOLD && missionDocs.length > 0 + ? Math.round((missionScore / missionDocs.length) * 100) / 100 + : null, + } satisfies LeaderMissionAggregate; + }), + ); + missionRows.sort((a, b) => { + const dateA = a.startDateTime ? new Date(a.startDateTime).getTime() : 0; + const dateB = b.startDateTime ? new Date(b.startDateTime).getTime() : 0; + return dateB - dateA; + }); + + return { rateeId, totalRaters, visible: true, levels, averageScore, missions: missionRows.slice(0, 5) }; +} + +/** + * The most recent subordinate-kind evaluation for a ratee, reduced to the level + * definition plus mission and timestamp. Rater identity and comment are private + * and intentionally absent from this output. + */ +export async function getLatestSubordinateLevel( + payload: PayloadType, + rateeId: number, +): Promise<{ level: PerformanceLevel; missionId: number; at: string } | null> { + const res = await payload.find({ + collection: "evaluations", + where: { + and: [{ ratee: { equals: rateeId } }, { kind: { equals: "subordinate" } }], + }, + sort: "-updatedAt", + limit: 1, + depth: 0, + overrideAccess: true, + }); + const doc = res.docs[0] as unknown as EvaluationDoc | undefined; + if (!doc) return null; + const level = getPerformanceLevel(doc.level); + if (!level) return null; + return { level, missionId: doc.mission, at: doc.updatedAt }; +} diff --git a/tests/int/evaluations.int.spec.ts b/tests/int/evaluations.int.spec.ts new file mode 100644 index 0000000..1ddd54b --- /dev/null +++ b/tests/int/evaluations.int.spec.ts @@ -0,0 +1,476 @@ +import { getPayload, Payload } from "payload"; +import config from "@/payload.config"; + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import type { Campaign, Map as MissionMap, Mission, Role, User } from "@/payload-types"; +import { invalidatePermissionCache } from "@/utils/access-control/loadUserPermissions"; +import { PERFORMANCE_LEVELS } from "@/lib/evaluations/levels"; +import { + ANONYMITY_THRESHOLD, + evaluateEligibility, + getLatestSubordinateLevel, + getLeaderAggregate, + isMissionEvaluable, + upsertEvaluation, +} from "@/lib/evaluations"; + +let payload: Payload; + +const RUN = `evl-${Date.now().toString(36)}`; + +describe("Leadership evaluations", () => { + let mapId: number; + let campaignId: number; + let leaderA: User; + let subordinateB: User; + let participantC: User; + let participantE: User; + let noRsvpF: User; + let outsiderD: User; + let assignmentId: number; + let missionCompletedId: number; + let missionFutureId: number; + + const userIds: number[] = []; + const attendanceIds: number[] = []; + const evaluationIds: number[] = []; + + const makeUser = async (label: string): Promise => { + const user = (await payload.create({ + collection: "users", + data: { + username: `${RUN}-${label}`, + discordUsername: `${RUN}-${label}`, + displayName: label.toUpperCase(), + steamId: `7656119${Math.floor(Math.random() * 1e9)}`, + password: "Test123", + roles: ["user"], + }, + overrideAccess: true, + depth: 0, + })) as unknown as User; + userIds.push(user.id); + return user; + }; + + const makeMission = async ( + label: string, + status: Mission["ownershipAndStatus"]["status"], + start: Date, + ): Promise => { + const mission = (await payload.create({ + collection: "missions", + data: { + name: `${RUN} ${label}`, + codeName: `${RUN}-${label}`, + summary: "Leadership evaluation test mission", + operationType: "main", + classification: { + map: mapId, + missionType: "PvE", + campaign: campaignId, + startDateTime: start.toISOString(), + estimatedDuration: 60, + }, + ownershipAndStatus: { + authors: [leaderA.id], + status, + visibility: "unit", + }, + missionRoles: { + maxPlayers: 16, + }, + gameDetails: { + serverDetails: { + serverIp: "127.0.0.1", + serverPort: 2302, + }, + }, + briefing: [], + }, + overrideAccess: true, + depth: 0, + })) as unknown as Mission; + return mission; + }; + + const rsvp = async (user: User, missionId: number): Promise => { + const attendance = await payload.create({ + collection: "mission-attendances", + data: { mission: missionId, user: user.id, response: "yes" }, + overrideAccess: true, + depth: 0, + }); + attendanceIds.push(attendance.id); + }; + + beforeAll(async () => { + const payloadConfig = await config; + payload = await getPayload({ config: payloadConfig }); + + const map = (await payload.create({ + collection: "maps", + data: { name: `${RUN} Map` }, + overrideAccess: true, + depth: 0, + })) as unknown as MissionMap; + mapId = map.id; + + const campaign = (await payload.create({ + collection: "campaigns", + data: { name: `${RUN} Campaign`, summary: "Evaluation test campaign", status: "concept" }, + overrideAccess: true, + depth: 0, + })) as unknown as Campaign; + campaignId = campaign.id; + + leaderA = await makeUser("leader-a"); + subordinateB = await makeUser("subordinate-b"); + participantC = await makeUser("participant-c"); + participantE = await makeUser("participant-e"); + noRsvpF = await makeUser("no-rsvp-f"); + outsiderD = await makeUser("outsider-d"); + + const assignment = await payload.create({ + collection: "assignments", + data: { + name: `${RUN} Squad`, + type: "squad", + leader: leaderA.id, + members: [subordinateB.id, noRsvpF.id], + }, + overrideAccess: true, + depth: 0, + }); + assignmentId = assignment.id; + + const past = new Date(); + past.setDate(past.getDate() - 7); + const future = new Date(); + future.setDate(future.getDate() + 14); + + const missionCompleted = await makeMission("completed", "Completed", past); + missionCompletedId = missionCompleted.id; + const missionFuture = await makeMission("future-scheduled", "Scheduled", future); + missionFutureId = missionFuture.id; + + // Participation proxy: yes-RSVPs for B, C, E. F and D deliberately have none. + await rsvp(subordinateB, missionCompletedId); + await rsvp(participantC, missionCompletedId); + await rsvp(participantE, missionCompletedId); + }); + + afterAll(async () => { + const evaluations = await payload + .find({ + collection: "evaluations", + where: { mission: { in: [missionCompletedId, missionFutureId] } }, + limit: 100, + depth: 0, + overrideAccess: true, + }) + .catch(() => null); + for (const doc of evaluations?.docs ?? []) { + await payload.delete({ collection: "evaluations", id: doc.id, overrideAccess: true }).catch( + () => {}, + ); + } + evaluationIds.length = 0; + + for (const id of attendanceIds) { + await payload + .delete({ collection: "mission-attendances", id, overrideAccess: true }) + .catch(() => {}); + } + await payload.delete({ collection: "assignments", id: assignmentId, overrideAccess: true }).catch( + () => {}, + ); + for (const id of [missionCompletedId, missionFutureId]) { + await payload.delete({ collection: "missions", id, overrideAccess: true }).catch(() => {}); + } + await payload + .delete({ collection: "campaigns", id: campaignId, overrideAccess: true }) + .catch(() => {}); + await payload.delete({ collection: "maps", id: mapId, overrideAccess: true }).catch(() => {}); + + for (const userId of userIds) { + const accounts = await payload + .find({ + collection: "bank-accounts", + where: { ownerUser: { equals: userId } }, + limit: 5, + depth: 0, + overrideAccess: true, + }) + .catch(() => null); + for (const account of accounts?.docs ?? []) { + await payload + .delete({ collection: "bank-accounts", id: account.id, overrideAccess: true }) + .catch(() => {}); + } + const profiles = await payload + .find({ + collection: "profiles", + where: { user: { equals: userId } }, + limit: 5, + depth: 0, + overrideAccess: true, + }) + .catch(() => null); + for (const profile of profiles?.docs ?? []) { + await payload + .delete({ collection: "profiles", id: profile.id, overrideAccess: true }) + .catch(() => {}); + } + await payload.delete({ collection: "users", id: userId, overrideAccess: true }).catch(() => {}); + } + }); + + it("classifies mission statuses for evaluability (pure)", () => { + const past = new Date(Date.now() - 86_400_000).toISOString(); + const future = new Date(Date.now() + 86_400_000).toISOString(); + + expect(isMissionEvaluable("Completed")).toBe(true); + expect(isMissionEvaluable("Scheduled", past)).toBe(true); + expect(isMissionEvaluable("Active", past)).toBe(true); + expect(isMissionEvaluable("Scheduled", future)).toBe(false); + expect(isMissionEvaluable("Ready", past)).toBe(false); + expect(isMissionEvaluable("Cancelled", past)).toBe(false); + expect(isMissionEvaluable("Scheduled")).toBe(false); + expect(isMissionEvaluable("Scheduled", "not-a-date")).toBe(false); + }); + + it("derives the evaluation kind from the rater/ratee/mission relationship", async () => { + const cases = [ + { input: { raterId: subordinateB.id, rateeId: leaderA.id }, expectKind: "leader-direct" }, + { input: { raterId: participantC.id, rateeId: leaderA.id }, expectKind: "leader-indirect" }, + { input: { raterId: leaderA.id, rateeId: subordinateB.id }, expectKind: "subordinate" }, + ]; + for (const { input, expectKind } of cases) { + const result = await evaluateEligibility(payload, { ...input, missionId: missionCompletedId }); + expect(result.eligible).toBe(true); + expect(result.kind).toBe(expectKind); + } + + // In A's assignment but never RSVPed yes — the participation proxy fails. + const noRsvp = await evaluateEligibility(payload, { + raterId: noRsvpF.id, + rateeId: leaderA.id, + missionId: missionCompletedId, + }); + expect(noRsvp.eligible).toBe(false); + expect(noRsvp.reason).toBe("No attendance recorded for this mission."); + + // No leadership relationship at all. + const outsider = await evaluateEligibility(payload, { + raterId: outsiderD.id, + rateeId: leaderA.id, + missionId: missionCompletedId, + }); + expect(outsider.eligible).toBe(false); + + // Self-rating is always rejected. + const selfRating = await evaluateEligibility(payload, { + raterId: leaderA.id, + rateeId: leaderA.id, + missionId: missionCompletedId, + }); + expect(selfRating.eligible).toBe(false); + + // A not-yet-past mission is not evaluable. + const tooEarly = await evaluateEligibility(payload, { + raterId: subordinateB.id, + rateeId: leaderA.id, + missionId: missionFutureId, + }); + expect(tooEarly.eligible).toBe(false); + }); + + it("keeps one row per rater/ratee/mission/kind", async () => { + const first = await upsertEvaluation(payload, { + raterId: subordinateB.id, + rateeId: leaderA.id, + missionId: missionCompletedId, + kind: "leader-direct", + level: "excellent", + comment: "First pass", + }); + evaluationIds.push(first.evaluation.id); + expect(first.created).toBe(true); + + const second = await upsertEvaluation(payload, { + raterId: subordinateB.id, + rateeId: leaderA.id, + missionId: missionCompletedId, + kind: "leader-direct", + level: "proficient", + comment: "Revised", + }); + expect(second.created).toBe(false); + expect(second.evaluation.id).toBe(first.evaluation.id); + expect(second.evaluation.level).toBe("proficient"); + + const rows = await payload.find({ + collection: "evaluations", + where: { + and: [ + { rater: { equals: subordinateB.id } }, + { ratee: { equals: leaderA.id } }, + { mission: { equals: missionCompletedId } }, + { kind: { equals: "leader-direct" } }, + ], + }, + limit: 10, + depth: 0, + overrideAccess: true, + }); + expect(rows.docs).toHaveLength(1); + }); + + it("withholds the leader aggregate below the anonymity threshold", async () => { + const c = await upsertEvaluation(payload, { + raterId: participantC.id, + rateeId: leaderA.id, + missionId: missionCompletedId, + kind: "leader-indirect", + level: "excellent", + }); + evaluationIds.push(c.evaluation.id); + + const aggregate = await getLeaderAggregate(payload, leaderA.id); + expect(aggregate.totalRaters).toBe(2); + expect(ANONYMITY_THRESHOLD).toBe(3); + expect(aggregate.visible).toBe(false); + expect(aggregate.levels).toBeNull(); + expect(aggregate.averageScore).toBeNull(); + }); + + it("exposes the leader aggregate once the anonymity threshold is met", async () => { + const e = await upsertEvaluation(payload, { + raterId: participantE.id, + rateeId: leaderA.id, + missionId: missionCompletedId, + kind: "leader-indirect", + level: "exceptional", + }); + evaluationIds.push(e.evaluation.id); + + const aggregate = await getLeaderAggregate(payload, leaderA.id); + expect(aggregate.totalRaters).toBe(3); + expect(aggregate.visible).toBe(true); + expect(aggregate.levels).not.toBeNull(); + expect(aggregate.averageScore).toBe(85); // (70 + 85 + 100) / 3 + + const byId = new Map((aggregate.levels ?? []).map((l) => [l.id, l])); + expect(byId.get("exceptional")?.count).toBe(1); + expect(byId.get("excellent")?.count).toBe(1); + expect(byId.get("proficient")?.count).toBe(1); + expect(byId.get("adequate")).toBeUndefined(); + for (const level of aggregate.levels ?? []) { + expect(level.percentage).toBeCloseTo(33.33, 1); + expect(PERFORMANCE_LEVELS.some((p) => p.id === level.id)).toBe(true); + } + }); + + it("returns only the latest subordinate level, without rater data", async () => { + await upsertEvaluation(payload, { + raterId: leaderA.id, + rateeId: subordinateB.id, + missionId: missionCompletedId, + kind: "subordinate", + level: "adequate", + comment: "Manager note — must never leak", + }); + + const revised = await upsertEvaluation(payload, { + raterId: leaderA.id, + rateeId: subordinateB.id, + missionId: missionCompletedId, + kind: "subordinate", + level: "excellent", + }); + evaluationIds.push(revised.evaluation.id); + + const latest = await getLatestSubordinateLevel(payload, subordinateB.id); + expect(latest).not.toBeNull(); + expect(latest?.level.id).toBe("excellent"); + expect(latest?.missionId).toBe(missionCompletedId); + expect(typeof latest?.at).toBe("string"); + expect(latest).not.toHaveProperty("rater"); + expect(latest).not.toHaveProperty("comment"); + + const none = await getLatestSubordinateLevel(payload, participantC.id); + expect(none).toBeNull(); + }); + + it("hides raw evaluation documents from users without the permission", async () => { + // Local ops bypass access control unless overrideAccess: false — this flag + // makes findOperation run the same requirePermission("evaluations:read") + // check that the REST endpoint runs. A user with no access gets a Forbidden + // error rather than an empty list. + await expect( + payload.find({ + collection: "evaluations", + limit: 50, + depth: 0, + user: outsiderD, + overrideAccess: false, + }), + ).rejects.toThrow(/not allowed/); + }); + + it("exposes raw evaluation documents to users holding evaluations:read", async () => { + const role = (await payload.create({ + collection: "roles", + data: { + name: `${RUN} Evaluation Reader`, + slug: `${RUN}-evaluation-reader`, + permissions: ["evaluations:read"], + }, + overrideAccess: true, + depth: 0, + })) as unknown as Role; + + const reader = (await payload.create({ + collection: "users", + data: { + username: `${RUN}-reader`, + discordUsername: `${RUN}-reader`, + displayName: "READER", + steamId: `7656119${Math.floor(Math.random() * 1e9)}`, + password: "Test123", + roles: ["user"], + }, + overrideAccess: true, + depth: 0, + })) as unknown as User; + userIds.push(reader.id); + + await payload.update({ + collection: "users", + id: reader.id, + data: { roleDocs: [role.id] }, + overrideAccess: true, + depth: 0, + }); + invalidatePermissionCache(); + + try { + const result = await payload.find({ + collection: "evaluations", + limit: 50, + depth: 0, + user: reader, + overrideAccess: false, + }); + expect(result.docs.length).toBeGreaterThan(0); + const doc = result.docs[0] as unknown as Record; + expect(typeof doc.rater).toBe("number"); + expect(typeof doc.ratee).toBe("number"); + expect(typeof doc.mission).toBe("number"); + expect(["leader-direct", "leader-indirect", "subordinate"]).toContain(doc.kind); + } finally { + await payload.delete({ collection: "roles", id: role.id, overrideAccess: true }).catch(() => {}); + } + }); +});