Compare commits
No commits in common. "53d053b3b253fe8a635ba991364f678ca8eb52cb" and "055db8d0f06012c6578e946aec154d32a7784af1" have entirely different histories.
53d053b3b2
...
055db8d0f0
29 changed files with 73 additions and 42154 deletions
|
|
@ -12,10 +12,7 @@ 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";
|
||||
|
|
@ -57,7 +54,6 @@ 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 = {
|
||||
|
|
@ -160,8 +156,6 @@ 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 ?? []} />
|
||||
|
|
|
|||
|
|
@ -1,30 +0,0 @@
|
|||
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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -66,10 +66,7 @@ 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),
|
||||
mission.classification.startDateTime,
|
||||
);
|
||||
const hash = attendanceHash(await fetchAttendanceDocs(payload, missionId));
|
||||
|
||||
await interaction.message.edit({
|
||||
embeds: [renderAttendanceEmbed(mission, summary)],
|
||||
|
|
|
|||
|
|
@ -28,6 +28,12 @@ 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.
|
||||
|
|
@ -53,18 +59,17 @@ const operationTypeLabel = (type: Mission["operationType"]): string => {
|
|||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Deterministic fingerprint of the attendance state so reconcile can skip
|
||||
* embeds that haven't changed (anti-loop).
|
||||
*/
|
||||
export const attendanceHash = (docs: MissionAttendance[], startDateTime?: string): string => {
|
||||
export const attendanceHash = (docs: MissionAttendance[]): 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, startDateTime: startDateTime ?? null });
|
||||
return JSON.stringify(rows);
|
||||
};
|
||||
|
||||
export const fetchAttendanceDocs = async (
|
||||
|
|
@ -92,23 +97,17 @@ 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 } },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
@ -201,10 +200,7 @@ const postEmbed = async (
|
|||
mission: Mission,
|
||||
): Promise<void> => {
|
||||
const summary = await getMissionAttendance(payload, mission.id);
|
||||
const hash = attendanceHash(
|
||||
await fetchAttendanceDocs(payload, mission.id),
|
||||
mission.classification.startDateTime,
|
||||
);
|
||||
const hash = attendanceHash(await fetchAttendanceDocs(payload, mission.id));
|
||||
const channel = await getOpsChannel(client, opsChannelId);
|
||||
const message = await channel.send({
|
||||
embeds: [renderAttendanceEmbed(mission, summary)],
|
||||
|
|
@ -261,10 +257,7 @@ const manageMission = async (
|
|||
return;
|
||||
}
|
||||
|
||||
const hash = attendanceHash(
|
||||
await fetchAttendanceDocs(payload, mission.id),
|
||||
mission.classification.startDateTime,
|
||||
);
|
||||
const hash = attendanceHash(await fetchAttendanceDocs(payload, mission.id));
|
||||
if (hash === mission.discordAttendanceHash) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -304,6 +297,7 @@ export const reconcileMission = async (
|
|||
if (
|
||||
mission.ownershipAndStatus.visibility !== "unit" ||
|
||||
!["Ready", "Scheduled"].includes(mission.ownershipAndStatus.status) ||
|
||||
!isStillFuture(mission) ||
|
||||
(!isRollCallDue(mission) && !mission.discordMessageId)
|
||||
) {
|
||||
return;
|
||||
|
|
@ -359,7 +353,7 @@ export const reconcile = async (client: Client, payload: Payload): Promise<void>
|
|||
}
|
||||
|
||||
const eligible = (await getEligibleMissions(payload)).filter(
|
||||
(mission) => isRollCallDue(mission) || !!mission.discordMessageId,
|
||||
(mission) => isStillFuture(mission) && (isRollCallDue(mission) || !!mission.discordMessageId),
|
||||
);
|
||||
for (const mission of eligible) {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
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 = {
|
||||
|
|
@ -48,30 +47,6 @@ 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",
|
||||
|
|
|
|||
|
|
@ -38,29 +38,6 @@ 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");
|
||||
|
|
@ -328,20 +305,11 @@ export const Missions: CollectionConfig = {
|
|||
value: "private",
|
||||
},
|
||||
],
|
||||
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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
defaultValue: "leadership",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "missionRoles",
|
||||
type: "group",
|
||||
|
|
|
|||
|
|
@ -131,7 +131,6 @@ export const Users: CollectionConfig = {
|
|||
group: "Users",
|
||||
},
|
||||
auth: {
|
||||
tokenExpiration: 43200,
|
||||
loginWithUsername: {
|
||||
requireUsername: true,
|
||||
allowEmailLogin: false,
|
||||
|
|
|
|||
|
|
@ -17,8 +17,6 @@ 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";
|
||||
|
||||
|
|
@ -184,7 +182,6 @@ 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",
|
||||
|
|
@ -243,7 +240,6 @@ 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);
|
||||
|
|
@ -253,7 +249,7 @@ export function MinesweeperBoard({
|
|||
setLastRun(null);
|
||||
setPhaseSafe("ready");
|
||||
},
|
||||
[customConfig, difficulty, expiredRef, onDifficultyChange, setPhaseSafe],
|
||||
[customConfig, difficulty, onDifficultyChange, setPhaseSafe],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -288,17 +284,6 @@ 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,
|
||||
|
|
@ -352,7 +337,7 @@ export function MinesweeperBoard({
|
|||
});
|
||||
}
|
||||
},
|
||||
[config.baseScore, config.maxSeconds, config.parSeconds, difficulty, expiredRef, onGameRecorded, router],
|
||||
[config.baseScore, config.maxSeconds, config.parSeconds, difficulty, onGameRecorded, router],
|
||||
);
|
||||
|
||||
const finishGame = useCallback(
|
||||
|
|
@ -381,7 +366,6 @@ 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);
|
||||
|
|
@ -403,13 +387,12 @@ export function MinesweeperBoard({
|
|||
setBoard(nextBoard);
|
||||
if (nextBoard.every((item) => item.mine || item.revealed)) finishGame(true, nextBoard);
|
||||
},
|
||||
[board, config, expiredRef, finishGame, phase, setPhaseSafe],
|
||||
[board, config, 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();
|
||||
|
|
@ -419,7 +402,7 @@ export function MinesweeperBoard({
|
|||
),
|
||||
);
|
||||
},
|
||||
[board, expiredRef, phase],
|
||||
[board, phase],
|
||||
);
|
||||
|
||||
const clearLongPress = useCallback(() => {
|
||||
|
|
@ -467,7 +450,7 @@ export function MinesweeperBoard({
|
|||
aria-label={cellLabel(cell, row, col)}
|
||||
aria-rowindex={row + 1}
|
||||
aria-colindex={col + 1}
|
||||
disabled={phase === "over" || expired || cell.revealed}
|
||||
disabled={phase === "over" || 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 &&
|
||||
|
|
@ -533,21 +516,6 @@ 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">
|
||||
|
|
@ -599,7 +567,7 @@ export function MinesweeperBoard({
|
|||
type="button"
|
||||
variant={difficulty === key ? "default" : "outline"}
|
||||
className="h-auto justify-start px-3 py-2 text-left"
|
||||
disabled={submitting || expired}
|
||||
disabled={submitting}
|
||||
aria-pressed={difficulty === key}
|
||||
onClick={() => reset(key)}
|
||||
>
|
||||
|
|
@ -708,7 +676,7 @@ export function MinesweeperBoard({
|
|||
<Button
|
||||
type="button"
|
||||
variant={difficulty === "custom" ? "default" : "outline"}
|
||||
disabled={submitting || expired}
|
||||
disabled={submitting}
|
||||
aria-pressed={difficulty === "custom"}
|
||||
onClick={() => reset("custom")}
|
||||
>
|
||||
|
|
@ -763,7 +731,7 @@ export function MinesweeperBoard({
|
|||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={submitting || expired}
|
||||
disabled={submitting}
|
||||
onClick={() => reset()}
|
||||
>
|
||||
<RotateCcw /> Retry sector
|
||||
|
|
@ -791,7 +759,7 @@ export function MinesweeperBoard({
|
|||
aria-label={cellLabel(cell, row, col)}
|
||||
aria-rowindex={row + 1}
|
||||
aria-colindex={col + 1}
|
||||
disabled={phase === "over" || expired || cell.revealed}
|
||||
disabled={phase === "over" || 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 &&
|
||||
|
|
|
|||
|
|
@ -5,9 +5,6 @@ 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;
|
||||
|
|
@ -336,7 +333,6 @@ 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");
|
||||
|
|
@ -371,7 +367,6 @@ export function FlappyBird({
|
|||
}, [onPhaseChange]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
if (expiredRef.current) return;
|
||||
const g = gameRef.current;
|
||||
g.birdY = START_Y;
|
||||
g.birdVy = 0;
|
||||
|
|
@ -388,15 +383,6 @@ 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,
|
||||
|
|
@ -437,7 +423,7 @@ export function FlappyBird({
|
|||
});
|
||||
}
|
||||
},
|
||||
[expiredRef, onGameRecorded, router],
|
||||
[onGameRecorded, router],
|
||||
);
|
||||
|
||||
const handleGameOverRef = useRef(handleGameOver);
|
||||
|
|
@ -446,7 +432,6 @@ export function FlappyBird({
|
|||
}, [handleGameOver]);
|
||||
|
||||
const flap = useCallback(() => {
|
||||
if (expiredRef.current) return;
|
||||
const p = phaseRef.current;
|
||||
if (p === "ready") {
|
||||
const g = gameRef.current;
|
||||
|
|
@ -460,7 +445,7 @@ export function FlappyBird({
|
|||
} else if (p === "over") {
|
||||
reset();
|
||||
}
|
||||
}, [expiredRef, reset, setPhaseSafe]);
|
||||
}, [reset, setPhaseSafe]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
|
|
@ -731,22 +716,6 @@ 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">
|
||||
|
|
|
|||
|
|
@ -1,234 +0,0 @@
|
|||
"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;
|
||||
}
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
"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;
|
||||
}
|
||||
|
|
@ -6,7 +6,6 @@ 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;
|
||||
|
|
|
|||
|
|
@ -1,33 +0,0 @@
|
|||
"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 };
|
||||
}
|
||||
|
|
@ -70,13 +70,10 @@ 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", completedAt: now.toISOString() } },
|
||||
data: { ownershipAndStatus: { status: "Completed" } },
|
||||
overrideAccess: true,
|
||||
});
|
||||
await emitGameEvent(payload, {
|
||||
|
|
|
|||
|
|
@ -1,174 +0,0 @@
|
|||
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),
|
||||
}));
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
/**
|
||||
* 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
|
|
@ -1,11 +0,0 @@
|
|||
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
|
|
@ -1,13 +0,0 @@
|
|||
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";`)
|
||||
}
|
||||
|
|
@ -5,8 +5,6 @@ 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 = [
|
||||
{
|
||||
|
|
@ -42,16 +40,6 @@ 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',
|
||||
},
|
||||
{
|
||||
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'
|
||||
name: '20260827_213857_add_evaluations_collection'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1099,10 +1099,6 @@ 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;
|
||||
|
|
@ -1924,10 +1920,6 @@ 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;
|
||||
|
|
@ -3640,7 +3632,6 @@ export interface MissionsSelect<T extends boolean = true> {
|
|||
zeus?: T;
|
||||
status?: T;
|
||||
visibility?: T;
|
||||
completedAt?: T;
|
||||
};
|
||||
missionRoles?:
|
||||
| T
|
||||
|
|
@ -3750,7 +3741,6 @@ export interface CampaignsSelect<T extends boolean = true> {
|
|||
name?: T;
|
||||
summary?: T;
|
||||
status?: T;
|
||||
campaignMode?: T;
|
||||
description?: T;
|
||||
startDate?: T;
|
||||
endDate?: T;
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ describe("Leadership evaluations", () => {
|
|||
|
||||
const campaign = (await payload.create({
|
||||
collection: "campaigns",
|
||||
data: { name: `${RUN} Campaign`, summary: "Evaluation test campaign", status: "concept", campaignMode: "custom" },
|
||||
data: { name: `${RUN} Campaign`, summary: "Evaluation test campaign", status: "concept" },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as Campaign;
|
||||
|
|
|
|||
|
|
@ -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", campaignMode: "custom" },
|
||||
data: { name: `${RUN} Campaign`, summary: "Auto-complete test campaign", status: "concept" },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as Campaign;
|
||||
|
|
@ -93,9 +93,7 @@ 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 (
|
||||
|
|
@ -198,79 +196,6 @@ 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());
|
||||
|
|
|
|||
|
|
@ -1,150 +0,0 @@
|
|||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,409 +0,0 @@
|
|||
// @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
|
||||
});
|
||||
});
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
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();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue