@10x-media/form-builder 0.1.0-beta.25 → 0.1.0-beta.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/LICENSE +21 -21
  3. package/dist/actions/body/serializeBody.d.ts +10 -14
  4. package/dist/actions/body/serializeBody.js +5 -10
  5. package/dist/actions/body/serializeBody.js.map +1 -1
  6. package/dist/actions/builtin/emailAction.d.ts +7 -0
  7. package/dist/actions/builtin/emailAction.js +14 -7
  8. package/dist/actions/builtin/emailAction.js.map +1 -1
  9. package/dist/actions/defineAction.d.ts +2 -4
  10. package/dist/actions/defineAction.js.map +1 -1
  11. package/dist/actions/dispatch.js +1 -1
  12. package/dist/actions/emailRender.d.ts +5 -5
  13. package/dist/actions/fromAddresses.d.ts +3 -1
  14. package/dist/actions/fromAddresses.js.map +1 -1
  15. package/dist/actions/recipientSources.d.ts +6 -17
  16. package/dist/actions/recipientSources.js.map +1 -1
  17. package/dist/actions/submissionContext.d.ts +34 -0
  18. package/dist/actions/task.js +12 -15
  19. package/dist/actions/task.js.map +1 -1
  20. package/dist/aggregation/aggregateResponses.js +3 -4
  21. package/dist/aggregation/aggregateResponses.js.map +1 -1
  22. package/dist/aggregation/resolveResultsRequest.js +38 -24
  23. package/dist/aggregation/resolveResultsRequest.js.map +1 -1
  24. package/dist/client/EndpointOptionsSelect.d.ts +1 -0
  25. package/dist/client/EndpointOptionsSelect.js +1 -0
  26. package/dist/client/EndpointOptionsSelect.js.map +1 -1
  27. package/dist/client/FieldNameSelect.d.ts +1 -0
  28. package/dist/client/FieldNameSelect.js +1 -0
  29. package/dist/client/FieldNameSelect.js.map +1 -1
  30. package/dist/client/RecipientsSelect.d.ts +1 -0
  31. package/dist/client/RecipientsSelect.js +1 -0
  32. package/dist/client/RecipientsSelect.js.map +1 -1
  33. package/dist/collections/formSubmissions.js +9 -10
  34. package/dist/collections/formSubmissions.js.map +1 -1
  35. package/dist/collections/forms.js +10 -10
  36. package/dist/collections/forms.js.map +1 -1
  37. package/dist/consent/resolveConsentSourcesRequest.js +2 -2
  38. package/dist/consent/resolveConsentSourcesRequest.js.map +1 -1
  39. package/dist/exports/react.d.ts +2 -2
  40. package/dist/exports/react.js +2 -2
  41. package/dist/exports/rsc.d.ts +3 -2
  42. package/dist/exports/rsc.js +5 -4
  43. package/dist/exports/types.d.ts +2 -1
  44. package/dist/form/findFormAtLocale.d.ts +30 -0
  45. package/dist/form/findFormAtLocale.js +88 -0
  46. package/dist/form/findFormAtLocale.js.map +1 -0
  47. package/dist/index.d.ts +5 -2
  48. package/dist/index.js +19 -11
  49. package/dist/index.js.map +1 -1
  50. package/dist/options.d.ts +39 -2
  51. package/dist/plugin/collectionSlugs.d.ts +28 -0
  52. package/dist/plugin/collectionSlugs.js +65 -0
  53. package/dist/plugin/collectionSlugs.js.map +1 -0
  54. package/dist/plugin/customState.js +1 -1
  55. package/dist/plugin/customState.js.map +1 -1
  56. package/dist/plugin/registerCollections.js +9 -4
  57. package/dist/plugin/registerCollections.js.map +1 -1
  58. package/dist/poll/closeJob.js +2 -2
  59. package/dist/poll/closeJob.js.map +1 -1
  60. package/dist/poll/outcomeBeforeChange.js +2 -2
  61. package/dist/poll/outcomeBeforeChange.js.map +1 -1
  62. package/dist/poll/resolvePollCloseRequest.js +3 -3
  63. package/dist/poll/resolvePollCloseRequest.js.map +1 -1
  64. package/dist/poll/resolvePollOptionsRequest.js +2 -2
  65. package/dist/poll/resolvePollOptionsRequest.js.map +1 -1
  66. package/dist/poll/resolvePollOutcome.js +4 -4
  67. package/dist/poll/resolvePollOutcome.js.map +1 -1
  68. package/dist/poll/votes/aggregateFromVotes.js +3 -2
  69. package/dist/poll/votes/aggregateFromVotes.js.map +1 -1
  70. package/dist/poll/votes/bumpPollVote.js +9 -6
  71. package/dist/poll/votes/bumpPollVote.js.map +1 -1
  72. package/dist/poll/votes/recountPollVotes.js +5 -5
  73. package/dist/poll/votes/recountPollVotes.js.map +1 -1
  74. package/dist/poll/votes/voteTallyHook.js +3 -3
  75. package/dist/poll/votes/voteTallyHook.js.map +1 -1
  76. package/dist/poll/votes/votesCollection.d.ts +5 -1
  77. package/dist/poll/votes/votesCollection.js +7 -2
  78. package/dist/poll/votes/votesCollection.js.map +1 -1
  79. package/dist/react/Form.d.ts +20 -7
  80. package/dist/react/Form.js +19 -13
  81. package/dist/react/Form.js.map +1 -1
  82. package/dist/react/Poll.d.ts +1 -1
  83. package/dist/react/Poll.js +7 -2
  84. package/dist/react/Poll.js.map +1 -1
  85. package/dist/react/fetchResults.d.ts +6 -3
  86. package/dist/react/fetchResults.js +10 -5
  87. package/dist/react/fetchResults.js.map +1 -1
  88. package/dist/react/submitForm.d.ts +12 -7
  89. package/dist/react/submitForm.js +5 -3
  90. package/dist/react/submitForm.js.map +1 -1
  91. package/dist/submissions/createSubmission.d.ts +7 -1
  92. package/dist/submissions/createSubmission.js +10 -5
  93. package/dist/submissions/createSubmission.js.map +1 -1
  94. package/dist/submissions/resolveVotedSubmission.js +5 -5
  95. package/dist/submissions/resolveVotedSubmission.js.map +1 -1
  96. package/dist/submissions/runSubmission.js +1 -1
  97. package/dist/submissions/submissionLocale.js +27 -6
  98. package/dist/submissions/submissionLocale.js.map +1 -1
  99. package/dist/submissions/validateSubmission.js +110 -103
  100. package/dist/submissions/validateSubmission.js.map +1 -1
  101. package/dist/submissions/voteChangeEndpoint.js +10 -9
  102. package/dist/submissions/voteChangeEndpoint.js.map +1 -1
  103. package/dist/submissions/votedCookie.d.ts +8 -4
  104. package/dist/submissions/votedCookie.js +18 -15
  105. package/dist/submissions/votedCookie.js.map +1 -1
  106. package/dist/validation/builtin/notAlreadySubmitted.js +2 -2
  107. package/dist/validation/builtin/notAlreadySubmitted.js.map +1 -1
  108. package/package.json +7 -5
@@ -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.25",
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",
104
+ "@10x-media/payload-test-harness": "0.0.0",
102
105
  "@10x-media/tsdown-config": "0.0.0",
103
- "@10x-media/tsconfig": "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 && 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",
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",