@10x-media/form-builder 0.1.0-beta.26 → 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.
Files changed (81) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/LICENSE +21 -21
  3. package/dist/actions/dispatch.js +1 -1
  4. package/dist/actions/task.js +4 -4
  5. package/dist/actions/task.js.map +1 -1
  6. package/dist/aggregation/aggregateResponses.js +3 -4
  7. package/dist/aggregation/aggregateResponses.js.map +1 -1
  8. package/dist/aggregation/resolveResultsRequest.js +2 -2
  9. package/dist/collections/formSubmissions.js +11 -12
  10. package/dist/collections/formSubmissions.js.map +1 -1
  11. package/dist/collections/forms.js +12 -12
  12. package/dist/collections/forms.js.map +1 -1
  13. package/dist/consent/resolveConsentSourcesRequest.js +2 -2
  14. package/dist/consent/resolveConsentSourcesRequest.js.map +1 -1
  15. package/dist/exports/react.d.ts +2 -2
  16. package/dist/exports/react.js +2 -2
  17. package/dist/exports/rsc.d.ts +2 -1
  18. package/dist/exports/rsc.js +3 -2
  19. package/dist/exports/types.d.ts +2 -1
  20. package/dist/form/findFormAtLocale.js +2 -2
  21. package/dist/form/findFormAtLocale.js.map +1 -1
  22. package/dist/index.d.ts +2 -1
  23. package/dist/index.js +14 -9
  24. package/dist/index.js.map +1 -1
  25. package/dist/options.d.ts +15 -1
  26. package/dist/plugin/collectionSlugs.d.ts +28 -0
  27. package/dist/plugin/collectionSlugs.js +65 -0
  28. package/dist/plugin/collectionSlugs.js.map +1 -0
  29. package/dist/plugin/customState.js +1 -1
  30. package/dist/plugin/customState.js.map +1 -1
  31. package/dist/plugin/registerCollections.js +7 -2
  32. package/dist/plugin/registerCollections.js.map +1 -1
  33. package/dist/poll/closeJob.js +2 -2
  34. package/dist/poll/closeJob.js.map +1 -1
  35. package/dist/poll/outcomeBeforeChange.js +2 -2
  36. package/dist/poll/outcomeBeforeChange.js.map +1 -1
  37. package/dist/poll/resolvePollCloseRequest.js +3 -3
  38. package/dist/poll/resolvePollCloseRequest.js.map +1 -1
  39. package/dist/poll/resolvePollOptionsRequest.js +2 -2
  40. package/dist/poll/resolvePollOptionsRequest.js.map +1 -1
  41. package/dist/poll/resolvePollOutcome.js +3 -3
  42. package/dist/poll/resolvePollOutcome.js.map +1 -1
  43. package/dist/poll/votes/aggregateFromVotes.js +3 -2
  44. package/dist/poll/votes/aggregateFromVotes.js.map +1 -1
  45. package/dist/poll/votes/bumpPollVote.js +9 -6
  46. package/dist/poll/votes/bumpPollVote.js.map +1 -1
  47. package/dist/poll/votes/recountPollVotes.js +4 -4
  48. package/dist/poll/votes/recountPollVotes.js.map +1 -1
  49. package/dist/poll/votes/voteTallyHook.js +2 -2
  50. package/dist/poll/votes/voteTallyHook.js.map +1 -1
  51. package/dist/poll/votes/votesCollection.d.ts +5 -1
  52. package/dist/poll/votes/votesCollection.js +7 -2
  53. package/dist/poll/votes/votesCollection.js.map +1 -1
  54. package/dist/react/Form.d.ts +11 -2
  55. package/dist/react/Form.js +15 -8
  56. package/dist/react/Form.js.map +1 -1
  57. package/dist/react/Poll.d.ts +1 -1
  58. package/dist/react/Poll.js +3 -0
  59. package/dist/react/Poll.js.map +1 -1
  60. package/dist/react/fetchResults.d.ts +5 -3
  61. package/dist/react/fetchResults.js +6 -4
  62. package/dist/react/fetchResults.js.map +1 -1
  63. package/dist/react/submitForm.d.ts +8 -3
  64. package/dist/react/submitForm.js +5 -3
  65. package/dist/react/submitForm.js.map +1 -1
  66. package/dist/submissions/createSubmission.d.ts +1 -1
  67. package/dist/submissions/createSubmission.js +3 -3
  68. package/dist/submissions/createSubmission.js.map +1 -1
  69. package/dist/submissions/resolveVotedSubmission.js +5 -5
  70. package/dist/submissions/resolveVotedSubmission.js.map +1 -1
  71. package/dist/submissions/runSubmission.js +1 -1
  72. package/dist/submissions/validateSubmission.js +7 -7
  73. package/dist/submissions/validateSubmission.js.map +1 -1
  74. package/dist/submissions/voteChangeEndpoint.js +11 -10
  75. package/dist/submissions/voteChangeEndpoint.js.map +1 -1
  76. package/dist/submissions/votedCookie.d.ts +8 -4
  77. package/dist/submissions/votedCookie.js +18 -15
  78. package/dist/submissions/votedCookie.js.map +1 -1
  79. package/dist/validation/builtin/notAlreadySubmitted.js +2 -2
  80. package/dist/validation/builtin/notAlreadySubmitted.js.map +1 -1
  81. package/package.json +7 -5
@@ -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.secret);
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: FORM_SUBMISSIONS_SLUG,
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: FORMS_SLUG,
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\nconst FORM_SUBMISSIONS_SLUG = 'form-submissions'\nconst FORMS_SLUG = 'forms'\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.secret)\n\tif (submissionId == null) {\n\t\treturn null\n\t}\n\tconst submission = await payload\n\t\t.findByID({\n\t\t\tcollection: FORM_SUBMISSIONS_SLUG,\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: FORMS_SLUG, 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":";;;;;AAMA,MAAM,wBAAwB;AAC9B,MAAM,aAAa;;;;;;;;;;;AAqBnB,MAAa,yBAAyB,OAAO,SAIN;CACtC,MAAM,EAAE,SAAS,cAAc,WAAW;CAC1C,MAAM,eAAe,4BAA4B,cAAc,QAAQ,QAAQ,MAAM;CACrF,IAAI,gBAAgB,MACnB,OAAO;CAER,MAAM,aAAa,MAAM,QACvB,SAAS;EACT,YAAY;EACZ,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;EAAY,IAAI;EAAQ,OAAO;EAAG,gBAAgB;CAAK,CAAC,EAC/E,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
+ {"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,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 { calcExpressionOf } from "../calc/computeCalcFields.js";
5
- import { resolveCalcContext } from "../calc/resolveCalcContext.js";
6
- import { isPollClosed, pollConfigOf } from "../form/pollState.js";
4
+ import { pluginSlugsOf } from "../plugin/collectionSlugs.js";
5
+ import { findFormAtLocale } from "../form/findFormAtLocale.js";
7
6
  import { SUBMISSION_LOCALE_CONTEXT_KEY, resolveSubmissionLocale } from "./submissionLocale.js";
7
+ import { isPollClosed, pollConfigOf } from "../form/pollState.js";
8
8
  import { formIdOf } from "./formIdOf.js";
9
9
  import { voteChangeTargetOf } from "./voteChange.js";
10
10
  import { POLL_CONTEXT_KEY } from "./votedCookie.js";
11
11
  import { IDENTITY_CONTEXT_KEY } from "../spam/constants.js";
12
+ import { calcExpressionOf } from "../calc/computeCalcFields.js";
13
+ import { resolveCalcContext } from "../calc/resolveCalcContext.js";
12
14
  import { applyPollOptions } from "../poll/applyPollOptions.js";
13
15
  import { resolveEffectivePollOptions } from "../poll/effectivePollOptions.js";
14
16
  import { runSubmission } from "./runSubmission.js";
15
- import { FORM_SUBMISSIONS_SLUG } from "../collections/formSubmissions.js";
16
- import { findFormAtLocale } from "../form/findFormAtLocale.js";
17
17
  import { APIError, ValidationError } from "payload";
18
18
  //#region src/submissions/validateSubmission.ts
19
19
  /**
@@ -77,7 +77,7 @@ const validateSubmission = ({ registry, ruleRegistry, calcSources, calcFunctions
77
77
  fields = applyPollOptions(fields, resultsField, resolved);
78
78
  const answer = incoming.find((entry) => entry.field === resultsField)?.value;
79
79
  if (answer != null && answer !== "" && !resolved.some((option) => option.value === answer)) throw new ValidationError({
80
- collection: FORM_SUBMISSIONS_SLUG,
80
+ collection: pluginSlugsOf(req.payload).formSubmissions,
81
81
  errors: [{
82
82
  path: resultsField,
83
83
  message: asTranslate(req.i18n.t)(keys.validationSelect)
@@ -136,7 +136,7 @@ const validateSubmission = ({ registry, ruleRegistry, calcSources, calcFunctions
136
136
  strictUploadOwnership
137
137
  });
138
138
  if (result.errors.length > 0) throw new ValidationError({
139
- collection: FORM_SUBMISSIONS_SLUG,
139
+ collection: pluginSlugsOf(req.payload).formSubmissions,
140
140
  errors: result.errors
141
141
  }, req.t);
142
142
  data.values = result.values;
@@ -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 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 { 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: FORM_SUBMISSIONS_SLUG,\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: FORM_SUBMISSIONS_SLUG, 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
+ {"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,14 +1,13 @@
1
1
  import { keys } from "../translations/keys.js";
2
2
  import { asTranslate } from "../translations/server.js";
3
- import { pollConfigOf } from "../form/pollState.js";
3
+ import { pluginSlugsOf } from "../plugin/collectionSlugs.js";
4
4
  import "../actions/dispatchContext.js";
5
+ import { pollConfigOf } from "../form/pollState.js";
5
6
  import { formIdOf } from "./formIdOf.js";
6
7
  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.secret);
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: FORMS_SLUG,
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: FORM_SUBMISSIONS_SLUG,
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 /form-submissions` endpoint. Payload matches a collection's custom endpoints
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[FORM_SUBMISSIONS_SLUG]?.config.endpoints;
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: FORM_SUBMISSIONS_SLUG,
85
+ collection: submissionsSlug,
85
86
  id: target.submissionId,
86
87
  data: {
87
88
  form: data.form,
@@ -1 +1 @@
1
- {"version":3,"file":"voteChangeEndpoint.js","names":[],"sources":["../../src/submissions/voteChangeEndpoint.ts"],"sourcesContent":["import {\n\tAPIError,\n\taddDataAndFileToRequest,\n\taddLocalesToRequestFromData,\n\ttype Endpoint,\n\ttype PayloadRequest,\n} from 'payload'\nimport {\n\tESSENTIAL_ACTION_FAILED_CONTEXT_KEY,\n\tESSENTIAL_ACTION_UNCERTAIN_CONTEXT_KEY,\n} from '../actions/dispatchContext'\nimport { pollConfigOf } from '../form/pollState'\nimport { keys } from '../translations/keys'\nimport { asTranslate } from '../translations/server'\nimport { formIdOf } from './formIdOf'\nimport { VOTE_CHANGE_CONTEXT_KEY, votedSubmissionIdFromCookie } from './votedCookie'\n\nconst FORM_SUBMISSIONS_SLUG = 'form-submissions'\nconst FORMS_SLUG = 'forms'\n\n/** Marks the vote-submit endpoint in the sanitized endpoint list (`custom.formBuilder`). */\nexport const VOTE_SUBMIT_ENDPOINT_TAG = 'vote-submit'\n\nconst isComplete = (doc: { status?: unknown }): boolean =>\n\tdoc.status == null || doc.status === 'complete'\n\n/**\n * The submission a cookie-identified re-vote should update, or null when the request must fall\n * through to a plain create. Guards run cheapest-first: no valid signed cookie for the posted form\n * means no form fetch at all, so the delegation overhead on ordinary submissions is one header\n * parse. A token is honored only when the form is an `allowChange` poll and it names a still\n * existing, complete submission of that same form; anything else (pruned row, cross-form replay,\n * legacy `1` marker, tampering) makes the caller a new voter rather than an error.\n */\nexport const resolveVoteChangeTarget = async (args: {\n\treq: PayloadRequest\n\tformId: number | string | undefined | null\n}): Promise<{ submissionId: number | string } | null> => {\n\tconst { req, formId } = args\n\tif (formId == null) {\n\t\treturn null\n\t}\n\tconst submissionId = votedSubmissionIdFromCookie(\n\t\treq.headers?.get('cookie'),\n\t\tformId,\n\t\treq.payload.secret\n\t)\n\tif (submissionId == null) {\n\t\treturn null\n\t}\n\tconst form = await req.payload\n\t\t.findByID({ collection: FORMS_SLUG, id: formId, depth: 0, overrideAccess: true, req })\n\t\t.catch(() => null)\n\tif (form?.pollEnabled !== true || pollConfigOf(form.poll)?.allowChange !== true) {\n\t\treturn null\n\t}\n\tconst submission = await req.payload\n\t\t.findByID({\n\t\t\tcollection: FORM_SUBMISSIONS_SLUG,\n\t\t\tid: submissionId,\n\t\t\tdepth: 0,\n\t\t\toverrideAccess: true,\n\t\t\treq,\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 }\n\tif (String(formIdOf(stored.form) ?? '') !== String(formId) || !isComplete(stored)) {\n\t\treturn null\n\t}\n\treturn { submissionId: submission.id as number | string }\n}\n\n/**\n * Custom root `POST /form-submissions` endpoint. Payload matches a collection's custom endpoints\n * ahead of its built-in REST routes, so this handler sees every REST create first: when the posted\n * form is an `allowChange` poll and the voted cookie identifies the caller's submission, it turns\n * the request into an in-place update (create-grade hooks opt in via the context flag); otherwise\n * it delegates to the stock create handler found in the same sanitized endpoint list, keeping the\n * default path semantics Payload's, not a reimplementation. The update runs `overrideAccess`\n * because the collection is deliberately update-closed to every API caller; the signed cookie is\n * the credential here, verified above rather than by collection access.\n */\nexport const buildVoteSubmitEndpoint = (): Endpoint => {\n\tconst handler = async (req: PayloadRequest): Promise<Response> => {\n\t\tawait addDataAndFileToRequest(req)\n\t\taddLocalesToRequestFromData(req)\n\t\tconst data = (req.data ?? {}) as {\n\t\t\tform?: number | string\n\t\t\tvalues?: unknown\n\t\t}\n\t\tconst target = await resolveVoteChangeTarget({ req, formId: data.form })\n\t\tif (!target) {\n\t\t\tconst endpoints = req.payload.collections[FORM_SUBMISSIONS_SLUG]?.config.endpoints\n\t\t\tconst registered: Endpoint[] = Array.isArray(endpoints) ? endpoints : []\n\t\t\t// Next root-POST match excluding our own tag (never handler identity: a host-wrapped\n\t\t\t// handler would find itself and recurse). First-match mirrors handleEndpoints routing,\n\t\t\t// so another plugin's interceptor still wins exactly as it would without us.\n\t\t\tconst stockCreate = registered.find(\n\t\t\t\t(endpoint) =>\n\t\t\t\t\tendpoint.method === 'post' &&\n\t\t\t\t\tendpoint.path === '/' &&\n\t\t\t\t\tendpoint.custom?.formBuilder !== VOTE_SUBMIT_ENDPOINT_TAG\n\t\t\t)\n\t\t\tif (!stockCreate) {\n\t\t\t\tthrow new APIError('form-builder: stock create endpoint not found', 500)\n\t\t\t}\n\t\t\t// A consumed fetch body stays non-null, so Payload's addDataAndFileToRequest wrapper\n\t\t\t// would re-read it and 500; req.data/req.file are already populated, so hide the spent\n\t\t\t// stream (defineProperty: `body` is a getter-only prototype accessor).\n\t\t\tif (req.body) {\n\t\t\t\tObject.defineProperty(req, 'body', { configurable: true, value: null })\n\t\t\t}\n\t\t\tconst response = await stockCreate.handler(req)\n\t\t\t// An essential action's failure is the submission's failure: the row is committed and kept\n\t\t\t// (see dispatchActions), but the visitor must not be told it worked. 502 for a definite\n\t\t\t// refusal by the upstream the submission exists for; 504 when it never answered inside the\n\t\t\t// deadline, because \"we are not sure this completed\" is a different fact from \"this\n\t\t\t// failed\" and prompts waiting over retrying.\n\t\t\tif (response.ok) {\n\t\t\t\tif (req.context?.[ESSENTIAL_ACTION_UNCERTAIN_CONTEXT_KEY] === true) {\n\t\t\t\t\treturn Response.json(\n\t\t\t\t\t\t{ errors: [{ message: asTranslate(req.t)(keys.submissionActionUncertain) }] },\n\t\t\t\t\t\t{ status: 504 }\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\tif (req.context?.[ESSENTIAL_ACTION_FAILED_CONTEXT_KEY] === true) {\n\t\t\t\t\treturn Response.json(\n\t\t\t\t\t\t{ errors: [{ message: asTranslate(req.t)(keys.submissionActionFailed) }] },\n\t\t\t\t\t\t{ status: 502 }\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn response\n\t\t}\n\t\treq.context[VOTE_CHANGE_CONTEXT_KEY] = target.submissionId\n\t\t// Slug-agnostic cast (the `createSubmission` idiom): a host's generated types pin the\n\t\t// runtime-registered collection's `form` id flavor and `values` JSON shape, which this\n\t\t// framework-level write cannot know. Bound, because `update` is a prototype method.\n\t\tconst update = req.payload.update.bind(req.payload) as unknown as (options: {\n\t\t\tcollection: string\n\t\t\tid: number | string\n\t\t\tdata: { form?: number | string; values?: unknown }\n\t\t\tdepth?: number\n\t\t\toverrideAccess?: boolean\n\t\t\treq?: PayloadRequest\n\t\t}) => Promise<unknown>\n\t\tconst doc = await update({\n\t\t\tcollection: FORM_SUBMISSIONS_SLUG,\n\t\t\tid: target.submissionId,\n\t\t\tdata: { form: data.form, values: data.values },\n\t\t\tdepth: 0,\n\t\t\toverrideAccess: true,\n\t\t\treq,\n\t\t})\n\t\treturn Response.json({ doc, message: req.t('general:updatedSuccessfully') }, { status: 200 })\n\t}\n\treturn { path: '/', method: 'post', handler, custom: { formBuilder: VOTE_SUBMIT_ENDPOINT_TAG } }\n}\n"],"mappings":";;;;;;;;;AAiBA,MAAM,wBAAwB;AAC9B,MAAM,aAAa;;AAGnB,MAAa,2BAA2B;AAExC,MAAM,cAAc,QACnB,IAAI,UAAU,QAAQ,IAAI,WAAW;;;;;;;;;AAUtC,MAAa,0BAA0B,OAAO,SAGW;CACxD,MAAM,EAAE,KAAK,WAAW;CACxB,IAAI,UAAU,MACb,OAAO;CAER,MAAM,eAAe,4BACpB,IAAI,SAAS,IAAI,QAAQ,GACzB,QACA,IAAI,QAAQ,MACb;CACA,IAAI,gBAAgB,MACnB,OAAO;CAER,MAAM,OAAO,MAAM,IAAI,QACrB,SAAS;EAAE,YAAY;EAAY,IAAI;EAAQ,OAAO;EAAG,gBAAgB;EAAM;CAAI,CAAC,EACpF,YAAY,IAAI;CAClB,IAAI,MAAM,gBAAgB,QAAQ,aAAa,KAAK,IAAI,GAAG,gBAAgB,MAC1E,OAAO;CAER,MAAM,aAAa,MAAM,IAAI,QAC3B,SAAS;EACT,YAAY;EACZ,IAAI;EACJ,OAAO;EACP,gBAAgB;EAChB;CACD,CAAC,EACA,YAAY,IAAI;CAClB,IAAI,cAAc,MACjB,OAAO;CAER,MAAM,SAAS;CACf,IAAI,OAAO,SAAS,OAAO,IAAI,KAAK,EAAE,MAAM,OAAO,MAAM,KAAK,CAAC,WAAW,MAAM,GAC/E,OAAO;CAER,OAAO,EAAE,cAAc,WAAW,GAAsB;AACzD;;;;;;;;;;;AAYA,MAAa,gCAA0C;CACtD,MAAM,UAAU,OAAO,QAA2C;EACjE,MAAM,wBAAwB,GAAG;EACjC,4BAA4B,GAAG;EAC/B,MAAM,OAAQ,IAAI,QAAQ,CAAC;EAI3B,MAAM,SAAS,MAAM,wBAAwB;GAAE;GAAK,QAAQ,KAAK;EAAK,CAAC;EACvE,IAAI,CAAC,QAAQ;GACZ,MAAM,YAAY,IAAI,QAAQ,YAAY,wBAAwB,OAAO;GAKzE,MAAM,eAJyB,MAAM,QAAQ,SAAS,IAAI,YAAY,CAAC,GAIxC,MAC7B,aACA,SAAS,WAAW,UACpB,SAAS,SAAS,OAClB,SAAS,QAAQ,gBAAA,aACnB;GACA,IAAI,CAAC,aACJ,MAAM,IAAI,SAAS,iDAAiD,GAAG;GAKxE,IAAI,IAAI,MACP,OAAO,eAAe,KAAK,QAAQ;IAAE,cAAc;IAAM,OAAO;GAAK,CAAC;GAEvE,MAAM,WAAW,MAAM,YAAY,QAAQ,GAAG;GAM9C,IAAI,SAAS,IAAI;IAChB,IAAI,IAAI,UAAA,2CAAsD,MAC7D,OAAO,SAAS,KACf,EAAE,QAAQ,CAAC,EAAE,SAAS,YAAY,IAAI,CAAC,EAAE,KAAK,yBAAyB,EAAE,CAAC,EAAE,GAC5E,EAAE,QAAQ,IAAI,CACf;IAED,IAAI,IAAI,UAAA,wCAAmD,MAC1D,OAAO,SAAS,KACf,EAAE,QAAQ,CAAC,EAAE,SAAS,YAAY,IAAI,CAAC,EAAE,KAAK,sBAAsB,EAAE,CAAC,EAAE,GACzE,EAAE,QAAQ,IAAI,CACf;GAEF;GACA,OAAO;EACR;EACA,IAAI,QAAQ,2BAA2B,OAAO;EAY9C,MAAM,MAAM,MARG,IAAI,QAAQ,OAAO,KAAK,IAAI,OAQpB,EAAE;GACxB,YAAY;GACZ,IAAI,OAAO;GACX,MAAM;IAAE,MAAM,KAAK;IAAM,QAAQ,KAAK;GAAO;GAC7C,OAAO;GACP,gBAAgB;GAChB;EACD,CAAC;EACD,OAAO,SAAS,KAAK;GAAE;GAAK,SAAS,IAAI,EAAE,6BAA6B;EAAE,GAAG,EAAE,QAAQ,IAAI,CAAC;CAC7F;CACA,OAAO;EAAE,MAAM;EAAK,QAAQ;EAAQ;EAAS,QAAQ,EAAE,aAAa,yBAAyB;CAAE;AAChG"}
1
+ {"version":3,"file":"voteChangeEndpoint.js","names":[],"sources":["../../src/submissions/voteChangeEndpoint.ts"],"sourcesContent":["import {\n\tAPIError,\n\taddDataAndFileToRequest,\n\taddLocalesToRequestFromData,\n\ttype Endpoint,\n\ttype PayloadRequest,\n} from 'payload'\nimport {\n\tESSENTIAL_ACTION_FAILED_CONTEXT_KEY,\n\tESSENTIAL_ACTION_UNCERTAIN_CONTEXT_KEY,\n} from '../actions/dispatchContext'\nimport { pollConfigOf } from '../form/pollState'\nimport { pluginSlugsOf } from '../plugin/collectionSlugs'\nimport { keys } from '../translations/keys'\nimport { asTranslate } from '../translations/server'\nimport { formIdOf } from './formIdOf'\nimport { VOTE_CHANGE_CONTEXT_KEY, votedSubmissionIdFromCookie } from './votedCookie'\n\n/** Marks the vote-submit endpoint in the sanitized endpoint list (`custom.formBuilder`). */\nexport const VOTE_SUBMIT_ENDPOINT_TAG = 'vote-submit'\n\nconst isComplete = (doc: { status?: unknown }): boolean =>\n\tdoc.status == null || doc.status === 'complete'\n\n/**\n * The submission a cookie-identified re-vote should update, or null when the request must fall\n * through to a plain create. Guards run cheapest-first: no valid signed cookie for the posted form\n * means no form fetch at all, so the delegation overhead on ordinary submissions is one header\n * parse. A token is honored only when the form is an `allowChange` poll and it names a still\n * existing, complete submission of that same form; anything else (pruned row, cross-form replay,\n * legacy `1` marker, tampering) makes the caller a new voter rather than an error.\n */\nexport const resolveVoteChangeTarget = async (args: {\n\treq: PayloadRequest\n\tformId: number | string | undefined | null\n}): Promise<{ submissionId: number | string } | null> => {\n\tconst { req, formId } = args\n\tif (formId == null) {\n\t\treturn null\n\t}\n\tconst submissionId = votedSubmissionIdFromCookie(req.headers?.get('cookie'), formId, req.payload)\n\tif (submissionId == null) {\n\t\treturn null\n\t}\n\tconst slugs = pluginSlugsOf(req.payload)\n\tconst form = await req.payload\n\t\t.findByID({ collection: slugs.forms, id: formId, depth: 0, overrideAccess: true, req })\n\t\t.catch(() => null)\n\tif (form?.pollEnabled !== true || pollConfigOf(form.poll)?.allowChange !== true) {\n\t\treturn null\n\t}\n\tconst submission = await req.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\treq,\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 }\n\tif (String(formIdOf(stored.form) ?? '') !== String(formId) || !isComplete(stored)) {\n\t\treturn null\n\t}\n\treturn { submissionId: submission.id as number | string }\n}\n\n/**\n * Custom root `POST` endpoint on the submissions collection (`/form-submissions` by default).\n * 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\n * form is an `allowChange` poll and the voted cookie identifies the caller's submission, it turns\n * the request into an in-place update (create-grade hooks opt in via the context flag); otherwise\n * it delegates to the stock create handler found in the same sanitized endpoint list, keeping the\n * default path semantics Payload's, not a reimplementation. The update runs `overrideAccess`\n * because the collection is deliberately update-closed to every API caller; the signed cookie is\n * the credential here, verified above rather than by collection access.\n */\nexport const buildVoteSubmitEndpoint = (): Endpoint => {\n\tconst handler = async (req: PayloadRequest): Promise<Response> => {\n\t\tawait addDataAndFileToRequest(req)\n\t\taddLocalesToRequestFromData(req)\n\t\tconst data = (req.data ?? {}) as {\n\t\t\tform?: number | string\n\t\t\tvalues?: unknown\n\t\t}\n\t\tconst submissionsSlug = pluginSlugsOf(req.payload).formSubmissions\n\t\tconst target = await resolveVoteChangeTarget({ req, formId: data.form })\n\t\tif (!target) {\n\t\t\tconst endpoints = req.payload.collections[submissionsSlug]?.config.endpoints\n\t\t\tconst registered: Endpoint[] = Array.isArray(endpoints) ? endpoints : []\n\t\t\t// Next root-POST match excluding our own tag (never handler identity: a host-wrapped\n\t\t\t// handler would find itself and recurse). First-match mirrors handleEndpoints routing,\n\t\t\t// so another plugin's interceptor still wins exactly as it would without us.\n\t\t\tconst stockCreate = registered.find(\n\t\t\t\t(endpoint) =>\n\t\t\t\t\tendpoint.method === 'post' &&\n\t\t\t\t\tendpoint.path === '/' &&\n\t\t\t\t\tendpoint.custom?.formBuilder !== VOTE_SUBMIT_ENDPOINT_TAG\n\t\t\t)\n\t\t\tif (!stockCreate) {\n\t\t\t\tthrow new APIError('form-builder: stock create endpoint not found', 500)\n\t\t\t}\n\t\t\t// A consumed fetch body stays non-null, so Payload's addDataAndFileToRequest wrapper\n\t\t\t// would re-read it and 500; req.data/req.file are already populated, so hide the spent\n\t\t\t// stream (defineProperty: `body` is a getter-only prototype accessor).\n\t\t\tif (req.body) {\n\t\t\t\tObject.defineProperty(req, 'body', { configurable: true, value: null })\n\t\t\t}\n\t\t\tconst response = await stockCreate.handler(req)\n\t\t\t// An essential action's failure is the submission's failure: the row is committed and kept\n\t\t\t// (see dispatchActions), but the visitor must not be told it worked. 502 for a definite\n\t\t\t// refusal by the upstream the submission exists for; 504 when it never answered inside the\n\t\t\t// deadline, because \"we are not sure this completed\" is a different fact from \"this\n\t\t\t// failed\" and prompts waiting over retrying.\n\t\t\tif (response.ok) {\n\t\t\t\tif (req.context?.[ESSENTIAL_ACTION_UNCERTAIN_CONTEXT_KEY] === true) {\n\t\t\t\t\treturn Response.json(\n\t\t\t\t\t\t{ errors: [{ message: asTranslate(req.t)(keys.submissionActionUncertain) }] },\n\t\t\t\t\t\t{ status: 504 }\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t\tif (req.context?.[ESSENTIAL_ACTION_FAILED_CONTEXT_KEY] === true) {\n\t\t\t\t\treturn Response.json(\n\t\t\t\t\t\t{ errors: [{ message: asTranslate(req.t)(keys.submissionActionFailed) }] },\n\t\t\t\t\t\t{ status: 502 }\n\t\t\t\t\t)\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn response\n\t\t}\n\t\treq.context[VOTE_CHANGE_CONTEXT_KEY] = target.submissionId\n\t\t// Slug-agnostic cast (the `createSubmission` idiom): a host's generated types pin the\n\t\t// runtime-registered collection's `form` id flavor and `values` JSON shape, which this\n\t\t// framework-level write cannot know. Bound, because `update` is a prototype method.\n\t\tconst update = req.payload.update.bind(req.payload) as unknown as (options: {\n\t\t\tcollection: string\n\t\t\tid: number | string\n\t\t\tdata: { form?: number | string; values?: unknown }\n\t\t\tdepth?: number\n\t\t\toverrideAccess?: boolean\n\t\t\treq?: PayloadRequest\n\t\t}) => Promise<unknown>\n\t\tconst doc = await update({\n\t\t\tcollection: submissionsSlug,\n\t\t\tid: target.submissionId,\n\t\t\tdata: { form: data.form, values: data.values },\n\t\t\tdepth: 0,\n\t\t\toverrideAccess: true,\n\t\t\treq,\n\t\t})\n\t\treturn Response.json({ doc, message: req.t('general:updatedSuccessfully') }, { status: 200 })\n\t}\n\treturn { path: '/', method: 'post', handler, custom: { formBuilder: VOTE_SUBMIT_ENDPOINT_TAG } }\n}\n"],"mappings":";;;;;;;;;;;AAmBA,MAAa,2BAA2B;AAExC,MAAM,cAAc,QACnB,IAAI,UAAU,QAAQ,IAAI,WAAW;;;;;;;;;AAUtC,MAAa,0BAA0B,OAAO,SAGW;CACxD,MAAM,EAAE,KAAK,WAAW;CACxB,IAAI,UAAU,MACb,OAAO;CAER,MAAM,eAAe,4BAA4B,IAAI,SAAS,IAAI,QAAQ,GAAG,QAAQ,IAAI,OAAO;CAChG,IAAI,gBAAgB,MACnB,OAAO;CAER,MAAM,QAAQ,cAAc,IAAI,OAAO;CACvC,MAAM,OAAO,MAAM,IAAI,QACrB,SAAS;EAAE,YAAY,MAAM;EAAO,IAAI;EAAQ,OAAO;EAAG,gBAAgB;EAAM;CAAI,CAAC,EACrF,YAAY,IAAI;CAClB,IAAI,MAAM,gBAAgB,QAAQ,aAAa,KAAK,IAAI,GAAG,gBAAgB,MAC1E,OAAO;CAER,MAAM,aAAa,MAAM,IAAI,QAC3B,SAAS;EACT,YAAY,MAAM;EAClB,IAAI;EACJ,OAAO;EACP,gBAAgB;EAChB;CACD,CAAC,EACA,YAAY,IAAI;CAClB,IAAI,cAAc,MACjB,OAAO;CAER,MAAM,SAAS;CACf,IAAI,OAAO,SAAS,OAAO,IAAI,KAAK,EAAE,MAAM,OAAO,MAAM,KAAK,CAAC,WAAW,MAAM,GAC/E,OAAO;CAER,OAAO,EAAE,cAAc,WAAW,GAAsB;AACzD;;;;;;;;;;;AAYA,MAAa,gCAA0C;CACtD,MAAM,UAAU,OAAO,QAA2C;EACjE,MAAM,wBAAwB,GAAG;EACjC,4BAA4B,GAAG;EAC/B,MAAM,OAAQ,IAAI,QAAQ,CAAC;EAI3B,MAAM,kBAAkB,cAAc,IAAI,OAAO,EAAE;EACnD,MAAM,SAAS,MAAM,wBAAwB;GAAE;GAAK,QAAQ,KAAK;EAAK,CAAC;EACvE,IAAI,CAAC,QAAQ;GACZ,MAAM,YAAY,IAAI,QAAQ,YAAY,kBAAkB,OAAO;GAKnE,MAAM,eAJyB,MAAM,QAAQ,SAAS,IAAI,YAAY,CAAC,GAIxC,MAC7B,aACA,SAAS,WAAW,UACpB,SAAS,SAAS,OAClB,SAAS,QAAQ,gBAAA,aACnB;GACA,IAAI,CAAC,aACJ,MAAM,IAAI,SAAS,iDAAiD,GAAG;GAKxE,IAAI,IAAI,MACP,OAAO,eAAe,KAAK,QAAQ;IAAE,cAAc;IAAM,OAAO;GAAK,CAAC;GAEvE,MAAM,WAAW,MAAM,YAAY,QAAQ,GAAG;GAM9C,IAAI,SAAS,IAAI;IAChB,IAAI,IAAI,UAAA,2CAAsD,MAC7D,OAAO,SAAS,KACf,EAAE,QAAQ,CAAC,EAAE,SAAS,YAAY,IAAI,CAAC,EAAE,KAAK,yBAAyB,EAAE,CAAC,EAAE,GAC5E,EAAE,QAAQ,IAAI,CACf;IAED,IAAI,IAAI,UAAA,wCAAmD,MAC1D,OAAO,SAAS,KACf,EAAE,QAAQ,CAAC,EAAE,SAAS,YAAY,IAAI,CAAC,EAAE,KAAK,sBAAsB,EAAE,CAAC,EAAE,GACzE,EAAE,QAAQ,IAAI,CACf;GAEF;GACA,OAAO;EACR;EACA,IAAI,QAAQ,2BAA2B,OAAO;EAY9C,MAAM,MAAM,MARG,IAAI,QAAQ,OAAO,KAAK,IAAI,OAQpB,EAAE;GACxB,YAAY;GACZ,IAAI,OAAO;GACX,MAAM;IAAE,MAAM,KAAK;IAAM,QAAQ,KAAK;GAAO;GAC7C,OAAO;GACP,gBAAgB;GAChB;EACD,CAAC;EACD,OAAO,SAAS,KAAK;GAAE;GAAK,SAAS,IAAI,EAAE,6BAA6B;EAAE,GAAG,EAAE,QAAQ,IAAI,CAAC;CAC7F;CACA,OAAO;EAAE,MAAM;EAAK,QAAQ;EAAQ;EAAS,QAAQ,EAAE,aAAa,yBAAyB;CAAE;AAChG"}
@@ -1,15 +1,19 @@
1
1
  import { Payload } from "payload";
2
2
 
3
3
  //#region src/submissions/votedCookie.d.ts
4
- /** Name of the httpOnly voted cookie for a form: `fb-voted-{formId}`. */
5
- declare const votedCookieName: (formId: number | string) => string;
4
+ /**
5
+ * Name of the httpOnly voted cookie for a form: `fb-voted-{formId}` by default. Pass `payload`
6
+ * when the plugin's `poll.cookiePrefix` is set, so the configured prefix is applied.
7
+ */
8
+ declare const votedCookieName: (formId: number | string, payload?: Payload) => string;
6
9
  /**
7
10
  * Whether a request's `Cookie` header carries the voted marker for a form. For SSR hosts: read
8
11
  * the header (e.g. Next's `(await headers()).get('cookie')`) and pass the result to `<Poll hasVoted>`.
9
12
  * The cookie is httpOnly (set server-side when the plugin's `poll.votedCookie` option is on), so
10
- * this server read is the only way to consume it.
13
+ * this server read is the only way to consume it. Pass `payload` when `poll.cookiePrefix` is set;
14
+ * without it the default `fb-voted-` name is read.
11
15
  */
12
- declare const hasVotedCookie: (cookieHeader: string | null | undefined, formId: number | string) => boolean;
16
+ declare const hasVotedCookie: (cookieHeader: string | null | undefined, formId: number | string, payload?: Payload) => boolean;
13
17
  //#endregion
14
18
  export { hasVotedCookie, votedCookieName };
15
19
  //# sourceMappingURL=votedCookie.d.ts.map
@@ -1,3 +1,4 @@
1
+ import { DEFAULT_VOTED_COOKIE_PREFIX, pluginSlugsOf, votedCookiePrefixOf } from "../plugin/collectionSlugs.js";
1
2
  import { signFormContext, verifyFormContext } from "../context/formContext.js";
2
3
  import "./voteChange.js";
3
4
  //#region src/submissions/votedCookie.ts
@@ -7,25 +8,26 @@ import "./voteChange.js";
7
8
  * fetch on the same request.
8
9
  */
9
10
  const POLL_CONTEXT_KEY = "formBuilderPollConfig";
10
- /** Redeclared to avoid a cycle: `collections/formSubmissions` imports from this module. */
11
- const FORM_SUBMISSIONS_SLUG = "form-submissions";
12
11
  /** One year, matching the voted cookie's `Max-Age` so the token never outlives the cookie by less. */
13
12
  const VOTED_COOKIE_MAX_AGE_SECONDS = 31536e3;
14
- /** Name of the httpOnly voted cookie for a form: `fb-voted-{formId}`. */
15
- const votedCookieName = (formId) => `fb-voted-${formId}`;
13
+ /**
14
+ * Name of the httpOnly voted cookie for a form: `fb-voted-{formId}` by default. Pass `payload`
15
+ * when the plugin's `poll.cookiePrefix` is set, so the configured prefix is applied.
16
+ */
17
+ const votedCookieName = (formId, payload) => `${payload ? votedCookiePrefixOf(payload) : DEFAULT_VOTED_COOKIE_PREFIX}${formId}`;
16
18
  /**
17
19
  * Whether a request's `Cookie` header carries the voted marker for a form. For SSR hosts: read
18
20
  * the header (e.g. Next's `(await headers()).get('cookie')`) and pass the result to `<Poll hasVoted>`.
19
21
  * The cookie is httpOnly (set server-side when the plugin's `poll.votedCookie` option is on), so
20
- * this server read is the only way to consume it.
22
+ * this server read is the only way to consume it. Pass `payload` when `poll.cookiePrefix` is set;
23
+ * without it the default `fb-voted-` name is read.
21
24
  */
22
- const hasVotedCookie = (cookieHeader, formId) => {
25
+ const hasVotedCookie = (cookieHeader, formId, payload) => {
23
26
  if (!cookieHeader) return false;
24
- return votedCookieValue(cookieHeader, formId) != null;
27
+ return votedCookieValue(cookieHeader, votedCookieName(formId, payload)) != null;
25
28
  };
26
- const votedCookieValue = (cookieHeader, formId) => {
29
+ const votedCookieValue = (cookieHeader, name) => {
27
30
  if (!cookieHeader) return null;
28
- const name = votedCookieName(formId);
29
31
  for (const pair of cookieHeader.split(";")) {
30
32
  const eq = pair.indexOf("=");
31
33
  if (eq !== -1 && pair.slice(0, eq).trim() === name) return pair.slice(eq + 1).trim();
@@ -40,20 +42,21 @@ const votedCookieValue = (cookieHeader, formId) => {
40
42
  */
41
43
  const signVotedCookieValue = (payload, submissionId) => signFormContext({
42
44
  payload,
43
- relationTo: FORM_SUBMISSIONS_SLUG,
45
+ relationTo: pluginSlugsOf(payload).formSubmissions,
44
46
  value: submissionId,
45
47
  expiresIn: VOTED_COOKIE_MAX_AGE_SECONDS
46
48
  });
47
49
  /**
48
50
  * The submission id carried by a form's voted cookie, or null when the header has no cookie for
49
51
  * the form, the value is the legacy boolean marker (`1`), or the token fails verification
50
- * (tampered, wrong secret, expired). Null means "treat the caller as a new voter".
52
+ * (tampered, wrong secret, expired, or issued for a different submissions collection). Null means
53
+ * "treat the caller as a new voter".
51
54
  */
52
- const votedSubmissionIdFromCookie = (cookieHeader, formId, secret) => {
53
- const value = votedCookieValue(cookieHeader, formId);
55
+ const votedSubmissionIdFromCookie = (cookieHeader, formId, payload) => {
56
+ const value = votedCookieValue(cookieHeader, votedCookieName(formId, payload));
54
57
  if (value == null || value.length === 0) return null;
55
- const reference = verifyFormContext(value, secret);
56
- if (reference == null || reference.relationTo !== FORM_SUBMISSIONS_SLUG) return null;
58
+ const reference = verifyFormContext(value, payload.secret);
59
+ if (reference == null || reference.relationTo !== pluginSlugsOf(payload).formSubmissions) return null;
57
60
  return String(reference.value);
58
61
  };
59
62
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"votedCookie.js","names":[],"sources":["../../src/submissions/votedCookie.ts"],"sourcesContent":["import type { Payload } from 'payload'\nimport { signFormContext, verifyFormContext } from '../context/formContext'\n\n/**\n * `req.context` key under which `validateSubmission` stashes the loaded form's poll state\n * (`{ pollEnabled, allowChange }`) so the voted-cookie `afterChange` hook can skip a second form\n * fetch on the same request.\n */\nexport const POLL_CONTEXT_KEY = 'formBuilderPollConfig'\n\n/** The poll state `validateSubmission` stashes under {@link POLL_CONTEXT_KEY}. */\nexport type PollContextState = { pollEnabled: boolean; allowChange: boolean }\n\nexport { VOTE_CHANGE_CONTEXT_KEY, voteChangeTargetOf } from './voteChange'\n\n/** Redeclared to avoid a cycle: `collections/formSubmissions` imports from this module. */\nconst FORM_SUBMISSIONS_SLUG = 'form-submissions'\n\n/** One year, matching the voted cookie's `Max-Age` so the token never outlives the cookie by less. */\nexport const VOTED_COOKIE_MAX_AGE_SECONDS = 31_536_000\n\n/** Name of the httpOnly voted cookie for a form: `fb-voted-{formId}`. */\nexport const votedCookieName = (formId: number | string): string => `fb-voted-${formId}`\n\n/**\n * Whether a request's `Cookie` header carries the voted marker for a form. For SSR hosts: read\n * the header (e.g. Next's `(await headers()).get('cookie')`) and pass the result to `<Poll hasVoted>`.\n * The cookie is httpOnly (set server-side when the plugin's `poll.votedCookie` option is on), so\n * this server read is the only way to consume it.\n */\nexport const hasVotedCookie = (\n\tcookieHeader: string | null | undefined,\n\tformId: number | string\n): boolean => {\n\tif (!cookieHeader) {\n\t\treturn false\n\t}\n\treturn votedCookieValue(cookieHeader, formId) != null\n}\n\nconst votedCookieValue = (\n\tcookieHeader: string | null | undefined,\n\tformId: number | string\n): string | null => {\n\tif (!cookieHeader) {\n\t\treturn null\n\t}\n\tconst name = votedCookieName(formId)\n\tfor (const pair of cookieHeader.split(';')) {\n\t\tconst eq = pair.indexOf('=')\n\t\tif (eq !== -1 && pair.slice(0, eq).trim() === name) {\n\t\t\treturn pair.slice(eq + 1).trim()\n\t\t}\n\t}\n\treturn null\n}\n\n/**\n * Sign a submission id into the voted cookie's value for an `allowChange` poll. The token (same\n * HMAC format as `signFormContext`) is what lets a repeat submit update that submission in place;\n * signing means only ids the server issued are accepted, so a forged cookie cannot point a\n * re-vote at someone else's submission.\n */\nexport const signVotedCookieValue = (payload: Payload, submissionId: number | string): string =>\n\tsignFormContext({\n\t\tpayload,\n\t\trelationTo: FORM_SUBMISSIONS_SLUG,\n\t\tvalue: submissionId,\n\t\texpiresIn: VOTED_COOKIE_MAX_AGE_SECONDS,\n\t})\n\n/**\n * The submission id carried by a form's voted cookie, or null when the header has no cookie for\n * the form, the value is the legacy boolean marker (`1`), or the token fails verification\n * (tampered, wrong secret, expired). Null means \"treat the caller as a new voter\".\n */\nexport const votedSubmissionIdFromCookie = (\n\tcookieHeader: string | null | undefined,\n\tformId: number | string,\n\tsecret: string\n): string | null => {\n\tconst value = votedCookieValue(cookieHeader, formId)\n\tif (value == null || value.length === 0) {\n\t\treturn null\n\t}\n\tconst reference = verifyFormContext(value, secret)\n\tif (reference == null || reference.relationTo !== FORM_SUBMISSIONS_SLUG) {\n\t\treturn null\n\t}\n\treturn String(reference.value)\n}\n"],"mappings":";;;;;;;;AAQA,MAAa,mBAAmB;;AAQhC,MAAM,wBAAwB;;AAG9B,MAAa,+BAA+B;;AAG5C,MAAa,mBAAmB,WAAoC,YAAY;;;;;;;AAQhF,MAAa,kBACZ,cACA,WACa;CACb,IAAI,CAAC,cACJ,OAAO;CAER,OAAO,iBAAiB,cAAc,MAAM,KAAK;AAClD;AAEA,MAAM,oBACL,cACA,WACmB;CACnB,IAAI,CAAC,cACJ,OAAO;CAER,MAAM,OAAO,gBAAgB,MAAM;CACnC,KAAK,MAAM,QAAQ,aAAa,MAAM,GAAG,GAAG;EAC3C,MAAM,KAAK,KAAK,QAAQ,GAAG;EAC3B,IAAI,OAAO,MAAM,KAAK,MAAM,GAAG,EAAE,EAAE,KAAK,MAAM,MAC7C,OAAO,KAAK,MAAM,KAAK,CAAC,EAAE,KAAK;CAEjC;CACA,OAAO;AACR;;;;;;;AAQA,MAAa,wBAAwB,SAAkB,iBACtD,gBAAgB;CACf;CACA,YAAY;CACZ,OAAO;CACP,WAAW;AACZ,CAAC;;;;;;AAOF,MAAa,+BACZ,cACA,QACA,WACmB;CACnB,MAAM,QAAQ,iBAAiB,cAAc,MAAM;CACnD,IAAI,SAAS,QAAQ,MAAM,WAAW,GACrC,OAAO;CAER,MAAM,YAAY,kBAAkB,OAAO,MAAM;CACjD,IAAI,aAAa,QAAQ,UAAU,eAAe,uBACjD,OAAO;CAER,OAAO,OAAO,UAAU,KAAK;AAC9B"}
1
+ {"version":3,"file":"votedCookie.js","names":[],"sources":["../../src/submissions/votedCookie.ts"],"sourcesContent":["import type { Payload } from 'payload'\nimport { signFormContext, verifyFormContext } from '../context/formContext'\nimport {\n\tDEFAULT_VOTED_COOKIE_PREFIX,\n\tpluginSlugsOf,\n\tvotedCookiePrefixOf,\n} from '../plugin/collectionSlugs'\n\n/**\n * `req.context` key under which `validateSubmission` stashes the loaded form's poll state\n * (`{ pollEnabled, allowChange }`) so the voted-cookie `afterChange` hook can skip a second form\n * fetch on the same request.\n */\nexport const POLL_CONTEXT_KEY = 'formBuilderPollConfig'\n\n/** The poll state `validateSubmission` stashes under {@link POLL_CONTEXT_KEY}. */\nexport type PollContextState = { pollEnabled: boolean; allowChange: boolean }\n\nexport { VOTE_CHANGE_CONTEXT_KEY, voteChangeTargetOf } from './voteChange'\n\n/** One year, matching the voted cookie's `Max-Age` so the token never outlives the cookie by less. */\nexport const VOTED_COOKIE_MAX_AGE_SECONDS = 31_536_000\n\n/**\n * Name of the httpOnly voted cookie for a form: `fb-voted-{formId}` by default. Pass `payload`\n * when the plugin's `poll.cookiePrefix` is set, so the configured prefix is applied.\n */\nexport const votedCookieName = (formId: number | string, payload?: Payload): string =>\n\t`${payload ? votedCookiePrefixOf(payload) : DEFAULT_VOTED_COOKIE_PREFIX}${formId}`\n\n/**\n * Whether a request's `Cookie` header carries the voted marker for a form. For SSR hosts: read\n * the header (e.g. Next's `(await headers()).get('cookie')`) and pass the result to `<Poll hasVoted>`.\n * The cookie is httpOnly (set server-side when the plugin's `poll.votedCookie` option is on), so\n * this server read is the only way to consume it. Pass `payload` when `poll.cookiePrefix` is set;\n * without it the default `fb-voted-` name is read.\n */\nexport const hasVotedCookie = (\n\tcookieHeader: string | null | undefined,\n\tformId: number | string,\n\tpayload?: Payload\n): boolean => {\n\tif (!cookieHeader) {\n\t\treturn false\n\t}\n\treturn votedCookieValue(cookieHeader, votedCookieName(formId, payload)) != null\n}\n\nconst votedCookieValue = (cookieHeader: string | null | undefined, name: string): string | null => {\n\tif (!cookieHeader) {\n\t\treturn null\n\t}\n\tfor (const pair of cookieHeader.split(';')) {\n\t\tconst eq = pair.indexOf('=')\n\t\tif (eq !== -1 && pair.slice(0, eq).trim() === name) {\n\t\t\treturn pair.slice(eq + 1).trim()\n\t\t}\n\t}\n\treturn null\n}\n\n/**\n * Sign a submission id into the voted cookie's value for an `allowChange` poll. The token (same\n * HMAC format as `signFormContext`) is what lets a repeat submit update that submission in place;\n * signing means only ids the server issued are accepted, so a forged cookie cannot point a\n * re-vote at someone else's submission.\n */\nexport const signVotedCookieValue = (payload: Payload, submissionId: number | string): string =>\n\tsignFormContext({\n\t\tpayload,\n\t\trelationTo: pluginSlugsOf(payload).formSubmissions,\n\t\tvalue: submissionId,\n\t\texpiresIn: VOTED_COOKIE_MAX_AGE_SECONDS,\n\t})\n\n/**\n * The submission id carried by a form's voted cookie, or null when the header has no cookie for\n * the form, the value is the legacy boolean marker (`1`), or the token fails verification\n * (tampered, wrong secret, expired, or issued for a different submissions collection). Null means\n * \"treat the caller as a new voter\".\n */\nexport const votedSubmissionIdFromCookie = (\n\tcookieHeader: string | null | undefined,\n\tformId: number | string,\n\tpayload: Payload\n): string | null => {\n\tconst value = votedCookieValue(cookieHeader, votedCookieName(formId, payload))\n\tif (value == null || value.length === 0) {\n\t\treturn null\n\t}\n\tconst reference = verifyFormContext(value, payload.secret)\n\tif (reference == null || reference.relationTo !== pluginSlugsOf(payload).formSubmissions) {\n\t\treturn null\n\t}\n\treturn String(reference.value)\n}\n"],"mappings":";;;;;;;;;AAaA,MAAa,mBAAmB;;AAQhC,MAAa,+BAA+B;;;;;AAM5C,MAAa,mBAAmB,QAAyB,YACxD,GAAG,UAAU,oBAAoB,OAAO,IAAI,8BAA8B;;;;;;;;AAS3E,MAAa,kBACZ,cACA,QACA,YACa;CACb,IAAI,CAAC,cACJ,OAAO;CAER,OAAO,iBAAiB,cAAc,gBAAgB,QAAQ,OAAO,CAAC,KAAK;AAC5E;AAEA,MAAM,oBAAoB,cAAyC,SAAgC;CAClG,IAAI,CAAC,cACJ,OAAO;CAER,KAAK,MAAM,QAAQ,aAAa,MAAM,GAAG,GAAG;EAC3C,MAAM,KAAK,KAAK,QAAQ,GAAG;EAC3B,IAAI,OAAO,MAAM,KAAK,MAAM,GAAG,EAAE,EAAE,KAAK,MAAM,MAC7C,OAAO,KAAK,MAAM,KAAK,CAAC,EAAE,KAAK;CAEjC;CACA,OAAO;AACR;;;;;;;AAQA,MAAa,wBAAwB,SAAkB,iBACtD,gBAAgB;CACf;CACA,YAAY,cAAc,OAAO,EAAE;CACnC,OAAO;CACP,WAAW;AACZ,CAAC;;;;;;;AAQF,MAAa,+BACZ,cACA,QACA,YACmB;CACnB,MAAM,QAAQ,iBAAiB,cAAc,gBAAgB,QAAQ,OAAO,CAAC;CAC7E,IAAI,SAAS,QAAQ,MAAM,WAAW,GACrC,OAAO;CAER,MAAM,YAAY,kBAAkB,OAAO,QAAQ,MAAM;CACzD,IAAI,aAAa,QAAQ,UAAU,eAAe,cAAc,OAAO,EAAE,iBACxE,OAAO;CAER,OAAO,OAAO,UAAU,KAAK;AAC9B"}
@@ -1,8 +1,8 @@
1
1
  import { keys } from "../../translations/keys.js";
2
+ import { pluginSlugsOf } from "../../plugin/collectionSlugs.js";
2
3
  import { voteChangeTargetOf } from "../../submissions/voteChange.js";
3
4
  import { defineValidationRule } from "../defineValidationRule.js";
4
5
  //#region src/validation/builtin/notAlreadySubmitted.ts
5
- const FORM_SUBMISSIONS_SLUG = "form-submissions";
6
6
  const SCAN_LIMIT = 500;
7
7
  /**
8
8
  * Server-only async exemplar: rejects a value already present for the same form. Scans up to
@@ -19,7 +19,7 @@ const notAlreadySubmittedRule = defineValidationRule({
19
19
  if (value == null || value === "" || !payload || formId == null) return true;
20
20
  const changeTarget = req ? voteChangeTargetOf(req) : void 0;
21
21
  return (await payload.find({
22
- collection: FORM_SUBMISSIONS_SLUG,
22
+ collection: pluginSlugsOf(payload).formSubmissions,
23
23
  where: changeTarget !== void 0 ? { and: [{ form: { equals: formId } }, { id: { not_equals: changeTarget } }] } : { form: { equals: formId } },
24
24
  limit: SCAN_LIMIT,
25
25
  depth: 0,
@@ -1 +1 @@
1
- {"version":3,"file":"notAlreadySubmitted.js","names":[],"sources":["../../../src/validation/builtin/notAlreadySubmitted.ts"],"sourcesContent":["import { voteChangeTargetOf } from '../../submissions/voteChange'\nimport { keys } from '../../translations/keys'\nimport { defineValidationRule } from '../defineValidationRule'\n\nconst FORM_SUBMISSIONS_SLUG = 'form-submissions'\nconst SCAN_LIMIT = 500\n\n/**\n * Server-only async exemplar: rejects a value already present for the same form. Scans up to\n * `SCAN_LIMIT` recent submissions in JS (the values live in a json column, so a portable DB query is\n * not available); a future phase can add an indexed dedup column for scale.\n */\nexport const notAlreadySubmittedRule = defineValidationRule<Record<string, never>, unknown>({\n\ttype: 'notAlreadySubmitted',\n\tlabel: keys.ruleNotAlreadySubmitted,\n\tdescription: keys.ruleNotAlreadySubmittedDescription,\n\tclient: false,\n\tdefaultMessage: keys.ruleNotAlreadySubmittedMessage,\n\tvalidate: async ({ value, field, payload, req, formId, message }) => {\n\t\tif (value == null || value === '' || !payload || formId == null) {\n\t\t\treturn true\n\t\t}\n\t\t// A vote change validates against everyone else's submissions, never its own stored row:\n\t\t// without the exclusion a re-vote keeping the same value would always collide with itself.\n\t\tconst changeTarget = req ? voteChangeTargetOf(req) : undefined\n\t\tconst result = await payload.find({\n\t\t\tcollection: FORM_SUBMISSIONS_SLUG,\n\t\t\twhere:\n\t\t\t\tchangeTarget !== undefined\n\t\t\t\t\t? { and: [{ form: { equals: formId } }, { id: { not_equals: changeTarget } }] }\n\t\t\t\t\t: { form: { equals: formId } },\n\t\t\tlimit: SCAN_LIMIT,\n\t\t\tdepth: 0,\n\t\t\t// Trusted internal dedup read: scan every prior submission of this form regardless of the\n\t\t\t// submitter's own access. This is the local-API default made explicit, matching the sibling\n\t\t\t// resolvePollOutcome/aggregateResponses reads; the `form` filter keeps it single-tenant.\n\t\t\toverrideAccess: true,\n\t\t\treq,\n\t\t})\n\t\tconst clash = result.docs.some((doc) => {\n\t\t\tconst values = (doc as { values?: { field: string; value: unknown }[] }).values ?? []\n\t\t\treturn values.some((entry) => entry.field === field.name && entry.value === value)\n\t\t})\n\t\treturn clash ? message() : true\n\t},\n})\n"],"mappings":";;;;AAIA,MAAM,wBAAwB;AAC9B,MAAM,aAAa;;;;;;AAOnB,MAAa,0BAA0B,qBAAqD;CAC3F,MAAM;CACN,OAAO,KAAK;CACZ,aAAa,KAAK;CAClB,QAAQ;CACR,gBAAgB,KAAK;CACrB,UAAU,OAAO,EAAE,OAAO,OAAO,SAAS,KAAK,QAAQ,cAAc;EACpE,IAAI,SAAS,QAAQ,UAAU,MAAM,CAAC,WAAW,UAAU,MAC1D,OAAO;EAIR,MAAM,eAAe,MAAM,mBAAmB,GAAG,IAAI,KAAA;EAmBrD,QAJc,MAdO,QAAQ,KAAK;GACjC,YAAY;GACZ,OACC,iBAAiB,KAAA,IACd,EAAE,KAAK,CAAC,EAAE,MAAM,EAAE,QAAQ,OAAO,EAAE,GAAG,EAAE,IAAI,EAAE,YAAY,aAAa,EAAE,CAAC,EAAE,IAC5E,EAAE,MAAM,EAAE,QAAQ,OAAO,EAAE;GAC/B,OAAO;GACP,OAAO;GAIP,gBAAgB;GAChB;EACD,CAAC,GACoB,KAAK,MAAM,QAAQ;GAEvC,QADgB,IAAyD,UAAU,CAAC,GACtE,MAAM,UAAU,MAAM,UAAU,MAAM,QAAQ,MAAM,UAAU,KAAK;EAClF,CACW,IAAI,QAAQ,IAAI;CAC5B;AACD,CAAC"}
1
+ {"version":3,"file":"notAlreadySubmitted.js","names":[],"sources":["../../../src/validation/builtin/notAlreadySubmitted.ts"],"sourcesContent":["import { pluginSlugsOf } from '../../plugin/collectionSlugs'\nimport { voteChangeTargetOf } from '../../submissions/voteChange'\nimport { keys } from '../../translations/keys'\nimport { defineValidationRule } from '../defineValidationRule'\n\nconst SCAN_LIMIT = 500\n\n/**\n * Server-only async exemplar: rejects a value already present for the same form. Scans up to\n * `SCAN_LIMIT` recent submissions in JS (the values live in a json column, so a portable DB query is\n * not available); a future phase can add an indexed dedup column for scale.\n */\nexport const notAlreadySubmittedRule = defineValidationRule<Record<string, never>, unknown>({\n\ttype: 'notAlreadySubmitted',\n\tlabel: keys.ruleNotAlreadySubmitted,\n\tdescription: keys.ruleNotAlreadySubmittedDescription,\n\tclient: false,\n\tdefaultMessage: keys.ruleNotAlreadySubmittedMessage,\n\tvalidate: async ({ value, field, payload, req, formId, message }) => {\n\t\tif (value == null || value === '' || !payload || formId == null) {\n\t\t\treturn true\n\t\t}\n\t\t// A vote change validates against everyone else's submissions, never its own stored row:\n\t\t// without the exclusion a re-vote keeping the same value would always collide with itself.\n\t\tconst changeTarget = req ? voteChangeTargetOf(req) : undefined\n\t\tconst result = await payload.find({\n\t\t\tcollection: pluginSlugsOf(payload).formSubmissions,\n\t\t\twhere:\n\t\t\t\tchangeTarget !== undefined\n\t\t\t\t\t? { and: [{ form: { equals: formId } }, { id: { not_equals: changeTarget } }] }\n\t\t\t\t\t: { form: { equals: formId } },\n\t\t\tlimit: SCAN_LIMIT,\n\t\t\tdepth: 0,\n\t\t\t// Trusted internal dedup read: scan every prior submission of this form regardless of the\n\t\t\t// submitter's own access. This is the local-API default made explicit, matching the sibling\n\t\t\t// resolvePollOutcome/aggregateResponses reads; the `form` filter keeps it single-tenant.\n\t\t\toverrideAccess: true,\n\t\t\treq,\n\t\t})\n\t\tconst clash = result.docs.some((doc) => {\n\t\t\tconst values = (doc as { values?: { field: string; value: unknown }[] }).values ?? []\n\t\t\treturn values.some((entry) => entry.field === field.name && entry.value === value)\n\t\t})\n\t\treturn clash ? message() : true\n\t},\n})\n"],"mappings":";;;;;AAKA,MAAM,aAAa;;;;;;AAOnB,MAAa,0BAA0B,qBAAqD;CAC3F,MAAM;CACN,OAAO,KAAK;CACZ,aAAa,KAAK;CAClB,QAAQ;CACR,gBAAgB,KAAK;CACrB,UAAU,OAAO,EAAE,OAAO,OAAO,SAAS,KAAK,QAAQ,cAAc;EACpE,IAAI,SAAS,QAAQ,UAAU,MAAM,CAAC,WAAW,UAAU,MAC1D,OAAO;EAIR,MAAM,eAAe,MAAM,mBAAmB,GAAG,IAAI,KAAA;EAmBrD,QAJc,MAdO,QAAQ,KAAK;GACjC,YAAY,cAAc,OAAO,EAAE;GACnC,OACC,iBAAiB,KAAA,IACd,EAAE,KAAK,CAAC,EAAE,MAAM,EAAE,QAAQ,OAAO,EAAE,GAAG,EAAE,IAAI,EAAE,YAAY,aAAa,EAAE,CAAC,EAAE,IAC5E,EAAE,MAAM,EAAE,QAAQ,OAAO,EAAE;GAC/B,OAAO;GACP,OAAO;GAIP,gBAAgB;GAChB;EACD,CAAC,GACoB,KAAK,MAAM,QAAQ;GAEvC,QADgB,IAAyD,UAAU,CAAC,GACtE,MAAM,UAAU,MAAM,UAAU,MAAM,QAAQ,MAAM,UAAU,KAAK;EAClF,CACW,IAAI,QAAQ,IAAI;CAC5B;AACD,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@10x-media/form-builder",
3
- "version": "0.1.0-beta.26",
3
+ "version": "0.1.0-beta.27",
4
4
  "description": "End-to-end forms platform for Payload: author, validate, render, collect, aggregate, and act.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -74,7 +74,8 @@
74
74
  }
75
75
  },
76
76
  "dependencies": {
77
- "@standard-schema/spec": "^1.0.0"
77
+ "@standard-schema/spec": "^1.0.0",
78
+ "to-snake-case": "1.0.0"
78
79
  },
79
80
  "devDependencies": {
80
81
  "@axe-core/playwright": "^4.11.3",
@@ -88,6 +89,7 @@
88
89
  "@types/node": "22.19.19",
89
90
  "@types/react": "19.2.15",
90
91
  "@types/react-dom": "19.2.3",
92
+ "@types/to-snake-case": "1.0.0",
91
93
  "axe-core": "^4.12.1",
92
94
  "jsdom": "^25.0.1",
93
95
  "msw": "^2.14.6",
@@ -99,10 +101,10 @@
99
101
  "tsdown": "0.22.1",
100
102
  "typescript": "5.9.3",
101
103
  "vitest": "4.1.7",
102
- "@10x-media/tsconfig": "0.0.0",
104
+ "@10x-media/payload-test-harness": "0.0.0",
103
105
  "@10x-media/tsdown-config": "0.0.0",
104
106
  "@10x-media/vitest-config": "0.0.0",
105
- "@10x-media/payload-test-harness": "0.0.0"
107
+ "@10x-media/tsconfig": "0.0.0"
106
108
  },
107
109
  "publishConfig": {
108
110
  "access": "public"
@@ -116,7 +118,7 @@
116
118
  "test": "vitest run",
117
119
  "test:unit": "vitest run src",
118
120
  "test:int": "vitest run tests/int",
119
- "test:matrix": "DB_MATRIX=mongo vitest run tests/int/matrix.int.spec.ts tests/int/messageResponse.int.spec.ts tests/int/pollAllowChange.int.spec.ts tests/int/pollAutoClose.int.spec.ts tests/int/pollVotes.int.spec.ts tests/int/emailLocale.int.spec.ts && DB_MATRIX=postgres vitest run tests/int/matrix.int.spec.ts tests/int/messageResponse.int.spec.ts tests/int/pollAllowChange.int.spec.ts tests/int/pollAutoClose.int.spec.ts tests/int/pollVotes.int.spec.ts tests/int/emailLocale.int.spec.ts",
121
+ "test:matrix": "DB_MATRIX=mongo vitest run tests/int/matrix.int.spec.ts tests/int/messageResponse.int.spec.ts tests/int/pollAllowChange.int.spec.ts tests/int/pollAutoClose.int.spec.ts tests/int/pollVotes.int.spec.ts tests/int/customCollectionSlugs.int.spec.ts tests/int/emailLocale.int.spec.ts && DB_MATRIX=postgres vitest run tests/int/matrix.int.spec.ts tests/int/messageResponse.int.spec.ts tests/int/pollAllowChange.int.spec.ts tests/int/pollAutoClose.int.spec.ts tests/int/pollVotes.int.spec.ts tests/int/customCollectionSlugs.int.spec.ts tests/int/emailLocale.int.spec.ts",
120
122
  "test:container": "TEST_DB=container DB_MATRIX=mongo vitest run tests/int/matrix.int.spec.ts && TEST_DB=container DB_MATRIX=postgres vitest run tests/int/matrix.int.spec.ts",
121
123
  "test:e2e": "bash scripts/e2e.sh",
122
124
  "dev": "pnpm --filter @10x-media/form-builder-dev dev",