1
0
Fork 0
polaris-task-force/src/lib/intelligence/roleSignups.ts
Z8MB1E 190ddac41f feat(missions): role sign-ups with FCFS waitlist and rich-text role descriptions
Players meeting a role's minimum rank and MOS can queue for mission roles before op time. Slot holders are recomputed statelessly from signup order plus current RSVP (first N committed signups hold the role); attendance writes diff holders around the write so RSVP changes auto-promote, with notifications and events. Adds mission-role-signups collection, roleSignups/missionTime libs, sign-up panel, role slots + rich-text descriptions (with RoleDescription renderer), orphan-pruning hook, migrations, tests, and AGENTS.md docs.
2026-09-30 14:50:26 -04:00

616 lines
20 KiB
TypeScript

import type { Mission, MissionRoleSignup, Qualification, Rank, User } from "@/payload-types";
import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes";
import { notifyUser } from "@/lib/notifications";
import { loadRankLadder, resolveRankFromLadder, rankIndex } from "@/lib/promotions/authority";
import { isMissionPast } from "@/lib/intelligence/missionTime";
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
export const roleSignupsClosedMessage = "Role sign-ups are closed for this mission.";
/** Notification type used when a waitlisted member is promoted into a slot. */
export const ROLE_PROMOTED_NOTIFICATION_TYPE = "mission:role-promoted";
export interface RoleRequirementRefs {
requiredMos?: (Qualification | number)[] | null;
minimumRank?: Rank | number | null;
}
export interface RoleEligibility {
eligible: boolean;
reasons: string[];
}
export interface RoleSignupGate {
open: boolean;
reason?: string;
}
export interface RoleQueueEntryView {
signupId: number;
userId: number;
name: string;
holding: boolean;
/** 1-based position within this role's queue (slot holders come first). */
position: number;
}
export interface RoleSignupRoleState {
roleIndex: number;
roleName: string;
slots: number;
filled: number;
entries: RoleQueueEntryView[];
}
export interface MissionRoleSignupState {
roles: RoleSignupRoleState[];
mySignup: { roleIndex: number; holding: boolean; position: number } | null;
}
/** userId -> roleIndex for everyone currently holding a slot on the mission. */
export type SlotHolderMap = Map<number, number>;
/**
* Sign-up gate: sign-ups stay open until the op start passes (mirroring
* attendance) and close permanently on Cancelled/Completed.
*/
export function roleSignupGate(mission: {
ownershipAndStatus?: { status?: string | null } | null;
classification?: { startDateTime?: string | null } | null;
}): RoleSignupGate {
const status = mission.ownershipAndStatus?.status;
if (status === "Cancelled") return { open: false, reason: "This operation was cancelled." };
if (status === "Completed") return { open: false, reason: "This operation has concluded." };
const startDateTime = mission.classification?.startDateTime;
if (startDateTime && isMissionPast(startDateTime)) {
return { open: false, reason: roleSignupsClosedMessage };
}
return { open: true };
}
interface EligibilityProfileData {
rankId: number | null;
qualificationIds: number[];
}
async function loadEligibilityProfileData(
payload: PayloadType,
userId: number,
): Promise<EligibilityProfileData | null> {
const res = await payload.find({
collection: "profiles",
where: { user: { equals: userId } },
limit: 1,
depth: 1,
select: { rank: true, progression: { qualifications: true } },
overrideAccess: true,
});
const doc = res.docs[0] as
| {
rank?: number | { id: number } | null;
progression?: { qualifications?: (Qualification | number)[] | null };
}
| undefined;
if (!doc) return null;
const rankId =
typeof doc.rank === "object" && doc.rank !== null ? doc.rank.id : (doc.rank ?? null);
const qualificationIds = (doc.progression?.qualifications ?? [])
.map((q) => (typeof q === "object" && q !== null ? q.id : q))
.filter((id): id is number => typeof id === "number");
return { rankId: rankId ?? null, qualificationIds };
}
async function resolveQualificationNames(
payload: PayloadType,
refs: (Qualification | number)[],
): Promise<Map<number, string>> {
const names = new Map<number, string>();
const ids: number[] = [];
for (const ref of refs) {
if (typeof ref === "object" && ref !== null) names.set(ref.id, ref.name);
else if (typeof ref === "number") ids.push(ref);
}
if (ids.length > 0) {
const res = await payload.find({
collection: "qualifications",
where: { id: { in: ids } },
limit: ids.length,
depth: 0,
overrideAccess: true,
});
for (const q of res.docs as Qualification[]) names.set(q.id, q.name);
}
return names;
}
/**
* Check the current user against each role's requirements in one pass
* (profile + rank ladder are loaded once). Roles without requirements are
* always eligible. A required rank missing from the ladder fails open so a
* misconfigured rank never bricks sign-ups.
*/
export async function checkRoleEligibilities(
payload: PayloadType,
userId: number,
roles: RoleRequirementRefs[],
bypass = false,
): Promise<RoleEligibility[]> {
if (bypass) return roles.map(() => ({ eligible: true, reasons: [] }));
const hasRequirements = roles.some(
(r) => r.minimumRank != null || (r.requiredMos?.length ?? 0) > 0,
);
if (!hasRequirements) return roles.map(() => ({ eligible: true, reasons: [] }));
const [ladder, profileData] = await Promise.all([
loadRankLadder(payload),
loadEligibilityProfileData(payload, userId),
]);
const results: RoleEligibility[] = [];
for (const role of roles) {
const reasons: string[] = [];
if (role.minimumRank != null) {
const minId = typeof role.minimumRank === "object" ? role.minimumRank.id : role.minimumRank;
const minRank = resolveRankFromLadder(ladder, minId);
if (minRank) {
const profileRank =
profileData?.rankId != null ? resolveRankFromLadder(ladder, profileData.rankId) : null;
if (!profileRank) {
reasons.push("You need a rank on your profile before you can hold this role.");
} else if (rankIndex(ladder, profileRank.id) < rankIndex(ladder, minRank.id)) {
reasons.push(`Requires the rank of ${minRank.name} or higher.`);
}
}
}
const mos = role.requiredMos ?? [];
if (mos.length > 0) {
if (!profileData) {
reasons.push("You need a profile before you can hold this role.");
} else {
const names = await resolveQualificationNames(payload, mos);
const have = new Set(profileData.qualificationIds);
const missing = mos
.map((ref) => (typeof ref === "object" && ref !== null ? ref.id : ref))
.filter((id): id is number => typeof id === "number" && !have.has(id))
.map((id) => names.get(id) ?? `#${id}`);
if (missing.length > 0) {
reasons.push(
missing.length === 1
? `Requires qualification: ${missing[0]}.`
: `Requires qualifications: ${missing.join(", ")}.`,
);
}
}
}
results.push({ eligible: reasons.length === 0, reasons });
}
return results;
}
export async function checkRoleEligibility(
payload: PayloadType,
userId: number,
role: RoleRequirementRefs,
): Promise<RoleEligibility> {
return (await checkRoleEligibilities(payload, userId, [role]))[0];
}
function missionName(mission: { name?: string | null } | null | undefined, id: number): string {
return mission?.name ?? `Mission #${id}`;
}
function signupUserId(signup: MissionRoleSignup): number {
return typeof signup.user === "object" ? signup.user.id : signup.user;
}
/** Stable FCFS order: createdAt ascending, id as the tiebreaker. */
function byFirstCome(a: MissionRoleSignup, b: MissionRoleSignup): number {
if (a.createdAt < b.createdAt) return -1;
if (a.createdAt > b.createdAt) return 1;
return a.id - b.id;
}
async function fetchSignupsForMission(
payload: PayloadType,
missionId: number,
): Promise<MissionRoleSignup[]> {
const res = await payload.find({
collection: "mission-role-signups",
where: { mission: { equals: missionId } },
limit: 1000,
depth: 0,
overrideAccess: true,
});
return (res.docs as MissionRoleSignup[]).slice().sort(byFirstCome);
}
async function fetchAttendingUserIds(
payload: PayloadType,
missionId: number,
): Promise<Set<number>> {
const res = await payload.find({
collection: "mission-attendances",
where: {
and: [{ mission: { equals: missionId } }, { response: { equals: "yes" } }],
},
limit: 1000,
depth: 0,
overrideAccess: true,
});
return new Set(
(res.docs as { user: number | { id: number } }[]).map((d) =>
typeof d.user === "object" ? d.user.id : d.user,
),
);
}
async function resolveUserName(payload: PayloadType, userId: number): Promise<string> {
const user = (await payload
.findByID({ collection: "users", id: userId, depth: 0, overrideAccess: true })
.catch(() => null)) as User | null;
return user ? user.displayName || user.username : `user #${userId}`;
}
/**
* Compute who currently holds each role's slots. Slot holders are the first
* `slots` sign-ups (FCFS) whose owner's attendance RSVP is "yes"; everyone
* else is waitlisted in signup order. Stateless: derived entirely from the
* current signups + attendance rows, so there is no promotion state to drift.
*/
export async function getSlotHolders(
payload: PayloadType,
missionId: number,
): Promise<SlotHolderMap> {
const signups = await fetchSignupsForMission(payload, missionId);
if (signups.length === 0) return new Map();
const mission = (await payload.findByID({
collection: "missions",
id: missionId,
depth: 0,
overrideAccess: true,
})) as Mission;
const roles = mission.missionRoles?.providedRoles ?? [];
const yesUserIds = await fetchAttendingUserIds(payload, missionId);
const holders: SlotHolderMap = new Map();
for (let index = 0; index < roles.length; index++) {
const slots = Math.max(1, roles[index].slots ?? 1);
let held = 0;
for (const signup of signups) {
if (held >= slots) break;
if (signup.roleIndex !== index) continue;
const userId = signupUserId(signup);
if (!yesUserIds.has(userId)) continue;
holders.set(userId, index);
held += 1;
}
}
return holders;
}
export function diffPromotedUsers(
before: SlotHolderMap,
after: SlotHolderMap,
): { userId: number; roleIndex: number }[] {
const promoted: { userId: number; roleIndex: number }[] = [];
for (const [userId, roleIndex] of after) {
if (!before.has(userId)) promoted.push({ userId, roleIndex });
}
return promoted;
}
/**
* Notify members who newly hold a slot (and log a promoted event). Called
* after any mutation that can shift slot ownership: RSVP changes, sign-up
* switches, and withdrawals. `excludeUserIds` silences notifications for the
* acting user (their own action already confirms it in the UI).
*/
export async function notifySlotHoldersDiff(
payload: PayloadType,
missionId: number,
before: SlotHolderMap,
after: SlotHolderMap,
excludeUserIds: number[] = [],
): Promise<void> {
const promoted = diffPromotedUsers(before, after).filter(
(p) => !excludeUserIds.includes(p.userId),
);
if (promoted.length === 0) return;
const mission = (await payload
.findByID({
collection: "missions",
id: missionId,
depth: 0,
overrideAccess: true,
})
.catch(() => null)) as Mission | null;
const roles = mission?.missionRoles?.providedRoles ?? [];
for (const { userId, roleIndex } of promoted) {
const roleName = roles[roleIndex]?.roleName ?? `role #${roleIndex}`;
const userName = await resolveUserName(payload, userId);
await notifyUser(payload, {
userId,
type: ROLE_PROMOTED_NOTIFICATION_TYPE,
title: `Slot granted: ${roleName}`,
message: `You now hold a ${roleName} slot for ${missionName(mission, missionId)}.`,
link: `/intelligence/missions/${missionId}`,
});
await emitGameEvent(payload, {
type: EventTypes.MissionRolePromoted,
message: `${missionName(mission, missionId)}: ${userName} was promoted into the ${roleName} slot.`,
targetCollection: "missions",
targetId: missionId,
data: { userId, roleIndex },
});
}
}
/**
* Full sign-up state for the mission detail page: per-role FCFS queue with
* slot-holding flags, plus the viewer's own position (if any). Sign-ups whose
* roleIndex points past the current role list (the mission's roles were later
* edited) are pruned by the Missions hook, so they are ignored here.
*/
export async function getMissionRoleSignupState(
payload: PayloadType,
missionId: number,
viewerId?: number | null,
): Promise<MissionRoleSignupState> {
const mission = (await payload.findByID({
collection: "missions",
id: missionId,
depth: 0,
overrideAccess: true,
})) as Mission;
const roles = mission.missionRoles?.providedRoles ?? [];
const signups = await fetchSignupsForMission(payload, missionId);
const yesUserIds = await fetchAttendingUserIds(payload, missionId);
const userIds = [...new Set(signups.map(signupUserId))];
const usersRes =
userIds.length > 0
? await payload.find({
collection: "users",
where: { id: { in: userIds } },
limit: userIds.length,
depth: 0,
overrideAccess: true,
})
: { docs: [] as User[] };
const nameById = new Map<number, string>();
for (const u of usersRes.docs as User[]) {
nameById.set(u.id, u.displayName || u.username);
}
const stateRoles: RoleSignupRoleState[] = roles.map((role, index) => {
const slots = Math.max(1, role.slots ?? 1);
const entries = signups.filter((s) => s.roleIndex === index);
let held = 0;
const views: RoleQueueEntryView[] = entries.map((signup, position) => {
const userId = signupUserId(signup);
const holding = yesUserIds.has(userId) && held < slots;
if (holding) held += 1;
return {
signupId: signup.id,
userId,
name: nameById.get(userId) ?? "Unknown",
holding,
position: position + 1,
};
});
return {
roleIndex: index,
roleName: role.roleName,
slots,
filled: views.filter((v) => v.holding).length,
entries: views,
};
});
let mySignup: MissionRoleSignupState["mySignup"] = null;
if (viewerId != null) {
for (const roleState of stateRoles) {
const mine = roleState.entries.find((e) => e.userId === viewerId);
if (mine) {
mySignup = {
roleIndex: roleState.roleIndex,
holding: mine.holding,
position: mine.position,
};
break;
}
}
}
return { roles: stateRoles, mySignup };
}
/**
* Queue the user for a role (FCFS). One live sign-up per user per mission:
* signing up for another role moves the existing sign-up. No-op when already
* signed up for the same role. Throws descriptive errors for the action layer.
*/
export async function signupForRole(
payload: PayloadType,
input: {
missionId: number;
userId: number;
roleIndex: number;
bypassEligibility?: boolean;
},
): Promise<void> {
const { missionId, userId, roleIndex, bypassEligibility = false } = input;
const mission = (await payload.findByID({
collection: "missions",
id: missionId,
depth: 0,
overrideAccess: true,
})) as Mission;
const gate = roleSignupGate(mission);
if (!gate.open) throw new Error(gate.reason ?? roleSignupsClosedMessage);
const roles = mission.missionRoles?.providedRoles ?? [];
const role = roles[roleIndex];
if (!role) throw new Error("This role no longer exists on the mission.");
if (!bypassEligibility) {
const eligibility = await checkRoleEligibility(payload, userId, role);
if (!eligibility.eligible) throw new Error(eligibility.reasons.join(" "));
}
const existingRes = await payload.find({
collection: "mission-role-signups",
where: {
and: [{ mission: { equals: missionId } }, { user: { equals: userId } }],
},
limit: 1,
depth: 0,
overrideAccess: true,
});
const existing = existingRes.docs[0] as MissionRoleSignup | undefined;
if (existing && existing.roleIndex === roleIndex) return;
const beforeHolders = await getSlotHolders(payload, missionId);
if (existing) {
await payload.update({
collection: "mission-role-signups",
id: existing.id,
data: { roleIndex, roleNameSnapshot: role.roleName },
overrideAccess: true,
depth: 0,
});
} else {
await payload.create({
collection: "mission-role-signups",
data: { mission: missionId, user: userId, roleIndex, roleNameSnapshot: role.roleName },
overrideAccess: true,
depth: 0,
});
}
const userName = await resolveUserName(payload, userId);
await emitGameEvent(payload, {
type: EventTypes.MissionRoleSignup,
message: `${missionName(mission, missionId)}: ${userName} signed up for ${role.roleName}.`,
actor: userId,
targetCollection: "missions",
targetId: missionId,
data: { roleIndex },
});
const afterHolders = await getSlotHolders(payload, missionId);
await notifySlotHoldersDiff(payload, missionId, beforeHolders, afterHolders, [userId]);
}
async function deleteSignupAndReconcile(
payload: PayloadType,
mission: Mission,
signup: MissionRoleSignup,
options: { actorId?: number; removedByStaff?: boolean } = {},
): Promise<void> {
const missionId = mission.id;
const roleIndex = signup.roleIndex;
const roleName =
signup.roleNameSnapshot ??
(mission.missionRoles?.providedRoles ?? [])[roleIndex]?.roleName ??
`role #${roleIndex}`;
const userId = signupUserId(signup);
const beforeHolders = await getSlotHolders(payload, missionId);
await payload.delete({
collection: "mission-role-signups",
id: signup.id,
overrideAccess: true,
});
const userName = await resolveUserName(payload, userId);
await emitGameEvent(payload, {
type: EventTypes.MissionRoleWithdraw,
message: options.removedByStaff
? `${missionName(mission, missionId)}: ${userName} was removed from ${roleName} by staff.`
: `${missionName(mission, missionId)}: ${userName} withdrew from ${roleName}.`,
actor: options.removedByStaff ? options.actorId : userId,
targetCollection: "missions",
targetId: missionId,
data: { roleIndex, removedByStaff: options.removedByStaff === true },
});
const afterHolders = await getSlotHolders(payload, missionId);
await notifySlotHoldersDiff(
payload,
missionId,
beforeHolders,
afterHolders,
[options.removedByStaff ? options.actorId : userId].filter(
(id): id is number => typeof id === "number",
),
);
}
/** Remove the user's own sign-up from the mission. */
export async function withdrawRoleSignup(
payload: PayloadType,
input: { missionId: number; userId: number },
): Promise<void> {
const { missionId, userId } = input;
const signups = await fetchSignupsForMission(payload, missionId);
const existing = signups.find((s) => signupUserId(s) === userId);
if (!existing) throw new Error("You are not signed up for a role on this mission.");
const mission = (await payload.findByID({
collection: "missions",
id: missionId,
depth: 0,
overrideAccess: true,
})) as Mission;
await deleteSignupAndReconcile(payload, mission, existing, { actorId: userId });
}
/** Staff removal: delete any sign-up on the mission and reconcile slots. */
export async function removeRoleSignup(
payload: PayloadType,
input: { missionId: number; signupId: number; actorId?: number },
): Promise<void> {
const signup = (await payload
.findByID({
collection: "mission-role-signups",
id: input.signupId,
depth: 0,
overrideAccess: true,
})
.catch(() => null)) as MissionRoleSignup | null;
if (!signup) throw new Error("Sign-up not found.");
const signupMissionId = typeof signup.mission === "object" ? signup.mission.id : signup.mission;
if (signupMissionId !== input.missionId) {
throw new Error("That sign-up belongs to a different mission.");
}
const mission = (await payload.findByID({
collection: "missions",
id: input.missionId,
depth: 0,
overrideAccess: true,
})) as Mission;
await deleteSignupAndReconcile(payload, mission, signup, {
actorId: input.actorId,
removedByStaff: true,
});
}