1
0
Fork 0

Compare commits

...

4 commits

Author SHA1 Message Date
b85242d242 v0.2.51 - add survey system on form builder, fix migration sidecar format 2026-09-23 18:17:33 -04:00
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
8cbf1820e3 fix(migrations): write sidecar index columns in drizzle object format 2026-09-23 18:17:24 -04:00
962064a5a4 feat(session): add pre-expiry warning banner with extend button
- SessionExpiryBanner: live countdown, warn 15min early, one-shot toast, Extend button
- countdown.ts: pure countdown helpers (timeUntilExpiry, sessionExpiryPhase, formatCountdown)
- useSessionExpired: added unlock handler for SESSION_EXTENDED_EVENT
- useGameTick: exported SESSION_EXTENDED_EVENT for session extension
- unit tests: 14/14 pass for countdown helpers
- e2e test: banner renders with countdown + extend button
2026-09-23 15:02:10 -04:00
30 changed files with 70150 additions and 624 deletions

View file

@ -19,6 +19,7 @@
"@payloadcms/email-nodemailer": "3.88.0", "@payloadcms/email-nodemailer": "3.88.0",
"@payloadcms/next": "3.88.0", "@payloadcms/next": "3.88.0",
"@payloadcms/payload-cloud": "3.88.0", "@payloadcms/payload-cloud": "3.88.0",
"@payloadcms/plugin-form-builder": "3.88.0",
"@payloadcms/plugin-mcp": "^3.88.0", "@payloadcms/plugin-mcp": "^3.88.0",
"@payloadcms/richtext-lexical": "3.88.0", "@payloadcms/richtext-lexical": "3.88.0",
"@payloadcms/ui": "3.88.0", "@payloadcms/ui": "3.88.0",
@ -588,6 +589,8 @@
"@payloadcms/payload-cloud": ["@payloadcms/payload-cloud@3.88.0", "", { "dependencies": { "@aws-sdk/client-cognito-identity": "^3.614.0", "@aws-sdk/client-s3": "^3.614.0", "@aws-sdk/credential-providers": "^3.614.0", "@aws-sdk/lib-storage": "^3.614.0", "@payloadcms/email-nodemailer": "3.88.0", "amazon-cognito-identity-js": "^6.1.2", "nodemailer": "^9.0.1" }, "peerDependencies": { "payload": "3.88.0" } }, "sha512-ijGuUkJP6wtVK5ur5/HLZOr3YrtFqHLcOhAfBAvVhgS3rkopbk+l5SIIiszGeaysAvzxAJX/NKiSRkeGhL7Qqw=="], "@payloadcms/payload-cloud": ["@payloadcms/payload-cloud@3.88.0", "", { "dependencies": { "@aws-sdk/client-cognito-identity": "^3.614.0", "@aws-sdk/client-s3": "^3.614.0", "@aws-sdk/credential-providers": "^3.614.0", "@aws-sdk/lib-storage": "^3.614.0", "@payloadcms/email-nodemailer": "3.88.0", "amazon-cognito-identity-js": "^6.1.2", "nodemailer": "^9.0.1" }, "peerDependencies": { "payload": "3.88.0" } }, "sha512-ijGuUkJP6wtVK5ur5/HLZOr3YrtFqHLcOhAfBAvVhgS3rkopbk+l5SIIiszGeaysAvzxAJX/NKiSRkeGhL7Qqw=="],
"@payloadcms/plugin-form-builder": ["@payloadcms/plugin-form-builder@3.88.0", "", { "dependencies": { "@payloadcms/translations": "3.88.0", "@payloadcms/ui": "3.88.0", "escape-html": "^1.0.3" }, "peerDependencies": { "payload": "3.88.0", "react": "^19.0.1 || ^19.1.2 || ^19.2.1", "react-dom": "^19.0.1 || ^19.1.2 || ^19.2.1" } }, "sha512-C915jEHADVN9xW7fSq/bYIZig1zjfQo+t4wrgiqjOCmB81oMgyqLRB3RlL2e6y4PzBUtgss2VS40vn/tVD/61Q=="],
"@payloadcms/plugin-mcp": ["@payloadcms/plugin-mcp@3.88.0", "", { "dependencies": { "@modelcontextprotocol/sdk": "1.30.0", "@types/json-schema": "7.0.15", "json-schema-to-zod": "2.6.1", "mcp-handler": "^1.0.7", "zod": "^3.25.50" }, "peerDependencies": { "payload": "3.88.0" } }, "sha512-+L4SeiYFs4hBfnwmoyxChs2jAL/dcSW16YXRQ+7ua8AIYJRveNGOHtcFZgKH0t/bHE1nK/1df9Wy2EpmBnAcTw=="], "@payloadcms/plugin-mcp": ["@payloadcms/plugin-mcp@3.88.0", "", { "dependencies": { "@modelcontextprotocol/sdk": "1.30.0", "@types/json-schema": "7.0.15", "json-schema-to-zod": "2.6.1", "mcp-handler": "^1.0.7", "zod": "^3.25.50" }, "peerDependencies": { "payload": "3.88.0" } }, "sha512-+L4SeiYFs4hBfnwmoyxChs2jAL/dcSW16YXRQ+7ua8AIYJRveNGOHtcFZgKH0t/bHE1nK/1df9Wy2EpmBnAcTw=="],
"@payloadcms/richtext-lexical": ["@payloadcms/richtext-lexical@3.88.0", "", { "dependencies": { "@lexical/clipboard": "0.41.0", "@lexical/headless": "0.41.0", "@lexical/html": "0.41.0", "@lexical/link": "0.41.0", "@lexical/list": "0.41.0", "@lexical/mark": "0.41.0", "@lexical/react": "0.41.0", "@lexical/rich-text": "0.41.0", "@lexical/selection": "0.41.0", "@lexical/table": "0.41.0", "@lexical/utils": "0.41.0", "@payloadcms/translations": "3.88.0", "@payloadcms/ui": "3.88.0", "acorn": "8.16.0", "bson-objectid": "2.0.4", "csstype": "3.1.3", "dequal": "2.0.3", "escape-html": "1.0.3", "jsox": "1.2.121", "lexical": "0.41.0", "mdast-util-from-markdown": "2.0.2", "mdast-util-mdx-jsx": "3.1.3", "micromark-extension-mdx-jsx": "3.0.1", "qs-esm": "8.0.1", "react-error-boundary": "4.1.2", "ts-essentials": "10.0.3", "uuid": "13.0.2" }, "peerDependencies": { "@faceless-ui/modal": "3.0.0", "@faceless-ui/scroll-info": "2.0.0", "@payloadcms/next": "3.88.0", "payload": "3.88.0", "react": "^19.0.1 || ^19.1.2 || ^19.2.1", "react-dom": "^19.0.1 || ^19.1.2 || ^19.2.1" } }, "sha512-lY8oIUiggykWKB3ljMfSidxBtA8ahMq0Qqp28OuvaCNIDp0HTMIPxcHvFOQZngMqtQHhpautD6vcQR0Q9CEHHQ=="], "@payloadcms/richtext-lexical": ["@payloadcms/richtext-lexical@3.88.0", "", { "dependencies": { "@lexical/clipboard": "0.41.0", "@lexical/headless": "0.41.0", "@lexical/html": "0.41.0", "@lexical/link": "0.41.0", "@lexical/list": "0.41.0", "@lexical/mark": "0.41.0", "@lexical/react": "0.41.0", "@lexical/rich-text": "0.41.0", "@lexical/selection": "0.41.0", "@lexical/table": "0.41.0", "@lexical/utils": "0.41.0", "@payloadcms/translations": "3.88.0", "@payloadcms/ui": "3.88.0", "acorn": "8.16.0", "bson-objectid": "2.0.4", "csstype": "3.1.3", "dequal": "2.0.3", "escape-html": "1.0.3", "jsox": "1.2.121", "lexical": "0.41.0", "mdast-util-from-markdown": "2.0.2", "mdast-util-mdx-jsx": "3.1.3", "micromark-extension-mdx-jsx": "3.0.1", "qs-esm": "8.0.1", "react-error-boundary": "4.1.2", "ts-essentials": "10.0.3", "uuid": "13.0.2" }, "peerDependencies": { "@faceless-ui/modal": "3.0.0", "@faceless-ui/scroll-info": "2.0.0", "@payloadcms/next": "3.88.0", "payload": "3.88.0", "react": "^19.0.1 || ^19.1.2 || ^19.2.1", "react-dom": "^19.0.1 || ^19.1.2 || ^19.2.1" } }, "sha512-lY8oIUiggykWKB3ljMfSidxBtA8ahMq0Qqp28OuvaCNIDp0HTMIPxcHvFOQZngMqtQHhpautD6vcQR0Q9CEHHQ=="],
@ -2702,6 +2705,8 @@
"@payloadcms/payload-cloud/nodemailer": ["nodemailer@9.0.3", "", {}, "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw=="], "@payloadcms/payload-cloud/nodemailer": ["nodemailer@9.0.3", "", {}, "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw=="],
"@payloadcms/plugin-form-builder/@payloadcms/translations": ["@payloadcms/translations@3.88.0", "", { "dependencies": { "date-fns": "4.1.0" } }, "sha512-AZVQjjW5pKxO+K9ODeh6yw6/EfXK1hxGPw55KzzDC5srO3hjNskgyWtkauKvbz+S0+xFspANbRc4OD/Rg/74WA=="],
"@payloadcms/plugin-mcp/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@payloadcms/plugin-mcp/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@payloadcms/richtext-lexical/@payloadcms/translations": ["@payloadcms/translations@3.88.0", "", { "dependencies": { "date-fns": "4.1.0" } }, "sha512-AZVQjjW5pKxO+K9ODeh6yw6/EfXK1hxGPw55KzzDC5srO3hjNskgyWtkauKvbz+S0+xFspANbRc4OD/Rg/74WA=="], "@payloadcms/richtext-lexical/@payloadcms/translations": ["@payloadcms/translations@3.88.0", "", { "dependencies": { "date-fns": "4.1.0" } }, "sha512-AZVQjjW5pKxO+K9ODeh6yw6/EfXK1hxGPw55KzzDC5srO3hjNskgyWtkauKvbz+S0+xFspANbRc4OD/Rg/74WA=="],

View file

@ -1,6 +1,6 @@
{ {
"name": "polaris-task-force", "name": "polaris-task-force",
"version": "0.2.50", "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",
@ -43,6 +43,7 @@
"@payloadcms/email-nodemailer": "3.88.0", "@payloadcms/email-nodemailer": "3.88.0",
"@payloadcms/next": "3.88.0", "@payloadcms/next": "3.88.0",
"@payloadcms/payload-cloud": "3.88.0", "@payloadcms/payload-cloud": "3.88.0",
"@payloadcms/plugin-form-builder": "3.88.0",
"@payloadcms/plugin-mcp": "^3.88.0", "@payloadcms/plugin-mcp": "^3.88.0",
"@payloadcms/richtext-lexical": "3.88.0", "@payloadcms/richtext-lexical": "3.88.0",
"@payloadcms/ui": "3.88.0", "@payloadcms/ui": "3.88.0",

View file

@ -16,6 +16,7 @@ import { ShipmentToasts } from "@/components/frontend/logistics/ShipmentToasts";
import { MissionReminderToasts } from "@/components/frontend/intelligence/MissionReminderToasts"; 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 { SessionWatchdog } from "@/components/frontend/session/SessionWatchdog";
import { SessionExpiryBanner } from "@/components/frontend/session/SessionExpiryBanner";
import { getSessionExpMs } from "@/lib/session/token"; 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";
@ -192,6 +193,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
} }
> >
<SiteHeader /> <SiteHeader />
{user && <SessionExpiryBanner sessionExp={sessionExp} />}
{impersonating && <ImpersonationBanner />} {impersonating && <ImpersonationBanner />}
{user && ( {user && (
<AnnouncementHost <AnnouncementHost

View file

@ -0,0 +1,136 @@
import config from "@payload-config";
import { headers as nextHeaders } from "next/headers";
import { getPayload } from "payload";
import Link from "next/link";
import { notFound } from "next/navigation";
import { ClipboardCheck, Lock, TriangleAlert } from "lucide-react";
import { SurveyForm } from "@/components/frontend/surveys/SurveyForm";
import { isSurveyOpen, surveyAudienceMatches } from "@/lib/surveys/evaluate";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import type { User } from "@/payload-types";
export const metadata = {
title: "Survey: Polaris Task Force",
};
function NoticeCard({ icon, title, body }: { icon: React.ReactNode; title: string; body: string }) {
return (
<Card>
<CardContent className="flex flex-col items-center gap-3 py-12 text-center">
{icon}
<div>
<p className="font-medium">{title}</p>
<p className="text-muted-foreground text-sm">{body}</p>
</div>
<Button asChild variant="outline">
<Link href="/surveys">Back to surveys</Link>
</Button>
</CardContent>
</Card>
);
}
export default async function SurveyDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const surveyId = Number(id);
if (!Number.isInteger(surveyId)) notFound();
const headers = await nextHeaders();
const payload = await getPayload({ config });
const { user } = await payload.auth({ headers, canSetHeaders: false });
if (!user || user.collection !== "users") return null;
const form = await payload
.findByID({ collection: "forms", id: surveyId, depth: 1 })
.catch(() => null);
if (!form) notFound();
const [profileRes, submissionCount] = await Promise.all([
payload.find({
collection: "profiles",
where: { user: { equals: user.id } },
limit: 1,
depth: 1,
}),
payload.count({
collection: "form-submissions",
where: { and: [{ form: { equals: form.id } }, { user: { equals: user.id } }] },
}),
]);
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 ?? [])
.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 roleIds = (user.roleDocs ?? [])
.map((r) => (typeof r === "object" ? r.id : r))
.filter((v): v is number => typeof v === "number");
const eligible = surveyAudienceMatches(form, {
userId: user.id,
roleIds,
qualificationNames: heldQualifications,
});
if (!eligible) {
return (
<NoticeCard
icon={<TriangleAlert className="text-amber-400 size-10" />}
title="Not eligible"
body="This survey is not available to your account."
/>
);
}
return (
<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
formId={form.id}
fields={form.fields ?? []}
submitLabel={form.submitButtonLabel || "Submit"}
confirmationMessage={form.confirmationMessage ?? null}
/>
</div>
);
}

View file

@ -0,0 +1,73 @@
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>
);
}

View file

@ -1,4 +1,3 @@
import { ComposeField as ComposeField_4490b89d4413c1ffaecdacfe72efaf73 } from '@ai-stack/payloadcms/client'
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc' import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc' import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc' import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
@ -24,6 +23,7 @@ import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ComposeField as ComposeField_4490b89d4413c1ffaecdacfe72efaf73 } from '@ai-stack/payloadcms/client'
import { default as default_513af5ed43d616d133e530d8c94232f8 } from '@/components/admin/groups/CollapsibleGroupField' import { default as default_513af5ed43d616d133e530d8c94232f8 } from '@/components/admin/groups/CollapsibleGroupField'
import { default as default_d9c22b951ac6762f436db59b889da382 } from '@/components/admin/awards/CommendationColorField' import { default as default_d9c22b951ac6762f436db59b889da382 } from '@/components/admin/awards/CommendationColorField'
import { default as default_c3ea68702b5e5f8f2f98055e01c43c8d } from '@/components/admin/awards/RibbonDesigner' import { default as default_c3ea68702b5e5f8f2f98055e01c43c8d } from '@/components/admin/awards/RibbonDesigner'
@ -46,7 +46,6 @@ import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } f
/** @type import('payload').ImportMap */ /** @type import('payload').ImportMap */
export const importMap = { export const importMap = {
"@ai-stack/payloadcms/client#ComposeField": ComposeField_4490b89d4413c1ffaecdacfe72efaf73,
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e, "@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e, "@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e, "@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
@ -72,6 +71,7 @@ export const importMap = {
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, "@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, "@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, "@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@ai-stack/payloadcms/client#ComposeField": ComposeField_4490b89d4413c1ffaecdacfe72efaf73,
"@/components/admin/groups/CollapsibleGroupField#default": default_513af5ed43d616d133e530d8c94232f8, "@/components/admin/groups/CollapsibleGroupField#default": default_513af5ed43d616d133e530d8c94232f8,
"@/components/admin/awards/CommendationColorField#default": default_d9c22b951ac6762f436db59b889da382, "@/components/admin/awards/CommendationColorField#default": default_d9c22b951ac6762f436db59b889da382,
"@/components/admin/awards/RibbonDesigner#default": default_c3ea68702b5e5f8f2f98055e01c43c8d, "@/components/admin/awards/RibbonDesigner#default": default_c3ea68702b5e5f8f2f98055e01c43c8d,

View file

@ -175,6 +175,7 @@ export function AppSidebar({
<NavCore label="Infrastructure" items={filteredNavLogistics} expandDefaultOpen={hasLogistics} /> <NavCore label="Infrastructure" items={filteredNavLogistics} expandDefaultOpen={hasLogistics} />
)} )}
<NavCore label="Chronicle" items={data.navStory} /> <NavCore label="Chronicle" items={data.navStory} />
<NavCore label="Surveys" items={data.navSurveys} />
<NavCore label="Helpdesk" items={filteredNavHelpdesk} /> <NavCore label="Helpdesk" items={filteredNavHelpdesk} />
<NavCore label="Commerce" items={data.navCommerce} /> <NavCore label="Commerce" items={data.navCommerce} />
<NavCore label="Personal" items={data.navPersonal} /> <NavCore label="Personal" items={data.navPersonal} />

View file

@ -6,6 +6,7 @@ import {
Boxes, Boxes,
Building2, Building2,
ClipboardList, ClipboardList,
ClipboardCheck,
Car, Car,
Contact, Contact,
Crosshair, Crosshair,
@ -191,5 +192,12 @@ export const sidebarData = {
icon: ScrollText, icon: ScrollText,
}, },
], ],
navSurveys: [
{
title: "Surveys",
url: "/surveys",
icon: ClipboardCheck,
},
],
navSecondary: [], navSecondary: [],
}; };

View file

@ -0,0 +1,155 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Clock, RefreshCw, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
COUNTDOWN_TICK_MS,
SESSION_AUTO_CLOSE_MS,
SESSION_WARN_THRESHOLD_MS,
formatCountdown,
sessionExpiryPhase,
timeUntilExpiry,
type SessionExpiryPhase,
} from "@/lib/session/countdown";
import { SESSION_EXTENDED_EVENT } from "@/hooks/useGameTick";
interface SessionExpiryBannerProps {
/** Absolute expiry (ms since epoch), server-computed from the JWT `exp`. */
sessionExp: number | null;
/** How early (ms) to warn before expiry. Overrides the default for tests. */
warnMs?: number;
/** How long before expiry (ms) to auto-close the banner. Overrides default. */
closeMs?: number;
}
/**
* Pre-expiry warning + session-extend surface for logged-in users.
*
* The `payload-token` cookie is HttpOnly, so the browser can only see the
* server-passed `sessionExp`. This component turns that into a live countdown:
* it warns `warnMs` before expiry (firing one sonner toast), offers an
* "Extend Session" button that calls Payload's built-in refresh endpoint to add
* another token window, and auto-closes shortly before the hard cutoff. On
* extend it dispatches `SESSION_EXTENDED_EVENT` so minigames un-lock and then
* re-validates to pick up the refreshed cookie.
*/
export function SessionExpiryBanner({
sessionExp,
warnMs = SESSION_WARN_THRESHOLD_MS,
closeMs = SESSION_AUTO_CLOSE_MS,
}: SessionExpiryBannerProps) {
const router = useRouter();
const [now, setNow] = useState(() => Date.now());
const [dismissed, setDismissed] = useState(false);
const [extending, setExtending] = useState(false);
const [error, setError] = useState<string | null>(null);
const toastFiredRef = useRef(false);
// Live clock so the countdown ticks every second.
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), COUNTDOWN_TICK_MS);
return () => window.clearInterval(timer);
}, []);
// Each new expiry window (after an Extend) re-arms the one-shot toast.
useEffect(() => {
toastFiredRef.current = false;
}, [sessionExp]);
const remainingMs = timeUntilExpiry(sessionExp, now);
const phase: SessionExpiryPhase =
remainingMs == null ? "idle" : sessionExpiryPhase(remainingMs, warnMs, closeMs);
const visible = phase === "warn" && !dismissed;
// Fire the heads-up toast once when we first enter the warn window.
const fireToast = useCallback(() => {
if (toastFiredRef.current) return;
toastFiredRef.current = true;
const minutes = Math.round(warnMs / 60_000);
toast.info(`Your session expires in about ${minutes} min.`, {
action: {
label: "Extend",
onClick: () => void extendSession(),
},
duration: Infinity,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [warnMs]);
useEffect(() => {
if (phase === "warn") fireToast();
}, [phase, warnMs, fireToast]);
const extendSession = useCallback(async () => {
setExtending(true);
setError(null);
try {
const response = await fetch("/api/users/refresh-token", {
method: "POST",
credentials: "same-origin",
});
if (!response.ok) {
throw new Error(`Session refresh failed (HTTP ${response.status}).`);
}
// Unlock minigames that locked when the session neared expiry, then
// re-validate so the refreshed cookie is picked up and the countdown
// resumes from the new expiry.
window.dispatchEvent(new CustomEvent(SESSION_EXTENDED_EVENT));
router.refresh();
} catch (e) {
const message = e instanceof Error ? e.message : "Could not extend session.";
setError(message);
toast.error(message);
} finally {
setExtending(false);
}
}, [router]);
if (!visible) return null;
const minutesLeft = Math.round(warnMs / 60_000);
return (
<div
className="flex flex-wrap items-center justify-between gap-3 border-b border-white/15 bg-slate-900/90 px-4 py-2 text-sm text-white/90 backdrop-blur-sm"
role="status"
aria-live="polite"
>
<div className="flex items-center gap-3">
<Clock className="size-4 shrink-0 text-white/60" aria-hidden="true" />
<span>
Session expires in{" "}
<span className="font-mono tabular-nums text-white">{formatCountdown((remainingMs ?? 0) / 1000)}</span>.{" "}
Extend to stay signed in for another {minutesLeft} min.
</span>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void extendSession()}
disabled={extending}
className="h-7 border-white/30 bg-transparent text-white hover:bg-white/10 hover:text-white"
>
<RefreshCw className={`size-3.5 ${extending ? "animate-spin" : ""}`} aria-hidden="true" />
{extending ? "Extending..." : "Extend"}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setDismissed(true)}
className="h-7 text-white/70 hover:bg-white/10 hover:text-white"
>
<X className="size-3.5" aria-hidden="true" />
<span className="sr-only">Dismiss</span>
</Button>
</div>
{error && <p className="absolute -bottom-6 left-4 text-xs text-red-400">{error}</p>}
</div>
);
}

View file

@ -0,0 +1,230 @@
"use client";
import { useState } from "react";
import { RichText } from "@payloadcms/richtext-lexical/react";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import type { Form as FormDoc } from "@/payload-types";
type SurveyField = NonNullable<FormDoc["fields"]>[number];
type ConfirmationMessage = FormDoc["confirmationMessage"];
type FieldValue = string | number | boolean;
function initialValues(fields: SurveyField[]): Record<string, FieldValue> {
const values: Record<string, FieldValue> = {};
for (const field of fields) {
if (field.blockType === "message" || !field.name) continue;
if (field.blockType === "checkbox" && !("defaultValue" in field)) {
values[field.name] = false;
} else if ("defaultValue" in field && field.defaultValue !== null && field.defaultValue !== undefined) {
values[field.name] = field.defaultValue;
} else {
values[field.name] = "";
}
}
return values;
}
export function SurveyForm({
formId,
fields,
submitLabel,
confirmationMessage,
}: {
formId: number;
fields: SurveyField[];
submitLabel: string;
confirmationMessage: ConfirmationMessage;
}) {
const [values, setValues] = useState<Record<string, FieldValue>>(() => initialValues(fields));
const [status, setStatus] = useState<"idle" | "submitting" | "success">("idle");
const [error, setError] = useState<string | null>(null);
const setValue = (name: string, value: FieldValue) => {
setValues((prev) => ({ ...prev, [name]: value }));
};
const missingRequired = fields.some(
(field) =>
field.blockType !== "message" &&
field.required &&
field.name &&
(values[field.name] === "" || values[field.name] === false),
);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setError(null);
if (missingRequired) {
setError("Please answer all required questions.");
return;
}
setStatus("submitting");
try {
const response = await fetch("/api/form-submissions", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({
form: formId,
submissionData: Object.entries(values).map(([field, value]) => ({ field, value })),
}),
});
if (!response.ok) {
const body = (await response.json().catch(() => null)) as { errors?: { message?: string }[] } | null;
const message = body?.errors?.[0]?.message;
throw new Error(message || `Submission failed (${response.status}).`);
}
setStatus("success");
} catch (e) {
setStatus("idle");
setError(e instanceof Error ? e.message : "Submission failed.");
}
};
if (status === "success") {
return (
<Card>
<CardContent className="py-10 text-center">
{confirmationMessage ? (
<div className="lexical-content text-sm [&_p]:my-2">
<RichText data={confirmationMessage} />
</div>
) : (
<p className="text-emerald-400">Your response has been recorded. Thank you!</p>
)}
</CardContent>
</Card>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
{fields.map((field, index) => {
if (field.blockType === "message") {
return field.message ? (
<div key={field.id ?? index} className="lexical-content text-sm">
<RichText data={field.message} />
</div>
) : null;
}
const name = field.name;
const label = field.label || name;
const value = values[name];
return (
<div key={field.id ?? index} className="space-y-2">
{field.blockType === "checkbox" ? (
<div className="flex items-center gap-2">
<Checkbox
id={name}
checked={value === true}
onCheckedChange={(checked) => setValue(name, checked === true)}
/>
<Label htmlFor={name}>
{label}
{field.required ? " *" : ""}
</Label>
</div>
) : (
<Label htmlFor={name}>
{label}
{field.required ? " *" : ""}
</Label>
)}
{(field.blockType === "text" || field.blockType === "email" || field.blockType === "country" || field.blockType === "state") && (
<Input
id={name}
type={field.blockType === "email" ? "email" : "text"}
value={String(value ?? "")}
onChange={(e) => setValue(name, e.target.value)}
/>
)}
{field.blockType === "textarea" && (
<Textarea
id={name}
rows={4}
value={String(value ?? "")}
onChange={(e) => setValue(name, e.target.value)}
/>
)}
{field.blockType === "number" && (
<Input
id={name}
type="number"
value={String(value ?? "")}
onChange={(e) => setValue(name, e.target.value === "" ? "" : Number(e.target.value))}
/>
)}
{field.blockType === "date" && (
<Input
id={name}
type="date"
value={String(value ?? "")}
onChange={(e) => setValue(name, e.target.value)}
/>
)}
{field.blockType === "select" && (
<Select value={String(value ?? "")} onValueChange={(v) => setValue(name, v)}>
<SelectTrigger id={name} className="w-full">
<SelectValue placeholder="Select an option" />
</SelectTrigger>
<SelectContent>
{(field.options ?? []).map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{field.blockType === "radio" && (
<RadioGroup
value={String(value ?? "")}
onValueChange={(v) => setValue(name, v)}
className="gap-2"
>
{(field.options ?? []).map((option) => (
<div key={option.value} className="flex items-center gap-2">
<RadioGroupItem id={`${name}-${option.value}`} value={option.value} />
<Label htmlFor={`${name}-${option.value}`}>{option.label}</Label>
</div>
))}
</RadioGroup>
)}
</div>
);
})}
{error && <p className="text-sm text-red-400">{error}</p>}
<Button type="submit" disabled={status === "submitting"}>
{status === "submitting" && <Loader2 className="mr-2 size-4 animate-spin" />}
{submitLabel}
</Button>
</form>
);
}

View file

@ -0,0 +1,55 @@
import Link from "next/link";
import { ClipboardCheck, Clock, ListChecks } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
export interface SurveyListItem {
id: number;
title: string;
openUntil: string | null;
questionCount: number;
}
export function SurveyList({ items }: { items: SurveyListItem[] }) {
if (items.length === 0) {
return (
<div className="text-muted-foreground flex flex-col items-center gap-2 py-16 text-center">
<ClipboardCheck className="size-10 opacity-50" />
<p className="text-sm">No surveys are open for you right now.</p>
</div>
);
}
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{items.map((item) => (
<Link key={item.id} href={`/surveys/${item.id}`} className="group">
<Card className="group-hover:border-sidebar-border group-hover:bg-sidebar-accent h-full transition-colors">
<CardHeader>
<CardTitle className="group-hover:text-foreground text-foreground/90 text-base">
{item.title}
</CardTitle>
<CardDescription className="flex items-center gap-3 text-xs">
<span className="flex items-center gap-1">
<ListChecks className="size-3.5" />
{item.questionCount} question{item.questionCount === 1 ? "" : "s"}
</span>
{item.openUntil && (
<span className="flex items-center gap-1">
<Clock className="size-3.5" />
until {new Date(item.openUntil).toLocaleDateString()}
</span>
)}
</CardDescription>
</CardHeader>
<CardContent>
<Badge variant="secondary" className="text-[10px] uppercase">
Open
</Badge>
</CardContent>
</Card>
</Link>
))}
</div>
);
}

View file

@ -12,6 +12,8 @@ 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 const SESSION_EXPIRED_EVENT = "ptf:session-expired";
/** Dispatched after a successful Extend so minigames / consumers un-lock. */
export const SESSION_EXTENDED_EVENT = "ptf:session-extended";
export const POKER_STATE_EVENT = "ptf:poker-state"; export const POKER_STATE_EVENT = "ptf:poker-state";
export const POKER_TABLES_EVENT = "ptf:poker-tables"; export const POKER_TABLES_EVENT = "ptf:poker-tables";
export const POKER_TERMINATED_EVENT = "ptf:poker-terminated"; export const POKER_TERMINATED_EVENT = "ptf:poker-terminated";

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { SESSION_EXPIRED_EVENT } from "@/hooks/useGameTick"; import { SESSION_EXPIRED_EVENT, SESSION_EXTENDED_EVENT } from "@/hooks/useGameTick";
/** /**
* Tracks whether the session has expired (per the client-side watchdog). * Tracks whether the session has expired (per the client-side watchdog).
@ -19,13 +19,19 @@ export function useSessionExpired(): {
const expiredRef = useRef(false); const expiredRef = useRef(false);
useEffect(() => { useEffect(() => {
const handler = () => { const lock = () => {
expiredRef.current = true; expiredRef.current = true;
setExpired(true); setExpired(true);
}; };
window.addEventListener(SESSION_EXPIRED_EVENT, handler); const unlock = () => {
expiredRef.current = false;
setExpired(false);
};
window.addEventListener(SESSION_EXPIRED_EVENT, lock);
window.addEventListener(SESSION_EXTENDED_EVENT, unlock);
return () => { return () => {
window.removeEventListener(SESSION_EXPIRED_EVENT, handler); window.removeEventListener(SESSION_EXPIRED_EVENT, lock);
window.removeEventListener(SESSION_EXTENDED_EVENT, unlock);
}; };
}, []); }, []);

View file

@ -0,0 +1,75 @@
/**
* Session-countdown helpers.
*
* The `payload-token` cookie is HttpOnly, so the browser cannot read when the
* session ends. The server computes the absolute expiry (`sessionExp`, ms since
* epoch) and passes it to the client, which uses these pure helpers to drive a
* live countdown and decide when to warn / auto-close the session banner.
*
* Everything here is side-effect free and client-safe so it is unit-testable
* and shareable between the banner and any consumer.
*/
/** Warnings show this long before the hard cutoff so the user can Extend. */
export const SESSION_WARN_THRESHOLD_MS = 15 * 60 * 1000; // 15 minutes
/** The banner auto-closes this long before expiry (Extend no longer helps). */
export const SESSION_AUTO_CLOSE_MS = 30 * 1000; // 30 seconds
/** Phases of the session-expiry window, driven by remaining time. */
export type SessionExpiryPhase = "idle" | "warn" | "close";
/**
* Milliseconds between countdown ticks. Kept separate from the phase constants
* so the visual countdown can be tuned independently of the warn/auto-close
* thresholds.
*/
export const COUNTDOWN_TICK_MS = 1000;
/**
* Remaining time until the session expires, in milliseconds.
*
* Returns `null` when there is no known expiry (guest / missing cookie) so
* callers can treat "unknown" and "idle" the same.
*/
export function timeUntilExpiry(
sessionExp: number | null | undefined,
now: number = Date.now(),
): number | null {
if (sessionExp == null || !Number.isFinite(sessionExp)) return null;
return sessionExp - now;
}
/**
* Which phase of the expiry window a given remaining time falls into.
*
* - `close`: about to expire (<= closeMs) -> banner auto-closes.
* - `warn`: within the warn window (closeMs < remaining <= warnMs) -> shown.
* - `idle`: plenty of time left (remaining > warnMs) or unknown.
*/
export function sessionExpiryPhase(
remainingMs: number | null,
warnMs: number = SESSION_WARN_THRESHOLD_MS,
closeMs: number = SESSION_AUTO_CLOSE_MS,
): SessionExpiryPhase {
if (remainingMs == null) return "idle";
if (remainingMs <= closeMs) return "close";
if (remainingMs <= warnMs) return "warn";
return "idle";
}
/**
* Format a duration in seconds as `H:MM:SS` (leading hour dropped when < 1h)
* for a live countdown, e.g. `14:59` or `1:02:05`. Always rounds up to the next
* second and never goes negative.
*/
export function formatCountdown(totalSeconds: number): string {
if (!Number.isFinite(totalSeconds)) return "00:00";
const safe = Math.max(0, Math.ceil(totalSeconds));
const hours = Math.floor(safe / 3600);
const minutes = Math.floor((safe % 3600) / 60);
const seconds = safe % 60;
const mm = String(minutes).padStart(2, "0");
const ss = String(seconds).padStart(2, "0");
return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`;
}

View file

@ -0,0 +1,64 @@
/**
* Pure survey eligibility helpers (client-safe: type-only payload-types import).
* The list/detail pages use them server-side to mirror the enforced gates in
* formBuilderConfig.ts, so users never see surveys they cannot submit.
*/
import type { Form } from "@/payload-types";
export interface SurveyAudienceContext {
userId: number;
roleIds: number[];
qualificationNames: string[];
}
export function isSurveyOpen(
form: Pick<Form, "activeFrom" | "activeUntil">,
now: number,
): boolean {
if (form.activeFrom && new Date(form.activeFrom).getTime() > now) return false;
if (form.activeUntil && new Date(form.activeUntil).getTime() <= now) return false;
return true;
}
function relationId(value: unknown): number | undefined {
if (typeof value === "number") return value;
if (typeof value === "object" && value !== null && "id" in value) {
return (value as { id: number }).id;
}
return undefined;
}
function relationName(value: unknown): string | null {
if (typeof value === "object" && value !== null && "name" in value) {
return (value as { name: string }).name;
}
if (typeof value === "string") return value;
return null;
}
export function surveyAudienceMatches(
form: Pick<Form, "targeting">,
ctx: SurveyAudienceContext,
): boolean {
const targeting = form.targeting;
const audience = targeting?.audience ?? "all";
if (audience === "all") return true;
if (audience === "users") {
const ids = (targeting?.users ?? []).map(relationId).filter((v): v is number => v !== undefined);
return ids.includes(ctx.userId);
}
if (audience === "roles") {
const ids = (targeting?.roles ?? []).map(relationId).filter((v): v is number => v !== undefined);
return ids.some((id) => ctx.roleIds.includes(id));
}
const names = (targeting?.qualifications ?? [])
.map(relationName)
.filter((n): n is string => n !== null)
.map((n) => n.toLowerCase());
if (names.length === 0) return false;
return ctx.qualificationNames.some((qn) => names.includes(qn.toLowerCase()));
}

View file

@ -0,0 +1,285 @@
/**
* Survey system built on top of the Payload Form Builder plugin.
*
* The plugin injects two collections: `forms` (survey definitions, authored in
* the admin panel with the block-based field builder) and `form-submissions`
* (one response per take). The plugin provides the field blocks and submission
* plumbing but no audience targeting, expiry, or attribution, so this module
* augments both collections via the plugin's override API:
*
* - audience targeting group (all / specific users / by role / by qualification)
* - activeFrom / activeUntil availability window
* - server-side attribution: submissions are stamped with the logged-in user
* - server-side gates on create: expiry, audience eligibility, one attempt
* per user per survey
*
* Admin-page visibility follows the repo-wide rule (read + admin:<slug>:manage)
* via requireAdminPageAccess; REST reads fall back to logged-in (forms) or
* staff (form-submissions). Authoring is gated by forms:* permissions.
*
* The frontend renders forms with a client-side block renderer and POSTs to
* /api/form-submissions. Payment and upload fields are disabled; the plugin's
* email step is a silent no-op because no form has emails configured.
*/
import type { Field } from "payload";
import { APIError } from "payload";
import { formBuilderPlugin } from "@payloadcms/plugin-form-builder";
import {
hasPermission,
requireAdminPageAccess,
requirePermission,
} from "@/utils/access-control/hasPermission";
type PluginOptions = Parameters<typeof formBuilderPlugin>[0];
const audienceOptions = [
{ label: "All users", value: "all" },
{ label: "Specific users", value: "users" },
{ label: "By role", value: "roles" },
{ label: "By qualification", value: "qualifications" },
];
/** Sibling-first fallback (BankAccounts pattern) for group-nested conditions. */
const whenAudience = (
audience: string,
data: { targeting?: { audience?: string } | null } | undefined,
siblingData: { audience?: string | null } | undefined,
) => (siblingData?.audience ?? data?.targeting?.audience) === audience;
const targetingFields: Field[] = [
{
name: "audience",
type: "select",
required: true,
defaultValue: "all",
options: audienceOptions,
admin: { description: "Who is allowed to see and take this survey?" },
},
{
name: "users",
type: "relationship",
relationTo: "users",
hasMany: true,
admin: { condition: (data, siblingData) => whenAudience("users", data, siblingData) },
},
{
name: "roles",
type: "relationship",
relationTo: "roles",
hasMany: true,
admin: { condition: (data, siblingData) => whenAudience("roles", data, siblingData) },
},
{
name: "qualifications",
type: "relationship",
relationTo: "qualifications",
hasMany: true,
admin: {
condition: (data, siblingData) => whenAudience("qualifications", data, siblingData),
},
},
];
/** Normalize a relationship value (id or hydrated doc) to its id. */
function relationId(value: unknown): number | undefined {
if (typeof value === "number") return value;
if (typeof value === "object" && value !== null && "id" in value) {
return (value as { id: number }).id;
}
return undefined;
}
export function surveyFormBuilderConfig(): PluginOptions {
return {
fields: {
// The plugin's default field map does not register the radio or date
// blocks; enable them explicitly for survey questions.
radio: true,
date: true,
payment: false,
upload: false,
},
formOverrides: {
slug: "forms",
access: {
// REST/API reads: any logged-in user (frontend list + detail pages).
// Admin panel: needs forms:read AND admin:forms:manage.
read: requireAdminPageAccess("forms", ({ req }) => Boolean(req.user)),
create: requirePermission("forms:create"),
update: requirePermission("forms:update"),
delete: requirePermission("forms:delete"),
},
admin: {
group: "Surveys",
useAsTitle: "title",
enableRichTextRelationship: false,
},
fields: ({ defaultFields }) => [
...defaultFields,
{
name: "activeFrom",
type: "date",
label: "Active From",
admin: {
description: "Optional. The survey cannot be taken before this moment.",
},
},
{
name: "activeUntil",
type: "date",
label: "Active Until",
admin: {
description: "Optional. The survey can no longer be taken after this moment.",
},
},
{
name: "targeting",
type: "group",
label: "Targeting",
fields: targetingFields,
},
],
},
formSubmissionOverrides: {
slug: "form-submissions",
access: {
// Any logged-in user may submit; the hooks below enforce eligibility.
create: ({ req }) => Boolean(req.user),
// Responses are sensitive: REST reads need form-submissions:read;
// admin panel additionally needs admin:form-submissions:manage.
read: requireAdminPageAccess("form-submissions", ({ req }) =>
hasPermission(req.payload, req.user, "form-submissions:read"),
),
update: () => false,
delete: requirePermission("form-submissions:delete"),
},
admin: {
group: "Surveys",
defaultColumns: ["form", "user", "createdAt"],
},
fields: ({ defaultFields }) => [
...defaultFields,
{
// Set server-side in beforeChange; hidden everywhere in the UI.
// Not `required` because required validation runs before hooks.
name: "user",
type: "relationship",
relationTo: "users",
admin: { condition: () => false },
},
],
hooks: {
beforeChange: [
// 1. Attribute the submission to the logged-in user.
({ data, req }) => {
if (!data.user && req.user) {
data.user = req.user.id;
}
},
// 2. Expiry gate: the survey must be inside its availability window.
async ({ data, req }) => {
if (!req.user) throw new APIError("You must be logged in.", 403);
const form = await req.payload.findByID({
collection: "forms",
id: data.form as number,
req,
depth: 0,
});
const now = Date.now();
if (form.activeFrom && new Date(form.activeFrom as string).getTime() > now) {
throw new APIError("This survey is not open yet.", 403);
}
if (form.activeUntil && new Date(form.activeUntil as string).getTime() <= now) {
throw new APIError("This survey has closed.", 403);
}
},
// 3. Audience gate: the submitter must match the survey's targeting.
async ({ data, req }) => {
if (!req.user || req.user.collection !== "users") {
throw new APIError("You must be logged in.", 403);
}
const user = req.user;
const form = await req.payload.findByID({
collection: "forms",
id: data.form as number,
req,
depth: 0,
});
const targeting = form.targeting;
const audience = targeting?.audience ?? "all";
if (audience === "all") return;
const ineligible = () => new APIError("You are not eligible for this survey.", 403);
if (audience === "users") {
const ids = (targeting?.users ?? []).map(relationId).filter((v) => v !== undefined);
if (!ids.includes(user.id)) throw ineligible();
return;
}
if (audience === "roles") {
const roleIds = (user.roleDocs ?? []).map(relationId).filter((v) => v !== undefined);
const ids = (targeting?.roles ?? []).map(relationId).filter((v) => v !== undefined);
if (!ids.some((id) => roleIds.includes(id))) throw ineligible();
return;
}
if (audience === "qualifications") {
// Targeting stores Qualifications collection ids; the user's
// qualifications live on their Profile, so resolve names.
const qualIds = (targeting?.qualifications ?? [])
.map(relationId)
.filter((v) => v !== undefined);
if (qualIds.length === 0) throw ineligible();
const qualDocs = await req.payload.find({
collection: "qualifications",
where: { id: { in: qualIds } },
limit: qualIds.length,
depth: 0,
overrideAccess: true,
});
const names = qualDocs.docs.map((q) => q.name.toLowerCase());
const profileRes = await req.payload.find({
collection: "profiles",
where: { user: { equals: user.id } },
limit: 1,
depth: 1,
overrideAccess: true,
});
const held = profileRes.docs[0]?.progression?.qualifications ?? [];
const hasMatch = held.some((q) => {
if (typeof q === "number") return qualIds.includes(q);
if (typeof q === "object" && q !== null && "name" in q) {
return names.includes((q.name as string).toLowerCase());
}
return false;
});
if (!hasMatch) throw ineligible();
}
},
// 4. One attempt per user per survey.
async ({ data, req }) => {
if (!req.user) throw new APIError("You must be logged in.", 403);
const { totalDocs } = await req.payload.count({
collection: "form-submissions",
where: {
and: [{ form: { equals: data.form } }, { user: { equals: req.user.id } }],
},
});
if (totalDocs > 0) {
throw new APIError("You have already submitted this survey.", 409);
}
},
],
},
},
};
}

View file

@ -30488,7 +30488,12 @@
"announcements_rels_roles_id_idx": { "announcements_rels_roles_id_idx": {
"name": "announcements_rels_roles_id_idx", "name": "announcements_rels_roles_id_idx",
"columns": [ "columns": [
"roles_id" {
"expression": "roles_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
], ],
"isUnique": false, "isUnique": false,
"concurrently": false, "concurrently": false,

View file

@ -30488,7 +30488,12 @@
"announcements_rels_roles_id_idx": { "announcements_rels_roles_id_idx": {
"name": "announcements_rels_roles_id_idx", "name": "announcements_rels_roles_id_idx",
"columns": [ "columns": [
"roles_id" {
"expression": "roles_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
], ],
"isUnique": false, "isUnique": false,
"concurrently": false, "concurrently": false,

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,319 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
// The roles.permissions multi-select is backed by the enum_roles_permissions
// Postgres type. Permission values added to the registry after the initial
// schema migration (20260820_004157) were never ALTERed into this type, so
// saving any role holding them fails with "invalid input value for enum".
// This migration converges the type with the current registry. Idempotent
// via IF NOT EXISTS; safe to re-run in any environment.
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'system:admin-access';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'media:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'media:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'media:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'media:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:media:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'users:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'users:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'users:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'users:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'users:unlock';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'users:assign-roles';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:users:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'roles:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'roles:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'roles:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'roles:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:roles:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'ranks:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'ranks:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'ranks:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'ranks:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:ranks:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'profiles:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'profiles:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'profiles:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'profiles:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:profiles:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'awards:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'awards:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'awards:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'awards:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:awards:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'qualifications:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'qualifications:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'qualifications:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'qualifications:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:qualifications:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'assignments:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'assignments:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'assignments:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'assignments:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:assignments:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'assignment-transfers:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'assignment-transfers:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'assignment-transfers:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'assignment-transfers:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:assignment-transfers:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'experience:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'experience:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'experience:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'experience:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:experience:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'user-notifications:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'user-notifications:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'user-notifications:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'user-notifications:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:user-notifications:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'evaluations:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'evaluations:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'evaluations:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'evaluations:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:evaluations:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'missions:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'missions:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'missions:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'missions:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'missions:read-sensitive';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:missions:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'mission-attendances:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'mission-attendances:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'mission-attendances:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'mission-attendances:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:mission-attendances:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'campaigns:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'campaigns:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'campaigns:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'campaigns:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:campaigns:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'campaigns:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'factions:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'factions:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'factions:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'factions:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:factions:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'technologies:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'technologies:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'technologies:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'technologies:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:technologies:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'assets:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'assets:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'assets:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'assets:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:assets:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'resources:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'resources:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'resources:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'resources:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:resources:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'vehicles:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'vehicles:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'vehicles:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'vehicles:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:vehicles:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'structures:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'structures:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'structures:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'structures:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:structures:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'shipments:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'shipments:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'shipments:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'shipments:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:shipments:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'bank-accounts:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'bank-accounts:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'bank-accounts:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'bank-accounts:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:bank-accounts:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'bank-transactions:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'bank-transactions:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'bank-transactions:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'bank-transactions:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:bank-transactions:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'ledger-entries:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'ledger-entries:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'ledger-entries:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'ledger-entries:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:ledger-entries:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'locker-storages:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'locker-storages:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'locker-storages:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'locker-storages:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:locker-storages:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'loadouts:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'loadouts:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'loadouts:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'loadouts:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:loadouts:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'market-listings:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'market-listings:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'market-listings:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'market-listings:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:market-listings:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'market-negotiations:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'market-negotiations:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'market-negotiations:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'market-negotiations:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:market-negotiations:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'market-state:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'market-state:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'market-state:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'market-state:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:market-state:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'projects:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'projects:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'projects:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'projects:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:projects:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'labels:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'labels:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'labels:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'labels:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:labels:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'sprints:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'sprints:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'sprints:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'sprints:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:sprints:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'releases:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'releases:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'releases:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'releases:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:releases:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'tickets:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'tickets:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'tickets:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'tickets:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:tickets:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'ticket-votes:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'ticket-votes:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'ticket-votes:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'ticket-votes:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:ticket-votes:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'game-structures:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'game-structures:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'game-structures:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'game-structures:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:game-structures:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'game-vehicles:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'game-vehicles:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'game-vehicles:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'game-vehicles:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:game-vehicles:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'game-npcs:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'game-npcs:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'game-npcs:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'game-npcs:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:game-npcs:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'game-hard-resources:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'game-hard-resources:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'game-hard-resources:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'game-hard-resources:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:game-hard-resources:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'game-event-logs:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'game-event-logs:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'game-event-logs:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'game-event-logs:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:game-event-logs:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'maps:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'maps:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'maps:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'maps:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:maps:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'map-roads:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:map-roads:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'map-zones:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:map-zones:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'resource-nodes:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:resource-nodes:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'narrative-events:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'narrative-events:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'narrative-events:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'narrative-events:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:narrative-events:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'mission-files:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'mission-files:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'mission-files:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'mission-files:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:mission-files:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'mod-lists:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'mod-lists:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'mod-lists:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'mod-lists:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:mod-lists:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'respawn-tickets:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'respawn-tickets:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'respawn-tickets:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'respawn-tickets:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:respawn-tickets:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'game-servers:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'game-servers:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'game-servers:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'game-servers:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:game-servers:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'arma-sync-events:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'arma-sync-events:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'arma-sync-events:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'arma-sync-events:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:arma-sync-events:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'arma-commands:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'arma-commands:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'arma-commands:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'arma-commands:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:arma-commands:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'operation-events:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'operation-events:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'operation-events:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'operation-events:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:operation-events:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'operation-effects:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'operation-effects:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'operation-effects:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'operation-effects:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:operation-effects:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'operation-reservations:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'operation-reservations:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'operation-reservations:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'operation-reservations:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:operation-reservations:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'operation-readiness:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'operation-readiness:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'operation-readiness:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'operation-readiness:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:operation-readiness:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'zone-pressure:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'zone-pressure:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'zone-pressure:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'zone-pressure:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:zone-pressure:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'announcements:create';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'announcements:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'announcements:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'announcements:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'admin:announcements:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'game-rules:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'game-rules:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'shims:read';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'shims:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'logistics:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'intelligence:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'profiles:intel_excerpt:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'banking:manage';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'tickets:staff';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'discord:staff';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'discord:announce';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'custom-minefields:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'custom-minefields:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'custom-minefield-scores:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'custom-radio-tests:update';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'custom-radio-tests:delete';
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE IF NOT EXISTS 'custom-radio-test-scores:delete';`)
}
// Postgres cannot remove values from an enum type; down is a best-effort no-op.
export async function down(): Promise<void> {
// No-op.
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,439 @@
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_forms_confirmation_type" AS ENUM('message', 'redirect');`)
await db.execute(sql`
CREATE TYPE "public"."enum_forms_targeting_audience" AS ENUM('all', 'users', 'roles', 'qualifications');`)
await db.execute(sql`
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE 'forms:create' BEFORE 'game-rules:read';`)
await db.execute(sql`
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE 'forms:read' BEFORE 'game-rules:read';`)
await db.execute(sql`
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE 'forms:update' BEFORE 'game-rules:read';`)
await db.execute(sql`
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE 'forms:delete' BEFORE 'game-rules:read';`)
await db.execute(sql`
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE 'form-submissions:read' BEFORE 'game-rules:read';`)
await db.execute(sql`
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE 'form-submissions:delete' BEFORE 'game-rules:read';`)
await db.execute(sql`
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE 'admin:forms:manage' BEFORE 'game-rules:read';`)
await db.execute(sql`
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE 'admin:form-submissions:manage' BEFORE 'game-rules:read';`)
await db.execute(sql`
CREATE TABLE "forms_blocks_checkbox" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"_path" text NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"name" varchar NOT NULL,
"label" varchar,
"width" numeric,
"required" boolean,
"default_value" boolean,
"block_name" varchar
);`)
await db.execute(sql`
CREATE TABLE "forms_blocks_country" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"_path" text NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"name" varchar NOT NULL,
"label" varchar,
"width" numeric,
"required" boolean,
"block_name" varchar
);`)
await db.execute(sql`
CREATE TABLE "forms_blocks_email" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"_path" text NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"name" varchar NOT NULL,
"label" varchar,
"width" numeric,
"required" boolean,
"block_name" varchar
);`)
await db.execute(sql`
CREATE TABLE "forms_blocks_message" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"_path" text NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"message" jsonb,
"block_name" varchar
);`)
await db.execute(sql`
CREATE TABLE "forms_blocks_number" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"_path" text NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"name" varchar NOT NULL,
"label" varchar,
"width" numeric,
"default_value" numeric,
"required" boolean,
"block_name" varchar
);`)
await db.execute(sql`
CREATE TABLE "forms_blocks_select_options" (
"_order" integer NOT NULL,
"_parent_id" varchar NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"label" varchar NOT NULL,
"value" varchar NOT NULL
);`)
await db.execute(sql`
CREATE TABLE "forms_blocks_select" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"_path" text NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"name" varchar NOT NULL,
"label" varchar,
"width" numeric,
"default_value" varchar,
"placeholder" varchar,
"required" boolean,
"block_name" varchar
);`)
await db.execute(sql`
CREATE TABLE "forms_blocks_state" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"_path" text NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"name" varchar NOT NULL,
"label" varchar,
"width" numeric,
"required" boolean,
"block_name" varchar
);`)
await db.execute(sql`
CREATE TABLE "forms_blocks_text" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"_path" text NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"name" varchar NOT NULL,
"label" varchar,
"width" numeric,
"default_value" varchar,
"required" boolean,
"block_name" varchar
);`)
await db.execute(sql`
CREATE TABLE "forms_blocks_textarea" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"_path" text NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"name" varchar NOT NULL,
"label" varchar,
"width" numeric,
"default_value" varchar,
"required" boolean,
"block_name" varchar
);`)
await db.execute(sql`
CREATE TABLE "forms_blocks_radio_options" (
"_order" integer NOT NULL,
"_parent_id" varchar NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"label" varchar NOT NULL,
"value" varchar NOT NULL
);`)
await db.execute(sql`
CREATE TABLE "forms_blocks_radio" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"_path" text NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"name" varchar NOT NULL,
"label" varchar,
"width" numeric,
"default_value" varchar,
"required" boolean,
"block_name" varchar
);`)
await db.execute(sql`
CREATE TABLE "forms_blocks_date" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"_path" text NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"name" varchar NOT NULL,
"label" varchar,
"width" numeric,
"required" boolean,
"default_value" timestamp(3) with time zone,
"block_name" varchar
);`)
await db.execute(sql`
CREATE TABLE "forms_emails" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"email_to" varchar,
"cc" varchar,
"bcc" varchar,
"reply_to" varchar,
"email_from" varchar,
"subject" varchar DEFAULT 'You''ve received a new message.' NOT NULL,
"message" jsonb
);`)
await db.execute(sql`
CREATE TABLE "forms" (
"id" serial PRIMARY KEY NOT NULL,
"title" varchar NOT NULL,
"submit_button_label" varchar,
"confirmation_type" "enum_forms_confirmation_type" DEFAULT 'message',
"confirmation_message" jsonb,
"redirect_url" varchar,
"active_from" timestamp(3) with time zone,
"active_until" timestamp(3) with time zone,
"targeting_audience" "enum_forms_targeting_audience" DEFAULT 'all' NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);`)
await db.execute(sql`
CREATE TABLE "forms_rels" (
"id" serial PRIMARY KEY NOT NULL,
"order" integer,
"parent_id" integer NOT NULL,
"path" varchar NOT NULL,
"users_id" integer,
"roles_id" integer,
"qualifications_id" integer
);`)
await db.execute(sql`
CREATE TABLE "form_submissions_submission_data" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"field" varchar NOT NULL,
"value" varchar NOT NULL
);`)
await db.execute(sql`
CREATE TABLE "form_submissions" (
"id" serial PRIMARY KEY NOT NULL,
"form_id" integer NOT NULL,
"user_id" integer,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);`)
await db.execute(sql`
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "forms_id" integer;`)
await db.execute(sql`
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "form_submissions_id" integer;`)
await db.execute(sql`
ALTER TABLE "forms_blocks_checkbox" ADD CONSTRAINT "forms_blocks_checkbox_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."forms"("id") ON DELETE cascade ON UPDATE no action;`)
await db.execute(sql`
ALTER TABLE "forms_blocks_country" ADD CONSTRAINT "forms_blocks_country_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."forms"("id") ON DELETE cascade ON UPDATE no action;`)
await db.execute(sql`
ALTER TABLE "forms_blocks_email" ADD CONSTRAINT "forms_blocks_email_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."forms"("id") ON DELETE cascade ON UPDATE no action;`)
await db.execute(sql`
ALTER TABLE "forms_blocks_message" ADD CONSTRAINT "forms_blocks_message_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."forms"("id") ON DELETE cascade ON UPDATE no action;`)
await db.execute(sql`
ALTER TABLE "forms_blocks_number" ADD CONSTRAINT "forms_blocks_number_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."forms"("id") ON DELETE cascade ON UPDATE no action;`)
await db.execute(sql`
ALTER TABLE "forms_blocks_select_options" ADD CONSTRAINT "forms_blocks_select_options_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."forms_blocks_select"("id") ON DELETE cascade ON UPDATE no action;`)
await db.execute(sql`
ALTER TABLE "forms_blocks_select" ADD CONSTRAINT "forms_blocks_select_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."forms"("id") ON DELETE cascade ON UPDATE no action;`)
await db.execute(sql`
ALTER TABLE "forms_blocks_state" ADD CONSTRAINT "forms_blocks_state_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."forms"("id") ON DELETE cascade ON UPDATE no action;`)
await db.execute(sql`
ALTER TABLE "forms_blocks_text" ADD CONSTRAINT "forms_blocks_text_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."forms"("id") ON DELETE cascade ON UPDATE no action;`)
await db.execute(sql`
ALTER TABLE "forms_blocks_textarea" ADD CONSTRAINT "forms_blocks_textarea_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."forms"("id") ON DELETE cascade ON UPDATE no action;`)
await db.execute(sql`
ALTER TABLE "forms_blocks_radio_options" ADD CONSTRAINT "forms_blocks_radio_options_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."forms_blocks_radio"("id") ON DELETE cascade ON UPDATE no action;`)
await db.execute(sql`
ALTER TABLE "forms_blocks_radio" ADD CONSTRAINT "forms_blocks_radio_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."forms"("id") ON DELETE cascade ON UPDATE no action;`)
await db.execute(sql`
ALTER TABLE "forms_blocks_date" ADD CONSTRAINT "forms_blocks_date_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."forms"("id") ON DELETE cascade ON UPDATE no action;`)
await db.execute(sql`
ALTER TABLE "forms_emails" ADD CONSTRAINT "forms_emails_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."forms"("id") ON DELETE cascade ON UPDATE no action;`)
await db.execute(sql`
ALTER TABLE "forms_rels" ADD CONSTRAINT "forms_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."forms"("id") ON DELETE cascade ON UPDATE no action;`)
await db.execute(sql`
ALTER TABLE "forms_rels" ADD CONSTRAINT "forms_rels_users_fk" FOREIGN KEY ("users_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;`)
await db.execute(sql`
ALTER TABLE "forms_rels" ADD CONSTRAINT "forms_rels_roles_fk" FOREIGN KEY ("roles_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;`)
await db.execute(sql`
ALTER TABLE "forms_rels" ADD CONSTRAINT "forms_rels_qualifications_fk" FOREIGN KEY ("qualifications_id") REFERENCES "public"."qualifications"("id") ON DELETE cascade ON UPDATE no action;`)
await db.execute(sql`
ALTER TABLE "form_submissions_submission_data" ADD CONSTRAINT "form_submissions_submission_data_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."form_submissions"("id") ON DELETE cascade ON UPDATE no action;`)
await db.execute(sql`
ALTER TABLE "form_submissions" ADD CONSTRAINT "form_submissions_form_id_forms_id_fk" FOREIGN KEY ("form_id") REFERENCES "public"."forms"("id") ON DELETE set null ON UPDATE no action;`)
await db.execute(sql`
ALTER TABLE "form_submissions" ADD CONSTRAINT "form_submissions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;`)
await db.execute(sql`
CREATE INDEX "forms_blocks_checkbox_order_idx" ON "forms_blocks_checkbox" USING btree ("_order");`)
await db.execute(sql`
CREATE INDEX "forms_blocks_checkbox_parent_id_idx" ON "forms_blocks_checkbox" USING btree ("_parent_id");`)
await db.execute(sql`
CREATE INDEX "forms_blocks_checkbox_path_idx" ON "forms_blocks_checkbox" USING btree ("_path");`)
await db.execute(sql`
CREATE INDEX "forms_blocks_country_order_idx" ON "forms_blocks_country" USING btree ("_order");`)
await db.execute(sql`
CREATE INDEX "forms_blocks_country_parent_id_idx" ON "forms_blocks_country" USING btree ("_parent_id");`)
await db.execute(sql`
CREATE INDEX "forms_blocks_country_path_idx" ON "forms_blocks_country" USING btree ("_path");`)
await db.execute(sql`
CREATE INDEX "forms_blocks_email_order_idx" ON "forms_blocks_email" USING btree ("_order");`)
await db.execute(sql`
CREATE INDEX "forms_blocks_email_parent_id_idx" ON "forms_blocks_email" USING btree ("_parent_id");`)
await db.execute(sql`
CREATE INDEX "forms_blocks_email_path_idx" ON "forms_blocks_email" USING btree ("_path");`)
await db.execute(sql`
CREATE INDEX "forms_blocks_message_order_idx" ON "forms_blocks_message" USING btree ("_order");`)
await db.execute(sql`
CREATE INDEX "forms_blocks_message_parent_id_idx" ON "forms_blocks_message" USING btree ("_parent_id");`)
await db.execute(sql`
CREATE INDEX "forms_blocks_message_path_idx" ON "forms_blocks_message" USING btree ("_path");`)
await db.execute(sql`
CREATE INDEX "forms_blocks_number_order_idx" ON "forms_blocks_number" USING btree ("_order");`)
await db.execute(sql`
CREATE INDEX "forms_blocks_number_parent_id_idx" ON "forms_blocks_number" USING btree ("_parent_id");`)
await db.execute(sql`
CREATE INDEX "forms_blocks_number_path_idx" ON "forms_blocks_number" USING btree ("_path");`)
await db.execute(sql`
CREATE INDEX "forms_blocks_select_options_order_idx" ON "forms_blocks_select_options" USING btree ("_order");`)
await db.execute(sql`
CREATE INDEX "forms_blocks_select_options_parent_id_idx" ON "forms_blocks_select_options" USING btree ("_parent_id");`)
await db.execute(sql`
CREATE INDEX "forms_blocks_select_order_idx" ON "forms_blocks_select" USING btree ("_order");`)
await db.execute(sql`
CREATE INDEX "forms_blocks_select_parent_id_idx" ON "forms_blocks_select" USING btree ("_parent_id");`)
await db.execute(sql`
CREATE INDEX "forms_blocks_select_path_idx" ON "forms_blocks_select" USING btree ("_path");`)
await db.execute(sql`
CREATE INDEX "forms_blocks_state_order_idx" ON "forms_blocks_state" USING btree ("_order");`)
await db.execute(sql`
CREATE INDEX "forms_blocks_state_parent_id_idx" ON "forms_blocks_state" USING btree ("_parent_id");`)
await db.execute(sql`
CREATE INDEX "forms_blocks_state_path_idx" ON "forms_blocks_state" USING btree ("_path");`)
await db.execute(sql`
CREATE INDEX "forms_blocks_text_order_idx" ON "forms_blocks_text" USING btree ("_order");`)
await db.execute(sql`
CREATE INDEX "forms_blocks_text_parent_id_idx" ON "forms_blocks_text" USING btree ("_parent_id");`)
await db.execute(sql`
CREATE INDEX "forms_blocks_text_path_idx" ON "forms_blocks_text" USING btree ("_path");`)
await db.execute(sql`
CREATE INDEX "forms_blocks_textarea_order_idx" ON "forms_blocks_textarea" USING btree ("_order");`)
await db.execute(sql`
CREATE INDEX "forms_blocks_textarea_parent_id_idx" ON "forms_blocks_textarea" USING btree ("_parent_id");`)
await db.execute(sql`
CREATE INDEX "forms_blocks_textarea_path_idx" ON "forms_blocks_textarea" USING btree ("_path");`)
await db.execute(sql`
CREATE INDEX "forms_blocks_radio_options_order_idx" ON "forms_blocks_radio_options" USING btree ("_order");`)
await db.execute(sql`
CREATE INDEX "forms_blocks_radio_options_parent_id_idx" ON "forms_blocks_radio_options" USING btree ("_parent_id");`)
await db.execute(sql`
CREATE INDEX "forms_blocks_radio_order_idx" ON "forms_blocks_radio" USING btree ("_order");`)
await db.execute(sql`
CREATE INDEX "forms_blocks_radio_parent_id_idx" ON "forms_blocks_radio" USING btree ("_parent_id");`)
await db.execute(sql`
CREATE INDEX "forms_blocks_radio_path_idx" ON "forms_blocks_radio" USING btree ("_path");`)
await db.execute(sql`
CREATE INDEX "forms_blocks_date_order_idx" ON "forms_blocks_date" USING btree ("_order");`)
await db.execute(sql`
CREATE INDEX "forms_blocks_date_parent_id_idx" ON "forms_blocks_date" USING btree ("_parent_id");`)
await db.execute(sql`
CREATE INDEX "forms_blocks_date_path_idx" ON "forms_blocks_date" USING btree ("_path");`)
await db.execute(sql`
CREATE INDEX "forms_emails_order_idx" ON "forms_emails" USING btree ("_order");`)
await db.execute(sql`
CREATE INDEX "forms_emails_parent_id_idx" ON "forms_emails" USING btree ("_parent_id");`)
await db.execute(sql`
CREATE INDEX "forms_updated_at_idx" ON "forms" USING btree ("updated_at");`)
await db.execute(sql`
CREATE INDEX "forms_created_at_idx" ON "forms" USING btree ("created_at");`)
await db.execute(sql`
CREATE INDEX "forms_rels_order_idx" ON "forms_rels" USING btree ("order");`)
await db.execute(sql`
CREATE INDEX "forms_rels_parent_idx" ON "forms_rels" USING btree ("parent_id");`)
await db.execute(sql`
CREATE INDEX "forms_rels_path_idx" ON "forms_rels" USING btree ("path");`)
await db.execute(sql`
CREATE INDEX "forms_rels_users_id_idx" ON "forms_rels" USING btree ("users_id");`)
await db.execute(sql`
CREATE INDEX "forms_rels_roles_id_idx" ON "forms_rels" USING btree ("roles_id");`)
await db.execute(sql`
CREATE INDEX "forms_rels_qualifications_id_idx" ON "forms_rels" USING btree ("qualifications_id");`)
await db.execute(sql`
CREATE INDEX "form_submissions_submission_data_order_idx" ON "form_submissions_submission_data" USING btree ("_order");`)
await db.execute(sql`
CREATE INDEX "form_submissions_submission_data_parent_id_idx" ON "form_submissions_submission_data" USING btree ("_parent_id");`)
await db.execute(sql`
CREATE INDEX "form_submissions_form_idx" ON "form_submissions" USING btree ("form_id");`)
await db.execute(sql`
CREATE INDEX "form_submissions_user_idx" ON "form_submissions" USING btree ("user_id");`)
await db.execute(sql`
CREATE INDEX "form_submissions_updated_at_idx" ON "form_submissions" USING btree ("updated_at");`)
await db.execute(sql`
CREATE INDEX "form_submissions_created_at_idx" ON "form_submissions" USING btree ("created_at");`)
await db.execute(sql`
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_forms_fk" FOREIGN KEY ("forms_id") REFERENCES "public"."forms"("id") ON DELETE cascade ON UPDATE no action;`)
await db.execute(sql`
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_form_submissions_fk" FOREIGN KEY ("form_submissions_id") REFERENCES "public"."form_submissions"("id") ON DELETE cascade ON UPDATE no action;`)
await db.execute(sql`
CREATE INDEX "payload_locked_documents_rels_forms_id_idx" ON "payload_locked_documents_rels" USING btree ("forms_id");`)
await db.execute(sql`
CREATE INDEX "payload_locked_documents_rels_form_submissions_id_idx" ON "payload_locked_documents_rels" USING btree ("form_submissions_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
// Postgres cannot remove enum values, so the enum_roles_permissions additions
// (forms:* / form-submissions:*) are intentionally left in place on rollback.
await db.execute(sql`
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN IF EXISTS "form_submissions_id";`)
await db.execute(sql`
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN IF EXISTS "forms_id";`)
await db.execute(sql`
DROP TABLE IF EXISTS "form_submissions" CASCADE;`)
await db.execute(sql`
DROP TABLE IF EXISTS "form_submissions_submission_data" CASCADE;`)
await db.execute(sql`
DROP TABLE IF EXISTS "forms_rels" CASCADE;`)
await db.execute(sql`
DROP TABLE IF EXISTS "forms" CASCADE;`)
await db.execute(sql`
DROP TABLE IF EXISTS "forms_emails" CASCADE;`)
await db.execute(sql`
DROP TABLE IF EXISTS "forms_blocks_date" CASCADE;`)
await db.execute(sql`
DROP TABLE IF EXISTS "forms_blocks_radio" CASCADE;`)
await db.execute(sql`
DROP TABLE IF EXISTS "forms_blocks_radio_options" CASCADE;`)
await db.execute(sql`
DROP TABLE IF EXISTS "forms_blocks_textarea" CASCADE;`)
await db.execute(sql`
DROP TABLE IF EXISTS "forms_blocks_text" CASCADE;`)
await db.execute(sql`
DROP TABLE IF EXISTS "forms_blocks_state" CASCADE;`)
await db.execute(sql`
DROP TABLE IF EXISTS "forms_blocks_select" CASCADE;`)
await db.execute(sql`
DROP TABLE IF EXISTS "forms_blocks_select_options" CASCADE;`)
await db.execute(sql`
DROP TABLE IF EXISTS "forms_blocks_number" CASCADE;`)
await db.execute(sql`
DROP TABLE IF EXISTS "forms_blocks_message" CASCADE;`)
await db.execute(sql`
DROP TABLE IF EXISTS "forms_blocks_email" CASCADE;`)
await db.execute(sql`
DROP TABLE IF EXISTS "forms_blocks_country" CASCADE;`)
await db.execute(sql`
DROP TABLE IF EXISTS "forms_blocks_checkbox" CASCADE;`)
await db.execute(sql`
DROP TYPE IF EXISTS "public"."enum_forms_targeting_audience";`)
await db.execute(sql`
DROP TYPE IF EXISTS "public"."enum_forms_confirmation_type";`)
}

View file

@ -86,6 +86,8 @@ import * as migration_20260921_150000_add_feature_flags from './20260921_150000_
import * as migration_20260922_230950_add_announcements_collection from './20260922_230950_add_announcements_collection'; import * as migration_20260922_230950_add_announcements_collection from './20260922_230950_add_announcements_collection';
import * as migration_20260923_001209_add_announcements_role_docs_targeting from './20260923_001209_add_announcements_role_docs_targeting'; import * as migration_20260923_001209_add_announcements_role_docs_targeting from './20260923_001209_add_announcements_role_docs_targeting';
import * as migration_20260923_002422_announcements_description_richtext from './20260923_002422_announcements_description_richtext'; import * as migration_20260923_002422_announcements_description_richtext from './20260923_002422_announcements_description_richtext';
import * as migration_20260923_200000_add_missing_role_permission_enum_values from './20260923_200000_add_missing_role_permission_enum_values';
import * as migration_20260923_202700_add_form_builder_collections from './20260923_202700_add_form_builder_collections';
export const migrations = [ export const migrations = [
{ {
@ -528,4 +530,14 @@ export const migrations = [
down: migration_20260923_002422_announcements_description_richtext.down, down: migration_20260923_002422_announcements_description_richtext.down,
name: '20260923_002422_announcements_description_richtext', name: '20260923_002422_announcements_description_richtext',
}, },
{
up: migration_20260923_200000_add_missing_role_permission_enum_values.up,
down: migration_20260923_200000_add_missing_role_permission_enum_values.down,
name: '20260923_200000_add_missing_role_permission_enum_values',
},
{
up: migration_20260923_202700_add_form_builder_collections.up,
down: migration_20260923_202700_add_form_builder_collections.down,
name: '20260923_202700_add_form_builder_collections',
},
]; ];

File diff suppressed because it is too large Load diff

View file

@ -88,6 +88,8 @@ import { scopedAdminPageAccess } from "@/utils/access-control/divisionAccess";
import { payloadAiPlugin, PayloadAiPluginLexicalEditorFeature } from "@ai-stack/payloadcms"; import { payloadAiPlugin, PayloadAiPluginLexicalEditorFeature } from "@ai-stack/payloadcms";
import type { SeedPromptFunction } from "@ai-stack/payloadcms/types"; import type { SeedPromptFunction } from "@ai-stack/payloadcms/types";
import { mcpPlugin } from "@payloadcms/plugin-mcp"; import { mcpPlugin } from "@payloadcms/plugin-mcp";
import { formBuilderPlugin } from "@payloadcms/plugin-form-builder";
import { surveyFormBuilderConfig } from "@/lib/surveys/formBuilderConfig";
import { createOpenAI } from "@ai-sdk/openai"; import { createOpenAI } from "@ai-sdk/openai";
import { createTextStreamResponse, jsonSchema, streamObject } from "ai"; import { createTextStreamResponse, jsonSchema, streamObject } from "ai";
import type { User } from "@/payload-types"; import type { User } from "@/payload-types";
@ -229,6 +231,9 @@ const META_DESCRIPTION = "A gamified platform for Polaris Task Force, an Arma 3
const collections = [ const collections = [
Media, Media,
// Announcements
Announcements,
// Game // Game
GameHardResources, GameHardResources,
GameStructures, GameStructures,
@ -331,9 +336,6 @@ const collections = [
CustomMinefieldScores, CustomMinefieldScores,
CustomRadioTests, CustomRadioTests,
CustomRadioTestScores, CustomRadioTestScores,
// Announcements
Announcements,
]; ];
// Scoped admin pages: a user needs BOTH `<slug>:read` AND `admin:<slug>:manage` // Scoped admin pages: a user needs BOTH `<slug>:read` AND `admin:<slug>:manage`
@ -422,7 +424,8 @@ export default buildConfig({
// it, because the migration chain assumes a schema that push created. // it, because the migration chain assumes a schema that push created.
push: process.env.DB_PUSH === "1", push: process.env.DB_PUSH === "1",
prodMigrations: migrations, prodMigrations: migrations,
}), sharp, }),
sharp,
bin: [ bin: [
{ {
scriptPath: path.resolve(dirname, "scripts/gameTick.ts"), scriptPath: path.resolve(dirname, "scripts/gameTick.ts"),
@ -454,6 +457,7 @@ export default buildConfig({
}, },
], ],
plugins: [ plugins: [
formBuilderPlugin(surveyFormBuilderConfig()),
// payloadCloudPlugin(), // payloadCloudPlugin(),
// storage-adapter-placeholder // storage-adapter-placeholder
mcpPlugin({ mcpPlugin({

View file

@ -627,6 +627,19 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ value: "admin:announcements:manage", label: "Manage Admin Page" }, { value: "admin:announcements:manage", label: "Manage Admin Page" },
], ],
}, },
{
group: "Surveys",
permissions: [
{ value: "forms:create", label: "Create Surveys" },
{ value: "forms:read", label: "Read Surveys" },
{ value: "forms:update", label: "Update Surveys" },
{ value: "forms:delete", label: "Delete Surveys" },
{ value: "form-submissions:read", label: "Read Submissions" },
{ value: "form-submissions:delete", label: "Delete Submissions" },
{ value: "admin:forms:manage", label: "Manage Surveys Admin Page" },
{ value: "admin:form-submissions:manage", label: "Manage Submissions Admin Page" },
],
},
// ---- Globals ------------------------------------------------------------- // ---- Globals -------------------------------------------------------------
{ {
@ -982,6 +995,14 @@ export const ALL_PERMISSION_VALUES = PERMISSION_GROUPS.flatMap((g) =>
"announcements:update", "announcements:update",
"announcements:delete", "announcements:delete",
"admin:announcements:manage", "admin:announcements:manage",
"forms:create",
"forms:read",
"forms:update",
"forms:delete",
"form-submissions:read",
"form-submissions:delete",
"admin:forms:manage",
"admin:form-submissions:manage",
"logistics:manage", "logistics:manage",
"intelligence:manage", "intelligence:manage",
"profiles:intel_excerpt:update", "profiles:intel_excerpt:update",

View file

@ -23,47 +23,16 @@ const GRANTABLE = new Set<string>(ALL_PERMISSION_VALUES);
const grantable = (permissions: Permission[]): Permission[] => const grantable = (permissions: Permission[]): Permission[] =>
permissions.filter((p) => GRANTABLE.has(p)); permissions.filter((p) => GRANTABLE.has(p));
/**
* Read-only baseline (grantable collection reads + global reads). No write
* permissions on purpose: admin-page visibility must stay scoped to division
* roles' manage grants. `<slug>:read` alone never grants admin panel entry
* (that needs a superuser role or any `admin:<slug>:manage`).
*/
const USER_PERMISSIONS: Permission[] = [ const USER_PERMISSIONS: Permission[] = [
"users:read", ...grantable(ALL_COLLECTION_READS),
"ranks:read",
"profiles:read",
"awards:read",
"qualifications:read",
"assignments:read",
"experience:read",
"missions:read",
"mission-attendances:read",
"campaigns:read",
"campaigns:manage",
"factions:read",
"technologies:read",
"assets:read",
"resources:read",
"vehicles:read",
"structures:read",
"shipments:read",
"bank-accounts:read",
"bank-transactions:read",
"ledger-entries:read",
"locker-storages:read",
"loadouts:read",
"market-listings:read",
"market-negotiations:read",
"tickets:read",
"game-structures:read",
"game-vehicles:read",
"game-npcs:read",
"game-hard-resources:read",
"game-event-logs:read",
"maps:read",
"narrative-events:read",
"mission-files:read",
"mod-lists:read",
"game-rules:read", "game-rules:read",
"shims:read", "shims:read",
"shipments:create",
"shipments:update",
"structures:delete",
]; ];
const ADMIN_PERMISSIONS: Permission[] = [ const ADMIN_PERMISSIONS: Permission[] = [
@ -95,6 +64,11 @@ const ADMIN_PERMISSIONS: Permission[] = [
"banking:manage", "banking:manage",
"discord:staff", "discord:staff",
"discord:announce", "discord:announce",
"forms:create",
"forms:read",
"forms:update",
"forms:delete",
"form-submissions:read",
"structures:create", "structures:create",
"structures:update", "structures:update",
...ALL_COLLECTION_READS, ...ALL_COLLECTION_READS,

View file

@ -0,0 +1,53 @@
import { test, expect } from "@playwright/test";
test("banner shows countdown + extend button", async ({ page, context }) => {
// Craft near-expiry token and set it as cookie
const { SignJWT } = await import("jose");
const SECRET = "f80003be79c28ca283c2cbfde2984356";
const key = new TextEncoder().encode(SECRET);
// Login to get a real sid
const loginRes = await fetch("http://localhost:3000/api/users/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "dev", password: "Test123" }),
});
const data = await loginRes.json();
const payload = JSON.parse(Buffer.from(data.token.split(".")[1], "base64url").toString());
const now = Math.floor(Date.now() / 1000);
const near = await new SignJWT({
id: 23, collection: "users", email: payload.email, sid: payload.sid, iat: now, exp: now + 600,
})
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
.setIssuedAt(now)
.setExpirationTime(now + 600)
.sign(key);
// Set cookie
await context.addCookies([{
name: "payload-token",
value: near,
domain: "localhost",
path: "/",
expires: now + 600,
}]);
// Navigate to home
await page.goto("http://localhost:3000/");
await page.waitForSelector("role=status", { timeout: 10000 });
// Check banner is visible
const banner = page.locator("role=status").first();
await expect(banner).toBeVisible();
// Check countdown
const countdown = banner.locator(".font-mono.tabular-nums").first();
await expect(countdown).toHaveText(/0\d:\d{2}/); // should show ~10:00
// Check extend button
const extendBtn = banner.locator("button:has-text('Extend')");
await expect(extendBtn).toBeVisible();
console.log("Banner check PASSED");
});

View file

@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import {
SESSION_AUTO_CLOSE_MS,
SESSION_WARN_THRESHOLD_MS,
formatCountdown,
sessionExpiryPhase,
timeUntilExpiry,
} from "@/lib/session/countdown";
describe("timeUntilExpiry", () => {
it("returns sessionExp - now", () => {
expect(timeUntilExpiry(1_800_000_000, 1_700_000_000)).toBe(100_000_000);
});
it("defaults now to Date.now()", () => {
const now = Date.now();
const result = timeUntilExpiry(now + 5_000);
expect(result).toBeGreaterThanOrEqual(4_900);
expect(result).toBeLessThanOrEqual(5_000);
});
it("returns null when there is no known expiry", () => {
expect(timeUntilExpiry(null)).toBeNull();
expect(timeUntilExpiry(undefined)).toBeNull();
});
it("returns null for a non-finite expiry", () => {
expect(timeUntilExpiry(Number.NaN)).toBeNull();
expect(timeUntilExpiry(Number.POSITIVE_INFINITY)).toBeNull();
});
it("allows a negative remaining time (already expired)", () => {
expect(timeUntilExpiry(100, 200)).toBe(-100);
});
});
describe("sessionExpiryPhase", () => {
const warn = SESSION_WARN_THRESHOLD_MS; // 15 min
const close = SESSION_AUTO_CLOSE_MS; // 30s
it("returns idle when remaining exceeds the warn window", () => {
expect(sessionExpiryPhase(warn + 1)).toBe("idle");
expect(sessionExpiryPhase(null)).toBe("idle");
});
it("returns warn inside the window (close < remaining <= warn)", () => {
expect(sessionExpiryPhase(warn)).toBe("warn");
expect(sessionExpiryPhase(close + 1)).toBe("warn");
expect(sessionExpiryPhase(warn / 2)).toBe("warn");
});
it("returns close at or below the close threshold", () => {
expect(sessionExpiryPhase(close)).toBe("close");
expect(sessionExpiryPhase(0)).toBe("close");
expect(sessionExpiryPhase(-1)).toBe("close");
});
it("honours custom thresholds", () => {
expect(sessionExpiryPhase(10 * 60_000, 15 * 60_000, 5 * 60_000)).toBe("warn");
expect(sessionExpiryPhase(3 * 60_000, 15 * 60_000, 5 * 60_000)).toBe("close");
});
});
describe("formatCountdown", () => {
it("formats under an hour as MM:SS", () => {
expect(formatCountdown(0)).toBe("00:00");
expect(formatCountdown(59)).toBe("00:59");
expect(formatCountdown(60)).toBe("01:00");
expect(formatCountdown(90)).toBe("01:30");
expect(formatCountdown(599)).toBe("09:59");
});
it("formats hours as H:MM:SS", () => {
expect(formatCountdown(3600)).toBe("1:00:00");
expect(formatCountdown(3725)).toBe("1:02:05");
});
it("rounds up to the next whole second", () => {
expect(formatCountdown(0.1)).toBe("00:01");
expect(formatCountdown(59.4)).toBe("01:00");
});
it("never goes negative", () => {
expect(formatCountdown(-5)).toBe("00:00");
});
it("handles non-finite input", () => {
expect(formatCountdown(Number.NaN)).toBe("00:00");
expect(formatCountdown(Number.POSITIVE_INFINITY)).toBe("00:00");
});
});

View file

@ -0,0 +1,411 @@
import { getPayload, type Payload } from "payload";
import config from "@/payload.config";
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";
let payload: Payload;
const RUN = `survey-${Date.now().toString(36)}`;
const TIMEOUT = 30_000;
const NOW = 1_700_000_000_000;
const HOUR = 60 * 60 * 1000;
const roleIds: number[] = [];
const userIds: number[] = [];
const formIds: number[] = [];
const submissionIds: number[] = [];
const qualificationIds: number[] = [];
const profileIds: number[] = [];
let rankId: number;
describe("survey evaluation (pure)", () => {
it("treats missing bounds as an open window", () => {
expect(isSurveyOpen({ activeFrom: null, activeUntil: null }, NOW)).toBe(true);
});
it("respects activeFrom and activeUntil", () => {
expect(isSurveyOpen({ activeFrom: new Date(NOW + HOUR).toISOString(), activeUntil: null }, NOW)).toBe(false);
expect(isSurveyOpen({ activeFrom: new Date(NOW - HOUR).toISOString(), activeUntil: null }, NOW)).toBe(true);
expect(isSurveyOpen({ activeFrom: null, activeUntil: new Date(NOW).toISOString() }, NOW)).toBe(false);
expect(isSurveyOpen({ activeFrom: null, activeUntil: new Date(NOW + HOUR).toISOString() }, NOW)).toBe(true);
});
it("audience all matches anyone; users by id; roles by roleDoc id", () => {
const ctx = (userId: number, roleIds: number[] = []) => ({
userId,
roleIds,
qualificationNames: [],
});
expect(surveyAudienceMatches({ targeting: { audience: "all" } }, ctx(7))).toBe(true);
expect(surveyAudienceMatches({ targeting: { audience: "users", users: [7, 9] } }, ctx(9))).toBe(true);
expect(surveyAudienceMatches({ targeting: { audience: "users", users: [7, 9] } }, ctx(8))).toBe(false);
expect(surveyAudienceMatches({ targeting: { audience: "roles", roles: [3, 5] } }, ctx(1, [2]))).toBe(false);
expect(surveyAudienceMatches({ targeting: { audience: "roles", roles: [3, 5] } }, ctx(1, [2, 5]))).toBe(true);
expect(surveyAudienceMatches({ targeting: { audience: "roles" } }, ctx(1, []))).toBe(false);
});
it("qualification audience matches by case-insensitive name", () => {
const ctx = (names: string[]) => ({ userId: 1, roleIds: [], qualificationNames: names });
expect(
surveyAudienceMatches(
{ targeting: { audience: "qualifications", qualifications: [{ id: 1, name: "Parachutist" } as unknown as Qualification] } },
ctx(["parachutist"]),
),
).toBe(true);
expect(
surveyAudienceMatches(
{ targeting: { audience: "qualifications", qualifications: [{ id: 1, name: "Parachutist" } as unknown as Qualification] } },
ctx(["sniper"]),
),
).toBe(false);
expect(surveyAudienceMatches({ targeting: { audience: "qualifications" } }, ctx(["anything"]))).toBe(false);
});
});
describe("survey submission gates", () => {
let memberUser: User;
let roleAUser: User;
let roleBUser: User;
let qualifiedUser: User;
let unqualifiedUser: User;
let roleA: Role;
let roleB: Role;
let openForm: Form;
let closedForm: Form;
let futureForm: Form;
let usersForm: Form;
let rolesForm: Form;
let qualsForm: Form;
const makeRole = async (label: string, extra: Partial<Role> = {}): Promise<Role> => {
const role = (await payload.create({
collection: "roles",
data: { name: `${RUN}-${label}`, slug: `${RUN}-${label}`, ...extra },
overrideAccess: true,
depth: 0,
})) as unknown as Role;
roleIds.push(role.id);
return role;
};
const makeUser = async (label: string, roleId?: number): Promise<User> => {
const user = (await payload.create({
collection: "users",
data: {
username: `${RUN}-${label}`,
discordUsername: `${RUN}-${label}`,
displayName: label.toUpperCase(),
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
password: "Test123",
...(roleId ? { roleDocs: [roleId] } : {}),
},
overrideAccess: true,
depth: 0,
})) as unknown as User;
userIds.push(user.id);
return user;
};
const makeForm = async (label: string, extra: Partial<Form> = {}): Promise<Form> => {
const form = (await payload.create({
collection: "forms",
data: {
title: `${RUN}-${label}`,
fields: [{ blockType: "text", name: "q1", label: "Q1" }],
targeting: { audience: "all" },
confirmationType: "message",
confirmationMessage: {
root: {
type: "root",
format: "",
indent: 0,
version: 1,
direction: "ltr",
children: [
{
type: "paragraph",
format: "",
indent: 0,
version: 1,
direction: "ltr",
children: [
{
type: "text",
format: 0,
style: "",
mode: "normal",
text: "Thanks!",
version: 1,
},
],
},
],
},
},
...extra,
},
overrideAccess: true,
depth: 0,
})) as unknown as Form;
formIds.push(form.id);
return form;
};
const makeQualification = async (name: string): Promise<Qualification> => {
const qual = (await payload.create({
collection: "qualifications",
data: { name },
overrideAccess: true,
depth: 0,
})) as unknown as Qualification;
qualificationIds.push(qual.id);
return qual;
};
const makeProfile = async (user: User, qualificationIdsForProfile: number[]): Promise<void> => {
const profile = await payload.create({
collection: "profiles",
data: {
user: user.id,
rank: rankId,
dossier: { enlistmentDate: new Date(NOW).toISOString() },
progression: { qualifications: qualificationIdsForProfile },
},
overrideAccess: true,
depth: 0,
});
profileIds.push(profile.id);
};
const submit = (formId: number, user: User) =>
payload.create({
collection: "form-submissions",
data: { form: formId, submissionData: [{ field: "q1", value: "yes" }] },
user,
overrideAccess: false,
depth: 0,
});
beforeAll(async () => {
const payloadConfig = await config;
payload = await getPayload({ config: payloadConfig });
invalidatePermissionCache();
const rank = await payload.create({
collection: "ranks",
data: { name: `${RUN} Rank`, abbreviation: "TS", description: `${RUN} test rank` },
overrideAccess: true,
depth: 0,
});
rankId = rank.id;
const plainRole = await makeRole("plain");
roleA = await makeRole("role-a");
roleB = await makeRole("role-b");
memberUser = await makeUser("member", plainRole.id);
roleAUser = await makeUser("role-a", roleA.id);
roleBUser = await makeUser("role-b", roleB.id);
const qual = await makeQualification(`${RUN}-parachutist`);
qualifiedUser = await makeUser("qualified", plainRole.id);
unqualifiedUser = await makeUser("unqualified", plainRole.id);
await makeProfile(qualifiedUser, [qual.id]);
await makeProfile(unqualifiedUser, []);
openForm = await makeForm("open");
closedForm = await makeForm("closed", {
activeUntil: new Date(Date.now() - HOUR).toISOString(),
});
futureForm = await makeForm("future", {
activeFrom: new Date(Date.now() + HOUR).toISOString(),
});
usersForm = await makeForm("users", {
targeting: { audience: "users", users: [memberUser.id] },
});
rolesForm = await makeForm("roles", {
targeting: { audience: "roles", roles: [roleA.id] },
});
qualsForm = await makeForm("quals", {
targeting: { audience: "qualifications", qualifications: [qual.id] },
});
}, TIMEOUT);
afterAll(async () => {
if (!payload) return;
for (const id of submissionIds) {
await payload.delete({ collection: "form-submissions", id, overrideAccess: true }).catch(() => {});
}
for (const id of profileIds) {
await payload.delete({ collection: "profiles", id, overrideAccess: true }).catch(() => {});
}
for (const id of qualificationIds) {
await payload.delete({ collection: "qualifications", id, overrideAccess: true }).catch(() => {});
}
for (const id of formIds) {
await payload.delete({ collection: "forms", id, overrideAccess: true }).catch(() => {});
}
for (const id of userIds) {
await payload.delete({ collection: "users", id, overrideAccess: true }).catch(() => {});
}
for (const id of roleIds) {
await payload.delete({ collection: "roles", id, overrideAccess: true }).catch(() => {});
}
});
it("attributes the submission to the submitter and records the answers", async () => {
const submission = (await submit(openForm.id, memberUser)) as unknown as {
id: number;
user: number;
submissionData: { field: string; value: string }[];
};
submissionIds.push(submission.id);
expect(submission.user).toBe(memberUser.id);
expect(submission.submissionData).toMatchObject([{ field: "q1", value: "yes" }]);
}, TIMEOUT);
it("enforces one attempt per user while allowing other users", async () => {
await expect(submit(openForm.id, memberUser)).rejects.toThrow(/already submitted/);
const other = (await submit(openForm.id, roleAUser)) as unknown as { id: number };
submissionIds.push(other.id);
}, TIMEOUT);
it("rejects submissions outside the availability window", async () => {
await expect(submit(closedForm.id, memberUser)).rejects.toThrow(/closed/);
await expect(submit(futureForm.id, memberUser)).rejects.toThrow(/not open yet/);
}, TIMEOUT);
it("enforces user-list audience", async () => {
const allowed = (await submit(usersForm.id, memberUser)) as unknown as { id: number };
submissionIds.push(allowed.id);
await expect(submit(usersForm.id, roleAUser)).rejects.toThrow(/not eligible/);
}, TIMEOUT);
it("enforces role audience via roleDocs", async () => {
const allowed = (await submit(rolesForm.id, roleAUser)) as unknown as { id: number };
submissionIds.push(allowed.id);
await expect(submit(rolesForm.id, roleBUser)).rejects.toThrow(/not eligible/);
}, TIMEOUT);
it("enforces qualification audience via the user's profile", async () => {
const allowed = (await submit(qualsForm.id, qualifiedUser)) as unknown as { id: number };
submissionIds.push(allowed.id);
await expect(submit(qualsForm.id, unqualifiedUser)).rejects.toThrow(/not eligible/);
}, TIMEOUT);
});
describe("survey collection access control", () => {
let staffUser: User;
let plainUser: User;
let staffRole: Role;
const makeRole = async (label: string, extra: Partial<Role> = {}): Promise<Role> => {
const role = (await payload.create({
collection: "roles",
data: { name: `${RUN}-acl-${label}`, slug: `${RUN}-acl-${label}`, ...extra },
overrideAccess: true,
depth: 0,
})) as unknown as Role;
roleIds.push(role.id);
return role;
};
const makeUser = async (label: string, roleId: number): Promise<User> => {
const user = (await payload.create({
collection: "users",
data: {
username: `${RUN}-acl-${label}`,
discordUsername: `${RUN}-acl-${label}`,
displayName: label.toUpperCase(),
steamId: `7656119${Math.floor(Math.random() * 1e9)}`,
password: "Test123",
roleDocs: [roleId],
},
overrideAccess: true,
depth: 0,
})) as unknown as User;
userIds.push(user.id);
return user;
};
const accessDecision = async (
collection: "forms" | "form-submissions",
action: "create" | "read" | "update" | "delete",
user: User | null,
pathname?: string,
): Promise<unknown> => {
const builtConfig = await config;
const target = builtConfig.collections.find((c) => c.slug === collection);
expect(target).toBeDefined();
const fn = target?.access?.[action];
expect(typeof fn).toBe("function");
return await (fn as (args: { req: unknown }) => Promise<unknown>)({
req: { user, payload, pathname },
});
};
beforeAll(async () => {
const payloadConfig = await config;
payload = await getPayload({ config: payloadConfig });
invalidatePermissionCache();
staffRole = await makeRole("staff", {
permissions: [
"forms:create",
"forms:read",
"forms:update",
"forms:delete",
"form-submissions:read",
"form-submissions:delete",
"admin:forms:manage",
"admin:form-submissions:manage",
],
});
const plainRole = await makeRole("plain-acl", { permissions: [] });
staffUser = await makeUser("staff", staffRole.id);
plainUser = await makeUser("plain", plainRole.id);
}, TIMEOUT);
it("forms REST reads are logged-in; authoring needs the permissions", async () => {
expect(await accessDecision("forms", "read", null)).toBe(false);
expect(await accessDecision("forms", "read", plainUser)).toBe(true);
expect(await accessDecision("forms", "create", plainUser)).toBe(false);
expect(await accessDecision("forms", "create", staffUser)).toBe(true);
});
it("forms admin-page access needs read AND admin manage", async () => {
expect(await accessDecision("forms", "read", plainUser, "/admin")).toBe(false);
expect(await accessDecision("forms", "read", staffUser, "/admin")).toBe(true);
});
it("submissions REST reads need form-submissions:read", async () => {
expect(await accessDecision("form-submissions", "read", null)).toBe(false);
expect(await accessDecision("form-submissions", "read", plainUser)).toBe(false);
expect(await accessDecision("form-submissions", "read", staffUser)).toBe(true);
});
it("submissions admin-page access needs read AND admin manage", async () => {
expect(await accessDecision("form-submissions", "read", plainUser, "/admin")).toBe(false);
expect(await accessDecision("form-submissions", "read", staffUser, "/admin")).toBe(true);
});
it("submissions create requires a user; delete needs the permission", async () => {
expect(await accessDecision("form-submissions", "create", null)).toBe(false);
expect(await accessDecision("form-submissions", "create", plainUser)).toBe(true);
expect(await accessDecision("form-submissions", "delete", staffUser)).toBe(true);
expect(await accessDecision("form-submissions", "delete", plainUser)).toBe(false);
});
});