feat(surveys): periodic reminder toasts for pending surveys
- getPendingSurveys resolves open + eligible + unsubmitted surveys (single eligibility path shared with the list page) - GET /api/surveys/pending poll endpoint (cookie auth) - SurveyReminderToasts in the frontend layout: polls every 5 min, toasts at most once per 15 min, action deep-links into the modal - integration coverage for the pending resolution
This commit is contained in:
parent
d56af60366
commit
cbb12e281c
5 changed files with 199 additions and 0 deletions
|
|
@ -14,6 +14,7 @@ import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQu
|
||||||
import { canAccessAdminPanel } from "@/utils/access-control/divisionAccess";
|
import { canAccessAdminPanel } from "@/utils/access-control/divisionAccess";
|
||||||
import { ShipmentToasts } from "@/components/frontend/logistics/ShipmentToasts";
|
import { ShipmentToasts } from "@/components/frontend/logistics/ShipmentToasts";
|
||||||
import { MissionReminderToasts } from "@/components/frontend/intelligence/MissionReminderToasts";
|
import { MissionReminderToasts } from "@/components/frontend/intelligence/MissionReminderToasts";
|
||||||
|
import { SurveyReminderToasts } from "@/components/frontend/surveys/SurveyReminderToasts";
|
||||||
import { GameTickRealtime } from "@/components/frontend/realtime/GameTickRealtime";
|
import { GameTickRealtime } from "@/components/frontend/realtime/GameTickRealtime";
|
||||||
import { SessionWatchdog } from "@/components/frontend/session/SessionWatchdog";
|
import { SessionWatchdog } from "@/components/frontend/session/SessionWatchdog";
|
||||||
import { SessionExpiryBanner } from "@/components/frontend/session/SessionExpiryBanner";
|
import { SessionExpiryBanner } from "@/components/frontend/session/SessionExpiryBanner";
|
||||||
|
|
@ -225,6 +226,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
|
||||||
{user && <SessionWatchdog sessionExp={sessionExp} />}
|
{user && <SessionWatchdog sessionExp={sessionExp} />}
|
||||||
{process.env.NODE_ENV === "development" && user && <DevDashboardGate />}
|
{process.env.NODE_ENV === "development" && user && <DevDashboardGate />}
|
||||||
{user && <MissionReminderToasts />}
|
{user && <MissionReminderToasts />}
|
||||||
|
{user && <SurveyReminderToasts />}
|
||||||
{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 ?? []} />
|
||||||
|
|
|
||||||
25
src/app/api/surveys/pending/route.ts
Normal file
25
src/app/api/surveys/pending/route.ts
Normal file
|
|
@ -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 })),
|
||||||
|
});
|
||||||
|
}
|
||||||
74
src/components/frontend/surveys/SurveyReminderToasts.tsx
Normal file
74
src/components/frontend/surveys/SurveyReminderToasts.tsx
Normal file
|
|
@ -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: <ClipboardCheck className="size-4 text-muted-foreground" />,
|
||||||
|
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;
|
||||||
|
}
|
||||||
70
src/lib/surveys/pending.ts
Normal file
70
src/lib/surveys/pending.ts
Normal file
|
|
@ -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<string[]> {
|
||||||
|
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<Form[]> {
|
||||||
|
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;
|
||||||
|
|
@ -6,6 +6,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
import type { Form, Qualification, Role, User } from "@/payload-types";
|
import type { Form, Qualification, Role, User } from "@/payload-types";
|
||||||
import { invalidatePermissionCache } from "@/utils/access-control/loadUserPermissions";
|
import { invalidatePermissionCache } from "@/utils/access-control/loadUserPermissions";
|
||||||
import { isSurveyOpen, surveyAudienceMatches } from "@/lib/surveys/evaluate";
|
import { isSurveyOpen, surveyAudienceMatches } from "@/lib/surveys/evaluate";
|
||||||
|
import { getPendingSurveys } from "@/lib/surveys/pending";
|
||||||
|
|
||||||
let payload: Payload;
|
let payload: Payload;
|
||||||
|
|
||||||
|
|
@ -74,8 +75,10 @@ describe("survey evaluation (pure)", () => {
|
||||||
describe("survey submission gates", () => {
|
describe("survey submission gates", () => {
|
||||||
let memberUser: User;
|
let memberUser: User;
|
||||||
let roleAUser: User;
|
let roleAUser: User;
|
||||||
|
let roleA2User: User;
|
||||||
let roleBUser: User;
|
let roleBUser: User;
|
||||||
let qualifiedUser: User;
|
let qualifiedUser: User;
|
||||||
|
let qualified2User: User;
|
||||||
let unqualifiedUser: User;
|
let unqualifiedUser: User;
|
||||||
|
|
||||||
let roleA: Role;
|
let roleA: Role;
|
||||||
|
|
@ -216,12 +219,15 @@ describe("survey submission gates", () => {
|
||||||
|
|
||||||
memberUser = await makeUser("member", plainRole.id);
|
memberUser = await makeUser("member", plainRole.id);
|
||||||
roleAUser = await makeUser("role-a", roleA.id);
|
roleAUser = await makeUser("role-a", roleA.id);
|
||||||
|
roleA2User = await makeUser("role-a-2", roleA.id);
|
||||||
roleBUser = await makeUser("role-b", roleB.id);
|
roleBUser = await makeUser("role-b", roleB.id);
|
||||||
|
|
||||||
const qual = await makeQualification(`${RUN}-parachutist`);
|
const qual = await makeQualification(`${RUN}-parachutist`);
|
||||||
qualifiedUser = await makeUser("qualified", plainRole.id);
|
qualifiedUser = await makeUser("qualified", plainRole.id);
|
||||||
|
qualified2User = await makeUser("qualified-2", plainRole.id);
|
||||||
unqualifiedUser = await makeUser("unqualified", plainRole.id);
|
unqualifiedUser = await makeUser("unqualified", plainRole.id);
|
||||||
await makeProfile(qualifiedUser, [qual.id]);
|
await makeProfile(qualifiedUser, [qual.id]);
|
||||||
|
await makeProfile(qualified2User, [qual.id]);
|
||||||
await makeProfile(unqualifiedUser, []);
|
await makeProfile(unqualifiedUser, []);
|
||||||
|
|
||||||
openForm = await makeForm("open");
|
openForm = await makeForm("open");
|
||||||
|
|
@ -304,6 +310,28 @@ describe("survey submission gates", () => {
|
||||||
submissionIds.push(allowed.id);
|
submissionIds.push(allowed.id);
|
||||||
await expect(submit(qualsForm.id, unqualifiedUser)).rejects.toThrow(/not eligible/);
|
await expect(submit(qualsForm.id, unqualifiedUser)).rejects.toThrow(/not eligible/);
|
||||||
}, TIMEOUT);
|
}, 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", () => {
|
describe("survey collection access control", () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue