@10x-media/form-builder 0.1.0-beta.25 → 0.1.0-beta.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/LICENSE +21 -21
- package/dist/actions/body/serializeBody.d.ts +10 -14
- package/dist/actions/body/serializeBody.js +5 -10
- package/dist/actions/body/serializeBody.js.map +1 -1
- package/dist/actions/builtin/emailAction.d.ts +7 -0
- package/dist/actions/builtin/emailAction.js +14 -7
- package/dist/actions/builtin/emailAction.js.map +1 -1
- package/dist/actions/defineAction.d.ts +2 -4
- package/dist/actions/defineAction.js.map +1 -1
- package/dist/actions/dispatch.js +1 -1
- package/dist/actions/emailRender.d.ts +5 -5
- package/dist/actions/fromAddresses.d.ts +3 -1
- package/dist/actions/fromAddresses.js.map +1 -1
- package/dist/actions/recipientSources.d.ts +6 -17
- package/dist/actions/recipientSources.js.map +1 -1
- package/dist/actions/submissionContext.d.ts +34 -0
- package/dist/actions/task.js +12 -15
- package/dist/actions/task.js.map +1 -1
- package/dist/aggregation/aggregateResponses.js +3 -4
- package/dist/aggregation/aggregateResponses.js.map +1 -1
- package/dist/aggregation/resolveResultsRequest.js +38 -24
- package/dist/aggregation/resolveResultsRequest.js.map +1 -1
- package/dist/client/EndpointOptionsSelect.d.ts +1 -0
- package/dist/client/EndpointOptionsSelect.js +1 -0
- package/dist/client/EndpointOptionsSelect.js.map +1 -1
- package/dist/client/FieldNameSelect.d.ts +1 -0
- package/dist/client/FieldNameSelect.js +1 -0
- package/dist/client/FieldNameSelect.js.map +1 -1
- package/dist/client/RecipientsSelect.d.ts +1 -0
- package/dist/client/RecipientsSelect.js +1 -0
- package/dist/client/RecipientsSelect.js.map +1 -1
- package/dist/collections/formSubmissions.js +9 -10
- package/dist/collections/formSubmissions.js.map +1 -1
- package/dist/collections/forms.js +10 -10
- package/dist/collections/forms.js.map +1 -1
- package/dist/consent/resolveConsentSourcesRequest.js +2 -2
- package/dist/consent/resolveConsentSourcesRequest.js.map +1 -1
- package/dist/exports/react.d.ts +2 -2
- package/dist/exports/react.js +2 -2
- package/dist/exports/rsc.d.ts +3 -2
- package/dist/exports/rsc.js +5 -4
- package/dist/exports/types.d.ts +2 -1
- package/dist/form/findFormAtLocale.d.ts +30 -0
- package/dist/form/findFormAtLocale.js +88 -0
- package/dist/form/findFormAtLocale.js.map +1 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.js +19 -11
- package/dist/index.js.map +1 -1
- package/dist/options.d.ts +39 -2
- package/dist/plugin/collectionSlugs.d.ts +28 -0
- package/dist/plugin/collectionSlugs.js +65 -0
- package/dist/plugin/collectionSlugs.js.map +1 -0
- package/dist/plugin/customState.js +1 -1
- package/dist/plugin/customState.js.map +1 -1
- package/dist/plugin/registerCollections.js +9 -4
- package/dist/plugin/registerCollections.js.map +1 -1
- package/dist/poll/closeJob.js +2 -2
- package/dist/poll/closeJob.js.map +1 -1
- package/dist/poll/outcomeBeforeChange.js +2 -2
- package/dist/poll/outcomeBeforeChange.js.map +1 -1
- package/dist/poll/resolvePollCloseRequest.js +3 -3
- package/dist/poll/resolvePollCloseRequest.js.map +1 -1
- package/dist/poll/resolvePollOptionsRequest.js +2 -2
- package/dist/poll/resolvePollOptionsRequest.js.map +1 -1
- package/dist/poll/resolvePollOutcome.js +4 -4
- package/dist/poll/resolvePollOutcome.js.map +1 -1
- package/dist/poll/votes/aggregateFromVotes.js +3 -2
- package/dist/poll/votes/aggregateFromVotes.js.map +1 -1
- package/dist/poll/votes/bumpPollVote.js +9 -6
- package/dist/poll/votes/bumpPollVote.js.map +1 -1
- package/dist/poll/votes/recountPollVotes.js +5 -5
- package/dist/poll/votes/recountPollVotes.js.map +1 -1
- package/dist/poll/votes/voteTallyHook.js +3 -3
- package/dist/poll/votes/voteTallyHook.js.map +1 -1
- package/dist/poll/votes/votesCollection.d.ts +5 -1
- package/dist/poll/votes/votesCollection.js +7 -2
- package/dist/poll/votes/votesCollection.js.map +1 -1
- package/dist/react/Form.d.ts +20 -7
- package/dist/react/Form.js +19 -13
- package/dist/react/Form.js.map +1 -1
- package/dist/react/Poll.d.ts +1 -1
- package/dist/react/Poll.js +7 -2
- package/dist/react/Poll.js.map +1 -1
- package/dist/react/fetchResults.d.ts +6 -3
- package/dist/react/fetchResults.js +10 -5
- package/dist/react/fetchResults.js.map +1 -1
- package/dist/react/submitForm.d.ts +12 -7
- package/dist/react/submitForm.js +5 -3
- package/dist/react/submitForm.js.map +1 -1
- package/dist/submissions/createSubmission.d.ts +7 -1
- package/dist/submissions/createSubmission.js +10 -5
- package/dist/submissions/createSubmission.js.map +1 -1
- package/dist/submissions/resolveVotedSubmission.js +5 -5
- package/dist/submissions/resolveVotedSubmission.js.map +1 -1
- package/dist/submissions/runSubmission.js +1 -1
- package/dist/submissions/submissionLocale.js +27 -6
- package/dist/submissions/submissionLocale.js.map +1 -1
- package/dist/submissions/validateSubmission.js +110 -103
- package/dist/submissions/validateSubmission.js.map +1 -1
- package/dist/submissions/voteChangeEndpoint.js +10 -9
- package/dist/submissions/voteChangeEndpoint.js.map +1 -1
- package/dist/submissions/votedCookie.d.ts +8 -4
- package/dist/submissions/votedCookie.js +18 -15
- package/dist/submissions/votedCookie.js.map +1 -1
- package/dist/validation/builtin/notAlreadySubmitted.js +2 -2
- package/dist/validation/builtin/notAlreadySubmitted.js.map +1 -1
- package/package.json +7 -5
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { pluginSlugsOf } from "../plugin/collectionSlugs.js";
|
|
2
|
+
import { SUBMISSION_LOCALE_CONTEXT_KEY } from "./submissionLocale.js";
|
|
2
3
|
//#region src/submissions/createSubmission.ts
|
|
3
4
|
/**
|
|
4
5
|
* Server-side counterpart to the browser `submitForm` helper: a typed wrapper over `payload.create`
|
|
5
|
-
* for the
|
|
6
|
+
* for the plugin's submissions collection (whatever slug it is registered under). Submitting this way runs the exact same pipeline a browser
|
|
6
7
|
* or raw REST submit does, server-authoritative validation, spam metadata capture when `req` is
|
|
7
8
|
* threaded through, descriptor snapshotting, consent capture, and post-submit action dispatch, since
|
|
8
9
|
* all of it lives in the collection's own hooks rather than in a route handler. Use it from server
|
|
@@ -11,14 +12,18 @@ import { FORM_SUBMISSIONS_SLUG } from "../collections/formSubmissions.js";
|
|
|
11
12
|
*/
|
|
12
13
|
const createSubmission = (payload, args) => {
|
|
13
14
|
const create = payload.create;
|
|
14
|
-
|
|
15
|
-
collection:
|
|
15
|
+
const created = create({
|
|
16
|
+
collection: pluginSlugsOf(payload).formSubmissions,
|
|
16
17
|
data: {
|
|
17
18
|
form: args.form,
|
|
18
19
|
values: args.values
|
|
19
20
|
},
|
|
20
|
-
req: args.req
|
|
21
|
+
req: args.req,
|
|
22
|
+
...args.locale ? { context: { [SUBMISSION_LOCALE_CONTEXT_KEY]: args.locale } } : {}
|
|
21
23
|
});
|
|
24
|
+
return args.locale && args.req ? created.finally(() => {
|
|
25
|
+
delete args.req?.context?.[SUBMISSION_LOCALE_CONTEXT_KEY];
|
|
26
|
+
}) : created;
|
|
22
27
|
};
|
|
23
28
|
//#endregion
|
|
24
29
|
export { createSubmission };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"createSubmission.js","names":[],"sources":["../../src/submissions/createSubmission.ts"],"sourcesContent":["import type { Payload, PayloadRequest } from 'payload'\nimport {
|
|
1
|
+
{"version":3,"file":"createSubmission.js","names":[],"sources":["../../src/submissions/createSubmission.ts"],"sourcesContent":["import type { Payload, PayloadRequest } from 'payload'\nimport { pluginSlugsOf } from '../plugin/collectionSlugs'\nimport type { ConsentProofEntry } from './runSubmission'\nimport { SUBMISSION_LOCALE_CONTEXT_KEY } from './submissionLocale'\nimport type { SubmissionDescriptor, SubmissionValue } from './types'\n\nexport type CreateSubmissionArgs = {\n\tform: number | string\n\tvalues: SubmissionValue[]\n\t/** Thread the incoming request when there is one, so spam metadata (IP, user agent) and per-identity rate limiting see it. */\n\treq?: PayloadRequest\n\t/**\n\t * The submission's content locale, what `<Form>`'s `submissionLocale` sends as `?locale=`: stored on\n\t * the submission and used to render its post-submit actions. Clamped like the browser's (a\n\t * localized host keeps only its configured codes). Absent, `req.locale` or the default locale applies.\n\t */\n\tlocale?: string\n}\n\n/** The stored shape of a `form-submissions` document, as written by the collection's own hooks. */\nexport type CreatedSubmission = {\n\tid: number | string\n\tform: number | string\n\tstatus: 'complete' | 'partial'\n\tlocale?: string\n\tvalues?: SubmissionValue[]\n\tdescriptors?: SubmissionDescriptor[]\n\tconsent?: ConsentProofEntry[]\n\tmeta?: Record<string, unknown>\n\tcreatedAt: string\n\tupdatedAt: string\n}\n\n/**\n * Server-side counterpart to the browser `submitForm` helper: a typed wrapper over `payload.create`\n * for the plugin's submissions collection (whatever slug it is registered under). Submitting this way runs the exact same pipeline a browser\n * or raw REST submit does, server-authoritative validation, spam metadata capture when `req` is\n * threaded through, descriptor snapshotting, consent capture, and post-submit action dispatch, since\n * all of it lives in the collection's own hooks rather than in a route handler. Use it from server\n * code (a script, a queued job, another collection's hook) instead of hand-rolling the collection\n * slug and body shape.\n */\nexport const createSubmission = (\n\tpayload: Payload,\n\targs: CreateSubmissionArgs\n): Promise<CreatedSubmission> => {\n\t// `form-submissions` is registered by this plugin at runtime, so a host's generated Payload types\n\t// need not describe it, and when they do, pin its `form` relationship to whichever single id type\n\t// (string or number) that host's database uses. `form` here accepts either, so create through a\n\t// slug-agnostic signature instead of the host's own shape (same idiom as `registerReliability`'s\n\t// locks-collection create in the jobs plugin).\n\tconst create = payload.create as unknown as (options: {\n\t\tcollection: string\n\t\tdata: { form: number | string; values: SubmissionValue[] }\n\t\treq?: PayloadRequest\n\t\tcontext?: Record<string, unknown>\n\t}) => Promise<CreatedSubmission>\n\tconst created = create({\n\t\tcollection: pluginSlugsOf(payload).formSubmissions,\n\t\tdata: { form: args.form, values: args.values },\n\t\treq: args.req,\n\t\t...(args.locale ? { context: { [SUBMISSION_LOCALE_CONTEXT_KEY]: args.locale } } : {}),\n\t})\n\t// `validateSubmission` consumes the key, but a create rejected before it runs (the spam guard's\n\t// rate limit) would leave it on the host's `req.context` for its next create.\n\treturn args.locale && args.req\n\t\t? created.finally(() => {\n\t\t\t\tdelete args.req?.context?.[SUBMISSION_LOCALE_CONTEXT_KEY]\n\t\t\t})\n\t\t: created\n}\n"],"mappings":";;;;;;;;;;;;AA0CA,MAAa,oBACZ,SACA,SACgC;CAMhC,MAAM,SAAS,QAAQ;CAMvB,MAAM,UAAU,OAAO;EACtB,YAAY,cAAc,OAAO,EAAE;EACnC,MAAM;GAAE,MAAM,KAAK;GAAM,QAAQ,KAAK;EAAO;EAC7C,KAAK,KAAK;EACV,GAAI,KAAK,SAAS,EAAE,SAAS,GAAG,gCAAgC,KAAK,OAAO,EAAE,IAAI,CAAC;CACpF,CAAC;CAGD,OAAO,KAAK,UAAU,KAAK,MACxB,QAAQ,cAAc;EACtB,OAAO,KAAK,KAAK,UAAU;CAC5B,CAAC,IACA;AACJ"}
|
|
@@ -1,10 +1,9 @@
|
|
|
1
|
+
import { pluginSlugsOf } from "../plugin/collectionSlugs.js";
|
|
1
2
|
import { pollConfigOf } from "../form/pollState.js";
|
|
2
3
|
import { formIdOf } from "./formIdOf.js";
|
|
3
4
|
import { answerValues } from "../poll/votes/answerValues.js";
|
|
4
5
|
import { votedSubmissionIdFromCookie } from "./votedCookie.js";
|
|
5
6
|
//#region src/submissions/resolveVotedSubmission.ts
|
|
6
|
-
const FORM_SUBMISSIONS_SLUG = "form-submissions";
|
|
7
|
-
const FORMS_SLUG = "forms";
|
|
8
7
|
/**
|
|
9
8
|
* Resolve which submission (and which pick) a browser's voted cookie identifies for a form. The
|
|
10
9
|
* cookie is httpOnly, so this is the server-side read: an SSR host passes the incoming request's
|
|
@@ -17,10 +16,11 @@ const FORMS_SLUG = "forms";
|
|
|
17
16
|
*/
|
|
18
17
|
const resolveVotedSubmission = async (args) => {
|
|
19
18
|
const { payload, cookieHeader, formId } = args;
|
|
20
|
-
const submissionId = votedSubmissionIdFromCookie(cookieHeader, formId, payload
|
|
19
|
+
const submissionId = votedSubmissionIdFromCookie(cookieHeader, formId, payload);
|
|
21
20
|
if (submissionId == null) return null;
|
|
21
|
+
const slugs = pluginSlugsOf(payload);
|
|
22
22
|
const submission = await payload.findByID({
|
|
23
|
-
collection:
|
|
23
|
+
collection: slugs.formSubmissions,
|
|
24
24
|
id: submissionId,
|
|
25
25
|
depth: 0,
|
|
26
26
|
overrideAccess: true
|
|
@@ -30,7 +30,7 @@ const resolveVotedSubmission = async (args) => {
|
|
|
30
30
|
if (String(formIdOf(stored.form) ?? "") !== String(formId)) return null;
|
|
31
31
|
if (stored.status != null && stored.status !== "complete") return null;
|
|
32
32
|
const form = await payload.findByID({
|
|
33
|
-
collection:
|
|
33
|
+
collection: slugs.forms,
|
|
34
34
|
id: formId,
|
|
35
35
|
depth: 0,
|
|
36
36
|
overrideAccess: true
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"resolveVotedSubmission.js","names":[],"sources":["../../src/submissions/resolveVotedSubmission.ts"],"sourcesContent":["import type { Payload } from 'payload'\nimport { pollConfigOf } from '../form/pollState'\nimport { answerValues } from '../poll/votes/answerValues'\nimport { formIdOf } from './formIdOf'\nimport { votedSubmissionIdFromCookie } from './votedCookie'\n\
|
|
1
|
+
{"version":3,"file":"resolveVotedSubmission.js","names":[],"sources":["../../src/submissions/resolveVotedSubmission.ts"],"sourcesContent":["import type { Payload } from 'payload'\nimport { pollConfigOf } from '../form/pollState'\nimport { pluginSlugsOf } from '../plugin/collectionSlugs'\nimport { answerValues } from '../poll/votes/answerValues'\nimport { formIdOf } from './formIdOf'\nimport { votedSubmissionIdFromCookie } from './votedCookie'\n\n/** The voter's current vote for a poll, resolved server-side from the httpOnly voted cookie. */\nexport type VotedSubmission = {\n\tsubmissionId: string\n\t/** The stored answer of the poll results field exactly as submitted (string, or string[] for a multi-select); use it to prefill the form for a vote change. */\n\tvalue: unknown\n\t/** The same answer normalized to non-empty strings; use it to highlight the voter's pick in results. */\n\tpick: string[]\n}\n\n/**\n * Resolve which submission (and which pick) a browser's voted cookie identifies for a form. The\n * cookie is httpOnly, so this is the server-side read: an SSR host passes the incoming request's\n * `Cookie` header (e.g. Next's `(await headers()).get('cookie')`) and hands the result to\n * `<Poll currentVote hasVoted>`. Returns null whenever the cookie is absent, is the legacy `1`\n * marker, fails signature verification, or names a submission that no longer exists or belongs to\n * a different form, so a null simply means \"treat this visitor as not having voted\". Works for any\n * poll whose cookie carries a signed id (set for `allowChange` polls), even if `allowChange` was\n * later turned off.\n */\nexport const resolveVotedSubmission = async (args: {\n\tpayload: Payload\n\tcookieHeader: string | null | undefined\n\tformId: number | string\n}): Promise<VotedSubmission | null> => {\n\tconst { payload, cookieHeader, formId } = args\n\tconst submissionId = votedSubmissionIdFromCookie(cookieHeader, formId, payload)\n\tif (submissionId == null) {\n\t\treturn null\n\t}\n\tconst slugs = pluginSlugsOf(payload)\n\tconst submission = await payload\n\t\t.findByID({\n\t\t\tcollection: slugs.formSubmissions,\n\t\t\tid: submissionId,\n\t\t\tdepth: 0,\n\t\t\toverrideAccess: true,\n\t\t})\n\t\t.catch(() => null)\n\tif (submission == null) {\n\t\treturn null\n\t}\n\tconst stored = submission as { form?: unknown; status?: unknown; values?: unknown }\n\tif (String(formIdOf(stored.form) ?? '') !== String(formId)) {\n\t\treturn null\n\t}\n\tif (stored.status != null && stored.status !== 'complete') {\n\t\treturn null\n\t}\n\tconst form = await payload\n\t\t.findByID({ collection: slugs.forms, id: formId, depth: 0, overrideAccess: true })\n\t\t.catch(() => null)\n\tconst poll = form?.pollEnabled === true ? pollConfigOf(form.poll) : undefined\n\tconst resultsField =\n\t\ttypeof poll?.resultsField === 'string' && poll.resultsField.length > 0\n\t\t\t? poll.resultsField\n\t\t\t: undefined\n\tif (!resultsField) {\n\t\treturn null\n\t}\n\tconst entry = Array.isArray(stored.values)\n\t\t? (stored.values as { field?: unknown; value?: unknown }[]).find(\n\t\t\t\t(row) => row.field === resultsField\n\t\t\t)\n\t\t: undefined\n\treturn {\n\t\tsubmissionId: String(submission.id),\n\t\tvalue: entry?.value,\n\t\tpick: answerValues(stored.values, resultsField),\n\t}\n}\n"],"mappings":";;;;;;;;;;;;;;;;AA0BA,MAAa,yBAAyB,OAAO,SAIN;CACtC,MAAM,EAAE,SAAS,cAAc,WAAW;CAC1C,MAAM,eAAe,4BAA4B,cAAc,QAAQ,OAAO;CAC9E,IAAI,gBAAgB,MACnB,OAAO;CAER,MAAM,QAAQ,cAAc,OAAO;CACnC,MAAM,aAAa,MAAM,QACvB,SAAS;EACT,YAAY,MAAM;EAClB,IAAI;EACJ,OAAO;EACP,gBAAgB;CACjB,CAAC,EACA,YAAY,IAAI;CAClB,IAAI,cAAc,MACjB,OAAO;CAER,MAAM,SAAS;CACf,IAAI,OAAO,SAAS,OAAO,IAAI,KAAK,EAAE,MAAM,OAAO,MAAM,GACxD,OAAO;CAER,IAAI,OAAO,UAAU,QAAQ,OAAO,WAAW,YAC9C,OAAO;CAER,MAAM,OAAO,MAAM,QACjB,SAAS;EAAE,YAAY,MAAM;EAAO,IAAI;EAAQ,OAAO;EAAG,gBAAgB;CAAK,CAAC,EAChF,YAAY,IAAI;CAClB,MAAM,OAAO,MAAM,gBAAgB,OAAO,aAAa,KAAK,IAAI,IAAI,KAAA;CACpE,MAAM,eACL,OAAO,MAAM,iBAAiB,YAAY,KAAK,aAAa,SAAS,IAClE,KAAK,eACL,KAAA;CACJ,IAAI,CAAC,cACJ,OAAO;CAER,MAAM,QAAQ,MAAM,QAAQ,OAAO,MAAM,IACrC,OAAO,OAAkD,MACzD,QAAQ,IAAI,UAAU,YACxB,IACC,KAAA;CACH,OAAO;EACN,cAAc,OAAO,WAAW,EAAE;EAClC,OAAO,OAAO;EACd,MAAM,aAAa,OAAO,QAAQ,YAAY;CAC/C;AACD"}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { keys } from "../translations/keys.js";
|
|
2
2
|
import { isNamedField } from "../fields/fieldKey.js";
|
|
3
3
|
import { calcExpressionOf, computeCalcFields } from "../calc/computeCalcFields.js";
|
|
4
|
-
import { consentDisplayOf } from "../consent/effectiveStatement.js";
|
|
5
4
|
import { evaluateCondition } from "../conditions/evaluate.js";
|
|
5
|
+
import { consentDisplayOf } from "../consent/effectiveStatement.js";
|
|
6
6
|
import { captureConsent } from "../consent/captureConsent.js";
|
|
7
7
|
import { captureFileRef } from "../uploads/captureFileRef.js";
|
|
8
8
|
import { runValidation } from "../validation/runValidation.js";
|
|
@@ -1,18 +1,39 @@
|
|
|
1
1
|
//#region src/submissions/submissionLocale.ts
|
|
2
|
-
/** A plain language tag (`uk`, `pt-BR`, `
|
|
3
|
-
const LOCALE_TAG = /^[A-Za-z]{2,8}(
|
|
2
|
+
/** A plain language tag (`uk`, `pt-BR`, `zh-Hant`), bounded so a stored locale stays a short identifier. */
|
|
3
|
+
const LOCALE_TAG = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8}){0,4}$/;
|
|
4
|
+
/**
|
|
5
|
+
* `locale` as a canonical BCP-47 tag, or `undefined` when it is not one. An underscore separator
|
|
6
|
+
* (`zh_Hant`) is normalized to a hyphen first: the stored value reaches `Intl` consumers (a field
|
|
7
|
+
* type's `format`), and `Intl` throws a `RangeError` on an underscore.
|
|
8
|
+
*/
|
|
9
|
+
const canonicalTag = (locale) => {
|
|
10
|
+
const tag = locale.replaceAll("_", "-");
|
|
11
|
+
if (tag === "all" || !LOCALE_TAG.test(tag)) return;
|
|
12
|
+
try {
|
|
13
|
+
return Intl.getCanonicalLocales(tag)[0];
|
|
14
|
+
} catch {
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
4
18
|
/**
|
|
5
19
|
* The locale a submission is stored and processed under, from the visitor-controlled `req.locale`.
|
|
6
20
|
* On a localized host only a configured locale code is accepted, anything else (`all`, `*`, a code
|
|
7
21
|
* outside `localeCodes`, which Payload passes through when `localization.fallback` is false) becomes
|
|
8
|
-
* the default locale. Without localization any
|
|
9
|
-
* wrapper can still localize its own strings, and anything else becomes `'en'`.
|
|
22
|
+
* the default locale. Without localization any valid language tag is kept in its canonical form, so a
|
|
23
|
+
* `richText.serialize` wrapper can still localize its own strings, and anything else becomes `'en'`.
|
|
10
24
|
*/
|
|
11
25
|
const resolveSubmissionLocale = (locale, localization) => {
|
|
12
26
|
if (localization) return typeof locale === "string" && localization.localeCodes.includes(locale) ? locale : localization.defaultLocale;
|
|
13
|
-
return typeof locale === "string"
|
|
27
|
+
return (typeof locale === "string" ? canonicalTag(locale) : void 0) ?? "en";
|
|
14
28
|
};
|
|
29
|
+
/**
|
|
30
|
+
* `req.context` key carrying `createSubmission`'s explicit `locale` into the create hook. The local
|
|
31
|
+
* API drops its own `locale` arg on a host without localization, so the context is the one channel
|
|
32
|
+
* that reaches `validateSubmission` either way. Read once and removed there, so it cannot leak into
|
|
33
|
+
* a later create on the same request.
|
|
34
|
+
*/
|
|
35
|
+
const SUBMISSION_LOCALE_CONTEXT_KEY = "formBuilderSubmissionLocale";
|
|
15
36
|
//#endregion
|
|
16
|
-
export { resolveSubmissionLocale };
|
|
37
|
+
export { SUBMISSION_LOCALE_CONTEXT_KEY, resolveSubmissionLocale };
|
|
17
38
|
|
|
18
39
|
//# sourceMappingURL=submissionLocale.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"submissionLocale.js","names":[],"sources":["../../src/submissions/submissionLocale.ts"],"sourcesContent":["import type { SanitizedConfig } from 'payload'\n\n/** A plain language tag (`uk`, `pt-BR`, `
|
|
1
|
+
{"version":3,"file":"submissionLocale.js","names":[],"sources":["../../src/submissions/submissionLocale.ts"],"sourcesContent":["import type { SanitizedConfig } from 'payload'\n\n/** A plain language tag (`uk`, `pt-BR`, `zh-Hant`), bounded so a stored locale stays a short identifier. */\nconst LOCALE_TAG = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8}){0,4}$/\n\n/**\n * `locale` as a canonical BCP-47 tag, or `undefined` when it is not one. An underscore separator\n * (`zh_Hant`) is normalized to a hyphen first: the stored value reaches `Intl` consumers (a field\n * type's `format`), and `Intl` throws a `RangeError` on an underscore.\n */\nconst canonicalTag = (locale: string): string | undefined => {\n\tconst tag = locale.replaceAll('_', '-')\n\tif (tag === 'all' || !LOCALE_TAG.test(tag)) {\n\t\treturn undefined\n\t}\n\ttry {\n\t\treturn Intl.getCanonicalLocales(tag)[0]\n\t} catch {\n\t\treturn undefined\n\t}\n}\n\n/**\n * The locale a submission is stored and processed under, from the visitor-controlled `req.locale`.\n * On a localized host only a configured locale code is accepted, anything else (`all`, `*`, a code\n * outside `localeCodes`, which Payload passes through when `localization.fallback` is false) becomes\n * the default locale. Without localization any valid language tag is kept in its canonical form, so a\n * `richText.serialize` wrapper can still localize its own strings, and anything else becomes `'en'`.\n */\nexport const resolveSubmissionLocale = (\n\tlocale: unknown,\n\tlocalization: SanitizedConfig['localization']\n): string => {\n\tif (localization) {\n\t\treturn typeof locale === 'string' && localization.localeCodes.includes(locale)\n\t\t\t? locale\n\t\t\t: localization.defaultLocale\n\t}\n\treturn (typeof locale === 'string' ? canonicalTag(locale) : undefined) ?? 'en'\n}\n\n/**\n * `req.context` key carrying `createSubmission`'s explicit `locale` into the create hook. The local\n * API drops its own `locale` arg on a host without localization, so the context is the one channel\n * that reaches `validateSubmission` either way. Read once and removed there, so it cannot leak into\n * a later create on the same request.\n */\nexport const SUBMISSION_LOCALE_CONTEXT_KEY = 'formBuilderSubmissionLocale'\n"],"mappings":";;AAGA,MAAM,aAAa;;;;;;AAOnB,MAAM,gBAAgB,WAAuC;CAC5D,MAAM,MAAM,OAAO,WAAW,KAAK,GAAG;CACtC,IAAI,QAAQ,SAAS,CAAC,WAAW,KAAK,GAAG,GACxC;CAED,IAAI;EACH,OAAO,KAAK,oBAAoB,GAAG,EAAE;CACtC,QAAQ;EACP;CACD;AACD;;;;;;;;AASA,MAAa,2BACZ,QACA,iBACY;CACZ,IAAI,cACH,OAAO,OAAO,WAAW,YAAY,aAAa,YAAY,SAAS,MAAM,IAC1E,SACA,aAAa;CAEjB,QAAQ,OAAO,WAAW,WAAW,aAAa,MAAM,IAAI,KAAA,MAAc;AAC3E;;;;;;;AAQA,MAAa,gCAAgC"}
|
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
import { keys } from "../translations/keys.js";
|
|
2
2
|
import { asFieldTranslate, asTranslate } from "../translations/server.js";
|
|
3
3
|
import { resolveConsentEntries } from "../consent/resolveConsentEntries.js";
|
|
4
|
+
import { pluginSlugsOf } from "../plugin/collectionSlugs.js";
|
|
5
|
+
import { findFormAtLocale } from "../form/findFormAtLocale.js";
|
|
6
|
+
import { SUBMISSION_LOCALE_CONTEXT_KEY, resolveSubmissionLocale } from "./submissionLocale.js";
|
|
4
7
|
import { isPollClosed, pollConfigOf } from "../form/pollState.js";
|
|
5
|
-
import { calcExpressionOf } from "../calc/computeCalcFields.js";
|
|
6
|
-
import { resolveCalcContext } from "../calc/resolveCalcContext.js";
|
|
7
|
-
import { resolveEffectivePollOptions } from "../poll/effectivePollOptions.js";
|
|
8
|
-
import { FORMS_SLUG } from "../collections/forms.js";
|
|
9
8
|
import { formIdOf } from "./formIdOf.js";
|
|
10
9
|
import { voteChangeTargetOf } from "./voteChange.js";
|
|
11
10
|
import { POLL_CONTEXT_KEY } from "./votedCookie.js";
|
|
12
11
|
import { IDENTITY_CONTEXT_KEY } from "../spam/constants.js";
|
|
12
|
+
import { calcExpressionOf } from "../calc/computeCalcFields.js";
|
|
13
|
+
import { resolveCalcContext } from "../calc/resolveCalcContext.js";
|
|
13
14
|
import { applyPollOptions } from "../poll/applyPollOptions.js";
|
|
15
|
+
import { resolveEffectivePollOptions } from "../poll/effectivePollOptions.js";
|
|
14
16
|
import { runSubmission } from "./runSubmission.js";
|
|
15
|
-
import { resolveSubmissionLocale } from "./submissionLocale.js";
|
|
16
|
-
import { FORM_SUBMISSIONS_SLUG } from "../collections/formSubmissions.js";
|
|
17
17
|
import { APIError, ValidationError } from "payload";
|
|
18
18
|
//#region src/submissions/validateSubmission.ts
|
|
19
19
|
/**
|
|
@@ -35,112 +35,119 @@ const validateSubmission = ({ registry, ruleRegistry, calcSources, calcFunctions
|
|
|
35
35
|
const boundFormId = originalFormId != null ? formIdOf(originalFormId) : null;
|
|
36
36
|
if (boundFormId == null || String(boundFormId) !== String(formId)) throw new APIError("form-builder: a vote change cannot move a submission across forms", 400);
|
|
37
37
|
}
|
|
38
|
-
const
|
|
39
|
-
req.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
38
|
+
const { localization } = req.payload.config;
|
|
39
|
+
const requested = req.context["formBuilderSubmissionLocale"] ?? req.locale;
|
|
40
|
+
delete req.context[SUBMISSION_LOCALE_CONTEXT_KEY];
|
|
41
|
+
const locale = resolveSubmissionLocale(requested, localization);
|
|
42
|
+
const previousLocale = req.locale;
|
|
43
|
+
if (localization) req.locale = locale;
|
|
44
|
+
try {
|
|
45
|
+
const form = await findFormAtLocale({
|
|
46
|
+
payload: req.payload,
|
|
47
|
+
id: formId,
|
|
48
|
+
locale,
|
|
49
|
+
req
|
|
50
|
+
});
|
|
51
|
+
const poll = pollConfigOf(form.poll);
|
|
52
|
+
const pollEnabled = form.pollEnabled === true;
|
|
53
|
+
req.context[POLL_CONTEXT_KEY] = {
|
|
54
|
+
pollEnabled,
|
|
55
|
+
allowChange: poll?.allowChange === true
|
|
56
|
+
};
|
|
57
|
+
if (pollEnabled && isPollClosed(poll)) throw new APIError(asTranslate(req.i18n.t)(keys.pollClosed), 403);
|
|
58
|
+
let fields = form.fields ?? [];
|
|
59
|
+
const incoming = data.values ?? [];
|
|
60
|
+
const t = asFieldTranslate(req.i18n.t);
|
|
61
|
+
const resultsField = pollEnabled && typeof poll?.resultsField === "string" && poll.resultsField.length > 0 ? poll.resultsField : void 0;
|
|
62
|
+
const resultsInstance = resultsField ? fields.find((instance) => instance.name === resultsField) : void 0;
|
|
63
|
+
const usesResolver = typeof poll?.optionSource === "string" && poll.optionSource.length > 0 || Boolean(resultsInstance && registry.get(resultsInstance.blockType)?.resolveOptions);
|
|
64
|
+
if (resultsField && usesResolver) {
|
|
65
|
+
let resolved;
|
|
66
|
+
try {
|
|
67
|
+
resolved = await resolveEffectivePollOptions({
|
|
68
|
+
payload: req.payload,
|
|
69
|
+
req,
|
|
70
|
+
form,
|
|
71
|
+
sources: pollSourceRegistry ?? /* @__PURE__ */ new Map(),
|
|
72
|
+
fieldTypes: registry
|
|
73
|
+
});
|
|
74
|
+
} catch {
|
|
75
|
+
throw new APIError(asTranslate(req.i18n.t)(keys.pollOptionsUnavailable), 503);
|
|
76
|
+
}
|
|
77
|
+
fields = applyPollOptions(fields, resultsField, resolved);
|
|
78
|
+
const answer = incoming.find((entry) => entry.field === resultsField)?.value;
|
|
79
|
+
if (answer != null && answer !== "" && !resolved.some((option) => option.value === answer)) throw new ValidationError({
|
|
80
|
+
collection: pluginSlugsOf(req.payload).formSubmissions,
|
|
81
|
+
errors: [{
|
|
82
|
+
path: resultsField,
|
|
83
|
+
message: asTranslate(req.i18n.t)(keys.validationSelect)
|
|
84
|
+
}]
|
|
85
|
+
}, req.t);
|
|
86
|
+
}
|
|
87
|
+
const expectedOwner = typeof req.context?.["formBuilderSpamIdentity"] === "string" ? req.context[IDENTITY_CONTEXT_KEY] : void 0;
|
|
88
|
+
let consentEntries = [];
|
|
89
|
+
if (consentSources && fields.some((field) => field.blockType === "consent")) try {
|
|
90
|
+
consentEntries = await resolveConsentEntries({
|
|
64
91
|
payload: req.payload,
|
|
65
92
|
req,
|
|
66
93
|
form,
|
|
67
|
-
sources:
|
|
68
|
-
fieldTypes: registry
|
|
94
|
+
sources: consentSources
|
|
69
95
|
});
|
|
70
96
|
} catch {
|
|
71
|
-
throw new APIError(asTranslate(req.i18n.t)(keys.
|
|
97
|
+
throw new APIError(asTranslate(req.i18n.t)(keys.consentSourcesUnavailable), 503);
|
|
72
98
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
99
|
+
let calcResolved;
|
|
100
|
+
if (fields.some((instance) => calcExpressionOf(instance) !== void 0)) {
|
|
101
|
+
let resolved;
|
|
102
|
+
try {
|
|
103
|
+
resolved = await resolveCalcContext({
|
|
104
|
+
fields,
|
|
105
|
+
sources: calcSources ?? {},
|
|
106
|
+
form,
|
|
107
|
+
payload: req.payload,
|
|
108
|
+
req
|
|
109
|
+
});
|
|
110
|
+
} catch (error) {
|
|
111
|
+
req.payload.logger?.error(`@10x-media/form-builder: calc source resolution failed for form ${String(formId)}: ${error instanceof Error ? error.message : String(error)}`);
|
|
112
|
+
throw new APIError(asTranslate(req.i18n.t)(keys.calcSourcesUnavailable), 503);
|
|
113
|
+
}
|
|
114
|
+
const functions = calcFunctions && Object.keys(calcFunctions).length > 0 ? Object.fromEntries(Object.entries(calcFunctions).map(([name, definition]) => [name, definition.apply])) : void 0;
|
|
115
|
+
calcResolved = {
|
|
116
|
+
...resolved,
|
|
117
|
+
...functions ? { functions } : {}
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
const result = await runSubmission({
|
|
121
|
+
fields,
|
|
122
|
+
values: incoming,
|
|
123
|
+
calcResolved,
|
|
124
|
+
registry,
|
|
125
|
+
ruleRegistry,
|
|
126
|
+
consentEntries,
|
|
127
|
+
consentSnapshot,
|
|
128
|
+
locale,
|
|
129
|
+
t,
|
|
130
|
+
operation: "create",
|
|
88
131
|
req,
|
|
89
|
-
|
|
90
|
-
|
|
132
|
+
payload: req.payload,
|
|
133
|
+
formId,
|
|
134
|
+
uploadSlug,
|
|
135
|
+
expectedOwner,
|
|
136
|
+
strictUploadOwnership
|
|
91
137
|
});
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
req
|
|
105
|
-
});
|
|
106
|
-
} catch (error) {
|
|
107
|
-
req.payload.logger?.error(`@10x-media/form-builder: calc source resolution failed for form ${String(formId)}: ${error instanceof Error ? error.message : String(error)}`);
|
|
108
|
-
throw new APIError(asTranslate(req.i18n.t)(keys.calcSourcesUnavailable), 503);
|
|
109
|
-
}
|
|
110
|
-
const functions = calcFunctions && Object.keys(calcFunctions).length > 0 ? Object.fromEntries(Object.entries(calcFunctions).map(([name, definition]) => [name, definition.apply])) : void 0;
|
|
111
|
-
calcResolved = {
|
|
112
|
-
...resolved,
|
|
113
|
-
...functions ? { functions } : {}
|
|
114
|
-
};
|
|
138
|
+
if (result.errors.length > 0) throw new ValidationError({
|
|
139
|
+
collection: pluginSlugsOf(req.payload).formSubmissions,
|
|
140
|
+
errors: result.errors
|
|
141
|
+
}, req.t);
|
|
142
|
+
data.values = result.values;
|
|
143
|
+
data.descriptors = result.descriptors;
|
|
144
|
+
data.consent = result.consent.length > 0 ? result.consent : void 0;
|
|
145
|
+
data.locale = locale;
|
|
146
|
+
if (!req.user) data.status = "complete";
|
|
147
|
+
return data;
|
|
148
|
+
} finally {
|
|
149
|
+
if (localization) req.locale = previousLocale;
|
|
115
150
|
}
|
|
116
|
-
const result = await runSubmission({
|
|
117
|
-
fields,
|
|
118
|
-
values: incoming,
|
|
119
|
-
calcResolved,
|
|
120
|
-
registry,
|
|
121
|
-
ruleRegistry,
|
|
122
|
-
consentEntries,
|
|
123
|
-
consentSnapshot,
|
|
124
|
-
locale,
|
|
125
|
-
t,
|
|
126
|
-
operation: "create",
|
|
127
|
-
req,
|
|
128
|
-
payload: req.payload,
|
|
129
|
-
formId,
|
|
130
|
-
uploadSlug,
|
|
131
|
-
expectedOwner,
|
|
132
|
-
strictUploadOwnership
|
|
133
|
-
});
|
|
134
|
-
if (result.errors.length > 0) throw new ValidationError({
|
|
135
|
-
collection: FORM_SUBMISSIONS_SLUG,
|
|
136
|
-
errors: result.errors
|
|
137
|
-
}, req.t);
|
|
138
|
-
data.values = result.values;
|
|
139
|
-
data.descriptors = result.descriptors;
|
|
140
|
-
data.consent = result.consent.length > 0 ? result.consent : void 0;
|
|
141
|
-
data.locale = locale;
|
|
142
|
-
if (!req.user) data.status = "complete";
|
|
143
|
-
return data;
|
|
144
151
|
};
|
|
145
152
|
//#endregion
|
|
146
153
|
export { validateSubmission };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"validateSubmission.js","names":[],"sources":["../../src/submissions/validateSubmission.ts"],"sourcesContent":["import {\n\tAPIError,\n\ttype CollectionBeforeValidateHook,\n\ttype TypedLocale,\n\tValidationError,\n} from 'payload'\nimport { calcExpressionOf } from '../calc/computeCalcFields'\nimport type { CalcResolved } from '../calc/evaluate'\nimport type { CalcFunction, CalcSource } from '../calc/registry'\nimport { resolveCalcContext } from '../calc/resolveCalcContext'\nimport { FORM_SUBMISSIONS_SLUG } from '../collections/formSubmissions'\nimport { FORMS_SLUG } from '../collections/forms'\nimport type { ConsentSnapshotMode } from '../consent/captureConsent'\nimport { resolveConsentEntries } from '../consent/resolveConsentEntries'\nimport type { ConsentSourceEntry, ConsentSourcesResolver } from '../consent/types'\nimport type { FieldTypeRegistry } from '../fields/registry'\nimport { isPollClosed, pollConfigOf } from '../form/pollState'\nimport { applyPollOptions } from '../poll/applyPollOptions'\nimport type { PollOption } from '../poll/definePollOptionSource'\nimport { resolveEffectivePollOptions } from '../poll/effectivePollOptions'\nimport type { PollOptionSourceRegistry } from '../poll/registry'\nimport { IDENTITY_CONTEXT_KEY } from '../spam/constants'\nimport { keys } from '../translations/keys'\nimport { asFieldTranslate, asTranslate } from '../translations/server'\nimport type { ValidationRuleRegistry } from '../validation/registry'\nimport { formIdOf } from './formIdOf'\nimport { runSubmission } from './runSubmission'\nimport { resolveSubmissionLocale } from './submissionLocale'\nimport type { FormFieldInstance, SubmissionValue } from './types'\nimport { POLL_CONTEXT_KEY, type PollContextState, voteChangeTargetOf } from './votedCookie'\n\nexport type ValidateSubmissionArgs = {\n\tregistry: FieldTypeRegistry\n\truleRegistry: ValidationRuleRegistry\n\t/** Registered calc sources (plugin option `calc.sources`); re-resolved fresh per submission. */\n\tcalcSources?: Record<string, CalcSource>\n\t/** Registered calc functions (plugin option `calc.functions`); threaded into calc evaluation. */\n\tcalcFunctions?: Record<string, CalcFunction>\n\t/** The host's consent sources resolver (plugin option `consent.sources`); absent when no sources are configured. */\n\tconsentSources?: ConsentSourcesResolver\n\t/** What each consent proof snapshots of the agreed wording (plugin option `consent.snapshot`). */\n\tconsentSnapshot?: ConsentSnapshotMode\n\t/** The plugin-configured uploads collection slug; absent when uploads are disabled. */\n\tuploadSlug?: string\n\t/** Registered poll option sources; a form's configured `optionSource` resolves through this at validation time. */\n\tpollSourceRegistry?: PollOptionSourceRegistry\n\t/** Plugin `spam.uploadOwnership: 'strict'`: reject an owned upload when the submitter is unidentifiable. */\n\tstrictUploadOwnership?: boolean\n}\n\n/**\n * Server-authoritative submission validation. On create (and on a vote-change update flagged by the\n * vote-submit endpoint) it loads the referenced form, re-runs every\n * field's required check, intrinsic validator, and declarative rules through `runSubmission`, threading\n * `req`/`payload` so server-only async rules can hit the DB, and throws a Payload `ValidationError` with\n * per-field paths on any failure. The client is never trusted.\n * Consent fields are captured into `result.consent` (array of proofs, one per visible consent field),\n * built from the host's sources re-resolved here rather than from anything the form or client carries.\n */\nexport const validateSubmission =\n\t({\n\t\tregistry,\n\t\truleRegistry,\n\t\tcalcSources,\n\t\tcalcFunctions,\n\t\tconsentSources,\n\t\tconsentSnapshot,\n\t\tuploadSlug,\n\t\tpollSourceRegistry,\n\t\tstrictUploadOwnership,\n\t}: ValidateSubmissionArgs): CollectionBeforeValidateHook =>\n\tasync ({ data, operation, originalDoc, req }) => {\n\t\t// Updates run the same create-grade pipeline only when the vote-change endpoint flagged the\n\t\t// request; every other update (host server code with overrideAccess) keeps today's behavior.\n\t\tconst changeTarget = operation === 'update' ? voteChangeTargetOf(req) : undefined\n\t\tif ((operation !== 'create' && changeTarget === undefined) || !data) {\n\t\t\treturn data\n\t\t}\n\t\tconst formId = data.form\n\t\tif (formId == null) {\n\t\t\treturn data\n\t\t}\n\t\tif (changeTarget !== undefined) {\n\t\t\t// Fail closed: an unresolvable stored form binding is as disqualifying as a mismatched one.\n\t\t\tconst originalFormId = (originalDoc as { form?: unknown } | undefined)?.form\n\t\t\tconst boundFormId = originalFormId != null ? formIdOf(originalFormId) : null\n\t\t\tif (boundFormId == null || String(boundFormId) !== String(formId)) {\n\t\t\t\tthrow new APIError('form-builder: a vote change cannot move a submission across forms', 400)\n\t\t\t}\n\t\t}\n\n\t\t// `req.locale` comes from the visitor's `?locale=`. Clamp it before anything reads it (the form\n\t\t// load below, the host's consent and calc resolvers, the inline action dispatch) so a visitor\n\t\t// cannot run the pipeline under `all` or an unconfigured code. Cast where Payload takes it: the\n\t\t// clamped value is one of the host's own codes, but its concrete `TypedLocale` union is unknowable here.\n\t\tconst locale = resolveSubmissionLocale(req.locale, req.payload.config.localization)\n\t\treq.locale = locale as TypedLocale\n\n\t\tconst form = await req.payload.findByID({\n\t\t\tcollection: FORMS_SLUG,\n\t\t\tid: formId as string | number,\n\t\t\tdepth: 0,\n\t\t\tlocale: locale as TypedLocale,\n\t\t\treq,\n\t\t})\n\n\t\t// Stash the form's poll state so the voted-cookie afterChange hook can skip a second form\n\t\t// fetch on the same request.\n\t\tconst poll = pollConfigOf(form.poll)\n\t\tconst pollEnabled = form.pollEnabled === true\n\t\treq.context[POLL_CONTEXT_KEY] = {\n\t\t\tpollEnabled,\n\t\t\tallowChange: poll?.allowChange === true,\n\t\t} satisfies PollContextState\n\n\t\t// Form-level lifecycle guard, before any field work: a closed poll accepts no submissions,\n\t\t// regardless of what the client rendered.\n\t\tif (pollEnabled && isPollClosed(poll)) {\n\t\t\tthrow new APIError(asTranslate(req.i18n.t)(keys.pollClosed), 403)\n\t\t}\n\n\t\tlet fields = ((form.fields as FormFieldInstance[] | undefined) ?? []) as FormFieldInstance[]\n\t\tconst incoming = ((data.values as SubmissionValue[] | undefined) ?? []) as SubmissionValue[]\n\t\tconst t = asFieldTranslate(req.i18n.t)\n\n\t\t// With a resolved choice set for the results field (a poll `optionSource`, or the field type's\n\t\t// own `resolveOptions`) the resolved values are the only accepted answers: options are injected\n\t\t// into the field instance (so the select's membership check and the stored option labels use\n\t\t// them) and membership is also enforced directly, so an empty resolution or a non-select results\n\t\t// field still fails closed. A resolve failure rejects the whole submission rather than skipping\n\t\t// the check. Static authored polls are unaffected: their field validates against its own options\n\t\t// through the normal field pipeline.\n\t\tconst resultsField =\n\t\t\tpollEnabled && typeof poll?.resultsField === 'string' && poll.resultsField.length > 0\n\t\t\t\t? poll.resultsField\n\t\t\t\t: undefined\n\t\tconst resultsInstance = resultsField\n\t\t\t? fields.find((instance) => instance.name === resultsField)\n\t\t\t: undefined\n\t\tconst usesResolver =\n\t\t\t(typeof poll?.optionSource === 'string' && poll.optionSource.length > 0) ||\n\t\t\tBoolean(resultsInstance && registry.get(resultsInstance.blockType)?.resolveOptions)\n\t\tif (resultsField && usesResolver) {\n\t\t\tlet resolved: PollOption[]\n\t\t\ttry {\n\t\t\t\tresolved = await resolveEffectivePollOptions({\n\t\t\t\t\tpayload: req.payload,\n\t\t\t\t\treq,\n\t\t\t\t\tform,\n\t\t\t\t\tsources: pollSourceRegistry ?? new Map(),\n\t\t\t\t\tfieldTypes: registry,\n\t\t\t\t})\n\t\t\t} catch {\n\t\t\t\tthrow new APIError(asTranslate(req.i18n.t)(keys.pollOptionsUnavailable), 503)\n\t\t\t}\n\t\t\tfields = applyPollOptions(fields, resultsField, resolved)\n\t\t\tconst answer = incoming.find((entry) => entry.field === resultsField)?.value\n\t\t\tconst isAnswered = answer != null && answer !== ''\n\t\t\tif (isAnswered && !resolved.some((option) => option.value === answer)) {\n\t\t\t\tthrow new ValidationError(\n\t\t\t\t\t{\n\t\t\t\t\t\tcollection: FORM_SUBMISSIONS_SLUG,\n\t\t\t\t\t\terrors: [\n\t\t\t\t\t\t\t{ path: resultsField, message: asTranslate(req.i18n.t)(keys.validationSelect) },\n\t\t\t\t\t\t],\n\t\t\t\t\t},\n\t\t\t\t\treq.t\n\t\t\t\t)\n\t\t\t}\n\t\t}\n\t\tconst expectedOwner =\n\t\t\ttypeof req.context?.[IDENTITY_CONTEXT_KEY] === 'string'\n\t\t\t\t? (req.context[IDENTITY_CONTEXT_KEY] as string)\n\t\t\t\t: undefined\n\n\t\t// Every consent proof is built from the source as it reads right now, not from anything the\n\t\t// form or the client carries, so a resolver failure rejects the submission rather than\n\t\t// recording an agreement to a statement the server cannot vouch for.\n\t\tlet consentEntries: ConsentSourceEntry[] = []\n\t\tif (consentSources && fields.some((field) => field.blockType === 'consent')) {\n\t\t\ttry {\n\t\t\t\tconsentEntries = await resolveConsentEntries({\n\t\t\t\t\tpayload: req.payload,\n\t\t\t\t\treq,\n\t\t\t\t\tform,\n\t\t\t\t\tsources: consentSources,\n\t\t\t\t})\n\t\t\t} catch {\n\t\t\t\tthrow new APIError(asTranslate(req.i18n.t)(keys.consentSourcesUnavailable), 503)\n\t\t\t}\n\t\t}\n\n\t\t// Calc extension values re-resolve fresh here, never trusting the render-time embedding, and a\n\t\t// resolver failure rejects the submission: a calc total is money math, so an outage must not\n\t\t// silently evaluate to 0 (the render path fails open instead; see `calcAfterRead`). Surfaced as\n\t\t// a translated 503 (the consent-sources precedent above), with the cause logged server-side\n\t\t// rather than leaked to the anonymous caller.\n\t\tlet calcResolved: CalcResolved | undefined\n\t\tif (fields.some((instance) => calcExpressionOf(instance) !== undefined)) {\n\t\t\tlet resolved: CalcResolved\n\t\t\ttry {\n\t\t\t\tresolved = await resolveCalcContext({\n\t\t\t\t\tfields,\n\t\t\t\t\tsources: calcSources ?? {},\n\t\t\t\t\t// Double cast: a host's generated Form interface has no index signature, so the direct\n\t\t\t\t\t// cast fails under a consumer tsconfig even though the shape is a plain document.\n\t\t\t\t\tform: form as unknown as { id: number | string } & Record<string, unknown>,\n\t\t\t\t\tpayload: req.payload,\n\t\t\t\t\treq,\n\t\t\t\t})\n\t\t\t} catch (error) {\n\t\t\t\treq.payload.logger?.error(\n\t\t\t\t\t`@10x-media/form-builder: calc source resolution failed for form ${String(formId)}: ${\n\t\t\t\t\t\terror instanceof Error ? error.message : String(error)\n\t\t\t\t\t}`\n\t\t\t\t)\n\t\t\t\tthrow new APIError(asTranslate(req.i18n.t)(keys.calcSourcesUnavailable), 503)\n\t\t\t}\n\t\t\tconst functions =\n\t\t\t\tcalcFunctions && Object.keys(calcFunctions).length > 0\n\t\t\t\t\t? Object.fromEntries(\n\t\t\t\t\t\t\tObject.entries(calcFunctions).map(([name, definition]) => [name, definition.apply])\n\t\t\t\t\t\t)\n\t\t\t\t\t: undefined\n\t\t\tcalcResolved = { ...resolved, ...(functions ? { functions } : {}) }\n\t\t}\n\n\t\tconst result = await runSubmission({\n\t\t\tfields,\n\t\t\tvalues: incoming,\n\t\t\tcalcResolved,\n\t\t\tregistry,\n\t\t\truleRegistry,\n\t\t\tconsentEntries,\n\t\t\tconsentSnapshot,\n\t\t\tlocale,\n\t\t\tt,\n\t\t\toperation: 'create',\n\t\t\treq,\n\t\t\tpayload: req.payload,\n\t\t\tformId: formId as number | string,\n\t\t\tuploadSlug,\n\t\t\texpectedOwner,\n\t\t\tstrictUploadOwnership,\n\t\t})\n\n\t\tif (result.errors.length > 0) {\n\t\t\tthrow new ValidationError({ collection: FORM_SUBMISSIONS_SLUG, errors: result.errors }, req.t)\n\t\t}\n\n\t\tdata.values = result.values\n\t\tdata.descriptors = result.descriptors\n\t\tdata.consent = result.consent.length > 0 ? result.consent : undefined\n\t\tdata.locale = locale\n\t\t// Unauthenticated submits are always 'complete'. A client-supplied 'partial' would\n\t\t// silently skip all post-submit actions and events. Authenticated callers (e.g. an\n\t\t// admin draft-save flow) may set status themselves.\n\t\tif (!req.user) {\n\t\t\tdata.status = 'complete'\n\t\t}\n\t\treturn data\n\t}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"validateSubmission.js","names":[],"sources":["../../src/submissions/validateSubmission.ts"],"sourcesContent":["import {\n\tAPIError,\n\ttype CollectionBeforeValidateHook,\n\ttype TypedLocale,\n\tValidationError,\n} from 'payload'\nimport { calcExpressionOf } from '../calc/computeCalcFields'\nimport type { CalcResolved } from '../calc/evaluate'\nimport type { CalcFunction, CalcSource } from '../calc/registry'\nimport { resolveCalcContext } from '../calc/resolveCalcContext'\nimport type { ConsentSnapshotMode } from '../consent/captureConsent'\nimport { resolveConsentEntries } from '../consent/resolveConsentEntries'\nimport type { ConsentSourceEntry, ConsentSourcesResolver } from '../consent/types'\nimport type { FieldTypeRegistry } from '../fields/registry'\nimport { findFormAtLocale } from '../form/findFormAtLocale'\nimport { isPollClosed, pollConfigOf } from '../form/pollState'\nimport { pluginSlugsOf } from '../plugin/collectionSlugs'\nimport { applyPollOptions } from '../poll/applyPollOptions'\nimport type { PollOption } from '../poll/definePollOptionSource'\nimport { resolveEffectivePollOptions } from '../poll/effectivePollOptions'\nimport type { PollOptionSourceRegistry } from '../poll/registry'\nimport { IDENTITY_CONTEXT_KEY } from '../spam/constants'\nimport { keys } from '../translations/keys'\nimport { asFieldTranslate, asTranslate } from '../translations/server'\nimport type { ValidationRuleRegistry } from '../validation/registry'\nimport { formIdOf } from './formIdOf'\nimport { runSubmission } from './runSubmission'\nimport { resolveSubmissionLocale, SUBMISSION_LOCALE_CONTEXT_KEY } from './submissionLocale'\nimport type { FormFieldInstance, SubmissionValue } from './types'\nimport { POLL_CONTEXT_KEY, type PollContextState, voteChangeTargetOf } from './votedCookie'\n\nexport type ValidateSubmissionArgs = {\n\tregistry: FieldTypeRegistry\n\truleRegistry: ValidationRuleRegistry\n\t/** Registered calc sources (plugin option `calc.sources`); re-resolved fresh per submission. */\n\tcalcSources?: Record<string, CalcSource>\n\t/** Registered calc functions (plugin option `calc.functions`); threaded into calc evaluation. */\n\tcalcFunctions?: Record<string, CalcFunction>\n\t/** The host's consent sources resolver (plugin option `consent.sources`); absent when no sources are configured. */\n\tconsentSources?: ConsentSourcesResolver\n\t/** What each consent proof snapshots of the agreed wording (plugin option `consent.snapshot`). */\n\tconsentSnapshot?: ConsentSnapshotMode\n\t/** The plugin-configured uploads collection slug; absent when uploads are disabled. */\n\tuploadSlug?: string\n\t/** Registered poll option sources; a form's configured `optionSource` resolves through this at validation time. */\n\tpollSourceRegistry?: PollOptionSourceRegistry\n\t/** Plugin `spam.uploadOwnership: 'strict'`: reject an owned upload when the submitter is unidentifiable. */\n\tstrictUploadOwnership?: boolean\n}\n\n/**\n * Server-authoritative submission validation. On create (and on a vote-change update flagged by the\n * vote-submit endpoint) it loads the referenced form, re-runs every\n * field's required check, intrinsic validator, and declarative rules through `runSubmission`, threading\n * `req`/`payload` so server-only async rules can hit the DB, and throws a Payload `ValidationError` with\n * per-field paths on any failure. The client is never trusted.\n * Consent fields are captured into `result.consent` (array of proofs, one per visible consent field),\n * built from the host's sources re-resolved here rather than from anything the form or client carries.\n */\nexport const validateSubmission =\n\t({\n\t\tregistry,\n\t\truleRegistry,\n\t\tcalcSources,\n\t\tcalcFunctions,\n\t\tconsentSources,\n\t\tconsentSnapshot,\n\t\tuploadSlug,\n\t\tpollSourceRegistry,\n\t\tstrictUploadOwnership,\n\t}: ValidateSubmissionArgs): CollectionBeforeValidateHook =>\n\tasync ({ data, operation, originalDoc, req }) => {\n\t\t// Updates run the same create-grade pipeline only when the vote-change endpoint flagged the\n\t\t// request; every other update (host server code with overrideAccess) keeps today's behavior.\n\t\tconst changeTarget = operation === 'update' ? voteChangeTargetOf(req) : undefined\n\t\tif ((operation !== 'create' && changeTarget === undefined) || !data) {\n\t\t\treturn data\n\t\t}\n\t\tconst formId = data.form\n\t\tif (formId == null) {\n\t\t\treturn data\n\t\t}\n\t\tif (changeTarget !== undefined) {\n\t\t\t// Fail closed: an unresolvable stored form binding is as disqualifying as a mismatched one.\n\t\t\tconst originalFormId = (originalDoc as { form?: unknown } | undefined)?.form\n\t\t\tconst boundFormId = originalFormId != null ? formIdOf(originalFormId) : null\n\t\t\tif (boundFormId == null || String(boundFormId) !== String(formId)) {\n\t\t\t\tthrow new APIError('form-builder: a vote change cannot move a submission across forms', 400)\n\t\t\t}\n\t\t}\n\n\t\t// `req.locale` comes from the visitor's `?locale=` (or `createSubmission`'s `locale`). Clamp it\n\t\t// before anything reads it (the form load below, the host's consent and calc resolvers) so a\n\t\t// visitor cannot run the pipeline under `all` or an unconfigured code. On a localized host the\n\t\t// clamped value is set on `req` only while this hook runs and restored after, because `req` may\n\t\t// be the host's own request; without localization Payload leaves its `locale` unset, and so do we.\n\t\tconst { localization } = req.payload.config\n\t\tconst requested = req.context[SUBMISSION_LOCALE_CONTEXT_KEY] ?? req.locale\n\t\tdelete req.context[SUBMISSION_LOCALE_CONTEXT_KEY]\n\t\tconst locale = resolveSubmissionLocale(requested, localization)\n\t\tconst previousLocale = req.locale\n\t\tif (localization) {\n\t\t\treq.locale = locale as TypedLocale\n\t\t}\n\t\ttry {\n\t\t\tconst form = await findFormAtLocale({\n\t\t\t\tpayload: req.payload,\n\t\t\t\tid: formId as string | number,\n\t\t\t\tlocale,\n\t\t\t\treq,\n\t\t\t})\n\n\t\t\t// Stash the form's poll state so the voted-cookie afterChange hook can skip a second form\n\t\t\t// fetch on the same request.\n\t\t\tconst poll = pollConfigOf(form.poll)\n\t\t\tconst pollEnabled = form.pollEnabled === true\n\t\t\treq.context[POLL_CONTEXT_KEY] = {\n\t\t\t\tpollEnabled,\n\t\t\t\tallowChange: poll?.allowChange === true,\n\t\t\t} satisfies PollContextState\n\n\t\t\t// Form-level lifecycle guard, before any field work: a closed poll accepts no submissions,\n\t\t\t// regardless of what the client rendered.\n\t\t\tif (pollEnabled && isPollClosed(poll)) {\n\t\t\t\tthrow new APIError(asTranslate(req.i18n.t)(keys.pollClosed), 403)\n\t\t\t}\n\n\t\t\tlet fields = ((form.fields as FormFieldInstance[] | undefined) ?? []) as FormFieldInstance[]\n\t\t\tconst incoming = ((data.values as SubmissionValue[] | undefined) ?? []) as SubmissionValue[]\n\t\t\tconst t = asFieldTranslate(req.i18n.t)\n\n\t\t\t// With a resolved choice set for the results field (a poll `optionSource`, or the field type's\n\t\t\t// own `resolveOptions`) the resolved values are the only accepted answers: options are injected\n\t\t\t// into the field instance (so the select's membership check and the stored option labels use\n\t\t\t// them) and membership is also enforced directly, so an empty resolution or a non-select results\n\t\t\t// field still fails closed. A resolve failure rejects the whole submission rather than skipping\n\t\t\t// the check. Static authored polls are unaffected: their field validates against its own options\n\t\t\t// through the normal field pipeline.\n\t\t\tconst resultsField =\n\t\t\t\tpollEnabled && typeof poll?.resultsField === 'string' && poll.resultsField.length > 0\n\t\t\t\t\t? poll.resultsField\n\t\t\t\t\t: undefined\n\t\t\tconst resultsInstance = resultsField\n\t\t\t\t? fields.find((instance) => instance.name === resultsField)\n\t\t\t\t: undefined\n\t\t\tconst usesResolver =\n\t\t\t\t(typeof poll?.optionSource === 'string' && poll.optionSource.length > 0) ||\n\t\t\t\tBoolean(resultsInstance && registry.get(resultsInstance.blockType)?.resolveOptions)\n\t\t\tif (resultsField && usesResolver) {\n\t\t\t\tlet resolved: PollOption[]\n\t\t\t\ttry {\n\t\t\t\t\tresolved = await resolveEffectivePollOptions({\n\t\t\t\t\t\tpayload: req.payload,\n\t\t\t\t\t\treq,\n\t\t\t\t\t\tform,\n\t\t\t\t\t\tsources: pollSourceRegistry ?? new Map(),\n\t\t\t\t\t\tfieldTypes: registry,\n\t\t\t\t\t})\n\t\t\t\t} catch {\n\t\t\t\t\tthrow new APIError(asTranslate(req.i18n.t)(keys.pollOptionsUnavailable), 503)\n\t\t\t\t}\n\t\t\t\tfields = applyPollOptions(fields, resultsField, resolved)\n\t\t\t\tconst answer = incoming.find((entry) => entry.field === resultsField)?.value\n\t\t\t\tconst isAnswered = answer != null && answer !== ''\n\t\t\t\tif (isAnswered && !resolved.some((option) => option.value === answer)) {\n\t\t\t\t\tthrow new ValidationError(\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tcollection: pluginSlugsOf(req.payload).formSubmissions,\n\t\t\t\t\t\t\terrors: [\n\t\t\t\t\t\t\t\t{ path: resultsField, message: asTranslate(req.i18n.t)(keys.validationSelect) },\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t},\n\t\t\t\t\t\treq.t\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t}\n\t\t\tconst expectedOwner =\n\t\t\t\ttypeof req.context?.[IDENTITY_CONTEXT_KEY] === 'string'\n\t\t\t\t\t? (req.context[IDENTITY_CONTEXT_KEY] as string)\n\t\t\t\t\t: undefined\n\n\t\t\t// Every consent proof is built from the source as it reads right now, not from anything the\n\t\t\t// form or the client carries, so a resolver failure rejects the submission rather than\n\t\t\t// recording an agreement to a statement the server cannot vouch for.\n\t\t\tlet consentEntries: ConsentSourceEntry[] = []\n\t\t\tif (consentSources && fields.some((field) => field.blockType === 'consent')) {\n\t\t\t\ttry {\n\t\t\t\t\tconsentEntries = await resolveConsentEntries({\n\t\t\t\t\t\tpayload: req.payload,\n\t\t\t\t\t\treq,\n\t\t\t\t\t\tform,\n\t\t\t\t\t\tsources: consentSources,\n\t\t\t\t\t})\n\t\t\t\t} catch {\n\t\t\t\t\tthrow new APIError(asTranslate(req.i18n.t)(keys.consentSourcesUnavailable), 503)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Calc extension values re-resolve fresh here, never trusting the render-time embedding, and a\n\t\t\t// resolver failure rejects the submission: a calc total is money math, so an outage must not\n\t\t\t// silently evaluate to 0 (the render path fails open instead; see `calcAfterRead`). Surfaced as\n\t\t\t// a translated 503 (the consent-sources precedent above), with the cause logged server-side\n\t\t\t// rather than leaked to the anonymous caller.\n\t\t\tlet calcResolved: CalcResolved | undefined\n\t\t\tif (fields.some((instance) => calcExpressionOf(instance) !== undefined)) {\n\t\t\t\tlet resolved: CalcResolved\n\t\t\t\ttry {\n\t\t\t\t\tresolved = await resolveCalcContext({\n\t\t\t\t\t\tfields,\n\t\t\t\t\t\tsources: calcSources ?? {},\n\t\t\t\t\t\t// Double cast: a host's generated Form interface has no index signature, so the direct\n\t\t\t\t\t\t// cast fails under a consumer tsconfig even though the shape is a plain document.\n\t\t\t\t\t\tform: form as unknown as { id: number | string } & Record<string, unknown>,\n\t\t\t\t\t\tpayload: req.payload,\n\t\t\t\t\t\treq,\n\t\t\t\t\t})\n\t\t\t\t} catch (error) {\n\t\t\t\t\treq.payload.logger?.error(\n\t\t\t\t\t\t`@10x-media/form-builder: calc source resolution failed for form ${String(formId)}: ${\n\t\t\t\t\t\t\terror instanceof Error ? error.message : String(error)\n\t\t\t\t\t\t}`\n\t\t\t\t\t)\n\t\t\t\t\tthrow new APIError(asTranslate(req.i18n.t)(keys.calcSourcesUnavailable), 503)\n\t\t\t\t}\n\t\t\t\tconst functions =\n\t\t\t\t\tcalcFunctions && Object.keys(calcFunctions).length > 0\n\t\t\t\t\t\t? Object.fromEntries(\n\t\t\t\t\t\t\t\tObject.entries(calcFunctions).map(([name, definition]) => [name, definition.apply])\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t: undefined\n\t\t\t\tcalcResolved = { ...resolved, ...(functions ? { functions } : {}) }\n\t\t\t}\n\n\t\t\tconst result = await runSubmission({\n\t\t\t\tfields,\n\t\t\t\tvalues: incoming,\n\t\t\t\tcalcResolved,\n\t\t\t\tregistry,\n\t\t\t\truleRegistry,\n\t\t\t\tconsentEntries,\n\t\t\t\tconsentSnapshot,\n\t\t\t\tlocale,\n\t\t\t\tt,\n\t\t\t\toperation: 'create',\n\t\t\t\treq,\n\t\t\t\tpayload: req.payload,\n\t\t\t\tformId: formId as number | string,\n\t\t\t\tuploadSlug,\n\t\t\t\texpectedOwner,\n\t\t\t\tstrictUploadOwnership,\n\t\t\t})\n\n\t\t\tif (result.errors.length > 0) {\n\t\t\t\tthrow new ValidationError(\n\t\t\t\t\t{ collection: pluginSlugsOf(req.payload).formSubmissions, errors: result.errors },\n\t\t\t\t\treq.t\n\t\t\t\t)\n\t\t\t}\n\n\t\t\tdata.values = result.values\n\t\t\tdata.descriptors = result.descriptors\n\t\t\tdata.consent = result.consent.length > 0 ? result.consent : undefined\n\t\t\tdata.locale = locale\n\t\t\t// Unauthenticated submits are always 'complete'. A client-supplied 'partial' would\n\t\t\t// silently skip all post-submit actions and events. Authenticated callers (e.g. an\n\t\t\t// admin draft-save flow) may set status themselves.\n\t\t\tif (!req.user) {\n\t\t\t\tdata.status = 'complete'\n\t\t\t}\n\t\t\treturn data\n\t\t} finally {\n\t\t\tif (localization) {\n\t\t\t\treq.locale = previousLocale\n\t\t\t}\n\t\t}\n\t}\n"],"mappings":"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"}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { keys } from "../translations/keys.js";
|
|
2
2
|
import { asTranslate } from "../translations/server.js";
|
|
3
|
+
import { pluginSlugsOf } from "../plugin/collectionSlugs.js";
|
|
3
4
|
import "../actions/dispatchContext.js";
|
|
4
5
|
import { pollConfigOf } from "../form/pollState.js";
|
|
5
6
|
import { formIdOf } from "./formIdOf.js";
|
|
@@ -7,8 +8,6 @@ import { VOTE_CHANGE_CONTEXT_KEY } from "./voteChange.js";
|
|
|
7
8
|
import { votedSubmissionIdFromCookie } from "./votedCookie.js";
|
|
8
9
|
import { APIError, addDataAndFileToRequest, addLocalesToRequestFromData } from "payload";
|
|
9
10
|
//#region src/submissions/voteChangeEndpoint.ts
|
|
10
|
-
const FORM_SUBMISSIONS_SLUG = "form-submissions";
|
|
11
|
-
const FORMS_SLUG = "forms";
|
|
12
11
|
/** Marks the vote-submit endpoint in the sanitized endpoint list (`custom.formBuilder`). */
|
|
13
12
|
const VOTE_SUBMIT_ENDPOINT_TAG = "vote-submit";
|
|
14
13
|
const isComplete = (doc) => doc.status == null || doc.status === "complete";
|
|
@@ -23,10 +22,11 @@ const isComplete = (doc) => doc.status == null || doc.status === "complete";
|
|
|
23
22
|
const resolveVoteChangeTarget = async (args) => {
|
|
24
23
|
const { req, formId } = args;
|
|
25
24
|
if (formId == null) return null;
|
|
26
|
-
const submissionId = votedSubmissionIdFromCookie(req.headers?.get("cookie"), formId, req.payload
|
|
25
|
+
const submissionId = votedSubmissionIdFromCookie(req.headers?.get("cookie"), formId, req.payload);
|
|
27
26
|
if (submissionId == null) return null;
|
|
27
|
+
const slugs = pluginSlugsOf(req.payload);
|
|
28
28
|
const form = await req.payload.findByID({
|
|
29
|
-
collection:
|
|
29
|
+
collection: slugs.forms,
|
|
30
30
|
id: formId,
|
|
31
31
|
depth: 0,
|
|
32
32
|
overrideAccess: true,
|
|
@@ -34,7 +34,7 @@ const resolveVoteChangeTarget = async (args) => {
|
|
|
34
34
|
}).catch(() => null);
|
|
35
35
|
if (form?.pollEnabled !== true || pollConfigOf(form.poll)?.allowChange !== true) return null;
|
|
36
36
|
const submission = await req.payload.findByID({
|
|
37
|
-
collection:
|
|
37
|
+
collection: slugs.formSubmissions,
|
|
38
38
|
id: submissionId,
|
|
39
39
|
depth: 0,
|
|
40
40
|
overrideAccess: true,
|
|
@@ -46,8 +46,8 @@ const resolveVoteChangeTarget = async (args) => {
|
|
|
46
46
|
return { submissionId: submission.id };
|
|
47
47
|
};
|
|
48
48
|
/**
|
|
49
|
-
* Custom root `POST
|
|
50
|
-
* ahead of its built-in REST routes, so this handler sees every REST create first: when the posted
|
|
49
|
+
* Custom root `POST` endpoint on the submissions collection (`/form-submissions` by default).
|
|
50
|
+
* Payload matches a collection's custom endpoints ahead of its built-in REST routes, so this handler sees every REST create first: when the posted
|
|
51
51
|
* form is an `allowChange` poll and the voted cookie identifies the caller's submission, it turns
|
|
52
52
|
* the request into an in-place update (create-grade hooks opt in via the context flag); otherwise
|
|
53
53
|
* it delegates to the stock create handler found in the same sanitized endpoint list, keeping the
|
|
@@ -60,12 +60,13 @@ const buildVoteSubmitEndpoint = () => {
|
|
|
60
60
|
await addDataAndFileToRequest(req);
|
|
61
61
|
addLocalesToRequestFromData(req);
|
|
62
62
|
const data = req.data ?? {};
|
|
63
|
+
const submissionsSlug = pluginSlugsOf(req.payload).formSubmissions;
|
|
63
64
|
const target = await resolveVoteChangeTarget({
|
|
64
65
|
req,
|
|
65
66
|
formId: data.form
|
|
66
67
|
});
|
|
67
68
|
if (!target) {
|
|
68
|
-
const endpoints = req.payload.collections[
|
|
69
|
+
const endpoints = req.payload.collections[submissionsSlug]?.config.endpoints;
|
|
69
70
|
const stockCreate = (Array.isArray(endpoints) ? endpoints : []).find((endpoint) => endpoint.method === "post" && endpoint.path === "/" && endpoint.custom?.formBuilder !== "vote-submit");
|
|
70
71
|
if (!stockCreate) throw new APIError("form-builder: stock create endpoint not found", 500);
|
|
71
72
|
if (req.body) Object.defineProperty(req, "body", {
|
|
@@ -81,7 +82,7 @@ const buildVoteSubmitEndpoint = () => {
|
|
|
81
82
|
}
|
|
82
83
|
req.context[VOTE_CHANGE_CONTEXT_KEY] = target.submissionId;
|
|
83
84
|
const doc = await req.payload.update.bind(req.payload)({
|
|
84
|
-
collection:
|
|
85
|
+
collection: submissionsSlug,
|
|
85
86
|
id: target.submissionId,
|
|
86
87
|
data: {
|
|
87
88
|
form: data.form,
|