1
0
Fork 0

Compare commits

...

6 commits

29 changed files with 42150 additions and 69 deletions

View file

@ -12,7 +12,10 @@ import { type ResolvedLevel, resolveLevel } from "@/utils/xp/resolveLevel";
import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
import { ShipmentToasts } from "@/components/frontend/logistics/ShipmentToasts";
import { MissionReminderToasts } from "@/components/frontend/intelligence/MissionReminderToasts";
import { GameTickRealtime } from "@/components/frontend/realtime/GameTickRealtime";
import { SessionWatchdog } from "@/components/frontend/session/SessionWatchdog";
import { getSessionExpMs } from "@/lib/session/token";
import VersionOverlay from "@/components/static/VersionOverlay";
import { LandingPage } from "@/components/frontend/LandingPage";
import { Toaster } from "@/components/ui/sonner";
@ -54,6 +57,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
const payload = await getPayload({ config });
const { user: authUser } = await payload.auth({ headers, canSetHeaders: false });
const user = isPayloadUser(authUser) ? authUser : null;
const sessionExp = getSessionExpMs(headers);
const impersonating = (await cookies()).has(IMPERSONATION_ACTIVE_COOKIE);
let xpLevel: ResolvedLevel = {
@ -156,6 +160,8 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
<VersionOverlay canSeeCommit={await isSuperuser(payload, user)} />
)}
{user && <GameTickRealtime />}
{user && <SessionWatchdog sessionExp={sessionExp} />}
{user && <MissionReminderToasts />}
{isLogistics && <ShipmentToasts />}
<Toaster />
<ShimLoader shims={shimEntries} userId={user?.id ?? null} userRoles={user?.roles ?? []} />

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

