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
This commit is contained in:
parent
8cbf1820e3
commit
20b65d08fc
21 changed files with 69749 additions and 617 deletions
5
bun.lock
5
bun.lock
|
|
@ -19,6 +19,7 @@
|
|||
"@payloadcms/email-nodemailer": "3.88.0",
|
||||
"@payloadcms/next": "3.88.0",
|
||||
"@payloadcms/payload-cloud": "3.88.0",
|
||||
"@payloadcms/plugin-form-builder": "3.88.0",
|
||||
"@payloadcms/plugin-mcp": "^3.88.0",
|
||||
"@payloadcms/richtext-lexical": "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/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/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/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/richtext-lexical/@payloadcms/translations": ["@payloadcms/translations@3.88.0", "", { "dependencies": { "date-fns": "4.1.0" } }, "sha512-AZVQjjW5pKxO+K9ODeh6yw6/EfXK1hxGPw55KzzDC5srO3hjNskgyWtkauKvbz+S0+xFspANbRc4OD/Rg/74WA=="],
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@
|
|||
"@payloadcms/email-nodemailer": "3.88.0",
|
||||
"@payloadcms/next": "3.88.0",
|
||||
"@payloadcms/payload-cloud": "3.88.0",
|
||||
"@payloadcms/plugin-form-builder": "3.88.0",
|
||||
"@payloadcms/plugin-mcp": "^3.88.0",
|
||||
"@payloadcms/richtext-lexical": "3.88.0",
|
||||
"@payloadcms/ui": "3.88.0",
|
||||
|
|
|
|||
136
src/app/(frontend)/surveys/[id]/page.tsx
Normal file
136
src/app/(frontend)/surveys/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
src/app/(frontend)/surveys/page.tsx
Normal file
73
src/app/(frontend)/surveys/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 { RscEntryLexicalField as RscEntryLexicalField_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 { BoldFeatureClient as BoldFeatureClient_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_d9c22b951ac6762f436db59b889da382 } from '@/components/admin/awards/CommendationColorField'
|
||||
import { default as default_c3ea68702b5e5f8f2f98055e01c43c8d } from '@/components/admin/awards/RibbonDesigner'
|
||||
|
|
@ -46,7 +46,6 @@ import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } f
|
|||
|
||||
/** @type import('payload').ImportMap */
|
||||
export const importMap = {
|
||||
"@ai-stack/payloadcms/client#ComposeField": ComposeField_4490b89d4413c1ffaecdacfe72efaf73,
|
||||
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_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#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@ai-stack/payloadcms/client#ComposeField": ComposeField_4490b89d4413c1ffaecdacfe72efaf73,
|
||||
"@/components/admin/groups/CollapsibleGroupField#default": default_513af5ed43d616d133e530d8c94232f8,
|
||||
"@/components/admin/awards/CommendationColorField#default": default_d9c22b951ac6762f436db59b889da382,
|
||||
"@/components/admin/awards/RibbonDesigner#default": default_c3ea68702b5e5f8f2f98055e01c43c8d,
|
||||
|
|
|
|||
|
|
@ -175,6 +175,7 @@ export function AppSidebar({
|
|||
<NavCore label="Infrastructure" items={filteredNavLogistics} expandDefaultOpen={hasLogistics} />
|
||||
)}
|
||||
<NavCore label="Chronicle" items={data.navStory} />
|
||||
<NavCore label="Surveys" items={data.navSurveys} />
|
||||
<NavCore label="Helpdesk" items={filteredNavHelpdesk} />
|
||||
<NavCore label="Commerce" items={data.navCommerce} />
|
||||
<NavCore label="Personal" items={data.navPersonal} />
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
Boxes,
|
||||
Building2,
|
||||
ClipboardList,
|
||||
ClipboardCheck,
|
||||
Car,
|
||||
Contact,
|
||||
Crosshair,
|
||||
|
|
@ -191,5 +192,12 @@ export const sidebarData = {
|
|||
icon: ScrollText,
|
||||
},
|
||||
],
|
||||
navSurveys: [
|
||||
{
|
||||
title: "Surveys",
|
||||
url: "/surveys",
|
||||
icon: ClipboardCheck,
|
||||
},
|
||||
],
|
||||
navSecondary: [],
|
||||
};
|
||||
|
|
|
|||
230
src/components/frontend/surveys/SurveyForm.tsx
Normal file
230
src/components/frontend/surveys/SurveyForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
src/components/frontend/surveys/SurveyList.tsx
Normal file
55
src/components/frontend/surveys/SurveyList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
src/lib/surveys/evaluate.ts
Normal file
64
src/lib/surveys/evaluate.ts
Normal 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()));
|
||||
}
|
||||
285
src/lib/surveys/formBuilderConfig.ts
Normal file
285
src/lib/surveys/formBuilderConfig.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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.
|
||||
}
|
||||
34599
src/migrations/20260923_202700_add_form_builder_collections.json
Normal file
34599
src/migrations/20260923_202700_add_form_builder_collections.json
Normal file
File diff suppressed because it is too large
Load diff
439
src/migrations/20260923_202700_add_form_builder_collections.ts
Normal file
439
src/migrations/20260923_202700_add_form_builder_collections.ts
Normal 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";`)
|
||||
}
|
||||
|
|
@ -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_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_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 = [
|
||||
{
|
||||
|
|
@ -528,4 +530,14 @@ export const migrations = [
|
|||
down: migration_20260923_002422_announcements_description_richtext.down,
|
||||
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',
|
||||
},
|
||||
];
|
||||
|
|
|
|||
1582
src/payload-types.ts
1582
src/payload-types.ts
File diff suppressed because it is too large
Load diff
|
|
@ -88,6 +88,8 @@ import { scopedAdminPageAccess } from "@/utils/access-control/divisionAccess";
|
|||
import { payloadAiPlugin, PayloadAiPluginLexicalEditorFeature } from "@ai-stack/payloadcms";
|
||||
import type { SeedPromptFunction } from "@ai-stack/payloadcms/types";
|
||||
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 { createTextStreamResponse, jsonSchema, streamObject } from "ai";
|
||||
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 = [
|
||||
Media,
|
||||
|
||||
// Announcements
|
||||
Announcements,
|
||||
|
||||
// Game
|
||||
GameHardResources,
|
||||
GameStructures,
|
||||
|
|
@ -331,9 +336,6 @@ const collections = [
|
|||
CustomMinefieldScores,
|
||||
CustomRadioTests,
|
||||
CustomRadioTestScores,
|
||||
|
||||
// Announcements
|
||||
Announcements,
|
||||
];
|
||||
|
||||
// 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.
|
||||
push: process.env.DB_PUSH === "1",
|
||||
prodMigrations: migrations,
|
||||
}), sharp,
|
||||
}),
|
||||
sharp,
|
||||
bin: [
|
||||
{
|
||||
scriptPath: path.resolve(dirname, "scripts/gameTick.ts"),
|
||||
|
|
@ -454,6 +457,7 @@ export default buildConfig({
|
|||
},
|
||||
],
|
||||
plugins: [
|
||||
formBuilderPlugin(surveyFormBuilderConfig()),
|
||||
// payloadCloudPlugin(),
|
||||
// storage-adapter-placeholder
|
||||
mcpPlugin({
|
||||
|
|
|
|||
|
|
@ -627,6 +627,19 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
|
|||
{ 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 -------------------------------------------------------------
|
||||
{
|
||||
|
|
@ -982,6 +995,14 @@ export const ALL_PERMISSION_VALUES = PERMISSION_GROUPS.flatMap((g) =>
|
|||
"announcements:update",
|
||||
"announcements:delete",
|
||||
"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",
|
||||
"intelligence:manage",
|
||||
"profiles:intel_excerpt:update",
|
||||
|
|
|
|||
|
|
@ -23,47 +23,16 @@ const GRANTABLE = new Set<string>(ALL_PERMISSION_VALUES);
|
|||
const grantable = (permissions: Permission[]): Permission[] =>
|
||||
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[] = [
|
||||
"users:read",
|
||||
"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",
|
||||
...grantable(ALL_COLLECTION_READS),
|
||||
"game-rules:read",
|
||||
"shims:read",
|
||||
"shipments:create",
|
||||
"shipments:update",
|
||||
"structures:delete",
|
||||
];
|
||||
|
||||
const ADMIN_PERMISSIONS: Permission[] = [
|
||||
|
|
@ -95,6 +64,11 @@ const ADMIN_PERMISSIONS: Permission[] = [
|
|||
"banking:manage",
|
||||
"discord:staff",
|
||||
"discord:announce",
|
||||
"forms:create",
|
||||
"forms:read",
|
||||
"forms:update",
|
||||
"forms:delete",
|
||||
"form-submissions:read",
|
||||
"structures:create",
|
||||
"structures:update",
|
||||
...ALL_COLLECTION_READS,
|
||||
|
|
|
|||
411
tests/int/surveys.int.spec.ts
Normal file
411
tests/int/surveys.int.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue