From f065aa67962356632d3f9635617ceb24f9b29792 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Wed, 12 Aug 2026 12:38:53 -0400 Subject: [PATCH] feat(missions): add RSVP attendance collection and detail UI --- .../intelligence/missions/[id]/page.tsx | 39 ++++ .../intelligence/missions/actions.ts | 49 +++++ .../intelligence/MissionAttendances.ts | 64 +++++++ src/collections/intelligence/Missions.ts | 42 +++-- .../intelligence/MissionAttendance.tsx | 157 ++++++++++++++++ src/lib/attendance/index.ts | 169 ++++++++++++++++++ src/payload-types.ts | 39 ++++ src/payload.config.ts | 2 + src/utils/event-log/eventTypes.ts | 3 + 9 files changed, 545 insertions(+), 19 deletions(-) create mode 100644 src/app/(frontend)/intelligence/missions/actions.ts create mode 100644 src/collections/intelligence/MissionAttendances.ts create mode 100644 src/components/frontend/intelligence/MissionAttendance.tsx create mode 100644 src/lib/attendance/index.ts diff --git a/src/app/(frontend)/intelligence/missions/[id]/page.tsx b/src/app/(frontend)/intelligence/missions/[id]/page.tsx index 542edd1..0ce8270 100644 --- a/src/app/(frontend)/intelligence/missions/[id]/page.tsx +++ b/src/app/(frontend)/intelligence/missions/[id]/page.tsx @@ -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>({}), + ]); + 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 (
+ + {/* Campaign Card */} {campaign && ( diff --git a/src/app/(frontend)/intelligence/missions/actions.ts b/src/app/(frontend)/intelligence/missions/actions.ts new file mode 100644 index 0000000..ea6b42e --- /dev/null +++ b/src/app/(frontend)/intelligence/missions/actions.ts @@ -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 { + 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 { + 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 }; + } +} diff --git a/src/collections/intelligence/MissionAttendances.ts b/src/collections/intelligence/MissionAttendances.ts new file mode 100644 index 0000000..9cf962a --- /dev/null +++ b/src/collections/intelligence/MissionAttendances.ts @@ -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" }, + ], + }, + ], +}; diff --git a/src/collections/intelligence/Missions.ts b/src/collections/intelligence/Missions.ts index 48af913..553a0d7 100644 --- a/src/collections/intelligence/Missions.ts +++ b/src/collections/intelligence/Missions.ts @@ -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: [ diff --git a/src/components/frontend/intelligence/MissionAttendance.tsx b/src/components/frontend/intelligence/MissionAttendance.tsx new file mode 100644 index 0000000..0ecd390 --- /dev/null +++ b/src/components/frontend/intelligence/MissionAttendance.tsx @@ -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(currentResponse); + const [pending, setPending] = useState(null); + const [error, setError] = useState(null); + + const groups: Record = { 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 ( + + +
+ + Attendance +
+
+ + {currentUserId !== null && !attendanceClosed && ( +
+ {states.map((response) => { + const meta = stateMeta[response]; + const Icon = meta.icon; + return ( + + ); + })} +
+ )} + {currentUserId !== null && attendanceClosed && ( +

+ Attendance is closed for this mission. +

+ )} + {error &&

{error}

} + {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 ( +
+
+ + {meta.label} + + ({people.length}) + +
+ {people.length > 0 && ( +
    + {people.map((person) => ( +
  • + {person.name} +
  • + ))} +
+ )} +
+ ); + })} +
+
+ ); +} diff --git a/src/lib/attendance/index.ts b/src/lib/attendance/index.ts new file mode 100644 index 0000000..3e8654f --- /dev/null +++ b/src/lib/attendance/index.ts @@ -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>; + +async function resolveUser(payload: PayloadType, ref: number | User): Promise { + 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 { + 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 { + 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> { + 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 = {}; + 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; +} diff --git a/src/payload-types.ts b/src/payload-types.ts index a2c7e95..2fabf60 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -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 | ExperienceSelect; 'user-notifications': UserNotificationsSelect | UserNotificationsSelect; missions: MissionsSelect | MissionsSelect; + 'mission-attendances': MissionAttendancesSelect | MissionAttendancesSelect; campaigns: CampaignsSelect | CampaignsSelect; factions: FactionsSelect | FactionsSelect; technologies: TechnologiesSelect | TechnologiesSelect; @@ -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 { codeName?: T; summary?: T; operationType?: T; + discordMessageId?: T; + discordAttendanceHash?: T; classification?: | T | { @@ -2809,6 +2837,17 @@ export interface MissionsSelect { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "mission-attendances_select". + */ +export interface MissionAttendancesSelect { + mission?: T; + user?: T; + response?: T; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "campaigns_select". diff --git a/src/payload.config.ts b/src/payload.config.ts index 6314ae0..6839529 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -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, diff --git a/src/utils/event-log/eventTypes.ts b/src/utils/event-log/eventTypes.ts index b6a3bee..7b4cc08 100644 --- a/src/utils/event-log/eventTypes.ts +++ b/src/utils/event-log/eventTypes.ts @@ -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];