1
0
Fork 0

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>
This commit is contained in:
Jason Fraley 2026-09-06 01:24:17 -04:00
parent 0f7d3a57e3
commit e3c4eada09
13 changed files with 23013 additions and 9 deletions

View file

@ -274,8 +274,9 @@ gameTick → POST `/api/game-tick/notify` (guarded by `x-game-tick-secret` heade
A Discord bot living in `src/bot/`, run as a standalone long-running process via `bun run bot` — it imports `@payload-config` directly (same pattern as `src/scripts/` and `src/tools/seed/`) and shares the Postgres DB with the web app. **Under active development and testing.** **Full product requirements live in `docs/bot/context.md`; the approved implementation design is in `docs/bot/design.md` — read both before touching bot code.**
- **Env / run**: requires `DISCORD_TOKEN` and `DISCORD_GUILD_ID` (fail-fast on missing required vars in `src/bot/config.ts`); optional `DISCORD_OPS_CHANNEL_ID`, `DISCORD_ANNOUNCE_CHANNEL_ID`, `DISCORD_STAFF_ROLE_IDS`, `DISCORD_ATTENDANCE_POLL_MS` (default 60s), `DISCORD_NOTIFICATION_POLL_MS` (default 20s), plus existing `APP_URL`. Logs through `payload.logger`.
- **Structure**: `commands/` — `ping`, `signup`, `link`, `announce`; `events/interactionCreate.ts` — routes `ptf-att:` RSVP buttons; `services/` — `missionEmbeds` (attendance embed lifecycle + reconcile loop) and `notificationBridge` (poll → Discord DMs); `lib/` — `roles.ts` (`isStaff`), `resolve.ts` (discordId ↔ Payload user lookups). Command registration scope: `signup`/`link`/`ping` are **global** (DM-usable — guild-scoped commands never appear in DMs), `announce` is guild-only.
- **Env / run**: requires `DISCORD_TOKEN` and `DISCORD_GUILD_ID` (fail-fast on missing required vars in `src/bot/config.ts`); optional `DISCORD_OPS_CHANNEL_ID`, `DISCORD_ANNOUNCE_CHANNEL_ID`, `DISCORD_STAFF_ROLE_IDS`, `DISCORD_ATTENDANCE_POLL_MS` (default 60s), `DISCORD_NOTIFICATION_POLL_MS` (default 20s), `DISCORD_EVALUATION_POLL_MS` (default 60s), plus existing `APP_URL`. Logs through `payload.logger`.
- **Structure**: `commands/` — `ping`, `signup`, `link`, `announce`, `remindEvaluations`; `events/interactionCreate.ts` — routes `ptf-att:` RSVP buttons; `services/` — `missionEmbeds` (attendance embed lifecycle + reconcile loop), `notificationBridge` (poll → Discord DMs), and `evaluationReminders` (post-mission evaluation reminder DMs); `lib/` — `roles.ts` (`isStaff`), `resolve.ts` (discordId ↔ Payload user lookups). Command registration scope: `signup`/`link`/`ping` are **global** (DM-usable — guild-scoped commands never appear in DMs), `announce`/`remind-evaluations` are guild-only.
- **Evaluation reminders**: once a mission is evaluable (`Completed`, or `Scheduled`/`Active` past its start — same rule as `isMissionEvaluable`), the bot sends a one-shot DM (per user with a linked `discordId`) asking them to rate their leadership, plus a "rate your subordinates" section for leaders whose members RSVP'd yes. Links go to each ratee's profile page where the rating dialog lives. Recipient computation: `computeEvaluationReminderPlan` in `src/lib/evaluations/reminders.ts`; state marker: `evaluationRemindersSentAt` on Missions (one-shot, same pattern as `discordAttendanceSentAt`; withheld if the 40-DM/tick cap is hit — retries next tick). These DMs deliberately ignore `preferences.discord.enabled` (defaults false, not yet exposed in the web UI — honoring it would DM nobody). Staff can trigger manually via `/remind-evaluations` (optional `mission` option accepts an ID, name, or code name and re-sends even if the marker is set; omitted, it sweeps all pending missions and reports `{processed, sent}`).
- **Sign-up / linking (feature 1)**: `/signup` is **DM-only** (the temp password flows through the DM). Creates the Payload user with `username` = `discordUsername` = the caller's Discord username, plus `discordId`, `displayName`, `steamId`, and a random temp password. The ephemeral reply carries the password as the guaranteed delivery path; `interaction.user.send()` is a best-effort persistent copy, so a blocked DM never orphans the account. `/link` works in servers **and** DMs (credential-free, ephemeral reply only) — matches `discordUsername` → sets `discordId`.
- **DM gotcha**: a user with "Allow direct messages from server members" off in Discord privacy settings can neither receive the bot's DMs nor open a DM with the bot. The `/signup` rejection message explains how to enable it.
- **Attendance (feature 2)**: `mission-attendances` collection + `src/lib/attendance/` (single write path, emits `mission:attendance-change`). The bot posts RSVP embeds (Yes/Tentative/No) for future, `Ready`/`Scheduled`, `visibility: "unit"` missions into the ops channel; stores `discordMessageId` + `discordAttendanceHash` on the mission; reconciles hash changes every poll tick (web ↔ Discord two-way sync, loop-safe). Web UI: `MissionAttendance` component on the mission detail page. `bun run payload generate-mission` clones the next weekly main mission.

View file

@ -71,6 +71,7 @@ src/bot/
signup.ts # /signup (DM)
link.ts # /link (works in servers and DMs — credential-free, ephemeral reply only)
announce.ts # /announce (staff only)
remindEvaluations.ts # /remind-evaluations (staff only, manual evaluation-reminder trigger)
events/
interactionCreate.ts # routes buttons + commands; the RSVP button handler lives here
ready.ts # startup: sync guild commands, kick off poll loops
@ -119,6 +120,14 @@ Dependency: `discord.js` ^14 (add to root `package.json`). Script: `"bot": "bun
- **Rate limits**: DM sends are ~5/5s per channel. Sequential sends with a small queue; cap ~5 DMs per tick. Backpressure by taking a longer poll interval if the queue stays full.
- **`/announce`** (staff only, `isStaff(member)`): options `message` (+ optional `channel`, default `DISCORD_ANNOUNCE_CHANNEL_ID`). Posts a formatted announcement embed. App → Discord announcements are a later iteration, not v1.
### D. Evaluation reminders (post-mission)
- **Trigger**: a mission becomes evaluable (status `Completed`, or `Scheduled`/`Active` whose `classification.startDateTime` has fully passed — same rule as `isMissionEvaluable` in `src/lib/evaluations`). Poll loop every `DISCORD_EVALUATION_POLL_MS` (default 60s) queries evaluable missions with no `evaluationRemindersSentAt` marker (one-shot, same pattern as `discordAttendanceSentAt`).
- **Recipients** (`computeEvaluationReminderPlan` in `src/lib/evaluations/reminders.ts`): participants = users with a "yes" RSVP. Each participant is reminded to rate their direct leader(s); each leader of an assignment containing a participant is reminded to rate that participant (subordinate kind needs no RSVP). Self-pairs skipped; a user who is both gets one merged DM.
- **DM**: embed with "Rate your leadership" / "Rate your subordinates" sections, each line linking to the ratee's profile page (`APP_URL + /profile/<username>`) where the rating dialog lives. Best-effort: DM failures are logged and skipped, marker is set after the pass (cap 40 DMs/tick, 300ms between sends; overflow retries next tick before the marker is set).
- **Deliberate exception**: these DMs ignore `preferences.discord.enabled` (defaults false, not yet exposed in the web UI — honoring it would DM nobody). Dedicated unit-admin flow, not notification-stream forwarding.
- **Manual trigger**: `/remind-evaluations` (staff only, guild-only). Optional `mission` option (mission ID, name, or code name — ambiguous matches list candidates) sends reminders for that one mission even if the marker is already set (as long as it's evaluable); omitted, it runs the reconcile sweep over all pending missions immediately and reports `{processed, sent}`. Replies are ephemeral; the interaction is deferred because DM sending takes minutes at scale.
### Authz (`src/bot/lib/roles.ts`)
`isStaff(member)`: caller has any `DISCORD_STAFF_ROLE_IDS`, **or** their linked Payload user has roles `admin`/`developer`. RSVP/signup/link are open to all members. The bot performs Payload writes with `overrideAccess` and relies on these checks + its own validations.
@ -134,6 +143,7 @@ Dependency: `discord.js` ^14 (add to root `package.json`). Script: `"bot": "bun
| `DISCORD_STAFF_ROLE_IDS` | no | comma-separated role IDs granting staff |
| `DISCORD_ATTENDANCE_POLL_MS` | no | default 60000 |
| `DISCORD_NOTIFICATION_POLL_MS` | no | default 20000 |
| `DISCORD_EVALUATION_POLL_MS` | no | default 60000 |
`APP_URL` already exists — used for login links in DMs.

View file

@ -19,6 +19,7 @@ bot/
signup.ts # /signup (DM-only, creates Payload user with temp password)
link.ts # /link (global, links discordId to existing user)
announce.ts # /announce (guild-only, staff only)
remindEvaluations.ts # /remind-evaluations (guild-only, staff only, manual evaluation-reminder trigger)
events/
interactionCreate.ts # Routes ptf-att: RSVP button interactions
@ -37,7 +38,7 @@ bot/
## Command registration scope
- **Global** (DM-usable): `signup`, `link`, `ping`
- **Guild-only**: `announce` (guild-scoped commands never appear in DMs)
- **Guild-only**: `announce`, `remind-evaluations` (guild-scoped commands never appear in DMs)
## Feature flow: signup/link

View file

@ -4,6 +4,7 @@ import { pingCommand } from "./ping";
import { signupCommand } from "./signup";
import { linkCommand } from "./link";
import { announceCommand } from "./announce";
import { remindEvaluationsCommand } from "./remindEvaluations";
export interface BotCommand {
builder: SlashCommandBuilder;
@ -16,4 +17,10 @@ export interface BotCommand {
scope?: "global" | "guild";
}
export const commands: BotCommand[] = [pingCommand, signupCommand, linkCommand, announceCommand];
export const commands: BotCommand[] = [
pingCommand,
signupCommand,
linkCommand,
announceCommand,
remindEvaluationsCommand,
];

View file

@ -0,0 +1,116 @@
import { MessageFlags, SlashCommandBuilder, type Client } from "discord.js";
import type { Payload } from "payload";
import type { BotCommand } from "./index";
import { isStaff } from "@/bot/lib/roles";
import { processMission, reconcile } from "@/bot/services/evaluationReminders";
import { isMissionEvaluable } from "@/lib/evaluations";
import type { Mission } from "@/payload-types";
const missionLabel = (mission: Mission): string =>
mission.codeName ? `${mission.name} (${mission.codeName})` : mission.name;
const resolveTargetMissions = async (
payload: Payload,
input: string,
): Promise<{ mission?: Mission; error?: string }> => {
const trimmed = input.trim();
try {
const mission = await payload.findByID({
collection: "missions",
id: trimmed,
overrideAccess: true,
depth: 0,
});
return { mission: mission as unknown as Mission };
} catch {
// Not an ID — fall through to a name / code-name search.
}
const res = await payload.find({
collection: "missions",
overrideAccess: true,
depth: 0,
limit: 6,
where: {
or: [{ name: { like: trimmed } }, { codeName: { like: trimmed } }],
},
});
const docs = res.docs as unknown as Mission[];
if (docs.length === 0) {
return { error: `No mission matches "${trimmed}" (searched by ID, name, and code name).` };
}
if (docs.length > 1) {
const list = docs.map((m) => `• ${missionLabel(m)} — #${m.id}`).join("\n");
return { error: `Multiple missions match "${trimmed}". Be more specific:\n${list}` };
}
return { mission: docs[0] };
};
export const remindEvaluationsCommand: BotCommand = {
builder: new SlashCommandBuilder()
.setName("remind-evaluations")
.setDescription("Send leadership evaluation reminder DMs (staff only)")
.addStringOption((option) =>
option
.setName("mission")
.setDescription("Mission ID, name, or code name. Omit to sweep all pending missions."),
) as SlashCommandBuilder,
execute: async (interaction, payload) => {
if (!interaction.inGuild() || !interaction.guild) {
await interaction.reply({
content: "This command only works in a server.",
flags: MessageFlags.Ephemeral,
});
return;
}
const member = await interaction.guild.members.fetch(interaction.user.id);
if (!(await isStaff(member, payload))) {
await interaction.reply({
content: "You don't have permission to trigger evaluation reminders.",
flags: MessageFlags.Ephemeral,
});
return;
}
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const client = interaction.client as Client;
const missionInput = interaction.options.getString("mission")?.trim();
try {
if (missionInput) {
const { mission, error } = await resolveTargetMissions(payload, missionInput);
if (error || !mission) {
await interaction.editReply(error ?? `No mission matches "${missionInput}".`);
return;
}
if (
!isMissionEvaluable(
mission.ownershipAndStatus?.status ?? "",
mission.classification?.startDateTime ?? null,
)
) {
await interaction.editReply(
`**${missionLabel(mission)}** isn't evaluable yet — missions must be completed or past their scheduled start before reminders go out.`,
);
return;
}
const sent = await processMission(client, payload, mission);
await interaction.editReply(
`Done — ${sent} evaluation reminder DM(s) sent for **${missionLabel(mission)}**.`,
);
return;
}
const { processed, sent } = await reconcile(client, payload);
await interaction.editReply(
`Sweep complete — ${processed} pending mission(s) processed, ${sent} reminder DM(s) sent.`,
);
} catch (error) {
await interaction.editReply(
`Reminder trigger failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
},
};

View file

@ -7,6 +7,7 @@ export interface BotConfig {
staffRoleIds: string[];
attendancePollMs: number;
notificationPollMs: number;
evaluationPollMs: number;
}
const required = (name: string): string => {
@ -46,4 +47,5 @@ export const botConfig: BotConfig = {
.filter((id) => id.length > 0),
attendancePollMs: pollMs("DISCORD_ATTENDANCE_POLL_MS", 60_000),
notificationPollMs: pollMs("DISCORD_NOTIFICATION_POLL_MS", 20_000),
evaluationPollMs: pollMs("DISCORD_EVALUATION_POLL_MS", 60_000),
};

View file

@ -0,0 +1,215 @@
import { type Client, EmbedBuilder } from "discord.js";
import type { Payload } from "payload";
import type { Mission } from "@/payload-types";
import { botConfig } from "@/bot/config";
import { computeEvaluationReminderPlan, type ReminderLink } from "@/lib/evaluations/reminders";
import { isMissionEvaluable } from "@/lib/evaluations";
/**
* Evaluation reminder service: after a mission becomes evaluable (completed, or
* its scheduled start has passed), every participant with a linked Discord
* account gets a one-shot DM asking them to rate their leadership — and every
* leader additionally gets a section asking them to rate the subordinates who
* participated. Each DM links to the relevant profile pages, where the rating
* dialog lives.
*
* The DM deliberately ignores `preferences.discord.enabled` (the notification
* bridge opt-in): that toggle defaults to false and is not yet exposed in the
* web UI, so honoring it here would silently DM nobody. This is a dedicated
* unit-admin flow, not a forwarding of the generic notification stream.
*/
const EMBED_COLOR = 0xf59e0b;
const MISSIONS_PER_TICK = 3;
const MAX_DMS_PER_TICK = 40;
const SEND_DELAY_MS = 300;
let reconcileRunning = false;
const errorMessage = (error: unknown): string =>
error instanceof Error ? error.message : "Unknown error";
const missionTitle = (mission: Mission): string =>
mission.codeName ? `${mission.name} (${mission.codeName})` : mission.name;
const getReminderMissions = async (payload: Payload): Promise<Mission[]> => {
const now = new Date().toISOString();
const res = await payload.find({
collection: "missions",
overrideAccess: true,
depth: 0,
sort: "-classification.startDateTime",
limit: MISSIONS_PER_TICK,
where: {
and: [
{ evaluationRemindersSentAt: { exists: false } },
{
or: [
{ "ownershipAndStatus.status": { equals: "Completed" } },
{
and: [
{ "ownershipAndStatus.status": { in: ["Scheduled", "Active"] } },
{ "classification.startDateTime": { less_than: now } },
],
},
],
},
],
},
});
return res.docs as unknown as Mission[];
};
const formatLinks = (links: ReminderLink[], appUrl: string): string => {
const lines = links.map(
(link) => `• [${link.displayName}](${appUrl}${link.profilePath})`,
);
// Discord embed field values cap at 1024 chars.
return (lines.join("\n").slice(0, 1024) || "—");
};
const renderReminderEmbed = (
mission: Mission,
rateLeaders: ReminderLink[],
rateSubordinates: ReminderLink[],
appUrl: string,
): EmbedBuilder => {
const embed = new EmbedBuilder()
.setTitle(`Leadership feedback requested — ${missionTitle(mission)}`)
.setColor(EMBED_COLOR)
.setFooter({
text: "Ratings are private and anonymous in aggregate. A leader's results become visible once 3+ members have rated them.",
});
if (rateLeaders.length > 0) {
embed.addFields({
name: "⭐ Rate your leadership",
value: formatLinks(rateLeaders, appUrl),
});
}
if (rateSubordinates.length > 0) {
embed.addFields({
name: "🪖 Rate your subordinates' performance",
value: formatLinks(rateSubordinates, appUrl),
});
}
return embed;
};
const sendReminderDms = async (
client: Client,
payload: Payload,
mission: Mission,
): Promise<{ sent: number; capped: boolean }> => {
const plan = await computeEvaluationReminderPlan(payload, mission.id);
const targets = plan.recipients.filter((r) => r.discordId);
const skipped = plan.recipients.length - targets.length;
if (skipped > 0) {
payload.logger.warn(
`[Bot] Evaluation reminders for mission #${mission.id}: skipping ${skipped} user(s) without a linked Discord account.`,
);
}
let sent = 0;
for (const target of targets) {
if (sent >= MAX_DMS_PER_TICK) {
payload.logger.warn(
`[Bot] Evaluation reminder DM cap (${MAX_DMS_PER_TICK}) reached for mission #${mission.id}; remaining users will be sent next tick.`,
);
return { sent, capped: true };
}
try {
const user = await client.users.fetch(target.discordId as string);
await user.send({
embeds: [
renderReminderEmbed(mission, target.rateLeaders, target.rateSubordinates, botConfig.appUrl),
],
});
sent += 1;
} catch (error) {
payload.logger.warn(
`[Bot] Evaluation reminder DM failed for user #${target.userId} (mission #${mission.id}): ${errorMessage(error)}`,
);
}
await new Promise((resolve) => setTimeout(resolve, SEND_DELAY_MS));
}
return { sent, capped: false };
};
/**
* Sends reminder DMs for one mission, then stamps the one-shot marker —
* unless the per-tick DM cap was hit, in which case the marker is left unset
* so the remaining users are sent on a later pass. Exported for the
* /remind-evaluations command's manual trigger.
*
* @returns the number of DMs actually sent.
*/
export const processMission = async (
client: Client,
payload: Payload,
mission: Mission,
): Promise<number> => {
const { sent, capped } = await sendReminderDms(client, payload, mission);
if (capped) {
payload.logger.warn(
`[Bot] Evaluation reminders for mission #${mission.id}: DM cap hit — marker not set, will retry next tick.`,
);
return sent;
}
await payload.update({
collection: "missions",
id: mission.id,
data: { evaluationRemindersSentAt: new Date().toISOString() },
overrideAccess: true,
});
payload.logger.info(
`[Bot] Sent ${sent} evaluation reminder DM(s) for mission #${mission.id} (${mission.name}).`,
);
return sent;
};
export interface ReconcileSummary {
processed: number;
sent: number;
}
export const reconcile = async (
client: Client,
payload: Payload,
): Promise<ReconcileSummary> => {
if (reconcileRunning) return { processed: 0, sent: 0 };
reconcileRunning = true;
let processed = 0;
let sent = 0;
try {
const missions = (await getReminderMissions(payload)).filter((mission) =>
isMissionEvaluable(
mission.ownershipAndStatus?.status ?? "",
mission.classification?.startDateTime ?? null,
),
);
for (const mission of missions) {
try {
sent += await processMission(client, payload, mission);
processed += 1;
} catch (error) {
payload.logger.error(
`[Bot] Evaluation reminder reconcile failed for mission #${mission.id}: ${errorMessage(error)}`,
);
}
}
} catch (error) {
payload.logger.error(`[Bot] Evaluation reminder reconcile failed: ${errorMessage(error)}`);
} finally {
reconcileRunning = false;
}
return { processed, sent };
};
export const start = (client: Client, payload: Payload): void => {
payload.logger.info("[Bot] Evaluation reminder service started");
void reconcile(client, payload);
setInterval(() => {
void reconcile(client, payload);
}, botConfig.evaluationPollMs);
};

View file

@ -3,6 +3,7 @@ import type { Payload } from "payload";
import { start as startMissionEmbeds } from "./missionEmbeds";
import { start as startNotificationBridge } from "./notificationBridge";
import { start as startTransferRequests } from "./transferRequests";
import { start as startEvaluationReminders } from "./evaluationReminders";
export type ServiceStarter = (client: Client, payload: Payload) => Promise<void> | void;
@ -10,4 +11,5 @@ export const services: ServiceStarter[] = [
startMissionEmbeds,
startNotificationBridge,
startTransferRequests,
startEvaluationReminders,
];

View file

@ -197,6 +197,15 @@ export const Missions: CollectionConfig = {
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",

View file

@ -0,0 +1,175 @@
import type { Payload } from "payload";
/**
* Recipient computation for the post-mission evaluation reminder DMs (Discord bot).
*
* Rules (aligned with `evaluateEligibility` in ./index.ts):
* - Participants = users with a "yes" RSVP for the mission (the documented
* participation proxy — same as the profile-page rating dialog).
* - Every participant is reminded to rate their direct leader(s) — eligible as
* `leader-direct` (and, for any other leader, `leader-indirect`).
* - Every leader of an assignment containing a participant is reminded to rate
* that participant — eligible as `subordinate` (no RSVP required for leaders).
* - Self-pairs are skipped; a user who is both a participant and a leader gets a
* single merged DM.
*
* This module is payload-only (no Discord imports) so the recipient plan is
* testable independently of the bot service.
*/
type PayloadType = Payload;
export interface ReminderLink {
userId: number;
username: string;
displayName: string;
/** App-relative link to the ratee's profile page where the rating dialog lives. */
profilePath: string;
}
export interface ReminderRecipient {
userId: number;
discordId: string | null;
/** Leaders this participant should rate (may be empty). */
rateLeaders: ReminderLink[];
/** Subordinates this leader should rate (may be empty). */
rateSubordinates: ReminderLink[];
}
export interface EvaluationReminderPlan {
missionId: number;
/** Only users with at least one actionable rating are included. */
recipients: ReminderRecipient[];
}
function extractIds(value: unknown): number[] {
if (!Array.isArray(value)) return [];
const ids: number[] = [];
for (const v of value) {
if (typeof v === "number") ids.push(v);
else if (v && typeof v === "object" && typeof (v as { id?: unknown }).id === "number") {
ids.push((v as { id: number }).id);
}
}
return ids;
}
function extractId(value: unknown): number | null {
if (typeof value === "number") return value;
if (value && typeof value === "object" && typeof (value as { id?: unknown }).id === "number") {
return (value as { id: number }).id;
}
return null;
}
function toLink(user: { id: number; username?: string | null; displayName?: string | null }): ReminderLink {
const username = user.username ?? `user-${user.id}`;
return {
userId: user.id,
username,
displayName: user.displayName || username,
profilePath: `/profile/${username}`,
};
}
/**
* Computes who should be reminded to submit evaluations for a mission.
* `missionId` must already be evaluable (see `isMissionEvaluable`) — this
* function does not re-check eligibility.
*/
export async function computeEvaluationReminderPlan(
payload: PayloadType,
missionId: number,
): Promise<EvaluationReminderPlan> {
// 1. Participants: yes-RSVP users for this mission.
const attendance = await payload.find({
collection: "mission-attendances",
where: {
and: [{ mission: { equals: missionId } }, { response: { equals: "yes" } }],
},
limit: 1000,
depth: 0,
overrideAccess: true,
});
const participantIds = [
...new Set(attendance.docs.map((doc) => extractId(doc.user)).filter((id): id is number => id !== null)),
];
if (participantIds.length === 0) {
return { missionId, recipients: [] };
}
// 2. Command graph from assignments: leader -> members, member -> leaders.
const assignments = await payload.find({
collection: "assignments",
where: { leader: { not_equals: null } },
limit: 1000,
depth: 0,
overrideAccess: true,
});
const leadersOf = new Map<number, Set<number>>();
const membersLedBy = new Map<number, Set<number>>();
for (const a of assignments.docs) {
const leaderId = extractId(a.leader);
if (leaderId === null) continue;
for (const memberId of extractIds(a.members)) {
if (memberId === leaderId) continue; // a leader is not their own leader
if (!leadersOf.has(memberId)) leadersOf.set(memberId, new Set());
leadersOf.get(memberId)!.add(leaderId);
if (!membersLedBy.has(leaderId)) membersLedBy.set(leaderId, new Set());
membersLedBy.get(leaderId)!.add(memberId);
}
}
// 3. Merge both roles per user: participant -> rate leaders; leader of a
// participant -> rate that participant.
const plan = new Map<number, { rateLeaders: Map<number, number>; rateSubordinates: Map<number, number> }>();
const ensure = (userId: number) => {
let entry = plan.get(userId);
if (!entry) {
entry = { rateLeaders: new Map(), rateSubordinates: new Map() };
plan.set(userId, entry);
}
return entry;
};
for (const participantId of participantIds) {
ensure(participantId); // even with no direct leader they may still be rated by theirs
for (const leaderId of leadersOf.get(participantId) ?? []) {
ensure(participantId).rateLeaders.set(leaderId, participantId);
ensure(leaderId).rateSubordinates.set(participantId, leaderId);
}
}
// 4. Resolve user docs (discordId / username / displayName) for everyone in the plan.
const userIds = [...plan.keys()];
const users = await payload.find({
collection: "users",
where: { id: { in: userIds } },
limit: 1000,
depth: 0,
overrideAccess: true,
});
const userById = new Map(users.docs.map((u) => [u.id, u]));
const recipients: ReminderRecipient[] = [];
for (const [userId, entry] of plan) {
const rateLeaders = [...entry.rateLeaders.keys()]
.map((id) => userById.get(id))
.filter((u): u is NonNullable<typeof u> => Boolean(u))
.map(toLink);
const rateSubordinates = [...entry.rateSubordinates.keys()]
.map((id) => userById.get(id))
.filter((u): u is NonNullable<typeof u> => Boolean(u))
.map(toLink);
if (rateLeaders.length === 0 && rateSubordinates.length === 0) continue;
const user = userById.get(userId);
recipients.push({
userId,
discordId: user?.discordId ?? null,
rateLeaders,
rateSubordinates,
});
}
return { missionId, recipients };
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,11 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "missions" ADD COLUMN "evaluation_reminders_sent_at" timestamp(3) with time zone;`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "missions" DROP COLUMN "evaluation_reminders_sent_at";`)
}

View file

@ -22,12 +22,13 @@ import * as migration_20260903_060000_add_radio_mode_stats from './20260903_0600
import * as migration_20260903_061000_add_radio_incorrect_counts from './20260903_061000_add_radio_incorrect_counts';
import * as migration_20260903_062000_add_aim_trainer_stats from './20260903_062000_add_aim_trainer_stats';
import * as migration_20260904_230838_add_wiki_collections from './20260904_230838_add_wiki_collections';
import * as migration_20260906_045502 from './20260906_045502';
import * as migration_20260905_183940_base_management from './20260905_183940_base_management';
import * as migration_20260905_195456_labor_headcount from './20260905_195456_labor_headcount';
import * as migration_20260905_210310_labor_classifications from './20260905_210310_labor_classifications';
import * as migration_20260905_213824 from './20260905_213824';
import * as migration_20260905_215658 from './20260905_215658';
import * as migration_20260906_045502 from './20260906_045502';
import * as migration_20260906_050900_add_evaluation_reminders_field from './20260906_050900_add_evaluation_reminders_field';
export const migrations = [
{
@ -151,10 +152,6 @@ export const migrations = [
name: '20260904_230838_add_wiki_collections',
},
{
up: migration_20260906_045502.up,
down: migration_20260906_045502.down,
name: '20260906_045502',
}, {
up: migration_20260905_183940_base_management.up,
down: migration_20260905_183940_base_management.down,
name: '20260905_183940_base_management',
@ -179,5 +176,14 @@ export const migrations = [
down: migration_20260905_215658.down,
name: '20260905_215658',
},
{
up: migration_20260906_045502.up,
down: migration_20260906_045502.down,
name: '20260906_045502',
},
{
up: migration_20260906_050900_add_evaluation_reminders_field.up,
down: migration_20260906_050900_add_evaluation_reminders_field.down,
name: '20260906_050900_add_evaluation_reminders_field'
},
];