1
0
Fork 0

feat(missions): daily ready-check reminders for unfinished side ops

Five days before a player-created side op starts, if it is not yet Ready, the creator gets one in-app notification per day (via mission-tick) plus one Discord DM per day (new bot service). Per-server-local-day dedupe via hidden readyReminderWebSentAt/readyReminderDmSentAt markers. The ready-reminder type is excluded from the notification bridge to avoid double-DMing. Markers are nulled on clone.
This commit is contained in:
Jason Fraley 2026-09-07 05:07:46 -04:00
parent ffb2c2c5d9
commit d757c58c5d
12 changed files with 23682 additions and 88 deletions

View file

@ -8,6 +8,7 @@ export interface BotConfig {
attendancePollMs: number;
notificationPollMs: number;
evaluationPollMs: number;
readyReminderPollMs: number;
}
const required = (name: string): string => {
@ -48,4 +49,5 @@ export const botConfig: BotConfig = {
attendancePollMs: pollMs("DISCORD_ATTENDANCE_POLL_MS", 60_000),
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),
};

View file

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

View file

@ -3,14 +3,19 @@ import type { Payload } from "payload";
import type { User, UserNotification } from "@/payload-types";
import { botConfig } from "@/bot/config";
import { TRANSFER_NOTIFICATION_TYPES } from "@/lib/transfers/notificationTypes";
import { READY_REMINDER_NOTIFICATION_TYPE } from "@/lib/intelligence/readyReminders";
const MAX_SENDS_PER_TICK = 5;
const SEND_DELAY_MS = 300;
const EMBED_COLOR = 0x3b82f6;
// Transfer notifications are delivered by the dedicated transfer bot service
// (Phase 2), not this generic bridge. Exclude exactly these types.
const NON_BRIDGED_TYPES = new Set<string>(Object.values(TRANSFER_NOTIFICATION_TYPES));
// (Phase 2), not this generic bridge. Ready reminders are delivered by the
// dedicated readyReminders DM service. Exclude exactly these types.
const NON_BRIDGED_TYPES = new Set<string>([
...Object.values(TRANSFER_NOTIFICATION_TYPES),
READY_REMINDER_NOTIFICATION_TYPE,
]);
let cursor: number | null = null;

View file

