diff --git a/src/app/(frontend)/intelligence/missions/actions.ts b/src/app/(frontend)/intelligence/missions/actions.ts index 0b133d0..81025c2 100644 --- a/src/app/(frontend)/intelligence/missions/actions.ts +++ b/src/app/(frontend)/intelligence/missions/actions.ts @@ -4,6 +4,7 @@ import config from "@payload-config"; import { getPayload } from "payload"; import type { AttendanceResponse } from "@/lib/attendance"; import { setMissionAttendance as setAttendance } from "@/lib/attendance"; +import { notifyAttendanceChanged } from "@/lib/attendance/realtime"; export interface ActionResult { success: boolean; @@ -38,6 +39,15 @@ export async function setMissionAttendance(input: { userId, response: input.response, }); + // The bot also polls as a recovery path, but this makes web RSVP changes + // visible in Discord immediately when the bot is running. + void notifyAttendanceChanged(payload, input.missionId).catch((error) => { + payload.logger.warn( + `Attendance changed, but Discord realtime notification failed: ${ + error instanceof Error ? error.message : "Unknown error" + }`, + ); + }); return { success: true }; } catch (error) { diff --git a/src/bot/services/missionEmbeds.ts b/src/bot/services/missionEmbeds.ts index 19b3a59..e9e55db 100644 --- a/src/bot/services/missionEmbeds.ts +++ b/src/bot/services/missionEmbeds.ts @@ -9,6 +9,7 @@ import { import type { Payload } from "payload"; import type { Mission, MissionAttendance, User } from "@/payload-types"; import { getMissionAttendance, type MissionAttendanceSummary } from "@/lib/attendance"; +import { ATTENDANCE_CHANGED_CHANNEL } from "@/lib/attendance/realtime"; import { botConfig } from "@/bot/config"; const EMBED_COLOR = 0x3b82f6; @@ -278,6 +279,33 @@ const manageMission = async ( }); }; +export const reconcileMission = async ( + client: Client, + payload: Payload, + missionId: number, +): Promise => { + const opsChannelId = botConfig.opsChannelId; + if (!opsChannelId) return; + + const mission = (await payload.findByID({ + collection: "missions", + id: missionId, + depth: 1, + overrideAccess: true, + })) as Mission; + + if ( + mission.ownershipAndStatus.visibility !== "unit" || + !["Ready", "Scheduled"].includes(mission.ownershipAndStatus.status) || + !isStillFuture(mission) || + (!isRollCallDue(mission) && !mission.discordMessageId) + ) { + return; + } + + await manageMission(client, payload, opsChannelId, mission); +}; + const cancelMissionEmbed = async ( client: Client, payload: Payload, @@ -356,8 +384,41 @@ export const reconcile = async (client: Client, payload: Payload): Promise export const start = (client: Client, payload: Payload): void => { payload.logger.info("[Bot] Attendance embed service started"); + void startAttendanceListener(client, payload).catch((error) => { + payload.logger.error(`[Bot] Attendance realtime listener failed to start: ${errorMessage(error)}`); + }); void reconcile(client, payload); setInterval(() => { void reconcile(client, payload); }, botConfig.attendancePollMs); }; + +const startAttendanceListener = async (client: Client, payload: Payload): Promise => { + if (!botConfig.opsChannelId) return; + + const listener = await payload.db.pool.connect(); + listener.on("notification", (notification) => { + if (notification.channel !== ATTENDANCE_CHANGED_CHANNEL || !notification.payload) return; + + let missionId: unknown; + try { + missionId = JSON.parse(notification.payload).missionId; + } catch { + payload.logger.warn("[Bot] Ignoring malformed attendance change notification."); + return; + } + if (typeof missionId !== "number" || !Number.isInteger(missionId)) return; + + void reconcileMission(client, payload, missionId).catch((error) => { + payload.logger.error( + `[Bot] Attendance change reconcile failed for mission #${missionId}: ${errorMessage(error)}`, + ); + }); + }); + + listener.on("error", (error) => { + payload.logger.error(`[Bot] Attendance listener failed: ${errorMessage(error)}`); + }); + await listener.query(`LISTEN ${ATTENDANCE_CHANGED_CHANNEL}`); + payload.logger.info("[Bot] Attendance realtime listener started"); +}; diff --git a/src/collections/logistics/Assets.ts b/src/collections/logistics/Assets.ts index 3163ed9..8bf1ca0 100644 --- a/src/collections/logistics/Assets.ts +++ b/src/collections/logistics/Assets.ts @@ -26,7 +26,7 @@ export const Assets: CollectionConfig = { { label: "Revision Requested", value: "revision_requested" }, ], defaultValue: "in_progress", - filterOptions: ({ options, data, req }) => { + filterOptions: ({ options, _, req }) => { return !isDeveloper({ req }) ? options.filter((option) => typeof option === "string" @@ -592,9 +592,6 @@ export const Assets: CollectionConfig = { { label: "Auditing", description: "Auditing and moderation for this Asset.", - admin: { - condition: ({ user }) => isDeveloper({ req: { user: user } } as never), - }, access: { read: requirePermission("assets:read"), create: requirePermission("assets:create"), diff --git a/src/lib/attendance/realtime.ts b/src/lib/attendance/realtime.ts new file mode 100644 index 0000000..fdfe6ab --- /dev/null +++ b/src/lib/attendance/realtime.ts @@ -0,0 +1,11 @@ +import type { Payload } from "payload"; + +export const ATTENDANCE_CHANGED_CHANNEL = "ptf_attendance_changed"; + +/** Notify the standalone Discord bot that a mission's attendance changed. */ +export async function notifyAttendanceChanged(payload: Payload, missionId: number): Promise { + await payload.db.pool.query("SELECT pg_notify($1, $2)", [ + ATTENDANCE_CHANGED_CHANNEL, + JSON.stringify({ missionId }), + ]); +}