Compare commits
No commits in common. "975a08195a53882815133934095d317e531a9c61" and "b85242d242e2ac249017133125c7b2bfa46d4787" have entirely different histories.
975a08195a
...
b85242d242
10 changed files with 113 additions and 389 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "polaris-task-force",
|
"name": "polaris-task-force",
|
||||||
"version": "0.2.52",
|
"version": "0.2.51",
|
||||||
"description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.",
|
"description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ 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";
|
||||||
|
|
@ -226,7 +225,6 @@ 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 ?? []} />
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { headers as nextHeaders } from "next/headers";
|
||||||
import { getPayload } from "payload";
|
import { getPayload } from "payload";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { ArrowLeftIcon, ClipboardCheck, Lock, TriangleAlert } from "lucide-react";
|
import { ClipboardCheck, Lock, TriangleAlert } from "lucide-react";
|
||||||
import { SurveyForm } from "@/components/frontend/surveys/SurveyForm";
|
import { SurveyForm } from "@/components/frontend/surveys/SurveyForm";
|
||||||
import { isSurveyOpen, surveyAudienceMatches } from "@/lib/surveys/evaluate";
|
import { isSurveyOpen, surveyAudienceMatches } from "@/lib/surveys/evaluate";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
|
@ -16,7 +16,7 @@ export const metadata = {
|
||||||
|
|
||||||
function NoticeCard({ icon, title, body }: { icon: React.ReactNode; title: string; body: string }) {
|
function NoticeCard({ icon, title, body }: { icon: React.ReactNode; title: string; body: string }) {
|
||||||
return (
|
return (
|
||||||
<Card className="max-w-2xl">
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center gap-3 py-12 text-center">
|
<CardContent className="flex flex-col items-center gap-3 py-12 text-center">
|
||||||
{icon}
|
{icon}
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -64,6 +64,26 @@ export default async function SurveyDetailPage({
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if (submissionCount.totalDocs > 0) {
|
||||||
|
return (
|
||||||
|
<NoticeCard
|
||||||
|
icon={<ClipboardCheck className="text-emerald-400 size-10" />}
|
||||||
|
title="Already submitted"
|
||||||
|
body="You have already responded to this survey."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSurveyOpen(form, Date.now())) {
|
||||||
|
return (
|
||||||
|
<NoticeCard
|
||||||
|
icon={<Lock className="text-muted-foreground size-10" />}
|
||||||
|
title="Survey closed"
|
||||||
|
body="This survey is not open right now."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const heldQualifications = (profileRes.docs[0]?.progression?.qualifications ?? [])
|
const heldQualifications = (profileRes.docs[0]?.progression?.qualifications ?? [])
|
||||||
.map((q) => (typeof q === "object" && q !== null && "name" in q ? (q.name as string) : typeof q === "string" ? q : null))
|
.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);
|
.filter((n): n is string => n !== null);
|
||||||
|
|
@ -78,70 +98,39 @@ export default async function SurveyDetailPage({
|
||||||
qualificationNames: heldQualifications,
|
qualificationNames: heldQualifications,
|
||||||
});
|
});
|
||||||
|
|
||||||
let content: React.ReactNode;
|
if (!eligible) {
|
||||||
if (submissionCount.totalDocs > 0) {
|
return (
|
||||||
content = (
|
|
||||||
<NoticeCard
|
|
||||||
icon={<ClipboardCheck className="text-emerald-400 size-10" />}
|
|
||||||
title="Already submitted"
|
|
||||||
body="You have already responded to this survey."
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (!isSurveyOpen(form, Date.now())) {
|
|
||||||
content = (
|
|
||||||
<NoticeCard
|
|
||||||
icon={<Lock className="text-muted-foreground size-10" />}
|
|
||||||
title="Survey closed"
|
|
||||||
body="This survey is not open right now."
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (!eligible) {
|
|
||||||
content = (
|
|
||||||
<NoticeCard
|
<NoticeCard
|
||||||
icon={<TriangleAlert className="text-amber-400 size-10" />}
|
icon={<TriangleAlert className="text-amber-400 size-10" />}
|
||||||
title="Not eligible"
|
title="Not eligible"
|
||||||
body="This survey is not available to your account."
|
body="This survey is not available to your account."
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else {
|
}
|
||||||
content = (
|
|
||||||
<Card className="max-w-2xl">
|
return (
|
||||||
<CardContent className="pt-6">
|
<div className="mx-auto max-w-2xl space-y-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
<Link href="/surveys" className="hover:underline">
|
||||||
|
Surveys
|
||||||
|
</Link>
|
||||||
|
{" / "}
|
||||||
|
Survey
|
||||||
|
</p>
|
||||||
|
<h1 className="text-2xl font-semibold">{form.title}</h1>
|
||||||
|
{form.activeUntil && (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Open until {new Date(form.activeUntil).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<SurveyForm
|
<SurveyForm
|
||||||
formId={form.id}
|
formId={form.id}
|
||||||
fields={form.fields ?? []}
|
fields={form.fields ?? []}
|
||||||
submitLabel={form.submitButtonLabel || "Submit"}
|
submitLabel={form.submitButtonLabel || "Submit"}
|
||||||
confirmationMessage={form.confirmationMessage ?? null}
|
confirmationMessage={form.confirmationMessage ?? null}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-6 p-5">
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<Link
|
|
||||||
href="/surveys"
|
|
||||||
className="flex w-fit items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-primary"
|
|
||||||
>
|
|
||||||
<ArrowLeftIcon className="size-3" />
|
|
||||||
Surveys
|
|
||||||
</Link>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ClipboardCheck className="size-5 text-muted-foreground" />
|
|
||||||
<h1 className="text-lg font-semibold">{form.title}</h1>
|
|
||||||
</div>
|
|
||||||
{form.activeUntil && (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Open until {new Date(form.activeUntil).toLocaleString()}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{content}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,18 +3,22 @@ import { headers as nextHeaders } from "next/headers";
|
||||||
import { getPayload } from "payload";
|
import { getPayload } from "payload";
|
||||||
import { ClipboardCheck } from "lucide-react";
|
import { ClipboardCheck } from "lucide-react";
|
||||||
import { SurveyList, type SurveyListItem } from "@/components/frontend/surveys/SurveyList";
|
import { SurveyList, type SurveyListItem } from "@/components/frontend/surveys/SurveyList";
|
||||||
import { getPendingSurveys } from "@/lib/surveys/pending";
|
import { isSurveyOpen, surveyAudienceMatches } from "@/lib/surveys/evaluate";
|
||||||
import type { User } from "@/payload-types";
|
import type { User } from "@/payload-types";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "Surveys: Polaris Task Force",
|
title: "Surveys: Polaris Task Force",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function SurveysPage({
|
function audienceContext(user: User) {
|
||||||
searchParams,
|
const roleIds = (user.roleDocs ?? [])
|
||||||
}: {
|
.map((r) => (typeof r === "object" ? r.id : r))
|
||||||
searchParams: Promise<{ open?: string }>;
|
.filter((id): id is number => typeof id === "number");
|
||||||
}) {
|
|
||||||
|
return { userId: user.id, roleIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function SurveysPage() {
|
||||||
const headers = await nextHeaders();
|
const headers = await nextHeaders();
|
||||||
const payload = await getPayload({ config });
|
const payload = await getPayload({ config });
|
||||||
const { user } = await payload.auth({ headers, canSetHeaders: false });
|
const { user } = await payload.auth({ headers, canSetHeaders: false });
|
||||||
|
|
@ -23,31 +27,47 @@ export default async function SurveysPage({
|
||||||
// the authed render path's types.
|
// the authed render path's types.
|
||||||
if (!user || user.collection !== "users") return null;
|
if (!user || user.collection !== "users") return null;
|
||||||
|
|
||||||
const [pendingForms, params] = await Promise.all([getPendingSurveys(payload, user), searchParams]);
|
const [formsResult, profileRes] = await Promise.all([
|
||||||
const openParam = Number(params.open);
|
payload.find({
|
||||||
const initialSurveyId = Number.isInteger(openParam) ? openParam : null;
|
collection: "forms",
|
||||||
|
sort: "-createdAt",
|
||||||
|
limit: 100,
|
||||||
|
depth: 1,
|
||||||
|
}),
|
||||||
|
payload.find({
|
||||||
|
collection: "profiles",
|
||||||
|
where: { user: { equals: user.id } },
|
||||||
|
limit: 1,
|
||||||
|
depth: 1,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
const items: SurveyListItem[] = pendingForms.map((form) => ({
|
const heldQualifications = (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);
|
||||||
|
|
||||||
|
const ctx = { ...audienceContext(user), qualificationNames: heldQualifications };
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const items: SurveyListItem[] = formsResult.docs
|
||||||
|
.filter((form) => isSurveyOpen(form, now) && surveyAudienceMatches(form, ctx))
|
||||||
|
.map((form) => ({
|
||||||
id: form.id,
|
id: form.id,
|
||||||
title: form.title,
|
title: form.title,
|
||||||
openUntil: form.activeUntil ?? null,
|
openUntil: form.activeUntil ?? null,
|
||||||
questionCount: form.fields?.filter((f) => f.blockType !== "message").length ?? 0,
|
questionCount: form.fields?.filter((f) => f.blockType !== "message").length ?? 0,
|
||||||
fields: form.fields ?? [],
|
|
||||||
submitLabel: form.submitButtonLabel || "Submit",
|
|
||||||
confirmationMessage: form.confirmationMessage ?? null,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 p-5">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<ClipboardCheck className="text-muted-foreground size-6" />
|
||||||
<ClipboardCheck className="size-5 text-muted-foreground" />
|
<div>
|
||||||
<h1 className="text-lg font-semibold">Surveys</h1>
|
<h1 className="text-2xl font-semibold">Surveys</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">Open surveys for you to take.</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">Open surveys for you to take.</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<SurveyList items={items} />
|
||||||
<SurveyList items={items} initialSurveyId={initialSurveyId} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
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 })),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -44,13 +44,11 @@ export function SurveyForm({
|
||||||
fields,
|
fields,
|
||||||
submitLabel,
|
submitLabel,
|
||||||
confirmationMessage,
|
confirmationMessage,
|
||||||
onSuccess,
|
|
||||||
}: {
|
}: {
|
||||||
formId: number;
|
formId: number;
|
||||||
fields: SurveyField[];
|
fields: SurveyField[];
|
||||||
submitLabel: string;
|
submitLabel: string;
|
||||||
confirmationMessage: ConfirmationMessage;
|
confirmationMessage: ConfirmationMessage;
|
||||||
onSuccess?: () => void;
|
|
||||||
}) {
|
}) {
|
||||||
const [values, setValues] = useState<Record<string, FieldValue>>(() => initialValues(fields));
|
const [values, setValues] = useState<Record<string, FieldValue>>(() => initialValues(fields));
|
||||||
const [status, setStatus] = useState<"idle" | "submitting" | "success">("idle");
|
const [status, setStatus] = useState<"idle" | "submitting" | "success">("idle");
|
||||||
|
|
@ -94,7 +92,6 @@ export function SurveyForm({
|
||||||
throw new Error(message || `Submission failed (${response.status}).`);
|
throw new Error(message || `Submission failed (${response.status}).`);
|
||||||
}
|
}
|
||||||
setStatus("success");
|
setStatus("success");
|
||||||
onSuccess?.();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
setError(e instanceof Error ? e.message : "Submission failed.");
|
setError(e instanceof Error ? e.message : "Submission failed.");
|
||||||
|
|
|
||||||
|
|
@ -1,63 +1,17 @@
|
||||||
"use client";
|
import Link from "next/link";
|
||||||
|
import { ClipboardCheck, Clock, ListChecks } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import {
|
import { Badge } from "@/components/ui/badge";
|
||||||
ChevronRight,
|
|
||||||
ClipboardCheck,
|
|
||||||
Clock,
|
|
||||||
ListChecks,
|
|
||||||
} from "lucide-react";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import type { Form } from "@/payload-types";
|
|
||||||
import { SurveyForm } from "@/components/frontend/surveys/SurveyForm";
|
|
||||||
|
|
||||||
export interface SurveyListItem {
|
export interface SurveyListItem {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
openUntil: string | null;
|
openUntil: string | null;
|
||||||
questionCount: number;
|
questionCount: number;
|
||||||
fields: NonNullable<Form["fields"]>;
|
|
||||||
submitLabel: string;
|
|
||||||
confirmationMessage: Form["confirmationMessage"];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SurveyList({
|
export function SurveyList({ items }: { items: SurveyListItem[] }) {
|
||||||
items,
|
if (items.length === 0) {
|
||||||
initialSurveyId = null,
|
|
||||||
}: {
|
|
||||||
items: SurveyListItem[];
|
|
||||||
initialSurveyId?: number | null;
|
|
||||||
}) {
|
|
||||||
const [active, setActive] = useState<SurveyListItem | null>(
|
|
||||||
() => items.find((item) => item.id === initialSurveyId) ?? null,
|
|
||||||
);
|
|
||||||
const [succeeded, setSucceeded] = useState(false);
|
|
||||||
const [completedIds, setCompletedIds] = useState<Set<number>>(() => new Set());
|
|
||||||
|
|
||||||
const visible = items.filter((item) => !completedIds.has(item.id));
|
|
||||||
|
|
||||||
const close = () => {
|
|
||||||
if (active && succeeded) {
|
|
||||||
setCompletedIds((prev) => new Set(prev).add(active.id));
|
|
||||||
}
|
|
||||||
setSucceeded(false);
|
|
||||||
setActive(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (visible.length === 0 && !active) {
|
|
||||||
return (
|
return (
|
||||||
<div className="text-muted-foreground flex flex-col items-center gap-2 py-16 text-center">
|
<div className="text-muted-foreground flex flex-col items-center gap-2 py-16 text-center">
|
||||||
<ClipboardCheck className="size-10 opacity-50" />
|
<ClipboardCheck className="size-10 opacity-50" />
|
||||||
|
|
@ -67,28 +21,14 @@ export function SurveyList({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{visible.map((item) => (
|
{items.map((item) => (
|
||||||
<Card
|
<Link key={item.id} href={`/surveys/${item.id}`} className="group">
|
||||||
key={item.id}
|
<Card className="group-hover:border-sidebar-border group-hover:bg-sidebar-accent h-full transition-colors">
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={() => {
|
|
||||||
setSucceeded(false);
|
|
||||||
setActive(item);
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
setSucceeded(false);
|
|
||||||
setActive(item);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="hover:border-sidebar-border hover:bg-sidebar-accent/40 h-full cursor-pointer transition-colors"
|
|
||||||
>
|
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">{item.title}</CardTitle>
|
<CardTitle className="group-hover:text-foreground text-foreground/90 text-base">
|
||||||
|
{item.title}
|
||||||
|
</CardTitle>
|
||||||
<CardDescription className="flex items-center gap-3 text-xs">
|
<CardDescription className="flex items-center gap-3 text-xs">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<ListChecks className="size-3.5" />
|
<ListChecks className="size-3.5" />
|
||||||
|
|
@ -97,42 +37,19 @@ export function SurveyList({
|
||||||
{item.openUntil && (
|
{item.openUntil && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Clock className="size-3.5" />
|
<Clock className="size-3.5" />
|
||||||
closes {new Date(item.openUntil).toLocaleDateString()}
|
until {new Date(item.openUntil).toLocaleDateString()}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="text-muted-foreground flex items-center gap-1 text-sm">
|
<CardContent>
|
||||||
Take survey
|
<Badge variant="secondary" className="text-[10px] uppercase">
|
||||||
<ChevronRight className="size-4" />
|
Open
|
||||||
|
</Badge>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={active !== null} onOpenChange={(open) => !open && close()}>
|
|
||||||
{active && (
|
|
||||||
<DialogContent className="max-h-[85vh] max-w-2xl gap-0 overflow-hidden p-0">
|
|
||||||
<DialogHeader className="border-b px-6 py-4">
|
|
||||||
<DialogTitle>{active.title}</DialogTitle>
|
|
||||||
{active.openUntil && (
|
|
||||||
<DialogDescription>
|
|
||||||
Open until {new Date(active.openUntil).toLocaleString()}
|
|
||||||
</DialogDescription>
|
|
||||||
)}
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="max-h-[calc(85vh-6rem)] overflow-y-auto px-6 py-6">
|
|
||||||
<SurveyForm
|
|
||||||
formId={active.id}
|
|
||||||
fields={active.fields}
|
|
||||||
submitLabel={active.submitLabel}
|
|
||||||
confirmationMessage={active.confirmationMessage}
|
|
||||||
onSuccess={() => setSucceeded(true)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
)}
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
"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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
/**
|
|
||||||
* 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,7 +6,6 @@ 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;
|
||||||
|
|
||||||
|
|
@ -75,10 +74,8 @@ 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;
|
||||||
|
|
@ -219,15 +216,12 @@ 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");
|
||||||
|
|
@ -310,28 +304,6 @@ 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