@ -0,0 +1,130 @@
import { type Client, EmbedBuilder } from "discord.js";
import type { Payload } from "payload";
import type { Mission, User } from "@/payload-types";
import { botConfig } from "@/bot/config";
import {
READY_REMINDER_LEAD_DAYS,
daysUntil,
getReadyReminderMissions,
isReminderDue,
missionTitle,
} from "@/lib/intelligence/readyReminders";
/**
* Ready-reminder service: starting READY_REMINDER_LEAD_DAYS days before a
* player-scheduled side op, the op's creator gets a once-per-day DM nudging
* them to finish the op and mark it "Ready" — the status that posts the
* roll-call, locks in details, and delivers the mission hooks.
*
* The DM deliberately ignores `preferences.discord.enabled` (the notification
* bridge opt-in): that toggle defaults to false and is not yet exposed in the
* web UI, so honoring it here would silently DM nobody. This is a dedicated
* creator-prompt flow, not a forwarding of the generic notification stream
* (the web half of each reminder is excluded from the bridge via
* NON_BRIDGED_TYPES). A per-day marker on the mission (readyReminderDmSentAt)
* keeps the cadence to one DM per day.
*/
const EMBED_COLOR = 0xf59e0b;
const MISSIONS_PER_TICK = 10;
const SEND_DELAY_MS = 300;
let reconcileRunning = false;
const errorMessage = (error: unknown): string =>
error instanceof Error ? error.message : "Unknown error";
const creatorIdOf = (mission: Mission): number | null =>
typeof mission.createdBy === "object" ? (mission.createdBy?.id ?? null) : (mission.createdBy ?? null);
const renderReminderEmbed = (mission: Mission, now: Date): EmbedBuilder => {
const days = daysUntil(mission.classification.startDateTime, now);
const when = days === 0 ? "today" : days === 1 ? "tomorrow" : `in ${days} days`;
return new EmbedBuilder()
.setTitle(`⏰ Ready check: ${missionTitle(mission)}`)
.setDescription(
`Your op starts **${when}** and it isn't marked **Ready** yet.\n\n` +
"Finish up the operation and set the mission status to **Ready** so the roll-call can be posted, the details can be locked in, and all the proper mission hooks can be delivered.",
)
.setColor(EMBED_COLOR)
.addFields({
name: "Open the op",
value: `${botConfig.appUrl}/intelligence/missions/${mission.id}`,
})
.setFooter({ text: "You'll get this reminder once a day until the op is Ready." });
};
const processMission = async (
client: Client,
payload: Payload,
mission: Mission,
now: Date,
): Promise<boolean> => {
const creatorId = creatorIdOf(mission);
if (!creatorId) return false;
const creator = (await payload.findByID({
collection: "users",
id: creatorId,
depth: 0,
overrideAccess: true,
})) as unknown as User;
if (!creator.discordId) return false;
const target = await client.users.fetch(creator.discordId);
await target.send({ embeds: [renderReminderEmbed(mission, now)] });
await payload.update({
collection: "missions",
id: mission.id,
data: { readyReminderDmSentAt: now.toISOString() },
overrideAccess: true,
});
payload.logger.info(
`[Bot] Sent ready reminder DM for mission #${mission.id} (${mission.name}) to user #${creatorId}.`,
);
return true;
};
export interface ReconcileSummary {
processed: number;
sent: number;
}
export const reconcile = async (client: Client, payload: Payload): Promise<ReconcileSummary> => {
if (reconcileRunning) return { processed: 0, sent: 0 };
reconcileRunning = true;
let processed = 0;
let sent = 0;
try {
const now = new Date();
const missions = (await getReadyReminderMissions(payload, now)).slice(0, MISSIONS_PER_TICK);
for (const mission of missions) {
if (!isReminderDue(mission.readyReminderDmSentAt, now)) continue;
processed += 1;
try {
if (await processMission(client, payload, mission, now)) {
sent += 1;
await new Promise((resolve) => setTimeout(resolve, SEND_DELAY_MS));
}
} catch (error) {
payload.logger.error(
`[Bot] Ready reminder failed for mission #${mission.id}: ${errorMessage(error)}`,
);
}
}
} catch (error) {
payload.logger.error(`[Bot] Ready reminder reconcile failed: ${errorMessage(error)}`);
} finally {
reconcileRunning = false;
}
return { processed, sent };
};
export const start = (client: Client, payload: Payload): void => {
payload.logger.info("[Bot] Ready reminder service started");
void reconcile(client, payload);
setInterval(() => {
void reconcile(client, payload);
}, botConfig.readyReminderPollMs);
};

View file

@ -216,6 +216,24 @@ export const Missions: CollectionConfig = {
hidden: true,
},
},
{
name: "readyReminderWebSentAt",
type: "date",
admin: {
description:
"Server-local day the last web 'not Ready' reminder notification was sent to the op creator (managed by the mission tick, once per day).",
hidden: true,
},
},
{
name: "readyReminderDmSentAt",
type: "date",
admin: {
description:
"Server-local day the last 'not Ready' reminder DM was sent to the op creator (managed by the Discord bot, once per day).",
hidden: true,
},
},
{
name: "createdBy",
type: "relationship",

View file

@ -0,0 +1,138 @@
import type { Payload } from "payload";
import type { Mission } from "@/payload-types";
import { notifyUser } from "@/lib/notifications";
/**
* Ready-reminder system for player-scheduled side ops.
*
* Starting READY_REMINDER_LEAD_DAYS days before an op's scheduled start, the
* creator gets a once-per-day reminder (web notification + Discord DM) to
* finish the op and mark it "Ready" — the status that triggers the roll-call
* embed, locks in details, and delivers the mission hooks. Reminders stop the
* moment the op is Ready (or cancelled/completed/active), and a marker field
* records the last server-local day each channel was sent so the cadence is
* exactly one reminder per day per channel.
*
* Scope: only ops with a `createdBy` (player-scheduled via the Friday Ops
* calendar — always `operationType: "side"`). Staff/admin-created missions are
* never reminded.
*
* Shared by the mission-tick bin (web notifications) and the Discord bot's
* readyReminders service (DMs) — keep this module free of discord.js imports.
*/
/** How many days before the op the daily reminders begin. */
export const READY_REMINDER_LEAD_DAYS = 5;
/** Notification type used for the web-app reminder (labels + bridge exclusion). */
export const READY_REMINDER_NOTIFICATION_TYPE = "mission:ready-reminder";
/**
* Statuses that still need the creator's attention. "Ready" is the committed
* state the reminders push toward; Active/Completed/Cancelled are terminal or
* already underway.
*/
export const READY_REMINDER_ELIGIBLE_STATUSES = ["Concept", "Planning", "Scheduled"] as const;
export const missionTitle = (mission: Mission): string =>
mission.codeName ? `${mission.name} (${mission.codeName})` : mission.name;
/** Server-local YYYY-MM-DD key used for the once-per-day cadence. */
export const dayKey = (date: Date): string =>
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(
date.getDate(),
).padStart(2, "0")}`;
/**
* A channel is due when it has never sent, or last sent on a previous
* server-local day. Day-based (not 24h-based) so retries within the same day
* stay deduped, and a status flapping Ready → Scheduled resumes reminders the
* next day instead of being suppressed forever.
*/
export const isReminderDue = (marker: string | null | undefined, now: Date): boolean => {
if (!marker) return true;
const markerDate = new Date(marker);
if (Number.isNaN(markerDate.getTime())) return true;
return dayKey(markerDate) !== dayKey(now);
};
export const daysUntil = (startIso: string, now: Date): number =>
Math.max(0, Math.ceil((new Date(startIso).getTime() - now.getTime()) / 86_400_000));
/**
* Ops due for reminders: player-created (createdBy set), still not Ready /
* terminal, and starting within the lead window (future start, at most
* READY_REMINDER_LEAD_DAYS days out). Marker filtering is done per-channel by
* the caller via isReminderDue — the "not sent today" condition can't be
* expressed in a single where clause.
*/
export const getReadyReminderMissions = async (payload: Payload, now = new Date()): Promise<Mission[]> => {
const nowIso = now.toISOString();
const windowEndIso = new Date(
now.getTime() + READY_REMINDER_LEAD_DAYS * 86_400_000,
).toISOString();
const res = await payload.find({
collection: "missions",
overrideAccess: true,
depth: 0,
sort: "classification.startDateTime",
limit: 50,
where: {
and: [
{ createdBy: { exists: true } },
{ "ownershipAndStatus.status": { in: [...READY_REMINDER_ELIGIBLE_STATUSES] } },
{ "classification.startDateTime": { greater_than: nowIso } },
{ "classification.startDateTime": { less_than_equal: windowEndIso } },
],
},
});
return res.docs as unknown as Mission[];
};
export interface ReadyReminderSummary {
missions: number;
notified: number;
}
/**
* Web sweep: one in-app notification per due mission per server-local day.
* Called from the mission-tick bin. Respects each user's notification mutes
* (notifyUser handles suppression) and stamps the per-day marker.
*/
export const sendWebReadyReminders = async (
payload: Payload,
now = new Date(),
): Promise<ReadyReminderSummary> => {
const missions = await getReadyReminderMissions(payload, now);
let notified = 0;
for (const mission of missions) {
if (!isReminderDue(mission.readyReminderWebSentAt, now)) continue;
if (mission.createdBy == null) continue;
const creatorId =
typeof mission.createdBy === "object" ? mission.createdBy.id : mission.createdBy;
if (!creatorId) continue;
const days = daysUntil(mission.classification.startDateTime, now);
const when =
days === 0
? "today"
: days === 1
? "tomorrow"
: `in ${days} days`;
await notifyUser(payload, {
userId: creatorId,
type: READY_REMINDER_NOTIFICATION_TYPE,
title: `Ready check: ${missionTitle(mission)}`,
message: `Your op starts ${when}. Finish up the details and set the mission status to Ready so the roll-call can be posted and the mission hooks delivered.`,
link: `/intelligence/missions/${mission.id}`,
});
await payload.update({
collection: "missions",
id: mission.id,
data: { readyReminderWebSentAt: now.toISOString() },
overrideAccess: true,
});
notified += 1;
}
return { missions: missions.length, notified };
};

View file

@ -91,6 +91,8 @@ export function notificationLabel(type?: string | null): string {
return "New reply";
case "ticket:status":
return "Ticket updated";
case "mission:ready-reminder":
return "Ready reminder";
case "assignment-transfer:request":
return "Transfer request";
case "assignment-transfer:approved":

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,13 @@
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 "ready_reminder_web_sent_at" timestamp(3) with time zone;
ALTER TABLE "missions" ADD COLUMN "ready_reminder_dm_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 "ready_reminder_web_sent_at";
ALTER TABLE "missions" DROP COLUMN "ready_reminder_dm_sent_at";`)
}

View file

@ -1,261 +1,267 @@
import * as migration_20260820_004157 from "./20260820_004157";
import * as migration_20260825_220000 from "./20260825_220000";
import * as migration_20260826_050000_repair_shims_auxiliary_tables from "./20260826_050000_repair_shims_auxiliary_tables";
import * as migration_20260826_060000_add_game_servers_event_target from "./20260826_060000_add_game_servers_event_target";
import * as migration_20260827_005757 from "./20260827_005757";
import * as migration_20260827_022334_add_eod_minesweeper_best_flags from "./20260827_022334_add_eod_minesweeper_best_flags";
import * as migration_20260827_213857_add_evaluations_collection from "./20260827_213857_add_evaluations_collection";
import * as migration_20260827_233224_add_mission_completed_at from "./20260827_233224_add_mission_completed_at";
import * as migration_20260828_011500_add_background_texture_preference from "./20260828_011500_add_background_texture_preference";
import * as migration_20260828_041615_add_campaign_mode from "./20260828_041615_add_campaign_mode";
import * as migration_20260828_044506 from "./20260828_044506";
import * as migration_20260831_142503 from "./20260831_142503";
import * as migration_20260831_180233_assignment_transfers from "./20260831_180233_assignment_transfers";
import * as migration_20260901_014006_add_assignment_transfer_event_target from "./20260901_014006_add_assignment_transfer_event_target";
import * as migration_20260901_213108_add_ticket_votes_and_edit_history from "./20260901_213108_add_ticket_votes_and_edit_history";
import * as migration_20260901_222200_add_admin_page_permissions from "./20260901_222200_add_admin_page_permissions";
import * as migration_20260902_195336_add_medal_backgrounds from "./20260902_195336_add_medal_backgrounds";
import * as migration_20260903_035544 from "./20260903_035544";
import * as migration_20260903_044047 from "./20260903_044047";
import * as migration_20260903_053419 from "./20260903_053419";
import * as migration_20260903_060000_add_radio_mode_stats from "./20260903_060000_add_radio_mode_stats";
import * as migration_20260903_061000_add_radio_incorrect_counts from "./20260903_061000_add_radio_incorrect_counts";
import * as migration_20260903_062000_add_aim_trainer_stats from "./20260903_062000_add_aim_trainer_stats";
import * as migration_20260904_230838_add_wiki_collections from "./20260904_230838_add_wiki_collections";
import * as migration_20260905_183940_base_management from "./20260905_183940_base_management";
import * as migration_20260905_195456_labor_headcount from "./20260905_195456_labor_headcount";
import * as migration_20260905_210310_labor_classifications from "./20260905_210310_labor_classifications";
import * as migration_20260905_213824 from "./20260905_213824";
import * as migration_20260905_215658 from "./20260905_215658";
import * as migration_20260906_045502 from "./20260906_045502";
import * as migration_20260906_050900_add_evaluation_reminders_field from "./20260906_050900_add_evaluation_reminders_field";
import * as migration_20260906_061451_add_wiki_template_css from "./20260906_061451_add_wiki_template_css";
import * as migration_20260906_063753_add_personal_excerpt_richtext from "./20260906_063753_add_personal_excerpt_richtext";
import * as migration_20260906_065405_add_wiki_template_html from "./20260906_065405_add_wiki_template_html";
import * as migration_20260906_100908_add_market_economy_state from "./20260906_100908_add_market_economy_state";
import * as migration_20260906_165837_add_market_state_event_target from "./20260906_165837_add_market_state_event_target";
import * as migration_20260906_202925_add_voice_submissions from "./20260906_202925_add_voice_submissions";
import * as migration_20260907_012326_add_missions_created_by from "./20260907_012326_add_missions_created_by";
import * as migration_20260907_013724_make_missions_campaign_optional from "./20260907_013724_make_missions_campaign_optional";
import * as migration_20260907_041051_add_voice_script_spoiler from "./20260907_041051_add_voice_script_spoiler";
import * as migration_20260907_043339_add_voice_script_description from "./20260907_043339_add_voice_script_description";
import * as migration_20260907_044934_add_missions_discord_modlist_hash from "./20260907_044934_add_missions_discord_modlist_hash";
import * as migration_20260907_083010_add_voice_submission_description from "./20260907_083010_add_voice_submission_description";
import * as migration_20260820_004157 from './20260820_004157';
import * as migration_20260825_220000 from './20260825_220000';
import * as migration_20260826_050000_repair_shims_auxiliary_tables from './20260826_050000_repair_shims_auxiliary_tables';
import * as migration_20260826_060000_add_game_servers_event_target from './20260826_060000_add_game_servers_event_target';
import * as migration_20260827_005757 from './20260827_005757';
import * as migration_20260827_022334_add_eod_minesweeper_best_flags from './20260827_022334_add_eod_minesweeper_best_flags';
import * as migration_20260827_213857_add_evaluations_collection from './20260827_213857_add_evaluations_collection';
import * as migration_20260827_233224_add_mission_completed_at from './20260827_233224_add_mission_completed_at';
import * as migration_20260828_011500_add_background_texture_preference from './20260828_011500_add_background_texture_preference';
import * as migration_20260828_041615_add_campaign_mode from './20260828_041615_add_campaign_mode';
import * as migration_20260828_044506 from './20260828_044506';
import * as migration_20260831_142503 from './20260831_142503';
import * as migration_20260831_180233_assignment_transfers from './20260831_180233_assignment_transfers';
import * as migration_20260901_014006_add_assignment_transfer_event_target from './20260901_014006_add_assignment_transfer_event_target';
import * as migration_20260901_213108_add_ticket_votes_and_edit_history from './20260901_213108_add_ticket_votes_and_edit_history';
import * as migration_20260901_222200_add_admin_page_permissions from './20260901_222200_add_admin_page_permissions';
import * as migration_20260902_195336_add_medal_backgrounds from './20260902_195336_add_medal_backgrounds';
import * as migration_20260903_035544 from './20260903_035544';
import * as migration_20260903_044047 from './20260903_044047';
import * as migration_20260903_053419 from './20260903_053419';
import * as migration_20260903_060000_add_radio_mode_stats from './20260903_060000_add_radio_mode_stats';
import * as migration_20260903_061000_add_radio_incorrect_counts from './20260903_061000_add_radio_incorrect_counts';
import * as migration_20260903_062000_add_aim_trainer_stats from './20260903_062000_add_aim_trainer_stats';
import * as migration_20260904_230838_add_wiki_collections from './20260904_230838_add_wiki_collections';
import * as migration_20260905_183940_base_management from './20260905_183940_base_management';
import * as migration_20260905_195456_labor_headcount from './20260905_195456_labor_headcount';
import * as migration_20260905_210310_labor_classifications from './20260905_210310_labor_classifications';
import * as migration_20260905_213824 from './20260905_213824';
import * as migration_20260905_215658 from './20260905_215658';
import * as migration_20260906_045502 from './20260906_045502';
import * as migration_20260906_050900_add_evaluation_reminders_field from './20260906_050900_add_evaluation_reminders_field';
import * as migration_20260906_061451_add_wiki_template_css from './20260906_061451_add_wiki_template_css';
import * as migration_20260906_063753_add_personal_excerpt_richtext from './20260906_063753_add_personal_excerpt_richtext';
import * as migration_20260906_065405_add_wiki_template_html from './20260906_065405_add_wiki_template_html';
import * as migration_20260906_100908_add_market_economy_state from './20260906_100908_add_market_economy_state';
import * as migration_20260906_165837_add_market_state_event_target from './20260906_165837_add_market_state_event_target';
import * as migration_20260906_202925_add_voice_submissions from './20260906_202925_add_voice_submissions';
import * as migration_20260907_012326_add_missions_created_by from './20260907_012326_add_missions_created_by';
import * as migration_20260907_013724_make_missions_campaign_optional from './20260907_013724_make_missions_campaign_optional';
import * as migration_20260907_041051_add_voice_script_spoiler from './20260907_041051_add_voice_script_spoiler';
import * as migration_20260907_043339_add_voice_script_description from './20260907_043339_add_voice_script_description';
import * as migration_20260907_044934_add_missions_discord_modlist_hash from './20260907_044934_add_missions_discord_modlist_hash';
import * as migration_20260907_083010_add_voice_submission_description from './20260907_083010_add_voice_submission_description';
import * as migration_20260907_090245_add_mission_ready_reminder_fields from './20260907_090245_add_mission_ready_reminder_fields';
export const migrations = [
{
up: migration_20260820_004157.up,
down: migration_20260820_004157.down,
name: "20260820_004157",
name: '20260820_004157',
},
{
up: migration_20260825_220000.up,
down: migration_20260825_220000.down,
name: "20260825_220000",
name: '20260825_220000',
},
{
up: migration_20260826_050000_repair_shims_auxiliary_tables.up,
down: migration_20260826_050000_repair_shims_auxiliary_tables.down,
name: "20260826_050000_repair_shims_auxiliary_tables",
name: '20260826_050000_repair_shims_auxiliary_tables',
},
{
up: migration_20260826_060000_add_game_servers_event_target.up,
down: migration_20260826_060000_add_game_servers_event_target.down,
name: "20260826_060000_add_game_servers_event_target",
name: '20260826_060000_add_game_servers_event_target',
},
{
up: migration_20260827_005757.up,
down: migration_20260827_005757.down,
name: "20260827_005757",
name: '20260827_005757',
},
{
up: migration_20260827_022334_add_eod_minesweeper_best_flags.up,
down: migration_20260827_022334_add_eod_minesweeper_best_flags.down,
name: "20260827_022334_add_eod_minesweeper_best_flags",
name: '20260827_022334_add_eod_minesweeper_best_flags',
},
{
up: migration_20260827_213857_add_evaluations_collection.up,
down: migration_20260827_213857_add_evaluations_collection.down,
name: "20260827_213857_add_evaluations_collection",
name: '20260827_213857_add_evaluations_collection',
},
{
up: migration_20260827_233224_add_mission_completed_at.up,
down: migration_20260827_233224_add_mission_completed_at.down,
name: "20260827_233224_add_mission_completed_at",
name: '20260827_233224_add_mission_completed_at',
},
{
up: migration_20260828_011500_add_background_texture_preference.up,
down: migration_20260828_011500_add_background_texture_preference.down,
name: "20260828_011500_add_background_texture_preference",
name: '20260828_011500_add_background_texture_preference',
},
{
up: migration_20260828_041615_add_campaign_mode.up,
down: migration_20260828_041615_add_campaign_mode.down,
name: "20260828_041615_add_campaign_mode",
name: '20260828_041615_add_campaign_mode',
},
{
up: migration_20260828_044506.up,
down: migration_20260828_044506.down,
name: "20260828_044506",
name: '20260828_044506',
},
{
up: migration_20260831_142503.up,
down: migration_20260831_142503.down,
name: "20260831_142503",
name: '20260831_142503',
},
{
up: migration_20260831_180233_assignment_transfers.up,
down: migration_20260831_180233_assignment_transfers.down,
name: "20260831_180233_assignment_transfers",
name: '20260831_180233_assignment_transfers',
},
{
up: migration_20260901_014006_add_assignment_transfer_event_target.up,
down: migration_20260901_014006_add_assignment_transfer_event_target.down,
name: "20260901_014006_add_assignment_transfer_event_target",
name: '20260901_014006_add_assignment_transfer_event_target',
},
{
up: migration_20260901_213108_add_ticket_votes_and_edit_history.up,
down: migration_20260901_213108_add_ticket_votes_and_edit_history.down,
name: "20260901_213108_add_ticket_votes_and_edit_history",
name: '20260901_213108_add_ticket_votes_and_edit_history',
},
{
up: migration_20260901_222200_add_admin_page_permissions.up,
down: migration_20260901_222200_add_admin_page_permissions.down,
name: "20260901_222200_add_admin_page_permissions",
name: '20260901_222200_add_admin_page_permissions',
},
{
up: migration_20260902_195336_add_medal_backgrounds.up,
down: migration_20260902_195336_add_medal_backgrounds.down,
name: "20260902_195336_add_medal_backgrounds",
name: '20260902_195336_add_medal_backgrounds',
},
{
up: migration_20260903_035544.up,
down: migration_20260903_035544.down,
name: "20260903_035544",
name: '20260903_035544',
},
{
up: migration_20260903_044047.up,
down: migration_20260903_044047.down,
name: "20260903_044047",
name: '20260903_044047',
},
{
up: migration_20260903_053419.up,
down: migration_20260903_053419.down,
name: "20260903_053419",
name: '20260903_053419',
},
{
up: migration_20260903_060000_add_radio_mode_stats.up,
down: migration_20260903_060000_add_radio_mode_stats.down,
name: "20260903_060000_add_radio_mode_stats",
name: '20260903_060000_add_radio_mode_stats',
},
{
up: migration_20260903_061000_add_radio_incorrect_counts.up,
down: migration_20260903_061000_add_radio_incorrect_counts.down,
name: "20260903_061000_add_radio_incorrect_counts",
name: '20260903_061000_add_radio_incorrect_counts',
},
{
up: migration_20260903_062000_add_aim_trainer_stats.up,
down: migration_20260903_062000_add_aim_trainer_stats.down,
name: "20260903_062000_add_aim_trainer_stats",
name: '20260903_062000_add_aim_trainer_stats',
},
{
up: migration_20260904_230838_add_wiki_collections.up,
down: migration_20260904_230838_add_wiki_collections.down,
name: "20260904_230838_add_wiki_collections",
name: '20260904_230838_add_wiki_collections',
},
{
up: migration_20260905_183940_base_management.up,
down: migration_20260905_183940_base_management.down,
name: "20260905_183940_base_management",
name: '20260905_183940_base_management',
},
{
up: migration_20260905_195456_labor_headcount.up,
down: migration_20260905_195456_labor_headcount.down,
name: "20260905_195456_labor_headcount",
name: '20260905_195456_labor_headcount',
},
{
up: migration_20260905_210310_labor_classifications.up,
down: migration_20260905_210310_labor_classifications.down,
name: "20260905_210310_labor_classifications",
name: '20260905_210310_labor_classifications',
},
{
up: migration_20260905_213824.up,
down: migration_20260905_213824.down,
name: "20260905_213824",
name: '20260905_213824',
},
{
up: migration_20260905_215658.up,
down: migration_20260905_215658.down,
name: "20260905_215658",
name: '20260905_215658',
},
{
up: migration_20260906_045502.up,
down: migration_20260906_045502.down,
name: "20260906_045502",
name: '20260906_045502',
},
{
up: migration_20260906_050900_add_evaluation_reminders_field.up,
down: migration_20260906_050900_add_evaluation_reminders_field.down,
name: "20260906_050900_add_evaluation_reminders_field",
name: '20260906_050900_add_evaluation_reminders_field',
},
{
up: migration_20260906_061451_add_wiki_template_css.up,
down: migration_20260906_061451_add_wiki_template_css.down,
name: "20260906_061451_add_wiki_template_css",
name: '20260906_061451_add_wiki_template_css',
},
{
up: migration_20260906_063753_add_personal_excerpt_richtext.up,
down: migration_20260906_063753_add_personal_excerpt_richtext.down,
name: "20260906_063753_add_personal_excerpt_richtext",
name: '20260906_063753_add_personal_excerpt_richtext',
},
{
up: migration_20260906_065405_add_wiki_template_html.up,
down: migration_20260906_065405_add_wiki_template_html.down,
name: "20260906_065405_add_wiki_template_html",
name: '20260906_065405_add_wiki_template_html',
},
{
up: migration_20260906_100908_add_market_economy_state.up,
down: migration_20260906_100908_add_market_economy_state.down,
name: "20260906_100908_add_market_economy_state",
name: '20260906_100908_add_market_economy_state',
},
{
up: migration_20260906_165837_add_market_state_event_target.up,
down: migration_20260906_165837_add_market_state_event_target.down,
name: "20260906_165837_add_market_state_event_target",
name: '20260906_165837_add_market_state_event_target',
},
{
up: migration_20260906_202925_add_voice_submissions.up,
down: migration_20260906_202925_add_voice_submissions.down,
name: "20260906_202925_add_voice_submissions",
name: '20260906_202925_add_voice_submissions',
},
{
up: migration_20260907_012326_add_missions_created_by.up,
down: migration_20260907_012326_add_missions_created_by.down,
name: "20260907_012326_add_missions_created_by",
name: '20260907_012326_add_missions_created_by',
},
{
up: migration_20260907_013724_make_missions_campaign_optional.up,
down: migration_20260907_013724_make_missions_campaign_optional.down,
name: "20260907_013724_make_missions_campaign_optional",
name: '20260907_013724_make_missions_campaign_optional',
},
{
up: migration_20260907_041051_add_voice_script_spoiler.up,
down: migration_20260907_041051_add_voice_script_spoiler.down,
name: "20260907_041051_add_voice_script_spoiler",
name: '20260907_041051_add_voice_script_spoiler',
},
{
up: migration_20260907_043339_add_voice_script_description.up,
down: migration_20260907_043339_add_voice_script_description.down,
name: "20260907_043339_add_voice_script_description",
name: '20260907_043339_add_voice_script_description',
},
{
up: migration_20260907_044934_add_missions_discord_modlist_hash.up,
down: migration_20260907_044934_add_missions_discord_modlist_hash.down,
name: "20260907_044934_add_missions_discord_modlist_hash",
name: '20260907_044934_add_missions_discord_modlist_hash',
},
{
up: migration_20260907_083010_add_voice_submission_description.up,
down: migration_20260907_083010_add_voice_submission_description.down,
name: "20260907_083010_add_voice_submission_description",
name: '20260907_083010_add_voice_submission_description',
},
{
up: migration_20260907_090245_add_mission_ready_reminder_fields.up,
down: migration_20260907_090245_add_mission_ready_reminder_fields.down,
name: '20260907_090245_add_mission_ready_reminder_fields'
},
];

