1
0
Fork 0

feat(intelligence): add mission feedback reminders

This commit is contained in:
Jason Fraley 2026-08-28 00:20:41 -04:00
parent c2a16120cc
commit d94da34af2
6 changed files with 1119 additions and 0 deletions

View file

@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from "next/server";
import config from "@payload-config";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
import { getPayload } from "payload";
import { getMissionReminders } from "@/lib/intelligence/missionReminder";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
/**
* GET /api/mission-reminder
*
* Returns recently completed main operations (within 72h of completion) where the
* authenticated user RSVP'd "yes" and at least one of their assignment-based
* direct leaders also RSVP'd "yes". The client uses this to prompt mission
* feedback. Leader entries carry identity only — no rater/comment data.
*/
export async function GET(req: NextRequest) {
const payload = await getPayload({ config: await config });
const { user } = await payload.auth({ headers: req.headers, canSetHeaders: false });
if (!isPayloadUser(user)) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
try {
const reminders = await getMissionReminders(payload, user);
return NextResponse.json({ reminders });
} catch (error) {
console.error("mission-reminder: failed to load reminders", error);
return NextResponse.json({ error: "Failed to load mission reminders." }, { status: 500 });
}
}

View file

@ -0,0 +1,234 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { useGameTick } from "@/hooks/useGameTick";
import {
buildReminderEntries,
parseMissionReminders,
readReminderDismissals,
writeReminderDismissals,
type Dismissals,
type MissionReminder,
type ReminderEntry,
} from "@/components/frontend/intelligence/missionReminderClient";
const POLL_MS = 60_000;
export function MissionReminderToasts() {
const router = useRouter();
const mountedRef = useRef(false);
const fetchInFlightRef = useRef<Promise<void> | null>(null);
const expiryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pumpScheduledRef = useRef(false);
const pumpQueueRef = useRef<() => void>(() => undefined);
const dismissedRef = useRef<Dismissals>({});
const currentRef = useRef(new Map<string, ReminderEntry>());
const queueRef = useRef<ReminderEntry[]>([]);
const queuedKeysRef = useRef(new Set<string>());
const shownKeysRef = useRef(new Set<string>());
const activeToastRef = useRef<{ key: string; toastId: string } | null>(null);
const schedulePump = useCallback(() => {
if (!mountedRef.current || pumpScheduledRef.current) return;
pumpScheduledRef.current = true;
setTimeout(() => {
pumpScheduledRef.current = false;
pumpQueueRef.current();
}, 0);
}, []);
const finishToast = useCallback(
(key: string, toastId: string) => {
const active = activeToastRef.current;
if (!active || active.key !== key || active.toastId !== toastId) return;
activeToastRef.current = null;
schedulePump();
},
[schedulePump],
);
const scheduleExpiryCheck = useCallback(() => {
if (expiryTimerRef.current) clearTimeout(expiryTimerRef.current);
const now = Date.now();
const nextExpiry = [...currentRef.current.values()]
.map((entry) => entry.expiresAt)
.filter((expiresAt) => expiresAt > now)
.sort((first, second) => first - second)[0];
if (nextExpiry === undefined) return;
expiryTimerRef.current = setTimeout(
() => {
expiryTimerRef.current = null;
const currentNow = Date.now();
for (const [key, entry] of currentRef.current) {
if (entry.expiresAt <= currentNow) currentRef.current.delete(key);
}
queueRef.current = queueRef.current.filter((entry) => {
const current = currentRef.current.get(entry.key);
const keep = current?.expiresAt === entry.expiresAt && entry.expiresAt > currentNow;
if (!keep) queuedKeysRef.current.delete(entry.key);
return keep;
});
const active = activeToastRef.current;
if (active && !currentRef.current.has(active.key)) {
activeToastRef.current = null;
toast.dismiss(active.toastId);
}
scheduleExpiryCheck();
schedulePump();
},
Math.max(0, nextExpiry - now + 1),
);
}, [schedulePump]);
const applyReminders = useCallback(
(reminders: readonly MissionReminder[]) => {
const now = Date.now();
const entries = buildReminderEntries(reminders, now);
const next = new Map(entries.map((entry) => [entry.key, entry]));
const active = activeToastRef.current;
if (active && !next.has(active.key)) {
activeToastRef.current = null;
toast.dismiss(active.toastId);
}
currentRef.current = next;
queueRef.current = queueRef.current.filter((entry) => {
const current = next.get(entry.key);
const keep = current?.expiresAt === entry.expiresAt;
if (!keep) queuedKeysRef.current.delete(entry.key);
return keep;
});
for (const entry of entries) {
if (
!shownKeysRef.current.has(entry.key) &&
!queuedKeysRef.current.has(entry.key) &&
dismissedRef.current[entry.key] !== undefined
) {
continue;
}
if (!shownKeysRef.current.has(entry.key) && !queuedKeysRef.current.has(entry.key)) {
queueRef.current.push(entry);
queuedKeysRef.current.add(entry.key);
}
}
scheduleExpiryCheck();
schedulePump();
},
[scheduleExpiryCheck, schedulePump],
);
const fetchReminders = useCallback(async () => {
if (fetchInFlightRef.current) return fetchInFlightRef.current;
const request = (async () => {
try {
const response = await fetch("/api/mission-reminder", { cache: "no-store" });
if (!response.ok || !mountedRef.current) return;
const body: unknown = await response.json();
if (mountedRef.current) applyReminders(parseMissionReminders(body));
} catch (error) {
if (error instanceof Error) return;
throw error;
}
})();
fetchInFlightRef.current = request;
try {
await request;
} finally {
if (fetchInFlightRef.current === request) fetchInFlightRef.current = null;
}
}, [applyReminders]);
const pumpQueue = useCallback(() => {
if (!mountedRef.current || activeToastRef.current) return;
const now = Date.now();
let entry: ReminderEntry | undefined;
while (queueRef.current.length > 0) {
const candidate = queueRef.current.shift();
if (!candidate) break;
queuedKeysRef.current.delete(candidate.key);
const current = currentRef.current.get(candidate.key);
if (
!current ||
current.expiresAt !== candidate.expiresAt ||
candidate.expiresAt <= now ||
shownKeysRef.current.has(candidate.key) ||
dismissedRef.current[candidate.key] !== undefined
) {
continue;
}
entry = candidate;
break;
}
if (!entry) return;
const toastId = `mission-reminder:${entry.key}`;
const leaderName = entry.leader.displayName || entry.leader.username;
const missionName = entry.missionName || entry.missionCodeName;
shownKeysRef.current.add(entry.key);
activeToastRef.current = { key: entry.key, toastId };
toast("Mission feedback", {
id: toastId,
description: `Rate ${leaderName}'s leadership on ${missionName}.`,
action: {
label: "Rate leader",
onClick: () => {
toast.dismiss(toastId);
finishToast(entry.key, toastId);
router.push(`/profile/${encodeURIComponent(entry.leader.username)}`);
},
},
cancel: {
label: "Dismiss reminder",
onClick: () => {
dismissedRef.current[entry.key] = entry.expiresAt;
writeReminderDismissals(dismissedRef.current, Date.now());
toast.dismiss(toastId);
finishToast(entry.key, toastId);
},
},
onAutoClose: () => finishToast(entry.key, toastId),
onDismiss: () => finishToast(entry.key, toastId),
});
}, [finishToast, router]);
pumpQueueRef.current = pumpQueue;
useEffect(() => {
mountedRef.current = true;
const now = Date.now();
dismissedRef.current = readReminderDismissals(now);
writeReminderDismissals(dismissedRef.current, now);
void fetchReminders();
const interval = setInterval(() => void fetchReminders(), POLL_MS);
return () => {
mountedRef.current = false;
clearInterval(interval);
if (expiryTimerRef.current) clearTimeout(expiryTimerRef.current);
const active = activeToastRef.current;
activeToastRef.current = null;
if (active) toast.dismiss(active.toastId);
queueRef.current = [];
queuedKeysRef.current.clear();
currentRef.current.clear();
};
}, [fetchReminders]);
useGameTick(() => {
void fetchReminders();
});
return null;
}

View file

@ -0,0 +1,122 @@
const REMINDER_EXPIRY_MS = 72 * 60 * 60 * 1000;
const DISMISSALS_STORAGE_KEY = "ptf:mission-reminder:dismissals";
export type ReminderLeader = {
readonly id: number;
readonly username: string;
readonly displayName: string;
};
export type MissionReminder = {
readonly missionId: number;
readonly missionName: string;
readonly missionCodeName: string;
readonly completedAt: string;
readonly leaders: readonly ReminderLeader[];
};
export type ReminderEntry = MissionReminder & {
readonly key: string;
readonly leader: ReminderLeader;
readonly expiresAt: number;
};
export type Dismissals = Record<string, number>;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isReminderLeader(value: unknown): value is ReminderLeader {
return (
isRecord(value) &&
typeof value.id === "number" &&
typeof value.username === "string" &&
typeof value.displayName === "string"
);
}
function isMissionReminder(value: unknown): value is MissionReminder {
return (
isRecord(value) &&
typeof value.missionId === "number" &&
typeof value.missionName === "string" &&
typeof value.missionCodeName === "string" &&
typeof value.completedAt === "string" &&
Array.isArray(value.leaders) &&
value.leaders.every(isReminderLeader)
);
}
export function parseMissionReminders(value: unknown): readonly MissionReminder[] {
if (!isRecord(value) || !Array.isArray(value.reminders)) return [];
return value.reminders.filter(isMissionReminder);
}
export function missionReminderKey(missionId: number, leaderId: number): string {
return `${missionId}:${leaderId}`;
}
export function buildReminderEntries(
reminders: readonly MissionReminder[],
now: number,
): readonly ReminderEntry[] {
const entries: ReminderEntry[] = [];
const seen = new Set<string>();
for (const reminder of reminders) {
const completedAt = Date.parse(reminder.completedAt);
if (!Number.isFinite(completedAt)) continue;
const expiresAt = completedAt + REMINDER_EXPIRY_MS;
if (expiresAt <= now) continue;
for (const leader of reminder.leaders) {
const key = missionReminderKey(reminder.missionId, leader.id);
if (seen.has(key)) continue;
seen.add(key);
entries.push({ ...reminder, key, leader, expiresAt });
}
}
return entries;
}
// Browser storage is read during mount and written during mount or the
// explicit dismissal handler; it is never accessed during render.
export function readReminderDismissals(now: number): Dismissals {
if (typeof window === "undefined") return {};
try {
const raw = window.localStorage.getItem(DISMISSALS_STORAGE_KEY);
if (!raw) return {};
const parsed: unknown = JSON.parse(raw);
if (!isRecord(parsed)) return {};
const dismissals: Dismissals = {};
for (const [key, value] of Object.entries(parsed)) {
if (typeof value === "number" && Number.isFinite(value) && value > now) {
dismissals[key] = value;
}
}
return dismissals;
} catch (error) {
if (error instanceof Error) return {};
throw error;
}
}
export function writeReminderDismissals(dismissals: Dismissals, now: number): void {
if (typeof window === "undefined") return;
const activeDismissals: Dismissals = {};
for (const [key, expiresAt] of Object.entries(dismissals)) {
if (expiresAt > now) activeDismissals[key] = expiresAt;
}
try {
window.localStorage.setItem(DISMISSALS_STORAGE_KEY, JSON.stringify(activeDismissals));
} catch (error) {
if (error instanceof Error) return;
throw error;
}
}

View file

@ -0,0 +1,174 @@
import type { Payload } from "payload";
/**
* Mission feedback reminder window: a completed main operation only prompts for
* leader feedback within 72 hours of its completion timestamp.
*/
export const MISSION_REMINDER_WINDOW_MS = 72 * 60 * 60 * 1000;
/** Leader identity, enough to render a link to `/profile/[username]`. */
export interface ReminderLeader {
id: number;
username: string;
displayName: string;
}
/** One eligible mission with the direct leaders who RSVP'd "yes". */
export interface MissionReminder {
missionId: number;
missionName: string;
missionCodeName: string;
completedAt: string;
leaders: ReminderLeader[];
}
function refId(value: number | { id: number } | null | undefined): number | null {
if (typeof value === "number") return value;
if (value && typeof value.id === "number") return value.id;
return null;
}
/**
* The user's direct leaders, using the same assignment model as
* `isDirectLeaderOf` in `@/lib/evaluations`: a leader is anyone who leads an
* assignment whose members include this user.
*/
async function findDirectLeaderIds(payload: Payload, userId: number): Promise<number[]> {
const res = await payload.find({
collection: "assignments",
where: { members: { contains: userId } },
limit: 100,
depth: 0,
overrideAccess: true,
});
const ids = new Set<number>();
for (const assignment of res.docs) {
const leaderId = refId(assignment.leader);
if (leaderId !== null) ids.add(leaderId);
}
return [...ids];
}
/**
* Recently completed main operations that need feedback from `user`: the mission
* is a main operation, transitioned to Completed within the last 72 hours
* (`ownershipAndStatus.completedAt`), `user` RSVP'd "yes", and at least one of
* the user's assignment-based direct leaders also RSVP'd "yes".
*
* Fail-closed: missions with a missing or unparseable completion timestamp are
* never eligible, and `updatedAt` is never used as a completion proxy.
*/
export async function getMissionReminders(
payload: Payload,
user: { id: number },
now: Date = new Date(),
): Promise<MissionReminder[]> {
const cutoff = new Date(now.getTime() - MISSION_REMINDER_WINDOW_MS);
// One query for the window + type + status. Missions without a completion
// timestamp cannot match the date comparison, and are re-checked below anyway.
const missionsRes = await payload.find({
collection: "missions",
where: {
and: [
{ operationType: { equals: "main" } },
{ "ownershipAndStatus.status": { equals: "Completed" } },
{ "ownershipAndStatus.completedAt": { greater_than_equal: cutoff.toISOString() } },
],
},
limit: 100,
depth: 0,
overrideAccess: true,
});
const candidates = missionsRes.docs
.map((mission) => {
const completedAt = mission.ownershipAndStatus?.completedAt;
if (!completedAt || typeof completedAt !== "string") return null; // fail closed on missing
const time = new Date(completedAt).getTime();
if (Number.isNaN(time)) return null; // fail closed on unknown data
if (time < cutoff.getTime()) return null;
return { mission, completedAt };
})
.filter(
(
candidate,
): candidate is { mission: (typeof missionsRes.docs)[number]; completedAt: string } =>
candidate !== null,
);
if (candidates.length === 0) return [];
const missionIds = candidates.map(({ mission }) => mission.id);
// All "yes" RSVPs for the candidate missions in a single query.
const attendanceRes = await payload.find({
collection: "mission-attendances",
where: {
and: [{ mission: { in: missionIds } }, { response: { equals: "yes" } }],
},
limit: 500,
depth: 0,
overrideAccess: true,
});
const yesByMission = new Map<number, Set<number>>();
for (const doc of attendanceRes.docs) {
const missionId = refId(doc.mission);
const userId = refId(doc.user);
if (missionId === null || userId === null) continue;
let yesUsers = yesByMission.get(missionId);
if (!yesUsers) {
yesUsers = new Set<number>();
yesByMission.set(missionId, yesUsers);
}
yesUsers.add(userId);
}
const leaderIds = await findDirectLeaderIds(payload, user.id);
if (leaderIds.length === 0) return [];
const leaderSet = new Set(leaderIds);
const eligible: Array<{
mission: (typeof missionsRes.docs)[number];
completedAt: string;
leaderIds: number[];
}> = [];
for (const { mission, completedAt } of candidates) {
const yesUsers = yesByMission.get(mission.id) ?? new Set<number>();
if (!yesUsers.has(user.id)) continue; // the user must have RSVP'd "yes"
const leaders = [...yesUsers].filter((id) => leaderSet.has(id));
if (leaders.length === 0) continue; // at least one direct leader must have RSVP'd "yes"
eligible.push({ mission, completedAt, leaderIds: leaders });
}
if (eligible.length === 0) return [];
const allLeaderIds = [...new Set(eligible.flatMap((entry) => entry.leaderIds))];
const usersRes = await payload.find({
collection: "users",
where: { id: { in: allLeaderIds } },
limit: allLeaderIds.length,
depth: 0,
overrideAccess: true,
});
const leaderById = new Map<number, ReminderLeader>();
for (const doc of usersRes.docs) {
if (typeof doc.id !== "number") continue;
leaderById.set(doc.id, {
id: doc.id,
username: String(doc.username ?? ""),
displayName: String(doc.displayName ?? ""),
});
}
return eligible.map(({ mission, completedAt, leaderIds }) => ({
missionId: mission.id,
missionName: mission.name,
missionCodeName: mission.codeName,
completedAt,
leaders: leaderIds
.map((id) => leaderById.get(id))
.filter((leader): leader is ReminderLeader => leader !== undefined),
}));
}

