Compare commits
4 commits
308a522764
...
7ccd825b8c
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ccd825b8c | |||
| 8926cd182b | |||
| 8f7573f742 | |||
| 35769ea0f6 |
16 changed files with 29569 additions and 6 deletions
|
|
@ -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.**
|
||||
|
||||
- **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), 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.
|
||||
- **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`.
|
||||
- **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.
|
||||
- **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`.
|
||||
- **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.
|
||||
|
|
|
|||
|
|
@ -129,6 +129,12 @@ 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.
|
||||
- **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`)
|
||||
|
||||
`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.
|
||||
|
|
@ -145,6 +151,7 @@ Dependency: `discord.js` ^14 (add to root `package.json`). Script: `"bot": "bun
|
|||
| `DISCORD_ATTENDANCE_POLL_MS` | no | default 60000 |
|
||||
| `DISCORD_NOTIFICATION_POLL_MS` | no | default 20000 |
|
||||
| `DISCORD_EVALUATION_POLL_MS` | no | default 60000 |
|
||||
| `DISCORD_IMMINENT_POLL_MS` | no | default 60000 |
|
||||
|
||||
`APP_URL` already exists — used for login links in DMs.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "polaris-task-force",
|
||||
"version": "0.2.41",
|
||||
"version": "0.2.42",
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ bot/
|
|||
missionEmbeds.ts # Attendance embed lifecycle + reconcile loop (poll tick)
|
||||
notificationBridge.ts # Poll user-notifications → Discord DMs
|
||||
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
|
||||
transferRequests.ts # Polls assignment transfer requests awaiting leader decision
|
||||
transferDelivery.ts # Transfer leader-request / rejection / info delivery DMs
|
||||
|
|
@ -61,6 +62,10 @@ 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`.
|
||||
|
||||
## 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
|
||||
|
||||
`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.
|
||||
|
|
@ -74,6 +79,7 @@ Once a mission is evaluable (`Completed`, or `Scheduled`/`Active` past its start
|
|||
| Modify embed lifecycle | `bot/services/missionEmbeds.ts` |
|
||||
| Change DM bridging | `bot/services/notificationBridge.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` |
|
||||
| User lookup patterns | `bot/lib/resolve.ts` |
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export interface BotConfig {
|
|||
notificationPollMs: number;
|
||||
evaluationPollMs: number;
|
||||
readyReminderPollMs: number;
|
||||
imminentReminderPollMs: number;
|
||||
}
|
||||
|
||||
const required = (name: string): string => {
|
||||
|
|
@ -50,4 +51,5 @@ export const botConfig: BotConfig = {
|
|||
notificationPollMs: pollMs("DISCORD_NOTIFICATION_POLL_MS", 20_000),
|
||||
evaluationPollMs: pollMs("DISCORD_EVALUATION_POLL_MS", 60_000),
|
||||
readyReminderPollMs: pollMs("DISCORD_READY_REMINDER_POLL_MS", 60_000),
|
||||
imminentReminderPollMs: pollMs("DISCORD_IMMINENT_POLL_MS", 60_000),
|
||||
};
|
||||
|
|
|
|||
129
src/bot/services/imminentReminders.ts
Normal file
129
src/bot/services/imminentReminders.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
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);
|
||||
};
|
||||
|
|
@ -5,6 +5,7 @@ import { start as startNotificationBridge } from "./notificationBridge";
|
|||
import { start as startTransferRequests } from "./transferRequests";
|
||||
import { start as startEvaluationReminders } from "./evaluationReminders";
|
||||
import { start as startReadyReminders } from "./readyReminders";
|
||||
import { start as startImminentReminders } from "./imminentReminders";
|
||||
|
||||
export type ServiceStarter = (client: Client, payload: Payload) => Promise<void> | void;
|
||||
|
||||
|
|
@ -14,4 +15,5 @@ export const services: ServiceStarter[] = [
|
|||
startTransferRequests,
|
||||
startEvaluationReminders,
|
||||
startReadyReminders,
|
||||
startImminentReminders,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -509,9 +509,9 @@ export const reconcileMission = async (
|
|||
/**
|
||||
* 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.
|
||||
* hash, one-shot send marker, and imminent-reminder 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,
|
||||
|
|
@ -536,6 +536,7 @@ const cancelMissionEmbed = async (
|
|||
discordMessageId: null,
|
||||
discordAttendanceHash: null,
|
||||
discordAttendanceSentAt: null,
|
||||
imminentReminderSentAt: null,
|
||||
discordCancelledAt: new Date().toISOString(),
|
||||
},
|
||||
overrideAccess: true,
|
||||
|
|
|
|||
|
|
@ -255,6 +255,15 @@ export const Missions: CollectionConfig = {
|
|||
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",
|
||||
type: "date",
|
||||
|
|
|
|||
57
src/lib/intelligence/imminentReminders.ts
Normal file
57
src/lib/intelligence/imminentReminders.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
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[];
|
||||
};
|
||||
29109
src/migrations/20260918_170318_add_imminent_reminder_marker.json
Normal file
29109
src/migrations/20260918_170318_add_imminent_reminder_marker.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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 "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";`)
|
||||
}
|
||||
|
|
@ -65,6 +65,7 @@ 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_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_20260918_170318_add_imminent_reminder_marker from "./20260918_170318_add_imminent_reminder_marker";
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -402,4 +403,9 @@ export const migrations = [
|
|||
down: migration_20260917_112258_add_zone_political_types.down,
|
||||
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",
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -2602,6 +2602,11 @@ export const missions = pgTable(
|
|||
withTimezone: true,
|
||||
precision: 3,
|
||||
}),
|
||||
imminentReminderSentAt: timestamp("imminent_reminder_sent_at", {
|
||||
mode: "string",
|
||||
withTimezone: true,
|
||||
precision: 3,
|
||||
}),
|
||||
ticketsSettledAt: timestamp("tickets_settled_at", {
|
||||
mode: "string",
|
||||
withTimezone: true,
|
||||
|
|
|
|||
|
|
@ -2592,6 +2592,10 @@ 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).
|
||||
*/
|
||||
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).
|
||||
*/
|
||||
|
|
@ -6402,6 +6406,7 @@ export interface MissionsSelect<T extends boolean = true> {
|
|||
evaluationRemindersSentAt?: T;
|
||||
readyReminderWebSentAt?: T;
|
||||
readyReminderDmSentAt?: T;
|
||||
imminentReminderSentAt?: T;
|
||||
ticketsSettledAt?: T;
|
||||
createdBy?: T;
|
||||
coverImage?: T;
|
||||
|
|
|
|||
214
tests/int/imminent-reminders.int.spec.ts
Normal file
214
tests/int/imminent-reminders.int.spec.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue