diff --git a/src/app/(frontend)/layout.tsx b/src/app/(frontend)/layout.tsx index 6f025af..f8a01f1 100644 --- a/src/app/(frontend)/layout.tsx +++ b/src/app/(frontend)/layout.tsx @@ -14,6 +14,7 @@ import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQu import { canAccessAdminPanel } from "@/utils/access-control/divisionAccess"; import { ShipmentToasts } from "@/components/frontend/logistics/ShipmentToasts"; import { MissionReminderToasts } from "@/components/frontend/intelligence/MissionReminderToasts"; +import { SurveyReminderToasts } from "@/components/frontend/surveys/SurveyReminderToasts"; import { GameTickRealtime } from "@/components/frontend/realtime/GameTickRealtime"; import { SessionWatchdog } from "@/components/frontend/session/SessionWatchdog"; import { SessionExpiryBanner } from "@/components/frontend/session/SessionExpiryBanner"; @@ -225,6 +226,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) { {user && } {process.env.NODE_ENV === "development" && user && } {user && } + {user && } {isLogistics && } diff --git a/src/app/api/surveys/pending/route.ts b/src/app/api/surveys/pending/route.ts new file mode 100644 index 0000000..02c4dee --- /dev/null +++ b/src/app/api/surveys/pending/route.ts @@ -0,0 +1,25 @@ +import config from "@payload-config"; +import { getPayload } from "payload"; +import { NextResponse } from "next/server"; +import { getPendingSurveys } from "@/lib/surveys/pending"; + +export const dynamic = "force-dynamic"; + +/** + * GET /api/surveys/pending — the signed-in user's open, eligible, uncompleted + * surveys. Polled by the layout-mounted SurveyReminderToasts (5 min) so users + * are reminded about surveys they can still take. + */ +export async function GET(request: Request) { + const payload = await getPayload({ config }); + const { user } = await payload.auth({ headers: request.headers, canSetHeaders: false }); + + if (!user || user.collection !== "users") { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const surveys = await getPendingSurveys(payload, user); + return NextResponse.json({ + surveys: surveys.map((s) => ({ id: s.id, title: s.title })), + }); +} diff --git a/src/components/frontend/surveys/SurveyReminderToasts.tsx b/src/components/frontend/surveys/SurveyReminderToasts.tsx new file mode 100644 index 0000000..aa6dae2 --- /dev/null +++ b/src/components/frontend/surveys/SurveyReminderToasts.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { ClipboardCheck } from "lucide-react"; + +const POLL_MS = 5 * 60_000; +const THROTTLE_MS = 15 * 60_000; +const FIRST_CHECK_DELAY_MS = 45_000; +const STORAGE_KEY = "ptf:survey:reminder-at"; + +interface PendingSurvey { + id: number; + title: string; +} + +/** + * Periodic reminder for surveys the user can take but has not completed. + * Polls /api/surveys/pending (open + eligible + unsubmitted, resolved + * server-side) and toasts at most once per THROTTLE_MS while surveys are + * pending. The toast action deep-links into the /surveys modal. + */ +export function SurveyReminderToasts() { + const router = useRouter(); + + useEffect(() => { + let cancelled = false; + + const check = async () => { + try { + const res = await fetch("/api/surveys/pending", { + credentials: "same-origin", + }); + if (!res.ok || cancelled) return; + + const data = (await res.json()) as { surveys?: PendingSurvey[] }; + const surveys = data.surveys ?? []; + if (cancelled || surveys.length === 0) return; + + const lastNotifiedAt = Number(localStorage.getItem(STORAGE_KEY) ?? 0); + if (Date.now() - lastNotifiedAt < THROTTLE_MS) return; + localStorage.setItem(STORAGE_KEY, String(Date.now())); + + const first = surveys[0]; + const waiting = + surveys.length === 1 + ? "A survey is waiting for your input..." + : `${surveys.length} surveys are waiting for your input..`; + toast(waiting, { + description: surveys.length === 1 ? first.title : "Share your input before they close.", + icon: , + action: { + label: "Take survey", + onClick: () => router.push(`/surveys?open=${first.id}`), + }, + duration: 8000, + }); + } catch (error) { + console.error("Survey reminder check failed:", error); + } + }; + + const firstCheck = setTimeout(check, FIRST_CHECK_DELAY_MS); + const interval = setInterval(check, POLL_MS); + return () => { + cancelled = true; + clearTimeout(firstCheck); + clearInterval(interval); + }; + }, [router]); + + return null; +} diff --git a/src/lib/surveys/pending.ts b/src/lib/surveys/pending.ts new file mode 100644 index 0000000..a060ab8 --- /dev/null +++ b/src/lib/surveys/pending.ts @@ -0,0 +1,70 @@ +/** + * Server-side pending-survey resolution: open + audience-eligible + not yet + * submitted by this user. Single source of truth for the /surveys list page, + * the /api/surveys/pending poll endpoint, and the reminder toast, so the + * client-side reminder can never advertise a survey the gates would reject. + */ +import type { Payload, User as PayloadUser } from "payload"; +import type { Form, User } from "@/payload-types"; +import { isSurveyOpen, surveyAudienceMatches } from "@/lib/surveys/evaluate"; + +function audienceContext(payload: Payload, user: User) { + const roleIds = (user.roleDocs ?? []) + .map((r) => (typeof r === "object" ? r.id : r)) + .filter((id): id is number => typeof id === "number"); + + return { userId: user.id, roleIds, payload }; +} + +async function qualificationNames(payload: Payload, userId: number): Promise { + const profileRes = await payload.find({ + collection: "profiles", + where: { user: { equals: userId } }, + limit: 1, + depth: 1, + select: { progression: { qualifications: true } }, + }); + + return (profileRes.docs[0]?.progression?.qualifications ?? []) + .map((q) => + typeof q === "object" && q !== null && "name" in q + ? (q.name as string) + : typeof q === "string" + ? q + : null, + ) + .filter((n): n is string => n !== null); +} + +/** + * Full survey docs the user can still take: inside the availability window, + * matching the audience targeting, and not yet submitted by them. + */ +export async function getPendingSurveys(payload: Payload, user: User): Promise { + const [formsRes, quals] = await Promise.all([ + payload.find({ collection: "forms", sort: "-createdAt", limit: 100, depth: 1 }), + qualificationNames(payload, user.id), + ]); + + const ctx = { ...audienceContext(payload, user), qualificationNames: quals }; + const now = Date.now(); + + const eligible = formsRes.docs.filter( + (form) => isSurveyOpen(form, now) && surveyAudienceMatches(form, ctx), + ); + if (eligible.length === 0) return []; + + const submitted = await payload.find({ + collection: "form-submissions", + where: { + and: [{ user: { equals: user.id } }, { form: { in: eligible.map((f) => f.id) } }], + }, + limit: 0, + depth: 0, + }); + const submittedFormIds = new Set(submitted.docs.map((d) => d.form as number)); + + return eligible.filter((f) => !submittedFormIds.has(f.id)); +} + +export type PendingSurveyUser = PayloadUser; diff --git a/tests/int/surveys.int.spec.ts b/tests/int/surveys.int.spec.ts index ab76107..04a345c 100644 --- a/tests/int/surveys.int.spec.ts +++ b/tests/int/surveys.int.spec.ts @@ -6,6 +6,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import type { Form, Qualification, Role, User } from "@/payload-types"; import { invalidatePermissionCache } from "@/utils/access-control/loadUserPermissions"; import { isSurveyOpen, surveyAudienceMatches } from "@/lib/surveys/evaluate"; +import { getPendingSurveys } from "@/lib/surveys/pending"; let payload: Payload; @@ -74,8 +75,10 @@ describe("survey evaluation (pure)", () => { describe("survey submission gates", () => { let memberUser: User; let roleAUser: User; + let roleA2User: User; let roleBUser: User; let qualifiedUser: User; + let qualified2User: User; let unqualifiedUser: User; let roleA: Role; @@ -216,12 +219,15 @@ describe("survey submission gates", () => { memberUser = await makeUser("member", plainRole.id); roleAUser = await makeUser("role-a", roleA.id); + roleA2User = await makeUser("role-a-2", roleA.id); roleBUser = await makeUser("role-b", roleB.id); const qual = await makeQualification(`${RUN}-parachutist`); qualifiedUser = await makeUser("qualified", plainRole.id); + qualified2User = await makeUser("qualified-2", plainRole.id); unqualifiedUser = await makeUser("unqualified", plainRole.id); await makeProfile(qualifiedUser, [qual.id]); + await makeProfile(qualified2User, [qual.id]); await makeProfile(unqualifiedUser, []); openForm = await makeForm("open"); @@ -304,6 +310,28 @@ describe("survey submission gates", () => { submissionIds.push(allowed.id); await expect(submit(qualsForm.id, unqualifiedUser)).rejects.toThrow(/not eligible/); }, TIMEOUT); + + it("getPendingSurveys returns only open, eligible, unsubmitted surveys", async () => { + // memberUser submitted openForm + usersForm; the rest are excluded by + // window (closed/future) or audience (roles/qualifications). + expect((await getPendingSurveys(payload, memberUser)).map((f) => f.id)).toEqual([]); + + // roleA2User holds roleA but has not submitted anything: openForm and + // rolesForm are both pending for them. Same-second createdAt ties make + // the -createdAt sort order unstable, so assert as an unordered set. + const roleA2Pending = (await getPendingSurveys(payload, roleA2User)).map((f) => f.id); + expect(roleA2Pending).toHaveLength(2); + expect(roleA2Pending).toEqual(expect.arrayContaining([openForm.id, rolesForm.id])); + + // qualified2User holds the qualification but has not submitted: qualsForm + // and the all-audience openForm are both pending for them. + const qualified2Pending = (await getPendingSurveys(payload, qualified2User)).map((f) => f.id); + expect(qualified2Pending).toHaveLength(2); + expect(qualified2Pending).toEqual(expect.arrayContaining([qualsForm.id, openForm.id])); + + // roleAUser submitted openForm + rolesForm, so nothing is pending. + expect((await getPendingSurveys(payload, roleAUser)).map((f) => f.id)).toEqual([]); + }, TIMEOUT); }); describe("survey collection access control", () => {