1
0
Fork 0

Compare commits

..

No commits in common. "7ccd825b8c2caaa2ccbdd4ce6792424d13f4a6c3" and "308a522764306a2dae9fc9b949ecd3dd06f9b5fc" have entirely different histories.

16 changed files with 6 additions and 29569 deletions

View file

@ -323,8 +323,8 @@ Five XP-earning minigames under one parent: routes live at `/minigames/*` (moved
A Discord bot living in `src/bot/`, run as a standalone long-running process via `bun run bot` — it imports `@payload-config` directly (same pattern as `src/scripts/` and `src/tools/seed/`) and shares the Postgres DB with the web app. **Under active development and testing.** **Full product requirements live in `docs/bot/context.md`; the approved implementation design is in `docs/bot/design.md` — read both before touching bot code.** A Discord bot living in `src/bot/`, run as a standalone long-running process via `bun run bot` — it imports `@payload-config` directly (same pattern as `src/scripts/` and `src/tools/seed/`) and shares the Postgres DB with the web app. **Under active development and testing.** **Full product requirements live in `docs/bot/context.md`; the approved implementation design is in `docs/bot/design.md` — read both before touching bot code.**
- **Env / run**: requires `DISCORD_TOKEN` and `DISCORD_GUILD_ID` (fail-fast on missing required vars in `src/bot/config.ts`); optional `DISCORD_OPS_CHANNEL_ID`, `DISCORD_ANNOUNCE_CHANNEL_ID`, `DISCORD_STAFF_ROLE_IDS`, `DISCORD_ATTENDANCE_POLL_MS` (default 60s), `DISCORD_NOTIFICATION_POLL_MS` (default 20s), `DISCORD_EVALUATION_POLL_MS` (default 60s), `DISCORD_IMMINENT_POLL_MS` (default 60s), plus existing `APP_URL`. Logs through `payload.logger`. - **Env / run**: requires `DISCORD_TOKEN` and `DISCORD_GUILD_ID` (fail-fast on missing required vars in `src/bot/config.ts`); optional `DISCORD_OPS_CHANNEL_ID`, `DISCORD_ANNOUNCE_CHANNEL_ID`, `DISCORD_STAFF_ROLE_IDS`, `DISCORD_ATTENDANCE_POLL_MS` (default 60s), `DISCORD_NOTIFICATION_POLL_MS` (default 20s), `DISCORD_EVALUATION_POLL_MS` (default 60s), plus existing `APP_URL`. Logs through `payload.logger`.
- **Structure**: `commands/` — `ping`, `signup`, `link`, `announce`, `remindEvaluations`; `events/interactionCreate.ts` — routes `ptf-att:` RSVP buttons; `services/` — `missionEmbeds` (attendance embed lifecycle + reconcile loop), `notificationBridge` (poll → Discord DMs), `evaluationReminders` (post-mission evaluation reminder DMs), and `imminentReminders` (pre-op thread + attendee pings); `lib/` — `roles.ts` (`isStaff`), `resolve.ts` (discordId ↔ Payload user lookups). Command registration scope: `signup`/`link`/`ping` are **global** (DM-usable — guild-scoped commands never appear in DMs), `announce`/`remind-evaluations` are guild-only. - **Structure**: `commands/` — `ping`, `signup`, `link`, `announce`, `remindEvaluations`; `events/interactionCreate.ts` — routes `ptf-att:` RSVP buttons; `services/` — `missionEmbeds` (attendance embed lifecycle + reconcile loop), `notificationBridge` (poll → Discord DMs), and `evaluationReminders` (post-mission evaluation reminder DMs); `lib/` — `roles.ts` (`isStaff`), `resolve.ts` (discordId ↔ Payload user lookups). Command registration scope: `signup`/`link`/`ping` are **global** (DM-usable — guild-scoped commands never appear in DMs), `announce`/`remind-evaluations` are guild-only.
- **Evaluation reminders**: once a mission is evaluable (`Completed`, or `Scheduled`/`Active` past its start — same rule as `isMissionEvaluable`), the bot sends a one-shot DM (per user with a linked `discordId`) asking them to rate their leadership, plus a "rate your subordinates" section for leaders whose members RSVP'd yes. Links go to each ratee's profile page where the rating dialog lives. Recipient computation: `computeEvaluationReminderPlan` in `src/lib/evaluations/reminders.ts`; state marker: `evaluationRemindersSentAt` on Missions (one-shot, same pattern as `discordAttendanceSentAt`; withheld if the 40-DM/tick cap is hit — retries next tick). These DMs deliberately ignore `preferences.discord.enabled` (defaults false, not yet exposed in the web UI — honoring it would DM nobody). Staff can trigger manually via `/remind-evaluations` (optional `mission` option accepts an ID, name, or code name and re-sends even if the marker is set; omitted, it sweeps all pending missions and reports `{processed, sent}`). - **Evaluation reminders**: once a mission is evaluable (`Completed`, or `Scheduled`/`Active` past its start — same rule as `isMissionEvaluable`), the bot sends a one-shot DM (per user with a linked `discordId`) asking them to rate their leadership, plus a "rate your subordinates" section for leaders whose members RSVP'd yes. Links go to each ratee's profile page where the rating dialog lives. Recipient computation: `computeEvaluationReminderPlan` in `src/lib/evaluations/reminders.ts`; state marker: `evaluationRemindersSentAt` on Missions (one-shot, same pattern as `discordAttendanceSentAt`; withheld if the 40-DM/tick cap is hit — retries next tick). These DMs deliberately ignore `preferences.discord.enabled` (defaults false, not yet exposed in the web UI — honoring it would DM nobody). Staff can trigger manually via `/remind-evaluations` (optional `mission` option accepts an ID, name, or code name and re-sends even if the marker is set; omitted, it sweeps all pending missions and reports `{processed, sent}`).
- **Sign-up / linking (feature 1)**: `/signup` is **DM-only** (the temp password flows through the DM). Creates the Payload user with `username` = `discordUsername` = the caller's Discord username, plus `discordId`, `displayName`, `steamId`, and a random temp password. The ephemeral reply carries the password as the guaranteed delivery path; `interaction.user.send()` is a best-effort persistent copy, so a blocked DM never orphans the account. `/link` works in servers **and** DMs (credential-free, ephemeral reply only) — matches `discordUsername` → sets `discordId`. - **Sign-up / linking (feature 1)**: `/signup` is **DM-only** (the temp password flows through the DM). Creates the Payload user with `username` = `discordUsername` = the caller's Discord username, plus `discordId`, `displayName`, `steamId`, and a random temp password. The ephemeral reply carries the password as the guaranteed delivery path; `interaction.user.send()` is a best-effort persistent copy, so a blocked DM never orphans the account. `/link` works in servers **and** DMs (credential-free, ephemeral reply only) — matches `discordUsername` → sets `discordId`.
- **DM gotcha**: a user with "Allow direct messages from server members" off in Discord privacy settings can neither receive the bot's DMs nor open a DM with the bot. The `/signup` rejection message explains how to enable it. - **DM gotcha**: a user with "Allow direct messages from server members" off in Discord privacy settings can neither receive the bot's DMs nor open a DM with the bot. The `/signup` rejection message explains how to enable it.

View file

@ -129,12 +129,6 @@ Dependency: `discord.js` ^14 (add to root `package.json`). Script: `"bot": "bun
- **Deliberate exception**: these DMs ignore `preferences.discord.enabled` (defaults false, not yet exposed in the web UI — honoring it would DM nobody). Dedicated unit-admin flow, not notification-stream forwarding. - **Deliberate exception**: these DMs ignore `preferences.discord.enabled` (defaults false, not yet exposed in the web UI — honoring it would DM nobody). Dedicated unit-admin flow, not notification-stream forwarding.
- **Manual trigger**: `/remind-evaluations` (staff only, guild-only). Optional `mission` option (mission ID, name, or code name — ambiguous matches list candidates) sends reminders for that one mission even if the marker is already set (as long as it's evaluable); omitted, it runs the reconcile sweep over all pending missions immediately and reports `{processed, sent}`. Replies are ephemeral; the interaction is deferred because DM sending takes minutes at scale. - **Manual trigger**: `/remind-evaluations` (staff only, guild-only). Optional `mission` option (mission ID, name, or code name — ambiguous matches list candidates) sends reminders for that one mission even if the marker is already set (as long as it's evaluable); omitted, it runs the reconcile sweep over all pending missions immediately and reports `{processed, sent}`. Replies are ephemeral; the interaction is deferred because DM sending takes minutes at scale.
### E. Imminent-op reminders (pre-op)
- **Trigger**: a posted op (unit-visible, Ready/Scheduled, live roll-call message) is within 15 minutes of `classification.startDateTime` (`src/lib/intelligence/imminentReminders.ts` holds the constants + query). Poll loop every `DISCORD_IMMINENT_POLL_MS` (default 60s), one-shot via the `imminentReminderSentAt` marker on Missions.
- **Action**: the bot opens a thread on the roll-call message ("Op starting soon: <title>", 100-char cap) and posts a reminder embed with the relative start time (`<t:...:R>`) plus a link to the mission page, pinging every yes-RSVP via `<@discordId>`. Pings ignore `preferences.discord.enabled` (same deliberate exception as evaluation reminders: a yes RSVP is explicit intent to attend). Unlinked attendees are skipped; a thread with no pings is still posted so players have a check-in point.
- **Staleness bound**: a missed reminder still fires within 15 minutes after the start (bot-restart grace), never later; the cancel flow clears the marker so a rescheduled op re-reminds for its new start time.
### Authz (`src/bot/lib/roles.ts`) ### Authz (`src/bot/lib/roles.ts`)
`isStaff(member)`: caller has any `DISCORD_STAFF_ROLE_IDS`, **or** their linked Payload user has roles `admin`/`developer`. RSVP/signup/link are open to all members. The bot performs Payload writes with `overrideAccess` and relies on these checks + its own validations. `isStaff(member)`: caller has any `DISCORD_STAFF_ROLE_IDS`, **or** their linked Payload user has roles `admin`/`developer`. RSVP/signup/link are open to all members. The bot performs Payload writes with `overrideAccess` and relies on these checks + its own validations.
@ -151,7 +145,6 @@ Dependency: `discord.js` ^14 (add to root `package.json`). Script: `"bot": "bun
| `DISCORD_ATTENDANCE_POLL_MS` | no | default 60000 | | `DISCORD_ATTENDANCE_POLL_MS` | no | default 60000 |
| `DISCORD_NOTIFICATION_POLL_MS` | no | default 20000 | | `DISCORD_NOTIFICATION_POLL_MS` | no | default 20000 |
| `DISCORD_EVALUATION_POLL_MS` | no | default 60000 | | `DISCORD_EVALUATION_POLL_MS` | no | default 60000 |
| `DISCORD_IMMINENT_POLL_MS` | no | default 60000 |
`APP_URL` already exists — used for login links in DMs. `APP_URL` already exists — used for login links in DMs.

View file

@ -1,6 +1,6 @@
{ {
"name": "polaris-task-force", "name": "polaris-task-force",
"version": "0.2.42", "version": "0.2.41",
"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",

View file

@ -30,7 +30,6 @@ bot/
missionEmbeds.ts # Attendance embed lifecycle + reconcile loop (poll tick) missionEmbeds.ts # Attendance embed lifecycle + reconcile loop (poll tick)
notificationBridge.ts # Poll user-notifications → Discord DMs notificationBridge.ts # Poll user-notifications → Discord DMs
evaluationReminders.ts # Post-mission evaluation reminder DMs (one-shot marker on Missions) evaluationReminders.ts # Post-mission evaluation reminder DMs (one-shot marker on Missions)
imminentReminders.ts # Pre-op reminder: thread on the roll-call message + attendee pings (one-shot marker on Missions)
signup.ts # Signup service logic signup.ts # Signup service logic
transferRequests.ts # Polls assignment transfer requests awaiting leader decision transferRequests.ts # Polls assignment transfer requests awaiting leader decision
transferDelivery.ts # Transfer leader-request / rejection / info delivery DMs transferDelivery.ts # Transfer leader-request / rejection / info delivery DMs
@ -62,10 +61,6 @@ Bot posts RSVP embeds (Yes/Tentative/No) for future, Ready/Scheduled, visibility
Once a mission is evaluable (`Completed`, or `Scheduled`/`Active` past its start), sends a one-shot DM per linked user asking them to rate their leadership (leaders also get a "rate your subordinates" section). Recipient plan: `computeEvaluationReminderPlan` in `src/lib/evaluations/reminders.ts`; state marker `evaluationRemindersSentAt` on Missions (withheld if the 40-DM/tick cap is hit — retries next tick). DMs ignore `preferences.discord.enabled` (defaults false, not yet exposed in web UI). Staff can re-send manually via `/remind-evaluations`. Once a mission is evaluable (`Completed`, or `Scheduled`/`Active` past its start), sends a one-shot DM per linked user asking them to rate their leadership (leaders also get a "rate your subordinates" section). Recipient plan: `computeEvaluationReminderPlan` in `src/lib/evaluations/reminders.ts`; state marker `evaluationRemindersSentAt` on Missions (withheld if the 40-DM/tick cap is hit — retries next tick). DMs ignore `preferences.discord.enabled` (defaults false, not yet exposed in web UI). Staff can re-send manually via `/remind-evaluations`.
## Feature flow: imminent op reminder
15 minutes before a posted op starts (`visibility: "unit"`, Ready/Scheduled, live roll-call message), `imminentReminders` opens a thread on the roll-call message ("Op starting soon: <name>") and pings every yes-RSVP via `<@discordId>` (pings ignore `preferences.discord.enabled`, same exception as evaluation reminders). One-shot via the `imminentReminderSentAt` marker on Missions; the cancel flow clears it so a rescheduled op re-reminds. Grace bound: a missed reminder still fires within 15 minutes after the start, never later. Query: `src/lib/intelligence/imminentReminders.ts` (window constants + `getImminentReminderMissions`).
## Feature flow: assignment transfers ## Feature flow: assignment transfers
`transferRequests` polls `assignment-transfers` for requests awaiting leader decision; leaders approve/deny via buttons handled in `transferInteractions.ts`; DMs are built by `lib/transferMessaging.ts` and sent through `transferDelivery.ts` (leader request, requester rejection notice, decision info). Web side: `src/lib/transfers/` + `/transfers` page. `transferRequests` polls `assignment-transfers` for requests awaiting leader decision; leaders approve/deny via buttons handled in `transferInteractions.ts`; DMs are built by `lib/transferMessaging.ts` and sent through `transferDelivery.ts` (leader request, requester rejection notice, decision info). Web side: `src/lib/transfers/` + `/transfers` page.
@ -79,7 +74,6 @@ Once a mission is evaluable (`Completed`, or `Scheduled`/`Active` past its start
| Modify embed lifecycle | `bot/services/missionEmbeds.ts` | | Modify embed lifecycle | `bot/services/missionEmbeds.ts` |
| Change DM bridging | `bot/services/notificationBridge.ts` | | Change DM bridging | `bot/services/notificationBridge.ts` |
| Evaluation reminder logic | `bot/services/evaluationReminders.ts` + `src/lib/evaluations/reminders.ts` | | Evaluation reminder logic | `bot/services/evaluationReminders.ts` + `src/lib/evaluations/reminders.ts` |
| Imminent-op reminder logic | `bot/services/imminentReminders.ts` + `src/lib/intelligence/imminentReminders.ts` |
| Transfer workflow | `bot/services/transferRequests.ts` + `lib/transferMessaging.ts` | | Transfer workflow | `bot/services/transferRequests.ts` + `lib/transferMessaging.ts` |
| User lookup patterns | `bot/lib/resolve.ts` | | User lookup patterns | `bot/lib/resolve.ts` |

View file

@ -9,7 +9,6 @@ export interface BotConfig {
notificationPollMs: number; notificationPollMs: number;
evaluationPollMs: number; evaluationPollMs: number;
readyReminderPollMs: number; readyReminderPollMs: number;
imminentReminderPollMs: number;
} }
const required = (name: string): string => { const required = (name: string): string => {
@ -51,5 +50,4 @@ export const botConfig: BotConfig = {
notificationPollMs: pollMs("DISCORD_NOTIFICATION_POLL_MS", 20_000), notificationPollMs: pollMs("DISCORD_NOTIFICATION_POLL_MS", 20_000),
evaluationPollMs: pollMs("DISCORD_EVALUATION_POLL_MS", 60_000), evaluationPollMs: pollMs("DISCORD_EVALUATION_POLL_MS", 60_000),
readyReminderPollMs: pollMs("DISCORD_READY_REMINDER_POLL_MS", 60_000), readyReminderPollMs: pollMs("DISCORD_READY_REMINDER_POLL_MS", 60_000),
imminentReminderPollMs: pollMs("DISCORD_IMMINENT_POLL_MS", 60_000),
}; };

View file

@ -1,129 +0,0 @@
import { EmbedBuilder, type Client, type Message } from "discord.js";
import type { Payload } from "payload";
import type { Mission, User } from "@/payload-types";
import { botConfig } from "@/bot/config";
import { getMissionAttendance } from "@/lib/attendance";
import { missionTitle } from "@/lib/intelligence/readyReminders";
import { getImminentReminderMissions } from "@/lib/intelligence/imminentReminders";
/**
* Imminent-op reminder service: 15 minutes before a posted op starts, open a
* thread on the roll-call message and ping everyone who RSVP'd yes, so the
* attendees get a last-call nudge that the op is imminent.
*
* The pings deliberately ignore `preferences.discord.enabled` (that toggle
* gates the generic notification DM bridge; a yes RSVP is an explicit intent
* to attend). One-shot per mission via the imminentReminderSentAt marker,
* which the cancel flow clears so a rescheduled op re-reminds.
*/
const EMBED_COLOR = 0xf59e0b;
const MISSIONS_PER_TICK = 10;
const THREAD_NAME_MAX = 100;
let reconcileRunning = false;
const errorMessage = (error: unknown): string =>
error instanceof Error ? error.message : "Unknown error";
const renderReminderEmbed = (mission: Mission): EmbedBuilder => {
const startUnix = Math.floor(new Date(mission.classification.startDateTime).getTime() / 1000);
return new EmbedBuilder()
.setTitle(`⏰ Op starting soon: ${missionTitle(mission)}`)
.setDescription(`The operation starts <t:${startUnix}:R>. Load up and check in here.`)
.setColor(EMBED_COLOR)
.addFields({
name: "Open the op",
value: `${botConfig.appUrl}/intelligence/missions/${mission.id}`,
});
};
const mentionsFor = (users: User[]): string[] =>
users
.filter((user) => user.discordId)
.map((user) => `<@${user.discordId as string}>`);
const fetchRollCallMessage = async (
client: Client,
opsChannelId: string,
messageId: string,
): Promise<Message> => {
const channel = await client.channels.fetch(opsChannelId);
if (!channel || !("messages" in channel)) {
throw new Error(`[Bot] Ops channel ${opsChannelId} is not a text channel.`);
}
return channel.messages.fetch(messageId);
};
const processMission = async (
client: Client,
payload: Payload,
mission: Mission,
now: Date,
): Promise<void> => {
const opsChannelId = botConfig.opsChannelId;
if (!opsChannelId || !mission.discordMessageId) return;
const summary = await getMissionAttendance(payload, mission.id);
const message = await fetchRollCallMessage(client, opsChannelId, mission.discordMessageId);
const thread = await message.startThread({
name: `Op starting soon: ${missionTitle(mission)}`.slice(0, THREAD_NAME_MAX),
autoArchiveDuration: 60,
});
const mentions = mentionsFor(summary.yes);
await thread.send({
content: mentions.length > 0 ? mentions.join(" ") : undefined,
embeds: [renderReminderEmbed(mission)],
});
await payload.update({
collection: "missions",
id: mission.id,
data: { imminentReminderSentAt: now.toISOString() },
overrideAccess: true,
});
payload.logger.info(
`[Bot] Posted imminent-op reminder thread for mission #${mission.id} (${mission.name}); pinged ${mentions.length} attendee(s).`,
);
};
export interface ReconcileSummary {
processed: number;
sent: number;
}
export const reconcile = async (client: Client, payload: Payload): Promise<ReconcileSummary> => {
if (reconcileRunning) return { processed: 0, sent: 0 };
if (!botConfig.opsChannelId) return { processed: 0, sent: 0 };
reconcileRunning = true;
let processed = 0;
let sent = 0;
try {
const now = new Date();
const missions = (await getImminentReminderMissions(payload, now)).slice(0, MISSIONS_PER_TICK);
for (const mission of missions) {
processed += 1;
try {
await processMission(client, payload, mission, now);
sent += 1;
} catch (error) {
payload.logger.error(
`[Bot] Imminent reminder failed for mission #${mission.id}: ${errorMessage(error)}`,
);
}
}
} catch (error) {
payload.logger.error(`[Bot] Imminent reminder reconcile failed: ${errorMessage(error)}`);
} finally {
reconcileRunning = false;
}
return { processed, sent };
};
export const start = (client: Client, payload: Payload): void => {
payload.logger.info("[Bot] Imminent reminder service started");
void reconcile(client, payload);
setInterval(() => {
void reconcile(client, payload);
}, botConfig.imminentReminderPollMs);
};

View file

@ -5,7 +5,6 @@ import { start as startNotificationBridge } from "./notificationBridge";
import { start as startTransferRequests } from "./transferRequests"; import { start as startTransferRequests } from "./transferRequests";
import { start as startEvaluationReminders } from "./evaluationReminders"; import { start as startEvaluationReminders } from "./evaluationReminders";
import { start as startReadyReminders } from "./readyReminders"; import { start as startReadyReminders } from "./readyReminders";
import { start as startImminentReminders } from "./imminentReminders";
export type ServiceStarter = (client: Client, payload: Payload) => Promise<void> | void; export type ServiceStarter = (client: Client, payload: Payload) => Promise<void> | void;
@ -15,5 +14,4 @@ export const services: ServiceStarter[] = [
startTransferRequests, startTransferRequests,
startEvaluationReminders, startEvaluationReminders,
startReadyReminders, startReadyReminders,
startImminentReminders,
]; ];

View file

@ -509,9 +509,9 @@ export const reconcileMission = async (
/** /**
* A cancelled op gets its roll-call edited into the cancelled state (buttons * A cancelled op gets its roll-call edited into the cancelled state (buttons
* removed) plus a standalone cancellation notice. The message id, attendance * removed) plus a standalone cancellation notice. The message id, attendance
* hash, one-shot send marker, and imminent-reminder marker are cleared, and * hash, and one-shot send marker are cleared, and `discordCancelledAt` marks
* `discordCancelledAt` marks the cancellation so a later reschedule can be * the cancellation so a later reschedule can be announced and get a fresh
* announced and get a fresh roll-call. * roll-call.
*/ */
const cancelMissionEmbed = async ( const cancelMissionEmbed = async (
client: Client, client: Client,
@ -536,7 +536,6 @@ const cancelMissionEmbed = async (
discordMessageId: null, discordMessageId: null,
discordAttendanceHash: null, discordAttendanceHash: null,
discordAttendanceSentAt: null, discordAttendanceSentAt: null,
imminentReminderSentAt: null,
discordCancelledAt: new Date().toISOString(), discordCancelledAt: new Date().toISOString(),
}, },
overrideAccess: true, overrideAccess: true,

View file

@ -255,15 +255,6 @@ export const Missions: CollectionConfig = {
hidden: true, hidden: true,
}, },
}, },
{
name: "imminentReminderSentAt",
type: "date",
admin: {
description:
"When the imminent-op thread reminder was posted to Discord for this mission (managed by the Discord bot, one-shot; cleared on cancel so a reschedule re-reminds).",
hidden: true,
},
},
{ {
name: "ticketsSettledAt", name: "ticketsSettledAt",
type: "date", type: "date",

View file

@ -1,57 +0,0 @@
import type { Payload } from "payload";
import type { Mission } from "@/payload-types";
/**
* Imminent-op reminder for the Discord bot.
*
* When a posted op (roll-call already live in the ops channel) is 15 minutes
* from starting, the bot opens a thread on the roll-call message and pings
* everyone who RSVP'd yes, as a last-call reminder that the op is imminent.
*
* A one-shot marker on the mission (imminentReminderSentAt) keeps it to a
* single thread per occurrence; the bot's cancel flow clears the marker so a
* rescheduled op reminds again for its new start time.
*
* The shared query lives here (not in the bot) to keep the window logic
* testable and free of discord.js imports, mirroring readyReminders.ts.
*/
/** How far before the start time the reminder fires. */
export const IMMINENT_REMINDER_WINDOW_MS = 15 * 60 * 1000;
/**
* How far past the start time a still-unsent reminder may fire (bounds staleness
* when the bot was down through the whole window).
*/
export const IMMINENT_REMINDER_GRACE_MS = 15 * 60 * 1000;
/**
* Ops due for the imminent reminder: unit-visible, Ready/Scheduled, with a
* live roll-call message, starting within the reminder window (or just past
* it, within the grace bound), and without the one-shot marker yet.
*/
export const getImminentReminderMissions = async (
payload: Payload,
now = new Date(),
): Promise<Mission[]> => {
const windowStartIso = new Date(now.getTime() - IMMINENT_REMINDER_GRACE_MS).toISOString();
const windowEndIso = new Date(now.getTime() + IMMINENT_REMINDER_WINDOW_MS).toISOString();
const res = await payload.find({
collection: "missions",
overrideAccess: true,
depth: 0,
sort: "classification.startDateTime",
limit: 50,
where: {
and: [
{ "ownershipAndStatus.visibility": { equals: "unit" } },
{ "ownershipAndStatus.status": { in: ["Ready", "Scheduled"] } },
{ discordMessageId: { exists: true } },
{ imminentReminderSentAt: { exists: false } },
{ "classification.startDateTime": { greater_than: windowStartIso } },
{ "classification.startDateTime": { less_than_equal: windowEndIso } },
],
},
});
return res.docs as unknown as Mission[];
};

File diff suppressed because it is too large Load diff

View file

@ -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 "imminent_reminder_sent_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 "imminent_reminder_sent_at";`)
}

View file

@ -65,7 +65,6 @@ import * as migration_20260916_185544_construction_timestamp_model from "./20260
import * as migration_20260916_220224_add_map_labels_and_styling from "./20260916_220224_add_map_labels_and_styling"; import * as migration_20260916_220224_add_map_labels_and_styling from "./20260916_220224_add_map_labels_and_styling";
import * as migration_20260917_023028_add_node_and_zone_resources from "./20260917_023028_add_node_and_zone_resources"; import * as migration_20260917_023028_add_node_and_zone_resources from "./20260917_023028_add_node_and_zone_resources";
import * as migration_20260917_112258_add_zone_political_types from "./20260917_112258_add_zone_political_types"; import * as migration_20260917_112258_add_zone_political_types from "./20260917_112258_add_zone_political_types";
import * as migration_20260918_170318_add_imminent_reminder_marker from "./20260918_170318_add_imminent_reminder_marker";
export const migrations = [ export const migrations = [
{ {
@ -403,9 +402,4 @@ export const migrations = [
down: migration_20260917_112258_add_zone_political_types.down, down: migration_20260917_112258_add_zone_political_types.down,
name: "20260917_112258_add_zone_political_types", name: "20260917_112258_add_zone_political_types",
}, },
{
up: migration_20260918_170318_add_imminent_reminder_marker.up,
down: migration_20260918_170318_add_imminent_reminder_marker.down,
name: "20260918_170318_add_imminent_reminder_marker",
},
]; ];

View file

@ -2602,11 +2602,6 @@ export const missions = pgTable(
withTimezone: true, withTimezone: true,
precision: 3, precision: 3,
}), }),
imminentReminderSentAt: timestamp("imminent_reminder_sent_at", {
mode: "string",
withTimezone: true,
precision: 3,
}),
ticketsSettledAt: timestamp("tickets_settled_at", { ticketsSettledAt: timestamp("tickets_settled_at", {
mode: "string", mode: "string",
withTimezone: true, withTimezone: true,

View file

@ -2592,10 +2592,6 @@ export interface Mission {
* Server-local day the last 'not Ready' reminder DM was sent to the op creator (managed by the Discord bot, once per day). * Server-local day the last 'not Ready' reminder DM was sent to the op creator (managed by the Discord bot, once per day).
*/ */
readyReminderDmSentAt?: string | null; readyReminderDmSentAt?: string | null;
/**
* When the imminent-op thread reminder was posted to Discord for this mission (managed by the Discord bot, one-shot; cleared on cancel so a reschedule re-reminds).
*/
imminentReminderSentAt?: string | null;
/** /**
* When respawn tickets for this mission were settled into player balances (managed by the arma-bridge tickets route; one-shot per mission). * When respawn tickets for this mission were settled into player balances (managed by the arma-bridge tickets route; one-shot per mission).
*/ */
@ -6406,7 +6402,6 @@ export interface MissionsSelect<T extends boolean = true> {
evaluationRemindersSentAt?: T; evaluationRemindersSentAt?: T;
readyReminderWebSentAt?: T; readyReminderWebSentAt?: T;
readyReminderDmSentAt?: T; readyReminderDmSentAt?: T;
imminentReminderSentAt?: T;
ticketsSettledAt?: T; ticketsSettledAt?: T;
createdBy?: T; createdBy?: T;
coverImage?: T; coverImage?: T;

View file

@ -1,214 +0,0 @@
import { getPayload, Payload } from "payload";
import config from "@/payload.config";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { Campaign, Map as MissionMap, Mission, User } from "@/payload-types";
import {
IMMINENT_REMINDER_GRACE_MS,
IMMINENT_REMINDER_WINDOW_MS,
getImminentReminderMissions,
} from "@/lib/intelligence/imminentReminders";
let payload: Payload;
const RUN = `imr-${Date.now().toString(36)}`;
describe("Imminent-op reminder query", () => {
let mapId: number;
let campaignId: number;
let authorId: number;
const missionIds: number[] = [];
beforeAll(async () => {
const payloadConfig = await config;
payload = await getPayload({ config: payloadConfig });
const map = (await payload.create({
collection: "maps",
data: { name: `${RUN} Map`, worldSizeWidth: 8192, worldSizeHeight: 8192, basemapMode: "image" },
overrideAccess: true,
depth: 0,
})) as unknown as MissionMap;
mapId = map.id;
const campaign = (await payload.create({
collection: "campaigns",
data: { name: `${RUN} Campaign`, summary: "Imminent reminder test campaign", status: "concept", campaignMode: "custom" },
overrideAccess: true,
depth: 0,
})) as unknown as Campaign;
campaignId = campaign.id;
const author = (await payload.create({
collection: "users",
data: {
username: `${RUN}-author`,
discordUsername: `${RUN}-author`,
displayName: "AUTHOR",
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
password: "Test123",
roles: ["user"],
},
overrideAccess: true,
depth: 0,
})) as unknown as User;
authorId = author.id;
});
afterAll(async () => {
for (const id of missionIds) {
await payload.delete({ collection: "missions", id, overrideAccess: true }).catch(() => {});
}
await payload
.delete({ collection: "campaigns", id: campaignId, overrideAccess: true })
.catch(() => {});
await payload.delete({ collection: "maps", id: mapId, overrideAccess: true }).catch(() => {});
// User deletion trips FK constraints unless the hook-provisioned
// personal bank account and profile are removed first.
const accounts = await payload
.find({
collection: "bank-accounts",
where: { ownerUser: { equals: authorId } },
limit: 5,
depth: 0,
overrideAccess: true,
})
.catch(() => null);
for (const account of accounts?.docs ?? []) {
await payload
.delete({ collection: "bank-accounts", id: account.id, overrideAccess: true })
.catch(() => {});
}
const profiles = await payload
.find({
collection: "profiles",
where: { user: { equals: authorId } },
limit: 5,
depth: 0,
overrideAccess: true,
})
.catch(() => null);
for (const profile of profiles?.docs ?? []) {
await payload
.delete({ collection: "profiles", id: profile.id, overrideAccess: true })
.catch(() => {});
}
await payload
.delete({ collection: "users", id: authorId, overrideAccess: true })
.catch(() => {});
});
const makeMission = async (
label: string,
overrides: {
status?: Mission["ownershipAndStatus"]["status"];
visibility?: Mission["ownershipAndStatus"]["visibility"];
start: Date;
withRollCall?: boolean;
marked?: boolean;
},
): Promise<Mission> => {
const mission = (await payload.create({
collection: "missions",
data: {
name: `${RUN} ${label}`,
codeName: `${RUN}-${label}`,
summary: "Imminent reminder boundary test mission",
operationType: "main",
classification: {
map: mapId,
missionType: "PvE",
campaign: campaignId,
startDateTime: overrides.start.toISOString(),
estimatedDuration: 60,
},
ownershipAndStatus: {
authors: [authorId],
status: overrides.status ?? "Scheduled",
visibility: overrides.visibility ?? "unit",
},
missionRoles: {
maxPlayers: 16,
},
gameDetails: {
serverDetails: {
serverIp: "127.0.0.1",
serverPort: 2302,
},
},
briefing: [],
...(overrides.withRollCall === false
? {}
: { discordMessageId: `${RUN}-${label}-message` }),
...(overrides.marked ? { imminentReminderSentAt: new Date().toISOString() } : {}),
},
overrideAccess: true,
depth: 0,
})) as unknown as Mission;
missionIds.push(mission.id);
return mission;
};
it("finds posted Ready/Scheduled ops starting inside the 15 minute window", async () => {
const now = new Date();
const start = new Date(now.getTime() + IMMINENT_REMINDER_WINDOW_MS - 60_000);
const ready = await makeMission("in-window-ready", { status: "Ready", start });
const scheduled = await makeMission("in-window-scheduled", { status: "Scheduled", start });
const found = await getImminentReminderMissions(payload, now);
expect(found.map((m) => m.id)).toContain(ready.id);
expect(found.map((m) => m.id)).toContain(scheduled.id);
});
it("includes the exact window edge and the grace zone just past the start", async () => {
const now = new Date();
const edge = await makeMission("window-edge", {
start: new Date(now.getTime() + IMMINENT_REMINDER_WINDOW_MS),
});
const justStarted = await makeMission("grace-zone", {
start: new Date(now.getTime() - 5 * 60_000),
});
const found = await getImminentReminderMissions(payload, now);
expect(found.map((m) => m.id)).toContain(edge.id);
expect(found.map((m) => m.id)).toContain(justStarted.id);
});
it("excludes ops outside the window, past the grace bound, marked, without a roll-call, or not unit-visible", async () => {
const now = new Date();
const tooEarly = await makeMission("too-early", {
start: new Date(now.getTime() + IMMINENT_REMINDER_WINDOW_MS + 60_000),
});
const tooLate = await makeMission("too-late", {
start: new Date(now.getTime() - IMMINENT_REMINDER_GRACE_MS - 60_000),
});
const alreadyReminded = await makeMission("already-reminded", {
start: new Date(now.getTime() + 10 * 60_000),
marked: true,
});
const noRollCall = await makeMission("no-roll-call", {
start: new Date(now.getTime() + 10 * 60_000),
withRollCall: false,
});
const notUnitVisible = await makeMission("not-unit-visible", {
start: new Date(now.getTime() + 10 * 60_000),
visibility: "leadership",
});
const draftStatus = await makeMission("draft-status", {
status: "Planning",
start: new Date(now.getTime() + 10 * 60_000),
});
const foundIds = (await getImminentReminderMissions(payload, now)).map((m) => m.id);
for (const excluded of [
tooEarly,
tooLate,
alreadyReminded,
noRollCall,
notUnitVisible,
draftStatus,
]) {
expect(foundIds).not.toContain(excluded.id);
}
});
});