@10x-media/form-builder 0.1.0-beta.3 → 0.1.0-beta.4

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 (166) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +2 -2
  3. package/dist/actions/body/serializeBody.d.ts +19 -4
  4. package/dist/actions/body/serializeBody.js +3 -1
  5. package/dist/actions/body/serializeBody.js.map +1 -1
  6. package/dist/actions/builtin/confirmation.js +13 -4
  7. package/dist/actions/builtin/confirmation.js.map +1 -1
  8. package/dist/actions/builtin/emailTeam.js +13 -4
  9. package/dist/actions/builtin/emailTeam.js.map +1 -1
  10. package/dist/actions/builtin/index.d.ts +13 -1
  11. package/dist/actions/builtin/index.js +18 -7
  12. package/dist/actions/builtin/index.js.map +1 -1
  13. package/dist/actions/builtin/signedWebhook.js +2 -16
  14. package/dist/actions/builtin/signedWebhook.js.map +1 -1
  15. package/dist/actions/runActions.js +2 -0
  16. package/dist/actions/runActions.js.map +1 -1
  17. package/dist/aggregation/resolveResultsRequest.d.ts +22 -6
  18. package/dist/aggregation/resolveResultsRequest.js +18 -7
  19. package/dist/aggregation/resolveResultsRequest.js.map +1 -1
  20. package/dist/client/FlowBuilder.js +98 -53
  21. package/dist/client/FlowBuilder.js.map +1 -1
  22. package/dist/client/admin.css +47 -5
  23. package/dist/client/flowAuthoring.js +35 -0
  24. package/dist/client/flowAuthoring.js.map +1 -0
  25. package/dist/client/synthesizeClientField.js +6 -2
  26. package/dist/client/synthesizeClientField.js.map +1 -1
  27. package/dist/collections/formSubmissions.js +47 -9
  28. package/dist/collections/formSubmissions.js.map +1 -1
  29. package/dist/collections/forms.js +180 -34
  30. package/dist/collections/forms.js.map +1 -1
  31. package/dist/conditions/conditionType.js +9 -2
  32. package/dist/conditions/conditionType.js.map +1 -1
  33. package/dist/conditions/normalizeConditions.js +2 -1
  34. package/dist/conditions/normalizeConditions.js.map +1 -1
  35. package/dist/consent/builtin/index.d.ts +11 -1
  36. package/dist/consent/builtin/index.js +15 -5
  37. package/dist/consent/builtin/index.js.map +1 -1
  38. package/dist/consent/builtin/static.js +7 -3
  39. package/dist/consent/builtin/static.js.map +1 -1
  40. package/dist/exports/react.d.ts +10 -6
  41. package/dist/exports/react.js +6 -2
  42. package/dist/exports/rsc.d.ts +6 -1
  43. package/dist/exports/rsc.js +6 -1
  44. package/dist/exports/types.d.ts +4 -4
  45. package/dist/fields/buildFieldBlocks.js +17 -8
  46. package/dist/fields/buildFieldBlocks.js.map +1 -1
  47. package/dist/fields/builtin/consent.js +8 -4
  48. package/dist/fields/builtin/consent.js.map +1 -1
  49. package/dist/fields/builtin/file.js +8 -4
  50. package/dist/fields/builtin/file.js.map +1 -1
  51. package/dist/fields/builtin/index.d.ts +11 -1
  52. package/dist/fields/builtin/index.js +21 -9
  53. package/dist/fields/builtin/index.js.map +1 -1
  54. package/dist/fields/builtin/message.js +26 -0
  55. package/dist/fields/builtin/message.js.map +1 -0
  56. package/dist/fields/builtin/repeater.js +7 -3
  57. package/dist/fields/builtin/repeater.js.map +1 -1
  58. package/dist/fields/builtin/select.js +7 -3
  59. package/dist/fields/builtin/select.js.map +1 -1
  60. package/dist/fields/localizedIf.d.ts +13 -0
  61. package/dist/fields/localizedIf.js +12 -0
  62. package/dist/fields/localizedIf.js.map +1 -0
  63. package/dist/fields/sharedConfig.js +64 -51
  64. package/dist/fields/sharedConfig.js.map +1 -1
  65. package/dist/fields/types.d.ts +7 -2
  66. package/dist/flow/normalizeFlow.js +7 -1
  67. package/dist/flow/normalizeFlow.js.map +1 -1
  68. package/dist/flow/stepLabel.js +15 -0
  69. package/dist/flow/stepLabel.js.map +1 -0
  70. package/dist/form/pollState.d.ts +22 -0
  71. package/dist/form/pollState.js +17 -0
  72. package/dist/form/pollState.js.map +1 -0
  73. package/dist/form/toFormDocument.d.ts +60 -0
  74. package/dist/form/toFormDocument.js +39 -0
  75. package/dist/form/toFormDocument.js.map +1 -0
  76. package/dist/form/types.d.ts +48 -0
  77. package/dist/index.d.ts +69 -18
  78. package/dist/index.js +24 -13
  79. package/dist/index.js.map +1 -1
  80. package/dist/plugin/registerCollections.js +15 -36
  81. package/dist/plugin/registerCollections.js.map +1 -1
  82. package/dist/plugin/uploadsCollection.d.ts +11 -0
  83. package/dist/plugin/uploadsCollection.js +44 -0
  84. package/dist/plugin/uploadsCollection.js.map +1 -0
  85. package/dist/poll/applyPollOptions.js +17 -0
  86. package/dist/poll/applyPollOptions.js.map +1 -0
  87. package/dist/poll/buildPollOptionSourceFields.js +53 -0
  88. package/dist/poll/buildPollOptionSourceFields.js.map +1 -0
  89. package/dist/poll/definePollOptionSource.d.ts +35 -0
  90. package/dist/poll/definePollOptionSource.js +6 -0
  91. package/dist/poll/definePollOptionSource.js.map +1 -0
  92. package/dist/poll/registry.d.ts +16 -0
  93. package/dist/poll/registry.js +16 -0
  94. package/dist/poll/registry.js.map +1 -0
  95. package/dist/poll/resolvePollOptions.d.ts +28 -0
  96. package/dist/poll/resolvePollOptions.js +64 -0
  97. package/dist/poll/resolvePollOptions.js.map +1 -0
  98. package/dist/poll/resolvePollOutcome.d.ts +21 -0
  99. package/dist/poll/resolvePollOutcome.js +36 -0
  100. package/dist/poll/resolvePollOutcome.js.map +1 -0
  101. package/dist/prefill/valuesFromSearchParams.js +1 -1
  102. package/dist/prefill/valuesFromSearchParams.js.map +1 -1
  103. package/dist/presentations/types.d.ts +3 -2
  104. package/dist/react/Form.d.ts +13 -31
  105. package/dist/react/Form.js +96 -83
  106. package/dist/react/Form.js.map +1 -1
  107. package/dist/react/FormContext.d.ts +35 -1
  108. package/dist/react/FormContext.js.map +1 -1
  109. package/dist/react/FormControls.d.ts +55 -0
  110. package/dist/react/FormControls.js +56 -0
  111. package/dist/react/FormControls.js.map +1 -0
  112. package/dist/react/FormResults.d.ts +6 -3
  113. package/dist/react/FormResults.js +37 -29
  114. package/dist/react/FormResults.js.map +1 -1
  115. package/dist/react/FormSteps.d.ts +17 -0
  116. package/dist/react/FormSteps.js +35 -0
  117. package/dist/react/FormSteps.js.map +1 -0
  118. package/dist/react/Poll.d.ts +17 -6
  119. package/dist/react/Poll.js +66 -18
  120. package/dist/react/Poll.js.map +1 -1
  121. package/dist/react/fetchResults.d.ts +1 -1
  122. package/dist/react/fetchResults.js +1 -1
  123. package/dist/react/fetchResults.js.map +1 -1
  124. package/dist/react/renderers/file.js +5 -1
  125. package/dist/react/renderers/file.js.map +1 -1
  126. package/dist/react/renderers/index.js +2 -0
  127. package/dist/react/renderers/index.js.map +1 -1
  128. package/dist/react/renderers/message.js +35 -0
  129. package/dist/react/renderers/message.js.map +1 -0
  130. package/dist/react/state.d.ts +25 -1
  131. package/dist/react/state.js +14 -1
  132. package/dist/react/state.js.map +1 -1
  133. package/dist/react/uploadFile.d.ts +2 -2
  134. package/dist/react/uploadFile.js +1 -1
  135. package/dist/react/uploadFile.js.map +1 -1
  136. package/dist/submissions/runSubmission.js +70 -33
  137. package/dist/submissions/runSubmission.js.map +1 -1
  138. package/dist/submissions/validateSubmission.js +41 -7
  139. package/dist/submissions/validateSubmission.js.map +1 -1
  140. package/dist/submissions/votedCookie.d.ts +13 -0
  141. package/dist/submissions/votedCookie.js +27 -0
  142. package/dist/submissions/votedCookie.js.map +1 -0
  143. package/dist/translations/de.js +219 -0
  144. package/dist/translations/de.js.map +1 -0
  145. package/dist/translations/en.js +45 -6
  146. package/dist/translations/en.js.map +1 -1
  147. package/dist/translations/index.d.ts +2 -1
  148. package/dist/translations/index.js +6 -2
  149. package/dist/translations/index.js.map +1 -1
  150. package/dist/translations/keys.d.ts +43 -4
  151. package/dist/translations/keys.js +44 -5
  152. package/dist/translations/keys.js.map +1 -1
  153. package/dist/uploads/types.d.ts +1 -2
  154. package/dist/validation/validateUrl.js +22 -0
  155. package/dist/validation/validateUrl.js.map +1 -0
  156. package/package.json +2 -1
  157. package/styles/form-builder.css +10 -0
  158. package/dist/collections/uploads.d.ts +0 -30
  159. package/dist/collections/uploads.js +0 -55
  160. package/dist/collections/uploads.js.map +0 -1
  161. package/dist/presentations/registry.d.ts +0 -15
  162. package/dist/presentations/registry.js +0 -15
  163. package/dist/presentations/registry.js.map +0 -1
  164. package/dist/react/toFormDocument.d.ts +0 -24
  165. package/dist/react/toFormDocument.js +0 -22
  166. package/dist/react/toFormDocument.js.map +0 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,47 @@
1
1
  # @10x-media/form-builder
2
2
 
3
+ ## 0.1.0-beta.4
4
+
5
+ ### Minor Changes
6
+
7
+ - Feedback round: flow authoring, localized content, response/display settings, bring-your-own uploads, poll lifecycle, composable form chrome, and a German locale.
8
+
9
+ **Breaking:**
10
+
11
+ - **Uploads are bring-your-own and off by default.** The plugin no longer registers a `form-uploads` collection. `uploads` is now `false` (default; the `file` field type is removed from the registry entirely) or `{ collection: 'slug' }` pointing at an upload-enabled collection the app owns; the plugin validates it at boot, appends a hidden `owner` field, and prepends the spam upload hooks. The file field's `relationTo` config is gone (the server stamps the configured slug onto each `file` block as a hidden `uploadsCollection`), and `overrides.uploads` no longer exists.
12
+ - **Stored presentation is gone.** The `defaultPresentation` field on `forms` and the plugin-level `presentations` option are removed. Presentation is decided at render time via the `presentation`/`presentations` props on `<Form>`; the React presentation system (page/inline/modal/drawer, custom `FormPresentation`) is unchanged.
13
+ - **Poll config moved into a `poll` group.** The top-level `showResults`/`resultsField` fields on `forms` are replaced by `poll.enabled`, `poll.resultsField`, plus the new `poll.resultsVisibility` and `poll.closesAt`. The results endpoint now gates on `poll.enabled` and visibility.
14
+ - **Content fields are localized by default.** Author-facing content (field labels, placeholders, descriptions, option labels, consent statements, action subjects/bodies, response/display content) carries `localized: true`; on hosts with Payload `localization` configured the stored data shape changes to per-locale objects. Hosts without localization are unaffected (Payload strips the flag). Opt out with `localizeContent: false`; when opting out, derive registry overrides from `buildDefaultFieldDefinitions(false)` / `buildDefaultActionDefinitions(false)` / `buildDefaultConsentSources(false)` instead of spreading the prebuilt defaults, which carry the localized flags.
15
+ - **`<Poll hasVoted>` ORs with the localStorage guard.** `hasVoted: true` marks the visitor as voted regardless of localStorage; it previously could suppress the localStorage read. `false`/omitted falls back to localStorage as before.
16
+ - **Form chrome markup changed.** The default buttons now carry stable classes (`fb-form__back`, `fb-form__next`, `fb-form__submit`) and always render inside a `.fb-form__controls` wrapper (previously classless buttons, wrapped only on multi-step forms). CSS or tests targeting the old bare buttons need the new hooks.
17
+
18
+ **Features:**
19
+
20
+ - Flow authoring without ids: steps are named by title (ids auto-generated, never shown), field assignment is exclusive with an unassigned-fields list, and save-time normalization dedupes fields across steps.
21
+ - `message` field type: display-only rich text between fields, never validated or stored, condition-targetable, recall-token aware.
22
+ - Response settings (`response` group + tab): success `message` rich text, `redirect` URL (fires on the custom-`children` path too), and `submitLabel` with prop-over-document-over-default precedence.
23
+ - Display settings (`display` group + tab): opt-in form title (`showTitle`/`title`) and rich text `intro` rendered above the fields, recall-token aware.
24
+ - Field config layout: the Field tab lays basics out in rows (name/label, width/placeholder), `width` is required with a `full` default, and `required` sits last.
25
+ - Poll lifecycle: `closesAt` close date enforced at submission time, `resultsVisibility` (`afterVote`/`afterClose`) gating for anonymous results, a `results.access` seam for multi-tenant scoping, and `poll: { votedCookie: true }` for an httpOnly SSR voted marker read via `hasVotedCookie`.
26
+ - Poll option sources: `definePollOptionSource` + the `poll.sources` registry resolve a poll's choices from host domain data at render (`resolvePollOptions` + `toFormDocument(doc, { pollOptions })`) and at submission (resolved values are the only accepted answers, fail closed).
27
+ - Poll outcome: `resolvePollOutcome` records a final `winningValue` (admin read-only, server-only write path); `<Poll>` renders the final state and `<FormResults winningValue>` highlights the winner.
28
+ - `richText.editor` overrides the editor on both action body fields (e.g. a minimal toolbar for email authors); a custom `richText.serialize` now receives the submitted `form` and `req` for per-tenant lookups or handing off to react-email.
29
+ - Composable chrome: `<FormControls>` (the default buttons with all `render*`/className overrides) and `<FormSteps>` (step progress with `aria-current`) are exported for custom `children` layouts, and `useFormContext` exposes the controller context including resolved `labels`, the active `t`, and `effectiveValues`.
30
+ - Server-safe `toFormDocument`: no `'use client'` directive, exported from the root and the `/rsc` subpath (which also gains `resolvePollOptions`, `resolvePollOutcome`, `hasVotedCookie`, `votedCookieName`, and `isPollClosed`), so Server Components narrow the document without pulling in the React client layer.
31
+ - German (`de`) admin and renderer strings ship built in alongside English.
32
+ - The shadcn registry now covers every built-in field type (adds date, consent, calculation, and repeater renderers); the default `textarea` type label is now "Textarea".
33
+ - Repeaters with `minRows` start pre-seeded with that many empty rows instead of appearing blank until validation; an explicit `initialValues` entry for the field (including `[]`) still wins over the seed.
34
+
35
+ **Fixes:**
36
+
37
+ - File fields nested inside a repeater row now get the same server-side upload enforcement as top-level file fields: each captured value is re-derived into a `FileRef` from the stored upload doc (owner, MIME type, and size checked against the sub-field config) instead of storing the raw client-submitted id, and the submission fails closed on a forged id or a missing uploads collection.
38
+ - Upload enforcement is now driven by a field's value kind rather than its block type, so a custom field type registered with `value: 'file'` is captured and enforced (owner/MIME/size) at both the top level and inside repeaters, not just the built-in `file` type.
39
+
40
+ **Notes:**
41
+
42
+ - The voted cookie is deliberately not `Secure`: it is a UX-only marker (skip re-showing an answered form), never an integrity or auth signal, and staying non-Secure keeps plain-HTTP local development working.
43
+ - No migrations are shipped for the removed/moved `forms` columns (`defaultPresentation`, `showResults`, top-level `resultsField`); pre-1.0 with no known production consumers, write your own if you carry data.
44
+
3
45
  ## 0.1.0-beta.3
4
46
 
5
47
  ### Minor Changes
package/README.md CHANGED
@@ -17,10 +17,10 @@ Part of the [@10x-media Payload plugins](https://github.com/10x-media/payload-pl
17
17
  - **Recall and prefill**: pipe answers into labels and messages; URL prefill; hidden context fields.
18
18
  - **Calculations**: a safe no-eval expression engine for totals and quiz scores, recomputed on the server.
19
19
  - **Post-submit actions**: email, confirmation, signed webhook, and custom actions, queued via Payload jobs with a bounded inline fallback.
20
- - **Consent** with proof by reference and policy-version capture; **file uploads** with server-enforced MIME/size; **polls** with a gated public results endpoint.
20
+ - **Consent** with proof by reference and policy-version capture; **file uploads** into your own upload collection with server-enforced MIME/size; **polls** with a gated public results endpoint.
21
21
  - **Spam protection** on by default: honeypot, per-identity rate limiting, bundled captcha adapters (Turnstile, reCAPTCHA, hCaptcha), upload-ownership scoping, privacy-first metadata.
22
22
  - **Accessibility** verified by automated axe checks; **typed translations** via `@10x-media/form-builder/i18n`.
23
- - **Collection overrides**: extend `forms`, `form-submissions`, and `form-uploads` with extra fields, hooks, and access rules using an explicit spread API that guarantees plugin-critical hooks always run.
23
+ - **Collection overrides**: extend `forms` and `form-submissions` with extra fields, hooks, and access rules using an explicit spread API that guarantees plugin-critical hooks always run.
24
24
 
25
25
  ## Quick start
26
26
 
@@ -1,27 +1,42 @@
1
1
  import { SubmissionDescriptor, SubmissionValue } from "../../submissions/types.js";
2
2
  import { BodyConverter } from "./converters.js";
3
+ import { PayloadRequest, RichTextField } from "payload";
3
4
 
4
5
  //#region src/actions/body/serializeBody.d.ts
6
+ /** Minimal form identity threaded alongside a rendered body (e.g. per-tenant template lookups). */
7
+ type SerializeBodyForm = {
8
+ id: number | string;
9
+ title?: string;
10
+ };
5
11
  /** Submission data plus optional converter overrides available while serializing a body. */
6
12
  type BodyContext = {
7
13
  values: SubmissionValue[];
8
14
  descriptors: SubmissionDescriptor[];
9
15
  converters?: Record<string, BodyConverter>;
10
16
  };
11
- /** Args a custom `richText.serialize` replacement receives per rendered body. */
17
+ /**
18
+ * Args a custom `richText.serialize` replacement receives per rendered body. Always populated by
19
+ * `makeRenderBody` (the action-body pipeline): `form` and `req` enable per-tenant template
20
+ * lookups or handing the body off to a renderer like react-email.
21
+ */
12
22
  type SerializeBodyArgs = {
13
23
  body: unknown;
14
24
  values: SubmissionValue[];
15
25
  descriptors: SubmissionDescriptor[];
26
+ form: SerializeBodyForm;
27
+ req?: PayloadRequest;
16
28
  };
17
29
  /**
18
- * Customizes how action bodies become channel-ready strings. `converters` spread over the
19
- * defaults per Lexical node type; `serialize` replaces the whole pipeline (for non-HTML
20
- * channels like chat or plain text).
30
+ * Customizes how action bodies are authored and rendered. `converters` spread over the default
31
+ * Lexical node converters; `serialize` replaces the whole pipeline (for non-HTML channels like
32
+ * chat or plain text, or to hand the body plus the submitted `form`/`req` off to a renderer like
33
+ * react-email). `editor` overrides the Lexical/richText editor on both the emailTeam and
34
+ * confirmation action body fields (see `buildDefaultActionDefinitions`).
21
35
  */
22
36
  type RichTextBodyOption = {
23
37
  converters?: Record<string, BodyConverter>;
24
38
  serialize?: (args: SerializeBodyArgs) => Promise<string> | string;
39
+ editor?: RichTextField['editor'];
25
40
  };
26
41
  /**
27
42
  * Serialize an action's `body` config into HTML. A legacy string body is interpolated as-is
@@ -62,7 +62,9 @@ const makeRenderBody = (args) => async (body) => {
62
62
  if (args.richText?.serialize) return await args.richText.serialize({
63
63
  body,
64
64
  values: args.values,
65
- descriptors: args.descriptors
65
+ descriptors: args.descriptors,
66
+ form: args.form,
67
+ req: args.req
66
68
  });
67
69
  return serializeBody(body, {
68
70
  values: args.values,
@@ -1 +1 @@
1
- {"version":3,"file":"serializeBody.js","names":[],"sources":["../../../src/actions/body/serializeBody.ts"],"sourcesContent":["import { interpolate } from '../../recall/interpolate'\nimport type { SubmissionDescriptor, SubmissionValue } from '../../submissions/types'\nimport type { BodyConverter, BodyRender } from './converters'\nimport { defaultBodyConverters } from './converters'\nimport { escapeHtml } from './escapeHtml'\nimport { serializeSlate } from './serializeSlate'\nimport { renderAllValues, renderAllValuesTable } from './wildcards'\n\n/** Submission data plus optional converter overrides available while serializing a body. */\nexport type BodyContext = {\n\tvalues: SubmissionValue[]\n\tdescriptors: SubmissionDescriptor[]\n\tconverters?: Record<string, BodyConverter>\n}\n\n/** Args a custom `richText.serialize` replacement receives per rendered body. */\nexport type SerializeBodyArgs = {\n\tbody: unknown\n\tvalues: SubmissionValue[]\n\tdescriptors: SubmissionDescriptor[]\n}\n\n/**\n * Customizes how action bodies become channel-ready strings. `converters` spread over the\n * defaults per Lexical node type; `serialize` replaces the whole pipeline (for non-HTML\n * channels like chat or plain text).\n */\nexport type RichTextBodyOption = {\n\tconverters?: Record<string, BodyConverter>\n\tserialize?: (args: SerializeBodyArgs) => Promise<string> | string\n}\n\n/** Recall resolver over submission values: field name to stringified value, `''` when absent. */\nexport const resolverFor =\n\t(values: SubmissionValue[]) =>\n\t(name: string): string => {\n\t\tconst entry = values.find((value) => value.field === name)\n\t\treturn entry == null ? '' : String(entry.value ?? '')\n\t}\n\nconst renderFor = (ctx: BodyContext): BodyRender => {\n\tconst resolve = resolverFor(ctx.values)\n\tconst htmlResolve = (name: string): string => {\n\t\tif (name === '*') {\n\t\t\treturn renderAllValues(ctx.values, ctx.descriptors)\n\t\t}\n\t\tif (name === '*:table') {\n\t\t\treturn renderAllValuesTable(ctx.values, ctx.descriptors)\n\t\t}\n\t\treturn escapeHtml(resolve(name))\n\t}\n\treturn {\n\t\ttext: (raw) => interpolate(escapeHtml(raw), htmlResolve),\n\t\tinterpolate: (raw) => interpolate(raw, resolve),\n\t}\n}\n\nconst serializeNodes = (\n\tnodes: unknown[],\n\tconverters: Record<string, BodyConverter>,\n\trender: BodyRender\n): string =>\n\tnodes\n\t\t.map((node) => {\n\t\t\tif (node == null || typeof node !== 'object') {\n\t\t\t\treturn ''\n\t\t\t}\n\t\t\tconst lexicalNode = node as Record<string, unknown>\n\t\t\tconst children = Array.isArray(lexicalNode.children)\n\t\t\t\t? serializeNodes(lexicalNode.children, converters, render)\n\t\t\t\t: ''\n\t\t\tconst converter =\n\t\t\t\ttypeof lexicalNode.type === 'string' ? converters[lexicalNode.type] : undefined\n\t\t\treturn converter ? converter({ node: lexicalNode, children, render }) : children\n\t\t})\n\t\t.join('')\n\nconst lexicalRootOf = (body: unknown): Record<string, unknown> | null => {\n\tif (body == null || typeof body !== 'object' || Array.isArray(body)) {\n\t\treturn null\n\t}\n\tconst root = (body as { root?: unknown }).root\n\treturn root != null && typeof root === 'object' ? (root as Record<string, unknown>) : null\n}\n\n/**\n * Serialize an action's `body` config into HTML. A legacy string body is interpolated as-is\n * (pre-richText behavior, no escaping); a Lexical state walks the converter registry; a Slate\n * array uses the minimal legacy serializer; anything else yields `''`. Rendered text is\n * HTML-escaped and supports `{{ name|fallback }}`, `{{*}}`, and `{{*:table}}` tokens.\n */\nexport const serializeBody = (body: unknown, ctx: BodyContext): string => {\n\tif (typeof body === 'string') {\n\t\treturn interpolate(body, resolverFor(ctx.values))\n\t}\n\tconst render = renderFor(ctx)\n\tif (Array.isArray(body)) {\n\t\treturn serializeSlate(body, render)\n\t}\n\tconst root = lexicalRootOf(body)\n\tif (root) {\n\t\tconst converters = { ...defaultBodyConverters, ...(ctx.converters ?? {}) }\n\t\treturn serializeNodes(Array.isArray(root.children) ? root.children : [], converters, render)\n\t}\n\treturn ''\n}\n\n/** Build the `renderBody` passed to actions, honoring a plugin-level `richText` customization. */\nexport const makeRenderBody =\n\t(args: {\n\t\tvalues: SubmissionValue[]\n\t\tdescriptors: SubmissionDescriptor[]\n\t\trichText?: RichTextBodyOption\n\t}) =>\n\tasync (body: unknown): Promise<string> => {\n\t\tif (args.richText?.serialize) {\n\t\t\treturn await args.richText.serialize({\n\t\t\t\tbody,\n\t\t\t\tvalues: args.values,\n\t\t\t\tdescriptors: args.descriptors,\n\t\t\t})\n\t\t}\n\t\treturn serializeBody(body, {\n\t\t\tvalues: args.values,\n\t\t\tdescriptors: args.descriptors,\n\t\t\tconverters: args.richText?.converters,\n\t\t})\n\t}\n"],"mappings":";;;;;;;AAiCA,MAAa,eACX,YACA,SAAyB;CACzB,MAAM,QAAQ,OAAO,MAAM,UAAU,MAAM,UAAU,IAAI;CACzD,OAAO,SAAS,OAAO,KAAK,OAAO,MAAM,SAAS,EAAE;AACrD;AAED,MAAM,aAAa,QAAiC;CACnD,MAAM,UAAU,YAAY,IAAI,MAAM;CACtC,MAAM,eAAe,SAAyB;EAC7C,IAAI,SAAS,KACZ,OAAO,gBAAgB,IAAI,QAAQ,IAAI,WAAW;EAEnD,IAAI,SAAS,WACZ,OAAO,qBAAqB,IAAI,QAAQ,IAAI,WAAW;EAExD,OAAO,WAAW,QAAQ,IAAI,CAAC;CAChC;CACA,OAAO;EACN,OAAO,QAAQ,YAAY,WAAW,GAAG,GAAG,WAAW;EACvD,cAAc,QAAQ,YAAY,KAAK,OAAO;CAC/C;AACD;AAEA,MAAM,kBACL,OACA,YACA,WAEA,MACE,KAAK,SAAS;CACd,IAAI,QAAQ,QAAQ,OAAO,SAAS,UACnC,OAAO;CAER,MAAM,cAAc;CACpB,MAAM,WAAW,MAAM,QAAQ,YAAY,QAAQ,IAChD,eAAe,YAAY,UAAU,YAAY,MAAM,IACvD;CACH,MAAM,YACL,OAAO,YAAY,SAAS,WAAW,WAAW,YAAY,QAAQ,KAAA;CACvE,OAAO,YAAY,UAAU;EAAE,MAAM;EAAa;EAAU;CAAO,CAAC,IAAI;AACzE,CAAC,EACA,KAAK,EAAE;AAEV,MAAM,iBAAiB,SAAkD;CACxE,IAAI,QAAQ,QAAQ,OAAO,SAAS,YAAY,MAAM,QAAQ,IAAI,GACjE,OAAO;CAER,MAAM,OAAQ,KAA4B;CAC1C,OAAO,QAAQ,QAAQ,OAAO,SAAS,WAAY,OAAmC;AACvF;;;;;;;AAQA,MAAa,iBAAiB,MAAe,QAA6B;CACzE,IAAI,OAAO,SAAS,UACnB,OAAO,YAAY,MAAM,YAAY,IAAI,MAAM,CAAC;CAEjD,MAAM,SAAS,UAAU,GAAG;CAC5B,IAAI,MAAM,QAAQ,IAAI,GACrB,OAAO,eAAe,MAAM,MAAM;CAEnC,MAAM,OAAO,cAAc,IAAI;CAC/B,IAAI,MAAM;EACT,MAAM,aAAa;GAAE,GAAG;GAAuB,GAAI,IAAI,cAAc,CAAC;EAAG;EACzE,OAAO,eAAe,MAAM,QAAQ,KAAK,QAAQ,IAAI,KAAK,WAAW,CAAC,GAAG,YAAY,MAAM;CAC5F;CACA,OAAO;AACR;;AAGA,MAAa,kBACX,SAKD,OAAO,SAAmC;CACzC,IAAI,KAAK,UAAU,WAClB,OAAO,MAAM,KAAK,SAAS,UAAU;EACpC;EACA,QAAQ,KAAK;EACb,aAAa,KAAK;CACnB,CAAC;CAEF,OAAO,cAAc,MAAM;EAC1B,QAAQ,KAAK;EACb,aAAa,KAAK;EAClB,YAAY,KAAK,UAAU;CAC5B,CAAC;AACF"}
1
+ {"version":3,"file":"serializeBody.js","names":[],"sources":["../../../src/actions/body/serializeBody.ts"],"sourcesContent":["import type { PayloadRequest, RichTextField } from 'payload'\nimport { interpolate } from '../../recall/interpolate'\nimport type { SubmissionDescriptor, SubmissionValue } from '../../submissions/types'\nimport type { BodyConverter, BodyRender } from './converters'\nimport { defaultBodyConverters } from './converters'\nimport { escapeHtml } from './escapeHtml'\nimport { serializeSlate } from './serializeSlate'\nimport { renderAllValues, renderAllValuesTable } from './wildcards'\n\n/** Minimal form identity threaded alongside a rendered body (e.g. per-tenant template lookups). */\ntype SerializeBodyForm = { id: number | string; title?: string }\n\n/** Submission data plus optional converter overrides available while serializing a body. */\nexport type BodyContext = {\n\tvalues: SubmissionValue[]\n\tdescriptors: SubmissionDescriptor[]\n\tconverters?: Record<string, BodyConverter>\n}\n\n/**\n * Args a custom `richText.serialize` replacement receives per rendered body. Always populated by\n * `makeRenderBody` (the action-body pipeline): `form` and `req` enable per-tenant template\n * lookups or handing the body off to a renderer like react-email.\n */\nexport type SerializeBodyArgs = {\n\tbody: unknown\n\tvalues: SubmissionValue[]\n\tdescriptors: SubmissionDescriptor[]\n\tform: SerializeBodyForm\n\treq?: PayloadRequest\n}\n\n/**\n * Customizes how action bodies are authored and rendered. `converters` spread over the default\n * Lexical node converters; `serialize` replaces the whole pipeline (for non-HTML channels like\n * chat or plain text, or to hand the body plus the submitted `form`/`req` off to a renderer like\n * react-email). `editor` overrides the Lexical/richText editor on both the emailTeam and\n * confirmation action body fields (see `buildDefaultActionDefinitions`).\n */\nexport type RichTextBodyOption = {\n\tconverters?: Record<string, BodyConverter>\n\tserialize?: (args: SerializeBodyArgs) => Promise<string> | string\n\teditor?: RichTextField['editor']\n}\n\n/** Recall resolver over submission values: field name to stringified value, `''` when absent. */\nexport const resolverFor =\n\t(values: SubmissionValue[]) =>\n\t(name: string): string => {\n\t\tconst entry = values.find((value) => value.field === name)\n\t\treturn entry == null ? '' : String(entry.value ?? '')\n\t}\n\nconst renderFor = (ctx: BodyContext): BodyRender => {\n\tconst resolve = resolverFor(ctx.values)\n\tconst htmlResolve = (name: string): string => {\n\t\tif (name === '*') {\n\t\t\treturn renderAllValues(ctx.values, ctx.descriptors)\n\t\t}\n\t\tif (name === '*:table') {\n\t\t\treturn renderAllValuesTable(ctx.values, ctx.descriptors)\n\t\t}\n\t\treturn escapeHtml(resolve(name))\n\t}\n\treturn {\n\t\ttext: (raw) => interpolate(escapeHtml(raw), htmlResolve),\n\t\tinterpolate: (raw) => interpolate(raw, resolve),\n\t}\n}\n\nconst serializeNodes = (\n\tnodes: unknown[],\n\tconverters: Record<string, BodyConverter>,\n\trender: BodyRender\n): string =>\n\tnodes\n\t\t.map((node) => {\n\t\t\tif (node == null || typeof node !== 'object') {\n\t\t\t\treturn ''\n\t\t\t}\n\t\t\tconst lexicalNode = node as Record<string, unknown>\n\t\t\tconst children = Array.isArray(lexicalNode.children)\n\t\t\t\t? serializeNodes(lexicalNode.children, converters, render)\n\t\t\t\t: ''\n\t\t\tconst converter =\n\t\t\t\ttypeof lexicalNode.type === 'string' ? converters[lexicalNode.type] : undefined\n\t\t\treturn converter ? converter({ node: lexicalNode, children, render }) : children\n\t\t})\n\t\t.join('')\n\nconst lexicalRootOf = (body: unknown): Record<string, unknown> | null => {\n\tif (body == null || typeof body !== 'object' || Array.isArray(body)) {\n\t\treturn null\n\t}\n\tconst root = (body as { root?: unknown }).root\n\treturn root != null && typeof root === 'object' ? (root as Record<string, unknown>) : null\n}\n\n/**\n * Serialize an action's `body` config into HTML. A legacy string body is interpolated as-is\n * (pre-richText behavior, no escaping); a Lexical state walks the converter registry; a Slate\n * array uses the minimal legacy serializer; anything else yields `''`. Rendered text is\n * HTML-escaped and supports `{{ name|fallback }}`, `{{*}}`, and `{{*:table}}` tokens.\n */\nexport const serializeBody = (body: unknown, ctx: BodyContext): string => {\n\tif (typeof body === 'string') {\n\t\treturn interpolate(body, resolverFor(ctx.values))\n\t}\n\tconst render = renderFor(ctx)\n\tif (Array.isArray(body)) {\n\t\treturn serializeSlate(body, render)\n\t}\n\tconst root = lexicalRootOf(body)\n\tif (root) {\n\t\tconst converters = { ...defaultBodyConverters, ...(ctx.converters ?? {}) }\n\t\treturn serializeNodes(Array.isArray(root.children) ? root.children : [], converters, render)\n\t}\n\treturn ''\n}\n\n/** Build the `renderBody` passed to actions, honoring a plugin-level `richText` customization. */\nexport const makeRenderBody =\n\t(args: {\n\t\tvalues: SubmissionValue[]\n\t\tdescriptors: SubmissionDescriptor[]\n\t\tform: SerializeBodyForm\n\t\treq?: PayloadRequest\n\t\trichText?: RichTextBodyOption\n\t}) =>\n\tasync (body: unknown): Promise<string> => {\n\t\tif (args.richText?.serialize) {\n\t\t\treturn await args.richText.serialize({\n\t\t\t\tbody,\n\t\t\t\tvalues: args.values,\n\t\t\t\tdescriptors: args.descriptors,\n\t\t\t\tform: args.form,\n\t\t\t\treq: args.req,\n\t\t\t})\n\t\t}\n\t\treturn serializeBody(body, {\n\t\t\tvalues: args.values,\n\t\t\tdescriptors: args.descriptors,\n\t\t\tconverters: args.richText?.converters,\n\t\t})\n\t}\n"],"mappings":";;;;;;;AA8CA,MAAa,eACX,YACA,SAAyB;CACzB,MAAM,QAAQ,OAAO,MAAM,UAAU,MAAM,UAAU,IAAI;CACzD,OAAO,SAAS,OAAO,KAAK,OAAO,MAAM,SAAS,EAAE;AACrD;AAED,MAAM,aAAa,QAAiC;CACnD,MAAM,UAAU,YAAY,IAAI,MAAM;CACtC,MAAM,eAAe,SAAyB;EAC7C,IAAI,SAAS,KACZ,OAAO,gBAAgB,IAAI,QAAQ,IAAI,WAAW;EAEnD,IAAI,SAAS,WACZ,OAAO,qBAAqB,IAAI,QAAQ,IAAI,WAAW;EAExD,OAAO,WAAW,QAAQ,IAAI,CAAC;CAChC;CACA,OAAO;EACN,OAAO,QAAQ,YAAY,WAAW,GAAG,GAAG,WAAW;EACvD,cAAc,QAAQ,YAAY,KAAK,OAAO;CAC/C;AACD;AAEA,MAAM,kBACL,OACA,YACA,WAEA,MACE,KAAK,SAAS;CACd,IAAI,QAAQ,QAAQ,OAAO,SAAS,UACnC,OAAO;CAER,MAAM,cAAc;CACpB,MAAM,WAAW,MAAM,QAAQ,YAAY,QAAQ,IAChD,eAAe,YAAY,UAAU,YAAY,MAAM,IACvD;CACH,MAAM,YACL,OAAO,YAAY,SAAS,WAAW,WAAW,YAAY,QAAQ,KAAA;CACvE,OAAO,YAAY,UAAU;EAAE,MAAM;EAAa;EAAU;CAAO,CAAC,IAAI;AACzE,CAAC,EACA,KAAK,EAAE;AAEV,MAAM,iBAAiB,SAAkD;CACxE,IAAI,QAAQ,QAAQ,OAAO,SAAS,YAAY,MAAM,QAAQ,IAAI,GACjE,OAAO;CAER,MAAM,OAAQ,KAA4B;CAC1C,OAAO,QAAQ,QAAQ,OAAO,SAAS,WAAY,OAAmC;AACvF;;;;;;;AAQA,MAAa,iBAAiB,MAAe,QAA6B;CACzE,IAAI,OAAO,SAAS,UACnB,OAAO,YAAY,MAAM,YAAY,IAAI,MAAM,CAAC;CAEjD,MAAM,SAAS,UAAU,GAAG;CAC5B,IAAI,MAAM,QAAQ,IAAI,GACrB,OAAO,eAAe,MAAM,MAAM;CAEnC,MAAM,OAAO,cAAc,IAAI;CAC/B,IAAI,MAAM;EACT,MAAM,aAAa;GAAE,GAAG;GAAuB,GAAI,IAAI,cAAc,CAAC;EAAG;EACzE,OAAO,eAAe,MAAM,QAAQ,KAAK,QAAQ,IAAI,KAAK,WAAW,CAAC,GAAG,YAAY,MAAM;CAC5F;CACA,OAAO;AACR;;AAGA,MAAa,kBACX,SAOD,OAAO,SAAmC;CACzC,IAAI,KAAK,UAAU,WAClB,OAAO,MAAM,KAAK,SAAS,UAAU;EACpC;EACA,QAAQ,KAAK;EACb,aAAa,KAAK;EAClB,MAAM,KAAK;EACX,KAAK,KAAK;CACX,CAAC;CAEF,OAAO,cAAc,MAAM;EAC1B,QAAQ,KAAK;EACb,aAAa,KAAK;EAClB,YAAY,KAAK,UAAU;CAC5B,CAAC;AACF"}
@@ -1,4 +1,5 @@
1
1
  import { fieldNamesOfType } from "../../fields/fieldNamesOfType.js";
2
+ import { localizedIf } from "../../fields/localizedIf.js";
2
3
  import { interpolate } from "../../recall/interpolate.js";
3
4
  import { keys } from "../../translations/keys.js";
4
5
  import { asTranslate, labelFor } from "../../translations/server.js";
@@ -14,7 +15,11 @@ const validateToField = (value, { data, req }) => {
14
15
  if (typeof value !== "string" || value.length === 0) return true;
15
16
  return fieldNamesOfType(data && typeof data === "object" ? data.fields : void 0, ["email"]).includes(value) ? true : asTranslate(req.t)(keys.validationEmailFieldUnknown);
16
17
  };
17
- const confirmation = defineAction({
18
+ /**
19
+ * `subject` and `body` are email content and follow `localize`; `toField` is an identifier and never does.
20
+ * `editor` overrides the body field's Lexical/richText editor (from the plugin's `richText.editor` option).
21
+ */
22
+ const buildConfirmation = (localize, editor) => defineAction({
18
23
  type: "confirmation",
19
24
  label: keys.actionConfirmation,
20
25
  config: [
@@ -34,13 +39,16 @@ const confirmation = defineAction({
34
39
  {
35
40
  name: "subject",
36
41
  type: "text",
37
- label: labelFor(keys.actionConfigSubject)
42
+ label: labelFor(keys.actionConfigSubject),
43
+ ...localizedIf(localize)
38
44
  },
39
45
  {
40
46
  name: "body",
41
47
  type: "richText",
42
48
  label: labelFor(keys.actionConfigBody),
43
- admin: { description: labelFor(keys.actionConfigBodyDescription) }
49
+ admin: { description: labelFor(keys.actionConfigBodyDescription) },
50
+ ...localizedIf(localize),
51
+ ...editor ? { editor } : {}
44
52
  }
45
53
  ],
46
54
  run: async (args) => {
@@ -59,7 +67,8 @@ const confirmation = defineAction({
59
67
  });
60
68
  }
61
69
  });
70
+ buildConfirmation(true);
62
71
  //#endregion
63
- export { confirmation };
72
+ export { buildConfirmation };
64
73
 
65
74
  //# sourceMappingURL=confirmation.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"confirmation.js","names":[],"sources":["../../../src/actions/builtin/confirmation.ts"],"sourcesContent":["import type { PayloadRequest } from 'payload'\nimport { fieldNamesOfType } from '../../fields/fieldNamesOfType'\nimport { interpolate } from '../../recall/interpolate'\nimport { keys } from '../../translations/keys'\nimport { asTranslate, labelFor } from '../../translations/server'\nimport { resolverFor } from '../body/serializeBody'\nimport { defineAction } from '../defineAction'\n\ntype ConfirmationConfig = { toField?: string; subject?: string; body?: unknown }\n\nconst TO_FIELD_REF = '@10x-media/form-builder/client#FieldNameSelect'\n\n/**\n * Validates the confirmation action's `toField`: unset is fine, otherwise it must name an\n * existing `email`-type field on the form. Exported for unit testing.\n */\nexport const validateToField = (\n\tvalue: unknown,\n\t{ data, req }: { data?: unknown; req: PayloadRequest }\n): string | true => {\n\tif (typeof value !== 'string' || value.length === 0) {\n\t\treturn true\n\t}\n\tconst fields =\n\t\tdata && typeof data === 'object' ? (data as Record<string, unknown>).fields : undefined\n\treturn fieldNamesOfType(fields, ['email']).includes(value)\n\t\t? true\n\t\t: asTranslate(req.t)(keys.validationEmailFieldUnknown)\n}\n\nexport const confirmation = defineAction<ConfirmationConfig>({\n\ttype: 'confirmation',\n\tlabel: keys.actionConfirmation,\n\tconfig: [\n\t\t{\n\t\t\tname: 'toField',\n\t\t\ttype: 'text',\n\t\t\tlabel: labelFor(keys.actionConfigToField),\n\t\t\tadmin: {\n\t\t\t\tdescription: labelFor(keys.actionConfigToFieldDescription),\n\t\t\t\tcomponents: { Field: { path: TO_FIELD_REF, clientProps: { types: ['email'] } } },\n\t\t\t},\n\t\t\tvalidate: validateToField,\n\t\t},\n\t\t{ name: 'subject', type: 'text', label: labelFor(keys.actionConfigSubject) },\n\t\t{\n\t\t\tname: 'body',\n\t\t\ttype: 'richText',\n\t\t\tlabel: labelFor(keys.actionConfigBody),\n\t\t\tadmin: { description: labelFor(keys.actionConfigBodyDescription) },\n\t\t},\n\t],\n\trun: async (args) => {\n\t\tconst { config, values, payload, renderBody } = args\n\n\t\tif (!config.toField) {\n\t\t\treturn\n\t\t}\n\n\t\tconst resolve = resolverFor(values)\n\t\tconst to = resolve(config.toField)\n\n\t\tif (!to) {\n\t\t\treturn\n\t\t}\n\n\t\tif (typeof payload.sendEmail !== 'function') {\n\t\t\tthrow new Error('confirmation: no email adapter configured')\n\t\t}\n\n\t\tconst subject = interpolate(config.subject ?? '', resolve)\n\t\tconst html = await renderBody(config.body)\n\n\t\tawait payload.sendEmail({ to, subject, html })\n\t},\n})\n"],"mappings":";;;;;;;AAUA,MAAM,eAAe;;;;;AAMrB,MAAa,mBACZ,OACA,EAAE,MAAM,UACW;CACnB,IAAI,OAAO,UAAU,YAAY,MAAM,WAAW,GACjD,OAAO;CAIR,OAAO,iBADN,QAAQ,OAAO,SAAS,WAAY,KAAiC,SAAS,KAAA,GAC/C,CAAC,OAAO,CAAC,EAAE,SAAS,KAAK,IACtD,OACA,YAAY,IAAI,CAAC,EAAE,KAAK,2BAA2B;AACvD;AAEA,MAAa,eAAe,aAAiC;CAC5D,MAAM;CACN,OAAO,KAAK;CACZ,QAAQ;EACP;GACC,MAAM;GACN,MAAM;GACN,OAAO,SAAS,KAAK,mBAAmB;GACxC,OAAO;IACN,aAAa,SAAS,KAAK,8BAA8B;IACzD,YAAY,EAAE,OAAO;KAAE,MAAM;KAAc,aAAa,EAAE,OAAO,CAAC,OAAO,EAAE;IAAE,EAAE;GAChF;GACA,UAAU;EACX;EACA;GAAE,MAAM;GAAW,MAAM;GAAQ,OAAO,SAAS,KAAK,mBAAmB;EAAE;EAC3E;GACC,MAAM;GACN,MAAM;GACN,OAAO,SAAS,KAAK,gBAAgB;GACrC,OAAO,EAAE,aAAa,SAAS,KAAK,2BAA2B,EAAE;EAClE;CACD;CACA,KAAK,OAAO,SAAS;EACpB,MAAM,EAAE,QAAQ,QAAQ,SAAS,eAAe;EAEhD,IAAI,CAAC,OAAO,SACX;EAGD,MAAM,UAAU,YAAY,MAAM;EAClC,MAAM,KAAK,QAAQ,OAAO,OAAO;EAEjC,IAAI,CAAC,IACJ;EAGD,IAAI,OAAO,QAAQ,cAAc,YAChC,MAAM,IAAI,MAAM,2CAA2C;EAG5D,MAAM,UAAU,YAAY,OAAO,WAAW,IAAI,OAAO;EACzD,MAAM,OAAO,MAAM,WAAW,OAAO,IAAI;EAEzC,MAAM,QAAQ,UAAU;GAAE;GAAI;GAAS;EAAK,CAAC;CAC9C;AACD,CAAC"}
1
+ {"version":3,"file":"confirmation.js","names":[],"sources":["../../../src/actions/builtin/confirmation.ts"],"sourcesContent":["import type { PayloadRequest, RichTextField } from 'payload'\nimport { fieldNamesOfType } from '../../fields/fieldNamesOfType'\nimport { localizedIf } from '../../fields/localizedIf'\nimport { interpolate } from '../../recall/interpolate'\nimport { keys } from '../../translations/keys'\nimport { asTranslate, labelFor } from '../../translations/server'\nimport { resolverFor } from '../body/serializeBody'\nimport { defineAction } from '../defineAction'\n\ntype ConfirmationConfig = { toField?: string; subject?: string; body?: unknown }\n\nconst TO_FIELD_REF = '@10x-media/form-builder/client#FieldNameSelect'\n\n/**\n * Validates the confirmation action's `toField`: unset is fine, otherwise it must name an\n * existing `email`-type field on the form. Exported for unit testing.\n */\nexport const validateToField = (\n\tvalue: unknown,\n\t{ data, req }: { data?: unknown; req: PayloadRequest }\n): string | true => {\n\tif (typeof value !== 'string' || value.length === 0) {\n\t\treturn true\n\t}\n\tconst fields =\n\t\tdata && typeof data === 'object' ? (data as Record<string, unknown>).fields : undefined\n\treturn fieldNamesOfType(fields, ['email']).includes(value)\n\t\t? true\n\t\t: asTranslate(req.t)(keys.validationEmailFieldUnknown)\n}\n\n/**\n * `subject` and `body` are email content and follow `localize`; `toField` is an identifier and never does.\n * `editor` overrides the body field's Lexical/richText editor (from the plugin's `richText.editor` option).\n */\nexport const buildConfirmation = (localize: boolean, editor?: RichTextField['editor']) =>\n\tdefineAction<ConfirmationConfig>({\n\t\ttype: 'confirmation',\n\t\tlabel: keys.actionConfirmation,\n\t\tconfig: [\n\t\t\t{\n\t\t\t\tname: 'toField',\n\t\t\t\ttype: 'text',\n\t\t\t\tlabel: labelFor(keys.actionConfigToField),\n\t\t\t\tadmin: {\n\t\t\t\t\tdescription: labelFor(keys.actionConfigToFieldDescription),\n\t\t\t\t\tcomponents: { Field: { path: TO_FIELD_REF, clientProps: { types: ['email'] } } },\n\t\t\t\t},\n\t\t\t\tvalidate: validateToField,\n\t\t\t},\n\t\t\t{\n\t\t\t\tname: 'subject',\n\t\t\t\ttype: 'text',\n\t\t\t\tlabel: labelFor(keys.actionConfigSubject),\n\t\t\t\t...localizedIf(localize),\n\t\t\t},\n\t\t\t{\n\t\t\t\tname: 'body',\n\t\t\t\ttype: 'richText',\n\t\t\t\tlabel: labelFor(keys.actionConfigBody),\n\t\t\t\tadmin: { description: labelFor(keys.actionConfigBodyDescription) },\n\t\t\t\t...localizedIf(localize),\n\t\t\t\t...(editor ? { editor } : {}),\n\t\t\t},\n\t\t],\n\t\trun: async (args) => {\n\t\t\tconst { config, values, payload, renderBody } = args\n\n\t\t\tif (!config.toField) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tconst resolve = resolverFor(values)\n\t\t\tconst to = resolve(config.toField)\n\n\t\t\tif (!to) {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif (typeof payload.sendEmail !== 'function') {\n\t\t\t\tthrow new Error('confirmation: no email adapter configured')\n\t\t\t}\n\n\t\t\tconst subject = interpolate(config.subject ?? '', resolve)\n\t\t\tconst html = await renderBody(config.body)\n\n\t\t\tawait payload.sendEmail({ to, subject, html })\n\t\t},\n\t})\n\nexport const confirmation = buildConfirmation(true)\n"],"mappings":";;;;;;;;AAWA,MAAM,eAAe;;;;;AAMrB,MAAa,mBACZ,OACA,EAAE,MAAM,UACW;CACnB,IAAI,OAAO,UAAU,YAAY,MAAM,WAAW,GACjD,OAAO;CAIR,OAAO,iBADN,QAAQ,OAAO,SAAS,WAAY,KAAiC,SAAS,KAAA,GAC/C,CAAC,OAAO,CAAC,EAAE,SAAS,KAAK,IACtD,OACA,YAAY,IAAI,CAAC,EAAE,KAAK,2BAA2B;AACvD;;;;;AAMA,MAAa,qBAAqB,UAAmB,WACpD,aAAiC;CAChC,MAAM;CACN,OAAO,KAAK;CACZ,QAAQ;EACP;GACC,MAAM;GACN,MAAM;GACN,OAAO,SAAS,KAAK,mBAAmB;GACxC,OAAO;IACN,aAAa,SAAS,KAAK,8BAA8B;IACzD,YAAY,EAAE,OAAO;KAAE,MAAM;KAAc,aAAa,EAAE,OAAO,CAAC,OAAO,EAAE;IAAE,EAAE;GAChF;GACA,UAAU;EACX;EACA;GACC,MAAM;GACN,MAAM;GACN,OAAO,SAAS,KAAK,mBAAmB;GACxC,GAAG,YAAY,QAAQ;EACxB;EACA;GACC,MAAM;GACN,MAAM;GACN,OAAO,SAAS,KAAK,gBAAgB;GACrC,OAAO,EAAE,aAAa,SAAS,KAAK,2BAA2B,EAAE;GACjE,GAAG,YAAY,QAAQ;GACvB,GAAI,SAAS,EAAE,OAAO,IAAI,CAAC;EAC5B;CACD;CACA,KAAK,OAAO,SAAS;EACpB,MAAM,EAAE,QAAQ,QAAQ,SAAS,eAAe;EAEhD,IAAI,CAAC,OAAO,SACX;EAGD,MAAM,UAAU,YAAY,MAAM;EAClC,MAAM,KAAK,QAAQ,OAAO,OAAO;EAEjC,IAAI,CAAC,IACJ;EAGD,IAAI,OAAO,QAAQ,cAAc,YAChC,MAAM,IAAI,MAAM,2CAA2C;EAG5D,MAAM,UAAU,YAAY,OAAO,WAAW,IAAI,OAAO;EACzD,MAAM,OAAO,MAAM,WAAW,OAAO,IAAI;EAEzC,MAAM,QAAQ,UAAU;GAAE;GAAI;GAAS;EAAK,CAAC;CAC9C;AACD,CAAC;AAE0B,kBAAkB,IAAI"}
@@ -1,10 +1,15 @@
1
+ import { localizedIf } from "../../fields/localizedIf.js";
1
2
  import { interpolate } from "../../recall/interpolate.js";
2
3
  import { keys } from "../../translations/keys.js";
3
4
  import { labelFor } from "../../translations/server.js";
4
5
  import { resolverFor } from "../body/serializeBody.js";
5
6
  import { defineAction } from "../defineAction.js";
6
7
  //#region src/actions/builtin/emailTeam.ts
7
- const emailTeam = defineAction({
8
+ /**
9
+ * `subject` and `body` are email content and follow `localize`; the `to` address never does.
10
+ * `editor` overrides the body field's Lexical/richText editor (from the plugin's `richText.editor` option).
11
+ */
12
+ const buildEmailTeam = (localize, editor) => defineAction({
8
13
  type: "emailTeam",
9
14
  label: keys.actionEmailTeam,
10
15
  config: [
@@ -16,13 +21,16 @@ const emailTeam = defineAction({
16
21
  {
17
22
  name: "subject",
18
23
  type: "text",
19
- label: labelFor(keys.actionConfigSubject)
24
+ label: labelFor(keys.actionConfigSubject),
25
+ ...localizedIf(localize)
20
26
  },
21
27
  {
22
28
  name: "body",
23
29
  type: "richText",
24
30
  label: labelFor(keys.actionConfigBody),
25
- admin: { description: labelFor(keys.actionConfigBodyDescription) }
31
+ admin: { description: labelFor(keys.actionConfigBodyDescription) },
32
+ ...localizedIf(localize),
33
+ ...editor ? { editor } : {}
26
34
  }
27
35
  ],
28
36
  run: async (args) => {
@@ -38,7 +46,8 @@ const emailTeam = defineAction({
38
46
  });
39
47
  }
40
48
  });
49
+ buildEmailTeam(true);
41
50
  //#endregion
42
- export { emailTeam };
51
+ export { buildEmailTeam };
43
52
 
44
53
  //# sourceMappingURL=emailTeam.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"emailTeam.js","names":[],"sources":["../../../src/actions/builtin/emailTeam.ts"],"sourcesContent":["import { interpolate } from '../../recall/interpolate'\nimport { keys } from '../../translations/keys'\nimport { labelFor } from '../../translations/server'\nimport { resolverFor } from '../body/serializeBody'\nimport { defineAction } from '../defineAction'\n\ntype EmailTeamConfig = { to?: string; subject?: string; body?: unknown }\n\nexport const emailTeam = defineAction<EmailTeamConfig>({\n\ttype: 'emailTeam',\n\tlabel: keys.actionEmailTeam,\n\tconfig: [\n\t\t{ name: 'to', type: 'text', label: labelFor(keys.actionConfigTo) },\n\t\t{ name: 'subject', type: 'text', label: labelFor(keys.actionConfigSubject) },\n\t\t{\n\t\t\tname: 'body',\n\t\t\ttype: 'richText',\n\t\t\tlabel: labelFor(keys.actionConfigBody),\n\t\t\tadmin: { description: labelFor(keys.actionConfigBodyDescription) },\n\t\t},\n\t],\n\trun: async (args) => {\n\t\tconst { config, values, payload, renderBody } = args\n\n\t\tif (!config.to) {\n\t\t\tthrow new Error('emailTeam: missing \"to\" address')\n\t\t}\n\n\t\tif (typeof payload.sendEmail !== 'function') {\n\t\t\tthrow new Error('emailTeam: no email adapter configured')\n\t\t}\n\n\t\tconst subject = interpolate(config.subject ?? '', resolverFor(values))\n\t\tconst html = await renderBody(config.body)\n\n\t\tawait payload.sendEmail({ to: config.to, subject, html })\n\t},\n})\n"],"mappings":";;;;;;AAQA,MAAa,YAAY,aAA8B;CACtD,MAAM;CACN,OAAO,KAAK;CACZ,QAAQ;EACP;GAAE,MAAM;GAAM,MAAM;GAAQ,OAAO,SAAS,KAAK,cAAc;EAAE;EACjE;GAAE,MAAM;GAAW,MAAM;GAAQ,OAAO,SAAS,KAAK,mBAAmB;EAAE;EAC3E;GACC,MAAM;GACN,MAAM;GACN,OAAO,SAAS,KAAK,gBAAgB;GACrC,OAAO,EAAE,aAAa,SAAS,KAAK,2BAA2B,EAAE;EAClE;CACD;CACA,KAAK,OAAO,SAAS;EACpB,MAAM,EAAE,QAAQ,QAAQ,SAAS,eAAe;EAEhD,IAAI,CAAC,OAAO,IACX,MAAM,IAAI,MAAM,mCAAiC;EAGlD,IAAI,OAAO,QAAQ,cAAc,YAChC,MAAM,IAAI,MAAM,wCAAwC;EAGzD,MAAM,UAAU,YAAY,OAAO,WAAW,IAAI,YAAY,MAAM,CAAC;EACrE,MAAM,OAAO,MAAM,WAAW,OAAO,IAAI;EAEzC,MAAM,QAAQ,UAAU;GAAE,IAAI,OAAO;GAAI;GAAS;EAAK,CAAC;CACzD;AACD,CAAC"}
1
+ {"version":3,"file":"emailTeam.js","names":[],"sources":["../../../src/actions/builtin/emailTeam.ts"],"sourcesContent":["import type { RichTextField } from 'payload'\nimport { localizedIf } from '../../fields/localizedIf'\nimport { interpolate } from '../../recall/interpolate'\nimport { keys } from '../../translations/keys'\nimport { labelFor } from '../../translations/server'\nimport { resolverFor } from '../body/serializeBody'\nimport { defineAction } from '../defineAction'\n\ntype EmailTeamConfig = { to?: string; subject?: string; body?: unknown }\n\n/**\n * `subject` and `body` are email content and follow `localize`; the `to` address never does.\n * `editor` overrides the body field's Lexical/richText editor (from the plugin's `richText.editor` option).\n */\nexport const buildEmailTeam = (localize: boolean, editor?: RichTextField['editor']) =>\n\tdefineAction<EmailTeamConfig>({\n\t\ttype: 'emailTeam',\n\t\tlabel: keys.actionEmailTeam,\n\t\tconfig: [\n\t\t\t{ name: 'to', type: 'text', label: labelFor(keys.actionConfigTo) },\n\t\t\t{\n\t\t\t\tname: 'subject',\n\t\t\t\ttype: 'text',\n\t\t\t\tlabel: labelFor(keys.actionConfigSubject),\n\t\t\t\t...localizedIf(localize),\n\t\t\t},\n\t\t\t{\n\t\t\t\tname: 'body',\n\t\t\t\ttype: 'richText',\n\t\t\t\tlabel: labelFor(keys.actionConfigBody),\n\t\t\t\tadmin: { description: labelFor(keys.actionConfigBodyDescription) },\n\t\t\t\t...localizedIf(localize),\n\t\t\t\t...(editor ? { editor } : {}),\n\t\t\t},\n\t\t],\n\t\trun: async (args) => {\n\t\t\tconst { config, values, payload, renderBody } = args\n\n\t\t\tif (!config.to) {\n\t\t\t\tthrow new Error('emailTeam: missing \"to\" address')\n\t\t\t}\n\n\t\t\tif (typeof payload.sendEmail !== 'function') {\n\t\t\t\tthrow new Error('emailTeam: no email adapter configured')\n\t\t\t}\n\n\t\t\tconst subject = interpolate(config.subject ?? '', resolverFor(values))\n\t\t\tconst html = await renderBody(config.body)\n\n\t\t\tawait payload.sendEmail({ to: config.to, subject, html })\n\t\t},\n\t})\n\nexport const emailTeam = buildEmailTeam(true)\n"],"mappings":";;;;;;;;;;;AAcA,MAAa,kBAAkB,UAAmB,WACjD,aAA8B;CAC7B,MAAM;CACN,OAAO,KAAK;CACZ,QAAQ;EACP;GAAE,MAAM;GAAM,MAAM;GAAQ,OAAO,SAAS,KAAK,cAAc;EAAE;EACjE;GACC,MAAM;GACN,MAAM;GACN,OAAO,SAAS,KAAK,mBAAmB;GACxC,GAAG,YAAY,QAAQ;EACxB;EACA;GACC,MAAM;GACN,MAAM;GACN,OAAO,SAAS,KAAK,gBAAgB;GACrC,OAAO,EAAE,aAAa,SAAS,KAAK,2BAA2B,EAAE;GACjE,GAAG,YAAY,QAAQ;GACvB,GAAI,SAAS,EAAE,OAAO,IAAI,CAAC;EAC5B;CACD;CACA,KAAK,OAAO,SAAS;EACpB,MAAM,EAAE,QAAQ,QAAQ,SAAS,eAAe;EAEhD,IAAI,CAAC,OAAO,IACX,MAAM,IAAI,MAAM,mCAAiC;EAGlD,IAAI,OAAO,QAAQ,cAAc,YAChC,MAAM,IAAI,MAAM,wCAAwC;EAGzD,MAAM,UAAU,YAAY,OAAO,WAAW,IAAI,YAAY,MAAM,CAAC;EACrE,MAAM,OAAO,MAAM,WAAW,OAAO,IAAI;EAEzC,MAAM,QAAQ,UAAU;GAAE,IAAI,OAAO;GAAI;GAAS;EAAK,CAAC;CACzD;AACD,CAAC;AAEuB,eAAe,IAAI"}
@@ -1,7 +1,19 @@
1
1
  import { AnyActionDefinition } from "../defineAction.js";
2
+ import { RichTextField } from "payload";
2
3
 
3
4
  //#region src/actions/builtin/index.d.ts
5
+ /**
6
+ * Built-in action definitions with content-bearing config fields (email subjects and rich text
7
+ * bodies) carrying `localized: true` when `localize` is true. `editor`, when given, overrides
8
+ * the Lexical/richText editor on both action bodies (from the plugin's `richText.editor` option).
9
+ * `signedWebhook` has no content fields and is shared static.
10
+ */
11
+ declare const buildDefaultActionDefinitions: (localize: boolean, editor?: RichTextField["editor"]) => Record<string, AnyActionDefinition>;
12
+ /**
13
+ * Prebuilt with content localization on; with `localizeContent: false`, derive overrides from
14
+ * `buildDefaultActionDefinitions(false)` instead so a spread does not reintroduce `localized`.
15
+ */
4
16
  declare const defaultActionDefinitions: Record<string, AnyActionDefinition>;
5
17
  //#endregion
6
- export { defaultActionDefinitions };
18
+ export { buildDefaultActionDefinitions, defaultActionDefinitions };
7
19
  //# sourceMappingURL=index.d.ts.map
@@ -1,13 +1,24 @@
1
- import { confirmation } from "./confirmation.js";
2
- import { emailTeam } from "./emailTeam.js";
1
+ import { buildConfirmation } from "./confirmation.js";
2
+ import { buildEmailTeam } from "./emailTeam.js";
3
3
  import { signedWebhook } from "./signedWebhook.js";
4
4
  //#region src/actions/builtin/index.ts
5
- const defaultActionDefinitions = {
6
- emailTeam,
7
- confirmation,
5
+ /**
6
+ * Built-in action definitions with content-bearing config fields (email subjects and rich text
7
+ * bodies) carrying `localized: true` when `localize` is true. `editor`, when given, overrides
8
+ * the Lexical/richText editor on both action bodies (from the plugin's `richText.editor` option).
9
+ * `signedWebhook` has no content fields and is shared static.
10
+ */
11
+ const buildDefaultActionDefinitions = (localize, editor) => ({
12
+ emailTeam: buildEmailTeam(localize, editor),
13
+ confirmation: buildConfirmation(localize, editor),
8
14
  signedWebhook
9
- };
15
+ });
16
+ /**
17
+ * Prebuilt with content localization on; with `localizeContent: false`, derive overrides from
18
+ * `buildDefaultActionDefinitions(false)` instead so a spread does not reintroduce `localized`.
19
+ */
20
+ const defaultActionDefinitions = buildDefaultActionDefinitions(true);
10
21
  //#endregion
11
- export { defaultActionDefinitions };
22
+ export { buildDefaultActionDefinitions, defaultActionDefinitions };
12
23
 
13
24
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../../../src/actions/builtin/index.ts"],"sourcesContent":["import type { AnyActionDefinition } from '../defineAction'\nimport { confirmation } from './confirmation'\nimport { emailTeam } from './emailTeam'\nimport { signedWebhook } from './signedWebhook'\n\nexport const defaultActionDefinitions: Record<string, AnyActionDefinition> = {\n\temailTeam: emailTeam as AnyActionDefinition,\n\tconfirmation: confirmation as AnyActionDefinition,\n\tsignedWebhook: signedWebhook as AnyActionDefinition,\n}\n"],"mappings":";;;;AAKA,MAAa,2BAAgE;CACjE;CACG;CACC;AAChB"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../../src/actions/builtin/index.ts"],"sourcesContent":["import type { RichTextField } from 'payload'\nimport type { AnyActionDefinition } from '../defineAction'\nimport { buildConfirmation } from './confirmation'\nimport { buildEmailTeam } from './emailTeam'\nimport { signedWebhook } from './signedWebhook'\n\n/**\n * Built-in action definitions with content-bearing config fields (email subjects and rich text\n * bodies) carrying `localized: true` when `localize` is true. `editor`, when given, overrides\n * the Lexical/richText editor on both action bodies (from the plugin's `richText.editor` option).\n * `signedWebhook` has no content fields and is shared static.\n */\nexport const buildDefaultActionDefinitions = (\n\tlocalize: boolean,\n\teditor?: RichTextField['editor']\n): Record<string, AnyActionDefinition> => ({\n\temailTeam: buildEmailTeam(localize, editor) as AnyActionDefinition,\n\tconfirmation: buildConfirmation(localize, editor) as AnyActionDefinition,\n\tsignedWebhook: signedWebhook as AnyActionDefinition,\n})\n\n/**\n * Prebuilt with content localization on; with `localizeContent: false`, derive overrides from\n * `buildDefaultActionDefinitions(false)` instead so a spread does not reintroduce `localized`.\n */\nexport const defaultActionDefinitions: Record<string, AnyActionDefinition> =\n\tbuildDefaultActionDefinitions(true)\n"],"mappings":";;;;;;;;;;AAYA,MAAa,iCACZ,UACA,YAC0C;CAC1C,WAAW,eAAe,UAAU,MAAM;CAC1C,cAAc,kBAAkB,UAAU,MAAM;CACjC;AAChB;;;;;AAMA,MAAa,2BACZ,8BAA8B,IAAI"}
@@ -1,23 +1,9 @@
1
1
  import { keys } from "../../translations/keys.js";
2
- import { asTranslate, labelFor } from "../../translations/server.js";
2
+ import { labelFor } from "../../translations/server.js";
3
3
  import { defineAction } from "../defineAction.js";
4
+ import { validateUrl } from "../../validation/validateUrl.js";
4
5
  import { SIGNATURE_HEADER, signPayload } from "../sign.js";
5
6
  //#region src/actions/builtin/signedWebhook.ts
6
- /**
7
- * Validates the signed-webhook action's `url`: unset is fine (the runtime throws on a missing
8
- * URL when the action actually runs), otherwise it must parse as an absolute http(s) URL.
9
- * Exported for unit testing.
10
- */
11
- const validateUrl = (value, { req }) => {
12
- if (typeof value !== "string" || value.length === 0) return true;
13
- let parsed;
14
- try {
15
- parsed = new URL(value);
16
- } catch {
17
- return asTranslate(req.t)(keys.validationUrlInvalid);
18
- }
19
- return parsed.protocol === "http:" || parsed.protocol === "https:" ? true : asTranslate(req.t)(keys.validationUrlInvalid);
20
- };
21
7
  const signedWebhook = defineAction({
22
8
  type: "signedWebhook",
23
9
  label: keys.actionSignedWebhook,
@@ -1 +1 @@
1
- {"version":3,"file":"signedWebhook.js","names":[],"sources":["../../../src/actions/builtin/signedWebhook.ts"],"sourcesContent":["import type { PayloadRequest } from 'payload'\nimport { keys } from '../../translations/keys'\nimport { asTranslate, labelFor } from '../../translations/server'\nimport { defineAction } from '../defineAction'\nimport { SIGNATURE_HEADER, signPayload } from '../sign'\n\ntype SignedWebhookConfig = { url?: string; secret?: string }\n\n/**\n * Validates the signed-webhook action's `url`: unset is fine (the runtime throws on a missing\n * URL when the action actually runs), otherwise it must parse as an absolute http(s) URL.\n * Exported for unit testing.\n */\nexport const validateUrl = (\n\tvalue: unknown,\n\t{ req }: { data?: unknown; req: PayloadRequest }\n): string | true => {\n\tif (typeof value !== 'string' || value.length === 0) {\n\t\treturn true\n\t}\n\tlet parsed: URL\n\ttry {\n\t\tparsed = new URL(value)\n\t} catch {\n\t\treturn asTranslate(req.t)(keys.validationUrlInvalid)\n\t}\n\treturn parsed.protocol === 'http:' || parsed.protocol === 'https:'\n\t\t? true\n\t\t: asTranslate(req.t)(keys.validationUrlInvalid)\n}\n\nexport const signedWebhook = defineAction<SignedWebhookConfig>({\n\ttype: 'signedWebhook',\n\tlabel: keys.actionSignedWebhook,\n\tconfig: [\n\t\t{\n\t\t\tname: 'url',\n\t\t\ttype: 'text',\n\t\t\tlabel: labelFor(keys.actionConfigUrl),\n\t\t\tadmin: { description: labelFor(keys.actionConfigUrlDescription) },\n\t\t\tvalidate: validateUrl,\n\t\t},\n\t\t{\n\t\t\tname: 'secret',\n\t\t\ttype: 'text',\n\t\t\tlabel: labelFor(keys.actionConfigSecret),\n\t\t\tadmin: { description: labelFor(keys.actionConfigSecretDescription) },\n\t\t},\n\t],\n\trun: async (args) => {\n\t\tconst { config, form, submissionId, values } = args\n\n\t\tif (!config.url) {\n\t\t\tthrow new Error('signedWebhook: missing \"url\"')\n\t\t}\n\t\tif (!config.secret) {\n\t\t\tthrow new Error('signedWebhook: missing \"secret\"')\n\t\t}\n\n\t\tconst body = JSON.stringify({ formId: form.id, submissionId, values })\n\t\tconst controller = new AbortController()\n\t\tconst timer = setTimeout(() => controller.abort(), 10_000)\n\n\t\tlet response: Response\n\t\ttry {\n\t\t\tresponse = await fetch(config.url, {\n\t\t\t\tmethod: 'POST',\n\t\t\t\theaders: {\n\t\t\t\t\t'Content-Type': 'application/json',\n\t\t\t\t\t[SIGNATURE_HEADER]: signPayload(body, config.secret),\n\t\t\t\t},\n\t\t\t\tbody,\n\t\t\t\tsignal: controller.signal,\n\t\t\t})\n\t\t} finally {\n\t\t\tclearTimeout(timer)\n\t\t}\n\n\t\tif (!response.ok) {\n\t\t\tthrow new Error(`signedWebhook: server responded ${response.status}`)\n\t\t}\n\t},\n})\n"],"mappings":";;;;;;;;;;AAaA,MAAa,eACZ,OACA,EAAE,UACiB;CACnB,IAAI,OAAO,UAAU,YAAY,MAAM,WAAW,GACjD,OAAO;CAER,IAAI;CACJ,IAAI;EACH,SAAS,IAAI,IAAI,KAAK;CACvB,QAAQ;EACP,OAAO,YAAY,IAAI,CAAC,EAAE,KAAK,oBAAoB;CACpD;CACA,OAAO,OAAO,aAAa,WAAW,OAAO,aAAa,WACvD,OACA,YAAY,IAAI,CAAC,EAAE,KAAK,oBAAoB;AAChD;AAEA,MAAa,gBAAgB,aAAkC;CAC9D,MAAM;CACN,OAAO,KAAK;CACZ,QAAQ,CACP;EACC,MAAM;EACN,MAAM;EACN,OAAO,SAAS,KAAK,eAAe;EACpC,OAAO,EAAE,aAAa,SAAS,KAAK,0BAA0B,EAAE;EAChE,UAAU;CACX,GACA;EACC,MAAM;EACN,MAAM;EACN,OAAO,SAAS,KAAK,kBAAkB;EACvC,OAAO,EAAE,aAAa,SAAS,KAAK,6BAA6B,EAAE;CACpE,CACD;CACA,KAAK,OAAO,SAAS;EACpB,MAAM,EAAE,QAAQ,MAAM,cAAc,WAAW;EAE/C,IAAI,CAAC,OAAO,KACX,MAAM,IAAI,MAAM,gCAA8B;EAE/C,IAAI,CAAC,OAAO,QACX,MAAM,IAAI,MAAM,mCAAiC;EAGlD,MAAM,OAAO,KAAK,UAAU;GAAE,QAAQ,KAAK;GAAI;GAAc;EAAO,CAAC;EACrE,MAAM,aAAa,IAAI,gBAAgB;EACvC,MAAM,QAAQ,iBAAiB,WAAW,MAAM,GAAG,GAAM;EAEzD,IAAI;EACJ,IAAI;GACH,WAAW,MAAM,MAAM,OAAO,KAAK;IAClC,QAAQ;IACR,SAAS;KACR,gBAAgB;MACf,mBAAmB,YAAY,MAAM,OAAO,MAAM;IACpD;IACA;IACA,QAAQ,WAAW;GACpB,CAAC;EACF,UAAU;GACT,aAAa,KAAK;EACnB;EAEA,IAAI,CAAC,SAAS,IACb,MAAM,IAAI,MAAM,mCAAmC,SAAS,QAAQ;CAEtE;AACD,CAAC"}
1
+ {"version":3,"file":"signedWebhook.js","names":[],"sources":["../../../src/actions/builtin/signedWebhook.ts"],"sourcesContent":["import { keys } from '../../translations/keys'\nimport { labelFor } from '../../translations/server'\nimport { validateUrl } from '../../validation/validateUrl'\nimport { defineAction } from '../defineAction'\nimport { SIGNATURE_HEADER, signPayload } from '../sign'\n\nexport { validateUrl } from '../../validation/validateUrl'\n\ntype SignedWebhookConfig = { url?: string; secret?: string }\n\nexport const signedWebhook = defineAction<SignedWebhookConfig>({\n\ttype: 'signedWebhook',\n\tlabel: keys.actionSignedWebhook,\n\tconfig: [\n\t\t{\n\t\t\tname: 'url',\n\t\t\ttype: 'text',\n\t\t\tlabel: labelFor(keys.actionConfigUrl),\n\t\t\tadmin: { description: labelFor(keys.actionConfigUrlDescription) },\n\t\t\tvalidate: validateUrl,\n\t\t},\n\t\t{\n\t\t\tname: 'secret',\n\t\t\ttype: 'text',\n\t\t\tlabel: labelFor(keys.actionConfigSecret),\n\t\t\tadmin: { description: labelFor(keys.actionConfigSecretDescription) },\n\t\t},\n\t],\n\trun: async (args) => {\n\t\tconst { config, form, submissionId, values } = args\n\n\t\tif (!config.url) {\n\t\t\tthrow new Error('signedWebhook: missing \"url\"')\n\t\t}\n\t\tif (!config.secret) {\n\t\t\tthrow new Error('signedWebhook: missing \"secret\"')\n\t\t}\n\n\t\tconst body = JSON.stringify({ formId: form.id, submissionId, values })\n\t\tconst controller = new AbortController()\n\t\tconst timer = setTimeout(() => controller.abort(), 10_000)\n\n\t\tlet response: Response\n\t\ttry {\n\t\t\tresponse = await fetch(config.url, {\n\t\t\t\tmethod: 'POST',\n\t\t\t\theaders: {\n\t\t\t\t\t'Content-Type': 'application/json',\n\t\t\t\t\t[SIGNATURE_HEADER]: signPayload(body, config.secret),\n\t\t\t\t},\n\t\t\t\tbody,\n\t\t\t\tsignal: controller.signal,\n\t\t\t})\n\t\t} finally {\n\t\t\tclearTimeout(timer)\n\t\t}\n\n\t\tif (!response.ok) {\n\t\t\tthrow new Error(`signedWebhook: server responded ${response.status}`)\n\t\t}\n\t},\n})\n"],"mappings":";;;;;;AAUA,MAAa,gBAAgB,aAAkC;CAC9D,MAAM;CACN,OAAO,KAAK;CACZ,QAAQ,CACP;EACC,MAAM;EACN,MAAM;EACN,OAAO,SAAS,KAAK,eAAe;EACpC,OAAO,EAAE,aAAa,SAAS,KAAK,0BAA0B,EAAE;EAChE,UAAU;CACX,GACA;EACC,MAAM;EACN,MAAM;EACN,OAAO,SAAS,KAAK,kBAAkB;EACvC,OAAO,EAAE,aAAa,SAAS,KAAK,6BAA6B,EAAE;CACpE,CACD;CACA,KAAK,OAAO,SAAS;EACpB,MAAM,EAAE,QAAQ,MAAM,cAAc,WAAW;EAE/C,IAAI,CAAC,OAAO,KACX,MAAM,IAAI,MAAM,gCAA8B;EAE/C,IAAI,CAAC,OAAO,QACX,MAAM,IAAI,MAAM,mCAAiC;EAGlD,MAAM,OAAO,KAAK,UAAU;GAAE,QAAQ,KAAK;GAAI;GAAc;EAAO,CAAC;EACrE,MAAM,aAAa,IAAI,gBAAgB;EACvC,MAAM,QAAQ,iBAAiB,WAAW,MAAM,GAAG,GAAM;EAEzD,IAAI;EACJ,IAAI;GACH,WAAW,MAAM,MAAM,OAAO,KAAK;IAClC,QAAQ;IACR,SAAS;KACR,gBAAgB;MACf,mBAAmB,YAAY,MAAM,OAAO,MAAM;IACpD;IACA;IACA,QAAQ,WAAW;GACpB,CAAC;EACF,UAAU;GACT,aAAa,KAAK;EACnB;EAEA,IAAI,CAAC,SAAS,IACb,MAAM,IAAI,MAAM,mCAAmC,SAAS,QAAQ;CAEtE;AACD,CAAC"}
@@ -9,6 +9,8 @@ const runActions = async (args) => {
9
9
  const renderBody = makeRenderBody({
10
10
  values: ctx.values,
11
11
  descriptors: ctx.descriptors,
12
+ form: ctx.form,
13
+ req: ctx.req,
12
14
  richText
13
15
  });
14
16
  const results = [];
@@ -1 +1 @@
1
- {"version":3,"file":"runActions.js","names":[],"sources":["../../src/actions/runActions.ts"],"sourcesContent":["import type { RichTextBodyOption } from './body/serializeBody'\nimport { makeRenderBody } from './body/serializeBody'\nimport type { ActionRunArgs } from './defineAction'\nimport type { ActionRegistry } from './registry'\n\n/** A stored action instance from the form's `actions` blocks array. */\nexport type ActionInstance = { blockType: string; [key: string]: unknown }\n\nexport type ActionResult = { type: string; ok: boolean; error?: string }\n\nexport type RunActionsArgs = Omit<ActionRunArgs, 'config' | 'renderBody'> & {\n\tactions: ActionInstance[]\n\tregistry: ActionRegistry\n\trichText?: RichTextBodyOption\n}\n\n/**\n * Run each configured action, isolating failures: a throwing action is captured as a failed result\n * and never breaks the others or the caller.\n */\nexport const runActions = async (args: RunActionsArgs): Promise<ActionResult[]> => {\n\tconst { actions, registry, richText, ...ctx } = args\n\tconst renderBody = makeRenderBody({ values: ctx.values, descriptors: ctx.descriptors, richText })\n\tconst results: ActionResult[] = []\n\tfor (const instance of actions) {\n\t\tconst definition = registry.get(instance.blockType)\n\t\tif (!definition) {\n\t\t\tcontinue\n\t\t}\n\t\ttry {\n\t\t\tawait definition.run({ ...ctx, renderBody, config: instance })\n\t\t\tresults.push({ type: instance.blockType, ok: true })\n\t\t} catch (error) {\n\t\t\tresults.push({\n\t\t\t\ttype: instance.blockType,\n\t\t\t\tok: false,\n\t\t\t\terror: error instanceof Error ? error.message : String(error),\n\t\t\t})\n\t\t}\n\t}\n\treturn results\n}\n"],"mappings":";;;;;;AAoBA,MAAa,aAAa,OAAO,SAAkD;CAClF,MAAM,EAAE,SAAS,UAAU,UAAU,GAAG,QAAQ;CAChD,MAAM,aAAa,eAAe;EAAE,QAAQ,IAAI;EAAQ,aAAa,IAAI;EAAa;CAAS,CAAC;CAChG,MAAM,UAA0B,CAAC;CACjC,KAAK,MAAM,YAAY,SAAS;EAC/B,MAAM,aAAa,SAAS,IAAI,SAAS,SAAS;EAClD,IAAI,CAAC,YACJ;EAED,IAAI;GACH,MAAM,WAAW,IAAI;IAAE,GAAG;IAAK;IAAY,QAAQ;GAAS,CAAC;GAC7D,QAAQ,KAAK;IAAE,MAAM,SAAS;IAAW,IAAI;GAAK,CAAC;EACpD,SAAS,OAAO;GACf,QAAQ,KAAK;IACZ,MAAM,SAAS;IACf,IAAI;IACJ,OAAO,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;GAC7D,CAAC;EACF;CACD;CACA,OAAO;AACR"}
1
+ {"version":3,"file":"runActions.js","names":[],"sources":["../../src/actions/runActions.ts"],"sourcesContent":["import type { RichTextBodyOption } from './body/serializeBody'\nimport { makeRenderBody } from './body/serializeBody'\nimport type { ActionRunArgs } from './defineAction'\nimport type { ActionRegistry } from './registry'\n\n/** A stored action instance from the form's `actions` blocks array. */\nexport type ActionInstance = { blockType: string; [key: string]: unknown }\n\nexport type ActionResult = { type: string; ok: boolean; error?: string }\n\nexport type RunActionsArgs = Omit<ActionRunArgs, 'config' | 'renderBody'> & {\n\tactions: ActionInstance[]\n\tregistry: ActionRegistry\n\trichText?: RichTextBodyOption\n}\n\n/**\n * Run each configured action, isolating failures: a throwing action is captured as a failed result\n * and never breaks the others or the caller.\n */\nexport const runActions = async (args: RunActionsArgs): Promise<ActionResult[]> => {\n\tconst { actions, registry, richText, ...ctx } = args\n\tconst renderBody = makeRenderBody({\n\t\tvalues: ctx.values,\n\t\tdescriptors: ctx.descriptors,\n\t\tform: ctx.form,\n\t\treq: ctx.req,\n\t\trichText,\n\t})\n\tconst results: ActionResult[] = []\n\tfor (const instance of actions) {\n\t\tconst definition = registry.get(instance.blockType)\n\t\tif (!definition) {\n\t\t\tcontinue\n\t\t}\n\t\ttry {\n\t\t\tawait definition.run({ ...ctx, renderBody, config: instance })\n\t\t\tresults.push({ type: instance.blockType, ok: true })\n\t\t} catch (error) {\n\t\t\tresults.push({\n\t\t\t\ttype: instance.blockType,\n\t\t\t\tok: false,\n\t\t\t\terror: error instanceof Error ? error.message : String(error),\n\t\t\t})\n\t\t}\n\t}\n\treturn results\n}\n"],"mappings":";;;;;;AAoBA,MAAa,aAAa,OAAO,SAAkD;CAClF,MAAM,EAAE,SAAS,UAAU,UAAU,GAAG,QAAQ;CAChD,MAAM,aAAa,eAAe;EACjC,QAAQ,IAAI;EACZ,aAAa,IAAI;EACjB,MAAM,IAAI;EACV,KAAK,IAAI;EACT;CACD,CAAC;CACD,MAAM,UAA0B,CAAC;CACjC,KAAK,MAAM,YAAY,SAAS;EAC/B,MAAM,aAAa,SAAS,IAAI,SAAS,SAAS;EAClD,IAAI,CAAC,YACJ;EAED,IAAI;GACH,MAAM,WAAW,IAAI;IAAE,GAAG;IAAK;IAAY,QAAQ;GAAS,CAAC;GAC7D,QAAQ,KAAK;IAAE,MAAM,SAAS;IAAW,IAAI;GAAK,CAAC;EACpD,SAAS,OAAO;GACf,QAAQ,KAAK;IACZ,MAAM,SAAS;IACf,IAAI;IACJ,OAAO,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;GAC7D,CAAC;EACF;CACD;CACA,OAAO;AACR"}
@@ -2,12 +2,26 @@ import { FieldAggregation } from "./types.js";
2
2
  import { Payload, PayloadRequest } from "payload";
3
3
 
4
4
  //#region src/aggregation/resolveResultsRequest.d.ts
5
+ type FormResultsAccessArgs = {
6
+ req: PayloadRequest; /** The loaded forms document (depth 0). Untyped beyond `id`: hosts read their own fields (e.g. `form.tenant`). */
7
+ form: {
8
+ id: number | string;
9
+ } & Record<string, unknown>;
10
+ };
11
+ /**
12
+ * Host seam gating anonymous results reads, evaluated after the form is loaded and before anything
13
+ * is aggregated. Multi-tenant recipe: compare `form.tenant` against the tenant derived from `req`
14
+ * (host header, cookie, or auth context) and return `false` for a cross-tenant read. Authenticated
15
+ * callers bypass this seam (they are admin-trusted, like the rest of the results endpoint).
16
+ */
17
+ type FormResultsAccess = (args: FormResultsAccessArgs) => boolean | Promise<boolean>;
5
18
  type ResolveResultsRequestArgs = {
6
19
  payload: Payload;
7
20
  formId: number | string | undefined; /** The requested field (query param). */
8
21
  field?: string; /** Whether the caller is authenticated (an admin/user). */
9
22
  isAuthed: boolean;
10
- req?: PayloadRequest;
23
+ req?: PayloadRequest; /** Optional host seam for anonymous reads; absent means plugin-default gating only. */
24
+ access?: FormResultsAccess;
11
25
  };
12
26
  type ResolveResultsRequestResult = {
13
27
  status: number;
@@ -21,12 +35,14 @@ type ResolveResultsRequestResult = {
21
35
  };
22
36
  /**
23
37
  * Authorize and resolve a poll/survey results request. Authed callers may aggregate any field (or all
24
- * enumerable fields). Anonymous callers are allowed only when the form opted in (`showResults`), and then
25
- * only for the configured `resultsField`, and only if that field is enumerable (has options) so a
26
- * misconfigured `resultsField` pointing at a free-text or PII field can never be dumped publicly. Returns
27
- * only aggregate counts, never raw submissions.
38
+ * enumerable fields) and bypass the `access` seam. Anonymous callers are allowed only when the form's
39
+ * poll is enabled, the poll's `resultsVisibility` permits it (`afterVote`: any time; `afterClose`: only
40
+ * once `closesAt` has passed), and the optional host `access` seam approves; and then only for the
41
+ * configured `poll.resultsField`, and only if that field is enumerable (has options) so a misconfigured
42
+ * `resultsField` pointing at a free-text or PII field can never be dumped publicly. Returns only
43
+ * aggregate counts, never raw submissions.
28
44
  */
29
45
  declare const resolveFormResultsRequest: (args: ResolveResultsRequestArgs) => Promise<ResolveResultsRequestResult>;
30
46
  //#endregion
31
- export { ResolveResultsRequestArgs, ResolveResultsRequestResult, resolveFormResultsRequest };
47
+ export { FormResultsAccess, FormResultsAccessArgs, ResolveResultsRequestArgs, ResolveResultsRequestResult, resolveFormResultsRequest };
32
48
  //# sourceMappingURL=resolveResultsRequest.d.ts.map
@@ -1,3 +1,4 @@
1
+ import { isPollClosed, pollConfigOf } from "../form/pollState.js";
1
2
  import { aggregateFormResponses, fieldHasOptions } from "./aggregateResponses.js";
2
3
  import { FORMS_SLUG } from "../collections/forms.js";
3
4
  //#region src/aggregation/resolveResultsRequest.ts
@@ -7,13 +8,15 @@ const forbidden = {
7
8
  };
8
9
  /**
9
10
  * Authorize and resolve a poll/survey results request. Authed callers may aggregate any field (or all
10
- * enumerable fields). Anonymous callers are allowed only when the form opted in (`showResults`), and then
11
- * only for the configured `resultsField`, and only if that field is enumerable (has options) so a
12
- * misconfigured `resultsField` pointing at a free-text or PII field can never be dumped publicly. Returns
13
- * only aggregate counts, never raw submissions.
11
+ * enumerable fields) and bypass the `access` seam. Anonymous callers are allowed only when the form's
12
+ * poll is enabled, the poll's `resultsVisibility` permits it (`afterVote`: any time; `afterClose`: only
13
+ * once `closesAt` has passed), and the optional host `access` seam approves; and then only for the
14
+ * configured `poll.resultsField`, and only if that field is enumerable (has options) so a misconfigured
15
+ * `resultsField` pointing at a free-text or PII field can never be dumped publicly. Returns only
16
+ * aggregate counts, never raw submissions.
14
17
  */
15
18
  const resolveFormResultsRequest = async (args) => {
16
- const { payload, formId, field, isAuthed, req } = args;
19
+ const { payload, formId, field, isAuthed, req, access } = args;
17
20
  if (formId == null) return {
18
21
  status: 400,
19
22
  body: { errors: [{ message: "Missing form id" }] }
@@ -32,8 +35,16 @@ const resolveFormResultsRequest = async (args) => {
32
35
  let fields;
33
36
  if (isAuthed) fields = field ? [field] : void 0;
34
37
  else {
35
- if (form.showResults !== true) return forbidden;
36
- const publicField = typeof form.resultsField === "string" && form.resultsField.length > 0 ? form.resultsField : void 0;
38
+ const poll = pollConfigOf(form.poll);
39
+ if (poll?.enabled !== true) return forbidden;
40
+ if (poll.resultsVisibility === "afterClose" && !isPollClosed(poll)) return forbidden;
41
+ if (access) {
42
+ if (!(req ? await access({
43
+ req,
44
+ form
45
+ }) : false)) return forbidden;
46
+ }
47
+ const publicField = typeof poll.resultsField === "string" && poll.resultsField.length > 0 ? poll.resultsField : void 0;
37
48
  if (!publicField) return forbidden;
38
49
  if (field && field !== publicField) return forbidden;
39
50
  const instance = (Array.isArray(form.fields) ? form.fields : []).find((entry) => entry.name === publicField);
@@ -1 +1 @@
1
- {"version":3,"file":"resolveResultsRequest.js","names":[],"sources":["../../src/aggregation/resolveResultsRequest.ts"],"sourcesContent":["import type { Payload, PayloadRequest } from 'payload'\nimport { FORMS_SLUG } from '../collections/forms'\nimport type { FormFieldInstance } from '../submissions/types'\nimport { aggregateFormResponses, fieldHasOptions } from './aggregateResponses'\nimport type { FieldAggregation } from './types'\n\nexport type ResolveResultsRequestArgs = {\n\tpayload: Payload\n\tformId: number | string | undefined\n\t/** The requested field (query param). */\n\tfield?: string\n\t/** Whether the caller is authenticated (an admin/user). */\n\tisAuthed: boolean\n\treq?: PayloadRequest\n}\n\nexport type ResolveResultsRequestResult = {\n\tstatus: number\n\tbody: { results: FieldAggregation[] } | { errors: { message: string }[] }\n}\n\nconst forbidden: ResolveResultsRequestResult = {\n\tstatus: 403,\n\tbody: { errors: [{ message: 'Forbidden' }] },\n}\n\n/**\n * Authorize and resolve a poll/survey results request. Authed callers may aggregate any field (or all\n * enumerable fields). Anonymous callers are allowed only when the form opted in (`showResults`), and then\n * only for the configured `resultsField`, and only if that field is enumerable (has options) so a\n * misconfigured `resultsField` pointing at a free-text or PII field can never be dumped publicly. Returns\n * only aggregate counts, never raw submissions.\n */\nexport const resolveFormResultsRequest = async (\n\targs: ResolveResultsRequestArgs\n): Promise<ResolveResultsRequestResult> => {\n\tconst { payload, formId, field, isAuthed, req } = args\n\tif (formId == null) {\n\t\treturn { status: 400, body: { errors: [{ message: 'Missing form id' }] } }\n\t}\n\tconst form = await payload\n\t\t.findByID({ collection: FORMS_SLUG, id: formId, depth: 0, overrideAccess: true, req })\n\t\t.catch(() => null)\n\tif (!form) {\n\t\treturn { status: 404, body: { errors: [{ message: 'Not found' }] } }\n\t}\n\n\tlet fields: string[] | undefined\n\tif (isAuthed) {\n\t\tfields = field ? [field] : undefined\n\t} else {\n\t\tif (form.showResults !== true) {\n\t\t\treturn forbidden\n\t\t}\n\t\tconst publicField =\n\t\t\ttypeof form.resultsField === 'string' && form.resultsField.length > 0\n\t\t\t\t? form.resultsField\n\t\t\t\t: undefined\n\t\tif (!publicField) {\n\t\t\treturn forbidden\n\t\t}\n\t\tif (field && field !== publicField) {\n\t\t\treturn forbidden\n\t\t}\n\t\tconst instances = Array.isArray(form.fields) ? (form.fields as FormFieldInstance[]) : []\n\t\tconst instance = instances.find((entry) => entry.name === publicField)\n\t\tif (!instance || !fieldHasOptions(instance)) {\n\t\t\treturn forbidden\n\t\t}\n\t\tfields = [publicField]\n\t}\n\n\tconst results = await aggregateFormResponses({ payload, formId, fields, req })\n\treturn { status: 200, body: { results } }\n}\n"],"mappings":";;;AAqBA,MAAM,YAAyC;CAC9C,QAAQ;CACR,MAAM,EAAE,QAAQ,CAAC,EAAE,SAAS,YAAY,CAAC,EAAE;AAC5C;;;;;;;;AASA,MAAa,4BAA4B,OACxC,SAC0C;CAC1C,MAAM,EAAE,SAAS,QAAQ,OAAO,UAAU,QAAQ;CAClD,IAAI,UAAU,MACb,OAAO;EAAE,QAAQ;EAAK,MAAM,EAAE,QAAQ,CAAC,EAAE,SAAS,kBAAkB,CAAC,EAAE;CAAE;CAE1E,MAAM,OAAO,MAAM,QACjB,SAAS;EAAE,YAAY;EAAY,IAAI;EAAQ,OAAO;EAAG,gBAAgB;EAAM;CAAI,CAAC,EACpF,YAAY,IAAI;CAClB,IAAI,CAAC,MACJ,OAAO;EAAE,QAAQ;EAAK,MAAM,EAAE,QAAQ,CAAC,EAAE,SAAS,YAAY,CAAC,EAAE;CAAE;CAGpE,IAAI;CACJ,IAAI,UACH,SAAS,QAAQ,CAAC,KAAK,IAAI,KAAA;MACrB;EACN,IAAI,KAAK,gBAAgB,MACxB,OAAO;EAER,MAAM,cACL,OAAO,KAAK,iBAAiB,YAAY,KAAK,aAAa,SAAS,IACjE,KAAK,eACL,KAAA;EACJ,IAAI,CAAC,aACJ,OAAO;EAER,IAAI,SAAS,UAAU,aACtB,OAAO;EAGR,MAAM,YADY,MAAM,QAAQ,KAAK,MAAM,IAAK,KAAK,SAAiC,CAAC,GAC5D,MAAM,UAAU,MAAM,SAAS,WAAW;EACrE,IAAI,CAAC,YAAY,CAAC,gBAAgB,QAAQ,GACzC,OAAO;EAER,SAAS,CAAC,WAAW;CACtB;CAGA,OAAO;EAAE,QAAQ;EAAK,MAAM,EAAE,SAAA,MADR,uBAAuB;GAAE;GAAS;GAAQ;GAAQ;EAAI,CAAC,EACvC;CAAE;AACzC"}
1
+ {"version":3,"file":"resolveResultsRequest.js","names":[],"sources":["../../src/aggregation/resolveResultsRequest.ts"],"sourcesContent":["import type { Payload, PayloadRequest } from 'payload'\nimport { FORMS_SLUG } from '../collections/forms'\nimport { isPollClosed, pollConfigOf } from '../form/pollState'\nimport type { FormFieldInstance } from '../submissions/types'\nimport { aggregateFormResponses, fieldHasOptions } from './aggregateResponses'\nimport type { FieldAggregation } from './types'\n\nexport type FormResultsAccessArgs = {\n\treq: PayloadRequest\n\t/** The loaded forms document (depth 0). Untyped beyond `id`: hosts read their own fields (e.g. `form.tenant`). */\n\tform: { id: number | string } & Record<string, unknown>\n}\n\n/**\n * Host seam gating anonymous results reads, evaluated after the form is loaded and before anything\n * is aggregated. Multi-tenant recipe: compare `form.tenant` against the tenant derived from `req`\n * (host header, cookie, or auth context) and return `false` for a cross-tenant read. Authenticated\n * callers bypass this seam (they are admin-trusted, like the rest of the results endpoint).\n */\nexport type FormResultsAccess = (args: FormResultsAccessArgs) => boolean | Promise<boolean>\n\nexport type ResolveResultsRequestArgs = {\n\tpayload: Payload\n\tformId: number | string | undefined\n\t/** The requested field (query param). */\n\tfield?: string\n\t/** Whether the caller is authenticated (an admin/user). */\n\tisAuthed: boolean\n\treq?: PayloadRequest\n\t/** Optional host seam for anonymous reads; absent means plugin-default gating only. */\n\taccess?: FormResultsAccess\n}\n\nexport type ResolveResultsRequestResult = {\n\tstatus: number\n\tbody: { results: FieldAggregation[] } | { errors: { message: string }[] }\n}\n\nconst forbidden: ResolveResultsRequestResult = {\n\tstatus: 403,\n\tbody: { errors: [{ message: 'Forbidden' }] },\n}\n\n/**\n * Authorize and resolve a poll/survey results request. Authed callers may aggregate any field (or all\n * enumerable fields) and bypass the `access` seam. Anonymous callers are allowed only when the form's\n * poll is enabled, the poll's `resultsVisibility` permits it (`afterVote`: any time; `afterClose`: only\n * once `closesAt` has passed), and the optional host `access` seam approves; and then only for the\n * configured `poll.resultsField`, and only if that field is enumerable (has options) so a misconfigured\n * `resultsField` pointing at a free-text or PII field can never be dumped publicly. Returns only\n * aggregate counts, never raw submissions.\n */\nexport const resolveFormResultsRequest = async (\n\targs: ResolveResultsRequestArgs\n): Promise<ResolveResultsRequestResult> => {\n\tconst { payload, formId, field, isAuthed, req, access } = args\n\tif (formId == null) {\n\t\treturn { status: 400, body: { errors: [{ message: 'Missing form id' }] } }\n\t}\n\tconst form = await payload\n\t\t.findByID({ collection: FORMS_SLUG, id: formId, depth: 0, overrideAccess: true, req })\n\t\t.catch(() => null)\n\tif (!form) {\n\t\treturn { status: 404, body: { errors: [{ message: 'Not found' }] } }\n\t}\n\n\tlet fields: string[] | undefined\n\tif (isAuthed) {\n\t\tfields = field ? [field] : undefined\n\t} else {\n\t\tconst poll = pollConfigOf(form.poll)\n\t\tif (poll?.enabled !== true) {\n\t\t\treturn forbidden\n\t\t}\n\t\tif (poll.resultsVisibility === 'afterClose' && !isPollClosed(poll)) {\n\t\t\treturn forbidden\n\t\t}\n\t\tif (access) {\n\t\t\t// No req means the seam cannot be evaluated; fail closed rather than skip a configured gate.\n\t\t\t// The concrete generated Form doc has no index signature; the seam's Record<string, unknown>\n\t\t\t// is the ergonomic host-facing contract, so widen the doc to it here.\n\t\t\tconst allowed = req\n\t\t\t\t? await access({ req, form: form as unknown as FormResultsAccessArgs['form'] })\n\t\t\t\t: false\n\t\t\tif (!allowed) {\n\t\t\t\treturn forbidden\n\t\t\t}\n\t\t}\n\t\tconst publicField =\n\t\t\ttypeof poll.resultsField === 'string' && poll.resultsField.length > 0\n\t\t\t\t? poll.resultsField\n\t\t\t\t: undefined\n\t\tif (!publicField) {\n\t\t\treturn forbidden\n\t\t}\n\t\tif (field && field !== publicField) {\n\t\t\treturn forbidden\n\t\t}\n\t\tconst instances = Array.isArray(form.fields) ? (form.fields as FormFieldInstance[]) : []\n\t\tconst instance = instances.find((entry) => entry.name === publicField)\n\t\tif (!instance || !fieldHasOptions(instance)) {\n\t\t\treturn forbidden\n\t\t}\n\t\tfields = [publicField]\n\t}\n\n\tconst results = await aggregateFormResponses({ payload, formId, fields, req })\n\treturn { status: 200, body: { results } }\n}\n"],"mappings":";;;;AAsCA,MAAM,YAAyC;CAC9C,QAAQ;CACR,MAAM,EAAE,QAAQ,CAAC,EAAE,SAAS,YAAY,CAAC,EAAE;AAC5C;;;;;;;;;;AAWA,MAAa,4BAA4B,OACxC,SAC0C;CAC1C,MAAM,EAAE,SAAS,QAAQ,OAAO,UAAU,KAAK,WAAW;CAC1D,IAAI,UAAU,MACb,OAAO;EAAE,QAAQ;EAAK,MAAM,EAAE,QAAQ,CAAC,EAAE,SAAS,kBAAkB,CAAC,EAAE;CAAE;CAE1E,MAAM,OAAO,MAAM,QACjB,SAAS;EAAE,YAAY;EAAY,IAAI;EAAQ,OAAO;EAAG,gBAAgB;EAAM;CAAI,CAAC,EACpF,YAAY,IAAI;CAClB,IAAI,CAAC,MACJ,OAAO;EAAE,QAAQ;EAAK,MAAM,EAAE,QAAQ,CAAC,EAAE,SAAS,YAAY,CAAC,EAAE;CAAE;CAGpE,IAAI;CACJ,IAAI,UACH,SAAS,QAAQ,CAAC,KAAK,IAAI,KAAA;MACrB;EACN,MAAM,OAAO,aAAa,KAAK,IAAI;EACnC,IAAI,MAAM,YAAY,MACrB,OAAO;EAER,IAAI,KAAK,sBAAsB,gBAAgB,CAAC,aAAa,IAAI,GAChE,OAAO;EAER,IAAI;OAOC,EAHY,MACb,MAAM,OAAO;IAAE;IAAW;GAAiD,CAAC,IAC5E,QAEF,OAAO;EAAA;EAGT,MAAM,cACL,OAAO,KAAK,iBAAiB,YAAY,KAAK,aAAa,SAAS,IACjE,KAAK,eACL,KAAA;EACJ,IAAI,CAAC,aACJ,OAAO;EAER,IAAI,SAAS,UAAU,aACtB,OAAO;EAGR,MAAM,YADY,MAAM,QAAQ,KAAK,MAAM,IAAK,KAAK,SAAiC,CAAC,GAC5D,MAAM,UAAU,MAAM,SAAS,WAAW;EACrE,IAAI,CAAC,YAAY,CAAC,gBAAgB,QAAQ,GACzC,OAAO;EAER,SAAS,CAAC,WAAW;CACtB;CAGA,OAAO;EAAE,QAAQ;EAAK,MAAM,EAAE,SAAA,MADR,uBAAuB;GAAE;GAAS;GAAQ;GAAQ;EAAI,CAAC,EACvC;CAAE;AACzC"}