View file

@ -0,0 +1,150 @@
import { createElement } from "react";
import { act, cleanup, render, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { toast } from "sonner";
import { GAME_TICK_EVENT } from "@/hooks/useGameTick";
import { MissionReminderToasts } from "@/components/frontend/intelligence/MissionReminderToasts";
const routerPush = vi.hoisted(() => vi.fn());
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: routerPush }),
}));
vi.mock("sonner", () => {
const mockedToast = Object.assign(vi.fn(), { dismiss: vi.fn() });
return { toast: mockedToast };
});
type Leader = {
id: number;
username: string;
displayName: string;
};
type Reminder = {
missionId: number;
missionName: string;
missionCodeName: string;
completedAt: string;
leaders: readonly Leader[];
};
const reminder: Reminder = {
missionId: 42,
missionName: "Operation Nightfall",
missionCodeName: "OP:NIGHTFALL",
completedAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
leaders: [
{ id: 7, username: "leader-one", displayName: "Leader One" },
{ id: 8, username: "leader-two", displayName: "Leader Two" },
],
};
const fetchMock = vi.fn<typeof fetch>();
const mockedToast = vi.mocked(toast);
function responseFor(reminders: readonly Reminder[]): Response {
return new Response(JSON.stringify({ reminders }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
function firstToastOptions() {
const options = mockedToast.mock.calls[0]?.[1];
if (!options) throw new Error("Expected the first reminder toast to have options.");
return options;
}
function invokeButton(button: unknown): void {
if (typeof button !== "object" || button === null || !("onClick" in button)) {
throw new Error("Expected a Sonner button contract.");
}
const onClick = button.onClick;
if (typeof onClick !== "function") throw new Error("Expected a Sonner button handler.");
onClick(new MouseEvent("click"));
}
describe("MissionReminderToasts", () => {
beforeEach(() => {
cleanup();
vi.clearAllMocks();
localStorage.clear();
vi.stubGlobal("fetch", fetchMock);
mockedToast.mockReturnValue("mission-reminder:42:7");
});
afterEach(() => {
cleanup();
});
it("shows eligible leaders one at a time and links the action to the leader profile", async () => {
fetchMock.mockResolvedValue(responseFor([reminder]));
render(createElement(MissionReminderToasts));
await waitFor(() => expect(mockedToast).toHaveBeenCalledTimes(1));
expect(firstToastOptions().action).toMatchObject({ label: "Rate leader" });
expect(firstToastOptions().cancel).toMatchObject({ label: "Dismiss reminder" });
invokeButton(firstToastOptions().action);
expect(routerPush).toHaveBeenCalledWith("/profile/leader-one");
await waitFor(() => expect(mockedToast).toHaveBeenCalledTimes(2));
expect(mockedToast.mock.calls[1]?.[1]?.description).toContain("Leader Two");
});
it("does not issue a second request while a realtime refresh overlaps the initial poll", async () => {
let resolveFetch: ((value: Response) => void) | undefined;
fetchMock.mockImplementation(
() =>
new Promise<Response>((resolve) => {
resolveFetch = resolve;
}),
);
render(createElement(MissionReminderToasts));
act(() => window.dispatchEvent(new Event(GAME_TICK_EVENT)));
expect(fetchMock).toHaveBeenCalledTimes(1);
resolveFetch?.(responseFor([reminder]));
await waitFor(() => expect(mockedToast).toHaveBeenCalledTimes(1));
});
it("persists an explicit dismissal for that mission and leader only", async () => {
fetchMock.mockResolvedValue(responseFor([reminder]));
render(createElement(MissionReminderToasts));
await waitFor(() => expect(mockedToast).toHaveBeenCalledTimes(1));
invokeButton(firstToastOptions().cancel);
const stored = localStorage.getItem("ptf:mission-reminder:dismissals");
expect(stored).toContain('"42:7"');
expect(stored).not.toContain('"42:8"');
cleanup();
mockedToast.mockClear();
fetchMock.mockResolvedValue(responseFor([reminder]));
render(createElement(MissionReminderToasts));
await waitFor(() => expect(mockedToast).toHaveBeenCalledTimes(1));
expect(mockedToast.mock.calls[0]?.[1]?.description).toContain("Leader Two");
});
it("does not show a reminder whose completion window has expired", async () => {
const expiredReminder = {
...reminder,
completedAt: new Date(Date.now() - 73 * 60 * 60 * 1000).toISOString(),
};
fetchMock.mockResolvedValue(responseFor([expiredReminder]));
render(createElement(MissionReminderToasts));
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
await act(async () => {
await Promise.resolve();
});
expect(mockedToast).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,409 @@
// @vitest-environment node
// jose (via payload's jwtSign) fails under jsdom: its module-level TextEncoder and
// the global Uint8Array come from different realms, so signing throws a TypeError.
import { getFieldsToSign, getPayload, jwtSign, Payload } from "payload";
import config from "@/payload.config";
import { randomUUID } from "node:crypto";
import { NextRequest } from "next/server";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { Assignment, Campaign, Map as MissionMap, Mission, User } from "@/payload-types";
import { GET } from "@/app/api/mission-reminder/route";
import { getMissionReminders } from "@/lib/intelligence/missionReminder";
let payload: Payload;
const RUN = `mrm-${Date.now().toString(36)}`;
const HOUR = 60 * 60 * 1000;
const DAY = 24 * HOUR;
/** Reference "now" for the boundary fixtures and the service-level window test. */
let fixedNow: Date;
function request(token?: string): NextRequest {
const headers = new Headers();
if (token) headers.set("cookie", `payload-token=${token}`);
return new NextRequest("http://localhost/api/mission-reminder", { headers });
}
/**
* Mint a payload-token JWT for a user, mirroring the impersonation route:
* persist a session on the user doc, then sign with that session's sid.
*/
async function mintToken(userId: number): Promise<string> {
const collection = payload.collections.users.config;
const user = await payload.findByID({
collection: "users",
id: userId,
depth: 0,
overrideAccess: true,
});
const sid = randomUUID();
const now = new Date();
const expiresAt = new Date(now.getTime() + collection.auth.tokenExpiration * 1000);
const sessions = (user.sessions ?? []).filter(
(session) => new Date(session.expiresAt).getTime() > now.getTime(),
);
sessions.push({ id: sid, createdAt: now.toISOString(), expiresAt: expiresAt.toISOString() });
await payload.update({
collection: "users",
id: userId,
data: { sessions },
overrideAccess: true,
});
const { token } = await jwtSign({
fieldsToSign: getFieldsToSign({
collectionConfig: collection,
email: user.email ?? "",
sid,
user,
}),
secret: payload.secret,
tokenExpiration: collection.auth.tokenExpiration,
});
return token;
}
describe("Mission feedback reminder", () => {
let mapId: number;
let campaignId: number;
const userIds: number[] = [];
const missionIds: number[] = [];
const attendanceIds: number[] = [];
const assignmentIds: number[] = [];
let memberId: number;
let leaderAId: number;
let leaderBId: number;
let strangerId: number;
let lonerId: number;
let memberToken: string;
let lonerToken: string;
// Eligible fixtures.
let eligibleMainId: number; // main, 24h ago, member + leaderA yes -> included [leaderA]
let multiLeaderId: number; // main, 12h ago, member + both leaders yes -> included [leaderA, leaderB]
// Ineligible fixtures.
let sideOpId: number; // side operation within the window
let staleMainId: number; // main, 73h ago (past the window)
let noStampId: number; // main, completion stamp cleared -> fail closed
let userNoId: number; // member RSVP'd "no"
let leaderNoId: number; // only a non-leader (stranger) RSVP'd yes
// Boundary fixtures relative to fixedNow.
let boundaryExactId: number; // exactly fixedNow - 72h -> included at fixedNow
let boundaryPastId: number; // fixedNow - 72h - 1ms -> excluded at fixedNow
beforeAll(async () => {
const payloadConfig = await config;
payload = await getPayload({ config: payloadConfig });
fixedNow = new Date();
const map = (await payload.create({
collection: "maps",
data: { name: `${RUN} Map` },
overrideAccess: true,
depth: 0,
})) as unknown as MissionMap;
mapId = map.id;
const campaign = (await payload.create({
collection: "campaigns",
data: { name: `${RUN} Campaign`, summary: "Reminder test campaign", status: "concept", campaignMode: "custom" },
overrideAccess: true,
depth: 0,
})) as unknown as Campaign;
campaignId = campaign.id;
const makeUser = async (label: string, displayName: string): Promise<User> => {
const user = (await payload.create({
collection: "users",
data: {
username: `${RUN}-${label}`,
discordUsername: `${RUN}-${label}`,
displayName,
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
password: "Test123",
roles: ["user"],
},
overrideAccess: true,
depth: 0,
})) as unknown as User;
userIds.push(user.id);
return user;
};
const member = await makeUser("member", "MEMBER");
const leaderA = await makeUser("leader-a", "LEADER A");
const leaderB = await makeUser("leader-b", "LEADER B");
const stranger = await makeUser("stranger", "STRANGER");
const loner = await makeUser("loner", "LONER");
memberId = member.id;
leaderAId = leaderA.id;
leaderBId = leaderB.id;
strangerId = stranger.id;
lonerId = loner.id;
// The member belongs to two squads, so their direct leaders are both
// leaders (same model as isDirectLeaderOf in @/lib/evaluations).
const makeAssignment = async (label: string, leaderId: number): Promise<Assignment> => {
const assignment = (await payload.create({
collection: "assignments",
data: { name: `${RUN} ${label}`, type: "squad", leader: leaderId, members: [memberId] },
overrideAccess: true,
depth: 0,
})) as unknown as Assignment;
assignmentIds.push(assignment.id);
return assignment;
};
await makeAssignment("Squad A", leaderAId);
await makeAssignment("Squad B", leaderBId);
const makeMission = async (
label: string,
operationType: Mission["operationType"],
completedAt?: string,
): Promise<Mission> => {
const mission = (await payload.create({
collection: "missions",
data: {
name: `${RUN} ${label}`,
codeName: `${RUN}-${label}`,
summary: "Reminder boundary test mission",
operationType,
classification: {
map: mapId,
missionType: "PvE",
campaign: campaignId,
startDateTime: new Date(fixedNow.getTime() - DAY).toISOString(),
estimatedDuration: 60,
},
ownershipAndStatus: {
authors: [memberId],
status: "Completed",
visibility: "unit",
...(completedAt ? { completedAt } : {}),
},
missionRoles: { maxPlayers: 16 },
gameDetails: { serverDetails: { serverIp: "127.0.0.1", serverPort: 2302 } },
briefing: [],
},
overrideAccess: true,
depth: 0,
})) as unknown as Mission;
missionIds.push(mission.id);
return mission;
};
const addAttendance = async (
missionId: number,
userId: number,
response: "yes" | "no" | "tentative",
) => {
const attendance = await payload.create({
collection: "mission-attendances",
data: { mission: missionId, user: userId, response },
overrideAccess: true,
depth: 0,
});
attendanceIds.push((attendance as unknown as { id: number }).id);
};
// Eligible fixtures.
eligibleMainId = (
await makeMission("eligible-main", "main", new Date(fixedNow.getTime() - DAY).toISOString())
).id;
await addAttendance(eligibleMainId, memberId, "yes");
await addAttendance(eligibleMainId, leaderAId, "yes");
multiLeaderId = (
await makeMission(
"multi-leader",
"main",
new Date(fixedNow.getTime() - 12 * HOUR).toISOString(),
)
).id;
await addAttendance(multiLeaderId, memberId, "yes");
await addAttendance(multiLeaderId, leaderAId, "yes");
await addAttendance(multiLeaderId, leaderBId, "yes");
// Ineligible fixtures.
sideOpId = (
await makeMission("side-op", "side", new Date(fixedNow.getTime() - DAY).toISOString())
).id;
await addAttendance(sideOpId, memberId, "yes");
await addAttendance(sideOpId, leaderAId, "yes");
staleMainId = (
await makeMission(
"stale-main",
"main",
new Date(fixedNow.getTime() - 73 * HOUR).toISOString(),
)
).id;
await addAttendance(staleMainId, memberId, "yes");
await addAttendance(staleMainId, leaderAId, "yes");
// Created as Completed (the hook stamps the completion time), then the
// stamp is cleared: the reminder must fail closed on a missing timestamp.
const noStamp = await makeMission("no-stamp", "main");
noStampId = noStamp.id;
await payload.update({
collection: "missions",
id: noStampId,
data: { ownershipAndStatus: { completedAt: null } },
overrideAccess: true,
});
await addAttendance(noStampId, memberId, "yes");
await addAttendance(noStampId, leaderAId, "yes");
userNoId = (
await makeMission("user-no", "main", new Date(fixedNow.getTime() - DAY).toISOString())
).id;
await addAttendance(userNoId, memberId, "no");
await addAttendance(userNoId, leaderAId, "yes");
leaderNoId = (
await makeMission("leader-no", "main", new Date(fixedNow.getTime() - DAY).toISOString())
).id;
await addAttendance(leaderNoId, memberId, "yes");
await addAttendance(leaderNoId, strangerId, "yes"); // a non-leader "yes" must not count
// Boundary fixtures relative to fixedNow.
boundaryExactId = (
await makeMission(
"boundary-exact",
"main",
new Date(fixedNow.getTime() - 72 * HOUR).toISOString(),
)
).id;
await addAttendance(boundaryExactId, memberId, "yes");
await addAttendance(boundaryExactId, leaderAId, "yes");
boundaryPastId = (
await makeMission(
"boundary-past",
"main",
new Date(fixedNow.getTime() - 72 * HOUR - 1).toISOString(),
)
).id;
await addAttendance(boundaryPastId, memberId, "yes");
await addAttendance(boundaryPastId, leaderAId, "yes");
memberToken = await mintToken(memberId);
lonerToken = await mintToken(lonerId);
});
afterAll(async () => {
for (const id of attendanceIds) {
await payload
.delete({ collection: "mission-attendances", id, overrideAccess: true })
.catch(() => {});
}
for (const id of missionIds) {
await payload.delete({ collection: "missions", id, overrideAccess: true }).catch(() => {});
}
for (const id of assignmentIds) {
await payload.delete({ collection: "assignments", id, overrideAccess: true }).catch(() => {});
}
// User deletion trips FK constraints unless the hook-provisioned personal
// bank account and profile are removed first.
for (const id of userIds) {
const accounts = await payload
.find({
collection: "bank-accounts",
where: { ownerUser: { equals: id } },
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: id } },
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, overrideAccess: true }).catch(() => {});
}
await payload
.delete({ collection: "campaigns", id: campaignId, overrideAccess: true })
.catch(() => {});
await payload.delete({ collection: "maps", id: mapId, overrideAccess: true }).catch(() => {});
});
it("rejects unauthenticated requests with 401", async () => {
const res = await GET(request());
expect(res.status).toBe(401);
});
it("returns only eligible missions with the yes-RSVP direct leaders", async () => {
const res = await GET(request(memberToken));
expect(res.status).toBe(200);
const body = (await res.json()) as { reminders: Array<Record<string, unknown>> };
const ids = body.reminders.map((reminder) => reminder.missionId as number);
// Exactly the two eligible missions — nothing else.
expect([...ids].sort()).toEqual([eligibleMainId, multiLeaderId].sort());
const byMissionId = new Map(
body.reminders.map((reminder) => [reminder.missionId as number, reminder]),
);
// Response shape: mission + leader identity only (no rater/comment data).
const eligible = byMissionId.get(eligibleMainId)!;
expect(Object.keys(eligible).sort()).toEqual([
"completedAt",
"leaders",
"missionCodeName",
"missionId",
"missionName",
]);
expect(eligible.missionName).toBe(`${RUN} eligible-main`);
expect(eligible.missionCodeName).toBe(`${RUN}-eligible-main`);
const leaders = eligible.leaders as Array<Record<string, unknown>>;
expect(leaders).toHaveLength(1);
expect(Object.keys(leaders[0]).sort()).toEqual(["displayName", "id", "username"]);
expect(leaders[0].id).toBe(leaderAId);
expect(leaders[0].username).toBe(`${RUN}-leader-a`);
expect(leaders[0].displayName).toBe("LEADER A");
// Both direct leaders who RSVP'd yes are reported.
const multi = byMissionId.get(multiLeaderId)!;
const multiLeaderIds = (multi.leaders as Array<{ id: number }>).map((leader) => leader.id);
expect([...multiLeaderIds].sort()).toEqual([leaderAId, leaderBId].sort());
});
it("returns nothing for a user without direct leaders", async () => {
const res = await GET(request(lonerToken));
expect(res.status).toBe(200);
const body = (await res.json()) as { reminders: unknown[] };
expect(body.reminders).toEqual([]);
});
it("treats the 72h window as inclusive and fails closed just past it", async () => {
const result = await getMissionReminders(payload, { id: memberId }, fixedNow);
const ids = result.map((reminder) => reminder.missionId);
expect(ids).toContain(boundaryExactId); // exactly now - 72h is still eligible
expect(ids).not.toContain(boundaryPastId); // one millisecond past the window is not
});
});