1
0
Fork 0
polaris-task-force/src/app/(frontend)/surveys/page.tsx
Z8MB1E 20b65d08fc feat(surveys): add survey system with audience targeting on plugin-form-builder
- forms collection: targeting group (all/users/roles/qualifications), activeFrom/activeUntil window, radio/date field blocks enabled
- form-submissions: server-side user attribution, expiry and audience gates, one attempt per user
- forms/submissions permissions in the Surveys registry group + admin role seed grants
- frontend /surveys list and /surveys/[id] take page with client block renderer
- register the pending role permission enum values migration
2026-09-23 18:17:29 -04:00

73 lines
2.4 KiB
TypeScript

import config from "@payload-config";
import { headers as nextHeaders } from "next/headers";
import { getPayload } from "payload";
import { ClipboardCheck } from "lucide-react";
import { SurveyList, type SurveyListItem } from "@/components/frontend/surveys/SurveyList";
import { isSurveyOpen, surveyAudienceMatches } from "@/lib/surveys/evaluate";
import type { User } from "@/payload-types";
export const metadata = {
title: "Surveys: Polaris Task Force",
};
function audienceContext(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 };
}
export default async function SurveysPage() {
const headers = await nextHeaders();
const payload = await getPayload({ config });
const { user } = await payload.auth({ headers, canSetHeaders: false });
// The (frontend) layout renders LandingPage for guests; this only guards
// the authed render path's types.
if (!user || user.collection !== "users") return null;
const [formsResult, profileRes] = await Promise.all([
payload.find({
collection: "forms",
sort: "-createdAt",
limit: 100,
depth: 1,
}),
payload.find({
collection: "profiles",
where: { user: { equals: user.id } },
limit: 1,
depth: 1,
}),
]);
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,
title: form.title,
openUntil: form.activeUntil ?? null,
questionCount: form.fields?.filter((f) => f.blockType !== "message").length ?? 0,
}));
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<ClipboardCheck className="text-muted-foreground size-6" />
<div>
<h1 className="text-2xl font-semibold">Surveys</h1>
<p className="text-muted-foreground text-sm">Open surveys for you to take.</p>
</div>
</div>
<SurveyList items={items} />
</div>
);
}