1
0
Fork 0

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:
Jason Fraley 2026-09-30 14:50:26 -04:00
parent 56fb6ac26a
commit 190ddac41f
21 changed files with 107148 additions and 13 deletions

View file

@ -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.**

View file

@ -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} />

View file

@ -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 };
}
}

View 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.",
},
},
],
};

View file

@ -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.",
},
},
], ],
}, },
{ {

View 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>
);
}

View file

@ -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" />

View 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>
);
}

View file

@ -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;
} }

View 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.";

View 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,
});
}

View file

@ -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";
} }

View file

@ -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);

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -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;`)
}

View file

@ -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",

View 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 }]);
});
});