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:
parent
0f7d3a57e3
commit
e3c4eada09
13 changed files with 23013 additions and 9 deletions
|
|
@ -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.**
|
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`.
|
- **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`; `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.
|
- **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`.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ src/bot/
|
||||||
signup.ts # /signup (DM)
|
signup.ts # /signup (DM)
|
||||||
link.ts # /link (works in servers and DMs — credential-free, ephemeral reply only)
|
link.ts # /link (works in servers and DMs — credential-free, ephemeral reply only)
|
||||||
announce.ts # /announce (staff only)
|
announce.ts # /announce (staff only)
|
||||||
|
remindEvaluations.ts # /remind-evaluations (staff only, manual evaluation-reminder trigger)
|
||||||
events/
|
events/
|
||||||
interactionCreate.ts # routes buttons + commands; the RSVP button handler lives here
|
interactionCreate.ts # routes buttons + commands; the RSVP button handler lives here
|
||||||
ready.ts # startup: sync guild commands, kick off poll loops
|
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.
|
- **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.
|
- **`/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`)
|
### 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.
|
`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_STAFF_ROLE_IDS` | no | comma-separated role IDs granting staff |
|
||||||
| `DISCORD_ATTENDANCE_POLL_MS` | no | default 60000 |
|
| `DISCORD_ATTENDANCE_POLL_MS` | no | default 60000 |
|
||||||
| `DISCORD_NOTIFICATION_POLL_MS` | no | default 20000 |
|
| `DISCORD_NOTIFICATION_POLL_MS` | no | default 20000 |
|
||||||
|
| `DISCORD_EVALUATION_POLL_MS` | no | default 60000 |
|
||||||
|
|
||||||
`APP_URL` already exists — used for login links in DMs.
|
`APP_URL` already exists — used for login links in DMs.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ bot/
|
||||||
signup.ts # /signup (DM-only, creates Payload user with temp password)
|
signup.ts # /signup (DM-only, creates Payload user with temp password)
|
||||||
link.ts # /link (global, links discordId to existing user)
|
link.ts # /link (global, links discordId to existing user)
|
||||||
announce.ts # /announce (guild-only, staff only)
|
announce.ts # /announce (guild-only, staff only)
|
||||||
|
remindEvaluations.ts # /remind-evaluations (guild-only, staff only, manual evaluation-reminder trigger)
|
||||||
|
|
||||||
events/
|
events/
|
||||||
interactionCreate.ts # Routes ptf-att: RSVP button interactions
|
interactionCreate.ts # Routes ptf-att: RSVP button interactions
|
||||||
|
|
@ -37,7 +38,7 @@ bot/
|
||||||
## Command registration scope
|
## Command registration scope
|
||||||
|
|
||||||
- **Global** (DM-usable): `signup`, `link`, `ping`
|
- **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
|
## Feature flow: signup/link
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { pingCommand } from "./ping";
|
||||||
import { signupCommand } from "./signup";
|
import { signupCommand } from "./signup";
|
||||||
import { linkCommand } from "./link";
|
import { linkCommand } from "./link";
|
||||||
import { announceCommand } from "./announce";
|
import { announceCommand } from "./announce";
|
||||||
|
import { remindEvaluationsCommand } from "./remindEvaluations";
|
||||||
|
|
||||||
export interface BotCommand {
|
export interface BotCommand {
|
||||||
builder: SlashCommandBuilder;
|
builder: SlashCommandBuilder;
|
||||||
|
|
@ -16,4 +17,10 @@ export interface BotCommand {
|
||||||
scope?: "global" | "guild";
|
scope?: "global" | "guild";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const commands: BotCommand[] = [pingCommand, signupCommand, linkCommand, announceCommand];
|
export const commands: BotCommand[] = [
|
||||||
|
pingCommand,
|
||||||
|
signupCommand,
|
||||||
|
linkCommand,
|
||||||
|
announceCommand,
|
||||||
|
remindEvaluationsCommand,
|
||||||
|
];
|
||||||
|
|
|
||||||
116
src/bot/commands/remindEvaluations.ts
Normal file
116
src/bot/commands/remindEvaluations.ts
Normal 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"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -7,6 +7,7 @@ export interface BotConfig {
|
||||||
staffRoleIds: string[];
|
staffRoleIds: string[];
|
||||||
attendancePollMs: number;
|
attendancePollMs: number;
|
||||||
notificationPollMs: number;
|
notificationPollMs: number;
|
||||||
|
evaluationPollMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const required = (name: string): string => {
|
const required = (name: string): string => {
|
||||||
|
|
@ -46,4 +47,5 @@ export const botConfig: BotConfig = {
|
||||||
.filter((id) => id.length > 0),
|
.filter((id) => id.length > 0),
|
||||||
attendancePollMs: pollMs("DISCORD_ATTENDANCE_POLL_MS", 60_000),
|
attendancePollMs: pollMs("DISCORD_ATTENDANCE_POLL_MS", 60_000),
|
||||||
notificationPollMs: pollMs("DISCORD_NOTIFICATION_POLL_MS", 20_000),
|
notificationPollMs: pollMs("DISCORD_NOTIFICATION_POLL_MS", 20_000),
|
||||||
|
evaluationPollMs: pollMs("DISCORD_EVALUATION_POLL_MS", 60_000),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
215
src/bot/services/evaluationReminders.ts
Normal file
215
src/bot/services/evaluationReminders.ts
Normal 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);
|
||||||
|
};
|
||||||
|
|
@ -3,6 +3,7 @@ import type { Payload } from "payload";
|
||||||
import { start as startMissionEmbeds } from "./missionEmbeds";
|
import { start as startMissionEmbeds } from "./missionEmbeds";
|
||||||
import { start as startNotificationBridge } from "./notificationBridge";
|
import { start as startNotificationBridge } from "./notificationBridge";
|
||||||
import { start as startTransferRequests } from "./transferRequests";
|
import { start as startTransferRequests } from "./transferRequests";
|
||||||
|
import { start as startEvaluationReminders } from "./evaluationReminders";
|
||||||
|
|
||||||
export type ServiceStarter = (client: Client, payload: Payload) => Promise<void> | void;
|
export type ServiceStarter = (client: Client, payload: Payload) => Promise<void> | void;
|
||||||
|
|
||||||
|
|
@ -10,4 +11,5 @@ export const services: ServiceStarter[] = [
|
||||||
startMissionEmbeds,
|
startMissionEmbeds,
|
||||||
startNotificationBridge,
|
startNotificationBridge,
|
||||||
startTransferRequests,
|
startTransferRequests,
|
||||||
|
startEvaluationReminders,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -197,6 +197,15 @@ export const Missions: CollectionConfig = {
|
||||||
hidden: true,
|
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",
|
name: "coverImage",
|
||||||
type: "upload",
|
type: "upload",
|
||||||
|
|
|
||||||
175
src/lib/evaluations/reminders.ts
Normal file
175
src/lib/evaluations/reminders.ts
Normal 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 };
|
||||||
|
}
|
||||||
22449
src/migrations/20260906_050900_add_evaluation_reminders_field.json
Normal file
22449
src/migrations/20260906_050900_add_evaluation_reminders_field.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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";`)
|
||||||
|
}
|
||||||
|
|
@ -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_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_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_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_183940_base_management from './20260905_183940_base_management';
|
||||||
import * as migration_20260905_195456_labor_headcount from './20260905_195456_labor_headcount';
|
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_210310_labor_classifications from './20260905_210310_labor_classifications';
|
||||||
import * as migration_20260905_213824 from './20260905_213824';
|
import * as migration_20260905_213824 from './20260905_213824';
|
||||||
import * as migration_20260905_215658 from './20260905_215658';
|
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 = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
|
|
@ -151,10 +152,6 @@ export const migrations = [
|
||||||
name: '20260904_230838_add_wiki_collections',
|
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,
|
up: migration_20260905_183940_base_management.up,
|
||||||
down: migration_20260905_183940_base_management.down,
|
down: migration_20260905_183940_base_management.down,
|
||||||
name: '20260905_183940_base_management',
|
name: '20260905_183940_base_management',
|
||||||
|
|
@ -179,5 +176,14 @@ export const migrations = [
|
||||||
down: migration_20260905_215658.down,
|
down: migration_20260905_215658.down,
|
||||||
name: '20260905_215658',
|
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'
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue