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>; 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; /** * 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 { 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> { const names = new Map(); 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 { 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 { 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 { 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> { 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 { 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 { 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 { 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 { 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(); 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 { 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 { 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 { 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 { 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, }); }