1
0
Fork 0

Compare commits

..

2 commits

Author SHA1 Message Date
b61db9dd3a v0.2.37 - add op cancellation and reschedule roll-call handling 2026-09-12 19:32:59 -04:00
f1da86ae81 feat(bot): cancel roll-calls and announce rescheduled ops
- Cancelled ops: roll-call embed edited to the cancelled state (buttons
  removed) plus a standalone cancellation notice in the ops channel;
  clears the roll-call markers and sets the new discordCancelledAt
  marker on the mission.
- Rescheduled ops (Cancelled back to Ready/Scheduled, future start):
  immediate announcement embed with the new start time and when the
  fresh roll-call goes up (usual 3-day lead, or live-in-channel when
  already due), then clears the marker so the normal flow posts a new
  roll-call. Prior RSVPs carry over.
- Hidden bot-managed discordCancelledAt field on Missions + migration.
- Bot embed poll loop handles both transitions idempotently.
2026-09-12 19:30:22 -04:00
8 changed files with 26947 additions and 15 deletions

View file

@ -104,7 +104,8 @@ Dependency: `discord.js` ^14 (add to root `package.json`). Script: `"bot": "bun
1. Periodic reconcile loop (every `DISCORD_ATTENDANCE_POLL_MS`, default 60s): query eligible missions.
- No `discordMessageId` yet → post embed + button row, store `discordMessageId` on the mission.
- Has `discordMessageId` → compute attendance hash (sorted member lists + counts, JSON) → if ≠ stored `discordAttendanceHash`, edit the message in place, store the new hash.
- Status `Cancelled` → edit embed to a cancelled state, stop polling it.
- Status `Cancelled` → edit embed to a cancelled state (buttons removed), post a standalone cancellation notice, clear `discordMessageId` / `discordAttendanceHash` / `discordAttendanceSentAt`, and set the `discordCancelledAt` marker (so the same op can roll-call again later).
- Status back to `Ready`/`Scheduled` with `discordCancelledAt` set (rescheduled op) → immediately post a reschedule announcement: the new `<t:start>` time plus when the fresh roll-call goes up (the usual 3-day lead, or "live in this channel" when it is already due / already posted). Then clear the marker and let the normal flow post the fresh roll-call. Requires a future `startDateTime`.
- Past end time → leave final attendance as-is, stop polling.
2. **RSVP buttons**: action row on each embed: ✅ Yes / 🤔 Tentative / ❌ No, customId `ptf-att:<missionId>:<response>` (well under Discord's 100-char limit).
- Click → resolve user via `discordId`; unlinked → ephemeral "Run /link or /signup first."

View file

@ -1,6 +1,6 @@
{
"name": "polaris-task-force",
"version": "0.2.36",
"version": "0.2.37",
"description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.",
"license": "MIT",
"type": "module",

View file

@ -17,6 +17,7 @@ import { botConfig } from "@/bot/config";
const EMBED_COLOR = 0x3b82f6;
const CANCELLED_COLOR = 0x6b7280;
const RESCHEDULED_COLOR = 0x22c55e;
const MAX_ELIGIBLE_MISSIONS = 10;
const ROLL_CALL_LEAD_TIME_MS = 3 * 24 * 60 * 60 * 1000;
// Discord attachment cap (8 MiB) — larger modlists fall back to a link.
@ -142,6 +143,30 @@ const getCancelledMissions = async (payload: Payload): Promise<Mission[]> => {
return res.docs as unknown as Mission[];
};
/**
* Cancelled missions that are live again (status back to Ready/Scheduled) and
* still carry the cancellation marker: the reschedule announcement has not
* happened yet.
*/
const getRescheduledMissions = async (payload: Payload): Promise<Mission[]> => {
const res = await payload.find({
collection: "missions",
overrideAccess: true,
depth: 1,
sort: "-classification.startDateTime",
limit: MAX_ELIGIBLE_MISSIONS,
where: {
and: [
{ "ownershipAndStatus.visibility": { equals: "unit" } },
{ "ownershipAndStatus.status": { in: ["Ready", "Scheduled"] } },
{ "classification.startDateTime": { greater_than: new Date().toISOString() } },
{ discordCancelledAt: { exists: true } },
],
},
});
return res.docs as unknown as Mission[];
};
export const renderAttendanceEmbed = (
mission: Mission,
summary: MissionAttendanceSummary,
@ -175,6 +200,32 @@ export const renderCancelledEmbed = (mission: Mission): EmbedBuilder =>
.setDescription("This operation has been cancelled.")
.setColor(CANCELLED_COLOR);
const renderCancelledNoticeEmbed = (mission: Mission): EmbedBuilder =>
new EmbedBuilder()
.setTitle(`Cancelled: ${missionTitle(mission)}`)
.setDescription(
"This operation has been cancelled and its roll-call is closed. " +
"If it is rescheduled, a fresh roll-call will be posted here.",
)
.setColor(CANCELLED_COLOR)
.setTimestamp();
const renderRescheduledEmbed = (mission: Mission, rollCallDueUnix: number | null): EmbedBuilder => {
const startUnix = Math.floor(new Date(mission.classification.startDateTime).getTime() / 1000);
return new EmbedBuilder()
.setTitle(`Rescheduled: ${missionTitle(mission)}`)
.setDescription(
[
`This operation has been rescheduled for <t:${startUnix}:F> (<t:${startUnix}:R>).`,
rollCallDueUnix
? `A new roll-call will be posted <t:${rollCallDueUnix}:R>, the usual lead time before the op.`
: "The new roll-call is live in this channel; react when you know your availability.",
].join("\n"),
)
.setColor(RESCHEDULED_COLOR)
.setTimestamp();
};
export const buildAttendanceRow = (missionId: number): ActionRowBuilder<ButtonBuilder> =>
new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
@ -455,6 +506,13 @@ export const reconcileMission = async (
await manageMission(client, payload, opsChannelId, mission);
};
/**
* A cancelled op gets its roll-call edited into the cancelled state (buttons
* removed) plus a standalone cancellation notice. The message id, attendance
* hash, and one-shot send marker are cleared, and `discordCancelledAt` marks
* the cancellation so a later reschedule can be announced and get a fresh
* roll-call.
*/
const cancelMissionEmbed = async (
client: Client,
payload: Payload,
@ -462,23 +520,24 @@ const cancelMissionEmbed = async (
mission: Mission,
): Promise<void> => {
const channel = await getOpsChannel(client, opsChannelId);
let message;
try {
message = await channel.messages.fetch(mission.discordMessageId as string);
const message = await channel.messages.fetch(mission.discordMessageId as string);
await message.edit({ embeds: [renderCancelledEmbed(mission)], components: [] });
} catch {
await payload.update({
collection: "missions",
id: mission.id,
data: { discordMessageId: null },
overrideAccess: true,
});
return;
payload.logger.warn(
`[Bot] Attendance embed for cancelled mission #${mission.id} is missing; posting the cancellation notice only.`,
);
}
await message.edit({ embeds: [renderCancelledEmbed(mission)], components: [] });
await channel.send({ embeds: [renderCancelledNoticeEmbed(mission)] });
await payload.update({
collection: "missions",
id: mission.id,
data: { discordMessageId: null },
data: {
discordMessageId: null,
discordAttendanceHash: null,
discordAttendanceSentAt: null,
discordCancelledAt: new Date().toISOString(),
},
overrideAccess: true,
});
payload.logger.info(
@ -486,6 +545,38 @@ const cancelMissionEmbed = async (
);
};
/**
* A cancelled op came back (status Cancelled -> Ready/Scheduled): announce the
* new start time immediately, note when the fresh roll-call goes up (the usual
* lead time before the op, or right away when it is already due), then clear
* the marker so the reconcile loop posts the roll-call as usual.
*/
const announceReschedule = async (
client: Client,
payload: Payload,
opsChannelId: string,
mission: Mission,
): Promise<void> => {
const start = new Date(mission.classification.startDateTime).getTime();
const rollCallAlreadyLive = !!mission.discordAttendanceSentAt;
const rollCallDueNow =
mission.ownershipAndStatus.status === "Ready" || start - Date.now() <= ROLL_CALL_LEAD_TIME_MS;
const rollCallDueUnix =
rollCallAlreadyLive || rollCallDueNow
? null
: Math.floor((start - ROLL_CALL_LEAD_TIME_MS) / 1000);
const channel = await getOpsChannel(client, opsChannelId);
await channel.send({ embeds: [renderRescheduledEmbed(mission, rollCallDueUnix)] });
await payload.update({
collection: "missions",
id: mission.id,
data: { discordCancelledAt: null },
overrideAccess: true,
});
payload.logger.info(`[Bot] Announced reschedule for mission #${mission.id} (${mission.name}).`);
};
export const reconcile = async (client: Client, payload: Payload): Promise<void> => {
if (reconcileRunning) return;
reconcileRunning = true;
@ -501,6 +592,19 @@ export const reconcile = async (client: Client, payload: Payload): Promise<void>
return;
}
// Rescheduled ops first, so the announcement lands in the channel before
// any same-tick roll-call post.
const rescheduled = await getRescheduledMissions(payload);
for (const mission of rescheduled) {
try {
await announceReschedule(client, payload, opsChannelId, mission);
} catch (error) {
payload.logger.error(
`[Bot] Reschedule reconcile failed for mission #${mission.id}: ${errorMessage(error)}`,
);
}
}
const eligible = (await getEligibleMissions(payload)).filter(
(mission) =>
isRollCallDue(mission) || !!mission.discordMessageId || !!mission.discordAttendanceSentAt,

View file

@ -1,5 +1,9 @@
import type { CollectionConfig, Payload, PayloadRequest, Where } from "payload";
import { requirePermission, hasPermission, isSuperuser } from "@/utils/access-control/hasPermission";
import {
requirePermission,
hasPermission,
isSuperuser,
} from "@/utils/access-control/hasPermission";
type AssignmentRef = { id: number } | number;
@ -215,6 +219,15 @@ export const Missions: CollectionConfig = {
hidden: true,
},
},
{
name: "discordCancelledAt",
type: "date",
admin: {
description:
"When the roll-call was marked cancelled in Discord (managed by the bot; cleared when a cancelled op is rescheduled).",
hidden: true,
},
},
{
name: "evaluationRemindersSentAt",
type: "date",

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,11 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "missions" ADD COLUMN "discord_cancelled_at" timestamp(3) with time zone;`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "missions" DROP COLUMN "discord_cancelled_at";`)
}

View file

@ -55,6 +55,7 @@ import * as migration_20260910_222518_add_dashboard_layout_preference from './20
import * as migration_20260911_044754_add_poker_minigame_stats from './20260911_044754_add_poker_minigame_stats';
import * as migration_20260911_052147_add_ribbon_submissions from './20260911_052147_add_ribbon_submissions';
import * as migration_20260911_201559_add_award_advancement_series from './20260911_201559_add_award_advancement_series';
import * as migration_20260912_231219_add_discord_cancelled_at from './20260912_231219_add_discord_cancelled_at';
export const migrations = [
{
@ -340,6 +341,11 @@ export const migrations = [
{
up: migration_20260911_201559_add_award_advancement_series.up,
down: migration_20260911_201559_add_award_advancement_series.down,
name: '20260911_201559_add_award_advancement_series'
name: '20260911_201559_add_award_advancement_series',
},
{
up: migration_20260912_231219_add_discord_cancelled_at.up,
down: migration_20260912_231219_add_discord_cancelled_at.down,
name: '20260912_231219_add_discord_cancelled_at'
},
];

View file

@ -2461,6 +2461,10 @@ export interface Mission {
* Fingerprint of the attached modlist last announced to Discord (managed by the bot).
*/
discordModlistHash?: string | null;
/**
* When the roll-call was marked cancelled in Discord (managed by the bot; cleared when a cancelled op is rescheduled).
*/
discordCancelledAt?: string | null;
/**
* When evaluation reminder DMs were sent for this mission (managed by the bot, one-shot).
*/
@ -6006,6 +6010,7 @@ export interface MissionsSelect<T extends boolean = true> {
discordAttendanceHash?: T;
discordAttendanceSentAt?: T;
discordModlistHash?: T;
discordCancelledAt?: T;
evaluationRemindersSentAt?: T;
readyReminderWebSentAt?: T;
readyReminderDmSentAt?: T;