Compare commits
No commits in common. "b61db9dd3a66a4f594d3737027dcca550752a53f" and "24662060dffb19a35566fd663c57ee952c18e120" have entirely different histories.
b61db9dd3a
...
24662060df
8 changed files with 15 additions and 26947 deletions
|
|
@ -104,8 +104,7 @@ 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.
|
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.
|
- 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.
|
- 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 (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 `Cancelled` → edit embed to a cancelled state, stop polling it.
|
||||||
- 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.
|
- 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).
|
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."
|
- Click → resolve user via `discordId`; unlinked → ephemeral "Run /link or /signup first."
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "polaris-task-force",
|
"name": "polaris-task-force",
|
||||||
"version": "0.2.37",
|
"version": "0.2.36",
|
||||||
"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.",
|
"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",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ import { botConfig } from "@/bot/config";
|
||||||
|
|
||||||
const EMBED_COLOR = 0x3b82f6;
|
const EMBED_COLOR = 0x3b82f6;
|
||||||
const CANCELLED_COLOR = 0x6b7280;
|
const CANCELLED_COLOR = 0x6b7280;
|
||||||
const RESCHEDULED_COLOR = 0x22c55e;
|
|
||||||
const MAX_ELIGIBLE_MISSIONS = 10;
|
const MAX_ELIGIBLE_MISSIONS = 10;
|
||||||
const ROLL_CALL_LEAD_TIME_MS = 3 * 24 * 60 * 60 * 1000;
|
const ROLL_CALL_LEAD_TIME_MS = 3 * 24 * 60 * 60 * 1000;
|
||||||
// Discord attachment cap (8 MiB) — larger modlists fall back to a link.
|
// Discord attachment cap (8 MiB) — larger modlists fall back to a link.
|
||||||
|
|
@ -143,30 +142,6 @@ const getCancelledMissions = async (payload: Payload): Promise<Mission[]> => {
|
||||||
return res.docs as unknown as 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 = (
|
export const renderAttendanceEmbed = (
|
||||||
mission: Mission,
|
mission: Mission,
|
||||||
summary: MissionAttendanceSummary,
|
summary: MissionAttendanceSummary,
|
||||||
|
|
@ -200,32 +175,6 @@ export const renderCancelledEmbed = (mission: Mission): EmbedBuilder =>
|
||||||
.setDescription("This operation has been cancelled.")
|
.setDescription("This operation has been cancelled.")
|
||||||
.setColor(CANCELLED_COLOR);
|
.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> =>
|
export const buildAttendanceRow = (missionId: number): ActionRowBuilder<ButtonBuilder> =>
|
||||||
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
new ButtonBuilder()
|
new ButtonBuilder()
|
||||||
|
|
@ -506,13 +455,6 @@ export const reconcileMission = async (
|
||||||
await manageMission(client, payload, opsChannelId, mission);
|
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 (
|
const cancelMissionEmbed = async (
|
||||||
client: Client,
|
client: Client,
|
||||||
payload: Payload,
|
payload: Payload,
|
||||||
|
|
@ -520,24 +462,23 @@ const cancelMissionEmbed = async (
|
||||||
mission: Mission,
|
mission: Mission,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const channel = await getOpsChannel(client, opsChannelId);
|
const channel = await getOpsChannel(client, opsChannelId);
|
||||||
|
let message;
|
||||||
try {
|
try {
|
||||||
const message = await channel.messages.fetch(mission.discordMessageId as string);
|
message = await channel.messages.fetch(mission.discordMessageId as string);
|
||||||
await message.edit({ embeds: [renderCancelledEmbed(mission)], components: [] });
|
|
||||||
} catch {
|
} catch {
|
||||||
payload.logger.warn(
|
await payload.update({
|
||||||
`[Bot] Attendance embed for cancelled mission #${mission.id} is missing; posting the cancellation notice only.`,
|
collection: "missions",
|
||||||
);
|
id: mission.id,
|
||||||
|
data: { discordMessageId: null },
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
await channel.send({ embeds: [renderCancelledNoticeEmbed(mission)] });
|
await message.edit({ embeds: [renderCancelledEmbed(mission)], components: [] });
|
||||||
await payload.update({
|
await payload.update({
|
||||||
collection: "missions",
|
collection: "missions",
|
||||||
id: mission.id,
|
id: mission.id,
|
||||||
data: {
|
data: { discordMessageId: null },
|
||||||
discordMessageId: null,
|
|
||||||
discordAttendanceHash: null,
|
|
||||||
discordAttendanceSentAt: null,
|
|
||||||
discordCancelledAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
overrideAccess: true,
|
overrideAccess: true,
|
||||||
});
|
});
|
||||||
payload.logger.info(
|
payload.logger.info(
|
||||||
|
|
@ -545,38 +486,6 @@ 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> => {
|
export const reconcile = async (client: Client, payload: Payload): Promise<void> => {
|
||||||
if (reconcileRunning) return;
|
if (reconcileRunning) return;
|
||||||
reconcileRunning = true;
|
reconcileRunning = true;
|
||||||
|
|
@ -592,19 +501,6 @@ export const reconcile = async (client: Client, payload: Payload): Promise<void>
|
||||||
return;
|
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(
|
const eligible = (await getEligibleMissions(payload)).filter(
|
||||||
(mission) =>
|
(mission) =>
|
||||||
isRollCallDue(mission) || !!mission.discordMessageId || !!mission.discordAttendanceSentAt,
|
isRollCallDue(mission) || !!mission.discordMessageId || !!mission.discordAttendanceSentAt,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,5 @@
|
||||||
import type { CollectionConfig, Payload, PayloadRequest, Where } from "payload";
|
import type { CollectionConfig, Payload, PayloadRequest, Where } from "payload";
|
||||||
import {
|
import { requirePermission, hasPermission, isSuperuser } from "@/utils/access-control/hasPermission";
|
||||||
requirePermission,
|
|
||||||
hasPermission,
|
|
||||||
isSuperuser,
|
|
||||||
} from "@/utils/access-control/hasPermission";
|
|
||||||
|
|
||||||
type AssignmentRef = { id: number } | number;
|
type AssignmentRef = { id: number } | number;
|
||||||
|
|
||||||
|
|
@ -219,15 +215,6 @@ export const Missions: CollectionConfig = {
|
||||||
hidden: true,
|
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",
|
name: "evaluationRemindersSentAt",
|
||||||
type: "date",
|
type: "date",
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,11 +0,0 @@
|
||||||
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";`)
|
|
||||||
}
|
|
||||||
|
|
@ -55,7 +55,6 @@ 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_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_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_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 = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
|
|
@ -341,11 +340,6 @@ export const migrations = [
|
||||||
{
|
{
|
||||||
up: migration_20260911_201559_add_award_advancement_series.up,
|
up: migration_20260911_201559_add_award_advancement_series.up,
|
||||||
down: migration_20260911_201559_add_award_advancement_series.down,
|
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'
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -2461,10 +2461,6 @@ export interface Mission {
|
||||||
* Fingerprint of the attached modlist last announced to Discord (managed by the bot).
|
* Fingerprint of the attached modlist last announced to Discord (managed by the bot).
|
||||||
*/
|
*/
|
||||||
discordModlistHash?: string | null;
|
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).
|
* When evaluation reminder DMs were sent for this mission (managed by the bot, one-shot).
|
||||||
*/
|
*/
|
||||||
|
|
@ -6010,7 +6006,6 @@ export interface MissionsSelect<T extends boolean = true> {
|
||||||
discordAttendanceHash?: T;
|
discordAttendanceHash?: T;
|
||||||
discordAttendanceSentAt?: T;
|
discordAttendanceSentAt?: T;
|
||||||
discordModlistHash?: T;
|
discordModlistHash?: T;
|
||||||
discordCancelledAt?: T;
|
|
||||||
evaluationRemindersSentAt?: T;
|
evaluationRemindersSentAt?: T;
|
||||||
readyReminderWebSentAt?: T;
|
readyReminderWebSentAt?: T;
|
||||||
readyReminderDmSentAt?: T;
|
readyReminderDmSentAt?: T;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue