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

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 (36) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/actions/body/serializeBody.d.ts +7 -1
  3. package/dist/actions/body/serializeBody.js +3 -1
  4. package/dist/actions/body/serializeBody.js.map +1 -1
  5. package/dist/actions/builtin/emailAction.d.ts +3 -1
  6. package/dist/actions/builtin/emailAction.js +11 -3
  7. package/dist/actions/builtin/emailAction.js.map +1 -1
  8. package/dist/actions/emailRender.d.ts +27 -0
  9. package/dist/actions/runActions.js +9 -7
  10. package/dist/actions/runActions.js.map +1 -1
  11. package/dist/collections/formSubmissions.js +2 -1
  12. package/dist/collections/formSubmissions.js.map +1 -1
  13. package/dist/index.d.ts +2 -1
  14. package/dist/index.js +2 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/options.d.ts +12 -2
  17. package/dist/react/Form.d.ts +6 -1
  18. package/dist/react/Form.js +6 -3
  19. package/dist/react/Form.js.map +1 -1
  20. package/dist/react/submitForm.d.ts +15 -3
  21. package/dist/react/submitForm.js +5 -3
  22. package/dist/react/submitForm.js.map +1 -1
  23. package/dist/spam/spamGuard.js +1 -1
  24. package/dist/submissions/submissionLocale.js +18 -0
  25. package/dist/submissions/submissionLocale.js.map +1 -0
  26. package/dist/submissions/validateSubmission.js +6 -3
  27. package/dist/submissions/validateSubmission.js.map +1 -1
  28. package/dist/submissions/voteChange.js +21 -0
  29. package/dist/submissions/voteChange.js.map +1 -0
  30. package/dist/submissions/voteChangeEndpoint.js +2 -1
  31. package/dist/submissions/voteChangeEndpoint.js.map +1 -1
  32. package/dist/submissions/votedCookie.js +2 -14
  33. package/dist/submissions/votedCookie.js.map +1 -1
  34. package/dist/validation/builtin/notAlreadySubmitted.js +1 -1
  35. package/dist/validation/builtin/notAlreadySubmitted.js.map +1 -1
  36. package/package.json +4 -4
@@ -0,0 +1,21 @@
1
+ //#region src/submissions/voteChange.ts
2
+ /**
3
+ * `req.context` key the vote-submit endpoint sets to the id of the submission a cookie-identified
4
+ * re-vote is updating. Its presence is what opts the update operation into the create-grade
5
+ * pipeline: the spam guard, full validation in `validateSubmission`, the dedup rule's
6
+ * self-exclusion, and the voted-cookie refresh all key off it, so an unflagged update (host
7
+ * server code, admin tooling) keeps today's behavior exactly.
8
+ *
9
+ * Kept apart from `votedCookie`, which signs cookies with `node:crypto`: the built-in validation
10
+ * rules read this key and ship in the `./react` client entry.
11
+ */
12
+ const VOTE_CHANGE_CONTEXT_KEY = "formBuilderVoteChange";
13
+ /** The change-target submission id a flagged request carries, or undefined. */
14
+ const voteChangeTargetOf = (req) => {
15
+ const target = req.context?.[VOTE_CHANGE_CONTEXT_KEY];
16
+ return typeof target === "string" || typeof target === "number" ? target : void 0;
17
+ };
18
+ //#endregion
19
+ export { VOTE_CHANGE_CONTEXT_KEY, voteChangeTargetOf };
20
+
21
+ //# sourceMappingURL=voteChange.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"voteChange.js","names":[],"sources":["../../src/submissions/voteChange.ts"],"sourcesContent":["/**\n * `req.context` key the vote-submit endpoint sets to the id of the submission a cookie-identified\n * re-vote is updating. Its presence is what opts the update operation into the create-grade\n * pipeline: the spam guard, full validation in `validateSubmission`, the dedup rule's\n * self-exclusion, and the voted-cookie refresh all key off it, so an unflagged update (host\n * server code, admin tooling) keeps today's behavior exactly.\n *\n * Kept apart from `votedCookie`, which signs cookies with `node:crypto`: the built-in validation\n * rules read this key and ship in the `./react` client entry.\n */\nexport const VOTE_CHANGE_CONTEXT_KEY = 'formBuilderVoteChange'\n\n/** The change-target submission id a flagged request carries, or undefined. */\nexport const voteChangeTargetOf = (req: {\n\tcontext?: Record<string, unknown>\n}): number | string | undefined => {\n\tconst target = req.context?.[VOTE_CHANGE_CONTEXT_KEY]\n\treturn typeof target === 'string' || typeof target === 'number' ? target : undefined\n}\n"],"mappings":";;;;;;;;;;;AAUA,MAAa,0BAA0B;;AAGvC,MAAa,sBAAsB,QAEA;CAClC,MAAM,SAAS,IAAI,UAAU;CAC7B,OAAO,OAAO,WAAW,YAAY,OAAO,WAAW,WAAW,SAAS,KAAA;AAC5E"}
@@ -3,7 +3,8 @@ import { asTranslate } from "../translations/server.js";
3
3
  import "../actions/dispatchContext.js";
4
4
  import { pollConfigOf } from "../form/pollState.js";
5
5
  import { formIdOf } from "./formIdOf.js";
6
- import { VOTE_CHANGE_CONTEXT_KEY, votedSubmissionIdFromCookie } from "./votedCookie.js";
6
+ import { VOTE_CHANGE_CONTEXT_KEY } from "./voteChange.js";
7
+ import { votedSubmissionIdFromCookie } from "./votedCookie.js";
7
8
  import { APIError, addDataAndFileToRequest, addLocalesToRequestFromData } from "payload";
