@10x-media/form-builder 0.1.0-beta.6 → 0.1.0-beta.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +73 -0
- package/dist/actions/buildActionBlocks.js +12 -9
- package/dist/actions/buildActionBlocks.js.map +1 -1
- package/dist/actions/builtin/confirmation.js +13 -68
- package/dist/actions/builtin/confirmation.js.map +1 -1
- package/dist/actions/builtin/emailAction.d.ts +17 -0
- package/dist/actions/builtin/emailAction.js +83 -0
- package/dist/actions/builtin/emailAction.js.map +1 -0
- package/dist/actions/builtin/emailTeam.js +14 -74
- package/dist/actions/builtin/emailTeam.js.map +1 -1
- package/dist/actions/builtin/index.d.ts +9 -12
- package/dist/actions/builtin/index.js +11 -11
- package/dist/actions/builtin/index.js.map +1 -1
- package/dist/actions/defineAction.d.ts +2 -2
- package/dist/actions/defineAction.js.map +1 -1
- package/dist/actions/emailRecipients.js +75 -31
- package/dist/actions/emailRecipients.js.map +1 -1
- package/dist/actions/registry.d.ts +2 -1
- package/dist/actions/registry.js +2 -6
- package/dist/actions/registry.js.map +1 -1
- package/dist/actions/task.js +5 -3
- package/dist/actions/task.js.map +1 -1
- package/dist/calc/evaluate.js +1 -2
- package/dist/calc/evaluate.js.map +1 -1
- package/dist/calc/normalizeCalc.js +1 -2
- package/dist/calc/normalizeCalc.js.map +1 -1
- package/dist/calc/types.js.map +1 -1
- package/dist/collections/buttonFields.js +1 -1
- package/dist/collections/formSubmissions.js +2 -1
- package/dist/collections/formSubmissions.js.map +1 -1
- package/dist/collections/forms.js +30 -99
- package/dist/collections/forms.js.map +1 -1
- package/dist/collections/formsEndpoints.js +101 -0
- package/dist/collections/formsEndpoints.js.map +1 -0
- package/dist/conditions/evaluate.js +1 -1
- package/dist/conditions/evaluate.js.map +1 -1
- package/dist/conditions/fieldTypes.d.ts +3 -1
- package/dist/conditions/fieldTypes.js +4 -1
- package/dist/conditions/fieldTypes.js.map +1 -1
- package/dist/conditions/normalizeConditions.js +18 -5
- package/dist/conditions/normalizeConditions.js.map +1 -1
- package/dist/consent/captureConsent.d.ts +7 -0
- package/dist/consent/captureConsent.js +7 -0
- package/dist/consent/captureConsent.js.map +1 -1
- package/dist/consent/consentSourcesField.d.ts +4 -0
- package/dist/consent/consentSourcesField.js +5 -1
- package/dist/consent/consentSourcesField.js.map +1 -1
- package/dist/consent/resolveConsentEntries.d.ts +1 -1
- package/dist/consent/resolveConsentEntries.js +1 -4
- package/dist/consent/resolveConsentEntries.js.map +1 -1
- package/dist/consent/resolveConsentSourcesRequest.js +0 -1
- package/dist/consent/resolveConsentSourcesRequest.js.map +1 -1
- package/dist/consent/resolveConsentStatements.d.ts +1 -1
- package/dist/consent/resolveConsentStatements.js.map +1 -1
- package/dist/consent/types.d.ts +9 -1
- package/dist/email/departmentsField.js +1 -1
- package/dist/exports/react.d.ts +3 -2
- package/dist/exports/react.js +3 -2
- package/dist/exports/rsc.js +1 -1
- package/dist/exports/types.d.ts +1 -1
- package/dist/fields/builtin/email.js +1 -1
- package/dist/fields/builtin/email.js.map +1 -1
- package/dist/fields/builtin/message.js +1 -1
- package/dist/fields/builtin/repeater.js +1 -1
- package/dist/fields/builtin/select.js +1 -1
- package/dist/fields/registry.d.ts +2 -1
- package/dist/fields/registry.js +2 -6
- package/dist/fields/registry.js.map +1 -1
- package/dist/fields/sharedConfig.js +1 -1
- package/dist/flow/normalizeFlow.js +7 -3
- package/dist/flow/normalizeFlow.js.map +1 -1
- package/dist/index.d.ts +4 -201
- package/dist/index.js +11 -4
- package/dist/index.js.map +1 -1
- package/dist/options.d.ts +228 -0
- package/dist/plugin/applyRegistryConfig.d.ts +11 -0
- package/dist/plugin/applyRegistryConfig.js +20 -0
- package/dist/plugin/applyRegistryConfig.js.map +1 -0
- package/dist/plugin/registerCollections.js +2 -1
- package/dist/plugin/registerCollections.js.map +1 -1
- package/dist/poll/buildPollOptionSourceFields.js +2 -2
- package/dist/poll/buildPollOptionSourceFields.js.map +1 -1
- package/dist/poll/definePollOptionSource.d.ts +2 -2
- package/dist/poll/definePollOptionSource.js.map +1 -1
- package/dist/poll/pollTypeRegistry.js +2 -4
- package/dist/poll/pollTypeRegistry.js.map +1 -1
- package/dist/poll/registry.d.ts +2 -1
- package/dist/poll/registry.js +2 -6
- package/dist/poll/registry.js.map +1 -1
- package/dist/poll/resolvePollOptionsRequest.js +0 -1
- package/dist/poll/resolvePollOptionsRequest.js.map +1 -1
- package/dist/prefill/valuesFromSearchParams.js +2 -7
- package/dist/prefill/valuesFromSearchParams.js.map +1 -1
- package/dist/react/Form.d.ts +3 -1
- package/dist/react/Form.js +93 -155
- package/dist/react/Form.js.map +1 -1
- package/dist/react/FormContext.d.ts +5 -1
- package/dist/react/FormContext.js.map +1 -1
- package/dist/react/FormFields.d.ts +18 -0
- package/dist/react/FormFields.js +114 -0
- package/dist/react/FormFields.js.map +1 -0
- package/dist/react/FormLayout.d.ts +4 -2
- package/dist/react/FormLayout.js +3 -2
- package/dist/react/FormLayout.js.map +1 -1
- package/dist/react/Poll.js +13 -3
- package/dist/react/Poll.js.map +1 -1
- package/dist/react/presentation/DialogSurface.js +1 -4
- package/dist/react/presentation/DialogSurface.js.map +1 -1
- package/dist/react/renderers/calculation.js +1 -3
- package/dist/react/renderers/calculation.js.map +1 -1
- package/dist/react/renderers/checkbox.js +1 -3
- package/dist/react/renderers/checkbox.js.map +1 -1
- package/dist/react/renderers/consent.js +2 -3
- package/dist/react/renderers/consent.js.map +1 -1
- package/dist/react/renderers/date.js +1 -3
- package/dist/react/renderers/date.js.map +1 -1
- package/dist/react/renderers/email.js +1 -3
- package/dist/react/renderers/email.js.map +1 -1
- package/dist/react/renderers/file.js +6 -7
- package/dist/react/renderers/file.js.map +1 -1
- package/dist/react/renderers/number.js +1 -3
- package/dist/react/renderers/number.js.map +1 -1
- package/dist/react/renderers/region.js +1 -3
- package/dist/react/renderers/region.js.map +1 -1
- package/dist/react/renderers/repeater.js +20 -4
- package/dist/react/renderers/repeater.js.map +1 -1
- package/dist/react/renderers/select.js +1 -3
- package/dist/react/renderers/select.js.map +1 -1
- package/dist/react/renderers/text.js +1 -3
- package/dist/react/renderers/text.js.map +1 -1
- package/dist/react/renderers/textarea.js +1 -3
- package/dist/react/renderers/textarea.js.map +1 -1
- package/dist/spam/constants.js +7 -1
- package/dist/spam/constants.js.map +1 -1
- package/dist/spam/resolveSpam.js +2 -1
- package/dist/spam/resolveSpam.js.map +1 -1
- package/dist/spam/spamGuard.js +22 -6
- package/dist/spam/spamGuard.js.map +1 -1
- package/dist/spam/types.d.ts +17 -2
- package/dist/submissions/coerceBoolean.js +14 -0
- package/dist/submissions/coerceBoolean.js.map +1 -0
- package/dist/submissions/runSubmission.js +7 -7
- package/dist/submissions/runSubmission.js.map +1 -1
- package/dist/submissions/validateSubmission.js +3 -2
- package/dist/submissions/validateSubmission.js.map +1 -1
- package/dist/translations/de.js +10 -0
- package/dist/translations/de.js.map +1 -1
- package/dist/translations/en.js +10 -0
- package/dist/translations/en.js.map +1 -1
- package/dist/translations/keys.d.ts +10 -0
- package/dist/translations/keys.js +10 -0
- package/dist/translations/keys.js.map +1 -1
- package/dist/translations/server.js +8 -1
- package/dist/translations/server.js.map +1 -1
- package/dist/uploads/captureFileRef.d.ts +10 -3
- package/dist/uploads/captureFileRef.js +18 -9
- package/dist/uploads/captureFileRef.js.map +1 -1
- package/dist/validation/builtin/email.js +1 -1
- package/dist/validation/builtin/email.js.map +1 -1
- package/dist/validation/builtin/notAlreadySubmitted.js +1 -0
- package/dist/validation/builtin/notAlreadySubmitted.js.map +1 -1
- package/dist/validation/emailPattern.js +10 -0
- package/dist/validation/emailPattern.js.map +1 -0
- package/dist/validation/registry.d.ts +2 -1
- package/dist/validation/registry.js +2 -9
- package/dist/validation/registry.js.map +1 -1
- package/package.json +4 -4
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { fieldNames, fieldNamesOfType } from "../fields/fieldNamesOfType.js";
|
|
2
|
-
import { localizedIf } from "../fields/localizedIf.js";
|
|
3
|
-
import { interpolate } from "../recall/interpolate.js";
|
|
4
2
|
import { keys } from "../translations/keys.js";
|
|
5
3
|
import { asTranslate, labelFor } from "../translations/server.js";
|
|
4
|
+
import { localizedIf } from "../fields/localizedIf.js";
|
|
5
|
+
import { interpolate } from "../recall/interpolate.js";
|
|
6
6
|
//#region src/actions/emailRecipients.ts
|
|
7
7
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
8
8
|
const TOKEN_RE = /^\{\{\s*([\w.-]+)\s*\}\}$/;
|
|
@@ -15,58 +15,102 @@ const parseFieldToken = (value) => {
|
|
|
15
15
|
};
|
|
16
16
|
const toList = (value) => Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : typeof value === "string" && value.length > 0 ? [value] : [];
|
|
17
17
|
/**
|
|
18
|
+
* The first plausible email in a resolved value, or empty. A recipient entry names a single address,
|
|
19
|
+
* so this drops anything past a separator or CR/LF that a `{{field}}` token might interpolate from
|
|
20
|
+
* visitor input, closing SMTP header injection and extra-recipient injection.
|
|
21
|
+
*/
|
|
22
|
+
const firstAddress = (value) => value.split(/[,;\n\r]+/).map((part) => part.trim()).find(isPlausibleEmail) ?? "";
|
|
23
|
+
/**
|
|
18
24
|
* Resolve a stored recipient value (a `string[]`, or a legacy single string) to the comma-separated
|
|
19
25
|
* list `payload.sendEmail` accepts: each entry is interpolated (a `{{field}}` token resolves from the
|
|
20
|
-
* submission, a plain email passes through),
|
|
26
|
+
* submission, a plain email passes through), sanitized to a single address, empties dropped, joined.
|
|
21
27
|
*/
|
|
22
|
-
const resolveRecipients = (value, resolve) => toList(value).map((entry) => interpolate(entry, resolve)
|
|
28
|
+
const resolveRecipients = (value, resolve) => toList(value).map((entry) => firstAddress(interpolate(entry, resolve))).filter((entry) => entry.length > 0).join(", ");
|
|
23
29
|
/**
|
|
24
30
|
* Field `validate` for a recipient list: unset is fine; otherwise every entry must be a valid email
|
|
25
|
-
* or a `{{field}}` token naming an existing field of an allowed token type
|
|
26
|
-
*
|
|
31
|
+
* or a `{{field}}` token naming an existing field of an allowed token type. When `allowCustom` is
|
|
32
|
+
* false, a non-token entry must additionally be a member of the resolved options (`resolveAllowed`,
|
|
33
|
+
* e.g. the department addresses), matching the client constraint on the server. Fails closed if the
|
|
34
|
+
* options resolver throws (mirroring the `from` field). Reads the form's `fields` off `data`. Like
|
|
35
|
+
* `from`, Payload runs this on every save, so the resolver is consulted each save under
|
|
36
|
+
* `allowCustom: false`; `buildRecipientField` memoizes it per request so all recipient fields share one call.
|
|
27
37
|
*/
|
|
28
|
-
const validateRecipients = (
|
|
38
|
+
const validateRecipients = (opts) => async (value, { data, req }) => {
|
|
29
39
|
const list = toList(value);
|
|
30
40
|
if (list.length === 0) return true;
|
|
31
41
|
const fields = data && typeof data === "object" ? data.fields : void 0;
|
|
32
|
-
const
|
|
42
|
+
const { tokenFieldTypes, allowCustom, fieldTokens, resolveAllowed } = opts;
|
|
43
|
+
const allowedFields = new Set(tokenFieldTypes && tokenFieldTypes.length > 0 ? fieldNamesOfType(fields, tokenFieldTypes) : fieldNames(fields));
|
|
44
|
+
let allowedValues;
|
|
45
|
+
if (allowCustom === false) if (resolveAllowed) try {
|
|
46
|
+
allowedValues = await resolveAllowed(req);
|
|
47
|
+
} catch {
|
|
48
|
+
return asTranslate(req.t)(keys.validationRecipientOptionsUnavailable);
|
|
49
|
+
}
|
|
50
|
+
else allowedValues = /* @__PURE__ */ new Set();
|
|
33
51
|
for (const entry of list) {
|
|
34
52
|
const token = parseFieldToken(entry);
|
|
35
53
|
if (token) {
|
|
36
|
-
if (
|
|
54
|
+
if (fieldTokens === false) return asTranslate(req.t)(keys.validationRecipientNotAllowed);
|
|
55
|
+
if (!allowedFields.has(token)) return asTranslate(req.t)(keys.validationRecipientUnknownField);
|
|
37
56
|
continue;
|
|
38
57
|
}
|
|
39
58
|
if (!isPlausibleEmail(entry)) return asTranslate(req.t)(keys.validationRecipientInvalid);
|
|
59
|
+
if (allowCustom === false && !allowedValues?.has(entry.trim().toLowerCase())) return asTranslate(req.t)(keys.validationRecipientNotAllowed);
|
|
40
60
|
}
|
|
41
61
|
return true;
|
|
42
62
|
};
|
|
43
63
|
const RECIPIENTS_FIELD_REF = "@10x-media/form-builder/client#RecipientsSelect";
|
|
64
|
+
const DEPARTMENT_ALLOWED_CACHE = "formBuilderDepartmentAllowedSet";
|
|
65
|
+
/**
|
|
66
|
+
* The department option values as a lowercased Set, memoized on `req.context` so every recipient
|
|
67
|
+
* field validated in one save shares a single `departments` call. Payload validates the fields
|
|
68
|
+
* concurrently, so the in-flight promise is cached synchronously (not the resolved value) to avoid a
|
|
69
|
+
* resolve-per-field race. Lowercased because email delivery ignores case, matching the client dedupe.
|
|
70
|
+
*/
|
|
71
|
+
const resolveDepartmentAllowed = (req, departments) => {
|
|
72
|
+
const cached = req.context?.[DEPARTMENT_ALLOWED_CACHE];
|
|
73
|
+
if (cached instanceof Promise) return cached;
|
|
74
|
+
const promise = Promise.resolve().then(() => departments({ req })).then((options) => new Set(options.map((option) => option.value.trim().toLowerCase())));
|
|
75
|
+
if (req.context) req.context[DEPARTMENT_ALLOWED_CACHE] = promise;
|
|
76
|
+
return promise;
|
|
77
|
+
};
|
|
44
78
|
/**
|
|
45
79
|
* A `text hasMany` field rendered by `RecipientsSelect`, used for every email address list. `endpoint`
|
|
46
80
|
* (when set) supplies preset options (e.g. the host's departments); `recipients` narrows the field's
|
|
47
|
-
* behavior; `width` sets `admin.width` so a pair can share a row
|
|
81
|
+
* behavior; `width` sets `admin.width` so a pair can share a row; `departments` (when set) backs
|
|
82
|
+
* server-side `allowCustom: false` enforcement with the resolved option values.
|
|
48
83
|
*/
|
|
49
|
-
const buildRecipientField = (name, labelKey, localize, opts = {}) =>
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
84
|
+
const buildRecipientField = (name, labelKey, localize, opts = {}) => {
|
|
85
|
+
const { departments } = opts;
|
|
86
|
+
const resolveAllowed = departments ? (req) => resolveDepartmentAllowed(req, departments) : void 0;
|
|
87
|
+
return {
|
|
88
|
+
name,
|
|
89
|
+
type: "text",
|
|
90
|
+
hasMany: true,
|
|
91
|
+
label: labelFor(labelKey),
|
|
92
|
+
validate: validateRecipients({
|
|
93
|
+
tokenFieldTypes: opts.recipients?.tokenFieldTypes ?? ["email"],
|
|
94
|
+
allowCustom: opts.recipients?.allowCustom,
|
|
95
|
+
fieldTokens: opts.recipients?.fieldTokens,
|
|
96
|
+
resolveAllowed
|
|
97
|
+
}),
|
|
98
|
+
...localizedIf(localize),
|
|
99
|
+
admin: {
|
|
100
|
+
...opts.width ? { width: opts.width } : {},
|
|
101
|
+
components: { Field: {
|
|
102
|
+
path: RECIPIENTS_FIELD_REF,
|
|
103
|
+
clientProps: {
|
|
104
|
+
...opts.endpoint ? { endpoint: opts.endpoint } : {},
|
|
105
|
+
...opts.recipients?.allowCustom === false ? { allowCustom: false } : {},
|
|
106
|
+
...opts.recipients?.fieldTokens === false ? { fieldTokens: false } : {},
|
|
107
|
+
...opts.recipients?.tokenFieldTypes ? { tokenFieldTypes: opts.recipients.tokenFieldTypes } : {}
|
|
108
|
+
}
|
|
109
|
+
} }
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
};
|
|
69
113
|
//#endregion
|
|
70
|
-
export { buildRecipientField, isPlausibleEmail, resolveRecipients };
|
|
114
|
+
export { buildRecipientField, firstAddress, isPlausibleEmail, resolveRecipients };
|
|
71
115
|
|
|
72
116
|
//# sourceMappingURL=emailRecipients.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"emailRecipients.js","names":[],"sources":["../../src/actions/emailRecipients.ts"],"sourcesContent":["import type { PayloadRequest, TextField } from 'payload'\nimport { fieldNames, 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'\n\nconst EMAIL_RE = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\nconst TOKEN_RE = /^\\{\\{\\s*([\\w.-]+)\\s*\\}\\}$/\n\n/** A plausible email address (a permissive shape check, not full RFC validation). */\nexport const isPlausibleEmail = (value: string): boolean => EMAIL_RE.test(value.trim())\n\n/** The field name inside a `{{name}}` recipient token, or undefined when the string is not a token. */\nexport const parseFieldToken = (value: string): string | undefined => {\n\tconst match = TOKEN_RE.exec(value.trim())\n\treturn match ? match[1] : undefined\n}\n\nexport const isFieldToken = (value: string): boolean => parseFieldToken(value) !== undefined\n\nconst toList = (value: unknown): string[] =>\n\tArray.isArray(value)\n\t\t? value.filter((entry): entry is string => typeof entry === 'string')\n\t\t: typeof value === 'string' && value.length > 0\n\t\t\t? [value]\n\t\t\t: []\n\n/**\n * Resolve a stored recipient value (a `string[]`, or a legacy single string) to the comma-separated\n * list `payload.sendEmail` accepts: each entry is interpolated (a `{{field}}` token resolves from the\n * submission, a plain email passes through), trimmed, empties dropped, joined with `, `.\n */\nexport const resolveRecipients = (value: unknown, resolve: (name: string) => string): string =>\n\ttoList(value)\n\t\t.map((entry) => interpolate(entry, resolve).trim())\n\t\t.filter((entry) => entry.length > 0)\n\t\t.join(', ')\n\n/**\n * Field `validate` for a recipient list: unset is fine; otherwise every entry must be a valid email\n * or a `{{field}}` token naming an existing field of an allowed token type (`tokenFieldTypes`, or any\n * named field when empty). Reads the form's `fields` off `data`, like the confirmation `toField`.\n */\nexport const validateRecipients =\n\t(tokenFieldTypes?: string[]) =>\n\t(value: unknown, { data, req }: { data?: unknown; req: PayloadRequest }): string | true => {\n\t\tconst list = toList(value)\n\t\tif (list.length === 0) {\n\t\t\treturn true\n\t\t}\n\t\tconst fields =\n\t\t\tdata && typeof data === 'object' ? (data as Record<string, unknown>).fields : undefined\n\t\tconst allowed = new Set(\n\t\t\ttokenFieldTypes && tokenFieldTypes.length > 0\n\t\t\t\t? fieldNamesOfType(fields, tokenFieldTypes)\n\t\t\t\t: fieldNames(fields)\n\t\t)\n\t\tfor (const entry of list) {\n\t\t\tconst token = parseFieldToken(entry)\n\t\t\tif (token) {\n\t\t\t\tif (!allowed.has(token)) {\n\t\t\t\t\treturn asTranslate(req.t)(keys.validationRecipientUnknownField)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif (!isPlausibleEmail(entry)) {\n\t\t\t\treturn asTranslate(req.t)(keys.validationRecipientInvalid)\n\t\t\t}\n\t\t}\n\t\treturn true\n\t}\n\n/** Host-configurable behavior for the recipient fields (plugin option `email.recipients`). */\nexport type RecipientsConfig = {\n\t/** Allow free-typed emails (default true). */\n\tallowCustom?: boolean\n\t/** Offer the form's own fields as recipient tokens (default true). */\n\tfieldTokens?: boolean\n\t/** Field types eligible as tokens (default `['email']`). */\n\ttokenFieldTypes?: string[]\n}\n\nconst RECIPIENTS_FIELD_REF = '@10x-media/form-builder/client#RecipientsSelect'\n\n/**\n * A `text hasMany` field rendered by `RecipientsSelect`, used for every email address list. `endpoint`\n * (when set) supplies preset options (e.g. the host's departments); `recipients` narrows the field's\n * behavior; `width` sets `admin.width` so a pair can share a row.\n */\n// biome-ignore lint/complexity/useMaxParams: the field identity (name, label, localize) plus its grouped options is the minimal surface\nexport const buildRecipientField = (\n\tname: string,\n\tlabelKey: string,\n\tlocalize: boolean,\n\topts: { endpoint?: string; recipients?: RecipientsConfig; width?: string } = {}\n): TextField => ({\n\tname,\n\ttype: 'text',\n\thasMany: true,\n\tlabel: labelFor(labelKey),\n\tvalidate: validateRecipients(opts.recipients?.tokenFieldTypes ?? ['email']),\n\t...localizedIf(localize),\n\tadmin: {\n\t\t...(opts.width ? { width: opts.width } : {}),\n\t\tcomponents: {\n\t\t\tField: {\n\t\t\t\tpath: RECIPIENTS_FIELD_REF,\n\t\t\t\tclientProps: {\n\t\t\t\t\t...(opts.endpoint ? { endpoint: opts.endpoint } : {}),\n\t\t\t\t\t...(opts.recipients?.allowCustom === false ? { allowCustom: false } : {}),\n\t\t\t\t\t...(opts.recipients?.fieldTokens === false ? { fieldTokens: false } : {}),\n\t\t\t\t\t...(opts.recipients?.tokenFieldTypes\n\t\t\t\t\t\t? { tokenFieldTypes: opts.recipients.tokenFieldTypes }\n\t\t\t\t\t\t: {}),\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t},\n})\n"],"mappings":";;;;;;AAOA,MAAM,WAAW;AACjB,MAAM,WAAW;;AAGjB,MAAa,oBAAoB,UAA2B,SAAS,KAAK,MAAM,KAAK,CAAC;;AAGtF,MAAa,mBAAmB,UAAsC;CACrE,MAAM,QAAQ,SAAS,KAAK,MAAM,KAAK,CAAC;CACxC,OAAO,QAAQ,MAAM,KAAK,KAAA;AAC3B;AAIA,MAAM,UAAU,UACf,MAAM,QAAQ,KAAK,IAChB,MAAM,QAAQ,UAA2B,OAAO,UAAU,QAAQ,IAClE,OAAO,UAAU,YAAY,MAAM,SAAS,IAC3C,CAAC,KAAK,IACN,CAAC;;;;;;AAON,MAAa,qBAAqB,OAAgB,YACjD,OAAO,KAAK,EACV,KAAK,UAAU,YAAY,OAAO,OAAO,EAAE,KAAK,CAAC,EACjD,QAAQ,UAAU,MAAM,SAAS,CAAC,EAClC,KAAK,IAAI;;;;;;AAOZ,MAAa,sBACX,qBACA,OAAgB,EAAE,MAAM,UAAkE;CAC1F,MAAM,OAAO,OAAO,KAAK;CACzB,IAAI,KAAK,WAAW,GACnB,OAAO;CAER,MAAM,SACL,QAAQ,OAAO,SAAS,WAAY,KAAiC,SAAS,KAAA;CAC/E,MAAM,UAAU,IAAI,IACnB,mBAAmB,gBAAgB,SAAS,IACzC,iBAAiB,QAAQ,eAAe,IACxC,WAAW,MAAM,CACrB;CACA,KAAK,MAAM,SAAS,MAAM;EACzB,MAAM,QAAQ,gBAAgB,KAAK;EACnC,IAAI,OAAO;GACV,IAAI,CAAC,QAAQ,IAAI,KAAK,GACrB,OAAO,YAAY,IAAI,CAAC,EAAE,KAAK,+BAA+B;GAE/D;EACD;EACA,IAAI,CAAC,iBAAiB,KAAK,GAC1B,OAAO,YAAY,IAAI,CAAC,EAAE,KAAK,0BAA0B;CAE3D;CACA,OAAO;AACR;AAYD,MAAM,uBAAuB;;;;;;AAQ7B,MAAa,uBACZ,MACA,UACA,UACA,OAA6E,CAAC,OAC9D;CAChB;CACA,MAAM;CACN,SAAS;CACT,OAAO,SAAS,QAAQ;CACxB,UAAU,mBAAmB,KAAK,YAAY,mBAAmB,CAAC,OAAO,CAAC;CAC1E,GAAG,YAAY,QAAQ;CACvB,OAAO;EACN,GAAI,KAAK,QAAQ,EAAE,OAAO,KAAK,MAAM,IAAI,CAAC;EAC1C,YAAY,EACX,OAAO;GACN,MAAM;GACN,aAAa;IACZ,GAAI,KAAK,WAAW,EAAE,UAAU,KAAK,SAAS,IAAI,CAAC;IACnD,GAAI,KAAK,YAAY,gBAAgB,QAAQ,EAAE,aAAa,MAAM,IAAI,CAAC;IACvE,GAAI,KAAK,YAAY,gBAAgB,QAAQ,EAAE,aAAa,MAAM,IAAI,CAAC;IACvE,GAAI,KAAK,YAAY,kBAClB,EAAE,iBAAiB,KAAK,WAAW,gBAAgB,IACnD,CAAC;GACL;EACD,EACD;CACD;AACD"}
|
|
1
|
+
{"version":3,"file":"emailRecipients.js","names":[],"sources":["../../src/actions/emailRecipients.ts"],"sourcesContent":["import type { PayloadRequest, TextField } from 'payload'\nimport type { DepartmentEmailsResolver } from '../email/departments'\nimport { fieldNames, 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'\n\nconst EMAIL_RE = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\nconst TOKEN_RE = /^\\{\\{\\s*([\\w.-]+)\\s*\\}\\}$/\n\n/** A plausible email address (a permissive shape check, not full RFC validation). */\nexport const isPlausibleEmail = (value: string): boolean => EMAIL_RE.test(value.trim())\n\n/** The field name inside a `{{name}}` recipient token, or undefined when the string is not a token. */\nexport const parseFieldToken = (value: string): string | undefined => {\n\tconst match = TOKEN_RE.exec(value.trim())\n\treturn match ? match[1] : undefined\n}\n\nexport const isFieldToken = (value: string): boolean => parseFieldToken(value) !== undefined\n\nconst toList = (value: unknown): string[] =>\n\tArray.isArray(value)\n\t\t? value.filter((entry): entry is string => typeof entry === 'string')\n\t\t: typeof value === 'string' && value.length > 0\n\t\t\t? [value]\n\t\t\t: []\n\n/**\n * The first plausible email in a resolved value, or empty. A recipient entry names a single address,\n * so this drops anything past a separator or CR/LF that a `{{field}}` token might interpolate from\n * visitor input, closing SMTP header injection and extra-recipient injection.\n */\nexport const firstAddress = (value: string): string =>\n\tvalue\n\t\t.split(/[,;\\n\\r]+/)\n\t\t.map((part) => part.trim())\n\t\t.find(isPlausibleEmail) ?? ''\n\n/**\n * Resolve a stored recipient value (a `string[]`, or a legacy single string) to the comma-separated\n * list `payload.sendEmail` accepts: each entry is interpolated (a `{{field}}` token resolves from the\n * submission, a plain email passes through), sanitized to a single address, empties dropped, joined.\n */\nexport const resolveRecipients = (value: unknown, resolve: (name: string) => string): string =>\n\ttoList(value)\n\t\t.map((entry) => firstAddress(interpolate(entry, resolve)))\n\t\t.filter((entry) => entry.length > 0)\n\t\t.join(', ')\n\n/**\n * Field `validate` for a recipient list: unset is fine; otherwise every entry must be a valid email\n * or a `{{field}}` token naming an existing field of an allowed token type. When `allowCustom` is\n * false, a non-token entry must additionally be a member of the resolved options (`resolveAllowed`,\n * e.g. the department addresses), matching the client constraint on the server. Fails closed if the\n * options resolver throws (mirroring the `from` field). Reads the form's `fields` off `data`. Like\n * `from`, Payload runs this on every save, so the resolver is consulted each save under\n * `allowCustom: false`; `buildRecipientField` memoizes it per request so all recipient fields share one call.\n */\nexport const validateRecipients =\n\t(opts: {\n\t\ttokenFieldTypes?: string[]\n\t\tallowCustom?: boolean\n\t\tfieldTokens?: boolean\n\t\tresolveAllowed?: (req: PayloadRequest) => Set<string> | Promise<Set<string>>\n\t}) =>\n\tasync (\n\t\tvalue: unknown,\n\t\t{ data, req }: { data?: unknown; req: PayloadRequest }\n\t): Promise<string | true> => {\n\t\tconst list = toList(value)\n\t\tif (list.length === 0) {\n\t\t\treturn true\n\t\t}\n\t\tconst fields =\n\t\t\tdata && typeof data === 'object' ? (data as Record<string, unknown>).fields : undefined\n\t\tconst { tokenFieldTypes, allowCustom, fieldTokens, resolveAllowed } = opts\n\t\tconst allowedFields = new Set(\n\t\t\ttokenFieldTypes && tokenFieldTypes.length > 0\n\t\t\t\t? fieldNamesOfType(fields, tokenFieldTypes)\n\t\t\t\t: fieldNames(fields)\n\t\t)\n\t\t// Only resolve the membership set when it is actually enforced (allowCustom explicitly false),\n\t\t// so the default (free-typed) path pays no resolver cost. Compare case-insensitively, matching\n\t\t// the client's dedupe, since email delivery ignores case.\n\t\tlet allowedValues: Set<string> | undefined\n\t\tif (allowCustom === false) {\n\t\t\tif (resolveAllowed) {\n\t\t\t\ttry {\n\t\t\t\t\tallowedValues = await resolveAllowed(req)\n\t\t\t\t} catch {\n\t\t\t\t\treturn asTranslate(req.t)(keys.validationRecipientOptionsUnavailable)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tallowedValues = new Set()\n\t\t\t}\n\t\t}\n\t\tfor (const entry of list) {\n\t\t\tconst token = parseFieldToken(entry)\n\t\t\tif (token) {\n\t\t\t\t// `fieldTokens: false` disables recipient tokens; enforce it on the server, not just the client.\n\t\t\t\tif (fieldTokens === false) {\n\t\t\t\t\treturn asTranslate(req.t)(keys.validationRecipientNotAllowed)\n\t\t\t\t}\n\t\t\t\tif (!allowedFields.has(token)) {\n\t\t\t\t\treturn asTranslate(req.t)(keys.validationRecipientUnknownField)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif (!isPlausibleEmail(entry)) {\n\t\t\t\treturn asTranslate(req.t)(keys.validationRecipientInvalid)\n\t\t\t}\n\t\t\tif (allowCustom === false && !allowedValues?.has(entry.trim().toLowerCase())) {\n\t\t\t\treturn asTranslate(req.t)(keys.validationRecipientNotAllowed)\n\t\t\t}\n\t\t}\n\t\treturn true\n\t}\n\n/** Host-configurable behavior for the recipient fields (plugin option `email.recipients`). */\nexport type RecipientsConfig = {\n\t/** Allow free-typed emails (default true). */\n\tallowCustom?: boolean\n\t/** Offer the form's own fields as recipient tokens (default true). */\n\tfieldTokens?: boolean\n\t/** Field types eligible as tokens (default `['email']`). */\n\ttokenFieldTypes?: string[]\n}\n\nconst RECIPIENTS_FIELD_REF = '@10x-media/form-builder/client#RecipientsSelect'\n\nconst DEPARTMENT_ALLOWED_CACHE = 'formBuilderDepartmentAllowedSet'\n\n/**\n * The department option values as a lowercased Set, memoized on `req.context` so every recipient\n * field validated in one save shares a single `departments` call. Payload validates the fields\n * concurrently, so the in-flight promise is cached synchronously (not the resolved value) to avoid a\n * resolve-per-field race. Lowercased because email delivery ignores case, matching the client dedupe.\n */\nconst resolveDepartmentAllowed = (\n\treq: PayloadRequest,\n\tdepartments: DepartmentEmailsResolver\n): Promise<Set<string>> => {\n\tconst cached = req.context?.[DEPARTMENT_ALLOWED_CACHE]\n\tif (cached instanceof Promise) {\n\t\treturn cached as Promise<Set<string>>\n\t}\n\tconst promise = Promise.resolve()\n\t\t.then(() => departments({ req }))\n\t\t.then((options) => new Set(options.map((option) => option.value.trim().toLowerCase())))\n\tif (req.context) {\n\t\treq.context[DEPARTMENT_ALLOWED_CACHE] = promise\n\t}\n\treturn promise\n}\n\n/**\n * A `text hasMany` field rendered by `RecipientsSelect`, used for every email address list. `endpoint`\n * (when set) supplies preset options (e.g. the host's departments); `recipients` narrows the field's\n * behavior; `width` sets `admin.width` so a pair can share a row; `departments` (when set) backs\n * server-side `allowCustom: false` enforcement with the resolved option values.\n */\n// biome-ignore lint/complexity/useMaxParams: the field identity (name, label, localize) plus its grouped options is the minimal surface\nexport const buildRecipientField = (\n\tname: string,\n\tlabelKey: string,\n\tlocalize: boolean,\n\topts: {\n\t\tendpoint?: string\n\t\trecipients?: RecipientsConfig\n\t\twidth?: string\n\t\tdepartments?: DepartmentEmailsResolver\n\t} = {}\n): TextField => {\n\tconst { departments } = opts\n\tconst resolveAllowed = departments\n\t\t? (req: PayloadRequest) => resolveDepartmentAllowed(req, departments)\n\t\t: undefined\n\treturn {\n\t\tname,\n\t\ttype: 'text',\n\t\thasMany: true,\n\t\tlabel: labelFor(labelKey),\n\t\tvalidate: validateRecipients({\n\t\t\ttokenFieldTypes: opts.recipients?.tokenFieldTypes ?? ['email'],\n\t\t\tallowCustom: opts.recipients?.allowCustom,\n\t\t\tfieldTokens: opts.recipients?.fieldTokens,\n\t\t\tresolveAllowed,\n\t\t}),\n\t\t...localizedIf(localize),\n\t\tadmin: {\n\t\t\t...(opts.width ? { width: opts.width } : {}),\n\t\t\tcomponents: {\n\t\t\t\tField: {\n\t\t\t\t\tpath: RECIPIENTS_FIELD_REF,\n\t\t\t\t\tclientProps: {\n\t\t\t\t\t\t...(opts.endpoint ? { endpoint: opts.endpoint } : {}),\n\t\t\t\t\t\t...(opts.recipients?.allowCustom === false ? { allowCustom: false } : {}),\n\t\t\t\t\t\t...(opts.recipients?.fieldTokens === false ? { fieldTokens: false } : {}),\n\t\t\t\t\t\t...(opts.recipients?.tokenFieldTypes\n\t\t\t\t\t\t\t? { tokenFieldTypes: opts.recipients.tokenFieldTypes }\n\t\t\t\t\t\t\t: {}),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n}\n"],"mappings":"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"}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { RegistryConfig } from "../plugin/applyRegistryConfig.js";
|
|
1
2
|
import { AnyActionDefinition } from "./defineAction.js";
|
|
2
3
|
|
|
3
4
|
//#region src/actions/registry.d.ts
|
|
4
5
|
type ActionRegistry = Map<string, AnyActionDefinition>;
|
|
5
6
|
/** `false` removes a built-in, `true` keeps it, a definition adds or replaces one. */
|
|
6
7
|
type ActionOption = boolean | AnyActionDefinition;
|
|
7
|
-
type ActionsConfig =
|
|
8
|
+
type ActionsConfig = RegistryConfig<AnyActionDefinition>;
|
|
8
9
|
/**
|
|
9
10
|
* Resolve the active action registry from built-in defaults and a consumer override map. `false`
|
|
10
11
|
* removes a type, `true` keeps the default (no-op when none exists), a definition adds or replaces.
|
package/dist/actions/registry.js
CHANGED
|
@@ -1,15 +1,11 @@
|
|
|
1
|
+
import { applyRegistryConfig } from "../plugin/applyRegistryConfig.js";
|
|
1
2
|
//#region src/actions/registry.ts
|
|
2
3
|
/**
|
|
3
4
|
* Resolve the active action registry from built-in defaults and a consumer override map. `false`
|
|
4
5
|
* removes a type, `true` keeps the default (no-op when none exists), a definition adds or replaces.
|
|
5
6
|
* Mirrors the field-type and validation-rule registry convention.
|
|
6
7
|
*/
|
|
7
|
-
const resolveActions = (defaults, config = {}) =>
|
|
8
|
-
const registry = new Map(Object.entries(defaults));
|
|
9
|
-
for (const [type, option] of Object.entries(config)) if (option === false) registry.delete(type);
|
|
10
|
-
else if (option === true) {} else registry.set(type, option);
|
|
11
|
-
return registry;
|
|
12
|
-
};
|
|
8
|
+
const resolveActions = (defaults, config = {}) => applyRegistryConfig(new Map(Object.entries(defaults)), config);
|
|
13
9
|
//#endregion
|
|
14
10
|
export { resolveActions };
|
|
15
11
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"registry.js","names":[],"sources":["../../src/actions/registry.ts"],"sourcesContent":["import type { AnyActionDefinition } from './defineAction'\n\nexport type ActionRegistry = Map<string, AnyActionDefinition>\n\n/** `false` removes a built-in, `true` keeps it, a definition adds or replaces one. */\nexport type ActionOption = boolean | AnyActionDefinition\n\nexport type ActionsConfig =
|
|
1
|
+
{"version":3,"file":"registry.js","names":[],"sources":["../../src/actions/registry.ts"],"sourcesContent":["import { applyRegistryConfig, type RegistryConfig } from '../plugin/applyRegistryConfig'\nimport type { AnyActionDefinition } from './defineAction'\n\nexport type ActionRegistry = Map<string, AnyActionDefinition>\n\n/** `false` removes a built-in, `true` keeps it, a definition adds or replaces one. */\nexport type ActionOption = boolean | AnyActionDefinition\n\nexport type ActionsConfig = RegistryConfig<AnyActionDefinition>\n\n/**\n * Resolve the active action registry from built-in defaults and a consumer override map. `false`\n * removes a type, `true` keeps the default (no-op when none exists), a definition adds or replaces.\n * Mirrors the field-type and validation-rule registry convention.\n */\nexport const resolveActions = (\n\tdefaults: Record<string, AnyActionDefinition>,\n\tconfig: ActionsConfig = {}\n): ActionRegistry => applyRegistryConfig(new Map(Object.entries(defaults)), config)\n"],"mappings":";;;;;;;AAeA,MAAa,kBACZ,UACA,SAAwB,CAAC,MACL,oBAAoB,IAAI,IAAI,OAAO,QAAQ,QAAQ,CAAC,GAAG,MAAM"}
|
package/dist/actions/task.js
CHANGED
|
@@ -21,7 +21,7 @@ const runActionsForSubmission = async (args) => {
|
|
|
21
21
|
overrideAccess: true,
|
|
22
22
|
req
|
|
23
23
|
}).catch(() => null);
|
|
24
|
-
if (!submission) return;
|
|
24
|
+
if (!submission) return [];
|
|
25
25
|
const locale = typeof submission.locale === "string" ? submission.locale : req?.locale ?? "en";
|
|
26
26
|
const form = await payload.findByID({
|
|
27
27
|
collection: FORMS_SLUG,
|
|
@@ -31,9 +31,9 @@ const runActionsForSubmission = async (args) => {
|
|
|
31
31
|
locale,
|
|
32
32
|
req
|
|
33
33
|
}).catch(() => null);
|
|
34
|
-
if (!form) return;
|
|
34
|
+
if (!form) return [];
|
|
35
35
|
const t = asFieldTranslate(req?.i18n?.t ?? ((key) => key));
|
|
36
|
-
await runActions({
|
|
36
|
+
const results = await runActions({
|
|
37
37
|
actions: asActions(form.actions),
|
|
38
38
|
registry,
|
|
39
39
|
richText,
|
|
@@ -49,6 +49,8 @@ const runActionsForSubmission = async (args) => {
|
|
|
49
49
|
locale,
|
|
50
50
|
t
|
|
51
51
|
});
|
|
52
|
+
for (const result of results) if (!result.ok) payload.logger?.error(`@10x-media/form-builder: action "${result.type}" failed for submission ${String(submission.id)}: ${result.error ?? "unknown error"}`);
|
|
53
|
+
return results;
|
|
52
54
|
};
|
|
53
55
|
/** Native Payload jobs task that runs a submission's post-submit actions out of band. */
|
|
54
56
|
const buildActionsTask = (registry, richText) => ({
|
package/dist/actions/task.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"task.js","names":[],"sources":["../../src/actions/task.ts"],"sourcesContent":["import type { Config, Payload, PayloadRequest, TaskConfig, TypedLocale } from 'payload'\nimport { FORM_SUBMISSIONS_SLUG } from '../collections/formSubmissions'\nimport { FORMS_SLUG } from '../collections/forms'\nimport type { Translate } from '../fields/types'\nimport type { SubmissionDescriptor, SubmissionValue } from '../submissions/types'\nimport { asFieldTranslate } from '../translations/server'\nimport type { RichTextBodyOption } from './body/serializeBody'\nimport type { ActionRegistry } from './registry'\nimport type { ActionInstance } from './runActions'\nimport { runActions } from './runActions'\n\nexport const ACTIONS_TASK_SLUG = 'form-builder-actions'\n\n/** Input the dispatch path enqueues; the handler re-loads everything else from the DB. */\nexport type ActionsTaskInput = { formId: number | string; submissionId: number | string }\n\nconst asActions = (value: unknown): ActionInstance[] =>\n\tArray.isArray(value) ? (value as ActionInstance[]) : []\n\nconst asValues = (value: unknown): SubmissionValue[] =>\n\tArray.isArray(value) ? (value as SubmissionValue[]) : []\n\nconst asDescriptors = (value: unknown): SubmissionDescriptor[] =>\n\tArray.isArray(value) ? (value as SubmissionDescriptor[]) : []\n\n/**\n * Load the form and submission by id and run the form's actions through the shared, failure-isolating\n * `runActions`. Tolerates a missing form or submission (the row may have been deleted between enqueue and\n * run) by returning early. Used by both the queued task handler and the inline fallback.\n */\nexport const runActionsForSubmission = async (args: {\n\tinput: ActionsTaskInput\n\tregistry: ActionRegistry\n\tpayload: Payload\n\treq?: PayloadRequest\n\trichText?: RichTextBodyOption\n}): Promise<
|
|
1
|
+
{"version":3,"file":"task.js","names":[],"sources":["../../src/actions/task.ts"],"sourcesContent":["import type { Config, Payload, PayloadRequest, TaskConfig, TypedLocale } from 'payload'\nimport { FORM_SUBMISSIONS_SLUG } from '../collections/formSubmissions'\nimport { FORMS_SLUG } from '../collections/forms'\nimport type { Translate } from '../fields/types'\nimport type { SubmissionDescriptor, SubmissionValue } from '../submissions/types'\nimport { asFieldTranslate } from '../translations/server'\nimport type { RichTextBodyOption } from './body/serializeBody'\nimport type { ActionRegistry } from './registry'\nimport type { ActionInstance, ActionResult } from './runActions'\nimport { runActions } from './runActions'\n\nexport const ACTIONS_TASK_SLUG = 'form-builder-actions'\n\n/** Input the dispatch path enqueues; the handler re-loads everything else from the DB. */\nexport type ActionsTaskInput = { formId: number | string; submissionId: number | string }\n\nconst asActions = (value: unknown): ActionInstance[] =>\n\tArray.isArray(value) ? (value as ActionInstance[]) : []\n\nconst asValues = (value: unknown): SubmissionValue[] =>\n\tArray.isArray(value) ? (value as SubmissionValue[]) : []\n\nconst asDescriptors = (value: unknown): SubmissionDescriptor[] =>\n\tArray.isArray(value) ? (value as SubmissionDescriptor[]) : []\n\n/**\n * Load the form and submission by id and run the form's actions through the shared, failure-isolating\n * `runActions`. Tolerates a missing form or submission (the row may have been deleted between enqueue and\n * run) by returning early. Used by both the queued task handler and the inline fallback.\n */\nexport const runActionsForSubmission = async (args: {\n\tinput: ActionsTaskInput\n\tregistry: ActionRegistry\n\tpayload: Payload\n\treq?: PayloadRequest\n\trichText?: RichTextBodyOption\n}): Promise<ActionResult[]> => {\n\tconst { input, registry, payload, req, richText } = args\n\tconst submission = await payload\n\t\t.findByID({\n\t\t\tcollection: FORM_SUBMISSIONS_SLUG,\n\t\t\tid: input.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) {\n\t\treturn []\n\t}\n\n\t// The submission's own stored locale (set from req.locale at submit) is authoritative, so the form\n\t// is loaded at it. A localized action config, notably the emailTeam `to`, then resolves to the\n\t// submission's locale even on the queued path, where the job runner's req may carry a different\n\t// (or no) locale than the visitor who submitted.\n\tconst locale = typeof submission.locale === 'string' ? submission.locale : (req?.locale ?? 'en')\n\n\tconst form = await payload\n\t\t.findByID({\n\t\t\tcollection: FORMS_SLUG,\n\t\t\tid: input.formId,\n\t\t\tdepth: 0,\n\t\t\toverrideAccess: true,\n\t\t\t// Cast: the stored locale is a plain string; a host's concrete locale union is unknowable from\n\t\t\t// the plugin, and an unrecognized code just falls back on read, so this narrows (zero runtime\n\t\t\t// delta) to satisfy a host whose `findByID` locale is a real union.\n\t\t\tlocale: locale as TypedLocale,\n\t\t\treq,\n\t\t})\n\t\t.catch(() => null)\n\tif (!form) {\n\t\treturn []\n\t}\n\n\tconst t: Translate = asFieldTranslate(req?.i18n?.t ?? ((key: string) => key))\n\n\tconst results = await runActions({\n\t\tactions: asActions(form.actions),\n\t\tregistry,\n\t\trichText,\n\t\tform: { id: form.id, title: typeof form.title === 'string' ? form.title : undefined },\n\t\tsubmissionId: submission.id,\n\t\tvalues: asValues(submission.values),\n\t\tdescriptors: asDescriptors(submission.descriptors),\n\t\tpayload,\n\t\treq,\n\t\tlocale,\n\t\tt,\n\t})\n\t// A failed action (SMTP down, webhook non-2xx, missing adapter) is isolated per action; surface it\n\t// so a silently undelivered email/webhook is visible instead of the submission looking successful.\n\tfor (const result of results) {\n\t\tif (!result.ok) {\n\t\t\tpayload.logger?.error(\n\t\t\t\t`@10x-media/form-builder: action \"${result.type}\" failed for submission ${String(submission.id)}: ${result.error ?? 'unknown error'}`\n\t\t\t)\n\t\t}\n\t}\n\treturn results\n}\n\n/** Native Payload jobs task that runs a submission's post-submit actions out of band. */\nexport const buildActionsTask = (\n\tregistry: ActionRegistry,\n\trichText?: RichTextBodyOption\n): TaskConfig =>\n\t({\n\t\tslug: ACTIONS_TASK_SLUG,\n\t\tinputSchema: [\n\t\t\t{ name: 'formId', type: 'text', required: true },\n\t\t\t{ name: 'submissionId', type: 'text', required: true },\n\t\t],\n\t\thandler: async ({ input, req }) => {\n\t\t\tawait runActionsForSubmission({\n\t\t\t\tinput: input as ActionsTaskInput,\n\t\t\t\tregistry,\n\t\t\t\tpayload: req.payload,\n\t\t\t\treq,\n\t\t\t\trichText,\n\t\t\t})\n\t\t\treturn { output: {} }\n\t\t},\n\t}) as TaskConfig\n\n/** Register the actions task on `config.jobs.tasks`, creating the jobs config if absent. */\nexport const registerActionsTask = (\n\tconfig: Config,\n\tregistry: ActionRegistry,\n\trichText?: RichTextBodyOption\n): void => {\n\tconfig.jobs ??= {}\n\tconfig.jobs.tasks ??= []\n\tconfig.jobs.tasks.push(buildActionsTask(registry, richText))\n}\n"],"mappings":";;;;;AAWA,MAAa,oBAAoB;AAKjC,MAAM,aAAa,UAClB,MAAM,QAAQ,KAAK,IAAK,QAA6B,CAAC;AAEvD,MAAM,YAAY,UACjB,MAAM,QAAQ,KAAK,IAAK,QAA8B,CAAC;AAExD,MAAM,iBAAiB,UACtB,MAAM,QAAQ,KAAK,IAAK,QAAmC,CAAC;;;;;;AAO7D,MAAa,0BAA0B,OAAO,SAMf;CAC9B,MAAM,EAAE,OAAO,UAAU,SAAS,KAAK,aAAa;CACpD,MAAM,aAAa,MAAM,QACvB,SAAS;EACT,YAAY;EACZ,IAAI,MAAM;EACV,OAAO;EACP,gBAAgB;EAChB;CACD,CAAC,EACA,YAAY,IAAI;CAClB,IAAI,CAAC,YACJ,OAAO,CAAC;CAOT,MAAM,SAAS,OAAO,WAAW,WAAW,WAAW,WAAW,SAAU,KAAK,UAAU;CAE3F,MAAM,OAAO,MAAM,QACjB,SAAS;EACT,YAAY;EACZ,IAAI,MAAM;EACV,OAAO;EACP,gBAAgB;EAIR;EACR;CACD,CAAC,EACA,YAAY,IAAI;CAClB,IAAI,CAAC,MACJ,OAAO,CAAC;CAGT,MAAM,IAAe,iBAAiB,KAAK,MAAM,OAAO,QAAgB,IAAI;CAE5E,MAAM,UAAU,MAAM,WAAW;EAChC,SAAS,UAAU,KAAK,OAAO;EAC/B;EACA;EACA,MAAM;GAAE,IAAI,KAAK;GAAI,OAAO,OAAO,KAAK,UAAU,WAAW,KAAK,QAAQ,KAAA;EAAU;EACpF,cAAc,WAAW;EACzB,QAAQ,SAAS,WAAW,MAAM;EAClC,aAAa,cAAc,WAAW,WAAW;EACjD;EACA;EACA;EACA;CACD,CAAC;CAGD,KAAK,MAAM,UAAU,SACpB,IAAI,CAAC,OAAO,IACX,QAAQ,QAAQ,MACf,oCAAoC,OAAO,KAAK,0BAA0B,OAAO,WAAW,EAAE,EAAE,IAAI,OAAO,SAAS,iBACrH;CAGF,OAAO;AACR;;AAGA,MAAa,oBACZ,UACA,cAEC;CACA,MAAM;CACN,aAAa,CACZ;EAAE,MAAM;EAAU,MAAM;EAAQ,UAAU;CAAK,GAC/C;EAAE,MAAM;EAAgB,MAAM;EAAQ,UAAU;CAAK,CACtD;CACA,SAAS,OAAO,EAAE,OAAO,UAAU;EAClC,MAAM,wBAAwB;GACtB;GACP;GACA,SAAS,IAAI;GACb;GACA;EACD,CAAC;EACD,OAAO,EAAE,QAAQ,CAAC,EAAE;CACrB;AACD;;AAGD,MAAa,uBACZ,QACA,UACA,aACU;CACV,OAAO,SAAS,CAAC;CACjB,OAAO,KAAK,UAAU,CAAC;CACvB,OAAO,KAAK,MAAM,KAAK,iBAAiB,UAAU,QAAQ,CAAC;AAC5D"}
|
package/dist/calc/evaluate.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
//#region src/calc/evaluate.ts
|
|
2
|
-
const MAX_DEPTH = 64;
|
|
3
2
|
const toNumber = (value) => {
|
|
4
3
|
if (typeof value === "number") return Number.isFinite(value) ? value : 0;
|
|
5
4
|
if (typeof value === "string" && value.trim() !== "") {
|
|
@@ -18,7 +17,7 @@ const FUNCTIONS = {
|
|
|
18
17
|
floor: (a) => Math.floor(a[0] ?? 0)
|
|
19
18
|
};
|
|
20
19
|
const evalNode = (expr, answers, depth) => {
|
|
21
|
-
if (depth >
|
|
20
|
+
if (depth > 64 || expr == null || typeof expr !== "object") return 0;
|
|
22
21
|
switch (expr.type) {
|
|
23
22
|
case "lit": return finite(toNumber(expr.value));
|
|
24
23
|
case "ref": return toNumber(answers[expr.field]);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"evaluate.js","names":[],"sources":["../../src/calc/evaluate.ts"],"sourcesContent":["import type
|
|
1
|
+
{"version":3,"file":"evaluate.js","names":[],"sources":["../../src/calc/evaluate.ts"],"sourcesContent":["import { type CalcExpression, MAX_DEPTH } from './types'\n\nconst toNumber = (value: unknown): number => {\n\tif (typeof value === 'number') {\n\t\treturn Number.isFinite(value) ? value : 0\n\t}\n\tif (typeof value === 'string' && value.trim() !== '') {\n\t\tconst n = Number(value)\n\t\treturn Number.isFinite(n) ? n : 0\n\t}\n\treturn 0\n}\n\nconst finite = (n: number): number => (Number.isFinite(n) ? n : 0)\n\nconst FUNCTIONS: Record<string, (args: number[]) => number> = {\n\tmin: (a) => (a.length ? Math.min(...a) : 0),\n\tmax: (a) => (a.length ? Math.max(...a) : 0),\n\tround: (a) => Math.round(a[0] ?? 0),\n\tabs: (a) => Math.abs(a[0] ?? 0),\n\tceil: (a) => Math.ceil(a[0] ?? 0),\n\tfloor: (a) => Math.floor(a[0] ?? 0),\n}\n\nconst evalNode = (\n\texpr: CalcExpression,\n\tanswers: Record<string, unknown>,\n\tdepth: number\n): number => {\n\tif (depth > MAX_DEPTH || expr == null || typeof expr !== 'object') {\n\t\treturn 0\n\t}\n\tswitch (expr.type) {\n\t\tcase 'lit':\n\t\t\treturn finite(toNumber(expr.value))\n\t\tcase 'ref':\n\t\t\treturn toNumber(answers[expr.field])\n\t\tcase 'neg':\n\t\t\treturn finite(-evalNode(expr.operand, answers, depth + 1))\n\t\tcase 'op': {\n\t\t\tconst l = evalNode(expr.left, answers, depth + 1)\n\t\t\tconst r = evalNode(expr.right, answers, depth + 1)\n\t\t\tswitch (expr.op) {\n\t\t\t\tcase '+':\n\t\t\t\t\treturn finite(l + r)\n\t\t\t\tcase '-':\n\t\t\t\t\treturn finite(l - r)\n\t\t\t\tcase '*':\n\t\t\t\t\treturn finite(l * r)\n\t\t\t\tcase '/':\n\t\t\t\t\treturn r === 0 ? 0 : finite(l / r)\n\t\t\t\tcase '%':\n\t\t\t\t\treturn r === 0 ? 0 : finite(l % r)\n\t\t\t\tdefault:\n\t\t\t\t\treturn 0\n\t\t\t}\n\t\t}\n\t\tcase 'fn': {\n\t\t\tconst fn = FUNCTIONS[expr.fn]\n\t\t\tif (!fn) {\n\t\t\t\treturn 0\n\t\t\t}\n\t\t\tconst args = Array.isArray(expr.args)\n\t\t\t\t? expr.args.map((a) => evalNode(a, answers, depth + 1))\n\t\t\t\t: []\n\t\t\treturn finite(fn(args))\n\t\t}\n\t\tcase 'weight': {\n\t\t\tconst weights = expr.weights ?? {}\n\t\t\tconst value = answers[expr.field]\n\t\t\tconst chosen = Array.isArray(value) ? value : value == null || value === '' ? [] : [value]\n\t\t\treturn finite(\n\t\t\t\tchosen.reduce((sum: number, v: unknown) => sum + toNumber(weights[String(v)]), 0)\n\t\t\t)\n\t\t}\n\t\tdefault:\n\t\t\treturn 0\n\t}\n}\n\n/** Evaluate a calc expression against form answers. Total + safe: no `eval`, always finite, div/mod by zero -> 0, missing ref -> 0, depth-guarded. Isomorphic (client + server). */\nexport const evaluateCalc = (\n\texpr: CalcExpression | null | undefined,\n\tanswers: Record<string, unknown>\n): number => {\n\tif (!expr) {\n\t\treturn 0\n\t}\n\treturn evalNode(expr, answers, 0)\n}\n"],"mappings":";AAEA,MAAM,YAAY,UAA2B;CAC5C,IAAI,OAAO,UAAU,UACpB,OAAO,OAAO,SAAS,KAAK,IAAI,QAAQ;CAEzC,IAAI,OAAO,UAAU,YAAY,MAAM,KAAK,MAAM,IAAI;EACrD,MAAM,IAAI,OAAO,KAAK;EACtB,OAAO,OAAO,SAAS,CAAC,IAAI,IAAI;CACjC;CACA,OAAO;AACR;AAEA,MAAM,UAAU,MAAuB,OAAO,SAAS,CAAC,IAAI,IAAI;AAEhE,MAAM,YAAwD;CAC7D,MAAM,MAAO,EAAE,SAAS,KAAK,IAAI,GAAG,CAAC,IAAI;CACzC,MAAM,MAAO,EAAE,SAAS,KAAK,IAAI,GAAG,CAAC,IAAI;CACzC,QAAQ,MAAM,KAAK,MAAM,EAAE,MAAM,CAAC;CAClC,MAAM,MAAM,KAAK,IAAI,EAAE,MAAM,CAAC;CAC9B,OAAO,MAAM,KAAK,KAAK,EAAE,MAAM,CAAC;CAChC,QAAQ,MAAM,KAAK,MAAM,EAAE,MAAM,CAAC;AACnC;AAEA,MAAM,YACL,MACA,SACA,UACY;CACZ,IAAI,QAAA,MAAqB,QAAQ,QAAQ,OAAO,SAAS,UACxD,OAAO;CAER,QAAQ,KAAK,MAAb;EACC,KAAK,OACJ,OAAO,OAAO,SAAS,KAAK,KAAK,CAAC;EACnC,KAAK,OACJ,OAAO,SAAS,QAAQ,KAAK,MAAM;EACpC,KAAK,OACJ,OAAO,OAAO,CAAC,SAAS,KAAK,SAAS,SAAS,QAAQ,CAAC,CAAC;EAC1D,KAAK,MAAM;GACV,MAAM,IAAI,SAAS,KAAK,MAAM,SAAS,QAAQ,CAAC;GAChD,MAAM,IAAI,SAAS,KAAK,OAAO,SAAS,QAAQ,CAAC;GACjD,QAAQ,KAAK,IAAb;IACC,KAAK,KACJ,OAAO,OAAO,IAAI,CAAC;IACpB,KAAK,KACJ,OAAO,OAAO,IAAI,CAAC;IACpB,KAAK,KACJ,OAAO,OAAO,IAAI,CAAC;IACpB,KAAK,KACJ,OAAO,MAAM,IAAI,IAAI,OAAO,IAAI,CAAC;IAClC,KAAK,KACJ,OAAO,MAAM,IAAI,IAAI,OAAO,IAAI,CAAC;IAClC,SACC,OAAO;GACT;EACD;EACA,KAAK,MAAM;GACV,MAAM,KAAK,UAAU,KAAK;GAC1B,IAAI,CAAC,IACJ,OAAO;GAKR,OAAO,OAAO,GAHD,MAAM,QAAQ,KAAK,IAAI,IACjC,KAAK,KAAK,KAAK,MAAM,SAAS,GAAG,SAAS,QAAQ,CAAC,CAAC,IACpD,CAAC,CACiB,CAAC;EACvB;EACA,KAAK,UAAU;GACd,MAAM,UAAU,KAAK,WAAW,CAAC;GACjC,MAAM,QAAQ,QAAQ,KAAK;GAE3B,OAAO,QADQ,MAAM,QAAQ,KAAK,IAAI,QAAQ,SAAS,QAAQ,UAAU,KAAK,CAAC,IAAI,CAAC,KAAK,GAEjF,QAAQ,KAAa,MAAe,MAAM,SAAS,QAAQ,OAAO,CAAC,EAAE,GAAG,CAAC,CACjF;EACD;EACA,SACC,OAAO;CACT;AACD;;AAGA,MAAa,gBACZ,MACA,YACY;CACZ,IAAI,CAAC,MACJ,OAAO;CAER,OAAO,SAAS,MAAM,SAAS,CAAC;AACjC"}
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { CALC_FNS, CALC_OPS } from "./types.js";
|
|
2
2
|
//#region src/calc/normalizeCalc.ts
|
|
3
|
-
const MAX_DEPTH = 64;
|
|
4
3
|
const isOp = (v) => CALC_OPS.includes(v);
|
|
5
4
|
const isFn = (v) => CALC_FNS.includes(v);
|
|
6
5
|
const isRecord = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
7
6
|
const normalizeNode = (value, depth) => {
|
|
8
|
-
if (depth >
|
|
7
|
+
if (depth > 64) return void 0;
|
|
9
8
|
if (!isRecord(value)) return void 0;
|
|
10
9
|
switch (value.type) {
|
|
11
10
|
case "lit": {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"normalizeCalc.js","names":[],"sources":["../../src/calc/normalizeCalc.ts"],"sourcesContent":["import {
|
|
1
|
+
{"version":3,"file":"normalizeCalc.js","names":[],"sources":["../../src/calc/normalizeCalc.ts"],"sourcesContent":["import {\n\tCALC_FNS,\n\tCALC_OPS,\n\ttype CalcExpression,\n\ttype CalcFn,\n\ttype CalcOp,\n\tMAX_DEPTH,\n} from './types'\n\nconst isOp = (v: unknown): v is CalcOp => (CALC_OPS as readonly unknown[]).includes(v)\nconst isFn = (v: unknown): v is CalcFn => (CALC_FNS as readonly unknown[]).includes(v)\n\nconst isRecord = (v: unknown): v is Record<string, unknown> =>\n\tv !== null && typeof v === 'object' && !Array.isArray(v)\n\nconst normalizeNode = (value: unknown, depth: number): CalcExpression | undefined => {\n\tif (depth > MAX_DEPTH) return undefined\n\tif (!isRecord(value)) return undefined\n\n\tswitch (value.type) {\n\t\tcase 'lit': {\n\t\t\tconst { value: v } = value\n\t\t\tif (typeof v !== 'number' || !Number.isFinite(v)) return undefined\n\t\t\treturn { type: 'lit', value: v }\n\t\t}\n\t\tcase 'ref': {\n\t\t\tconst { field } = value\n\t\t\tif (typeof field !== 'string') return undefined\n\t\t\treturn { type: 'ref', field }\n\t\t}\n\t\tcase 'op': {\n\t\t\tconst { op, left, right } = value\n\t\t\tif (!isOp(op)) return undefined\n\t\t\tconst l = normalizeNode(left, depth + 1)\n\t\t\tif (!l) return undefined\n\t\t\tconst r = normalizeNode(right, depth + 1)\n\t\t\tif (!r) return undefined\n\t\t\treturn { type: 'op', op, left: l, right: r }\n\t\t}\n\t\tcase 'neg': {\n\t\t\tconst operand = normalizeNode(value.operand, depth + 1)\n\t\t\tif (!operand) return undefined\n\t\t\treturn { type: 'neg', operand }\n\t\t}\n\t\tcase 'fn': {\n\t\t\tconst { fn, args } = value\n\t\t\tif (!isFn(fn)) return undefined\n\t\t\tif (!Array.isArray(args)) return undefined\n\t\t\tconst normalizedArgs: CalcExpression[] = []\n\t\t\tfor (const arg of args) {\n\t\t\t\tconst a = normalizeNode(arg, depth + 1)\n\t\t\t\tif (!a) return undefined\n\t\t\t\tnormalizedArgs.push(a)\n\t\t\t}\n\t\t\treturn { type: 'fn', fn, args: normalizedArgs }\n\t\t}\n\t\tcase 'weight': {\n\t\t\tconst { field, weights } = value\n\t\t\tif (typeof field !== 'string') return undefined\n\t\t\tif (!isRecord(weights)) return undefined\n\t\t\tconst normalizedWeights: Record<string, number> = {}\n\t\t\tfor (const [k, v] of Object.entries(weights)) {\n\t\t\t\tif (typeof v !== 'number' || !Number.isFinite(v)) return undefined\n\t\t\t\tnormalizedWeights[k] = v\n\t\t\t}\n\t\t\treturn { type: 'weight', field, weights: normalizedWeights }\n\t\t}\n\t\tdefault:\n\t\t\treturn undefined\n\t}\n}\n\n/** Structural guard: returns a valid CalcExpression if `value` is structurally sound (depth-guarded at 64), otherwise undefined. Never throws. */\nexport const normalizeCalc = (value: unknown): CalcExpression | undefined => normalizeNode(value, 0)\n"],"mappings":";;AASA,MAAM,QAAQ,MAA6B,SAAgC,SAAS,CAAC;AACrF,MAAM,QAAQ,MAA6B,SAAgC,SAAS,CAAC;AAErF,MAAM,YAAY,MACjB,MAAM,QAAQ,OAAO,MAAM,YAAY,CAAC,MAAM,QAAQ,CAAC;AAExD,MAAM,iBAAiB,OAAgB,UAA8C;CACpF,IAAI,QAAA,IAAmB,OAAO,KAAA;CAC9B,IAAI,CAAC,SAAS,KAAK,GAAG,OAAO,KAAA;CAE7B,QAAQ,MAAM,MAAd;EACC,KAAK,OAAO;GACX,MAAM,EAAE,OAAO,MAAM;GACrB,IAAI,OAAO,MAAM,YAAY,CAAC,OAAO,SAAS,CAAC,GAAG,OAAO,KAAA;GACzD,OAAO;IAAE,MAAM;IAAO,OAAO;GAAE;EAChC;EACA,KAAK,OAAO;GACX,MAAM,EAAE,UAAU;GAClB,IAAI,OAAO,UAAU,UAAU,OAAO,KAAA;GACtC,OAAO;IAAE,MAAM;IAAO;GAAM;EAC7B;EACA,KAAK,MAAM;GACV,MAAM,EAAE,IAAI,MAAM,UAAU;GAC5B,IAAI,CAAC,KAAK,EAAE,GAAG,OAAO,KAAA;GACtB,MAAM,IAAI,cAAc,MAAM,QAAQ,CAAC;GACvC,IAAI,CAAC,GAAG,OAAO,KAAA;GACf,MAAM,IAAI,cAAc,OAAO,QAAQ,CAAC;GACxC,IAAI,CAAC,GAAG,OAAO,KAAA;GACf,OAAO;IAAE,MAAM;IAAM;IAAI,MAAM;IAAG,OAAO;GAAE;EAC5C;EACA,KAAK,OAAO;GACX,MAAM,UAAU,cAAc,MAAM,SAAS,QAAQ,CAAC;GACtD,IAAI,CAAC,SAAS,OAAO,KAAA;GACrB,OAAO;IAAE,MAAM;IAAO;GAAQ;EAC/B;EACA,KAAK,MAAM;GACV,MAAM,EAAE,IAAI,SAAS;GACrB,IAAI,CAAC,KAAK,EAAE,GAAG,OAAO,KAAA;GACtB,IAAI,CAAC,MAAM,QAAQ,IAAI,GAAG,OAAO,KAAA;GACjC,MAAM,iBAAmC,CAAC;GAC1C,KAAK,MAAM,OAAO,MAAM;IACvB,MAAM,IAAI,cAAc,KAAK,QAAQ,CAAC;IACtC,IAAI,CAAC,GAAG,OAAO,KAAA;IACf,eAAe,KAAK,CAAC;GACtB;GACA,OAAO;IAAE,MAAM;IAAM;IAAI,MAAM;GAAe;EAC/C;EACA,KAAK,UAAU;GACd,MAAM,EAAE,OAAO,YAAY;GAC3B,IAAI,OAAO,UAAU,UAAU,OAAO,KAAA;GACtC,IAAI,CAAC,SAAS,OAAO,GAAG,OAAO,KAAA;GAC/B,MAAM,oBAA4C,CAAC;GACnD,KAAK,MAAM,CAAC,GAAG,MAAM,OAAO,QAAQ,OAAO,GAAG;IAC7C,IAAI,OAAO,MAAM,YAAY,CAAC,OAAO,SAAS,CAAC,GAAG,OAAO,KAAA;IACzD,kBAAkB,KAAK;GACxB;GACA,OAAO;IAAE,MAAM;IAAU;IAAO,SAAS;GAAkB;EAC5D;EACA,SACC;CACF;AACD;;AAGA,MAAa,iBAAiB,UAA+C,cAAc,OAAO,CAAC"}
|
package/dist/calc/types.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.js","names":[],"sources":["../../src/calc/types.ts"],"sourcesContent":["/** Canonical operator grammar; `CalcExpression`, `normalizeCalc`, and the admin editor's jsonSchema all derive from this list. */\nexport const CALC_OPS = ['+', '-', '*', '/', '%'] as const\nexport type CalcOp = (typeof CALC_OPS)[number]\n\n/** Canonical function grammar; same single source of truth as `CALC_OPS`. */\nexport const CALC_FNS = ['min', 'max', 'round', 'abs', 'ceil', 'floor'] as const\nexport type CalcFn = (typeof CALC_FNS)[number]\n\n/** A serializable, safe-by-construction calculation expression. No strings are parsed; the evaluator tree-walks this closed node set (never `eval`). */\nexport type CalcExpression =\n\t| { type: 'lit'; value: number }\n\t| { type: 'ref'; field: string }\n\t| { type: 'op'; op: CalcOp; left: CalcExpression; right: CalcExpression }\n\t| { type: 'neg'; operand: CalcExpression }\n\t| { type: 'fn'; fn: CalcFn; args: CalcExpression[] }\n\t| { type: 'weight'; field: string; weights: Record<string, number> }\n"],"mappings":";;AACA,MAAa,WAAW;CAAC;CAAK;CAAK;CAAK;CAAK;AAAG;;AAIhD,MAAa,WAAW;CAAC;CAAO;CAAO;CAAS;CAAO;CAAQ;AAAO"}
|
|
1
|
+
{"version":3,"file":"types.js","names":[],"sources":["../../src/calc/types.ts"],"sourcesContent":["/** Canonical operator grammar; `CalcExpression`, `normalizeCalc`, and the admin editor's jsonSchema all derive from this list. */\nexport const CALC_OPS = ['+', '-', '*', '/', '%'] as const\nexport type CalcOp = (typeof CALC_OPS)[number]\n\n/** Canonical function grammar; same single source of truth as `CALC_OPS`. */\nexport const CALC_FNS = ['min', 'max', 'round', 'abs', 'ceil', 'floor'] as const\nexport type CalcFn = (typeof CALC_FNS)[number]\n\n/** Recursion guard shared by `normalizeCalc` (parse) and `evaluate` (walk) so both bound depth identically. */\nexport const MAX_DEPTH = 64\n\n/** A serializable, safe-by-construction calculation expression. No strings are parsed; the evaluator tree-walks this closed node set (never `eval`). */\nexport type CalcExpression =\n\t| { type: 'lit'; value: number }\n\t| { type: 'ref'; field: string }\n\t| { type: 'op'; op: CalcOp; left: CalcExpression; right: CalcExpression }\n\t| { type: 'neg'; operand: CalcExpression }\n\t| { type: 'fn'; fn: CalcFn; args: CalcExpression[] }\n\t| { type: 'weight'; field: string; weights: Record<string, number> }\n"],"mappings":";;AACA,MAAa,WAAW;CAAC;CAAK;CAAK;CAAK;CAAK;AAAG;;AAIhD,MAAa,WAAW;CAAC;CAAO;CAAO;CAAS;CAAO;CAAQ;AAAO"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { localizedIf } from "../fields/localizedIf.js";
|
|
2
1
|
import { keys } from "../translations/keys.js";
|
|
3
2
|
import { labelForKey } from "../translations/server.js";
|
|
3
|
+
import { localizedIf } from "../fields/localizedIf.js";
|
|
4
4
|
//#region src/collections/buttonFields.ts
|
|
5
5
|
/** The default submit button label field (`buttons.submitLabel`), localized when `localize` is true. */
|
|
6
6
|
const buildSubmitLabelField = (localize) => ({
|
|
@@ -179,7 +179,8 @@ const buildSubmissionsCollection = ({ registry, ruleRegistry, consentSources, co
|
|
|
179
179
|
consentSources,
|
|
180
180
|
consentSnapshot,
|
|
181
181
|
uploadSlug,
|
|
182
|
-
pollSourceRegistry
|
|
182
|
+
pollSourceRegistry,
|
|
183
|
+
strictUploadOwnership: spam ? spam.uploadOwnership === "strict" : false
|
|
183
184
|
}),
|
|
184
185
|
...overrides?.hooks?.beforeValidate ?? []
|
|
185
186
|
],
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"formSubmissions.js","names":[],"sources":["../../src/collections/formSubmissions.ts"],"sourcesContent":["import type { CollectionAfterChangeHook, CollectionConfig, Field } from 'payload'\nimport type { RichTextBodyOption } from '../actions/body/serializeBody'\nimport { dispatchActions } from '../actions/dispatch'\nimport type { ActionRegistry } from '../actions/registry'\nimport type { ActionInstance } from '../actions/runActions'\nimport type { ConsentSnapshotMode } from '../consent/captureConsent'\nimport type { ConsentSourcesResolver } from '../consent/types'\nimport { resolveEventSink } from '../events/resolveEventSink'\nimport type { FormEventSink } from '../events/types'\nimport type { FieldTypeRegistry } from '../fields/registry'\nimport { isLoggedIn } from '../plugin/access'\nimport type { CollectionOverrides } from '../plugin/collectionOverrides'\nimport type { PollOptionSourceRegistry } from '../poll/registry'\nimport { buildSpamGuard } from '../spam/spamGuard'\nimport type { ResolvedSpamConfig } from '../spam/types'\nimport { validateSubmission } from '../submissions/validateSubmission'\nimport { POLL_CONTEXT_KEY, votedCookieName } from '../submissions/votedCookie'\nimport { keys } from '../translations/keys'\nimport { labelForKey } from '../translations/server'\nimport type { ValidationRuleRegistry } from '../validation/registry'\nimport { FORMS_SLUG } from './forms'\n\nexport const FORM_SUBMISSIONS_SLUG = 'form-submissions'\n\ntype BuildSubmissionsCollectionArgs = {\n\tregistry: FieldTypeRegistry\n\truleRegistry: ValidationRuleRegistry\n\t/** The host's consent sources resolver (plugin option `consent.sources`); absent when no sources are configured. */\n\tconsentSources?: ConsentSourcesResolver\n\t/** What each consent proof snapshots of the agreed wording (plugin option `consent.snapshot`). */\n\tconsentSnapshot?: ConsentSnapshotMode\n\tactionRegistry?: ActionRegistry\n\tevents?: FormEventSink\n\t/** Whether a job runner is likely present; gates the queued vs bounded-inline dispatch path. */\n\thasRunner?: boolean\n\t/** Body serialization customization forwarded to the inline action dispatch path. */\n\trichText?: RichTextBodyOption\n\t/** The plugin-configured uploads collection slug; absent when uploads are disabled. */\n\tuploadSlug?: string\n\t/** Resolved spam config; when active, prepends the spam guard before validation. `false` disables it. */\n\tspam?: ResolvedSpamConfig | false\n\t/** Opt-in (`poll.votedCookie`): set an httpOnly `fb-voted-{formId}` cookie on poll submission creates. */\n\tvotedCookie?: boolean\n\t/** Registered poll option sources; submission validation resolves allowed values through them. */\n\tpollSourceRegistry?: PollOptionSourceRegistry\n\t/**\n\t * When `true`, reveals the standalone `locale`, `values`, `descriptors`, `consent`, and `meta`\n\t * fields in the admin UI. Default `false`, because the `SubmissionAnswers` UI component already\n\t * represents them fully (locale and meta are folded into its Submission-details section).\n\t */\n\tshowRawFields?: boolean\n\toverrides?: CollectionOverrides\n}\n\nconst formIdOf = (form: unknown): number | string | undefined => {\n\tif (typeof form === 'number' || typeof form === 'string') {\n\t\treturn form\n\t}\n\tif (form && typeof form === 'object' && 'id' in form) {\n\t\tconst id = (form as { id: unknown }).id\n\t\tif (typeof id === 'number' || typeof id === 'string') {\n\t\t\treturn id\n\t\t}\n\t}\n\treturn undefined\n}\n\n/**\n * On a completed submission create, dispatch the form's post-submit actions and emit `submission.created`.\n * Both are side effects on an already-written row, so the whole body is wrapped: nothing it does can throw\n * past the hook (a failed action or sink must never fail the submission write). Dispatch is itself bounded\n * and non-throwing; the inline fallback is awaited so the bound caps the added latency, while the queued\n * path returns immediately. The form's `actions` are null-guarded for legacy rows created before the field\n * existed.\n */\nconst makeAfterChange =\n\t(args: {\n\t\tactionRegistry: ActionRegistry\n\t\tevents?: FormEventSink\n\t\thasRunner: boolean\n\t\trichText?: RichTextBodyOption\n\t}): CollectionAfterChangeHook =>\n\tasync ({ doc, operation, req }) => {\n\t\tif (operation !== 'create' || (doc.status != null && doc.status !== 'complete')) {\n\t\t\treturn doc\n\t\t}\n\t\tconst { payload } = req\n\t\ttry {\n\t\t\tconst formId = formIdOf(doc.form)\n\t\t\tif (formId == null) {\n\t\t\t\treturn doc\n\t\t\t}\n\t\t\tconst form = await payload\n\t\t\t\t.findByID({ collection: FORMS_SLUG, id: formId, depth: 0, overrideAccess: true, req })\n\t\t\t\t.catch(() => null)\n\n\t\t\tawait dispatchActions({\n\t\t\t\tactions: (form?.actions ?? null) as ActionInstance[] | null,\n\t\t\t\tformId,\n\t\t\t\tsubmissionId: doc.id as number | string,\n\t\t\t\tregistry: args.actionRegistry,\n\t\t\t\tpayload,\n\t\t\t\treq,\n\t\t\t\thasRunner: args.hasRunner,\n\t\t\t\trichText: args.richText,\n\t\t\t})\n\n\t\t\ttry {\n\t\t\t\tawait resolveEventSink(args.events).emit({\n\t\t\t\t\ttype: 'submission.created',\n\t\t\t\t\tformId: String(formId),\n\t\t\t\t\tsubmissionId: String(doc.id),\n\t\t\t\t\tat: new Date().toISOString(),\n\t\t\t\t})\n\t\t\t} catch (error) {\n\t\t\t\tpayload.logger?.error(\n\t\t\t\t\t`@10x-media/form-builder: submission.created sink threw: ${\n\t\t\t\t\t\terror instanceof Error ? error.message : String(error)\n\t\t\t\t\t}`\n\t\t\t\t)\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tpayload.logger?.error(\n\t\t\t\t`@10x-media/form-builder: afterChange dispatch failed for submission ${String(doc.id)}: ${\n\t\t\t\t\terror instanceof Error ? error.message : String(error)\n\t\t\t\t}`\n\t\t\t)\n\t\t}\n\t\treturn doc\n\t}\n\n/**\n * Opt-in (`poll.votedCookie`) hook: after a submission to a poll-enabled form is created, appends\n * an httpOnly `fb-voted-{formId}=1` cookie via `req.responseHeaders`, Payload's supported channel\n * for hook-set response headers (its own auth strategies use it; `handleEndpoints` merges it into\n * every REST response). Reads the poll config `validateSubmission` stashed on `req.context`,\n * falling back to a form fetch when absent. httpOnly keeps the marker readable only server-side\n * (`hasVotedCookie`); the client `<Poll>` keeps its localStorage guard.\n */\nconst makeVotedCookieHook = (): CollectionAfterChangeHook => {\n\treturn async ({ doc, operation, req }) => {\n\t\tif (operation !== 'create') {\n\t\t\treturn doc\n\t\t}\n\t\tconst formId = formIdOf(doc.form)\n\t\tif (formId == null) {\n\t\t\treturn doc\n\t\t}\n\t\tconst stashed = req.context?.[POLL_CONTEXT_KEY]\n\t\tlet pollEnabled = typeof stashed === 'boolean' ? stashed : undefined\n\t\tif (pollEnabled === undefined) {\n\t\t\tconst form = await req.payload\n\t\t\t\t.findByID({ collection: FORMS_SLUG, id: formId, depth: 0, overrideAccess: true, req })\n\t\t\t\t.catch(() => null)\n\t\t\tpollEnabled = form?.pollEnabled === true\n\t\t}\n\t\tif (!pollEnabled) {\n\t\t\treturn doc\n\t\t}\n\t\treq.responseHeaders ??= new Headers()\n\t\treq.responseHeaders.append(\n\t\t\t'Set-Cookie',\n\t\t\t`${votedCookieName(formId)}=1; Path=/; Max-Age=31536000; HttpOnly; SameSite=Lax`\n\t\t)\n\t\treturn doc\n\t}\n}\n\nexport const buildSubmissionsCollection = ({\n\tregistry,\n\truleRegistry,\n\tconsentSources,\n\tconsentSnapshot,\n\tactionRegistry = new Map(),\n\tevents,\n\thasRunner = false,\n\trichText,\n\tuploadSlug,\n\tspam,\n\tvotedCookie = false,\n\tpollSourceRegistry,\n\tshowRawFields = false,\n\toverrides,\n}: BuildSubmissionsCollectionArgs): CollectionConfig => {\n\tconst defaultFields: Field[] = [\n\t\t{ name: 'form', type: 'relationship', relationTo: FORMS_SLUG, required: true },\n\t\t{\n\t\t\tname: 'status',\n\t\t\ttype: 'select',\n\t\t\tdefaultValue: 'complete',\n\t\t\toptions: [\n\t\t\t\t{ label: labelForKey(keys.statusComplete), value: 'complete' },\n\t\t\t\t{ label: labelForKey(keys.statusPartial), value: 'partial' },\n\t\t\t],\n\t\t\t// Not clearable: aggregation counts `status: complete` submissions, so a cleared status\n\t\t\t// would drop the submission out of every poll result with nothing in the UI saying so.\n\t\t\tadmin: { isClearable: false },\n\t\t\t// Defense-in-depth at the REST layer: anonymous clients cannot set status via the API.\n\t\t\t// The validateSubmission hook also forces 'complete' server-side, so this covers both paths.\n\t\t\taccess: { create: isLoggedIn, update: isLoggedIn },\n\t\t},\n\t\t// answers UI appears first so it is the dominant view when opening a submission document.\n\t\t{\n\t\t\tname: 'answers',\n\t\t\ttype: 'ui',\n\t\t\tadmin: {\n\t\t\t\tcomponents: { Field: '@10x-media/form-builder/rsc#SubmissionAnswers' },\n\t\t\t},\n\t\t},\n\t\t{ name: 'locale', type: 'text', admin: { hidden: !showRawFields } },\n\t\t{ name: 'values', type: 'json', admin: { hidden: !showRawFields } },\n\t\t{ name: 'descriptors', type: 'json', admin: { hidden: !showRawFields } },\n\t\t{ name: 'consent', type: 'json', admin: { hidden: !showRawFields } },\n\t\t{ name: 'meta', type: 'json', admin: { hidden: !showRawFields } },\n\t]\n\n\treturn {\n\t\t...(overrides ?? {}),\n\t\tslug: FORM_SUBMISSIONS_SLUG,\n\t\tlabels: {\n\t\t\tsingular: labelForKey(keys.collectionSubmissionSingular),\n\t\t\tplural: labelForKey(keys.collectionSubmissionPlural),\n\t\t\t...(overrides?.labels ?? {}),\n\t\t},\n\t\tadmin: {\n\t\t\tgroup: 'Forms',\n\t\t\tdefaultColumns: ['form', 'status', 'locale', 'createdAt'],\n\t\t\t...(overrides?.admin ?? {}),\n\t\t},\n\t\taccess: {\n\t\t\tcreate: () => true,\n\t\t\tread: isLoggedIn,\n\t\t\tupdate: () => false,\n\t\t\t...(overrides?.access ?? {}),\n\t\t},\n\t\thooks: {\n\t\t\t...(overrides?.hooks ?? {}),\n\t\t\t// Spam guard + validateSubmission must remain first: they enforce the security invariant\n\t\t\t// that anonymous callers cannot bypass post-submit actions or supply a forged status.\n\t\t\t// Consumer beforeValidate hooks are appended after so they run on already-validated data.\n\t\t\tbeforeValidate: [\n\t\t\t\t...(spam ? [buildSpamGuard(spam)] : []),\n\t\t\t\tvalidateSubmission({\n\t\t\t\t\tregistry,\n\t\t\t\t\truleRegistry,\n\t\t\t\t\tconsentSources,\n\t\t\t\t\tconsentSnapshot,\n\t\t\t\t\tuploadSlug,\n\t\t\t\t\tpollSourceRegistry,\n\t\t\t\t}),\n\t\t\t\t...(overrides?.hooks?.beforeValidate ?? []),\n\t\t\t],\n\t\t\tafterChange: [\n\t\t\t\tmakeAfterChange({ actionRegistry, events, hasRunner, richText }),\n\t\t\t\t...(votedCookie ? [makeVotedCookieHook()] : []),\n\t\t\t\t...(overrides?.hooks?.afterChange ?? []),\n\t\t\t],\n\t\t},\n\t\tfields: overrides?.fields ? overrides.fields({ defaultFields }) : defaultFields,\n\t}\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"formSubmissions.js","names":[],"sources":["../../src/collections/formSubmissions.ts"],"sourcesContent":["import type { CollectionAfterChangeHook, CollectionConfig, Field } from 'payload'\nimport type { RichTextBodyOption } from '../actions/body/serializeBody'\nimport { dispatchActions } from '../actions/dispatch'\nimport type { ActionRegistry } from '../actions/registry'\nimport type { ActionInstance } from '../actions/runActions'\nimport type { ConsentSnapshotMode } from '../consent/captureConsent'\nimport type { ConsentSourcesResolver } from '../consent/types'\nimport { resolveEventSink } from '../events/resolveEventSink'\nimport type { FormEventSink } from '../events/types'\nimport type { FieldTypeRegistry } from '../fields/registry'\nimport { isLoggedIn } from '../plugin/access'\nimport type { CollectionOverrides } from '../plugin/collectionOverrides'\nimport type { PollOptionSourceRegistry } from '../poll/registry'\nimport { buildSpamGuard } from '../spam/spamGuard'\nimport type { ResolvedSpamConfig } from '../spam/types'\nimport { validateSubmission } from '../submissions/validateSubmission'\nimport { POLL_CONTEXT_KEY, votedCookieName } from '../submissions/votedCookie'\nimport { keys } from '../translations/keys'\nimport { labelForKey } from '../translations/server'\nimport type { ValidationRuleRegistry } from '../validation/registry'\nimport { FORMS_SLUG } from './forms'\n\nexport const FORM_SUBMISSIONS_SLUG = 'form-submissions'\n\ntype BuildSubmissionsCollectionArgs = {\n\tregistry: FieldTypeRegistry\n\truleRegistry: ValidationRuleRegistry\n\t/** The host's consent sources resolver (plugin option `consent.sources`); absent when no sources are configured. */\n\tconsentSources?: ConsentSourcesResolver\n\t/** What each consent proof snapshots of the agreed wording (plugin option `consent.snapshot`). */\n\tconsentSnapshot?: ConsentSnapshotMode\n\tactionRegistry?: ActionRegistry\n\tevents?: FormEventSink\n\t/** Whether a job runner is likely present; gates the queued vs bounded-inline dispatch path. */\n\thasRunner?: boolean\n\t/** Body serialization customization forwarded to the inline action dispatch path. */\n\trichText?: RichTextBodyOption\n\t/** The plugin-configured uploads collection slug; absent when uploads are disabled. */\n\tuploadSlug?: string\n\t/** Resolved spam config; when active, prepends the spam guard before validation. `false` disables it. */\n\tspam?: ResolvedSpamConfig | false\n\t/** Opt-in (`poll.votedCookie`): set an httpOnly `fb-voted-{formId}` cookie on poll submission creates. */\n\tvotedCookie?: boolean\n\t/** Registered poll option sources; submission validation resolves allowed values through them. */\n\tpollSourceRegistry?: PollOptionSourceRegistry\n\t/**\n\t * When `true`, reveals the standalone `locale`, `values`, `descriptors`, `consent`, and `meta`\n\t * fields in the admin UI. Default `false`, because the `SubmissionAnswers` UI component already\n\t * represents them fully (locale and meta are folded into its Submission-details section).\n\t */\n\tshowRawFields?: boolean\n\toverrides?: CollectionOverrides\n}\n\nconst formIdOf = (form: unknown): number | string | undefined => {\n\tif (typeof form === 'number' || typeof form === 'string') {\n\t\treturn form\n\t}\n\tif (form && typeof form === 'object' && 'id' in form) {\n\t\tconst id = (form as { id: unknown }).id\n\t\tif (typeof id === 'number' || typeof id === 'string') {\n\t\t\treturn id\n\t\t}\n\t}\n\treturn undefined\n}\n\n/**\n * On a completed submission create, dispatch the form's post-submit actions and emit `submission.created`.\n * Both are side effects on an already-written row, so the whole body is wrapped: nothing it does can throw\n * past the hook (a failed action or sink must never fail the submission write). Dispatch is itself bounded\n * and non-throwing; the inline fallback is awaited so the bound caps the added latency, while the queued\n * path returns immediately. The form's `actions` are null-guarded for legacy rows created before the field\n * existed.\n */\nconst makeAfterChange =\n\t(args: {\n\t\tactionRegistry: ActionRegistry\n\t\tevents?: FormEventSink\n\t\thasRunner: boolean\n\t\trichText?: RichTextBodyOption\n\t}): CollectionAfterChangeHook =>\n\tasync ({ doc, operation, req }) => {\n\t\tif (operation !== 'create' || (doc.status != null && doc.status !== 'complete')) {\n\t\t\treturn doc\n\t\t}\n\t\tconst { payload } = req\n\t\ttry {\n\t\t\tconst formId = formIdOf(doc.form)\n\t\t\tif (formId == null) {\n\t\t\t\treturn doc\n\t\t\t}\n\t\t\tconst form = await payload\n\t\t\t\t.findByID({ collection: FORMS_SLUG, id: formId, depth: 0, overrideAccess: true, req })\n\t\t\t\t.catch(() => null)\n\n\t\t\tawait dispatchActions({\n\t\t\t\tactions: (form?.actions ?? null) as ActionInstance[] | null,\n\t\t\t\tformId,\n\t\t\t\tsubmissionId: doc.id as number | string,\n\t\t\t\tregistry: args.actionRegistry,\n\t\t\t\tpayload,\n\t\t\t\treq,\n\t\t\t\thasRunner: args.hasRunner,\n\t\t\t\trichText: args.richText,\n\t\t\t})\n\n\t\t\ttry {\n\t\t\t\tawait resolveEventSink(args.events).emit({\n\t\t\t\t\ttype: 'submission.created',\n\t\t\t\t\tformId: String(formId),\n\t\t\t\t\tsubmissionId: String(doc.id),\n\t\t\t\t\tat: new Date().toISOString(),\n\t\t\t\t})\n\t\t\t} catch (error) {\n\t\t\t\tpayload.logger?.error(\n\t\t\t\t\t`@10x-media/form-builder: submission.created sink threw: ${\n\t\t\t\t\t\terror instanceof Error ? error.message : String(error)\n\t\t\t\t\t}`\n\t\t\t\t)\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tpayload.logger?.error(\n\t\t\t\t`@10x-media/form-builder: afterChange dispatch failed for submission ${String(doc.id)}: ${\n\t\t\t\t\terror instanceof Error ? error.message : String(error)\n\t\t\t\t}`\n\t\t\t)\n\t\t}\n\t\treturn doc\n\t}\n\n/**\n * Opt-in (`poll.votedCookie`) hook: after a submission to a poll-enabled form is created, appends\n * an httpOnly `fb-voted-{formId}=1` cookie via `req.responseHeaders`, Payload's supported channel\n * for hook-set response headers (its own auth strategies use it; `handleEndpoints` merges it into\n * every REST response). Reads the poll config `validateSubmission` stashed on `req.context`,\n * falling back to a form fetch when absent. httpOnly keeps the marker readable only server-side\n * (`hasVotedCookie`); the client `<Poll>` keeps its localStorage guard.\n */\nconst makeVotedCookieHook = (): CollectionAfterChangeHook => {\n\treturn async ({ doc, operation, req }) => {\n\t\tif (operation !== 'create') {\n\t\t\treturn doc\n\t\t}\n\t\tconst formId = formIdOf(doc.form)\n\t\tif (formId == null) {\n\t\t\treturn doc\n\t\t}\n\t\tconst stashed = req.context?.[POLL_CONTEXT_KEY]\n\t\tlet pollEnabled = typeof stashed === 'boolean' ? stashed : undefined\n\t\tif (pollEnabled === undefined) {\n\t\t\tconst form = await req.payload\n\t\t\t\t.findByID({ collection: FORMS_SLUG, id: formId, depth: 0, overrideAccess: true, req })\n\t\t\t\t.catch(() => null)\n\t\t\tpollEnabled = form?.pollEnabled === true\n\t\t}\n\t\tif (!pollEnabled) {\n\t\t\treturn doc\n\t\t}\n\t\treq.responseHeaders ??= new Headers()\n\t\treq.responseHeaders.append(\n\t\t\t'Set-Cookie',\n\t\t\t`${votedCookieName(formId)}=1; Path=/; Max-Age=31536000; HttpOnly; SameSite=Lax`\n\t\t)\n\t\treturn doc\n\t}\n}\n\nexport const buildSubmissionsCollection = ({\n\tregistry,\n\truleRegistry,\n\tconsentSources,\n\tconsentSnapshot,\n\tactionRegistry = new Map(),\n\tevents,\n\thasRunner = false,\n\trichText,\n\tuploadSlug,\n\tspam,\n\tvotedCookie = false,\n\tpollSourceRegistry,\n\tshowRawFields = false,\n\toverrides,\n}: BuildSubmissionsCollectionArgs): CollectionConfig => {\n\tconst defaultFields: Field[] = [\n\t\t{ name: 'form', type: 'relationship', relationTo: FORMS_SLUG, required: true },\n\t\t{\n\t\t\tname: 'status',\n\t\t\ttype: 'select',\n\t\t\tdefaultValue: 'complete',\n\t\t\toptions: [\n\t\t\t\t{ label: labelForKey(keys.statusComplete), value: 'complete' },\n\t\t\t\t{ label: labelForKey(keys.statusPartial), value: 'partial' },\n\t\t\t],\n\t\t\t// Not clearable: aggregation counts `status: complete` submissions, so a cleared status\n\t\t\t// would drop the submission out of every poll result with nothing in the UI saying so.\n\t\t\tadmin: { isClearable: false },\n\t\t\t// Defense-in-depth at the REST layer: anonymous clients cannot set status via the API.\n\t\t\t// The validateSubmission hook also forces 'complete' server-side, so this covers both paths.\n\t\t\taccess: { create: isLoggedIn, update: isLoggedIn },\n\t\t},\n\t\t// answers UI appears first so it is the dominant view when opening a submission document.\n\t\t{\n\t\t\tname: 'answers',\n\t\t\ttype: 'ui',\n\t\t\tadmin: {\n\t\t\t\tcomponents: { Field: '@10x-media/form-builder/rsc#SubmissionAnswers' },\n\t\t\t},\n\t\t},\n\t\t{ name: 'locale', type: 'text', admin: { hidden: !showRawFields } },\n\t\t{ name: 'values', type: 'json', admin: { hidden: !showRawFields } },\n\t\t{ name: 'descriptors', type: 'json', admin: { hidden: !showRawFields } },\n\t\t{ name: 'consent', type: 'json', admin: { hidden: !showRawFields } },\n\t\t{ name: 'meta', type: 'json', admin: { hidden: !showRawFields } },\n\t]\n\n\treturn {\n\t\t...(overrides ?? {}),\n\t\tslug: FORM_SUBMISSIONS_SLUG,\n\t\tlabels: {\n\t\t\tsingular: labelForKey(keys.collectionSubmissionSingular),\n\t\t\tplural: labelForKey(keys.collectionSubmissionPlural),\n\t\t\t...(overrides?.labels ?? {}),\n\t\t},\n\t\tadmin: {\n\t\t\tgroup: 'Forms',\n\t\t\tdefaultColumns: ['form', 'status', 'locale', 'createdAt'],\n\t\t\t...(overrides?.admin ?? {}),\n\t\t},\n\t\taccess: {\n\t\t\tcreate: () => true,\n\t\t\tread: isLoggedIn,\n\t\t\tupdate: () => false,\n\t\t\t...(overrides?.access ?? {}),\n\t\t},\n\t\thooks: {\n\t\t\t...(overrides?.hooks ?? {}),\n\t\t\t// Spam guard + validateSubmission must remain first: they enforce the security invariant\n\t\t\t// that anonymous callers cannot bypass post-submit actions or supply a forged status.\n\t\t\t// Consumer beforeValidate hooks are appended after so they run on already-validated data.\n\t\t\tbeforeValidate: [\n\t\t\t\t...(spam ? [buildSpamGuard(spam)] : []),\n\t\t\t\tvalidateSubmission({\n\t\t\t\t\tregistry,\n\t\t\t\t\truleRegistry,\n\t\t\t\t\tconsentSources,\n\t\t\t\t\tconsentSnapshot,\n\t\t\t\t\tuploadSlug,\n\t\t\t\t\tpollSourceRegistry,\n\t\t\t\t\tstrictUploadOwnership: spam ? spam.uploadOwnership === 'strict' : false,\n\t\t\t\t}),\n\t\t\t\t...(overrides?.hooks?.beforeValidate ?? []),\n\t\t\t],\n\t\t\tafterChange: [\n\t\t\t\tmakeAfterChange({ actionRegistry, events, hasRunner, richText }),\n\t\t\t\t...(votedCookie ? [makeVotedCookieHook()] : []),\n\t\t\t\t...(overrides?.hooks?.afterChange ?? []),\n\t\t\t],\n\t\t},\n\t\tfields: overrides?.fields ? overrides.fields({ defaultFields }) : defaultFields,\n\t}\n}\n"],"mappings":"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"}
|