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.
This commit is contained in:
parent
56fb6ac26a
commit
190ddac41f
21 changed files with 107148 additions and 13 deletions
15
AGENTS.md
15
AGENTS.md
|
|
@ -334,6 +334,21 @@ Five XP-earning minigames under one parent: routes live at `/minigames/*` (moved
|
||||||
- **Flight Simulator**: `/minigames/flappy` (canvas game + leaderboard). **Minefield Clearance (EOD)**: `/minigames/eod`. **OPORD Recall**: `/minigames/opord` (`src/lib/minigames/opord-recall.ts`).
|
- **Flight Simulator**: `/minigames/flappy` (canvas game + leaderboard). **Minefield Clearance (EOD)**: `/minigames/eod`. **OPORD Recall**: `/minigames/opord` (`src/lib/minigames/opord-recall.ts`).
|
||||||
- Leaderboards match active players by user id (not display name) — see commit 72715c4 if leaderboard queries look wrong.
|
- Leaderboards match active players by user id (not display name) — see commit 72715c4 if leaderboard queries look wrong.
|
||||||
|
|
||||||
|
## Mission Role Sign-Ups
|
||||||
|
|
||||||
|
Players meeting a role's minimum rank + MOS can queue for mission roles ahead of op time, with a first-come, first-served waitlist that auto-promotes when a slot holder stops attending.
|
||||||
|
|
||||||
|
- **Collections**: `mission-role-signups` (`src/collections/intelligence/MissionRoleSignups.ts`) — one live signup per user per mission: `mission`, `user`, `roleIndex` (index into `missions.missionRoles.providedRoles`), `roleNameSnapshot` (display fallback if the role list is later edited). Access mirrors MissionAttendances; all writes flow through the lib with `overrideAccess`. Slots live on the roles themselves: `missions.missionRoles.providedRoles[].slots` (number, default 1), plus an optional `description` richText (Lexical) shown to players under the role name in the Roles table and the sign-up panel via the shared `RoleDescription` renderer (falls back to plain text for legacy strings).
|
||||||
|
- **Queue model (stateless)**: slot holders are recomputed from signup order + current RSVP. The first `slots` signups (FCFS: createdAt, then id) whose owner's attendance RSVP is "yes" hold the role; everyone else is waitlisted in signup order. Only "yes" holds a slot (tentative keeps your place but fills nothing). RSVPing No, going tentative, or withdrawing frees the slot and the next committed member slides up. A returning member reclaims their original first-come position, which can displace someone promoted earlier (that is the cost of pure FCFS; there is no promotion state to drift).
|
||||||
|
- **Lib**: `src/lib/intelligence/roleSignups.ts` — `signupForRole` / `withdrawRoleSignup` / `removeRoleSignup` (staff removal, gated by `missions:update` in the action), `getMissionRoleSignupState` (page render), `getSlotHolders` + `diffPromotedUsers` + `notifySlotHoldersDiff` (promotion reconciliation), `checkRoleEligibilities` (rank vs `loadRankLadder`, MOS vs `progression.qualifications`; admin/developer bypass via `bypassEligibility`), `roleSignupGate` (open until the start time passes; Cancelled/Completed close immediately).
|
||||||
|
- **Promotion triggers**: the shared attendance write path (`setMissionAttendance` in `src/lib/attendance/index.ts`) diffs slot holders around the write, so both web and Discord RSVP changes promote; signups, switches, withdrawals, and staff removals do the same. Promoted members get an in-app notification (`mission:role-promoted`, muteable, never sent to the acting user) + a `mission:role-promoted` event. Signups/withdrawals emit `mission:role-signup` / `mission:role-withdraw` (all targeting `missions`).
|
||||||
|
- **Shared time gate**: `src/lib/intelligence/missionTime.ts` (`isMissionPast`, `attendanceClosedMessage`) was extracted from the attendance lib so roleSignups can reuse it without an import cycle; attendance re-exports both for API compatibility.
|
||||||
|
- **Server actions**: `signupForMissionRole` / `withdrawMissionRoleSignup` / `removeMissionRoleSignup` in `src/app/(frontend)/intelligence/missions/actions.ts` (canonical ActionResult pattern; the first two verify mission visibility via `findByID` with `user` + `overrideAccess: false`).
|
||||||
|
- **UI**: `MissionRoleSignups` panel under the Roles table on the mission detail page (sign up / switch / withdraw, slot holders, numbered waitlist, per-role eligibility reasons, staff remove buttons when `missions:update`); the read-only Roles table gained a Slots column.
|
||||||
|
- **Orphan pruning**: a Missions `beforeChange` hook (update-only) deletes signups whose `roleIndex >= providedRoles.length` after a mission's role list is edited.
|
||||||
|
- **Not covered**: real in-game no-show detection (promotions are RSVP-driven only) and Discord-side signup UI.
|
||||||
|
- **Tests**: `tests/int/mission-role-signups.int.spec.ts` (eligibility gating, FCFS + multi-slot queue, RSVP-drop and withdrawal promotions with notification/event assertions, role switching, staff bypass + removal, closed gates, holder diff).
|
||||||
|
|
||||||
## Discord Bot
|
## Discord Bot
|
||||||
|
|
||||||
A Discord bot living in `src/bot/`, run as a standalone long-running process via `bun run bot` — it imports `@payload-config` directly (same pattern as `src/scripts/` and `src/tools/seed/`) and shares the Postgres DB with the web app. **Under active development and testing.** **Full product requirements live in `docs/bot/context.md`; the approved implementation design is in `docs/bot/design.md` — read both before touching bot code.**
|
A Discord bot living in `src/bot/`, run as a standalone long-running process via `bun run bot` — it imports `@payload-config` directly (same pattern as `src/scripts/` and `src/tools/seed/`) and shares the Postgres DB with the web app. **Under active development and testing.** **Full product requirements live in `docs/bot/context.md`; the approved implementation design is in `docs/bot/design.md` — read both before touching bot code.**
|
||||||
|
|
|
||||||
|
|
@ -23,11 +23,18 @@ import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||||
import { redactText } from "@/lib/redaction";
|
import { redactText } from "@/lib/redaction";
|
||||||
import { MissionFactions } from "@/components/frontend/intelligence/MissionFactions";
|
import { MissionFactions } from "@/components/frontend/intelligence/MissionFactions";
|
||||||
import { MissionRoles } from "@/components/frontend/intelligence/MissionRoles";
|
import { MissionRoles } from "@/components/frontend/intelligence/MissionRoles";
|
||||||
|
import { MissionRoleSignups } from "@/components/frontend/intelligence/MissionRoleSignups";
|
||||||
import { MissionLogistics } from "@/components/frontend/intelligence/MissionLogistics";
|
import { MissionLogistics } from "@/components/frontend/intelligence/MissionLogistics";
|
||||||
import { MissionComms } from "@/components/frontend/intelligence/MissionComms";
|
import { MissionComms } from "@/components/frontend/intelligence/MissionComms";
|
||||||
import { MissionAttendance } from "@/components/frontend/intelligence/MissionAttendance";
|
import { MissionAttendance } from "@/components/frontend/intelligence/MissionAttendance";
|
||||||
import { SideOpControls } from "@/components/frontend/intelligence/SideOpControls";
|
import { SideOpControls } from "@/components/frontend/intelligence/SideOpControls";
|
||||||
import type { SideOpDialogOption } from "@/components/frontend/intelligence/CreateSideOpDialog";
|
import type { SideOpDialogOption } from "@/components/frontend/intelligence/CreateSideOpDialog";
|
||||||
|
import { isAdminOrDeveloper } from "@/lib/promotions/authority";
|
||||||
|
import {
|
||||||
|
checkRoleEligibilities,
|
||||||
|
getMissionRoleSignupState,
|
||||||
|
roleSignupGate,
|
||||||
|
} from "@/lib/intelligence/roleSignups";
|
||||||
import {
|
import {
|
||||||
type AttendanceResponse,
|
type AttendanceResponse,
|
||||||
getMissionAttendance,
|
getMissionAttendance,
|
||||||
|
|
@ -254,6 +261,31 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const providedRoles = mission.missionRoles?.providedRoles ?? [];
|
||||||
|
const signupGate = roleSignupGate(mission);
|
||||||
|
const signupState = await getMissionRoleSignupState(payload, mission.id, currentUser?.id ?? null);
|
||||||
|
const signupEligibilities = currentUser
|
||||||
|
? await checkRoleEligibilities(
|
||||||
|
payload,
|
||||||
|
currentUser.id as number,
|
||||||
|
providedRoles.map((role) => ({
|
||||||
|
requiredMos: role.requiredMos,
|
||||||
|
minimumRank: role.minimumRank,
|
||||||
|
})),
|
||||||
|
isAdminOrDeveloper(currentUser as User),
|
||||||
|
)
|
||||||
|
: providedRoles.map(() => null);
|
||||||
|
const signupRoles = providedRoles.map((role, index) => ({
|
||||||
|
roleIndex: index,
|
||||||
|
roleName: role.roleName,
|
||||||
|
description: role.description ?? null,
|
||||||
|
missionCritical: role.missionCritical ?? true,
|
||||||
|
slots: Math.max(1, role.slots ?? 1),
|
||||||
|
filled: signupState.roles[index]?.filled ?? 0,
|
||||||
|
entries: signupState.roles[index]?.entries ?? [],
|
||||||
|
eligibility: signupEligibilities[index] ?? null,
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<header className="relative flex min-h-[500px] w-full flex-col overflow-hidden bg-background">
|
<header className="relative flex min-h-[500px] w-full flex-col overflow-hidden bg-background">
|
||||||
|
|
@ -425,6 +457,16 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
|
||||||
maxPlayers={mission.missionRoles?.maxPlayers ?? 32}
|
maxPlayers={mission.missionRoles?.maxPlayers ?? 32}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<MissionRoleSignups
|
||||||
|
missionId={mission.id}
|
||||||
|
currentUserId={currentUser ? (currentUser.id as number) : null}
|
||||||
|
closed={!signupGate.open}
|
||||||
|
closedReason={signupGate.reason ?? null}
|
||||||
|
roles={signupRoles}
|
||||||
|
mySignup={signupState.mySignup}
|
||||||
|
canManage={canManage}
|
||||||
|
/>
|
||||||
|
|
||||||
<MissionLogistics logisticsAndSupport={mission.logisticsAndSupport ?? null} />
|
<MissionLogistics logisticsAndSupport={mission.logisticsAndSupport ?? null} />
|
||||||
|
|
||||||
<MissionComms commsAndDocs={mission.commsAndDocs ?? null} />
|
<MissionComms commsAndDocs={mission.commsAndDocs ?? null} />
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,12 @@ import { getPayload } from "payload";
|
||||||
import type { AttendanceResponse } from "@/lib/attendance";
|
import type { AttendanceResponse } from "@/lib/attendance";
|
||||||
import { setMissionAttendance as setAttendance } from "@/lib/attendance";
|
import { setMissionAttendance as setAttendance } from "@/lib/attendance";
|
||||||
import { notifyAttendanceChanged } from "@/lib/attendance/realtime";
|
import { notifyAttendanceChanged } from "@/lib/attendance/realtime";
|
||||||
|
import { isAdminOrDeveloper } from "@/lib/promotions/authority";
|
||||||
|
import {
|
||||||
|
removeRoleSignup,
|
||||||
|
signupForRole,
|
||||||
|
withdrawRoleSignup,
|
||||||
|
} from "@/lib/intelligence/roleSignups";
|
||||||
import { hasPermission } from "@/utils/access-control/hasPermission";
|
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||||
|
|
@ -17,7 +23,7 @@ import {
|
||||||
zonedParts,
|
zonedParts,
|
||||||
zonedWeekday,
|
zonedWeekday,
|
||||||
} from "@/lib/intelligence/fridayTime";
|
} from "@/lib/intelligence/fridayTime";
|
||||||
import type { Mission } from "@/payload-types";
|
import type { Mission, User } from "@/payload-types";
|
||||||
|
|
||||||
export interface ActionResult<T = undefined> {
|
export interface ActionResult<T = undefined> {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|
@ -942,3 +948,103 @@ export async function moveFridaySideOpToCampaign(
|
||||||
: { success: false, error: message };
|
: { success: false, error: message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Role sign-ups
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function signupForMissionRole(input: {
|
||||||
|
missionId: number;
|
||||||
|
roleIndex: number;
|
||||||
|
}): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
const { payload, user } = await authenticate();
|
||||||
|
const userId = user.id as number;
|
||||||
|
|
||||||
|
const mission = await payload
|
||||||
|
.findByID({
|
||||||
|
collection: "missions",
|
||||||
|
id: input.missionId,
|
||||||
|
depth: 0,
|
||||||
|
user,
|
||||||
|
overrideAccess: false,
|
||||||
|
})
|
||||||
|
.catch(() => null);
|
||||||
|
if (!mission) {
|
||||||
|
return { success: false, error: "You do not have access to this mission." };
|
||||||
|
}
|
||||||
|
|
||||||
|
await signupForRole(payload, {
|
||||||
|
missionId: input.missionId,
|
||||||
|
userId,
|
||||||
|
roleIndex: input.roleIndex,
|
||||||
|
bypassEligibility: isAdminOrDeveloper(user as User),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
return message === "Unauthorized"
|
||||||
|
? { success: false, error: "You must be logged in." }
|
||||||
|
: { success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function withdrawMissionRoleSignup(input: {
|
||||||
|
missionId: number;
|
||||||
|
}): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
const { payload, user } = await authenticate();
|
||||||
|
const userId = user.id as number;
|
||||||
|
|
||||||
|
const mission = await payload
|
||||||
|
.findByID({
|
||||||
|
collection: "missions",
|
||||||
|
id: input.missionId,
|
||||||
|
depth: 0,
|
||||||
|
user,
|
||||||
|
overrideAccess: false,
|
||||||
|
})
|
||||||
|
.catch(() => null);
|
||||||
|
if (!mission) {
|
||||||
|
return { success: false, error: "You do not have access to this mission." };
|
||||||
|
}
|
||||||
|
|
||||||
|
await withdrawRoleSignup(payload, { missionId: input.missionId, userId });
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
return message === "Unauthorized"
|
||||||
|
? { success: false, error: "You must be logged in." }
|
||||||
|
: { success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeMissionRoleSignup(input: {
|
||||||
|
missionId: number;
|
||||||
|
signupId: number;
|
||||||
|
}): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
const { payload, user } = await authenticate();
|
||||||
|
const userId = user.id as number;
|
||||||
|
|
||||||
|
const canManage = await hasPermission(payload, user, "missions:update");
|
||||||
|
if (!canManage) {
|
||||||
|
return { success: false, error: "You do not have permission to manage role sign-ups." };
|
||||||
|
}
|
||||||
|
|
||||||
|
await removeRoleSignup(payload, {
|
||||||
|
missionId: input.missionId,
|
||||||
|
signupId: input.signupId,
|
||||||
|
actorId: userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
return message === "Unauthorized"
|
||||||
|
? { success: false, error: "You must be logged in." }
|
||||||
|
: { success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
92
src/collections/intelligence/MissionRoleSignups.ts
Normal file
92
src/collections/intelligence/MissionRoleSignups.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { CollectionConfig } from "payload";
|
||||||
|
import { requirePermission, hasPermission } from "@/utils/access-control/hasPermission";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Role sign-ups for missions. Each row queues a user for one role (by index
|
||||||
|
* into the mission's `missionRoles.providedRoles` array). All writes flow
|
||||||
|
* through `src/lib/intelligence/roleSignups.ts` (overrideAccess), which
|
||||||
|
* enforces one live sign-up per user per mission and eligibility rules, so the
|
||||||
|
* collection-level access mirrors MissionAttendances.
|
||||||
|
*/
|
||||||
|
export const MissionRoleSignups: CollectionConfig = {
|
||||||
|
slug: "mission-role-signups",
|
||||||
|
admin: {
|
||||||
|
group: "Intelligence",
|
||||||
|
defaultColumns: ["mission", "user", "roleIndex", "createdAt"],
|
||||||
|
},
|
||||||
|
access: {
|
||||||
|
read: async ({ req }) => !!req.user,
|
||||||
|
create: async ({ req }) => {
|
||||||
|
return await hasPermission(req.payload, req.user, "mission-role-signups:create");
|
||||||
|
},
|
||||||
|
update: async ({ req, data, id }) => {
|
||||||
|
if (!req.user || typeof id !== "number") return false;
|
||||||
|
if (await hasPermission(req.payload, req.user, "mission-role-signups:update")) return true;
|
||||||
|
|
||||||
|
const ownerId = typeof data?.user === "object" ? data.user?.id : data?.user;
|
||||||
|
if (ownerId === req.user.id) return true;
|
||||||
|
|
||||||
|
const doc = (await req.payload
|
||||||
|
.findByID({
|
||||||
|
collection: "mission-role-signups",
|
||||||
|
id,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
})
|
||||||
|
.catch(() => null)) as unknown as { user?: number | { id: number } } | null;
|
||||||
|
if (!doc) return false;
|
||||||
|
const docOwner = typeof doc.user === "object" ? doc.user?.id : doc.user;
|
||||||
|
return docOwner === req.user.id;
|
||||||
|
},
|
||||||
|
delete: async ({ req, id }) => {
|
||||||
|
if (!req.user || typeof id !== "number") return false;
|
||||||
|
if (await hasPermission(req.payload, req.user, "mission-role-signups:delete")) return true;
|
||||||
|
|
||||||
|
const doc = (await req.payload
|
||||||
|
.findByID({
|
||||||
|
collection: "mission-role-signups",
|
||||||
|
id,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
})
|
||||||
|
.catch(() => null)) as unknown as { user?: number | { id: number } } | null;
|
||||||
|
if (!doc) return false;
|
||||||
|
const docOwner = typeof doc.user === "object" ? doc.user?.id : doc.user;
|
||||||
|
return docOwner === req.user.id;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "mission",
|
||||||
|
type: "relationship",
|
||||||
|
relationTo: "missions",
|
||||||
|
required: true,
|
||||||
|
index: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "user",
|
||||||
|
type: "relationship",
|
||||||
|
relationTo: "users",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "roleIndex",
|
||||||
|
type: "number",
|
||||||
|
label: "Role Index",
|
||||||
|
required: true,
|
||||||
|
min: 0,
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"Index into the mission's missionRoles.providedRoles array. A roleName snapshot is kept alongside in case the mission's role list is later edited.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "roleNameSnapshot",
|
||||||
|
type: "text",
|
||||||
|
label: "Role Name Snapshot",
|
||||||
|
admin: {
|
||||||
|
description: "Copy of the role name at sign-up time, for display if the role list changes.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
@ -109,6 +109,36 @@ export const Missions: CollectionConfig = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
// Prune role sign-ups whose roleIndex points past the current role list
|
||||||
|
// (the mission's roles were edited after people signed up). Update-only:
|
||||||
|
// a mission cannot have sign-ups before it exists.
|
||||||
|
async ({ data, originalDoc, req, operation }) => {
|
||||||
|
if (operation !== "update") return data;
|
||||||
|
const providedRoles = data?.missionRoles?.providedRoles;
|
||||||
|
if (!providedRoles) return data;
|
||||||
|
const missionId = originalDoc?.id;
|
||||||
|
if (typeof missionId !== "number") return data;
|
||||||
|
const stale = await req.payload.find({
|
||||||
|
collection: "mission-role-signups",
|
||||||
|
where: {
|
||||||
|
and: [
|
||||||
|
{ mission: { equals: missionId } },
|
||||||
|
{ roleIndex: { greater_than_equal: providedRoles.length } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
limit: 1000,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
for (const signup of stale.docs) {
|
||||||
|
await req.payload.delete({
|
||||||
|
collection: "mission-role-signups",
|
||||||
|
id: signup.id,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
access: {
|
access: {
|
||||||
|
|
@ -448,6 +478,14 @@ export const Missions: CollectionConfig = {
|
||||||
type: "text",
|
type: "text",
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "description",
|
||||||
|
type: "richText",
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"Shown to players under the role name: what the role does and what is expected of whoever fills it.",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "missionCritical",
|
name: "missionCritical",
|
||||||
type: "checkbox",
|
type: "checkbox",
|
||||||
|
|
@ -477,6 +515,18 @@ export const Missions: CollectionConfig = {
|
||||||
"This role can only be fulfilled by someone of this rank or higher.",
|
"This role can only be fulfilled by someone of this rank or higher.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "slots",
|
||||||
|
type: "number",
|
||||||
|
label: "Slots",
|
||||||
|
defaultValue: 1,
|
||||||
|
min: 1,
|
||||||
|
max: 128,
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"How many members can hold this role. Extra sign-ups form a first-come, first-served waitlist that auto-promotes when a holder stops attending.",
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
252
src/components/frontend/intelligence/MissionRoleSignups.tsx
Normal file
252
src/components/frontend/intelligence/MissionRoleSignups.tsx
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { ClipboardListIcon, UserCheckIcon, UserPlusIcon, UsersIcon, XIcon } from "lucide-react";
|
||||||
|
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
RoleDescription,
|
||||||
|
type RoleDescriptionData,
|
||||||
|
} from "@/components/frontend/intelligence/RoleDescription";
|
||||||
|
import {
|
||||||
|
removeMissionRoleSignup,
|
||||||
|
signupForMissionRole,
|
||||||
|
withdrawMissionRoleSignup,
|
||||||
|
type ActionResult,
|
||||||
|
} from "@/app/(frontend)/intelligence/missions/actions";
|
||||||
|
|
||||||
|
interface SignupPersonView {
|
||||||
|
signupId: number;
|
||||||
|
userId: number;
|
||||||
|
name: string;
|
||||||
|
holding: boolean;
|
||||||
|
position: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SignupRoleView {
|
||||||
|
roleIndex: number;
|
||||||
|
roleName: string;
|
||||||
|
description?: RoleDescriptionData;
|
||||||
|
missionCritical: boolean;
|
||||||
|
slots: number;
|
||||||
|
filled: number;
|
||||||
|
entries: SignupPersonView[];
|
||||||
|
eligibility: { eligible: boolean; reasons: string[] } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MissionRoleSignupsProps {
|
||||||
|
missionId: number;
|
||||||
|
currentUserId: number | null;
|
||||||
|
closed: boolean;
|
||||||
|
closedReason?: string | null;
|
||||||
|
roles: SignupRoleView[];
|
||||||
|
mySignup: { roleIndex: number; holding: boolean; position: number } | null;
|
||||||
|
canManage: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MissionRoleSignups({
|
||||||
|
missionId,
|
||||||
|
currentUserId,
|
||||||
|
closed,
|
||||||
|
closedReason,
|
||||||
|
roles,
|
||||||
|
mySignup,
|
||||||
|
canManage,
|
||||||
|
}: MissionRoleSignupsProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [pending, setPending] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
if (roles.length === 0) return null;
|
||||||
|
|
||||||
|
async function run(key: string, action: () => Promise<ActionResult>) {
|
||||||
|
setPending(key);
|
||||||
|
setError(null);
|
||||||
|
const res = await action();
|
||||||
|
if (!res.success) {
|
||||||
|
setError(res.error ?? "Something went wrong.");
|
||||||
|
}
|
||||||
|
setPending(null);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
const signup = (role: SignupRoleView) =>
|
||||||
|
run(`signup:${role.roleIndex}`, () =>
|
||||||
|
signupForMissionRole({ missionId, roleIndex: role.roleIndex }),
|
||||||
|
);
|
||||||
|
const withdraw = () => run("withdraw", () => withdrawMissionRoleSignup({ missionId }));
|
||||||
|
const remove = (signupId: number) =>
|
||||||
|
run(`remove:${signupId}`, () => removeMissionRoleSignup({ missionId, signupId }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Item variant="outline">
|
||||||
|
<ItemHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ClipboardListIcon className="size-4 text-muted-foreground" />
|
||||||
|
<ItemTitle>Role Sign-Ups</ItemTitle>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
First come, first served
|
||||||
|
</span>
|
||||||
|
</ItemHeader>
|
||||||
|
<ItemContent className="flex flex-col gap-3">
|
||||||
|
{closed && closedReason && <p className="text-xs text-muted-foreground">{closedReason}</p>}
|
||||||
|
{!currentUserId && (
|
||||||
|
<p className="text-xs text-muted-foreground">Log in to sign up for a role.</p>
|
||||||
|
)}
|
||||||
|
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||||
|
|
||||||
|
{roles.map((role) => {
|
||||||
|
const holders = role.entries.filter((e) => e.holding);
|
||||||
|
const waitlist = role.entries.filter((e) => !e.holding);
|
||||||
|
const isMine = mySignup?.roleIndex === role.roleIndex;
|
||||||
|
const openSlots = Math.max(0, role.slots - role.filled);
|
||||||
|
const eligible = role.eligibility?.eligible ?? true;
|
||||||
|
const reasons = role.eligibility?.reasons ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={role.roleIndex}
|
||||||
|
className={cn(
|
||||||
|
"rounded-md border p-3 flex flex-col gap-2",
|
||||||
|
isMine ? "border-primary/40" : "border-border/60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">{role.roleName}</span>
|
||||||
|
{role.missionCritical && (
|
||||||
|
<span className="rounded-sm bg-red-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-red-400">
|
||||||
|
Critical
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="ml-auto font-mono text-xs text-muted-foreground">
|
||||||
|
{role.filled}/{role.slots}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{role.description && <RoleDescription description={role.description} />}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider">
|
||||||
|
<UserCheckIcon className="size-3.5 text-emerald-400" />
|
||||||
|
<span className="text-emerald-400">Slots</span>
|
||||||
|
{openSlots > 0 && (
|
||||||
|
<span className="font-mono text-amber-400">({openSlots} open)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{holders.length > 0 ? (
|
||||||
|
<ul className="flex flex-wrap gap-x-3 gap-y-0.5">
|
||||||
|
{holders.map((person) => (
|
||||||
|
<li
|
||||||
|
key={person.signupId}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1 text-sm",
|
||||||
|
person.userId === currentUserId
|
||||||
|
? "text-foreground font-medium"
|
||||||
|
: "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{person.name}
|
||||||
|
{canManage && person.userId !== currentUserId && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Remove sign-up"
|
||||||
|
disabled={pending !== null}
|
||||||
|
onClick={() => void remove(person.signupId)}
|
||||||
|
className="text-muted-foreground/50 transition-colors hover:text-red-400 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<XIcon className="size-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground italic">Unfilled.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{waitlist.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider">
|
||||||
|
<UsersIcon className="size-3.5 text-muted-foreground" />
|
||||||
|
<span className="text-muted-foreground">Waitlist</span>
|
||||||
|
<span className="font-mono text-muted-foreground">({waitlist.length})</span>
|
||||||
|
</div>
|
||||||
|
<ul className="flex flex-wrap gap-x-3 gap-y-0.5">
|
||||||
|
{waitlist.map((person) => (
|
||||||
|
<li key={person.signupId} className="flex items-center gap-1 text-sm">
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">
|
||||||
|
{person.position}.
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
person.userId === currentUserId
|
||||||
|
? "text-foreground font-medium"
|
||||||
|
: "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{person.name}
|
||||||
|
</span>
|
||||||
|
{canManage && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Remove sign-up"
|
||||||
|
disabled={pending !== null}
|
||||||
|
onClick={() => void remove(person.signupId)}
|
||||||
|
className="text-muted-foreground/50 transition-colors hover:text-red-400 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<XIcon className="size-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isMine && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{mySignup?.holding
|
||||||
|
? "You hold a slot for this role."
|
||||||
|
: `You are waitlisted (#${mySignup?.position}).`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{currentUserId !== null && !closed && !eligible && !isMine && reasons.length > 0 && (
|
||||||
|
<p className="text-xs text-red-400">{reasons.join(" ")}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentUserId !== null && !closed && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isMine ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={pending !== null}
|
||||||
|
onClick={() => void withdraw()}
|
||||||
|
>
|
||||||
|
<XIcon className="size-3.5" />
|
||||||
|
Withdraw
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={pending !== null || !eligible}
|
||||||
|
title={eligible ? undefined : reasons.join(" ")}
|
||||||
|
onClick={() => void signup(role)}
|
||||||
|
>
|
||||||
|
<UserPlusIcon className="size-3.5" />
|
||||||
|
{mySignup ? "Switch to this role" : "Sign up"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,18 @@
|
||||||
import { CheckIcon, UsersIcon, XIcon } from "lucide-react";
|
import { CheckIcon, UsersIcon, XIcon } from "lucide-react";
|
||||||
import type { Qualification, Rank } from "@/payload-types";
|
import type { Qualification, Rank } from "@/payload-types";
|
||||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||||
|
import {
|
||||||
|
RoleDescription,
|
||||||
|
type RoleDescriptionData,
|
||||||
|
} from "@/components/frontend/intelligence/RoleDescription";
|
||||||
|
|
||||||
interface RoleEntry {
|
interface RoleEntry {
|
||||||
roleName: string;
|
roleName: string;
|
||||||
|
description?: RoleDescriptionData | null;
|
||||||
missionCritical?: boolean | null;
|
missionCritical?: boolean | null;
|
||||||
requiredMos?: (Qualification | number)[] | null;
|
requiredMos?: (Qualification | number)[] | null;
|
||||||
minimumRank?: Rank | number | null;
|
minimumRank?: Rank | number | null;
|
||||||
|
slots?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MissionRolesProps {
|
interface MissionRolesProps {
|
||||||
|
|
@ -59,6 +65,9 @@ export function MissionRoles({ roles, notes, maxPlayers }: MissionRolesProps) {
|
||||||
<th className="text-left px-4 py-2.5 font-semibold uppercase text-xs tracking-wider text-muted-foreground">
|
<th className="text-left px-4 py-2.5 font-semibold uppercase text-xs tracking-wider text-muted-foreground">
|
||||||
Role Name
|
Role Name
|
||||||
</th>
|
</th>
|
||||||
|
<th className="text-center px-4 py-2.5 font-semibold uppercase text-xs tracking-wider text-muted-foreground">
|
||||||
|
Slots
|
||||||
|
</th>
|
||||||
<th className="text-center px-4 py-2.5 font-semibold uppercase text-xs tracking-wider text-muted-foreground">
|
<th className="text-center px-4 py-2.5 font-semibold uppercase text-xs tracking-wider text-muted-foreground">
|
||||||
Critical
|
Critical
|
||||||
</th>
|
</th>
|
||||||
|
|
@ -73,7 +82,15 @@ export function MissionRoles({ roles, notes, maxPlayers }: MissionRolesProps) {
|
||||||
<tbody>
|
<tbody>
|
||||||
{roles.map((role, index) => (
|
{roles.map((role, index) => (
|
||||||
<tr key={index} className="border-b border-border/50">
|
<tr key={index} className="border-b border-border/50">
|
||||||
<td className="px-4 py-2 font-medium">{role.roleName}</td>
|
<td className="px-4 py-2 font-medium">
|
||||||
|
{role.roleName}
|
||||||
|
{role.description && (
|
||||||
|
<RoleDescription description={role.description} className="mt-1 max-w-md" />
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-center font-mono text-muted-foreground">
|
||||||
|
{role.slots ?? 1}
|
||||||
|
</td>
|
||||||
<td className="px-4 py-2 text-center">
|
<td className="px-4 py-2 text-center">
|
||||||
{role.missionCritical ? (
|
{role.missionCritical ? (
|
||||||
<CheckIcon className="size-3.5 text-green-400 inline" />
|
<CheckIcon className="size-3.5 text-green-400 inline" />
|
||||||
|
|
|
||||||
31
src/components/frontend/intelligence/RoleDescription.tsx
Normal file
31
src/components/frontend/intelligence/RoleDescription.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { RichText } from "@payloadcms/richtext-lexical/react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type LexicalData = Parameters<typeof RichText>[0]["data"];
|
||||||
|
|
||||||
|
export type RoleDescriptionData = LexicalData | string | null;
|
||||||
|
|
||||||
|
interface RoleDescriptionProps {
|
||||||
|
description: RoleDescriptionData | undefined;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Player-facing mission role description. Renders Lexical richText; plain
|
||||||
|
* strings (written before the field became richText) still render as text.
|
||||||
|
*/
|
||||||
|
export function RoleDescription({ description, className }: RoleDescriptionProps) {
|
||||||
|
if (typeof description === "string") {
|
||||||
|
return (
|
||||||
|
<p className={cn("whitespace-pre-line text-xs text-muted-foreground", className)}>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!description || typeof description !== "object") return null;
|
||||||
|
return (
|
||||||
|
<div className={cn("lexical-content", className)}>
|
||||||
|
<RichText data={description as LexicalData} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,21 +1,19 @@
|
||||||
import type { MissionAttendance, User } from "@/payload-types";
|
import type { MissionAttendance, User } from "@/payload-types";
|
||||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||||
|
import { getSlotHolders, notifySlotHoldersDiff } from "@/lib/intelligence/roleSignups";
|
||||||
|
|
||||||
export type AttendanceResponse = "yes" | "no" | "tentative";
|
export type AttendanceResponse = "yes" | "no" | "tentative";
|
||||||
|
|
||||||
/**
|
// Shared mission-time gates live in lib/intelligence/missionTime.ts; re-exported
|
||||||
* Returns true when the mission's start time has passed.
|
// here to keep the existing public API of this module unchanged.
|
||||||
* Failures to parse the start date fall open (allow attendance) so a malformed
|
import {
|
||||||
* timestamp never bricks RSVP on an otherwise valid mission.
|
attendanceClosedMessage as sharedAttendanceClosedMessage,
|
||||||
*/
|
isMissionPast as sharedIsMissionPast,
|
||||||
export function isMissionPast(startDateTime: string, now: Date = new Date()): boolean {
|
} from "@/lib/intelligence/missionTime";
|
||||||
const start = new Date(startDateTime);
|
|
||||||
if (Number.isNaN(start.getTime())) return false;
|
|
||||||
return start.getTime() <= now.getTime();
|
|
||||||
}
|
|
||||||
|
|
||||||
export const attendanceClosedMessage = "Attendance is closed for this mission.";
|
export const isMissionPast = sharedIsMissionPast;
|
||||||
|
export const attendanceClosedMessage = sharedAttendanceClosedMessage;
|
||||||
|
|
||||||
export interface MissionAttendanceCounts {
|
export interface MissionAttendanceCounts {
|
||||||
yes: number;
|
yes: number;
|
||||||
|
|
@ -67,6 +65,11 @@ export async function setMissionAttendance(
|
||||||
throw new Error(attendanceClosedMessage);
|
throw new Error(attendanceClosedMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Slot holders are derived from RSVP, so an RSVP change can promote the next
|
||||||
|
// waitlisted role sign-up. Diff around the write (single write path shared by
|
||||||
|
// web and Discord RSVPs).
|
||||||
|
const beforeSlotHolders = await getSlotHolders(payload, missionId);
|
||||||
|
|
||||||
const existing = (await payload.find({
|
const existing = (await payload.find({
|
||||||
collection: "mission-attendances",
|
collection: "mission-attendances",
|
||||||
where: {
|
where: {
|
||||||
|
|
@ -104,6 +107,9 @@ export async function setMissionAttendance(
|
||||||
data: { response },
|
data: { response },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const afterSlotHolders = await getSlotHolders(payload, missionId);
|
||||||
|
await notifySlotHoldersDiff(payload, missionId, beforeSlotHolders, afterSlotHolders, [userId]);
|
||||||
|
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
18
src/lib/intelligence/missionTime.ts
Normal file
18
src/lib/intelligence/missionTime.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
/**
|
||||||
|
* Shared mission-time gate helpers, extracted from `src/lib/attendance` so the
|
||||||
|
* role sign-ups lib can reuse them without importing the attendance module
|
||||||
|
* (which itself depends on the sign-ups lib for slot promotion reconciliation).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when the mission's start time has passed.
|
||||||
|
* Failures to parse the start date fall open (allow attendance) so a malformed
|
||||||
|
* timestamp never bricks RSVP on an otherwise valid mission.
|
||||||
|
*/
|
||||||
|
export function isMissionPast(startDateTime: string, now: Date = new Date()): boolean {
|
||||||
|
const start = new Date(startDateTime);
|
||||||
|
if (Number.isNaN(start.getTime())) return false;
|
||||||
|
return start.getTime() <= now.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const attendanceClosedMessage = "Attendance is closed for this mission.";
|
||||||
616
src/lib/intelligence/roleSignups.ts
Normal file
616
src/lib/intelligence/roleSignups.ts
Normal file
|
|
@ -0,0 +1,616 @@
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -117,6 +117,8 @@ export function notificationLabel(type?: string | null): string {
|
||||||
return "Ribbon not approved";
|
return "Ribbon not approved";
|
||||||
case "personnel:promoted":
|
case "personnel:promoted":
|
||||||
return "Promotion";
|
return "Promotion";
|
||||||
|
case "mission:role-promoted":
|
||||||
|
return "Role slot granted";
|
||||||
default:
|
default:
|
||||||
return type ?? "Notification";
|
return type ?? "Notification";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ export const MUTEABLE_NOTIFICATION_TYPES = [
|
||||||
{ label: "Poker game auto-ended", value: "poker:everyone-left" },
|
{ label: "Poker game auto-ended", value: "poker:everyone-left" },
|
||||||
{ label: "Blackjack", value: "blackjack:force-ended" },
|
{ label: "Blackjack", value: "blackjack:force-ended" },
|
||||||
{ label: "Blackjack table auto-ended", value: "blackjack:everyone-left" },
|
{ label: "Blackjack table auto-ended", value: "blackjack:everyone-left" },
|
||||||
|
{ label: "Role slot promotions", value: "mission:role-promoted" },
|
||||||
] as const;
|
] as const;
|
||||||
export type MuteableNotificationType = (typeof MUTEABLE_NOTIFICATION_TYPES)[number]["value"];
|
export type MuteableNotificationType = (typeof MUTEABLE_NOTIFICATION_TYPES)[number]["value"];
|
||||||
export const MUTEABLE_NOTIFICATION_VALUES = MUTEABLE_NOTIFICATION_TYPES.map((t) => t.value);
|
export const MUTEABLE_NOTIFICATION_VALUES = MUTEABLE_NOTIFICATION_TYPES.map((t) => t.value);
|
||||||
|
|
|
||||||
35056
src/migrations/20260925_235150_add_mission_role_signups.json
Normal file
35056
src/migrations/20260925_235150_add_mission_role_signups.json
Normal file
File diff suppressed because it is too large
Load diff
63
src/migrations/20260925_235150_add_mission_role_signups.ts
Normal file
63
src/migrations/20260925_235150_add_mission_role_signups.ts
Normal file
File diff suppressed because one or more lines are too long
35062
src/migrations/20260926_030859_add_mission_role_description.json
Normal file
35062
src/migrations/20260926_030859_add_mission_role_description.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||||
|
|
||||||
|
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "missions_mission_roles_provided_roles" ADD COLUMN "description" varchar;`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "missions_mission_roles_provided_roles" DROP COLUMN "description";`)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||||
|
|
||||||
|
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "missions_mission_roles_provided_roles" ALTER COLUMN "description" SET DATA TYPE jsonb USING
|
||||||
|
CASE
|
||||||
|
WHEN "description" IS NULL OR "description" = '' THEN NULL
|
||||||
|
ELSE jsonb_build_object(
|
||||||
|
'root',
|
||||||
|
jsonb_build_object(
|
||||||
|
'type', 'root',
|
||||||
|
'format', '',
|
||||||
|
'indent', 0,
|
||||||
|
'version', 1,
|
||||||
|
'direction', 'ltr',
|
||||||
|
'children', jsonb_build_array(
|
||||||
|
jsonb_build_object(
|
||||||
|
'type', 'paragraph',
|
||||||
|
'format', '',
|
||||||
|
'indent', 0,
|
||||||
|
'version', 1,
|
||||||
|
'direction', 'ltr',
|
||||||
|
'children', jsonb_build_array(
|
||||||
|
jsonb_build_object(
|
||||||
|
'type', 'text',
|
||||||
|
'format', 0,
|
||||||
|
'style', '',
|
||||||
|
'mode', 'normal',
|
||||||
|
'text', "description",
|
||||||
|
'version', 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
END;`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "missions_mission_roles_provided_roles" ALTER COLUMN "description" SET DATA TYPE varchar USING
|
||||||
|
CASE
|
||||||
|
WHEN "description" IS NULL THEN NULL
|
||||||
|
ELSE COALESCE("description" #>> '{root,children,0,children,0,text}', '')
|
||||||
|
END;`)
|
||||||
|
}
|
||||||
|
|
@ -129,6 +129,11 @@ export const EventTypes = {
|
||||||
// Attendance
|
// Attendance
|
||||||
MissionAttendanceChange: "mission:attendance-change",
|
MissionAttendanceChange: "mission:attendance-change",
|
||||||
|
|
||||||
|
// Mission role sign-ups
|
||||||
|
MissionRoleSignup: "mission:role-signup",
|
||||||
|
MissionRoleWithdraw: "mission:role-withdraw",
|
||||||
|
MissionRolePromoted: "mission:role-promoted",
|
||||||
|
|
||||||
// Evaluations
|
// Evaluations
|
||||||
EvaluationSubmit: "evaluation:submit",
|
EvaluationSubmit: "evaluation:submit",
|
||||||
|
|
||||||
|
|
|
||||||
581
tests/int/mission-role-signups.int.spec.ts
Normal file
581
tests/int/mission-role-signups.int.spec.ts
Normal file
|
|
@ -0,0 +1,581 @@
|
||||||
|
import { getPayload, Payload } from "payload";
|
||||||
|
import config from "@/payload.config";
|
||||||
|
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import type {
|
||||||
|
Map as MissionMap,
|
||||||
|
Mission,
|
||||||
|
MissionRoleSignup,
|
||||||
|
Qualification,
|
||||||
|
Rank,
|
||||||
|
User,
|
||||||
|
} from "@/payload-types";
|
||||||
|
import {
|
||||||
|
diffPromotedUsers,
|
||||||
|
getMissionRoleSignupState,
|
||||||
|
getSlotHolders,
|
||||||
|
removeRoleSignup,
|
||||||
|
roleSignupGate,
|
||||||
|
roleSignupsClosedMessage,
|
||||||
|
signupForRole,
|
||||||
|
withdrawRoleSignup,
|
||||||
|
} from "@/lib/intelligence/roleSignups";
|
||||||
|
import { setMissionAttendance } from "@/lib/attendance";
|
||||||
|
|
||||||
|
let payload: Payload;
|
||||||
|
|
||||||
|
const RUN = `mrs-${Date.now().toString(36)}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User deletion trips FK constraints unless the hook-provisioned personal bank
|
||||||
|
* account and profile are removed first, and event/notification rows must go
|
||||||
|
* before the users they reference.
|
||||||
|
*/
|
||||||
|
const deleteUserWithRelations = async (pg: Payload, id: number): Promise<void> => {
|
||||||
|
const accounts = await pg
|
||||||
|
.find({
|
||||||
|
collection: "bank-accounts",
|
||||||
|
where: { ownerUser: { equals: id } },
|
||||||
|
limit: 5,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
})
|
||||||
|
.catch(() => null);
|
||||||
|
for (const account of accounts?.docs ?? []) {
|
||||||
|
await pg
|
||||||
|
.delete({ collection: "bank-accounts", id: account.id, overrideAccess: true })
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
const profiles = await pg
|
||||||
|
.find({
|
||||||
|
collection: "profiles",
|
||||||
|
where: { user: { equals: id } },
|
||||||
|
limit: 5,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
})
|
||||||
|
.catch(() => null);
|
||||||
|
for (const profile of profiles?.docs ?? []) {
|
||||||
|
await pg
|
||||||
|
.delete({ collection: "profiles", id: profile.id, overrideAccess: true })
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
await pg.delete({ collection: "users", id, overrideAccess: true }).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteMissionWithRelations = async (pg: Payload, missionId: number): Promise<void> => {
|
||||||
|
for (const collection of ["mission-role-signups", "mission-attendances"] as const) {
|
||||||
|
const docs = await pg
|
||||||
|
.find({
|
||||||
|
collection,
|
||||||
|
where: { mission: { equals: missionId } },
|
||||||
|
limit: 500,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
})
|
||||||
|
.catch(() => null);
|
||||||
|
for (const doc of docs?.docs ?? []) {
|
||||||
|
await pg.delete({ collection, id: doc.id, overrideAccess: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await pg.delete({ collection: "missions", id: missionId, overrideAccess: true }).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Mission role sign-ups", () => {
|
||||||
|
let mapId: number;
|
||||||
|
let missionId: number;
|
||||||
|
let queueMissionId: number;
|
||||||
|
let switchMissionId: number;
|
||||||
|
let bypassMissionId: number;
|
||||||
|
let pastMissionId: number;
|
||||||
|
let cancelledMissionId: number;
|
||||||
|
const missionIds: number[] = [];
|
||||||
|
const rankIds: number[] = [];
|
||||||
|
const qualIds: number[] = [];
|
||||||
|
const userIds: number[] = [];
|
||||||
|
|
||||||
|
let pvt: Rank;
|
||||||
|
let cpl: Rank;
|
||||||
|
let sgt: Rank;
|
||||||
|
let medic: Qualification;
|
||||||
|
|
||||||
|
let memberA: User; // Pvt with the Medic MOS
|
||||||
|
let memberB: User; // Pvt with no qualifications
|
||||||
|
let memberC: User; // Cpl, no qualifications
|
||||||
|
let memberD: User; // Sgt with the Medic MOS
|
||||||
|
let adminUser: User;
|
||||||
|
|
||||||
|
const makeMission = async (
|
||||||
|
label: string,
|
||||||
|
options: {
|
||||||
|
status?: Mission["ownershipAndStatus"]["status"];
|
||||||
|
start?: Date;
|
||||||
|
roles: Record<string, unknown>[];
|
||||||
|
},
|
||||||
|
): Promise<Mission> => {
|
||||||
|
const providedRoles = options.roles as NonNullable<Mission["missionRoles"]>["providedRoles"];
|
||||||
|
const mission = (await payload.create({
|
||||||
|
collection: "missions",
|
||||||
|
data: {
|
||||||
|
name: `${RUN} ${label}`,
|
||||||
|
codeName: `${RUN}-${label}`,
|
||||||
|
summary: "Role sign-up test mission",
|
||||||
|
operationType: "main",
|
||||||
|
classification: {
|
||||||
|
map: mapId,
|
||||||
|
missionType: "PvE",
|
||||||
|
startDateTime: (
|
||||||
|
options.start ?? new Date(Date.now() + 2 * 24 * 60 * 60 * 1000)
|
||||||
|
).toISOString(),
|
||||||
|
estimatedDuration: 60,
|
||||||
|
},
|
||||||
|
ownershipAndStatus: {
|
||||||
|
authors: [adminUser.id],
|
||||||
|
status: options.status ?? "Scheduled",
|
||||||
|
visibility: "unit",
|
||||||
|
},
|
||||||
|
briefing: [],
|
||||||
|
gameDetails: {
|
||||||
|
serverDetails: {
|
||||||
|
serverIp: "127.0.0.1",
|
||||||
|
serverPort: 2302,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
missionRoles: {
|
||||||
|
maxPlayers: 32,
|
||||||
|
providedRoles,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
})) as unknown as Mission;
|
||||||
|
missionIds.push(mission.id);
|
||||||
|
return mission;
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeUser = async (username: string, roles: User["roles"] = ["user"]): Promise<User> => {
|
||||||
|
const user = (await payload.create({
|
||||||
|
collection: "users",
|
||||||
|
data: {
|
||||||
|
username,
|
||||||
|
discordUsername: username,
|
||||||
|
displayName: "ROLE SIGNUP TEST",
|
||||||
|
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
|
||||||
|
password: "Test123",
|
||||||
|
roles,
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
})) as unknown as User;
|
||||||
|
userIds.push(user.id);
|
||||||
|
return user;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setProfile = async (userId: number, rankId: number, qualificationIds: number[]) => {
|
||||||
|
const profiles = (await payload.find({
|
||||||
|
collection: "profiles",
|
||||||
|
where: { user: { equals: userId } },
|
||||||
|
limit: 1,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
})) as unknown as { docs: Array<{ id: number }> };
|
||||||
|
await payload.update({
|
||||||
|
collection: "profiles",
|
||||||
|
id: profiles.docs[0].id,
|
||||||
|
data: { rank: rankId, progression: { qualifications: qualificationIds } },
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const payloadConfig = await config;
|
||||||
|
payload = await getPayload({ config: payloadConfig });
|
||||||
|
|
||||||
|
const map = (await payload.create({
|
||||||
|
collection: "maps",
|
||||||
|
data: {
|
||||||
|
name: `${RUN} Map`,
|
||||||
|
worldSizeWidth: 8192,
|
||||||
|
worldSizeHeight: 8192,
|
||||||
|
basemapMode: "image",
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
})) as unknown as MissionMap;
|
||||||
|
mapId = map.id;
|
||||||
|
|
||||||
|
// Self-contained 3-rank ladder: Pvt < Cpl < Sgt.
|
||||||
|
for (const [name, abbreviation] of [
|
||||||
|
["Pvt", "pvt"],
|
||||||
|
["Cpl", "cpl"],
|
||||||
|
["Sgt", "sgt"],
|
||||||
|
] as const) {
|
||||||
|
const rank = (await payload.create({
|
||||||
|
collection: "ranks",
|
||||||
|
data: {
|
||||||
|
name: `${RUN} ${name}`,
|
||||||
|
abbreviation: `${RUN}-${abbreviation}`,
|
||||||
|
description: `${RUN} test rank`,
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
})) as unknown as Rank;
|
||||||
|
rankIds.push(rank.id);
|
||||||
|
if (name === "Pvt") pvt = rank;
|
||||||
|
if (name === "Cpl") cpl = rank;
|
||||||
|
if (name === "Sgt") sgt = rank;
|
||||||
|
}
|
||||||
|
|
||||||
|
medic = (await payload.create({
|
||||||
|
collection: "qualifications",
|
||||||
|
data: { name: `${RUN} Advanced Medic` },
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
})) as unknown as Qualification;
|
||||||
|
qualIds.push(medic.id);
|
||||||
|
|
||||||
|
adminUser = await makeUser(`${RUN}-admin`, ["admin"]);
|
||||||
|
memberA = await makeUser(`${RUN}-a`);
|
||||||
|
memberB = await makeUser(`${RUN}-b`);
|
||||||
|
memberC = await makeUser(`${RUN}-c`);
|
||||||
|
memberD = await makeUser(`${RUN}-d`);
|
||||||
|
|
||||||
|
await setProfile(memberA.id, pvt.id, [medic.id]);
|
||||||
|
await setProfile(memberB.id, pvt.id, []);
|
||||||
|
await setProfile(memberC.id, cpl.id, []);
|
||||||
|
await setProfile(memberD.id, sgt.id, [medic.id]);
|
||||||
|
|
||||||
|
const main = await makeMission("Main", {
|
||||||
|
roles: [
|
||||||
|
{ roleName: "Squad Leader", slots: 1, minimumRank: cpl.id },
|
||||||
|
{ roleName: "Medic", slots: 1, requiredMos: [medic.id] },
|
||||||
|
{ roleName: "Rifleman", slots: 2 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
missionId = main.id;
|
||||||
|
|
||||||
|
const queue = await makeMission("Queue", {
|
||||||
|
roles: [{ roleName: "Rifleman", slots: 2 }],
|
||||||
|
});
|
||||||
|
queueMissionId = queue.id;
|
||||||
|
|
||||||
|
const switchMission = await makeMission("Switch", {
|
||||||
|
roles: [
|
||||||
|
{ roleName: "Medic", slots: 1, requiredMos: [medic.id] },
|
||||||
|
{ roleName: "Rifleman", slots: 2 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
switchMissionId = switchMission.id;
|
||||||
|
|
||||||
|
const bypass = await makeMission("Bypass", {
|
||||||
|
roles: [{ roleName: "Squad Leader", slots: 1, minimumRank: sgt.id }],
|
||||||
|
});
|
||||||
|
bypassMissionId = bypass.id;
|
||||||
|
|
||||||
|
const past = await makeMission("Past", {
|
||||||
|
start: new Date(Date.now() - 24 * 60 * 60 * 1000),
|
||||||
|
roles: [{ roleName: "Rifleman", slots: 2 }],
|
||||||
|
});
|
||||||
|
pastMissionId = past.id;
|
||||||
|
|
||||||
|
const cancelled = await makeMission("Cancelled", {
|
||||||
|
status: "Cancelled",
|
||||||
|
roles: [{ roleName: "Rifleman", slots: 2 }],
|
||||||
|
});
|
||||||
|
cancelledMissionId = cancelled.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const id of missionIds) {
|
||||||
|
await deleteMissionWithRelations(payload, id);
|
||||||
|
}
|
||||||
|
// Events and notifications reference users; remove them before the users.
|
||||||
|
for (const collection of ["game-event-logs", "user-notifications"] as const) {
|
||||||
|
const docs = await payload
|
||||||
|
.find({
|
||||||
|
collection,
|
||||||
|
where: { id: { exists: true } },
|
||||||
|
limit: 500,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
})
|
||||||
|
.catch(() => null);
|
||||||
|
for (const doc of docs?.docs ?? []) {
|
||||||
|
const event = doc as unknown as {
|
||||||
|
actor?: number | { id: number } | null;
|
||||||
|
user?: number | { id: number } | null;
|
||||||
|
};
|
||||||
|
const ref = event.actor ?? event.user;
|
||||||
|
const refId = typeof ref === "object" ? ref?.id : ref;
|
||||||
|
if (refId != null && userIds.includes(refId)) {
|
||||||
|
await payload.delete({ collection, id: doc.id, overrideAccess: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const id of userIds) {
|
||||||
|
await deleteUserWithRelations(payload, id);
|
||||||
|
}
|
||||||
|
for (const id of qualIds) {
|
||||||
|
await payload
|
||||||
|
.delete({ collection: "qualifications", id, overrideAccess: true })
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
for (const id of rankIds) {
|
||||||
|
await payload.delete({ collection: "ranks", id, overrideAccess: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
await payload.delete({ collection: "maps", id: mapId, overrideAccess: true }).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gates sign-ups by minimum rank and required MOS", async () => {
|
||||||
|
// B is a Pvt: the Squad Leader role requires Cpl or higher.
|
||||||
|
await expect(
|
||||||
|
signupForRole(payload, { missionId, userId: memberB.id, roleIndex: 0 }),
|
||||||
|
).rejects.toThrow(/Requires the rank of/);
|
||||||
|
|
||||||
|
// C is a Cpl: passes the rank gate and immediately holds the slot once RSVPed.
|
||||||
|
await setMissionAttendance(payload, { missionId, userId: memberC.id, response: "yes" });
|
||||||
|
await signupForRole(payload, { missionId, userId: memberC.id, roleIndex: 0 });
|
||||||
|
const afterC = await getMissionRoleSignupState(payload, missionId);
|
||||||
|
expect(afterC.roles[0].filled).toBe(1);
|
||||||
|
expect(afterC.roles[0].entries[0].holding).toBe(true);
|
||||||
|
|
||||||
|
// B lacks the Medic MOS.
|
||||||
|
await expect(
|
||||||
|
signupForRole(payload, { missionId, userId: memberB.id, roleIndex: 1 }),
|
||||||
|
).rejects.toThrow(/Requires qualification:/);
|
||||||
|
|
||||||
|
// A is a Pvt with the Medic MOS: the Medic role has no rank floor.
|
||||||
|
await signupForRole(payload, { missionId, userId: memberA.id, roleIndex: 1 });
|
||||||
|
const afterA = await getMissionRoleSignupState(payload, missionId);
|
||||||
|
expect(afterA.roles[1].filled).toBe(0); // A has not RSVPed yet, so no slot held
|
||||||
|
expect(afterA.roles[1].entries[0].holding).toBe(false);
|
||||||
|
|
||||||
|
// Re-signing for the same role is a no-op (one signup per user per mission).
|
||||||
|
await signupForRole(payload, { missionId, userId: memberA.id, roleIndex: 1 });
|
||||||
|
const signups = await payload.find({
|
||||||
|
collection: "mission-role-signups",
|
||||||
|
where: { mission: { equals: missionId } },
|
||||||
|
limit: 100,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
expect(signups.docs).toHaveLength(2);
|
||||||
|
|
||||||
|
// A role index past the end of the mission's role list is refused.
|
||||||
|
await expect(
|
||||||
|
signupForRole(payload, { missionId, userId: memberA.id, roleIndex: 99 }),
|
||||||
|
).rejects.toThrow(/no longer exists/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("queues by first-come-first-served and auto-promotes on RSVP drop", async () => {
|
||||||
|
await setMissionAttendance(payload, {
|
||||||
|
missionId: queueMissionId,
|
||||||
|
userId: memberA.id,
|
||||||
|
response: "yes",
|
||||||
|
});
|
||||||
|
await setMissionAttendance(payload, {
|
||||||
|
missionId: queueMissionId,
|
||||||
|
userId: memberB.id,
|
||||||
|
response: "yes",
|
||||||
|
});
|
||||||
|
await setMissionAttendance(payload, {
|
||||||
|
missionId: queueMissionId,
|
||||||
|
userId: memberC.id,
|
||||||
|
response: "yes",
|
||||||
|
});
|
||||||
|
|
||||||
|
await signupForRole(payload, { missionId: queueMissionId, userId: memberA.id, roleIndex: 0 });
|
||||||
|
await signupForRole(payload, { missionId: queueMissionId, userId: memberB.id, roleIndex: 0 });
|
||||||
|
await signupForRole(payload, { missionId: queueMissionId, userId: memberC.id, roleIndex: 0 });
|
||||||
|
|
||||||
|
let state = await getMissionRoleSignupState(payload, queueMissionId);
|
||||||
|
expect(state.roles[0].filled).toBe(2);
|
||||||
|
expect(state.roles[0].entries.map((e) => [e.position, e.holding])).toEqual([
|
||||||
|
[1, true],
|
||||||
|
[2, true],
|
||||||
|
[3, false],
|
||||||
|
]);
|
||||||
|
expect(state.roles[0].slots).toBe(2);
|
||||||
|
|
||||||
|
// A (slot holder) RSVPs No: the first waitlisted member takes over.
|
||||||
|
await setMissionAttendance(payload, {
|
||||||
|
missionId: queueMissionId,
|
||||||
|
userId: memberA.id,
|
||||||
|
response: "no",
|
||||||
|
});
|
||||||
|
|
||||||
|
state = await getMissionRoleSignupState(payload, queueMissionId);
|
||||||
|
expect(state.roles[0].filled).toBe(2);
|
||||||
|
expect(state.roles[0].entries.map((e) => [e.holding])).toEqual([[false], [true], [true]]);
|
||||||
|
|
||||||
|
// The promoted member is notified and the promotion is logged.
|
||||||
|
const notifications = await payload.find({
|
||||||
|
collection: "user-notifications",
|
||||||
|
where: { user: { equals: memberC.id } },
|
||||||
|
limit: 100,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
const promotedNotes = notifications.docs.filter(
|
||||||
|
(n) => (n as unknown as { type: string }).type === "mission:role-promoted",
|
||||||
|
);
|
||||||
|
expect(promotedNotes).toHaveLength(1);
|
||||||
|
expect(promotedNotes[0]).toMatchObject({
|
||||||
|
title: "Slot granted: Rifleman",
|
||||||
|
link: `/intelligence/missions/${queueMissionId}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = await payload.find({
|
||||||
|
collection: "game-event-logs",
|
||||||
|
where: { type: { equals: "mission:role-promoted" } },
|
||||||
|
limit: 100,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
const promotionEvent = events.docs.find(
|
||||||
|
(e) =>
|
||||||
|
(e as unknown as { targetId: number }).targetId === queueMissionId &&
|
||||||
|
JSON.stringify((e as unknown as { data?: { userId?: number } }).data ?? {}).includes(
|
||||||
|
`"userId":${memberC.id}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(promotionEvent).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("promotes on withdrawal and refuses a second withdrawal", async () => {
|
||||||
|
// D joins the (full) Rifleman queue: two holders, two waitlisted.
|
||||||
|
await setMissionAttendance(payload, {
|
||||||
|
missionId: queueMissionId,
|
||||||
|
userId: memberD.id,
|
||||||
|
response: "yes",
|
||||||
|
});
|
||||||
|
await signupForRole(payload, { missionId: queueMissionId, userId: memberD.id, roleIndex: 0 });
|
||||||
|
|
||||||
|
let state = await getMissionRoleSignupState(payload, queueMissionId);
|
||||||
|
expect(state.roles[0].entries).toHaveLength(4);
|
||||||
|
|
||||||
|
// B withdraws: C keeps a slot and D (next committed member) takes B's.
|
||||||
|
await withdrawRoleSignup(payload, { missionId: queueMissionId, userId: memberB.id });
|
||||||
|
|
||||||
|
state = await getMissionRoleSignupState(payload, queueMissionId);
|
||||||
|
expect(state.roles[0].filled).toBe(2);
|
||||||
|
expect(state.roles[0].entries.find((e) => e.userId === memberD.id)?.holding).toBe(true);
|
||||||
|
expect(state.roles[0].entries.find((e) => e.userId === memberB.id)).toBeUndefined();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
withdrawRoleSignup(payload, { missionId: queueMissionId, userId: memberB.id }),
|
||||||
|
).rejects.toThrow(/not signed up/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves the signup when switching roles and enforces requirements on the target", async () => {
|
||||||
|
// A holds a Rifleman slot on the switch mission, then moves to Medic.
|
||||||
|
await setMissionAttendance(payload, {
|
||||||
|
missionId: switchMissionId,
|
||||||
|
userId: memberA.id,
|
||||||
|
response: "yes",
|
||||||
|
});
|
||||||
|
await signupForRole(payload, { missionId: switchMissionId, userId: memberA.id, roleIndex: 1 });
|
||||||
|
let state = await getMissionRoleSignupState(payload, switchMissionId, memberA.id);
|
||||||
|
expect(state.roles[1].filled).toBe(1);
|
||||||
|
expect(state.mySignup).toMatchObject({ roleIndex: 1, holding: true });
|
||||||
|
|
||||||
|
// Switching to Medic moves the one live signup (no second row).
|
||||||
|
await signupForRole(payload, { missionId: switchMissionId, userId: memberA.id, roleIndex: 0 });
|
||||||
|
state = await getMissionRoleSignupState(payload, switchMissionId, memberA.id);
|
||||||
|
expect(state.roles[1].entries).toHaveLength(0);
|
||||||
|
expect(state.roles[0].filled).toBe(1);
|
||||||
|
expect(state.mySignup).toMatchObject({ roleIndex: 0, holding: true });
|
||||||
|
|
||||||
|
// B (Pvt, no MOS) and C (Cpl, no MOS) both fail the Medic MOS gate.
|
||||||
|
await expect(
|
||||||
|
signupForRole(payload, { missionId: switchMissionId, userId: memberB.id, roleIndex: 0 }),
|
||||||
|
).rejects.toThrow(/Requires qualification:/);
|
||||||
|
await expect(
|
||||||
|
signupForRole(payload, { missionId: switchMissionId, userId: memberC.id, roleIndex: 0 }),
|
||||||
|
).rejects.toThrow(/Requires qualification:/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets staff bypass eligibility and remove sign-ups", async () => {
|
||||||
|
// Admin signs up for the Sgt-floored role despite holding no test rank.
|
||||||
|
await setMissionAttendance(payload, {
|
||||||
|
missionId: bypassMissionId,
|
||||||
|
userId: adminUser.id,
|
||||||
|
response: "yes",
|
||||||
|
});
|
||||||
|
await signupForRole(payload, {
|
||||||
|
missionId: bypassMissionId,
|
||||||
|
userId: adminUser.id,
|
||||||
|
roleIndex: 0,
|
||||||
|
bypassEligibility: true,
|
||||||
|
});
|
||||||
|
const state = await getMissionRoleSignupState(payload, bypassMissionId, adminUser.id);
|
||||||
|
expect(state.roles[0].filled).toBe(1);
|
||||||
|
expect(state.mySignup).toMatchObject({ roleIndex: 0, holding: true });
|
||||||
|
|
||||||
|
// Staff removal reconciles slots and is marked in the event log.
|
||||||
|
const signupId = state.roles[0].entries[0].signupId;
|
||||||
|
await removeRoleSignup(payload, {
|
||||||
|
missionId: bypassMissionId,
|
||||||
|
signupId,
|
||||||
|
actorId: adminUser.id,
|
||||||
|
});
|
||||||
|
const after = await getMissionRoleSignupState(payload, bypassMissionId, adminUser.id);
|
||||||
|
expect(after.roles[0].entries).toHaveLength(0);
|
||||||
|
expect(after.mySignup).toBeNull();
|
||||||
|
|
||||||
|
const withdrawEvents = await payload.find({
|
||||||
|
collection: "game-event-logs",
|
||||||
|
where: { type: { equals: "mission:role-withdraw" } },
|
||||||
|
limit: 100,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
const removal = withdrawEvents.docs.find(
|
||||||
|
(e) =>
|
||||||
|
(e as unknown as { targetId: number }).targetId === bypassMissionId &&
|
||||||
|
(e as unknown as { data?: { removedByStaff?: boolean } }).data?.removedByStaff === true,
|
||||||
|
);
|
||||||
|
expect(removal).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes sign-ups for past and cancelled missions", async () => {
|
||||||
|
const pastGate = roleSignupGate({
|
||||||
|
ownershipAndStatus: { status: "Scheduled" },
|
||||||
|
classification: { startDateTime: new Date(Date.now() - 60_000).toISOString() },
|
||||||
|
});
|
||||||
|
expect(pastGate.open).toBe(false);
|
||||||
|
expect(pastGate.reason).toBe(roleSignupsClosedMessage);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
signupForRole(payload, { missionId: pastMissionId, userId: memberA.id, roleIndex: 0 }),
|
||||||
|
).rejects.toThrow(roleSignupsClosedMessage);
|
||||||
|
|
||||||
|
const cancelledGate = roleSignupGate({
|
||||||
|
ownershipAndStatus: { status: "Cancelled" },
|
||||||
|
classification: { startDateTime: new Date(Date.now() + 60_000).toISOString() },
|
||||||
|
});
|
||||||
|
expect(cancelledGate.open).toBe(false);
|
||||||
|
expect(cancelledGate.reason).toBe("This operation was cancelled.");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
signupForRole(payload, { missionId: cancelledMissionId, userId: memberA.id, roleIndex: 0 }),
|
||||||
|
).rejects.toThrow("This operation was cancelled.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives slot holders as a userId -> roleIndex map and diffs promotions", async () => {
|
||||||
|
const holders = await getSlotHolders(payload, missionId);
|
||||||
|
// C holds Squad Leader (RSVPed yes). A RSVPed no on the main mission, so no
|
||||||
|
// Medic slot is held there.
|
||||||
|
expect(holders.get(memberC.id)).toBe(0);
|
||||||
|
expect(holders.has(memberA.id)).toBe(false);
|
||||||
|
|
||||||
|
// A RSVPs yes: they now hold the Medic slot.
|
||||||
|
await setMissionAttendance(payload, { missionId, userId: memberA.id, response: "yes" });
|
||||||
|
const holdersAfter = await getSlotHolders(payload, missionId);
|
||||||
|
expect(holdersAfter.get(memberA.id)).toBe(1);
|
||||||
|
|
||||||
|
const promoted = diffPromotedUsers(holders, holdersAfter);
|
||||||
|
expect(promoted).toEqual([{ userId: memberA.id, roleIndex: 1 }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue