1
0
Fork 0

feat(attendance): implement real-time attendance change notifications for Discord

This commit is contained in:
Jason Fraley 2026-08-21 02:12:01 -04:00
parent 4ad49d3b95
commit 40b130c8d3
4 changed files with 83 additions and 4 deletions

View file

@ -4,6 +4,7 @@ import config from "@payload-config";
import { getPayload } from "payload"; import { getPayload } from "payload";
import type { AttendanceResponse } from "@/lib/attendance"; import type { AttendanceResponse } from "@/lib/attendance";
import { setMissionAttendance as setAttendance } from "@/lib/attendance"; import { setMissionAttendance as setAttendance } from "@/lib/attendance";
import { notifyAttendanceChanged } from "@/lib/attendance/realtime";
export interface ActionResult<T = undefined> { export interface ActionResult<T = undefined> {
success: boolean; success: boolean;
@ -38,6 +39,15 @@ export async function setMissionAttendance(input: {
userId, userId,
response: input.response, 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 }; return { success: true };
} catch (error) { } catch (error) {

View file

@ -9,6 +9,7 @@ import {
import type { Payload } from "payload"; import type { Payload } from "payload";
import type { Mission, MissionAttendance, User } from "@/payload-types"; import type { Mission, MissionAttendance, User } from "@/payload-types";
import { getMissionAttendance, type MissionAttendanceSummary } from "@/lib/attendance"; import { getMissionAttendance, type MissionAttendanceSummary } from "@/lib/attendance";
import { ATTENDANCE_CHANGED_CHANNEL } from "@/lib/attendance/realtime";
import { botConfig } from "@/bot/config"; import { botConfig } from "@/bot/config";
const EMBED_COLOR = 0x3b82f6; const EMBED_COLOR = 0x3b82f6;
@ -278,6 +279,33 @@ const manageMission = async (
}); });
}; };
export const reconcileMission = async (
client: Client,
payload: Payload,
missionId: number,
): Promise<void> => {
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 ( const cancelMissionEmbed = async (
client: Client, client: Client,
payload: Payload, payload: Payload,
@ -356,8 +384,41 @@ export const reconcile = async (client: Client, payload: Payload): Promise<void>
export const start = (client: Client, payload: Payload): void => { export const start = (client: Client, payload: Payload): void => {
payload.logger.info("[Bot] Attendance embed service started"); 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); void reconcile(client, payload);
setInterval(() => { setInterval(() => {
void reconcile(client, payload); void reconcile(client, payload);
}, botConfig.attendancePollMs); }, botConfig.attendancePollMs);
}; };
const startAttendanceListener = async (client: Client, payload: Payload): Promise<void> => {
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");
};

View file

@ -26,7 +26,7 @@ export const Assets: CollectionConfig = {
{ label: "Revision Requested", value: "revision_requested" }, { label: "Revision Requested", value: "revision_requested" },
], ],
defaultValue: "in_progress", defaultValue: "in_progress",
filterOptions: ({ options, data, req }) => { filterOptions: ({ options, _, req }) => {
return !isDeveloper({ req }) return !isDeveloper({ req })
? options.filter((option) => ? options.filter((option) =>
typeof option === "string" typeof option === "string"
@ -592,9 +592,6 @@ export const Assets: CollectionConfig = {
{ {
label: "Auditing", label: "Auditing",
description: "Auditing and moderation for this Asset.", description: "Auditing and moderation for this Asset.",
admin: {
condition: ({ user }) => isDeveloper({ req: { user: user } } as never),
},
access: { access: {
read: requirePermission("assets:read"), read: requirePermission("assets:read"),
create: requirePermission("assets:create"), create: requirePermission("assets:create"),

View file

@ -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<void> {
await payload.db.pool.query("SELECT pg_notify($1, $2)", [
ATTENDANCE_CHANGED_CHANNEL,
JSON.stringify({ missionId }),
]);
}