From d94da34af2c8e6775b826b06815d32b3e920facc Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Fri, 28 Aug 2026 00:20:41 -0400 Subject: [PATCH] feat(intelligence): add mission feedback reminders --- src/app/api/mission-reminder/route.ts | 30 ++ .../intelligence/MissionReminderToasts.tsx | 234 ++++++++++ .../intelligence/missionReminderClient.ts | 122 ++++++ src/lib/intelligence/missionReminder.ts | 174 ++++++++ tests/int/mission-reminder-toasts.int.spec.ts | 150 +++++++ tests/int/mission-reminder.int.spec.ts | 409 ++++++++++++++++++ 6 files changed, 1119 insertions(+) create mode 100644 src/app/api/mission-reminder/route.ts create mode 100644 src/components/frontend/intelligence/MissionReminderToasts.tsx create mode 100644 src/components/frontend/intelligence/missionReminderClient.ts create mode 100644 src/lib/intelligence/missionReminder.ts create mode 100644 tests/int/mission-reminder-toasts.int.spec.ts create mode 100644 tests/int/mission-reminder.int.spec.ts diff --git a/src/app/api/mission-reminder/route.ts b/src/app/api/mission-reminder/route.ts new file mode 100644 index 0000000..533fdb4 --- /dev/null +++ b/src/app/api/mission-reminder/route.ts @@ -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 }); + } +} diff --git a/src/components/frontend/intelligence/MissionReminderToasts.tsx b/src/components/frontend/intelligence/MissionReminderToasts.tsx new file mode 100644 index 0000000..e7383d7 --- /dev/null +++ b/src/components/frontend/intelligence/MissionReminderToasts.tsx @@ -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 | null>(null); + const expiryTimerRef = useRef | null>(null); + const pumpScheduledRef = useRef(false); + const pumpQueueRef = useRef<() => void>(() => undefined); + const dismissedRef = useRef({}); + const currentRef = useRef(new Map()); + const queueRef = useRef([]); + const queuedKeysRef = useRef(new Set()); + const shownKeysRef = useRef(new Set()); + 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; +} diff --git a/src/components/frontend/intelligence/missionReminderClient.ts b/src/components/frontend/intelligence/missionReminderClient.ts new file mode 100644 index 0000000..701d967 --- /dev/null +++ b/src/components/frontend/intelligence/missionReminderClient.ts @@ -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; + +function isRecord(value: unknown): value is Record { + 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(); + + 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; + } +} diff --git a/src/lib/intelligence/missionReminder.ts b/src/lib/intelligence/missionReminder.ts new file mode 100644 index 0000000..6ce6943 --- /dev/null +++ b/src/lib/intelligence/missionReminder.ts @@ -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 { + const res = await payload.find({ + collection: "assignments", + where: { members: { contains: userId } }, + limit: 100, + depth: 0, + overrideAccess: true, + }); + const ids = new Set(); + 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 { + 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>(); + 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(); + 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(); + 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(); + 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), + })); +} diff --git a/tests/int/mission-reminder-toasts.int.spec.ts b/tests/int/mission-reminder-toasts.int.spec.ts new file mode 100644 index 0000000..a0592ce --- /dev/null +++ b/tests/int/mission-reminder-toasts.int.spec.ts @@ -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(); +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((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(); + }); +}); diff --git a/tests/int/mission-reminder.int.spec.ts b/tests/int/mission-reminder.int.spec.ts new file mode 100644 index 0000000..17ab958 --- /dev/null +++ b/tests/int/mission-reminder.int.spec.ts @@ -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 { + 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 => { + 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 => { + 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 => { + 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> }; + + 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>; + 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 + }); +});