Compare commits
6 commits
055db8d0f0
...
53d053b3b2
| Author | SHA1 | Date | |
|---|---|---|---|
| 53d053b3b2 | |||
| 450697416b | |||
| 64babe2608 | |||
| d94da34af2 | |||
| c2a16120cc | |||
| f6d42399d4 |
29 changed files with 42150 additions and 69 deletions
|
|
@ -12,7 +12,10 @@ import { type ResolvedLevel, resolveLevel } from "@/utils/xp/resolveLevel";
|
||||||
import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
|
import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
|
||||||
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
||||||
import { ShipmentToasts } from "@/components/frontend/logistics/ShipmentToasts";
|
import { ShipmentToasts } from "@/components/frontend/logistics/ShipmentToasts";
|
||||||
|
import { MissionReminderToasts } from "@/components/frontend/intelligence/MissionReminderToasts";
|
||||||
import { GameTickRealtime } from "@/components/frontend/realtime/GameTickRealtime";
|
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 VersionOverlay from "@/components/static/VersionOverlay";
|
||||||
import { LandingPage } from "@/components/frontend/LandingPage";
|
import { LandingPage } from "@/components/frontend/LandingPage";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
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 payload = await getPayload({ config });
|
||||||
const { user: authUser } = await payload.auth({ headers, canSetHeaders: false });
|
const { user: authUser } = await payload.auth({ headers, canSetHeaders: false });
|
||||||
const user = isPayloadUser(authUser) ? authUser : null;
|
const user = isPayloadUser(authUser) ? authUser : null;
|
||||||
|
const sessionExp = getSessionExpMs(headers);
|
||||||
const impersonating = (await cookies()).has(IMPERSONATION_ACTIVE_COOKIE);
|
const impersonating = (await cookies()).has(IMPERSONATION_ACTIVE_COOKIE);
|
||||||
|
|
||||||
let xpLevel: ResolvedLevel = {
|
let xpLevel: ResolvedLevel = {
|
||||||
|
|
@ -156,6 +160,8 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
|
||||||
<VersionOverlay canSeeCommit={await isSuperuser(payload, user)} />
|
<VersionOverlay canSeeCommit={await isSuperuser(payload, user)} />
|
||||||
)}
|
)}
|
||||||
{user && <GameTickRealtime />}
|
{user && <GameTickRealtime />}
|
||||||
|
{user && <SessionWatchdog sessionExp={sessionExp} />}
|
||||||
|
{user && <MissionReminderToasts />}
|
||||||
{isLogistics && <ShipmentToasts />}
|
{isLogistics && <ShipmentToasts />}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<ShimLoader shims={shimEntries} userId={user?.id ?? null} userRoles={user?.roles ?? []} />
|
<ShimLoader shims={shimEntries} userId={user?.id ?? null} userRoles={user?.roles ?? []} />
|
||||||
|
|
|
||||||
30
src/app/api/mission-reminder/route.ts
Normal file
30
src/app/api/mission-reminder/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -66,7 +66,10 @@ export const handleButtonInteraction = async (
|
||||||
await setMissionAttendance(payload, { missionId, userId: user.id, response });
|
await setMissionAttendance(payload, { missionId, userId: user.id, response });
|
||||||
|
|
||||||
const summary = await getMissionAttendance(payload, missionId);
|
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({
|
await interaction.message.edit({
|
||||||
embeds: [renderAttendanceEmbed(mission, summary)],
|
embeds: [renderAttendanceEmbed(mission, summary)],
|
||||||
|
|
|
||||||
|
|
@ -28,12 +28,6 @@ const missionTitle = (mission: Mission): string =>
|
||||||
|
|
||||||
const memberName = (user: User): string => user.displayName ?? user.username;
|
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
|
* Roll call is sent once: as soon as the mission is Ready, or three days
|
||||||
* before a Scheduled mission, whichever happens first.
|
* 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
|
* Deterministic fingerprint of the attendance state and scheduled start time
|
||||||
* embeds that haven't changed (anti-loop).
|
* 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
|
const rows = docs
|
||||||
.map((doc) => {
|
.map((doc) => {
|
||||||
const userId = typeof doc.user === "object" ? doc.user.id : doc.user;
|
const userId = typeof doc.user === "object" ? doc.user.id : doc.user;
|
||||||
return [userId, doc.response] as const;
|
return [userId, doc.response] as const;
|
||||||
})
|
})
|
||||||
.sort((a, b) => a[0] - b[0] || a[1].localeCompare(b[1]));
|
.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 (
|
export const fetchAttendanceDocs = async (
|
||||||
|
|
@ -97,17 +92,23 @@ export const getEligibleMissions = async (payload: Payload): Promise<Mission[]>
|
||||||
limit: MAX_ELIGIBLE_MISSIONS,
|
limit: MAX_ELIGIBLE_MISSIONS,
|
||||||
where: {
|
where: {
|
||||||
and: [
|
and: [
|
||||||
{ "classification.startDateTime": { greater_than: new Date().toISOString() } },
|
|
||||||
{ "ownershipAndStatus.visibility": { equals: "unit" } },
|
{ "ownershipAndStatus.visibility": { equals: "unit" } },
|
||||||
{
|
{
|
||||||
or: [
|
or: [
|
||||||
{ "ownershipAndStatus.status": { equals: "Ready" } },
|
|
||||||
{
|
{
|
||||||
and: [
|
and: [
|
||||||
|
{ "classification.startDateTime": { greater_than: new Date().toISOString() } },
|
||||||
|
{ "ownershipAndStatus.status": { equals: "Ready" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
and: [
|
||||||
|
{ "classification.startDateTime": { greater_than: new Date().toISOString() } },
|
||||||
{ "ownershipAndStatus.status": { equals: "Scheduled" } },
|
{ "ownershipAndStatus.status": { equals: "Scheduled" } },
|
||||||
{ "classification.startDateTime": { less_than_equal: rollCallCutoff } },
|
{ "classification.startDateTime": { less_than_equal: rollCallCutoff } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{ discordMessageId: { exists: true } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -200,7 +201,10 @@ const postEmbed = async (
|
||||||
mission: Mission,
|
mission: Mission,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const summary = await getMissionAttendance(payload, mission.id);
|
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 channel = await getOpsChannel(client, opsChannelId);
|
||||||
const message = await channel.send({
|
const message = await channel.send({
|
||||||
embeds: [renderAttendanceEmbed(mission, summary)],
|
embeds: [renderAttendanceEmbed(mission, summary)],
|
||||||
|
|
@ -257,7 +261,10 @@ const manageMission = async (
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hash = attendanceHash(await fetchAttendanceDocs(payload, mission.id));
|
const hash = attendanceHash(
|
||||||
|
await fetchAttendanceDocs(payload, mission.id),
|
||||||
|
mission.classification.startDateTime,
|
||||||
|
);
|
||||||
if (hash === mission.discordAttendanceHash) {
|
if (hash === mission.discordAttendanceHash) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -297,7 +304,6 @@ export const reconcileMission = async (
|
||||||
if (
|
if (
|
||||||
mission.ownershipAndStatus.visibility !== "unit" ||
|
mission.ownershipAndStatus.visibility !== "unit" ||
|
||||||
!["Ready", "Scheduled"].includes(mission.ownershipAndStatus.status) ||
|
!["Ready", "Scheduled"].includes(mission.ownershipAndStatus.status) ||
|
||||||
!isStillFuture(mission) ||
|
|
||||||
(!isRollCallDue(mission) && !mission.discordMessageId)
|
(!isRollCallDue(mission) && !mission.discordMessageId)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -353,7 +359,7 @@ export const reconcile = async (client: Client, payload: Payload): Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
const eligible = (await getEligibleMissions(payload)).filter(
|
const eligible = (await getEligibleMissions(payload)).filter(
|
||||||
(mission) => isStillFuture(mission) && (isRollCallDue(mission) || !!mission.discordMessageId),
|
(mission) => isRollCallDue(mission) || !!mission.discordMessageId,
|
||||||
);
|
);
|
||||||
for (const mission of eligible) {
|
for (const mission of eligible) {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { CollectionConfig } from "payload";
|
import { CollectionConfig } from "payload";
|
||||||
import { requirePermission, requireCampaignOwnership } from "@/utils/access-control/hasPermission";
|
import { requirePermission, requireCampaignOwnership } from "@/utils/access-control/hasPermission";
|
||||||
|
import { isDeveloper } from "@/utils/access-control/isRole";
|
||||||
import type { User } from "@/payload-types";
|
import type { User } from "@/payload-types";
|
||||||
|
|
||||||
export const Campaigns: CollectionConfig = {
|
export const Campaigns: CollectionConfig = {
|
||||||
|
|
@ -47,6 +48,30 @@ export const Campaigns: CollectionConfig = {
|
||||||
required: true,
|
required: true,
|
||||||
defaultValue: "concept",
|
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",
|
name: "description",
|
||||||
type: "richText",
|
type: "richText",
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,29 @@ export const Missions: CollectionConfig = {
|
||||||
group: "Intelligence",
|
group: "Intelligence",
|
||||||
useAsTitle: "name",
|
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: {
|
access: {
|
||||||
create: async ({ req }) => {
|
create: async ({ req }) => {
|
||||||
return await hasPermission(req.payload, req.user, "missions:create");
|
return await hasPermission(req.payload, req.user, "missions:create");
|
||||||
|
|
@ -305,11 +328,20 @@ export const Missions: CollectionConfig = {
|
||||||
value: "private",
|
value: "private",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
defaultValue: "leadership",
|
defaultValue: "leadership",
|
||||||
required: true,
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "missionRoles",
|
name: "missionRoles",
|
||||||
type: "group",
|
type: "group",
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,7 @@ export const Users: CollectionConfig = {
|
||||||
group: "Users",
|
group: "Users",
|
||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
|
tokenExpiration: 43200,
|
||||||
loginWithUsername: {
|
loginWithUsername: {
|
||||||
requireUsername: true,
|
requireUsername: true,
|
||||||
allowEmailLogin: false,
|
allowEmailLogin: false,
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ import { Input } from "@/components/ui/input";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { submitEodResult, type EodDifficulty, type EodStats } from "@/app/(frontend)/eod/actions";
|
import { submitEodResult, type EodDifficulty, type EodStats } from "@/app/(frontend)/eod/actions";
|
||||||
import { PROFILE_UPDATE_EVENT } from "@/hooks/useGameTick";
|
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 { cn } from "@/lib/utils";
|
||||||
import { playClear, playExplosion, playFlag, playReveal, playXp } from "./sounds";
|
import { playClear, playExplosion, playFlag, playReveal, playXp } from "./sounds";
|
||||||
|
|
||||||
|
|
@ -182,6 +184,7 @@ export function MinesweeperBoard({
|
||||||
onDifficultyChange: (difficulty: EodDifficulty) => void;
|
onDifficultyChange: (difficulty: EodDifficulty) => void;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { expired, expiredRef } = useSessionExpired();
|
||||||
const [difficulty, setDifficulty] = useState<GameMode>("training");
|
const [difficulty, setDifficulty] = useState<GameMode>("training");
|
||||||
const [customConfig, setCustomConfig] = useState<GameConfig>({
|
const [customConfig, setCustomConfig] = useState<GameConfig>({
|
||||||
label: "Custom Minefield",
|
label: "Custom Minefield",
|
||||||
|
|
@ -240,6 +243,7 @@ export function MinesweeperBoard({
|
||||||
const reset = useCallback(
|
const reset = useCallback(
|
||||||
(nextDifficulty: GameMode = difficulty) => {
|
(nextDifficulty: GameMode = difficulty) => {
|
||||||
if (submittingRef.current) return;
|
if (submittingRef.current) return;
|
||||||
|
if (expiredRef.current) return;
|
||||||
const nextConfig = nextDifficulty === "custom" ? customConfig : DIFFICULTIES[nextDifficulty];
|
const nextConfig = nextDifficulty === "custom" ? customConfig : DIFFICULTIES[nextDifficulty];
|
||||||
setDifficulty(nextDifficulty);
|
setDifficulty(nextDifficulty);
|
||||||
if (nextDifficulty !== "custom") onDifficultyChange(nextDifficulty);
|
if (nextDifficulty !== "custom") onDifficultyChange(nextDifficulty);
|
||||||
|
|
@ -249,7 +253,7 @@ export function MinesweeperBoard({
|
||||||
setLastRun(null);
|
setLastRun(null);
|
||||||
setPhaseSafe("ready");
|
setPhaseSafe("ready");
|
||||||
},
|
},
|
||||||
[customConfig, difficulty, onDifficultyChange, setPhaseSafe],
|
[customConfig, difficulty, expiredRef, onDifficultyChange, setPhaseSafe],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -284,6 +288,17 @@ export function MinesweeperBoard({
|
||||||
const recordResult = useCallback(
|
const recordResult = useCallback(
|
||||||
async (won: boolean, elapsedSeconds: number, flags: number) => {
|
async (won: boolean, elapsedSeconds: number, flags: number) => {
|
||||||
if (submittingRef.current) return;
|
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") {
|
if (difficulty === "custom") {
|
||||||
setLastRun({
|
setLastRun({
|
||||||
won,
|
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(
|
const finishGame = useCallback(
|
||||||
|
|
@ -366,6 +381,7 @@ export function MinesweeperBoard({
|
||||||
const reveal = useCallback(
|
const reveal = useCallback(
|
||||||
(index: number) => {
|
(index: number) => {
|
||||||
if (phase === "over" || submittingRef.current) return;
|
if (phase === "over" || submittingRef.current) return;
|
||||||
|
if (expiredRef.current) return;
|
||||||
const existing = board[index];
|
const existing = board[index];
|
||||||
if (!existing || existing.flagged || existing.revealed) return;
|
if (!existing || existing.flagged || existing.revealed) return;
|
||||||
const workingBoard = generatedRef.current ? board : createBoard(config, index);
|
const workingBoard = generatedRef.current ? board : createBoard(config, index);
|
||||||
|
|
@ -387,12 +403,13 @@ export function MinesweeperBoard({
|
||||||
setBoard(nextBoard);
|
setBoard(nextBoard);
|
||||||
if (nextBoard.every((item) => item.mine || item.revealed)) finishGame(true, 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(
|
const toggleFlag = useCallback(
|
||||||
(index: number) => {
|
(index: number) => {
|
||||||
if (phase === "over" || submittingRef.current) return;
|
if (phase === "over" || submittingRef.current) return;
|
||||||
|
if (expiredRef.current) return;
|
||||||
const cell = board[index];
|
const cell = board[index];
|
||||||
if (!cell || cell.revealed) return;
|
if (!cell || cell.revealed) return;
|
||||||
playFlag();
|
playFlag();
|
||||||
|
|
@ -402,7 +419,7 @@ export function MinesweeperBoard({
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[board, phase],
|
[board, expiredRef, phase],
|
||||||
);
|
);
|
||||||
|
|
||||||
const clearLongPress = useCallback(() => {
|
const clearLongPress = useCallback(() => {
|
||||||
|
|
@ -450,7 +467,7 @@ export function MinesweeperBoard({
|
||||||
aria-label={cellLabel(cell, row, col)}
|
aria-label={cellLabel(cell, row, col)}
|
||||||
aria-rowindex={row + 1}
|
aria-rowindex={row + 1}
|
||||||
aria-colindex={col + 1}
|
aria-colindex={col + 1}
|
||||||
disabled={phase === "over" || cell.revealed}
|
disabled={phase === "over" || expired || cell.revealed}
|
||||||
className={cn(
|
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",
|
"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 &&
|
cell.revealed &&
|
||||||
|
|
@ -516,6 +533,21 @@ export function MinesweeperBoard({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full max-w-5xl flex-col gap-4">
|
<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 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-wrap items-start justify-between gap-4">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
|
|
@ -567,7 +599,7 @@ export function MinesweeperBoard({
|
||||||
type="button"
|
type="button"
|
||||||
variant={difficulty === key ? "default" : "outline"}
|
variant={difficulty === key ? "default" : "outline"}
|
||||||
className="h-auto justify-start px-3 py-2 text-left"
|
className="h-auto justify-start px-3 py-2 text-left"
|
||||||
disabled={submitting}
|
disabled={submitting || expired}
|
||||||
aria-pressed={difficulty === key}
|
aria-pressed={difficulty === key}
|
||||||
onClick={() => reset(key)}
|
onClick={() => reset(key)}
|
||||||
>
|
>
|
||||||
|
|
@ -676,7 +708,7 @@ export function MinesweeperBoard({
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant={difficulty === "custom" ? "default" : "outline"}
|
variant={difficulty === "custom" ? "default" : "outline"}
|
||||||
disabled={submitting}
|
disabled={submitting || expired}
|
||||||
aria-pressed={difficulty === "custom"}
|
aria-pressed={difficulty === "custom"}
|
||||||
onClick={() => reset("custom")}
|
onClick={() => reset("custom")}
|
||||||
>
|
>
|
||||||
|
|
@ -731,7 +763,7 @@ export function MinesweeperBoard({
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={submitting}
|
disabled={submitting || expired}
|
||||||
onClick={() => reset()}
|
onClick={() => reset()}
|
||||||
>
|
>
|
||||||
<RotateCcw /> Retry sector
|
<RotateCcw /> Retry sector
|
||||||
|
|
@ -759,7 +791,7 @@ export function MinesweeperBoard({
|
||||||
aria-label={cellLabel(cell, row, col)}
|
aria-label={cellLabel(cell, row, col)}
|
||||||
aria-rowindex={row + 1}
|
aria-rowindex={row + 1}
|
||||||
aria-colindex={col + 1}
|
aria-colindex={col + 1}
|
||||||
disabled={phase === "over" || cell.revealed}
|
disabled={phase === "over" || expired || cell.revealed}
|
||||||
className={cn(
|
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",
|
"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 &&
|
cell.revealed &&
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@ import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { type FlappyStats, submitFlappyScore } from "@/app/(frontend)/flappy/actions";
|
import { type FlappyStats, submitFlappyScore } from "@/app/(frontend)/flappy/actions";
|
||||||
import { PROFILE_UPDATE_EVENT } from "@/hooks/useGameTick";
|
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";
|
import { playFlap, playHit, playXp } from "./sounds";
|
||||||
|
|
||||||
const CANVAS_W = 420;
|
const CANVAS_W = 420;
|
||||||
|
|
@ -333,6 +336,7 @@ export function FlappyBird({
|
||||||
onPhaseChange?: (phase: Phase) => void;
|
onPhaseChange?: (phase: Phase) => void;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { expired, expiredRef } = useSessionExpired();
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||||
const phaseRef = useRef<Phase>("ready");
|
const phaseRef = useRef<Phase>("ready");
|
||||||
|
|
@ -367,6 +371,7 @@ export function FlappyBird({
|
||||||
}, [onPhaseChange]);
|
}, [onPhaseChange]);
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
const reset = useCallback(() => {
|
||||||
|
if (expiredRef.current) return;
|
||||||
const g = gameRef.current;
|
const g = gameRef.current;
|
||||||
g.birdY = START_Y;
|
g.birdY = START_Y;
|
||||||
g.birdVy = 0;
|
g.birdVy = 0;
|
||||||
|
|
@ -383,6 +388,15 @@ export function FlappyBird({
|
||||||
const handleGameOver = useCallback(
|
const handleGameOver = useCallback(
|
||||||
async (finalScore: number) => {
|
async (finalScore: number) => {
|
||||||
if (submittingRef.current) return;
|
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) {
|
if (finalScore <= 0) {
|
||||||
setLastRun({
|
setLastRun({
|
||||||
score: finalScore,
|
score: finalScore,
|
||||||
|
|
@ -423,7 +437,7 @@ export function FlappyBird({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onGameRecorded, router],
|
[expiredRef, onGameRecorded, router],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleGameOverRef = useRef(handleGameOver);
|
const handleGameOverRef = useRef(handleGameOver);
|
||||||
|
|
@ -432,6 +446,7 @@ export function FlappyBird({
|
||||||
}, [handleGameOver]);
|
}, [handleGameOver]);
|
||||||
|
|
||||||
const flap = useCallback(() => {
|
const flap = useCallback(() => {
|
||||||
|
if (expiredRef.current) return;
|
||||||
const p = phaseRef.current;
|
const p = phaseRef.current;
|
||||||
if (p === "ready") {
|
if (p === "ready") {
|
||||||
const g = gameRef.current;
|
const g = gameRef.current;
|
||||||
|
|
@ -445,7 +460,7 @@ export function FlappyBird({
|
||||||
} else if (p === "over") {
|
} else if (p === "over") {
|
||||||
reset();
|
reset();
|
||||||
}
|
}
|
||||||
}, [reset, setPhaseSafe]);
|
}, [expiredRef, reset, setPhaseSafe]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
|
|
@ -716,6 +731,22 @@ export function FlappyBird({
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="flex flex-wrap justify-center gap-6 text-sm text-muted-foreground">
|
<div className="flex flex-wrap justify-center gap-6 text-sm text-muted-foreground">
|
||||||
|
|
|
||||||
234
src/components/frontend/intelligence/MissionReminderToasts.tsx
Normal file
234
src/components/frontend/intelligence/MissionReminderToasts.tsx
Normal 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;
|
||||||
|
}
|
||||||
122
src/components/frontend/intelligence/missionReminderClient.ts
Normal file
122
src/components/frontend/intelligence/missionReminderClient.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
91
src/components/frontend/session/SessionWatchdog.tsx
Normal file
91
src/components/frontend/session/SessionWatchdog.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ export const GAME_TICK_EVENT = "ptf:game-tick";
|
||||||
export const PROFILE_UPDATE_EVENT = "ptf:profile-update";
|
export const PROFILE_UPDATE_EVENT = "ptf:profile-update";
|
||||||
export const GAME_EVENT_EVENT = "ptf:game-event";
|
export const GAME_EVENT_EVENT = "ptf:game-event";
|
||||||
export const NOTIFICATION_EVENT = "ptf:notification";
|
export const NOTIFICATION_EVENT = "ptf:notification";
|
||||||
|
export const SESSION_EXPIRED_EVENT = "ptf:session-expired";
|
||||||
|
|
||||||
export interface GameTickDetail {
|
export interface GameTickDetail {
|
||||||
processedAt?: string;
|
processedAt?: string;
|
||||||
|
|
|
||||||
33
src/hooks/useSessionExpired.ts
Normal file
33
src/hooks/useSessionExpired.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
|
@ -70,10 +70,13 @@ export async function autoCompleteMissions(
|
||||||
for (const mission of due.docs) {
|
for (const mission of due.docs) {
|
||||||
const previousStatus = mission.ownershipAndStatus?.status ?? null;
|
const previousStatus = mission.ownershipAndStatus?.status ?? null;
|
||||||
try {
|
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({
|
await payload.update({
|
||||||
collection: "missions",
|
collection: "missions",
|
||||||
id: mission.id,
|
id: mission.id,
|
||||||
data: { ownershipAndStatus: { status: "Completed" } },
|
data: { ownershipAndStatus: { status: "Completed", completedAt: now.toISOString() } },
|
||||||
overrideAccess: true,
|
overrideAccess: true,
|
||||||
});
|
});
|
||||||
await emitGameEvent(payload, {
|
await emitGameEvent(payload, {
|
||||||
|
|
|
||||||
174
src/lib/intelligence/missionReminder.ts
Normal file
174
src/lib/intelligence/missionReminder.ts
Normal 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
44
src/lib/session/token.ts
Normal 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;
|
||||||
|
}
|
||||||
19334
src/migrations/20260827_233224_add_mission_completed_at.json
Normal file
19334
src/migrations/20260827_233224_add_mission_completed_at.json
Normal file
File diff suppressed because it is too large
Load diff
11
src/migrations/20260827_233224_add_mission_completed_at.ts
Normal file
11
src/migrations/20260827_233224_add_mission_completed_at.ts
Normal 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";`)
|
||||||
|
}
|
||||||
19350
src/migrations/20260828_041615_add_campaign_mode.json
Normal file
19350
src/migrations/20260828_041615_add_campaign_mode.json
Normal file
File diff suppressed because it is too large
Load diff
13
src/migrations/20260828_041615_add_campaign_mode.ts
Normal file
13
src/migrations/20260828_041615_add_campaign_mode.ts
Normal 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";`)
|
||||||
|
}
|
||||||
|
|
@ -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_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_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_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 = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
|
|
@ -40,6 +42,16 @@ export const migrations = [
|
||||||
{
|
{
|
||||||
up: migration_20260827_213857_add_evaluations_collection.up,
|
up: migration_20260827_213857_add_evaluations_collection.up,
|
||||||
down: migration_20260827_213857_add_evaluations_collection.down,
|
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
|
|
@ -1099,6 +1099,10 @@ export interface Campaign {
|
||||||
name: string;
|
name: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
status: 'concept' | 'inProgress' | 'completed';
|
status: 'concept' | 'inProgress' | 'completed';
|
||||||
|
/**
|
||||||
|
* Only developers can designate a campaign as official or custom.
|
||||||
|
*/
|
||||||
|
campaignMode: 'official' | 'custom';
|
||||||
description?: {
|
description?: {
|
||||||
root: {
|
root: {
|
||||||
type: string;
|
type: string;
|
||||||
|
|
@ -1920,6 +1924,10 @@ export interface Mission {
|
||||||
zeus?: (number | User)[] | null;
|
zeus?: (number | User)[] | null;
|
||||||
status: 'Concept' | 'Planning' | 'Ready' | 'Scheduled' | 'Active' | 'Completed' | 'Cancelled';
|
status: 'Concept' | 'Planning' | 'Ready' | 'Scheduled' | 'Active' | 'Completed' | 'Cancelled';
|
||||||
visibility: 'unit' | 'leadership' | 'intel' | 'private';
|
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: {
|
missionRoles: {
|
||||||
maxPlayers: number;
|
maxPlayers: number;
|
||||||
|
|
@ -3632,6 +3640,7 @@ export interface MissionsSelect<T extends boolean = true> {
|
||||||
zeus?: T;
|
zeus?: T;
|
||||||
status?: T;
|
status?: T;
|
||||||
visibility?: T;
|
visibility?: T;
|
||||||
|
completedAt?: T;
|
||||||
};
|
};
|
||||||
missionRoles?:
|
missionRoles?:
|
||||||
| T
|
| T
|
||||||
|
|
@ -3741,6 +3750,7 @@ export interface CampaignsSelect<T extends boolean = true> {
|
||||||
name?: T;
|
name?: T;
|
||||||
summary?: T;
|
summary?: T;
|
||||||
status?: T;
|
status?: T;
|
||||||
|
campaignMode?: T;
|
||||||
description?: T;
|
description?: T;
|
||||||
startDate?: T;
|
startDate?: T;
|
||||||
endDate?: T;
|
endDate?: T;
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,7 @@ describe("Leadership evaluations", () => {
|
||||||
|
|
||||||
const campaign = (await payload.create({
|
const campaign = (await payload.create({
|
||||||
collection: "campaigns",
|
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,
|
overrideAccess: true,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
})) as unknown as Campaign;
|
})) as unknown as Campaign;
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ describe("Mission auto-complete", () => {
|
||||||
|
|
||||||
const campaign = (await payload.create({
|
const campaign = (await payload.create({
|
||||||
collection: "campaigns",
|
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,
|
overrideAccess: true,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
})) as unknown as Campaign;
|
})) as unknown as Campaign;
|
||||||
|
|
@ -93,7 +93,9 @@ describe("Mission auto-complete", () => {
|
||||||
.delete({ collection: "profiles", id: profile.id, overrideAccess: true })
|
.delete({ collection: "profiles", id: profile.id, overrideAccess: true })
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
await payload.delete({ collection: "users", id: authorId, overrideAccess: true }).catch(() => {});
|
await payload
|
||||||
|
.delete({ collection: "users", id: authorId, overrideAccess: true })
|
||||||
|
.catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
const makeMission = async (
|
const makeMission = async (
|
||||||
|
|
@ -196,6 +198,79 @@ describe("Mission auto-complete", () => {
|
||||||
expect(result.missions).toEqual([]);
|
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", () => {
|
it("classifies day boundaries deterministically", () => {
|
||||||
const now = new Date(2026, 7, 24, 12, 0, 0);
|
const now = new Date(2026, 7, 24, 12, 0, 0);
|
||||||
expect(startOfToday(now).getTime()).toBe(new Date(2026, 7, 24, 0, 0, 0).getTime());
|
expect(startOfToday(now).getTime()).toBe(new Date(2026, 7, 24, 0, 0, 0).getTime());
|
||||||
|
|
|
||||||
150
tests/int/mission-reminder-toasts.int.spec.ts
Normal file
150
tests/int/mission-reminder-toasts.int.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
409
tests/int/mission-reminder.int.spec.ts
Normal file
409
tests/int/mission-reminder.int.spec.ts
Normal 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
|
||||||
|
});
|
||||||
|
});
|
||||||
70
tests/int/session-token.int.spec.ts
Normal file
70
tests/int/session-token.int.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue