1
0
Fork 0

feat(missions): add RSVP attendance collection and detail UI

This commit is contained in:
Jason Fraley 2026-08-12 12:38:53 -04:00
parent 2984c72d77
commit f065aa6796
9 changed files with 545 additions and 19 deletions

View file

@ -2,6 +2,7 @@ import config from "@payload-config";
import { getPayload } from "payload";
import { notFound } from "next/navigation";
import Link from "next/link";
import { headers as nextHeaders } from "next/headers";
import type {
Mission,
Campaign,
@ -37,6 +38,13 @@ import { MissionFactions } from "@/components/frontend/intelligence/MissionFacti
import { MissionRoles } from "@/components/frontend/intelligence/MissionRoles";
import { MissionLogistics } from "@/components/frontend/intelligence/MissionLogistics";
import { MissionComms } from "@/components/frontend/intelligence/MissionComms";
import { MissionAttendance } from "@/components/frontend/intelligence/MissionAttendance";
import {
getMissionAttendance,
getUserAttendance,
isMissionPast,
type AttendanceResponse,
} from "@/lib/attendance";
interface MissionDetailPageProps {
params: Promise<{ id: string }>;
@ -128,6 +136,29 @@ export default async function MissionDetailPage({
const siblingMissions = siblingMissionsRes.docs;
const { user: currentUser } = await payload.auth({
headers: await nextHeaders(),
canSetHeaders: false,
});
const [attendance, userAttendance] = await Promise.all([
getMissionAttendance(payload, mission.id),
currentUser
? getUserAttendance(payload, currentUser.id as number, [mission.id])
: Promise.resolve<Record<number, AttendanceResponse>>({}),
]);
const currentResponse = userAttendance[mission.id] ?? null;
const attendanceClosed = isMissionPast(mission.classification.startDateTime);
const attendanceProps = {
yes: attendance.yes.map((u) => ({ id: u.id, name: u.displayName || u.username })),
no: attendance.no.map((u) => ({ id: u.id, name: u.displayName || u.username })),
tentative: attendance.tentative.map((u) => ({
id: u.id,
name: u.displayName || u.username,
})),
};
return (
<div className="p-5 flex flex-col gap-6 max-w-7xl">
<Link
@ -252,6 +283,14 @@ export default async function MissionDetailPage({
{/* ── Right Sidebar (1/3) ─────────────────────────── */}
<div className="flex flex-col gap-4">
<MissionAttendance
missionId={mission.id}
currentUserId={currentUser ? (currentUser.id as number) : null}
currentResponse={currentResponse}
attendanceClosed={attendanceClosed}
{...attendanceProps}
/>
{/* Campaign Card */}
{campaign && (
<Item variant="outline">

View file

@ -0,0 +1,49 @@
"use server";
import config from "@payload-config";
import { getPayload } from "payload";
import { setMissionAttendance as setAttendance } from "@/lib/attendance";
import type { AttendanceResponse } from "@/lib/attendance";
export interface ActionResult<T = undefined> {
success: boolean;
error?: string;
data?: T;
}
async function authenticate() {
const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig });
const { headers } = await import("next/headers");
const hdrs = await headers();
const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false });
if (!user) {
throw new Error("Unauthorized");
}
return { payload, user };
}
export async function setMissionAttendance(input: {
missionId: number;
response: AttendanceResponse;
}): Promise<ActionResult> {
try {
const { payload, user } = await authenticate();
const userId = user.id as number;
await setAttendance(payload, {
missionId: input.missionId,
userId,
response: input.response,
});
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,64 @@
import { CollectionConfig } from "payload";
import hasRoles from "@/utils/access-control/hasRoles";
export const MissionAttendances: CollectionConfig = {
slug: "mission-attendances",
admin: {
group: "Intelligence",
defaultColumns: ["mission", "user", "response"],
},
access: {
read: ({ req }) => !!req.user,
create: ({ req, data }) => {
if (hasRoles(["admin", "developer"], req.user)) return true;
if (!req.user || !data) return false;
const ownerId = typeof data.user === "object" ? data.user?.id : data.user;
return ownerId === req.user.id;
},
update: async ({ req, data, id }) => {
if (hasRoles(["admin", "developer"], req.user)) return true;
if (!req.user || typeof id !== "number") return false;
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-attendances",
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: ({ req }) => hasRoles(["admin", "developer"], req.user),
},
fields: [
{
name: "mission",
type: "relationship",
relationTo: "missions",
required: true,
index: true,
},
{
name: "user",
type: "relationship",
relationTo: "users",
required: true,
},
{
name: "response",
type: "select",
required: true,
options: [
{ label: "Yes", value: "yes" },
{ label: "No", value: "no" },
{ label: "Tentative", value: "tentative" },
],
},
],
};

View file

@ -18,11 +18,8 @@ async function isInAssignments(
select: { leader: true, members: true },
});
return assignments.docs.some((a) => {
const leaderId =
a.leader && typeof a.leader === "object" ? a.leader.id : a.leader;
const memberIds = (a.members ?? []).map((m) =>
typeof m === "object" ? m.id : m,
);
const leaderId = a.leader && typeof a.leader === "object" ? a.leader.id : a.leader;
const memberIds = (a.members ?? []).map((m) => (typeof m === "object" ? m.id : m));
return (
(leaderId !== null && leaderId !== undefined && leaderId === userId) ||
memberIds.includes(userId)
@ -52,26 +49,17 @@ export const Missions: CollectionConfig = {
const userId = req.user.id;
const gameRules = await req.payload.findGlobal({ slug: "game-rules" });
const commandIds = extractIds(
gameRules.commandAssignments as AssignmentRef[] | undefined,
);
const intelIds = extractIds(
gameRules.intelAssignments as AssignmentRef[] | undefined,
);
const commandIds = extractIds(gameRules.commandAssignments as AssignmentRef[] | undefined);
const intelIds = extractIds(gameRules.intelAssignments as AssignmentRef[] | undefined);
const [isCommand, isIntel] = await Promise.all([
isInAssignments(req.payload, userId, commandIds),
isInAssignments(req.payload, userId, intelIds),
]);
const authorIds = extractIds(
data?.ownershipAndStatus?.authors as unknown[] | undefined,
);
const zeusIds = extractIds(
data?.ownershipAndStatus?.zeus as unknown[] | undefined,
);
const isAuthorOrZeus =
authorIds.includes(userId) || zeusIds.includes(userId);
const authorIds = extractIds(data?.ownershipAndStatus?.authors as unknown[] | undefined);
const zeusIds = extractIds(data?.ownershipAndStatus?.zeus as unknown[] | undefined);
const isAuthorOrZeus = authorIds.includes(userId) || zeusIds.includes(userId);
const status = data?.ownershipAndStatus?.status;
const isDraft = status === "Concept" || status === "Planning";
@ -160,6 +148,22 @@ export const Missions: CollectionConfig = {
defaultValue: ({ req }) => (isDeveloper({ req: req }) ? "main" : "side"),
required: true,
},
{
name: "discordMessageId",
type: "text",
admin: {
description: "ID of the attendance embed message in Discord (managed by the bot).",
hidden: true,
},
},
{
name: "discordAttendanceHash",
type: "text",
admin: {
description: "Hash of the last attendance state rendered to Discord (managed by the bot).",
hidden: true,
},
},
{
type: "tabs",
tabs: [

View file

@ -0,0 +1,157 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { CalendarCheckIcon, CheckIcon, MinusIcon, XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { setMissionAttendance } from "@/app/(frontend)/intelligence/missions/actions";
type AttendanceResponse = "yes" | "no" | "tentative";
interface Attendee {
id: number;
name: string;
}
interface MissionAttendanceProps {
missionId: number;
currentUserId: number | null;
currentResponse: AttendanceResponse | null;
attendanceClosed?: boolean;
yes: Attendee[];
no: Attendee[];
tentative: Attendee[];
}
const stateMeta: Record<
AttendanceResponse,
{ label: string; icon: typeof CheckIcon; color: string; active: string }
> = {
yes: {
label: "Yes",
icon: CheckIcon,
color: "text-green-400",
active: "border-green-500/60 bg-green-500/15 text-green-400 hover:bg-green-500/25",
},
no: {
label: "No",
icon: XIcon,
color: "text-red-400",
active: "border-red-500/60 bg-red-500/15 text-red-400 hover:bg-red-500/25",
},
tentative: {
label: "Tentative",
icon: MinusIcon,
color: "text-amber-400",
active: "border-amber-500/60 bg-amber-500/15 text-amber-400 hover:bg-amber-500/25",
},
};
const states = Object.keys(stateMeta) as AttendanceResponse[];
export function MissionAttendance({
missionId,
currentUserId,
currentResponse,
attendanceClosed = false,
yes,
no,
tentative,
}: MissionAttendanceProps) {
const router = useRouter();
const [selected, setSelected] = useState<AttendanceResponse | null>(currentResponse);
const [pending, setPending] = useState<AttendanceResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const groups: Record<AttendanceResponse, Attendee[]> = { yes, no, tentative };
async function handleSet(response: AttendanceResponse) {
setPending(response);
setSelected(response);
setError(null);
const res = await setMissionAttendance({ missionId, response });
if (!res.success) {
setSelected(currentResponse);
setError(res.error ?? "Failed to update attendance.");
}
setPending(null);
router.refresh();
}
return (
<Item variant="outline">
<ItemHeader>
<div className="flex items-center gap-2">
<CalendarCheckIcon className="size-4 text-muted-foreground" />
<ItemTitle>Attendance</ItemTitle>
</div>
</ItemHeader>
<ItemContent className="flex flex-col gap-3">
{currentUserId !== null && !attendanceClosed && (
<div className="flex items-center gap-1.5">
{states.map((response) => {
const meta = stateMeta[response];
const Icon = meta.icon;
return (
<Button
key={response}
size="sm"
variant="outline"
className={cn("flex-1", selected === response && meta.active)}
disabled={pending !== null}
onClick={() => handleSet(response)}
>
<Icon className="size-3.5" />
{meta.label}
</Button>
);
})}
</div>
)}
{currentUserId !== null && attendanceClosed && (
<p className="text-xs text-muted-foreground">
Attendance is closed for this mission.
</p>
)}
{error && <p className="text-xs text-red-400">{error}</p>}
{states.map((response) => {
const meta = stateMeta[response];
const Icon = meta.icon;
const people = groups[response]
.slice()
.sort((a, b) => a.name.localeCompare(b.name));
return (
<div key={response} className="flex flex-col gap-1">
<div className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider">
<Icon className={cn("size-3.5", meta.color)} />
<span className={meta.color}>{meta.label}</span>
<span className="font-mono text-muted-foreground">
({people.length})
</span>
</div>
{people.length > 0 && (
<ul className="flex flex-wrap gap-x-3 gap-y-0.5">
{people.map((person) => (
<li
key={person.id}
className={cn(
"text-sm",
person.id === currentUserId
? "text-foreground font-medium"
: "text-muted-foreground",
)}
>
{person.name}
</li>
))}
</ul>
)}
</div>
);
})}
</ItemContent>
</Item>
);
}

169
src/lib/attendance/index.ts Normal file
View file

@ -0,0 +1,169 @@
import type { MissionAttendance, User } from "@/payload-types";
import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes";
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();
}
export const attendanceClosedMessage = "Attendance is closed for this mission.";
export interface MissionAttendanceCounts {
yes: number;
no: number;
tentative: number;
}
export interface MissionAttendanceSummary {
yes: User[];
no: User[];
tentative: User[];
counts: MissionAttendanceCounts;
}
export interface SetMissionAttendanceInput {
missionId: number;
userId: number;
response: AttendanceResponse;
}
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
async function resolveUser(payload: PayloadType, ref: number | User): Promise<User | null> {
if (typeof ref === "object" && ref !== null) return ref;
return (await payload
.findByID({ collection: "users", id: ref, depth: 0, overrideAccess: true })
.catch(() => null)) as unknown as User | null;
}
export async function setMissionAttendance(
payload: PayloadType,
input: SetMissionAttendanceInput,
): Promise<MissionAttendance> {
const { missionId, userId, response } = input;
if (!["yes", "no", "tentative"].includes(response)) {
throw new Error("Attendance response must be yes, no, or tentative.");
}
const mission = (await payload
.findByID({ collection: "missions", id: missionId, depth: 0, overrideAccess: true })
.catch(() => null)) as unknown as
| { name?: string; classification: { startDateTime: string } }
| null;
if (!mission) throw new Error("Mission not found.");
if (isMissionPast(mission.classification.startDateTime)) {
throw new Error(attendanceClosedMessage);
}
const existing = (await payload.find({
collection: "mission-attendances",
where: {
and: [{ mission: { equals: missionId } }, { user: { equals: userId } }],
},
limit: 1,
depth: 0,
overrideAccess: true,
})) as unknown as { docs: MissionAttendance[] };
let entry: MissionAttendance;
if (existing.docs.length > 0) {
entry = (await payload.update({
collection: "mission-attendances",
id: existing.docs[0].id,
data: { response },
overrideAccess: true,
depth: 0,
})) as unknown as MissionAttendance;
} else {
entry = (await payload.create({
collection: "mission-attendances",
data: { mission: missionId, user: userId, response },
overrideAccess: true,
depth: 0,
})) as unknown as MissionAttendance;
}
await emitGameEvent(payload, {
type: EventTypes.MissionAttendanceChange,
message: `${mission.name ?? `Mission #${missionId}`}: attendance set to ${response}`,
actor: userId,
targetCollection: "missions",
targetId: missionId,
data: { response },
});
return entry;
}
export async function getMissionAttendance(
payload: PayloadType,
missionId: number,
): Promise<MissionAttendanceSummary> {
const res = (await payload.find({
collection: "mission-attendances",
where: { mission: { equals: missionId } },
depth: 1,
limit: 1000,
sort: "createdAt",
overrideAccess: true,
})) as unknown as { docs: MissionAttendance[] };
const summary: MissionAttendanceSummary = {
yes: [],
no: [],
tentative: [],
counts: { yes: 0, no: 0, tentative: 0 },
};
for (const doc of res.docs) {
const user = await resolveUser(payload, doc.user as number | User);
if (!user) continue;
if (doc.response === "yes") summary.yes.push(user);
else if (doc.response === "no") summary.no.push(user);
else summary.tentative.push(user);
}
summary.counts = {
yes: summary.yes.length,
no: summary.no.length,
tentative: summary.tentative.length,
};
return summary;
}
export async function getUserAttendance(
payload: PayloadType,
userId: number,
missionIds: number[],
): Promise<Record<number, AttendanceResponse>> {
if (missionIds.length === 0) return {};
const res = (await payload.find({
collection: "mission-attendances",
where: {
and: [{ user: { equals: userId } }, { mission: { in: missionIds } }],
},
depth: 0,
limit: missionIds.length,
overrideAccess: true,
})) as unknown as { docs: MissionAttendance[] };
const result: Record<number, AttendanceResponse> = {};
for (const doc of res.docs) {
const missionId = typeof doc.mission === "object" ? doc.mission.id : (doc.mission as number);
result[missionId] = doc.response;
}
return result;
}

View file

@ -82,6 +82,7 @@ export interface Config {
experience: Experience;
'user-notifications': UserNotification;
missions: Mission;
'mission-attendances': MissionAttendance;
campaigns: Campaign;
factions: Faction;
technologies: Technology;
@ -130,6 +131,7 @@ export interface Config {
experience: ExperienceSelect<false> | ExperienceSelect<true>;
'user-notifications': UserNotificationsSelect<false> | UserNotificationsSelect<true>;
missions: MissionsSelect<false> | MissionsSelect<true>;
'mission-attendances': MissionAttendancesSelect<false> | MissionAttendancesSelect<true>;
campaigns: CampaignsSelect<false> | CampaignsSelect<true>;
factions: FactionsSelect<false> | FactionsSelect<true>;
technologies: TechnologiesSelect<false> | TechnologiesSelect<true>;
@ -1509,6 +1511,14 @@ export interface Mission {
*/
summary: string;
operationType: 'main' | 'side' | 'external';
/**
* ID of the attendance embed message in Discord (managed by the bot).
*/
discordMessageId?: string | null;
/**
* Hash of the last attendance state rendered to Discord (managed by the bot).
*/
discordAttendanceHash?: string | null;
classification: {
map: number | Map;
missionType: 'Zeus' | 'PvE' | 'PvP' | 'PvPvE' | 'Training' | 'Special';
@ -1732,6 +1742,18 @@ export interface ModList {
focalX?: number | null;
focalY?: number | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "mission-attendances".
*/
export interface MissionAttendance {
id: number;
mission: number | Mission;
user: number | User;
response: 'yes' | 'no' | 'tentative';
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "shipments".
@ -2268,6 +2290,10 @@ export interface PayloadLockedDocument {
relationTo: 'missions';
value: number | Mission;
} | null)
| ({
relationTo: 'mission-attendances';
value: number | MissionAttendance;
} | null)
| ({
relationTo: 'campaigns';
value: number | Campaign;
@ -2703,6 +2729,8 @@ export interface MissionsSelect<T extends boolean = true> {
codeName?: T;
summary?: T;
operationType?: T;
discordMessageId?: T;
discordAttendanceHash?: T;
classification?:
| T
| {
@ -2809,6 +2837,17 @@ export interface MissionsSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "mission-attendances_select".
*/
export interface MissionAttendancesSelect<T extends boolean = true> {
mission?: T;
user?: T;
response?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "campaigns_select".

View file

@ -12,6 +12,7 @@ import { Media } from "@/collections/Media";
import { Ranks } from "@/collections/users/Ranks";
import { Profiles } from "@/collections/users/Profiles";
import { Missions } from "@/collections/intelligence/Missions";
import { MissionAttendances } from "@/collections/intelligence/MissionAttendances";
import { Maps } from "@/collections/world/Maps";
import { Campaigns } from "@/collections/intelligence/Campaigns";
import { Qualifications } from "@/collections/users/Qualifications";
@ -100,6 +101,7 @@ export default buildConfig({
// Intel
Missions,
MissionAttendances,
Campaigns,
Factions,
Technologies,

View file

@ -81,6 +81,9 @@ export const EventTypes = {
AccountProfileUpdate: "account:profile-update",
AccountDiscordRequest: "account:discord-request",
AccountPreferencesUpdate: "account:preferences-update",
// Attendance
MissionAttendanceChange: "mission:attendance-change",
} as const;
export type EventType = (typeof EventTypes)[keyof typeof EventTypes];