feat(attendance): implement real-time attendance change notifications for Discord
This commit is contained in:
parent
4ad49d3b95
commit
40b130c8d3
4 changed files with 83 additions and 4 deletions
|
|
@ -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<T = undefined> {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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<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 (
|
||||
client: Client,
|
||||
payload: Payload,
|
||||
|
|
@ -356,8 +384,41 @@ export const reconcile = async (client: Client, payload: Payload): Promise<void>
|
|||
|
||||
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<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");
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
11
src/lib/attendance/realtime.ts
Normal file
11
src/lib/attendance/realtime.ts
Normal 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 }),
|
||||
]);
|
||||
}
|
||||
Loading…
Reference in a new issue