View file

@ -2380,6 +2380,14 @@ export interface Mission {
* When evaluation reminder DMs were sent for this mission (managed by the bot, one-shot).
*/
evaluationRemindersSentAt?: string | null;
/**
* Server-local day the last web 'not Ready' reminder notification was sent to the op creator (managed by the mission tick, once per day).
*/
readyReminderWebSentAt?: string | null;
/**
* 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;
/**
* Player who scheduled this mission via the Friday Ops calendar (ownership + scheduling-cap tracking). Set automatically; leave empty for staff-created missions.
*/
@ -4489,6 +4497,8 @@ export interface MissionsSelect<T extends boolean = true> {
discordAttendanceSentAt?: T;
discordModlistHash?: T;
evaluationRemindersSentAt?: T;
readyReminderWebSentAt?: T;
readyReminderDmSentAt?: T;
createdBy?: T;
coverImage?: T;
classification?:

View file

@ -1,6 +1,7 @@
import type { SanitizedConfig } from "payload";
import { getPayload } from "payload";
import { autoCompleteMissions } from "@/lib/intelligence/missionLifecycle";
import { sendWebReadyReminders } from "@/lib/intelligence/readyReminders";
import { createBinLogger, errorMessage } from "./lib/binFileLogger";
export const script = async (config: SanitizedConfig) => {
@ -13,6 +14,13 @@ export const script = async (config: SanitizedConfig) => {
const nowIso = new Date().toISOString();
const result = await autoCompleteMissions(payload);
const reminders = await sendWebReadyReminders(payload);
if (reminders.notified > 0) {
log.info(
`[Mission Tick] Sent ${reminders.notified} ready reminder notification(s) across ${reminders.missions} due op(s).`,
);
}
const notifyUrl = (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, "");
const notifySecret = process.env.GAME_TICK_NOTIFY_SECRET;
if (notifySecret) {