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`).
|
||||
- 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
|
||||
|
||||
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 { MissionFactions } from "@/components/frontend/intelligence/MissionFactions";
|
||||
import { MissionRoles } from "@/components/frontend/intelligence/MissionRoles";
|
||||
import { MissionRoleSignups } from "@/components/frontend/intelligence/MissionRoleSignups";
|
||||
import { MissionLogistics } from "@/components/frontend/intelligence/MissionLogistics";
|
||||
import { MissionComms } from "@/components/frontend/intelligence/MissionComms";
|
||||
import { MissionAttendance } from "@/components/frontend/intelligence/MissionAttendance";
|
||||
import { SideOpControls } from "@/components/frontend/intelligence/SideOpControls";
|
||||
import type { SideOpDialogOption } from "@/components/frontend/intelligence/CreateSideOpDialog";
|
||||
import { isAdminOrDeveloper } from "@/lib/promotions/authority";
|
||||
import {
|
||||
checkRoleEligibilities,
|
||||
getMissionRoleSignupState,
|
||||
roleSignupGate,
|
||||
} from "@/lib/intelligence/roleSignups";
|
||||
import {
|
||||
type AttendanceResponse,
|
||||
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 (
|
||||
<div className="flex flex-col">
|
||||
<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}
|
||||
/>
|
||||
|
||||
<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} />
|
||||
|
||||
<MissionComms commsAndDocs={mission.commsAndDocs ?? null} />
|
||||
|
|
|
|||
|
|
@ -5,6 +5,12 @@ import { getPayload } from "payload";
|
|||
import type { AttendanceResponse } from "@/lib/attendance";
|
||||
import { setMissionAttendance as setAttendance } from "@/lib/attendance";
|
||||
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 { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||
|
|
@ -17,7 +23,7 @@ import {
|
|||
zonedParts,
|
||||
zonedWeekday,
|
||||
} from "@/lib/intelligence/fridayTime";
|
||||
import type { Mission } from "@/payload-types";
|
||||
import type { Mission, User } from "@/payload-types";
|
||||
|
||||
export interface ActionResult<T = undefined> {
|
||||
success: boolean;
|
||||
|
|
@ -942,3 +948,103 @@ export async function moveFridaySideOpToCampaign(
|
|||
: { 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: {
|
||||
|
|
@ -448,6 +478,14 @@ export const Missions: CollectionConfig = {
|
|||
type: "text",
|
||||
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",
|
||||
type: "checkbox",
|
||||
|
|
@ -477,6 +515,18 @@ export const Missions: CollectionConfig = {
|
|||
"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 type { Qualification, Rank } from "@/payload-types";
|
||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||
import {
|
||||
RoleDescription,
|
||||
type RoleDescriptionData,
|
||||
} from "@/components/frontend/intelligence/RoleDescription";
|
||||
|
||||
interface RoleEntry {
|
||||
roleName: string;
|
||||
description?: RoleDescriptionData | null;
|
||||
missionCritical?: boolean | null;
|
||||
requiredMos?: (Qualification | number)[] | null;
|
||||
minimumRank?: Rank | number | null;
|
||||
slots?: number | null;
|
||||
}
|
||||
|
||||
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">
|
||||
Role Name
|
||||
</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">
|
||||
Critical
|
||||
</th>
|
||||
|
|
@ -73,7 +82,15 @@ export function MissionRoles({ roles, notes, maxPlayers }: MissionRolesProps) {
|
|||
<tbody>
|
||||
{roles.map((role, index) => (
|
||||
<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">
|
||||
{role.missionCritical ? (
|
||||
<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 { emitGameEvent } from "@/utils/event-log/emit";
|
||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||
import { getSlotHolders, notifySlotHoldersDiff } from "@/lib/intelligence/roleSignups";
|
||||
|
||||
export type AttendanceResponse = "yes" | "no" | "tentative";
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
// Shared mission-time gates live in lib/intelligence/missionTime.ts; re-exported
|
||||
// here to keep the existing public API of this module unchanged.
|
||||
import {
|
||||
attendanceClosedMessage as sharedAttendanceClosedMessage,
|
||||
isMissionPast as sharedIsMissionPast,
|
||||
} from "@/lib/intelligence/missionTime";
|
||||
|
||||
export const attendanceClosedMessage = "Attendance is closed for this mission.";
|
||||
export const isMissionPast = sharedIsMissionPast;
|
||||
export const attendanceClosedMessage = sharedAttendanceClosedMessage;
|
||||
|
||||
export interface MissionAttendanceCounts {
|
||||
yes: number;
|
||||
|
|
@ -67,6 +65,11 @@ export async function setMissionAttendance(
|
|||
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({
|
||||
collection: "mission-attendances",
|
||||
where: {
|
||||
|
|
@ -104,6 +107,9 @@ export async function setMissionAttendance(
|
|||
data: { response },
|
||||
});
|
||||
|
||||
const afterSlotHolders = await getSlotHolders(payload, missionId);
|
||||
await notifySlotHoldersDiff(payload, missionId, beforeSlotHolders, afterSlotHolders, [userId]);
|
||||
|
||||
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";
|
||||
case "personnel:promoted":
|
||||
return "Promotion";
|
||||
case "mission:role-promoted":
|
||||
return "Role slot granted";
|
||||
default:
|
||||
return type ?? "Notification";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export const MUTEABLE_NOTIFICATION_TYPES = [
|
|||
{ label: "Poker game auto-ended", value: "poker:everyone-left" },
|
||||
{ label: "Blackjack", value: "blackjack:force-ended" },
|
||||
{ label: "Blackjack table auto-ended", value: "blackjack:everyone-left" },
|
||||
{ label: "Role slot promotions", value: "mission:role-promoted" },
|
||||
] as const;
|
||||
export type MuteableNotificationType = (typeof MUTEABLE_NOTIFICATION_TYPES)[number]["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
|
||||
MissionAttendanceChange: "mission:attendance-change",
|
||||
|
||||
// Mission role sign-ups
|
||||
MissionRoleSignup: "mission:role-signup",
|
||||
MissionRoleWithdraw: "mission:role-withdraw",
|
||||
MissionRolePromoted: "mission:role-promoted",
|
||||
|
||||
// Evaluations
|
||||
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