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 { 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">
|
||||
|
|
|
|||
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 },
|
||||
});
|
||||
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: [
|
||||
|
|
|
|||
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;
|
||||
'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".
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
Loading…
Reference in a new issue