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 { 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) {
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
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