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