feat(missions): add RSVP attendance collection and detail UI
This commit is contained in:
parent
2984c72d77
commit
f065aa6796
9 changed files with 545 additions and 19 deletions
|
|
@ -2,6 +2,7 @@ import config from "@payload-config";
|
||||||
import { getPayload } from "payload";
|
import { getPayload } from "payload";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { headers as nextHeaders } from "next/headers";
|
||||||
import type {
|
import type {
|
||||||
Mission,
|
Mission,
|
||||||
Campaign,
|
Campaign,
|
||||||
|
|
@ -37,6 +38,13 @@ import { MissionFactions } from "@/components/frontend/intelligence/MissionFacti
|
||||||
import { MissionRoles } from "@/components/frontend/intelligence/MissionRoles";
|
import { MissionRoles } from "@/components/frontend/intelligence/MissionRoles";
|
||||||
import { MissionLogistics } from "@/components/frontend/intelligence/MissionLogistics";
|
import { MissionLogistics } from "@/components/frontend/intelligence/MissionLogistics";
|
||||||
import { MissionComms } from "@/components/frontend/intelligence/MissionComms";
|
import { MissionComms } from "@/components/frontend/intelligence/MissionComms";
|
||||||
|
import { MissionAttendance } from "@/components/frontend/intelligence/MissionAttendance";
|
||||||
|
import {
|
||||||
|
getMissionAttendance,
|
||||||
|
getUserAttendance,
|
||||||
|
isMissionPast,
|
||||||
|
type AttendanceResponse,
|
||||||
|
} from "@/lib/attendance";
|
||||||
|
|
||||||
interface MissionDetailPageProps {
|
interface MissionDetailPageProps {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
|
|
@ -128,6 +136,29 @@ export default async function MissionDetailPage({
|
||||||
|
|
||||||
const siblingMissions = siblingMissionsRes.docs;
|
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 (
|
return (
|
||||||
<div className="p-5 flex flex-col gap-6 max-w-7xl">
|
<div className="p-5 flex flex-col gap-6 max-w-7xl">
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -252,6 +283,14 @@ export default async function MissionDetailPage({
|
||||||
|
|
||||||
{/* ── Right Sidebar (1/3) ─────────────────────────── */}
|
{/* ── Right Sidebar (1/3) ─────────────────────────── */}
|
||||||
<div className="flex flex-col gap-4">
|
<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 Card */}
|
||||||
{campaign && (
|
{campaign && (
|
||||||
<Item variant="outline">
|
<Item variant="outline">
|
||||||
|
|
|
||||||
49
src/app/(frontend)/intelligence/missions/actions.ts
Normal file
49
src/app/(frontend)/intelligence/missions/actions.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/collections/intelligence/MissionAttendances.ts
Normal file
64
src/collections/intelligence/MissionAttendances.ts
Normal 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" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
@ -18,11 +18,8 @@ async function isInAssignments(
|
||||||
select: { leader: true, members: true },
|
select: { leader: true, members: true },
|
||||||
});
|
});
|
||||||
return assignments.docs.some((a) => {
|
return assignments.docs.some((a) => {
|
||||||
const leaderId =
|
const leaderId = a.leader && typeof a.leader === "object" ? a.leader.id : a.leader;
|
||||||
a.leader && typeof a.leader === "object" ? a.leader.id : a.leader;
|
const memberIds = (a.members ?? []).map((m) => (typeof m === "object" ? m.id : m));
|
||||||
const memberIds = (a.members ?? []).map((m) =>
|
|
||||||
typeof m === "object" ? m.id : m,
|
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
(leaderId !== null && leaderId !== undefined && leaderId === userId) ||
|
(leaderId !== null && leaderId !== undefined && leaderId === userId) ||
|
||||||
memberIds.includes(userId)
|
memberIds.includes(userId)
|
||||||
|
|
@ -52,26 +49,17 @@ export const Missions: CollectionConfig = {
|
||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
|
|
||||||
const gameRules = await req.payload.findGlobal({ slug: "game-rules" });
|
const gameRules = await req.payload.findGlobal({ slug: "game-rules" });
|
||||||
const commandIds = extractIds(
|
const commandIds = extractIds(gameRules.commandAssignments as AssignmentRef[] | undefined);
|
||||||
gameRules.commandAssignments as AssignmentRef[] | undefined,
|
const intelIds = extractIds(gameRules.intelAssignments as AssignmentRef[] | undefined);
|
||||||
);
|
|
||||||
const intelIds = extractIds(
|
|
||||||
gameRules.intelAssignments as AssignmentRef[] | undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
const [isCommand, isIntel] = await Promise.all([
|
const [isCommand, isIntel] = await Promise.all([
|
||||||
isInAssignments(req.payload, userId, commandIds),
|
isInAssignments(req.payload, userId, commandIds),
|
||||||
isInAssignments(req.payload, userId, intelIds),
|
isInAssignments(req.payload, userId, intelIds),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const authorIds = extractIds(
|
const authorIds = extractIds(data?.ownershipAndStatus?.authors as unknown[] | undefined);
|
||||||
data?.ownershipAndStatus?.authors as unknown[] | undefined,
|
const zeusIds = extractIds(data?.ownershipAndStatus?.zeus as unknown[] | undefined);
|
||||||
);
|
const isAuthorOrZeus = authorIds.includes(userId) || zeusIds.includes(userId);
|
||||||
const zeusIds = extractIds(
|
|
||||||
data?.ownershipAndStatus?.zeus as unknown[] | undefined,
|
|
||||||
);
|
|
||||||
const isAuthorOrZeus =
|
|
||||||
authorIds.includes(userId) || zeusIds.includes(userId);
|
|
||||||
|
|
||||||
const status = data?.ownershipAndStatus?.status;
|
const status = data?.ownershipAndStatus?.status;
|
||||||
const isDraft = status === "Concept" || status === "Planning";
|
const isDraft = status === "Concept" || status === "Planning";
|
||||||
|
|
@ -160,6 +148,22 @@ export const Missions: CollectionConfig = {
|
||||||
defaultValue: ({ req }) => (isDeveloper({ req: req }) ? "main" : "side"),
|
defaultValue: ({ req }) => (isDeveloper({ req: req }) ? "main" : "side"),
|
||||||
required: true,
|
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",
|
type: "tabs",
|
||||||
tabs: [
|
tabs: [
|
||||||
|
|
|
||||||
157
src/components/frontend/intelligence/MissionAttendance.tsx
Normal file
157
src/components/frontend/intelligence/MissionAttendance.tsx
Normal 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
169
src/lib/attendance/index.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -82,6 +82,7 @@ export interface Config {
|
||||||
experience: Experience;
|
experience: Experience;
|
||||||
'user-notifications': UserNotification;
|
'user-notifications': UserNotification;
|
||||||
missions: Mission;
|
missions: Mission;
|
||||||
|
'mission-attendances': MissionAttendance;
|
||||||
campaigns: Campaign;
|
campaigns: Campaign;
|
||||||
factions: Faction;
|
factions: Faction;
|
||||||
technologies: Technology;
|
technologies: Technology;
|
||||||
|
|
@ -130,6 +131,7 @@ export interface Config {
|
||||||
experience: ExperienceSelect<false> | ExperienceSelect<true>;
|
experience: ExperienceSelect<false> | ExperienceSelect<true>;
|
||||||
'user-notifications': UserNotificationsSelect<false> | UserNotificationsSelect<true>;
|
'user-notifications': UserNotificationsSelect<false> | UserNotificationsSelect<true>;
|
||||||
missions: MissionsSelect<false> | MissionsSelect<true>;
|
missions: MissionsSelect<false> | MissionsSelect<true>;
|
||||||
|
'mission-attendances': MissionAttendancesSelect<false> | MissionAttendancesSelect<true>;
|
||||||
campaigns: CampaignsSelect<false> | CampaignsSelect<true>;
|
campaigns: CampaignsSelect<false> | CampaignsSelect<true>;
|
||||||
factions: FactionsSelect<false> | FactionsSelect<true>;
|
factions: FactionsSelect<false> | FactionsSelect<true>;
|
||||||
technologies: TechnologiesSelect<false> | TechnologiesSelect<true>;
|
technologies: TechnologiesSelect<false> | TechnologiesSelect<true>;
|
||||||
|
|
@ -1509,6 +1511,14 @@ export interface Mission {
|
||||||
*/
|
*/
|
||||||
summary: string;
|
summary: string;
|
||||||
operationType: 'main' | 'side' | 'external';
|
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: {
|
classification: {
|
||||||
map: number | Map;
|
map: number | Map;
|
||||||
missionType: 'Zeus' | 'PvE' | 'PvP' | 'PvPvE' | 'Training' | 'Special';
|
missionType: 'Zeus' | 'PvE' | 'PvP' | 'PvPvE' | 'Training' | 'Special';
|
||||||
|
|
@ -1732,6 +1742,18 @@ export interface ModList {
|
||||||
focalX?: number | null;
|
focalX?: number | null;
|
||||||
focalY?: 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
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "shipments".
|
* via the `definition` "shipments".
|
||||||
|
|
@ -2268,6 +2290,10 @@ export interface PayloadLockedDocument {
|
||||||
relationTo: 'missions';
|
relationTo: 'missions';
|
||||||
value: number | Mission;
|
value: number | Mission;
|
||||||
} | null)
|
} | null)
|
||||||
|
| ({
|
||||||
|
relationTo: 'mission-attendances';
|
||||||
|
value: number | MissionAttendance;
|
||||||
|
} | null)
|
||||||
| ({
|
| ({
|
||||||
relationTo: 'campaigns';
|
relationTo: 'campaigns';
|
||||||
value: number | Campaign;
|
value: number | Campaign;
|
||||||
|
|
@ -2703,6 +2729,8 @@ export interface MissionsSelect<T extends boolean = true> {
|
||||||
codeName?: T;
|
codeName?: T;
|
||||||
summary?: T;
|
summary?: T;
|
||||||
operationType?: T;
|
operationType?: T;
|
||||||
|
discordMessageId?: T;
|
||||||
|
discordAttendanceHash?: T;
|
||||||
classification?:
|
classification?:
|
||||||
| T
|
| T
|
||||||
| {
|
| {
|
||||||
|
|
@ -2809,6 +2837,17 @@ export interface MissionsSelect<T extends boolean = true> {
|
||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: 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
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "campaigns_select".
|
* via the `definition` "campaigns_select".
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { Media } from "@/collections/Media";
|
||||||
import { Ranks } from "@/collections/users/Ranks";
|
import { Ranks } from "@/collections/users/Ranks";
|
||||||
import { Profiles } from "@/collections/users/Profiles";
|
import { Profiles } from "@/collections/users/Profiles";
|
||||||
import { Missions } from "@/collections/intelligence/Missions";
|
import { Missions } from "@/collections/intelligence/Missions";
|
||||||
|
import { MissionAttendances } from "@/collections/intelligence/MissionAttendances";
|
||||||
import { Maps } from "@/collections/world/Maps";
|
import { Maps } from "@/collections/world/Maps";
|
||||||
import { Campaigns } from "@/collections/intelligence/Campaigns";
|
import { Campaigns } from "@/collections/intelligence/Campaigns";
|
||||||
import { Qualifications } from "@/collections/users/Qualifications";
|
import { Qualifications } from "@/collections/users/Qualifications";
|
||||||
|
|
@ -100,6 +101,7 @@ export default buildConfig({
|
||||||
|
|
||||||
// Intel
|
// Intel
|
||||||
Missions,
|
Missions,
|
||||||
|
MissionAttendances,
|
||||||
Campaigns,
|
Campaigns,
|
||||||
Factions,
|
Factions,
|
||||||
Technologies,
|
Technologies,
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,9 @@ export const EventTypes = {
|
||||||
AccountProfileUpdate: "account:profile-update",
|
AccountProfileUpdate: "account:profile-update",
|
||||||
AccountDiscordRequest: "account:discord-request",
|
AccountDiscordRequest: "account:discord-request",
|
||||||
AccountPreferencesUpdate: "account:preferences-update",
|
AccountPreferencesUpdate: "account:preferences-update",
|
||||||
|
|
||||||
|
// Attendance
|
||||||
|
MissionAttendanceChange: "mission:attendance-change",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type EventType = (typeof EventTypes)[keyof typeof EventTypes];
|
export type EventType = (typeof EventTypes)[keyof typeof EventTypes];
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue