1
0
Fork 0
polaris-task-force/src/collections/intelligence/Missions.ts
Z8MB1E e3c4eada09 feat(bot): add post-mission evaluation reminder DMs
Sends one-shot DMs once a mission is evaluable asking members to rate their leadership (and leaders to rate subordinates), gated by a new evaluationRemindersSentAt marker on Missions with its migration. Adds the /remind-evaluations staff command (manual trigger/sweep) and DISCORD_EVALUATION_POLL_MS config; updates bot and root docs.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-06 01:24:17 -04:00

798 lines
26 KiB
TypeScript

import type { CollectionConfig, Payload } from "payload";
import { requirePermission, hasPermission } from "@/utils/access-control/hasPermission";
type AssignmentRef = { id: number } | number;
async function isInAssignments(
payload: Payload,
userId: number,
assignmentIds: number[],
): Promise<boolean> {
if (assignmentIds.length === 0) return false;
const assignments = await payload.find({
collection: "assignments",
where: { id: { in: assignmentIds } },
limit: assignmentIds.length,
depth: 0,
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));
return (
(leaderId !== null && leaderId !== undefined && leaderId === userId) ||
memberIds.includes(userId)
);
});
}
function extractIds(refs: unknown[] | undefined): number[] {
return (refs ?? []).map((r) =>
typeof r === "object" && r !== null ? (r as { id: number }).id : (r as number),
);
}
export const Missions: CollectionConfig = {
slug: "missions",
admin: {
group: "Intelligence",
useAsTitle: "name",
},
hooks: {
beforeChange: [
// Loop-safe by construction: this mutates the incoming data of the very
// operation it runs in, so it never triggers a second write. It only
// stamps `ownershipAndStatus.completedAt` when a transition lands on
// "Completed" and no completion time exists yet — auto-complete passes
// its own sweep time explicitly (preserved by the existing-value check),
// and re-completing an already-stamped mission keeps the original stamp.
async ({ data, originalDoc }) => {
if (data?.ownershipAndStatus?.status !== "Completed") return data;
const existing =
data.ownershipAndStatus.completedAt ?? originalDoc?.ownershipAndStatus?.completedAt;
if (existing) return data;
return {
...data,
ownershipAndStatus: {
...data.ownershipAndStatus,
completedAt: new Date().toISOString(),
},
};
},
],
},
access: {
create: async ({ req }) => {
return await hasPermission(req.payload, req.user, "missions:create");
},
update: async ({ req }) => {
return await hasPermission(req.payload, req.user, "missions:update");
},
delete: async ({ req }) => {
return await hasPermission(req.payload, req.user, "missions:delete");
},
read: async ({ req, data }) => {
if (!req.user) return false;
if (await hasPermission(req.payload, req.user, "missions:read")) return true;
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 [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 status = data?.ownershipAndStatus?.status;
const isDraft = status === "Concept" || status === "Planning";
if (isDraft) {
return isAuthorOrZeus || isCommand || isIntel;
}
const visibility = data?.ownershipAndStatus?.visibility;
switch (visibility) {
case "unit":
return true;
case "leadership":
return isCommand || isIntel || isAuthorOrZeus;
case "intel":
return isIntel || isAuthorOrZeus;
case "private":
return isAuthorOrZeus;
default:
return true;
}
},
},
fields: [
{
name: "name",
type: "text",
required: true,
},
{
name: "codeName",
type: "text",
admin: {
description:
'A "code name" provides a unique way to reference this mission beyond just the mission\'s name. For example, a mission called "Operation Dragon Break" could be codenamed "OP:DRGBRK".',
},
required: true,
unique: true,
},
{
name: "summary",
type: "textarea",
admin: {
description:
"A summary of the mission. This is not the same as a briefing; instead, it describes the mission's theme and concept to the unit as a whole.",
},
required: true,
},
{
name: "operationType",
type: "select",
options: [
{
label: "Main Operation",
value: "main",
},
{
label: "Side Operation",
value: "side",
},
{
label: "External Operation",
value: "external",
},
],
filterOptions: ({ req }) => {
return [
{
label: "Side Operation",
value: "side",
},
{
label: "External Operation",
value: "external",
},
{
label: "Main Operation",
value: "main",
},
];
},
defaultValue: ({ req }) => "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,
},
},
{
name: "discordAttendanceSentAt",
type: "date",
admin: {
description: "When the attendance roll-call was first posted to Discord (managed by the bot).",
hidden: true,
},
},
{
name: "evaluationRemindersSentAt",
type: "date",
admin: {
description:
"When evaluation reminder DMs were sent for this mission (managed by the bot, one-shot).",
hidden: true,
},
},
{
name: "coverImage",
type: "upload",
relationTo: "media",
admin: {
description:
"Background image for the mission page hero header. Renders translucent with a top-to-bottom fade to black. Recommended aspect ratio: 16:9 or wider.",
},
},
{
type: "tabs",
tabs: [
{
label: "Mission Configuration",
description:
"Essential mission configuration, like the type of mission, what map it is played on, who authored it, etc.",
fields: [
{
name: "classification",
type: "group",
fields: [
{
name: "map",
type: "relationship",
relationTo: "maps",
required: true,
},
{
name: "missionType",
type: "select",
options: ["Zeus", "PvE", "PvP", "PvPvE", "Training", "Special"],
defaultValue: "Zeus",
required: true,
},
{
name: "campaign",
type: "relationship",
relationTo: "campaigns",
required: true,
},
{
name: "customScripts",
type: "checkbox",
label: "Contains Custom Scripts",
admin: {
description: "This mission contains custom SQF scripts.",
},
},
{
name: "startDateTime",
type: "date",
admin: {
date: {
pickerAppearance: "dayAndTime",
},
},
defaultValue: () => {
const today = new Date();
const day = today.getDay();
const diff = (6 - day) % 7;
const nextSaturday = new Date(today);
nextSaturday.setDate(today.getDate() + diff + 7);
nextSaturday.setHours(20, 0, 0, 0);
return nextSaturday;
},
required: true,
},
{
name: "estimatedDuration",
type: "number",
label: "Estimated Duration (in minutes)",
defaultValue: 60 * 4, // Usually about 4 hours
required: true,
},
],
},
{
name: "ownershipAndStatus",
type: "group",
label: "Ownership & Status",
fields: [
{
name: "authors",
type: "relationship",
relationTo: "users",
hasMany: true,
required: true,
},
{
name: "zeus",
type: "relationship",
relationTo: "users",
hasMany: true,
required: false,
},
{
name: "status",
type: "select",
options: [
"Concept",
"Planning",
"Ready",
"Scheduled",
"Active",
"Completed",
"Cancelled",
],
defaultValue: "Concept",
required: true,
},
{
name: "visibility",
type: "select",
options: [
{
label: "Entire Unit",
value: "unit",
},
{
label: "Leadership & Intel Only",
value: "leadership",
},
{
label: "Intel Only",
value: "intel",
},
{
label: "Private",
value: "private",
},
],
defaultValue: "leadership",
required: true,
},
{
name: "completedAt",
type: "date",
admin: {
description:
"When the mission transitioned to Completed. Set automatically by the lifecycle (auto-complete tick or manual status change) — managed here, not editable in the form.",
disabled: true,
},
},
],
},
{
name: "missionRoles",
type: "group",
fields: [
{
name: "maxPlayers",
type: "number",
defaultValue: 32,
max: 32,
required: true,
},
{
name: "providedRoles",
type: "array",
label: "Roles Provided",
fields: [
{
name: "roleName",
type: "text",
required: true,
},
{
name: "missionCritical",
type: "checkbox",
admin: {
description:
"If true, this mission cannot be executed without someone fulfilling this role.",
},
defaultValue: true,
},
{
name: "requiredMos",
type: "relationship",
hasMany: true,
relationTo: "qualifications",
label: "Required MOS/Qualifications",
admin: {
description:
"This role can only be fulfilled by someone possessing these qualifications.",
},
},
{
name: "minimumRank",
type: "relationship",
relationTo: "ranks",
admin: {
description:
"This role can only be fulfilled by someone of this rank or higher.",
},
},
],
},
{
name: "notes",
type: "textarea",
},
],
},
],
},
{
label: "Narrative",
description:
"Narrative details, like the mission briefing, objectives, civilian presence, RoEs, etc.",
fields: [
{
name: "briefing",
type: "array",
maxRows: 8,
labels: {
plural: "Briefing Entries",
singular: "Briefing Entry",
},
fields: [
{
name: "sectionName",
type: "text",
required: true,
},
{
name: "details",
type: "richText",
required: true,
},
],
defaultValue: [
{
sectionName: "Situation",
},
{
sectionName: "Mission",
},
{
sectionName: "Execution",
},
{
sectionName: "Signals",
},
],
},
{
name: "missionObjectives",
type: "array",
fields: [
{
name: "title",
type: "text",
required: true,
},
{
name: "description",
type: "textarea",
},
{
name: "missionCritical",
admin: {
description:
"If true, this mission will be considered a failure if this objective is failed.",
},
type: "checkbox",
defaultValue: true,
},
{
name: "successOrFailConditions",
type: "textarea",
label: "Success / Fail Conditions",
},
{
name: "redacted",
type: "checkbox",
label: "Redacted",
admin: {
description:
"If true, this objective will be hidden from players until revealed.",
},
defaultValue: false,
},
],
},
{
name: "civilianPresence",
type: "select",
options: ["None", "Low", "Medium", "High"],
defaultValue: "None",
},
{
name: "factionsInvolved",
type: "array",
fields: [
{
name: "faction",
type: "relationship",
relationTo: "factions",
required: true,
},
{
name: "threatLevel",
type: "select",
options: [
{
label: "Allied",
value: "ally",
},
{
label: "Friendly",
value: "friendly",
},
{
label: "Neutral",
value: "neutral",
},
{
label: "Hostile",
value: "hostile",
},
],
defaultValue: "hostile",
required: true,
},
],
},
],
},
{
label: "Dedicated Server",
description: "Server-specific details, like the IP, port, mission file, mod list, etc.",
fields: [
{
name: "gameDetails",
type: "group",
fields: [
{
name: "serverDetails",
type: "group",
fields: [
{
name: "serverIp",
type: "text",
label: "Server IP or Domain Name",
defaultValue: "arma.onyxsimple.com",
required: true,
},
{
name: "serverPort",
type: "number",
defaultValue: 2302,
required: true,
},
{
name: "serverPassword",
type: "text",
hidden: true,
defaultValue: "ptf313",
access: {
read: async ({ req }) => {
return await hasPermission(req.payload, req.user, "missions:read-sensitive");
},
},
},
],
},
{
name: "gameSetup",
type: "group",
fields: [
{
name: "missionFile",
type: "upload",
relationTo: "mission-files",
},
{
name: "requiredMods",
type: "upload",
admin: {
description:
"This field is not required only because you may be running a vanilla gamemode. If you have any mods at all in your mission, you MUST include a mod list.",
},
relationTo: "mod-lists",
},
],
},
],
},
],
},
{
label: "Advanced Details",
description:
"Advanced mission details and configuration. Most of these fields are optional, but for those who want extra-detailed missions, this section is for you.",
fields: [
{
name: "logisticsAndSupport",
type: "group",
label: "Logistics & Support",
admin: {
description:
"Mission requirements and conditions surrounding the deployment and use of logistics and support assets during the mission's runtime.",
},
fields: [
{
type: "row",
fields: [
{
name: "insertion",
label: "Allowed/Provided Insertion Method(s)",
type: "select",
options: [
{
label: "Helicopter",
value: "helicopter",
},
{
label: "Aircraft",
value: "aircraft",
},
{
label: "HALO / Paradrop",
value: "paradrop",
},
{
label: "Boat",
value: "boat",
},
{
label: "Car",
value: "car",
},
{
label: "On Foot",
value: "onFoot",
},
],
hasMany: true,
},
{
name: "extraction",
label: "Allowed/Provided Extraction Method(s)",
type: "select",
options: [
{
label: "Helicopter",
value: "helicopter",
},
{
label: "Aircraft",
value: "aircraft",
},
{
label: "HALO / Paradrop",
value: "paradrop",
},
{
label: "Boat",
value: "boat",
},
{
label: "Car",
value: "car",
},
{
label: "On Foot",
value: "onFoot",
},
],
hasMany: true,
},
],
},
{
name: "assetsAvailable",
type: "array",
labels: {
plural: "Assets",
singular: "Asset",
},
fields: [
{
name: "assetType",
type: "text",
admin: {
description:
'The name or type of the asset provided (e.g. "Artillery", "M1A1 Tank", "Fuel Truck", "Close Air Support", etc.)',
},
required: true,
},
{
name: "assetCount",
type: "text",
admin: {
description:
"If applicable, the number of this asset that will be available at the start of the mission.",
},
},
{
name: "doesRespawn",
type: "checkbox",
label: "Is Respawnable?",
admin: {
description:
"Whether or not this asset respawns during the mission, either automatically or manually via Zeus.",
},
},
{
name: "assetCallsign",
type: "text",
admin: {
description:
"If applicable, the callsign of this asset (such as an aicraft providing close air support.)",
},
},
],
},
],
},
{
name: "commsAndDocs",
type: "group",
label: "Comms & SOPs",
admin: {
description:
"Communications and Standard Operating Procedures specific to this mission.",
},
fields: [
{
name: "radioPlan",
type: "richText",
admin: {
description:
"A detailed description of how radios will be used during this operation, including signals, contingency plans, and other relevant details.",
},
},
{
name: "frequencies",
type: "array",
admin: {
description:
"A list and description of each of the radio frequencies used for this mission.",
},
fields: [
{
type: "row",
fields: [
{
name: "frequency",
type: "number",
required: true,
},
{
name: "channel",
type: "number",
admin: {
description:
"Must be provided if the frequency used requires a separate radio channel.",
},
},
],
},
{
name: "netName",
type: "text",
admin: {
description:
'The name assigned to this frequency (+ channel.) Examples of names include "COMNET", "PLTNET", "CASNET", etc.',
},
required: true,
},
{
name: "freqUsage",
type: "textarea",
admin: {
description:
"An explanation of the expected usage and purpose of this radio net.",
},
required: true,
},
],
},
],
},
],
},
],
},
],
};