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 => { 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 => { 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 => { 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); };