feat(evaluations): add evaluation service
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
15145f613a
commit
a21770e54e
2 changed files with 872 additions and 0 deletions
396
src/lib/evaluations/index.ts
Normal file
396
src/lib/evaluations/index.ts
Normal file
|
|
@ -0,0 +1,396 @@
|
||||||
|
import {
|
||||||
|
getPerformanceLevel,
|
||||||
|
isPerformanceLevelId,
|
||||||
|
PERFORMANCE_LEVELS,
|
||||||
|
type PerformanceLevel,
|
||||||
|
type PerformanceLevelId,
|
||||||
|
} from "./levels";
|
||||||
|
|
||||||
|
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
|
||||||
|
|
||||||
|
/** 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<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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<EligibilityResult> {
|
||||||
|
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<LeaderEvaluationAggregate> {
|
||||||
|
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<string, number>();
|
||||||
|
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 };
|
||||||
|
}
|
||||||
476
tests/int/evaluations.int.spec.ts
Normal file
476
tests/int/evaluations.int.spec.ts
Normal file
|
|
@ -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<User> => {
|
||||||
|
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<Mission> => {
|
||||||
|
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<void> => {
|
||||||
|
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<string, unknown>;
|
||||||
|
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(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue