- | {role.roleName} |
+
+ {role.roleName}
+ {role.description && (
+
+ )}
+ |
+
+ {role.slots ?? 1}
+ |
{role.missionCritical ? (
diff --git a/src/components/frontend/intelligence/RoleDescription.tsx b/src/components/frontend/intelligence/RoleDescription.tsx
new file mode 100644
index 0000000..dbdcf03
--- /dev/null
+++ b/src/components/frontend/intelligence/RoleDescription.tsx
@@ -0,0 +1,31 @@
+import { RichText } from "@payloadcms/richtext-lexical/react";
+import { cn } from "@/lib/utils";
+
+type LexicalData = Parameters[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 (
+
+ {description}
+
+ );
+ }
+ if (!description || typeof description !== "object") return null;
+ return (
+
+
+
+ );
+}
diff --git a/src/lib/attendance/index.ts b/src/lib/attendance/index.ts
index 824fa5c..b090cea 100644
--- a/src/lib/attendance/index.ts
+++ b/src/lib/attendance/index.ts
@@ -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;
}
diff --git a/src/lib/intelligence/missionTime.ts b/src/lib/intelligence/missionTime.ts
new file mode 100644
index 0000000..55b4397
--- /dev/null
+++ b/src/lib/intelligence/missionTime.ts
@@ -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.";
diff --git a/src/lib/intelligence/roleSignups.ts b/src/lib/intelligence/roleSignups.ts
new file mode 100644
index 0000000..1bb93c5
--- /dev/null
+++ b/src/lib/intelligence/roleSignups.ts
@@ -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>;
+
+export const roleSignupsClosedMessage = "Role sign-ups are closed for this mission.";
+
+/** Notification type used when a waitlisted member is promoted into a slot. */
+export const ROLE_PROMOTED_NOTIFICATION_TYPE = "mission:role-promoted";
+
+export interface RoleRequirementRefs {
+ requiredMos?: (Qualification | number)[] | null;
+ minimumRank?: Rank | number | null;
+}
+
+export interface RoleEligibility {
+ eligible: boolean;
+ reasons: string[];
+}
+
+export interface RoleSignupGate {
+ open: boolean;
+ reason?: string;
+}
+
+export interface RoleQueueEntryView {
+ signupId: number;
+ userId: number;
+ name: string;
+ holding: boolean;
+ /** 1-based position within this role's queue (slot holders come first). */
+ position: number;
+}
+
+export interface RoleSignupRoleState {
+ roleIndex: number;
+ roleName: string;
+ slots: number;
+ filled: number;
+ entries: RoleQueueEntryView[];
+}
+
+export interface MissionRoleSignupState {
+ roles: RoleSignupRoleState[];
+ mySignup: { roleIndex: number; holding: boolean; position: number } | null;
+}
+
+/** userId -> roleIndex for everyone currently holding a slot on the mission. */
+export type SlotHolderMap = Map;
+
+/**
+ * Sign-up gate: sign-ups stay open until the op start passes (mirroring
+ * attendance) and close permanently on Cancelled/Completed.
+ */
+export function roleSignupGate(mission: {
+ ownershipAndStatus?: { status?: string | null } | null;
+ classification?: { startDateTime?: string | null } | null;
+}): RoleSignupGate {
+ const status = mission.ownershipAndStatus?.status;
+ if (status === "Cancelled") return { open: false, reason: "This operation was cancelled." };
+ if (status === "Completed") return { open: false, reason: "This operation has concluded." };
+ const startDateTime = mission.classification?.startDateTime;
+ if (startDateTime && isMissionPast(startDateTime)) {
+ return { open: false, reason: roleSignupsClosedMessage };
+ }
+ return { open: true };
+}
+
+interface EligibilityProfileData {
+ rankId: number | null;
+ qualificationIds: number[];
+}
+
+async function loadEligibilityProfileData(
+ payload: PayloadType,
+ userId: number,
+): Promise {
+ const res = await payload.find({
+ collection: "profiles",
+ where: { user: { equals: userId } },
+ limit: 1,
+ depth: 1,
+ select: { rank: true, progression: { qualifications: true } },
+ overrideAccess: true,
+ });
+ const doc = res.docs[0] as
+ | {
+ rank?: number | { id: number } | null;
+ progression?: { qualifications?: (Qualification | number)[] | null };
+ }
+ | undefined;
+ if (!doc) return null;
+
+ const rankId =
+ typeof doc.rank === "object" && doc.rank !== null ? doc.rank.id : (doc.rank ?? null);
+ const qualificationIds = (doc.progression?.qualifications ?? [])
+ .map((q) => (typeof q === "object" && q !== null ? q.id : q))
+ .filter((id): id is number => typeof id === "number");
+
+ return { rankId: rankId ?? null, qualificationIds };
+}
+
+async function resolveQualificationNames(
+ payload: PayloadType,
+ refs: (Qualification | number)[],
+): Promise |