@ -66,7 +66,10 @@ export const handleButtonInteraction = async (
await setMissionAttendance(payload, { missionId, userId: user.id, response });
const summary = await getMissionAttendance(payload, missionId);
const hash = attendanceHash(await fetchAttendanceDocs(payload, missionId));
const hash = attendanceHash(
await fetchAttendanceDocs(payload, missionId),
mission.classification.startDateTime,
);
await interaction.message.edit({
embeds: [renderAttendanceEmbed(mission, summary)],

View file

@ -28,12 +28,6 @@ const missionTitle = (mission: Mission): string =>
const memberName = (user: User): string => user.displayName ?? user.username;
const isStillFuture = (mission: Mission): boolean => {
const start = new Date(mission.classification.startDateTime).getTime();
const end = start + mission.classification.estimatedDuration * 60_000;
return end > Date.now();
};
/**
* Roll call is sent once: as soon as the mission is Ready, or three days
* before a Scheduled mission, whichever happens first.
@ -59,17 +53,18 @@ const operationTypeLabel = (type: Mission["operationType"]): string => {
};
/**
* Deterministic fingerprint of the attendance state so reconcile can skip
* embeds that haven't changed (anti-loop).
* Deterministic fingerprint of the attendance state and scheduled start time
* so reconcile can skip embeds that haven't changed (anti-loop), while still
* detecting mission date changes.
*/
export const attendanceHash = (docs: MissionAttendance[]): string => {
export const attendanceHash = (docs: MissionAttendance[], startDateTime?: string): string => {
const rows = docs
.map((doc) => {
const userId = typeof doc.user === "object" ? doc.user.id : doc.user;
return [userId, doc.response] as const;
})
.sort((a, b) => a[0] - b[0] || a[1].localeCompare(b[1]));
return JSON.stringify(rows);
return JSON.stringify({ rows, startDateTime: startDateTime ?? null });
};
export const fetchAttendanceDocs = async (
@ -97,17 +92,23 @@ export const getEligibleMissions = async (payload: Payload): Promise<Mission[]>
limit: MAX_ELIGIBLE_MISSIONS,
where: {
and: [
{ "classification.startDateTime": { greater_than: new Date().toISOString() } },
{ "ownershipAndStatus.visibility": { equals: "unit" } },
{
or: [
{ "ownershipAndStatus.status": { equals: "Ready" } },
{
and: [
{ "classification.startDateTime": { greater_than: new Date().toISOString() } },
{ "ownershipAndStatus.status": { equals: "Ready" } },
],
},
{
and: [
{ "classification.startDateTime": { greater_than: new Date().toISOString() } },
{ "ownershipAndStatus.status": { equals: "Scheduled" } },
{ "classification.startDateTime": { less_than_equal: rollCallCutoff } },
],
},
{ discordMessageId: { exists: true } },
],
},
],
@ -200,7 +201,10 @@ const postEmbed = async (
mission: Mission,
): Promise<void> => {
const summary = await getMissionAttendance(payload, mission.id);
const hash = attendanceHash(await fetchAttendanceDocs(payload, mission.id));
const hash = attendanceHash(
await fetchAttendanceDocs(payload, mission.id),
mission.classification.startDateTime,
);
const channel = await getOpsChannel(client, opsChannelId);
const message = await channel.send({
embeds: [renderAttendanceEmbed(mission, summary)],
@ -257,7 +261,10 @@ const manageMission = async (
return;
}
const hash = attendanceHash(await fetchAttendanceDocs(payload, mission.id));
const hash = attendanceHash(
await fetchAttendanceDocs(payload, mission.id),
mission.classification.startDateTime,
);
if (hash === mission.discordAttendanceHash) {
return;
}
@ -297,7 +304,6 @@ export const reconcileMission = async (
if (
mission.ownershipAndStatus.visibility !== "unit" ||
!["Ready", "Scheduled"].includes(mission.ownershipAndStatus.status) ||
!isStillFuture(mission) ||
(!isRollCallDue(mission) && !mission.discordMessageId)
) {
return;
@ -353,7 +359,7 @@ export const reconcile = async (client: Client, payload: Payload): Promise<void>
}
const eligible = (await getEligibleMissions(payload)).filter(
(mission) => isStillFuture(mission) && (isRollCallDue(mission) || !!mission.discordMessageId),
(mission) => isRollCallDue(mission) || !!mission.discordMessageId,
);
for (const mission of eligible) {
try {

View file

@ -1,5 +1,6 @@
import { CollectionConfig } from "payload";
import { requirePermission, requireCampaignOwnership } from "@/utils/access-control/hasPermission";
import { isDeveloper } from "@/utils/access-control/isRole";
import type { User } from "@/payload-types";
export const Campaigns: CollectionConfig = {
@ -47,6 +48,30 @@ export const Campaigns: CollectionConfig = {
required: true,
defaultValue: "concept",
},
{
name: "campaignMode",
type: "select",
label: "Campaign Mode",
options: [
{
label: "Official Campaign",
value: "official",
},
{
label: "Custom Campaign",
value: "custom",
},
],
required: true,
defaultValue: "custom",
access: {
create: isDeveloper,
update: isDeveloper,
},
admin: {
description: "Only developers can designate a campaign as official or custom.",
},
},
{
name: "description",
type: "richText",

View file

@ -38,6 +38,29 @@ export const Missions: CollectionConfig = {
group: "Intelligence",
useAsTitle: "name",
},
hooks: {
beforeChange: [
// Loop-safe by construction: this mutates the incoming data of the very
// operation it runs in, so it never triggers a second write. It only
// stamps `ownershipAndStatus.completedAt` when a transition lands on
// "Completed" and no completion time exists yet — auto-complete passes
// its own sweep time explicitly (preserved by the existing-value check),
// and re-completing an already-stamped mission keeps the original stamp.
async ({ data, originalDoc }) => {
if (data?.ownershipAndStatus?.status !== "Completed") return data;
const existing =
data.ownershipAndStatus.completedAt ?? originalDoc?.ownershipAndStatus?.completedAt;
if (existing) return data;
return {
...data,
ownershipAndStatus: {
...data.ownershipAndStatus,
completedAt: new Date().toISOString(),
},
};
},
],
},
access: {
create: async ({ req }) => {
return await hasPermission(req.payload, req.user, "missions:create");
@ -308,6 +331,15 @@ export const Missions: CollectionConfig = {
defaultValue: "leadership",
required: true,
},
{
name: "completedAt",
type: "date",
admin: {
description:
"When the mission transitioned to Completed. Set automatically by the lifecycle (auto-complete tick or manual status change) — managed here, not editable in the form.",
disabled: true,
},
},
],
},
{

View file

@ -131,6 +131,7 @@ export const Users: CollectionConfig = {
group: "Users",
},
auth: {
tokenExpiration: 43200,
loginWithUsername: {
requireUsername: true,
allowEmailLogin: false,

View file

@ -17,6 +17,8 @@ import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { submitEodResult, type EodDifficulty, type EodStats } from "@/app/(frontend)/eod/actions";
import { PROFILE_UPDATE_EVENT } from "@/hooks/useGameTick";
import { useSessionExpired } from "@/hooks/useSessionExpired";
import { LoginLink } from "@/components/frontend/auth/LoginLink";
import { cn } from "@/lib/utils";
import { playClear, playExplosion, playFlag, playReveal, playXp } from "./sounds";
@ -182,6 +184,7 @@ export function MinesweeperBoard({
onDifficultyChange: (difficulty: EodDifficulty) => void;
}) {
const router = useRouter();
const { expired, expiredRef } = useSessionExpired();
const [difficulty, setDifficulty] = useState<GameMode>("training");
const [customConfig, setCustomConfig] = useState<GameConfig>({
label: "Custom Minefield",
@ -240,6 +243,7 @@ export function MinesweeperBoard({
const reset = useCallback(
(nextDifficulty: GameMode = difficulty) => {
if (submittingRef.current) return;
if (expiredRef.current) return;
const nextConfig = nextDifficulty === "custom" ? customConfig : DIFFICULTIES[nextDifficulty];
setDifficulty(nextDifficulty);
if (nextDifficulty !== "custom") onDifficultyChange(nextDifficulty);
@ -249,7 +253,7 @@ export function MinesweeperBoard({
setLastRun(null);
setPhaseSafe("ready");
},
[customConfig, difficulty, onDifficultyChange, setPhaseSafe],
[customConfig, difficulty, expiredRef, onDifficultyChange, setPhaseSafe],
);
useEffect(() => {
@ -284,6 +288,17 @@ export function MinesweeperBoard({
const recordResult = useCallback(
async (won: boolean, elapsedSeconds: number, flags: number) => {
if (submittingRef.current) return;
if (expiredRef.current) {
setLastRun({
won,
seconds: elapsedSeconds,
score: 0,
xpEarned: 0,
best: 0,
error: "Your session expired. Result not recorded.",
});
return;
}
if (difficulty === "custom") {
setLastRun({
won,
@ -337,7 +352,7 @@ export function MinesweeperBoard({
});
}
},
[config.baseScore, config.maxSeconds, config.parSeconds, difficulty, onGameRecorded, router],
[config.baseScore, config.maxSeconds, config.parSeconds, difficulty, expiredRef, onGameRecorded, router],
);
const finishGame = useCallback(
@ -366,6 +381,7 @@ export function MinesweeperBoard({
const reveal = useCallback(
(index: number) => {
if (phase === "over" || submittingRef.current) return;
if (expiredRef.current) return;
const existing = board[index];
if (!existing || existing.flagged || existing.revealed) return;
const workingBoard = generatedRef.current ? board : createBoard(config, index);
@ -387,12 +403,13 @@ export function MinesweeperBoard({
setBoard(nextBoard);
if (nextBoard.every((item) => item.mine || item.revealed)) finishGame(true, nextBoard);
},
[board, config, finishGame, phase, setPhaseSafe],
[board, config, expiredRef, finishGame, phase, setPhaseSafe],
);
const toggleFlag = useCallback(
(index: number) => {
if (phase === "over" || submittingRef.current) return;
if (expiredRef.current) return;
const cell = board[index];
if (!cell || cell.revealed) return;
playFlag();
@ -402,7 +419,7 @@ export function MinesweeperBoard({
),
);
},
[board, phase],
[board, expiredRef, phase],
);
const clearLongPress = useCallback(() => {
@ -450,7 +467,7 @@ export function MinesweeperBoard({
aria-label={cellLabel(cell, row, col)}
aria-rowindex={row + 1}
aria-colindex={col + 1}
disabled={phase === "over" || cell.revealed}
disabled={phase === "over" || expired || cell.revealed}
className={cn(
"flex size-7 items-center justify-center rounded-sm border text-xs font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
cell.revealed &&
@ -516,6 +533,21 @@ export function MinesweeperBoard({
return (
<div className="flex w-full max-w-5xl flex-col gap-4">
{expired && (
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-amber-500/40 bg-amber-500/10 p-6 text-center">
<span className="text-xl font-black uppercase tracking-widest text-amber-400">
Session Ending Soon
</span>
<span className="max-w-md text-sm text-white/80">
Your session is about to end. The app will refresh when it expires.
</span>
<LoginLink>
<Button type="button" size="sm">
Log in
</Button>
</LoginLink>
</div>
)}
<div className="flex select-none flex-col gap-4 rounded-xl border border-border bg-card p-4 shadow">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex flex-col gap-1">
@ -567,7 +599,7 @@ export function MinesweeperBoard({
type="button"
variant={difficulty === key ? "default" : "outline"}
className="h-auto justify-start px-3 py-2 text-left"
disabled={submitting}
disabled={submitting || expired}
aria-pressed={difficulty === key}
onClick={() => reset(key)}
>
@ -676,7 +708,7 @@ export function MinesweeperBoard({
<Button
type="button"
variant={difficulty === "custom" ? "default" : "outline"}
disabled={submitting}
disabled={submitting || expired}
aria-pressed={difficulty === "custom"}
onClick={() => reset("custom")}
>
@ -731,7 +763,7 @@ export function MinesweeperBoard({
type="button"
variant="outline"
size="sm"
disabled={submitting}
disabled={submitting || expired}
onClick={() => reset()}
>
<RotateCcw /> Retry sector
@ -759,7 +791,7 @@ export function MinesweeperBoard({
aria-label={cellLabel(cell, row, col)}
aria-rowindex={row + 1}
aria-colindex={col + 1}
disabled={phase === "over" || cell.revealed}
disabled={phase === "over" || expired || cell.revealed}
className={cn(
"flex size-7 items-center justify-center rounded-sm border text-xs font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
cell.revealed &&

View file

@ -5,6 +5,9 @@ import { useRouter } from "next/navigation";
import { type FlappyStats, submitFlappyScore } from "@/app/(frontend)/flappy/actions";
import { PROFILE_UPDATE_EVENT } from "@/hooks/useGameTick";
import { useSessionExpired } from "@/hooks/useSessionExpired";
import { LoginLink } from "@/components/frontend/auth/LoginLink";
import { Button } from "@/components/ui/button";
import { playFlap, playHit, playXp } from "./sounds";
const CANVAS_W = 420;
@ -333,6 +336,7 @@ export function FlappyBird({
onPhaseChange?: (phase: Phase) => void;
}) {
const router = useRouter();
const { expired, expiredRef } = useSessionExpired();
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const wrapperRef = useRef<HTMLDivElement | null>(null);
const phaseRef = useRef<Phase>("ready");
@ -367,6 +371,7 @@ export function FlappyBird({
}, [onPhaseChange]);
const reset = useCallback(() => {
if (expiredRef.current) return;
const g = gameRef.current;
g.birdY = START_Y;
g.birdVy = 0;
@ -383,6 +388,15 @@ export function FlappyBird({
const handleGameOver = useCallback(
async (finalScore: number) => {
if (submittingRef.current) return;
if (expiredRef.current) {
setLastRun({
score: finalScore,
xpEarned: 0,
best: statsRef.current.bestScore,
error: "Your session expired. Score not recorded.",
});
return;
}
if (finalScore <= 0) {
setLastRun({
score: finalScore,
@ -423,7 +437,7 @@ export function FlappyBird({
});
}
},
[onGameRecorded, router],
[expiredRef, onGameRecorded, router],
);
const handleGameOverRef = useRef(handleGameOver);
@ -432,6 +446,7 @@ export function FlappyBird({
}, [handleGameOver]);
const flap = useCallback(() => {
if (expiredRef.current) return;
const p = phaseRef.current;
if (p === "ready") {
const g = gameRef.current;
@ -445,7 +460,7 @@ export function FlappyBird({
} else if (p === "over") {
reset();
}
}, [reset, setPhaseSafe]);
}, [expiredRef, reset, setPhaseSafe]);
useEffect(() => {
const canvas = canvasRef.current;
@ -716,6 +731,22 @@ export function FlappyBird({
</span>
</div>
)}
{expired && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-center bg-black/70 rounded-xl">
<span className="text-2xl font-black uppercase tracking-widest text-amber-400 drop-shadow-lg">
Session Ending Soon
</span>
<span className="max-w-[260px] text-xs text-white/80">
Your session is about to end. The app will refresh when it expires.
</span>
<LoginLink className="mt-1">
<Button type="button" size="sm">
Log in
</Button>
</LoginLink>
</div>
)}
</div>
<div className="flex flex-wrap justify-center gap-6 text-sm text-muted-foreground">

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,91 @@
"use client";
import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { SESSION_EXPIRED_EVENT } from "@/hooks/useGameTick";
/**
* How early (ms) before the JWT `exp` we lock the client (dispatch the
* `SESSION_EXPIRED_EVENT`). The server is authoritative; this buffer only
* makes the client-side UX hint fire a little ahead of the hard cutoff so the
* user isn't mid-action when the token lapses.
*/
const NEAR_EXPIRY_BUFFER_MS = 30_000;
/**
* How often (ms) the safety check runs. It compares the wall clock against the
* server-provided `sessionExp` — it makes no server requests and reads no
* cookies. It exists to catch a throttled timer (e.g. a backgrounded tab) that
* delayed the one-shot timeout past the real expiry.
*/
const SAFETY_CHECK_INTERVAL_MS = 5 * 60_000;
/**
* Mounted only in the authenticated branch of `(frontend)/layout.tsx`.
*
* The server reads the HttpOnly `payload-token` cookie and passes only the JWT
* `exp` (ms) as `sessionExp`. This component schedules a one-shot lock timer
* just before expiry (dispatches `SESSION_EXPIRED_EVENT` so minigames and other
* consumers can lock themselves) and a refresh timer at the actual expiry
* (`router.refresh()` — the server then re-renders the layout, sees the expired
* token, and swaps the shell for the guest landing page). A lightweight safety
* interval catches throttled timers. Guards prevent duplicate lock/refresh.
*/
export function SessionWatchdog({ sessionExp }: { sessionExp: number | null }) {
const router = useRouter();
const lockFiredRef = useRef(false);
const refreshFiredRef = useRef(false);
useEffect(() => {
if (sessionExp === null) return;
const lock = () => {
if (lockFiredRef.current) return;
lockFiredRef.current = true;
window.dispatchEvent(new CustomEvent(SESSION_EXPIRED_EVENT));
};
const refresh = () => {
if (refreshFiredRef.current) return;
refreshFiredRef.current = true;
router.refresh();
};
const now = Date.now();
const lockDelay = sessionExp - NEAR_EXPIRY_BUFFER_MS - now;
const refreshDelay = sessionExp - now;
let lockTimer: number | null = null;
let refreshTimer: number | null = null;
if (refreshDelay <= 0) {
// Already past expiry — lock and refresh immediately.
lock();
refresh();
} else {
if (lockDelay <= 0) {
lock();
} else {
lockTimer = window.setTimeout(lock, lockDelay);
}
refreshTimer = window.setTimeout(refresh, refreshDelay);
}
// Safety net for throttled timers in background tabs.
const safety = window.setInterval(() => {
if (Date.now() >= sessionExp) {
lock();
refresh();
window.clearInterval(safety);
}
}, SAFETY_CHECK_INTERVAL_MS);
return () => {
if (lockTimer !== null) window.clearTimeout(lockTimer);
if (refreshTimer !== null) window.clearTimeout(refreshTimer);
window.clearInterval(safety);
};
}, [router, sessionExp]);
return null;
}

View file

@ -6,6 +6,7 @@ export const GAME_TICK_EVENT = "ptf:game-tick";
export const PROFILE_UPDATE_EVENT = "ptf:profile-update";
export const GAME_EVENT_EVENT = "ptf:game-event";
export const NOTIFICATION_EVENT = "ptf:notification";
export const SESSION_EXPIRED_EVENT = "ptf:session-expired";
export interface GameTickDetail {
processedAt?: string;

View file

@ -0,0 +1,33 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { SESSION_EXPIRED_EVENT } from "@/hooks/useGameTick";
/**
* Tracks whether the session has expired (per the client-side watchdog).
*
* Returns both a reactive `expired` boolean (for rendering) and an
* `expiredRef` (for use inside callbacks / the game loop without stale
* closures). Consumers should gate start/retry/input and skip server saves
* on `expiredRef.current`.
*/
export function useSessionExpired(): {
expired: boolean;
expiredRef: React.RefObject<boolean>;
} {
const [expired, setExpired] = useState(false);
const expiredRef = useRef(false);
useEffect(() => {
const handler = () => {
expiredRef.current = true;
setExpired(true);
};
window.addEventListener(SESSION_EXPIRED_EVENT, handler);
return () => {
window.removeEventListener(SESSION_EXPIRED_EVENT, handler);
};
}, []);
return { expired, expiredRef };
}

View file

@ -70,10 +70,13 @@ export async function autoCompleteMissions(
for (const mission of due.docs) {
const previousStatus = mission.ownershipAndStatus?.status ?? null;
try {
// Pass the sweep's `now` explicitly so the completion timestamp is the
// exact tick time (deterministic for tests); the collection's
// beforeChange hook preserves it because it only stamps when empty.
await payload.update({
collection: "missions",
id: mission.id,
data: { ownershipAndStatus: { status: "Completed" } },
data: { ownershipAndStatus: { status: "Completed", completedAt: now.toISOString() } },
overrideAccess: true,
});
await emitGameEvent(payload, {

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),
}));
}

44
src/lib/session/token.ts Normal file
View file

@ -0,0 +1,44 @@
/**
* Session-token helpers.
*
* The `payload-token` cookie is HttpOnly (set by Payload), so it is invisible
* to client-side JavaScript. The server reads it from the request headers and
* extracts only the JWT `exp` claim, which is passed to the client watchdog as
* a plain number. The server remains the authority on whether a session is
* actually valid — the `exp` is used purely as a client-side UX hint.
*/
/**
* Decode the `exp` claim (seconds since epoch) from a JWT string, or null when
* the token is malformed or carries no numeric `exp`. Pure and side-effect
* free so it is trivially unit-testable on both server and client.
*/
export function decodeTokenExp(token: string): number | null {
const payload = token.split(".")[1];
if (!payload) return null;
let json: unknown;
try {
const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
json = JSON.parse(atob(base64));
} catch {
return null;
}
if (typeof json !== "object" || json === null) return null;
const exp = (json as { exp?: unknown }).exp;
return typeof exp === "number" && Number.isFinite(exp) ? exp : null;
}
/**
* Read the `payload-token` cookie from the request headers and return the JWT
* `exp` in milliseconds since epoch, or null when the cookie is absent or the
* token is malformed. Server-only in practice (needs the request headers), but
* takes the headers as a parameter so it stays pure and testable.
*/
export function getSessionExpMs(headers: { get(name: string): string | null }): number | null {
const cookieHeader = headers.get("cookie");
if (!cookieHeader) return null;
const match = cookieHeader.match(/(?:^|;\s*)payload-token=([^;]+)/);
if (!match) return null;
const exp = decodeTokenExp(match[1]);
return exp === null ? null : exp * 1000;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,11 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "missions" ADD COLUMN "ownership_and_status_completed_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 "ownership_and_status_completed_at";`)
}

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`
CREATE TYPE "public"."enum_campaigns_campaign_mode" AS ENUM('official', 'custom');
ALTER TABLE "campaigns" ADD COLUMN "campaign_mode" "enum_campaigns_campaign_mode" DEFAULT 'custom' NOT NULL;`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "campaigns" DROP COLUMN "campaign_mode";
DROP TYPE "public"."enum_campaigns_campaign_mode";`)
}

View file

@ -5,6 +5,8 @@ import * as migration_20260826_060000_add_game_servers_event_target from './2026
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_041615_add_campaign_mode from './20260828_041615_add_campaign_mode';
export const migrations = [
{
@ -40,6 +42,16 @@ export const migrations = [
{
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',
},
{
up: migration_20260828_041615_add_campaign_mode.up,
down: migration_20260828_041615_add_campaign_mode.down,
name: '20260828_041615_add_campaign_mode'
},
];

File diff suppressed because it is too large Load diff

View file

@ -1099,6 +1099,10 @@ export interface Campaign {
name: string;
summary: string;
status: 'concept' | 'inProgress' | 'completed';
/**
* Only developers can designate a campaign as official or custom.
*/
campaignMode: 'official' | 'custom';
description?: {
root: {
type: string;
@ -1920,6 +1924,10 @@ export interface Mission {
zeus?: (number | User)[] | null;
status: 'Concept' | 'Planning' | 'Ready' | 'Scheduled' | 'Active' | 'Completed' | 'Cancelled';
visibility: 'unit' | 'leadership' | 'intel' | 'private';
/**
* When the mission transitioned to Completed. Set automatically by the lifecycle (auto-complete tick or manual status change) — managed here, not editable in the form.
*/
completedAt?: string | null;
};
missionRoles: {
maxPlayers: number;
@ -3632,6 +3640,7 @@ export interface MissionsSelect<T extends boolean = true> {
zeus?: T;
status?: T;
visibility?: T;
completedAt?: T;
};
missionRoles?:
| T
@ -3741,6 +3750,7 @@ export interface CampaignsSelect<T extends boolean = true> {
name?: T;
summary?: T;
status?: T;
campaignMode?: T;
description?: T;
startDate?: T;
endDate?: T;

View file

@ -118,7 +118,7 @@ describe("Leadership evaluations", () => {
const campaign = (await payload.create({
collection: "campaigns",
data: { name: `${RUN} Campaign`, summary: "Evaluation test campaign", status: "concept" },
data: { name: `${RUN} Campaign`, summary: "Evaluation test campaign", status: "concept", campaignMode: "custom" },
overrideAccess: true,
depth: 0,
})) as unknown as Campaign;

View file

@ -33,7 +33,7 @@ describe("Mission auto-complete", () => {
const campaign = (await payload.create({
collection: "campaigns",
data: { name: `${RUN} Campaign`, summary: "Auto-complete test campaign", status: "concept" },
data: { name: `${RUN} Campaign`, summary: "Auto-complete test campaign", status: "concept", campaignMode: "custom" },
overrideAccess: true,
depth: 0,
})) as unknown as Campaign;
@ -93,7 +93,9 @@ describe("Mission auto-complete", () => {
.delete({ collection: "profiles", id: profile.id, overrideAccess: true })
.catch(() => {});
}
await payload.delete({ collection: "users", id: authorId, overrideAccess: true }).catch(() => {});
await payload
.delete({ collection: "users", id: authorId, overrideAccess: true })
.catch(() => {});
});
const makeMission = async (
@ -196,6 +198,79 @@ describe("Mission auto-complete", () => {
expect(result.missions).toEqual([]);
});
it("stamps the exact sweep time as completedAt when auto-completing", async () => {
const now = new Date();
now.setHours(15, 0, 0, 0);
const start = new Date(now);
start.setDate(now.getDate() - 1);
start.setHours(20, 0, 0, 0);
const due = await makeMission("timestamp-due", "Scheduled", start);
const result = await autoCompleteMissions(payload, now, { missionIds: [due.id] });
expect(result.completed).toBe(1);
const updated = await fetchMission(due.id);
expect(updated.ownershipAndStatus.status).toBe("Completed");
expect(updated.ownershipAndStatus.completedAt).toBe(now.toISOString());
});
it("stamps completedAt once on manual transition to Completed and never overwrites it", async () => {
const start = new Date();
// Future start date — the auto-complete sweep must never touch this mission.
start.setDate(start.getDate() + 1);
start.setHours(20, 0, 0, 0);
const mission = await makeMission("manual-complete", "Scheduled", start);
expect(mission.ownershipAndStatus.completedAt).toBeNull();
// Non-terminal transitions must not stamp a completion time.
await payload.update({
collection: "missions",
id: mission.id,
data: { ownershipAndStatus: { status: "Active" } },
overrideAccess: true,
});
expect((await fetchMission(mission.id)).ownershipAndStatus.completedAt).toBeNull();
const before = Date.now();
await payload.update({
collection: "missions",
id: mission.id,
data: { ownershipAndStatus: { status: "Completed" } },
overrideAccess: true,
});
const after = Date.now();
const completed = await fetchMission(mission.id);
expect(completed.ownershipAndStatus.completedAt).toBeTruthy();
// timestamptz(3) truncates sub-millisecond precision, hence the 1ms tolerance.
const stamped = new Date(completed.ownershipAndStatus.completedAt!).getTime();
expect(stamped).toBeGreaterThanOrEqual(before - 1);
expect(stamped).toBeLessThanOrEqual(after);
// Cancelling and re-completing keeps the original stamp.
await payload.update({
collection: "missions",
id: mission.id,
data: { ownershipAndStatus: { status: "Cancelled" } },
overrideAccess: true,
});
expect((await fetchMission(mission.id)).ownershipAndStatus.completedAt).toBe(
completed.ownershipAndStatus.completedAt,
);
await payload.update({
collection: "missions",
id: mission.id,
data: { ownershipAndStatus: { status: "Completed" } },
overrideAccess: true,
});
const recompleted = await fetchMission(mission.id);
expect(recompleted.ownershipAndStatus.completedAt).toBe(
completed.ownershipAndStatus.completedAt,
);
});
it("classifies day boundaries deterministically", () => {
const now = new Date(2026, 7, 24, 12, 0, 0);
expect(startOfToday(now).getTime()).toBe(new Date(2026, 7, 24, 0, 0, 0).getTime());

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
});
});

View file

@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { decodeTokenExp, getSessionExpMs } from "@/lib/session/token";
/** Build a JWT-shaped string with the given payload object. */
function makeToken(payload: Record<string, unknown>): string {
const header = btoa(JSON.stringify({ alg: "HS256", typ: "JWT" }));
const body = btoa(JSON.stringify(payload));
return `${header}.${body}.signature`;
}
describe("decodeTokenExp", () => {
it("returns the numeric exp claim from a well-formed token", () => {
const exp = 1_800_000_000;
expect(decodeTokenExp(makeToken({ sub: "1", exp }))).toBe(exp);
});
it("returns null when the token has no payload segment", () => {
expect(decodeTokenExp("header")).toBeNull();
expect(decodeTokenExp("")).toBeNull();
});
it("returns null when the payload is not valid base64 JSON", () => {
expect(decodeTokenExp("header.not-json.signature")).toBeNull();
});
it("returns null when exp is missing or not a finite number", () => {
expect(decodeTokenExp(makeToken({ sub: "1" }))).toBeNull();
expect(decodeTokenExp(makeToken({ exp: "soon" }))).toBeNull();
expect(decodeTokenExp(makeToken({ exp: null }))).toBeNull();
expect(decodeTokenExp(makeToken({ exp: Number.NaN }))).toBeNull();
});
it("handles URL-safe base64 (JWT padding) in the payload", () => {
// A payload whose base64 would contain - and _ after URL-safe encoding.
const payload = { exp: 1_800_000_000, data: "a+b/c=d" };
const body = btoa(JSON.stringify(payload)).replace(/\+/g, "-").replace(/\//g, "_");
const token = `header.${body}.signature`;
expect(decodeTokenExp(token)).toBe(1_800_000_000);
});
});
describe("getSessionExpMs", () => {
const exp = 1_800_000_000;
const token = makeToken({ sub: "1", exp });
it("returns the exp in milliseconds from the cookie header", () => {
const headers = { get: (name: string) => (name === "cookie" ? `payload-token=${token}` : null) };
expect(getSessionExpMs(headers)).toBe(exp * 1000);
});
it("returns null when there is no cookie header", () => {
const headers = { get: () => null };
expect(getSessionExpMs(headers)).toBeNull();
});
it("returns null when the payload-token cookie is absent", () => {
const headers = { get: () => "other=value" };
expect(getSessionExpMs(headers)).toBeNull();
});
it("parses the token among other cookies", () => {
const headers = { get: () => `foo=1; payload-token=${token}; bar=2` };
expect(getSessionExpMs(headers)).toBe(exp * 1000);
});
it("returns null when the token is malformed", () => {
const headers = { get: () => "payload-token=not-a-jwt" };
expect(getSessionExpMs(headers)).toBeNull();
});
});