From 4ad49d3b958ff9ec5699aa7ece157557b52781a1 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Fri, 21 Aug 2026 01:56:12 -0400 Subject: [PATCH] fix(discord): send mission roll calls once --- src/bot/services/missionEmbeds.ts | 69 +++++++++++++++++++++--- src/collections/intelligence/Missions.ts | 8 +++ src/migrations/20260821_060000.ts | 16 ++++++ src/migrations/index.ts | 6 +++ src/payload-types.ts | 5 ++ 5 files changed, 97 insertions(+), 7 deletions(-) create mode 100644 src/migrations/20260821_060000.ts diff --git a/src/bot/services/missionEmbeds.ts b/src/bot/services/missionEmbeds.ts index d125a08..19b3a59 100644 --- a/src/bot/services/missionEmbeds.ts +++ b/src/bot/services/missionEmbeds.ts @@ -13,10 +13,11 @@ import { botConfig } from "@/bot/config"; const EMBED_COLOR = 0x3b82f6; const CANCELLED_COLOR = 0x6b7280; -const ELIGIBLE_STATUSES = ["Ready", "Scheduled"]; const MAX_ELIGIBLE_MISSIONS = 10; +const ROLL_CALL_LEAD_TIME_MS = 3 * 24 * 60 * 60 * 1000; let opsChannelWarned = false; +let reconcileRunning = false; const errorMessage = (error: unknown): string => error instanceof Error ? error.message : "Unknown error"; @@ -32,6 +33,19 @@ const isStillFuture = (mission: Mission): boolean => { return end > Date.now(); }; +/** + * Roll call is sent once: as soon as the mission is Ready, or three days + * before a Scheduled mission, whichever happens first. + */ +export const isRollCallDue = (mission: Mission, now = Date.now()): boolean => { + if (mission.discordAttendanceSentAt) return false; + if (mission.ownershipAndStatus.status === "Ready") return true; + if (mission.ownershipAndStatus.status !== "Scheduled") return false; + + const start = new Date(mission.classification.startDateTime).getTime(); + return start - now <= ROLL_CALL_LEAD_TIME_MS; +}; + const operationTypeLabel = (type: Mission["operationType"]): string => { switch (type) { case "main": @@ -73,6 +87,7 @@ export const fetchAttendanceDocs = async ( }; export const getEligibleMissions = async (payload: Payload): Promise => { + const rollCallCutoff = new Date(Date.now() + ROLL_CALL_LEAD_TIME_MS).toISOString(); const res = await payload.find({ collection: "missions", overrideAccess: true, @@ -82,8 +97,18 @@ export const getEligibleMissions = async (payload: Payload): Promise where: { and: [ { "classification.startDateTime": { greater_than: new Date().toISOString() } }, - { "ownershipAndStatus.status": { in: ELIGIBLE_STATUSES } }, { "ownershipAndStatus.visibility": { equals: "unit" } }, + { + or: [ + { "ownershipAndStatus.status": { equals: "Ready" } }, + { + and: [ + { "ownershipAndStatus.status": { equals: "Scheduled" } }, + { "classification.startDateTime": { less_than_equal: rollCallCutoff } }, + ], + }, + ], + }, ], }, }); @@ -183,7 +208,11 @@ const postEmbed = async ( await payload.update({ collection: "missions", id: mission.id, - data: { discordMessageId: message.id, discordAttendanceHash: hash }, + data: { + discordMessageId: message.id, + discordAttendanceHash: hash, + discordAttendanceSentAt: new Date().toISOString(), + }, overrideAccess: true, }); payload.logger.info( @@ -197,7 +226,13 @@ const manageMission = async ( opsChannelId: string, mission: Mission, ): Promise => { + if (!isRollCallDue(mission)) { + // A sent roll call may still need its existing message reconciled. + if (!mission.discordAttendanceSentAt) return; + } + if (!mission.discordMessageId) { + if (mission.discordAttendanceSentAt) return; await postEmbed(client, payload, opsChannelId, mission); return; } @@ -207,8 +242,17 @@ const manageMission = async ( try { message = await channel.messages.fetch(mission.discordMessageId); } catch { - await postEmbed(client, payload, opsChannelId, mission); - payload.logger.warn(`[Bot] Attendance embed for mission #${mission.id} was deleted; reposted.`); + // The roll call is intentionally one-shot. Do not replace a deleted + // message, even for missions created before the persistent send marker. + await payload.update({ + collection: "missions", + id: mission.id, + data: { discordMessageId: null, discordAttendanceSentAt: new Date().toISOString() }, + overrideAccess: true, + }); + payload.logger.warn( + `[Bot] Attendance embed for mission #${mission.id} is missing; will not repost a one-shot roll call.`, + ); return; } @@ -224,7 +268,12 @@ const manageMission = async ( await payload.update({ collection: "missions", id: mission.id, - data: { discordAttendanceHash: hash }, + data: { + discordAttendanceHash: hash, + ...(mission.discordAttendanceSentAt + ? {} + : { discordAttendanceSentAt: new Date().toISOString() }), + }, overrideAccess: true, }); }; @@ -261,6 +310,8 @@ const cancelMissionEmbed = async ( }; export const reconcile = async (client: Client, payload: Payload): Promise => { + if (reconcileRunning) return; + reconcileRunning = true; try { const opsChannelId = botConfig.opsChannelId; if (!opsChannelId) { @@ -273,7 +324,9 @@ export const reconcile = async (client: Client, payload: Payload): Promise return; } - const eligible = (await getEligibleMissions(payload)).filter(isStillFuture); + const eligible = (await getEligibleMissions(payload)).filter( + (mission) => isStillFuture(mission) && (isRollCallDue(mission) || !!mission.discordMessageId), + ); for (const mission of eligible) { try { await manageMission(client, payload, opsChannelId, mission); @@ -296,6 +349,8 @@ export const reconcile = async (client: Client, payload: Payload): Promise } } catch (error) { payload.logger.error(`[Bot] Attendance embed reconcile failed: ${errorMessage(error)}`); + } finally { + reconcileRunning = false; } }; diff --git a/src/collections/intelligence/Missions.ts b/src/collections/intelligence/Missions.ts index f5346f1..fe6d847 100644 --- a/src/collections/intelligence/Missions.ts +++ b/src/collections/intelligence/Missions.ts @@ -166,6 +166,14 @@ export const Missions: CollectionConfig = { hidden: true, }, }, + { + name: "discordAttendanceSentAt", + type: "date", + admin: { + description: "When the attendance roll-call was first posted to Discord (managed by the bot).", + hidden: true, + }, + }, { name: "coverImage", type: "upload", diff --git a/src/migrations/20260821_060000.ts b/src/migrations/20260821_060000.ts new file mode 100644 index 0000000..39ce2ee --- /dev/null +++ b/src/migrations/20260821_060000.ts @@ -0,0 +1,16 @@ +import { MigrateDownArgs, MigrateUpArgs, sql } from "@payloadcms/db-postgres"; + +/** Persist the one-time Discord roll-call send marker. */ +export async function up({ db }: MigrateUpArgs): Promise { + await db.execute(sql` + ALTER TABLE "missions" + ADD COLUMN IF NOT EXISTS "discord_attendance_sent_at" timestamp(3) with time zone; + `); +} + +export async function down({ db }: MigrateDownArgs): Promise { + await db.execute(sql` + ALTER TABLE "missions" + DROP COLUMN IF EXISTS "discord_attendance_sent_at"; + `); +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts index 1f9efba..3cc036a 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -1,6 +1,7 @@ import * as migration_20260818_024510 from './_archived/20260818_024510'; import * as migration_20260820_004157 from './20260820_004157'; import * as migration_20260821_043007 from './20260821_043007'; +import * as migration_20260821_060000 from './20260821_060000'; export const migrations = [ { @@ -13,4 +14,9 @@ export const migrations = [ down: migration_20260821_043007.down, name: '20260821_043007' }, + { + up: migration_20260821_060000.up, + down: migration_20260821_060000.down, + name: '20260821_060000' + }, ]; diff --git a/src/payload-types.ts b/src/payload-types.ts index 780a38a..91adecc 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -1802,6 +1802,10 @@ export interface Mission { * Hash of the last attendance state rendered to Discord (managed by the bot). */ discordAttendanceHash?: string | null; + /** + * When the attendance roll-call was first posted to Discord (managed by the bot). + */ + discordAttendanceSentAt?: string | null; /** * Background image for the mission page hero header. Renders translucent with a top-to-bottom fade to black. Recommended aspect ratio: 16:9 or wider. */ @@ -3338,6 +3342,7 @@ export interface MissionsSelect { operationType?: T; discordMessageId?: T; discordAttendanceHash?: T; + discordAttendanceSentAt?: T; coverImage?: T; classification?: | T