8
9
  //#region src/submissions/voteChangeEndpoint.ts
9
10
  const FORM_SUBMISSIONS_SLUG = "form-submissions";
@@ -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 { 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,4 +1,5 @@
1
1
  import { signFormContext, verifyFormContext } from "../context/formContext.js";
2
+ import "./voteChange.js";
2
3
  //#region src/submissions/votedCookie.ts
3
4
  /**
4
5
  * `req.context` key under which `validateSubmission` stashes the loaded form's poll state
@@ -6,19 +7,6 @@ import { signFormContext, verifyFormContext } from "../context/formContext.js";
6
7
  * fetch on the same request.
7
8
  */
8
9
  const POLL_CONTEXT_KEY = "formBuilderPollConfig";
9
- /**
10
- * `req.context` key the vote-submit endpoint sets to the id of the submission a cookie-identified
11
- * re-vote is updating. Its presence is what opts the update operation into the create-grade
12
- * pipeline: the spam guard, full validation in `validateSubmission`, the dedup rule's
13
- * self-exclusion, and the voted-cookie refresh all key off it, so an unflagged update (host
14
- * server code, admin tooling) keeps today's behavior exactly.
15
- */
16
- const VOTE_CHANGE_CONTEXT_KEY = "formBuilderVoteChange";
17
- /** The change-target submission id a flagged request carries, or undefined. */
18
- const voteChangeTargetOf = (req) => {
19
- const target = req.context?.[VOTE_CHANGE_CONTEXT_KEY];
20
- return typeof target === "string" || typeof target === "number" ? target : void 0;
21
- };
22
10
  /** Redeclared to avoid a cycle: `collections/formSubmissions` imports from this module. */
23
11
  const FORM_SUBMISSIONS_SLUG = "form-submissions";
24
12
  /** One year, matching the voted cookie's `Max-Age` so the token never outlives the cookie by less. */
@@ -69,6 +57,6 @@ const votedSubmissionIdFromCookie = (cookieHeader, formId, secret) => {
69
57
  return String(reference.value);
70
58
  };
71
59
  //#endregion
72
- export { POLL_CONTEXT_KEY, VOTED_COOKIE_MAX_AGE_SECONDS, VOTE_CHANGE_CONTEXT_KEY, hasVotedCookie, signVotedCookieValue, voteChangeTargetOf, votedCookieName, votedSubmissionIdFromCookie };
60
+ export { POLL_CONTEXT_KEY, VOTED_COOKIE_MAX_AGE_SECONDS, hasVotedCookie, signVotedCookieValue, votedCookieName, votedSubmissionIdFromCookie };
73
61
 
74
62
  //# sourceMappingURL=votedCookie.js.map
@@ -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\n/**\n * `req.context` key the vote-submit endpoint sets to the id of the submission a cookie-identified\n * re-vote is updating. Its presence is what opts the update operation into the create-grade\n * pipeline: the spam guard, full validation in `validateSubmission`, the dedup rule's\n * self-exclusion, and the voted-cookie refresh all key off it, so an unflagged update (host\n * server code, admin tooling) keeps today's behavior exactly.\n */\nexport const VOTE_CHANGE_CONTEXT_KEY = 'formBuilderVoteChange'\n\n/** The change-target submission id a flagged request carries, or undefined. */\nexport const voteChangeTargetOf = (req: {\n\tcontext?: Record<string, unknown>\n}): number | string | undefined => {\n\tconst target = req.context?.[VOTE_CHANGE_CONTEXT_KEY]\n\treturn typeof target === 'string' || typeof target === 'number' ? target : undefined\n}\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;;;;;;;;AAYhC,MAAa,0BAA0B;;AAGvC,MAAa,sBAAsB,QAEA;CAClC,MAAM,SAAS,IAAI,UAAU;CAC7B,OAAO,OAAO,WAAW,YAAY,OAAO,WAAW,WAAW,SAAS,KAAA;AAC5E;;AAGA,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'\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,5 +1,5 @@
1
1
  import { keys } from "../../translations/keys.js";
2
- import { voteChangeTargetOf } from "../../submissions/votedCookie.js";
2
+ import { voteChangeTargetOf } from "../../submissions/voteChange.js";
3
3
  import { defineValidationRule } from "../defineValidationRule.js";
4
4
  //#region src/validation/builtin/notAlreadySubmitted.ts
5
5
  const FORM_SUBMISSIONS_SLUG = "form-submissions";
@@ -1 +1 @@
1
- {"version":3,"file":"notAlreadySubmitted.js","names":[],"sources":["../../../src/validation/builtin/notAlreadySubmitted.ts"],"sourcesContent":["import { voteChangeTargetOf } from '../../submissions/votedCookie'\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 { 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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@10x-media/form-builder",
3
- "version": "0.1.0-beta.23",
3
+ "version": "0.1.0-beta.25",
4
4
  "description": "End-to-end forms platform for Payload: author, validate, render, collect, aggregate, and act.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -99,10 +99,10 @@
99
99
  "tsdown": "0.22.1",
100
100
  "typescript": "5.9.3",
101
101
  "vitest": "4.1.7",
102
- "@10x-media/tsconfig": "0.0.0",
103
102
  "@10x-media/tsdown-config": "0.0.0",
104
- "@10x-media/payload-test-harness": "0.0.0",
105
- "@10x-media/vitest-config": "0.0.0"
103
+ "@10x-media/tsconfig": "0.0.0",
104
+ "@10x-media/vitest-config": "0.0.0",
105
+ "@10x-media/payload-test-harness": "0.0.0"
106
106
  },
107
107
  "publishConfig": {
108
108
  "access": "public"