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