@nextlyhq/plugin-form-builder 0.0.2-alpha.60 → 0.0.2-alpha.64
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/README.md +4 -5
- package/dist/admin/index.d.ts +42 -5
- package/dist/admin/index.js +1819 -1310
- package/dist/admin/index.js.map +1 -1
- package/dist/{chunk-XCICDELK.js → chunk-W6NLZFHH.js} +184 -54
- package/dist/chunk-W6NLZFHH.js.map +1 -0
- package/dist/index.d.ts +3 -18
- package/dist/index.js +1599 -1021
- package/dist/index.js.map +1 -1
- package/dist/{types-DuEFdLNU.d.ts → types-hLfLe81K.d.ts} +122 -75
- package/package.json +13 -13
- package/dist/chunk-XCICDELK.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -2,9 +2,11 @@ import {
|
|
|
2
2
|
BUILT_IN_FORM_FIELD_TYPES,
|
|
3
3
|
DEFAULT_FORM_SETTINGS,
|
|
4
4
|
DEFAULT_NOTIFICATION_SETTINGS,
|
|
5
|
+
ENFORCED_VALIDATION_RULES,
|
|
5
6
|
applyFieldDefaults,
|
|
6
7
|
applyFormDefaults,
|
|
7
8
|
createFormConfig,
|
|
9
|
+
documentReachability,
|
|
8
10
|
downloadFile,
|
|
9
11
|
evaluateConditions,
|
|
10
12
|
evaluateSingleCondition,
|
|
@@ -24,13 +26,19 @@ import {
|
|
|
24
26
|
isTextFormField,
|
|
25
27
|
isValidComparisonOperator,
|
|
26
28
|
normalizeFormSettings,
|
|
29
|
+
parseFieldRef,
|
|
30
|
+
parseRedirectReference,
|
|
31
|
+
pickedDocumentField,
|
|
27
32
|
pluralize,
|
|
28
33
|
toTitleCase
|
|
29
|
-
} from "./chunk-
|
|
34
|
+
} from "./chunk-W6NLZFHH.js";
|
|
30
35
|
|
|
31
36
|
// src/plugin.ts
|
|
32
37
|
import { createRequire } from "module";
|
|
33
|
-
import {
|
|
38
|
+
import {
|
|
39
|
+
definePlugin,
|
|
40
|
+
NextlyError as NextlyError2
|
|
41
|
+
} from "@nextlyhq/plugin-sdk";
|
|
34
42
|
|
|
35
43
|
// src/collections/forms.ts
|
|
36
44
|
import { NextlyError } from "@nextlyhq/plugin-sdk";
|
|
@@ -39,18 +47,284 @@ import {
|
|
|
39
47
|
textarea,
|
|
40
48
|
select,
|
|
41
49
|
checkbox,
|
|
50
|
+
date,
|
|
42
51
|
group,
|
|
43
52
|
json,
|
|
44
53
|
relationship
|
|
45
54
|
} from "nextly";
|
|
55
|
+
|
|
56
|
+
// src/utils/redirect-target.ts
|
|
57
|
+
import { previewUrlFromTemplate } from "nextly/config";
|
|
58
|
+
var DEFAULT_REDIRECT_PATTERN = "/{slug}";
|
|
59
|
+
function normalizeRedirectRelationships(option2) {
|
|
60
|
+
if (!option2) return {};
|
|
61
|
+
const entries = Array.isArray(option2) ? option2.map((collection) => [collection, DEFAULT_REDIRECT_PATTERN]) : Object.entries(option2);
|
|
62
|
+
return Object.fromEntries(
|
|
63
|
+
entries.filter(
|
|
64
|
+
([, pattern]) => typeof pattern !== "string" || pattern.trim()
|
|
65
|
+
)
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
function applyRedirectPattern(pattern, document) {
|
|
69
|
+
if (typeof pattern === "function") {
|
|
70
|
+
const produced = pattern(document);
|
|
71
|
+
return produced ? produced : void 0;
|
|
72
|
+
}
|
|
73
|
+
return previewUrlFromTemplate(pattern)(document) ?? void 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// src/collections/access-defaults.ts
|
|
77
|
+
var authenticated = ({ user }) => !!user;
|
|
78
|
+
var administrative = ({ roles }) => roles.includes("admin") || roles.includes("super-admin");
|
|
79
|
+
function accessWithDefaults(overrides, defaults = {}) {
|
|
80
|
+
return {
|
|
81
|
+
read: overrides?.read ?? defaults.read ?? authenticated,
|
|
82
|
+
create: overrides?.create ?? defaults.create ?? authenticated,
|
|
83
|
+
update: overrides?.update ?? authenticated,
|
|
84
|
+
// Deleting is administrative in both collections; the DB permission layer
|
|
85
|
+
// remains the backstop underneath it.
|
|
86
|
+
delete: overrides?.delete ?? administrative
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// src/collections/forms.ts
|
|
91
|
+
function withHostHooks(pluginHooks, hostHooks, trailing = {}) {
|
|
92
|
+
const host = { ...hostHooks };
|
|
93
|
+
const asList = (value) => Array.isArray(value) ? value : value === void 0 ? [] : [value];
|
|
94
|
+
const merged = { ...host };
|
|
95
|
+
for (const [phase, handlers] of Object.entries(
|
|
96
|
+
pluginHooks
|
|
97
|
+
)) {
|
|
98
|
+
merged[phase] = [...asList(handlers), ...asList(host[phase])];
|
|
99
|
+
}
|
|
100
|
+
for (const [phase, handlers] of Object.entries(trailing)) {
|
|
101
|
+
merged[phase] = [...asList(merged[phase]), ...asList(handlers)];
|
|
102
|
+
}
|
|
103
|
+
return merged;
|
|
104
|
+
}
|
|
105
|
+
function documentRedirectSettings(settings) {
|
|
106
|
+
if (typeof settings !== "object" || settings === null) return null;
|
|
107
|
+
const record = settings;
|
|
108
|
+
const field = pickedDocumentField(record);
|
|
109
|
+
return field ? { settings: record, field } : null;
|
|
110
|
+
}
|
|
111
|
+
function pageRedirectSettings(data, operation) {
|
|
112
|
+
if (!data) return null;
|
|
113
|
+
if (operation !== "create" && data.settings === void 0) return null;
|
|
114
|
+
return documentRedirectSettings(data.settings);
|
|
115
|
+
}
|
|
116
|
+
function formAcceptsSubmissions(data, originalData) {
|
|
117
|
+
return (data?.status ?? originalData?.status) === "published";
|
|
118
|
+
}
|
|
119
|
+
function storedSettings(value) {
|
|
120
|
+
if (typeof value === "string") {
|
|
121
|
+
try {
|
|
122
|
+
return documentRedirectSettings(JSON.parse(value));
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return documentRedirectSettings(value);
|
|
128
|
+
}
|
|
129
|
+
function storedRedirectReference(originalData, patterns) {
|
|
130
|
+
const picked = storedSettings(originalData?.settings);
|
|
131
|
+
if (!picked) return null;
|
|
132
|
+
const collections = Object.keys(patterns);
|
|
133
|
+
const reference = parseRedirectReference(
|
|
134
|
+
picked.settings[picked.field],
|
|
135
|
+
collections
|
|
136
|
+
);
|
|
137
|
+
if (!reference) return null;
|
|
138
|
+
return {
|
|
139
|
+
reference,
|
|
140
|
+
field: picked.field,
|
|
141
|
+
configured: collections.includes(reference.collection)
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function normalizeStoredReference(settings, field, reference) {
|
|
145
|
+
const stored = settings[field];
|
|
146
|
+
if (typeof stored === "string") {
|
|
147
|
+
if (stored !== reference.id) settings[field] = reference.id;
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (typeof stored !== "object" || stored === null || Array.isArray(stored)) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const record = stored;
|
|
154
|
+
if (record.relationTo !== reference.collection) {
|
|
155
|
+
settings[field] = { ...record, relationTo: reference.collection };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function namedRedirectReference(data, operation, patterns) {
|
|
159
|
+
const picked = pageRedirectSettings(data, operation);
|
|
160
|
+
if (!picked) return null;
|
|
161
|
+
const { settings, field } = picked;
|
|
162
|
+
const collections = Object.keys(patterns);
|
|
163
|
+
const reference = parseRedirectReference(settings[field], collections);
|
|
164
|
+
if (reference) normalizeStoredReference(settings, field, reference);
|
|
165
|
+
return {
|
|
166
|
+
settings,
|
|
167
|
+
field,
|
|
168
|
+
reference,
|
|
169
|
+
configured: reference !== null && collections.includes(reference.collection)
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
function wouldStrandVisitors(data, originalData, target, targetCollectionIsLocalized) {
|
|
173
|
+
return formAcceptsSubmissions(data, originalData) && documentReachability(target, targetCollectionIsLocalized) === "unreachable";
|
|
174
|
+
}
|
|
175
|
+
function publishesForm(data, originalData) {
|
|
176
|
+
if (data?.status !== "published") return false;
|
|
177
|
+
return originalData?.status !== "published";
|
|
178
|
+
}
|
|
179
|
+
function sameTarget(named, stored) {
|
|
180
|
+
if (!named.reference || !stored) return false;
|
|
181
|
+
return stored.field === named.field && stored.reference.collection === named.reference.collection && stored.reference.id === named.reference.id;
|
|
182
|
+
}
|
|
183
|
+
function judgeNamedReference(named, stored) {
|
|
184
|
+
if (sameTarget(named, stored) && named.reference) {
|
|
185
|
+
return {
|
|
186
|
+
reference: named.reference,
|
|
187
|
+
field: named.field,
|
|
188
|
+
chosen: false,
|
|
189
|
+
configured: named.configured
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
if (!named.reference || !named.configured) {
|
|
193
|
+
throw redirectRefusal(
|
|
194
|
+
"Choose a page to redirect to, or pick a different confirmation.",
|
|
195
|
+
named.field
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
reference: named.reference,
|
|
200
|
+
field: named.field,
|
|
201
|
+
chosen: true,
|
|
202
|
+
configured: true
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
function redirectReferenceForWrite(context, patterns) {
|
|
206
|
+
const stored = storedRedirectReference(
|
|
207
|
+
context.originalData,
|
|
208
|
+
patterns
|
|
209
|
+
);
|
|
210
|
+
const named = namedRedirectReference(
|
|
211
|
+
context.data,
|
|
212
|
+
context.operation,
|
|
213
|
+
patterns
|
|
214
|
+
);
|
|
215
|
+
if (named) return judgeNamedReference(named, stored);
|
|
216
|
+
const data = context.data;
|
|
217
|
+
if (data?.settings !== void 0) return null;
|
|
218
|
+
return stored ? { ...stored, chosen: false } : null;
|
|
219
|
+
}
|
|
220
|
+
function assertPatternBuildsUrl(patterns, reference, field, target) {
|
|
221
|
+
let url2;
|
|
222
|
+
try {
|
|
223
|
+
url2 = applyRedirectPattern(patterns[reference.collection], target);
|
|
224
|
+
} catch {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (url2) return;
|
|
228
|
+
throw redirectRefusal(
|
|
229
|
+
`No URL could be built for that page from the pattern configured for "${reference.collection}". Choose another page, or ask a developer to check that collection's redirect pattern.`,
|
|
230
|
+
field
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
async function assertRedirectTargetUsable(context, patterns, localization) {
|
|
234
|
+
const write = redirectReferenceForWrite(context, patterns);
|
|
235
|
+
if (!write) return;
|
|
236
|
+
const { reference, field, chosen, configured } = write;
|
|
237
|
+
if (!configured) {
|
|
238
|
+
if (publishesForm(
|
|
239
|
+
context.data,
|
|
240
|
+
context.originalData
|
|
241
|
+
)) {
|
|
242
|
+
throw redirectRefusal(
|
|
243
|
+
`This form redirects to "${reference.collection}", which is no longer configured as a redirect target, so it has nowhere to send anyone. Choose another page, or pick a different confirmation.`,
|
|
244
|
+
field
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const nextly = context.req?.nextly;
|
|
250
|
+
if (!nextly) return;
|
|
251
|
+
if (context.executor) return;
|
|
252
|
+
const found = await readRedirectTarget(nextly, reference);
|
|
253
|
+
if (found.status === "unreadable") return;
|
|
254
|
+
if (found.status === "missing") {
|
|
255
|
+
if (!chosen && !publishesForm(
|
|
256
|
+
context.data,
|
|
257
|
+
context.originalData
|
|
258
|
+
)) {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
throw redirectRefusal(
|
|
262
|
+
`That page no longer exists in "${reference.collection}". Pick another.`,
|
|
263
|
+
field
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
const judged = chosen || publishesForm(
|
|
267
|
+
context.data,
|
|
268
|
+
context.originalData
|
|
269
|
+
);
|
|
270
|
+
if (judged && wouldStrandVisitors(
|
|
271
|
+
context.data,
|
|
272
|
+
context.originalData,
|
|
273
|
+
found.document,
|
|
274
|
+
localization[reference.collection]
|
|
275
|
+
)) {
|
|
276
|
+
throw redirectRefusal(
|
|
277
|
+
`That page is not published yet, and this form is. A visitor sent there would reach a "page not found". Publish the page, or save this form as a draft until you do.`,
|
|
278
|
+
field
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
if (chosen)
|
|
282
|
+
assertPatternBuildsUrl(patterns, reference, field, found.document);
|
|
283
|
+
}
|
|
284
|
+
async function readRedirectTarget(nextly, reference) {
|
|
285
|
+
try {
|
|
286
|
+
const row = await nextly.findByID({
|
|
287
|
+
collection: reference.collection,
|
|
288
|
+
id: reference.id
|
|
289
|
+
});
|
|
290
|
+
return row ? { status: "ok", document: row } : { status: "missing" };
|
|
291
|
+
} catch (error) {
|
|
292
|
+
return isMissingTarget(error) ? { status: "missing" } : { status: "unreadable" };
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
function isMissingTarget(error) {
|
|
296
|
+
return typeof error === "object" && error !== null && error.code === "NOT_FOUND";
|
|
297
|
+
}
|
|
298
|
+
function stampFirstPublish(context) {
|
|
299
|
+
const data = context.data;
|
|
300
|
+
if (!data) return;
|
|
301
|
+
const stored = context.originalData;
|
|
302
|
+
delete data.wentLiveAt;
|
|
303
|
+
if (recordedStamp(stored) !== void 0) return;
|
|
304
|
+
if (data.status === "published" || stored?.status === "published") {
|
|
305
|
+
data.wentLiveAt = /* @__PURE__ */ new Date();
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
function recordedStamp(stored) {
|
|
309
|
+
const value = stored?.wentLiveAt;
|
|
310
|
+
return value === null || value === "" ? void 0 : value;
|
|
311
|
+
}
|
|
312
|
+
function redirectRefusal(message, field) {
|
|
313
|
+
return NextlyError.validation({
|
|
314
|
+
errors: [{ path: `settings.${field}`, code: "REQUIRED", message }]
|
|
315
|
+
});
|
|
316
|
+
}
|
|
46
317
|
function formsCollection(pluginConfig) {
|
|
47
318
|
const {
|
|
48
319
|
slug,
|
|
49
320
|
labels,
|
|
50
321
|
fields: additionalFields,
|
|
51
322
|
access: accessOverrides,
|
|
323
|
+
hooks: hookOverrides,
|
|
324
|
+
admin: adminOverrides,
|
|
52
325
|
...overrides
|
|
53
326
|
} = pluginConfig.formOverrides;
|
|
327
|
+
const redirectCollections = Object.keys(pluginConfig.redirectRelationships);
|
|
54
328
|
const settingsFields = [
|
|
55
329
|
text({
|
|
56
330
|
name: "submitButtonText",
|
|
@@ -67,7 +341,7 @@ function formsCollection(pluginConfig) {
|
|
|
67
341
|
options: [
|
|
68
342
|
{ label: "Show Message", value: "message" },
|
|
69
343
|
{ label: "Redirect to URL", value: "redirect" },
|
|
70
|
-
...
|
|
344
|
+
...redirectCollections.length > 0 ? [{ label: "Redirect to Page", value: "relationship" }] : []
|
|
71
345
|
],
|
|
72
346
|
admin: {
|
|
73
347
|
description: "What happens after a successful form submission"
|
|
@@ -97,12 +371,12 @@ function formsCollection(pluginConfig) {
|
|
|
97
371
|
}
|
|
98
372
|
})
|
|
99
373
|
];
|
|
100
|
-
if (
|
|
374
|
+
if (redirectCollections.length > 0) {
|
|
101
375
|
settingsFields.push(
|
|
102
376
|
relationship({
|
|
103
377
|
name: "redirectPage",
|
|
104
378
|
label: "Redirect Page",
|
|
105
|
-
relationTo:
|
|
379
|
+
relationTo: redirectCollections,
|
|
106
380
|
admin: {
|
|
107
381
|
description: "Select a page to redirect to after submission",
|
|
108
382
|
condition: {
|
|
@@ -224,6 +498,19 @@ function formsCollection(pluginConfig) {
|
|
|
224
498
|
description: "Only published forms can receive submissions. Closed forms display a message instead."
|
|
225
499
|
}
|
|
226
500
|
}),
|
|
501
|
+
// NOT `firstPublishedAt`, which is the obvious name and the one to reach
|
|
502
|
+
// for next. The framework owns that name on collections it manages the
|
|
503
|
+
// publish lifecycle for, and a write naming it from a plugin collection is
|
|
504
|
+
// stripped on the way to the insert — silently, leaving the column null and
|
|
505
|
+
// every closed form reading as one that was never public.
|
|
506
|
+
date({
|
|
507
|
+
name: "wentLiveAt",
|
|
508
|
+
label: "First Published",
|
|
509
|
+
admin: {
|
|
510
|
+
readOnly: true,
|
|
511
|
+
description: "When this form first went live. Written once and never changed."
|
|
512
|
+
}
|
|
513
|
+
}),
|
|
227
514
|
textarea({
|
|
228
515
|
name: "closedMessage",
|
|
229
516
|
label: "Closed Form Message",
|
|
@@ -265,49 +552,75 @@ function formsCollection(pluginConfig) {
|
|
|
265
552
|
}
|
|
266
553
|
}
|
|
267
554
|
},
|
|
268
|
-
...
|
|
555
|
+
...adminOverrides
|
|
269
556
|
},
|
|
270
557
|
// Access control with sensible defaults
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
558
|
+
// `read: true` — a form is public because a site renders it. The rest are
|
|
559
|
+
// the shared defaults.
|
|
560
|
+
access: accessWithDefaults(accessOverrides, { read: true }),
|
|
561
|
+
hooks: withHostHooks(
|
|
562
|
+
{
|
|
563
|
+
// Auto-generate slug from name if not provided
|
|
564
|
+
beforeValidate: [
|
|
565
|
+
(context) => {
|
|
566
|
+
const { data, operation } = context;
|
|
567
|
+
if (operation === "create" && data && !data.slug && data.name) {
|
|
568
|
+
data.slug = String(data.name).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
569
|
+
}
|
|
570
|
+
const setsFields = operation === "create" || data?.fields !== void 0;
|
|
571
|
+
if (data && setsFields && (data.fields === void 0 || Array.isArray(data.fields) && data.fields.length === 0)) {
|
|
572
|
+
throw NextlyError.validation({
|
|
573
|
+
errors: [
|
|
574
|
+
{
|
|
575
|
+
path: "fields",
|
|
576
|
+
code: "REQUIRED",
|
|
577
|
+
message: "Form must have at least one field."
|
|
578
|
+
}
|
|
579
|
+
]
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
namedRedirectReference(
|
|
583
|
+
data,
|
|
584
|
+
operation,
|
|
585
|
+
pluginConfig.redirectRelationships
|
|
586
|
+
);
|
|
587
|
+
return data;
|
|
287
588
|
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
589
|
+
]
|
|
590
|
+
},
|
|
591
|
+
hookOverrides,
|
|
592
|
+
{
|
|
593
|
+
// The last COLLECTION-level phase, after every host handler in it. A
|
|
594
|
+
// host `beforeValidate` may rewrite the payload once the earlier call
|
|
595
|
+
// has passed, so the check has to run again here.
|
|
596
|
+
//
|
|
597
|
+
// It is not the last mutating point in the write, and saying so would
|
|
598
|
+
// be false: `collection-mutation-service` runs FIELD-level
|
|
599
|
+
// `beforeChange` hooks after this one, and a host may contribute a
|
|
600
|
+
// field through `formOverrides.fields`. Nothing collection-level can
|
|
601
|
+
// sit after that — the next steps are hashing and the insert. What
|
|
602
|
+
// bounds the consequence is the submit path, which resolves an
|
|
603
|
+
// unusable target to NO redirect and logs it, never to a wrong one.
|
|
604
|
+
beforeChange: [
|
|
605
|
+
async (context) => {
|
|
606
|
+
await assertRedirectTargetUsable(
|
|
607
|
+
context,
|
|
608
|
+
pluginConfig.redirectRelationships,
|
|
609
|
+
pluginConfig.redirectTargetLocalization
|
|
610
|
+
);
|
|
611
|
+
stampFirstPublish(context);
|
|
612
|
+
return context.data;
|
|
299
613
|
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
},
|
|
614
|
+
]
|
|
615
|
+
}
|
|
616
|
+
),
|
|
304
617
|
// Spread any additional overrides (excluding already used properties)
|
|
305
618
|
...overrides
|
|
306
619
|
};
|
|
307
620
|
}
|
|
308
621
|
|
|
309
622
|
// src/collections/submissions.ts
|
|
310
|
-
import { text as text2, textarea as textarea2, select as select2, json as json2, date, relationship as relationship2 } from "nextly";
|
|
623
|
+
import { text as text2, textarea as textarea2, select as select2, json as json2, date as date2, relationship as relationship2 } from "nextly";
|
|
311
624
|
function submissionsCollection(pluginConfig) {
|
|
312
625
|
const {
|
|
313
626
|
slug,
|
|
@@ -400,7 +713,7 @@ function submissionsCollection(pluginConfig) {
|
|
|
400
713
|
description: "Browser/device information of the submitter"
|
|
401
714
|
}
|
|
402
715
|
}),
|
|
403
|
-
|
|
716
|
+
date2({
|
|
404
717
|
name: "submittedAt",
|
|
405
718
|
label: "Submitted At",
|
|
406
719
|
required: true,
|
|
@@ -411,7 +724,7 @@ function submissionsCollection(pluginConfig) {
|
|
|
411
724
|
}),
|
|
412
725
|
// Honesty stamps for admin edits of submitted data: a corrected
|
|
413
726
|
// submission must never be indistinguishable from what the visitor sent.
|
|
414
|
-
|
|
727
|
+
date2({
|
|
415
728
|
name: "editedAt",
|
|
416
729
|
label: "Edited At",
|
|
417
730
|
admin: {
|
|
@@ -475,16 +788,9 @@ function submissionsCollection(pluginConfig) {
|
|
|
475
788
|
},
|
|
476
789
|
// Access control with sensible defaults
|
|
477
790
|
// Public can create (submit forms), but only authenticated users can read/update/delete
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
// Only authenticated users can view submissions
|
|
482
|
-
read: accessOverrides?.read ?? (({ user }) => !!user),
|
|
483
|
-
// Only authenticated users can update submissions (e.g., change status, add notes)
|
|
484
|
-
update: accessOverrides?.update ?? (({ user }) => !!user),
|
|
485
|
-
// Only admins can delete submissions (falls back to DB permissions)
|
|
486
|
-
delete: accessOverrides?.delete ?? (({ roles }) => roles.includes("admin") || roles.includes("super-admin"))
|
|
487
|
-
},
|
|
791
|
+
// `create: true` — a visitor submits without an account. Reading, updating
|
|
792
|
+
// and deleting are the shared defaults.
|
|
793
|
+
access: accessWithDefaults(accessOverrides, { create: true }),
|
|
488
794
|
hooks: {
|
|
489
795
|
// Auto-set submittedAt timestamp on creation. (The edit stamp for
|
|
490
796
|
// updates is registered in plugin.ts init — direct registry hooks are
|
|
@@ -521,140 +827,527 @@ function asFormDocument(row) {
|
|
|
521
827
|
return row;
|
|
522
828
|
}
|
|
523
829
|
|
|
524
|
-
// src/
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
const
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
}
|
|
539
|
-
},
|
|
540
|
-
formSubmissionOverrides: {
|
|
541
|
-
...submissionOverrides,
|
|
542
|
-
slug: submissionOverrides.slug || "form-submissions",
|
|
543
|
-
labels: {
|
|
544
|
-
singular: submissionOverrides.labels?.singular || "Submission",
|
|
545
|
-
plural: submissionOverrides.labels?.plural || "Submissions"
|
|
546
|
-
}
|
|
547
|
-
},
|
|
548
|
-
fields: {
|
|
549
|
-
text: options.fields?.text ?? true,
|
|
550
|
-
email: options.fields?.email ?? true,
|
|
551
|
-
number: options.fields?.number ?? true,
|
|
552
|
-
phone: options.fields?.phone ?? true,
|
|
553
|
-
url: options.fields?.url ?? true,
|
|
554
|
-
textarea: options.fields?.textarea ?? true,
|
|
555
|
-
select: options.fields?.select ?? true,
|
|
556
|
-
checkbox: options.fields?.checkbox ?? true,
|
|
557
|
-
radio: options.fields?.radio ?? true,
|
|
558
|
-
file: options.fields?.file ?? true,
|
|
559
|
-
date: options.fields?.date ?? true,
|
|
560
|
-
time: options.fields?.time ?? true,
|
|
561
|
-
hidden: options.fields?.hidden ?? true
|
|
562
|
-
},
|
|
563
|
-
redirectRelationships: options.redirectRelationships || [],
|
|
564
|
-
beforeEmail: options.beforeEmail,
|
|
565
|
-
notifications: {
|
|
566
|
-
defaultFrom: options.notifications?.defaultFrom,
|
|
567
|
-
defaultToEmail: options.notifications?.defaultToEmail,
|
|
568
|
-
enabled: options.notifications?.enabled ?? true
|
|
569
|
-
},
|
|
570
|
-
spamProtection: {
|
|
571
|
-
honeypot: options.spamProtection?.honeypot ?? true,
|
|
572
|
-
recaptcha: options.spamProtection?.recaptcha ?? { enabled: false },
|
|
573
|
-
rateLimit: {
|
|
574
|
-
maxSubmissions: options.spamProtection?.rateLimit?.maxSubmissions ?? 10,
|
|
575
|
-
windowMs: options.spamProtection?.rateLimit?.windowMs ?? 6e4
|
|
576
|
-
}
|
|
577
|
-
},
|
|
578
|
-
uploads: {
|
|
579
|
-
maxFileSize: options.uploads?.maxFileSize ?? 10485760,
|
|
580
|
-
// 10 MB
|
|
581
|
-
allowedMimeTypes: options.uploads?.allowedMimeTypes ?? [
|
|
582
|
-
"image/*",
|
|
583
|
-
"application/pdf",
|
|
584
|
-
"text/*"
|
|
585
|
-
],
|
|
586
|
-
uploadCollection: options.uploads?.uploadCollection ?? "media"
|
|
587
|
-
},
|
|
588
|
-
features: {
|
|
589
|
-
builder: options.features?.builder ?? true,
|
|
590
|
-
conditionalLogic: options.features?.conditionalLogic ?? true,
|
|
591
|
-
fileUploads: options.features?.fileUploads ?? true
|
|
830
|
+
// src/handlers/prepare-submission.ts
|
|
831
|
+
import { stripHtmlTags } from "nextly";
|
|
832
|
+
|
|
833
|
+
// src/utils/generate-schema.ts
|
|
834
|
+
import { z } from "zod";
|
|
835
|
+
function generateZodSchema(fields) {
|
|
836
|
+
const schemaShape = {};
|
|
837
|
+
for (const field of fields) {
|
|
838
|
+
if (!isKnownFormField(field)) {
|
|
839
|
+
schemaShape[field.name] = field.required ? z.unknown().refine(
|
|
840
|
+
(value) => value !== void 0 && value !== null && value !== "",
|
|
841
|
+
{ message: `${field.label || field.name} is required` }
|
|
842
|
+
) : z.unknown();
|
|
843
|
+
continue;
|
|
592
844
|
}
|
|
593
|
-
|
|
845
|
+
if (field.type === "hidden" && !field.required) {
|
|
846
|
+
schemaShape[field.name] = z.string().optional();
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
849
|
+
const fieldSchema = generateFieldSchema(field);
|
|
850
|
+
if (fieldSchema) {
|
|
851
|
+
schemaShape[field.name] = fieldSchema;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
return z.object(schemaShape);
|
|
594
855
|
}
|
|
595
|
-
function
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
856
|
+
function generateFieldSchema(field) {
|
|
857
|
+
switch (field.type) {
|
|
858
|
+
case "text":
|
|
859
|
+
return generateTextSchema(field);
|
|
860
|
+
case "email":
|
|
861
|
+
return generateEmailSchema(field);
|
|
862
|
+
case "number":
|
|
863
|
+
return generateNumberSchema(field);
|
|
864
|
+
case "phone":
|
|
865
|
+
return generatePhoneSchema(field);
|
|
866
|
+
case "url":
|
|
867
|
+
return generateUrlSchema(field);
|
|
868
|
+
case "textarea":
|
|
869
|
+
return generateTextareaSchema(field);
|
|
870
|
+
case "select":
|
|
871
|
+
return generateSelectSchema(field);
|
|
872
|
+
case "checkbox":
|
|
873
|
+
return generateCheckboxSchema(field);
|
|
874
|
+
case "radio":
|
|
875
|
+
return generateRadioSchema(field);
|
|
876
|
+
case "file":
|
|
877
|
+
return generateFileSchema(field);
|
|
878
|
+
case "date":
|
|
879
|
+
return generateDateSchema(field);
|
|
880
|
+
case "time":
|
|
881
|
+
return generateTimeSchema(field);
|
|
882
|
+
case "hidden":
|
|
883
|
+
return generateHiddenSchema(field);
|
|
884
|
+
default:
|
|
885
|
+
return z.unknown();
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
function customMessage(field) {
|
|
889
|
+
const enforced = ENFORCED_VALIDATION_RULES[field.type] ?? [];
|
|
890
|
+
if (!enforced.includes("message")) return void 0;
|
|
891
|
+
return field.validation?.errorMessage;
|
|
892
|
+
}
|
|
893
|
+
function enforcedValidation(field) {
|
|
894
|
+
const stored = field.validation ?? {};
|
|
895
|
+
return { ...stored, errorMessage: customMessage(field) };
|
|
896
|
+
}
|
|
897
|
+
var STRING_RULE_APPLIERS = {
|
|
898
|
+
minLength: (schema, validation) => validation.minLength === void 0 ? schema : schema.min(
|
|
899
|
+
validation.minLength,
|
|
900
|
+
validation.errorMessage || `Minimum ${validation.minLength} characters required`
|
|
901
|
+
),
|
|
902
|
+
maxLength: (schema, validation) => validation.maxLength === void 0 ? schema : schema.max(
|
|
903
|
+
validation.maxLength,
|
|
904
|
+
validation.errorMessage || `Maximum ${validation.maxLength} characters allowed`
|
|
905
|
+
),
|
|
906
|
+
pattern: (schema, validation, fallback) => validation.pattern ? schema.regex(
|
|
907
|
+
new RegExp(validation.pattern),
|
|
908
|
+
validation.errorMessage || fallback
|
|
909
|
+
) : schema
|
|
910
|
+
};
|
|
911
|
+
var NUMBER_RULE_APPLIERS = {
|
|
912
|
+
min: (schema, validation) => validation.min === void 0 ? schema : schema.min(
|
|
913
|
+
validation.min,
|
|
914
|
+
validation.errorMessage || `Minimum value is ${validation.min}`
|
|
915
|
+
),
|
|
916
|
+
max: (schema, validation) => validation.max === void 0 ? schema : schema.max(
|
|
917
|
+
validation.max,
|
|
918
|
+
validation.errorMessage || `Maximum value is ${validation.max}`
|
|
919
|
+
)
|
|
920
|
+
};
|
|
921
|
+
function overridesPattern(field) {
|
|
922
|
+
const enforced = ENFORCED_VALIDATION_RULES[field.type] ?? [];
|
|
923
|
+
if (!enforced.includes("pattern")) return false;
|
|
924
|
+
const validation = field.validation ?? {};
|
|
925
|
+
return Boolean(validation.pattern);
|
|
926
|
+
}
|
|
927
|
+
function applyStringRules(schema, field, patternMessage) {
|
|
928
|
+
const validation = enforcedValidation(field);
|
|
929
|
+
let constrained = schema;
|
|
930
|
+
for (const rule of ENFORCED_VALIDATION_RULES[field.type] ?? []) {
|
|
931
|
+
const apply = STRING_RULE_APPLIERS[rule];
|
|
932
|
+
if (apply) constrained = apply(constrained, validation, patternMessage);
|
|
933
|
+
}
|
|
934
|
+
return constrained;
|
|
935
|
+
}
|
|
936
|
+
function generateTextSchema(field) {
|
|
937
|
+
const schema = applyStringRules(z.string(), field, "Invalid format");
|
|
938
|
+
return applyRequired(schema, field.required, customMessage(field));
|
|
939
|
+
}
|
|
940
|
+
function generateEmailSchema(field) {
|
|
941
|
+
const schema = applyStringRules(
|
|
942
|
+
z.string().email(customMessage(field) || "Invalid email address"),
|
|
943
|
+
field,
|
|
944
|
+
"Invalid email format"
|
|
945
|
+
);
|
|
946
|
+
return applyRequired(schema, field.required, customMessage(field));
|
|
947
|
+
}
|
|
948
|
+
function generateNumberSchema(field) {
|
|
949
|
+
let schema = z.number({
|
|
950
|
+
error: (issue) => issue.input === void 0 ? customMessage(field) || "This field is required" : "Must be a number"
|
|
951
|
+
});
|
|
952
|
+
const validation = enforcedValidation(field);
|
|
953
|
+
for (const rule of ENFORCED_VALIDATION_RULES[field.type] ?? []) {
|
|
954
|
+
const apply = NUMBER_RULE_APPLIERS[rule];
|
|
955
|
+
if (apply) schema = apply(schema, validation);
|
|
956
|
+
}
|
|
957
|
+
if (field.required) {
|
|
958
|
+
return schema;
|
|
959
|
+
}
|
|
960
|
+
return schema.optional().nullable();
|
|
961
|
+
}
|
|
962
|
+
function generatePhoneSchema(field) {
|
|
963
|
+
const base = overridesPattern(field) ? z.string() : z.string().regex(
|
|
964
|
+
/^[\d\s\-+()]+$/,
|
|
965
|
+
customMessage(field) || "Invalid phone number"
|
|
966
|
+
);
|
|
967
|
+
const schema = applyStringRules(base, field, "Invalid phone number format");
|
|
968
|
+
return applyRequired(schema, field.required, customMessage(field));
|
|
969
|
+
}
|
|
970
|
+
function generateUrlSchema(field) {
|
|
971
|
+
const schema = applyStringRules(
|
|
972
|
+
z.string().url(customMessage(field) || "Invalid URL"),
|
|
973
|
+
field,
|
|
974
|
+
"Invalid URL format"
|
|
975
|
+
);
|
|
976
|
+
return applyRequired(schema, field.required, customMessage(field));
|
|
977
|
+
}
|
|
978
|
+
function generateTextareaSchema(field) {
|
|
979
|
+
const schema = applyStringRules(z.string(), field, "Invalid format");
|
|
980
|
+
return applyRequired(schema, field.required, customMessage(field));
|
|
981
|
+
}
|
|
982
|
+
function generateSelectSchema(field) {
|
|
983
|
+
const validValues = field.options.map((opt) => opt.value);
|
|
984
|
+
if (field.allowMultiple) {
|
|
985
|
+
const schema2 = z.array(z.enum(validValues));
|
|
986
|
+
if (field.required) {
|
|
987
|
+
return schema2.min(
|
|
988
|
+
1,
|
|
989
|
+
customMessage(field) || "Please select at least one option"
|
|
990
|
+
);
|
|
991
|
+
}
|
|
992
|
+
return schema2.optional();
|
|
993
|
+
}
|
|
994
|
+
const schema = z.enum(validValues, {
|
|
995
|
+
error: () => customMessage(field) || "Please select a valid option"
|
|
996
|
+
});
|
|
997
|
+
if (field.required) {
|
|
998
|
+
return schema;
|
|
999
|
+
}
|
|
1000
|
+
return schema.optional();
|
|
1001
|
+
}
|
|
1002
|
+
function generateCheckboxSchema(field) {
|
|
1003
|
+
const schema = z.boolean();
|
|
1004
|
+
if (field.required) {
|
|
1005
|
+
return schema.refine((val) => val === true, {
|
|
1006
|
+
message: customMessage(field) || "This field is required"
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
return schema.optional();
|
|
1010
|
+
}
|
|
1011
|
+
function generateRadioSchema(field) {
|
|
1012
|
+
const validValues = field.options.map((opt) => opt.value);
|
|
1013
|
+
const schema = z.enum(validValues, {
|
|
1014
|
+
error: () => customMessage(field) || "Please select an option"
|
|
1015
|
+
});
|
|
1016
|
+
if (field.required) {
|
|
1017
|
+
return schema;
|
|
1018
|
+
}
|
|
1019
|
+
return schema.optional();
|
|
1020
|
+
}
|
|
1021
|
+
function generateFileSchema(field) {
|
|
1022
|
+
if (field.multiple) {
|
|
1023
|
+
const schema2 = z.array(z.string());
|
|
1024
|
+
if (field.required) {
|
|
1025
|
+
return schema2.min(
|
|
1026
|
+
1,
|
|
1027
|
+
customMessage(field) || "Please upload at least one file"
|
|
1028
|
+
);
|
|
1029
|
+
}
|
|
1030
|
+
return schema2.optional();
|
|
1031
|
+
}
|
|
1032
|
+
const schema = z.string();
|
|
1033
|
+
return applyRequired(schema, field.required, customMessage(field));
|
|
1034
|
+
}
|
|
1035
|
+
function generateDateSchema(field) {
|
|
1036
|
+
let schema = z.string();
|
|
1037
|
+
schema = schema.refine(
|
|
1038
|
+
(val) => {
|
|
1039
|
+
if (!val) return true;
|
|
1040
|
+
const date4 = new Date(val);
|
|
1041
|
+
return !isNaN(date4.getTime());
|
|
1042
|
+
},
|
|
1043
|
+
{ message: customMessage(field) || "Invalid date" }
|
|
1044
|
+
);
|
|
1045
|
+
if (field.min || field.max) {
|
|
1046
|
+
schema = schema.refine(
|
|
1047
|
+
(val) => {
|
|
1048
|
+
if (!val) return true;
|
|
1049
|
+
const date4 = new Date(val);
|
|
1050
|
+
if (field.min && date4 < new Date(field.min)) return false;
|
|
1051
|
+
if (field.max && date4 > new Date(field.max)) return false;
|
|
1052
|
+
return true;
|
|
1053
|
+
},
|
|
1054
|
+
{
|
|
1055
|
+
message: customMessage(field) || `Date must be between ${field.min || "any"} and ${field.max || "any"}`
|
|
1056
|
+
}
|
|
1057
|
+
);
|
|
1058
|
+
}
|
|
1059
|
+
if (field.required) {
|
|
1060
|
+
return schema.refine(
|
|
1061
|
+
(val) => val !== void 0 && val !== null && val !== "",
|
|
1062
|
+
{ message: customMessage(field) || "This field is required" }
|
|
1063
|
+
);
|
|
1064
|
+
}
|
|
1065
|
+
return schema.optional().or(z.literal(""));
|
|
1066
|
+
}
|
|
1067
|
+
function generateTimeSchema(field) {
|
|
1068
|
+
const schema = z.string().regex(
|
|
1069
|
+
/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/,
|
|
1070
|
+
customMessage(field) || "Invalid time format (HH:mm)"
|
|
1071
|
+
);
|
|
1072
|
+
return applyRequired(schema, field.required, customMessage(field));
|
|
1073
|
+
}
|
|
1074
|
+
function generateHiddenSchema(field) {
|
|
1075
|
+
const schema = z.string();
|
|
1076
|
+
return applyRequired(schema, field.required, customMessage(field));
|
|
1077
|
+
}
|
|
1078
|
+
function applyRequired(schema, required, errorMessage) {
|
|
1079
|
+
if (required) {
|
|
1080
|
+
return schema.min(1, errorMessage || "This field is required");
|
|
1081
|
+
}
|
|
1082
|
+
return schema.optional().or(z.literal(""));
|
|
1083
|
+
}
|
|
1084
|
+
function transformFormData(data, fields) {
|
|
1085
|
+
const transformed = {};
|
|
1086
|
+
for (const field of fields) {
|
|
1087
|
+
const value = data[field.name];
|
|
1088
|
+
if (value === void 0) {
|
|
1089
|
+
continue;
|
|
1090
|
+
}
|
|
1091
|
+
transformed[field.name] = isKnownFormField(field) ? transformFieldValue(value, field) : value;
|
|
1092
|
+
}
|
|
1093
|
+
return transformed;
|
|
1094
|
+
}
|
|
1095
|
+
function transformFieldValue(value, field) {
|
|
1096
|
+
if (value === null || value === void 0) {
|
|
1097
|
+
return value;
|
|
1098
|
+
}
|
|
1099
|
+
switch (field.type) {
|
|
1100
|
+
case "number":
|
|
1101
|
+
if (typeof value === "string") {
|
|
1102
|
+
const num = parseFloat(value);
|
|
1103
|
+
return isNaN(num) ? value : num;
|
|
1104
|
+
}
|
|
1105
|
+
return value;
|
|
1106
|
+
case "checkbox":
|
|
1107
|
+
if (typeof value === "string") {
|
|
1108
|
+
return value === "true" || value === "1" || value === "on";
|
|
1109
|
+
}
|
|
1110
|
+
return Boolean(value);
|
|
1111
|
+
case "text":
|
|
1112
|
+
case "email":
|
|
1113
|
+
case "phone":
|
|
1114
|
+
case "url":
|
|
1115
|
+
case "textarea":
|
|
1116
|
+
case "hidden":
|
|
1117
|
+
if (typeof value === "string") {
|
|
1118
|
+
return value.trim();
|
|
1119
|
+
}
|
|
1120
|
+
return value;
|
|
1121
|
+
case "select":
|
|
1122
|
+
if (field.allowMultiple) {
|
|
1123
|
+
if (!Array.isArray(value)) {
|
|
1124
|
+
return value ? [value] : [];
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
return value;
|
|
1128
|
+
default:
|
|
1129
|
+
return value;
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
function validateFormData(data, fields) {
|
|
1133
|
+
const transformed = transformFormData(data, fields);
|
|
1134
|
+
const schema = generateZodSchema(fields);
|
|
1135
|
+
return schema.safeParse(transformed);
|
|
1136
|
+
}
|
|
1137
|
+
function getValidationErrors(result) {
|
|
1138
|
+
if (result.success) {
|
|
1139
|
+
return {};
|
|
1140
|
+
}
|
|
1141
|
+
const errors = {};
|
|
1142
|
+
for (const error of result.error.issues) {
|
|
1143
|
+
const path = error.path.join(".");
|
|
1144
|
+
if (!errors[path]) {
|
|
1145
|
+
errors[path] = error.message;
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
return errors;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// src/handlers/prepare-submission.ts
|
|
1152
|
+
var TEXT_FORM_FIELDS = /* @__PURE__ */ new Set([
|
|
1153
|
+
"text",
|
|
1154
|
+
"email",
|
|
1155
|
+
"textarea",
|
|
1156
|
+
"phone",
|
|
1157
|
+
"url",
|
|
1158
|
+
"hidden"
|
|
1159
|
+
]);
|
|
1160
|
+
function sanitizeSubmissionData(data, fields) {
|
|
1161
|
+
for (const field of fields) {
|
|
1162
|
+
if (!TEXT_FORM_FIELDS.has(field.type)) continue;
|
|
1163
|
+
const value = data[field.name];
|
|
1164
|
+
if (typeof value !== "string") continue;
|
|
1165
|
+
data[field.name] = stripHtmlTags(value);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
var PLUGIN_SUBMISSION = Symbol.for("nextly.plugin-form-builder.submission");
|
|
1169
|
+
function asPluginSubmission(row, marks) {
|
|
1170
|
+
return Object.defineProperty(row, PLUGIN_SUBMISSION, {
|
|
1171
|
+
value: marks,
|
|
1172
|
+
enumerable: true,
|
|
1173
|
+
configurable: true
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
function submissionMarks(row) {
|
|
1177
|
+
return row[PLUGIN_SUBMISSION];
|
|
1178
|
+
}
|
|
1179
|
+
function sameValue(left, right) {
|
|
1180
|
+
if (Object.is(left, right)) return true;
|
|
1181
|
+
if (left instanceof Date && right instanceof Date) {
|
|
1182
|
+
return left.getTime() === right.getTime();
|
|
1183
|
+
}
|
|
1184
|
+
if (Array.isArray(left) && Array.isArray(right)) {
|
|
1185
|
+
return left.length === right.length && left.every((value, index) => sameValue(value, right[index]));
|
|
1186
|
+
}
|
|
1187
|
+
return false;
|
|
1188
|
+
}
|
|
1189
|
+
function sameSubmittedPayload(granted, incoming) {
|
|
1190
|
+
const answers = Object.entries(granted);
|
|
1191
|
+
if (answers.length !== Object.keys(incoming).length) return false;
|
|
1192
|
+
return answers.every(([field, answer]) => sameValue(answer, incoming[field]));
|
|
1193
|
+
}
|
|
1194
|
+
function prepareSubmission({
|
|
1195
|
+
data,
|
|
1196
|
+
fields,
|
|
1197
|
+
validate
|
|
1198
|
+
}) {
|
|
1199
|
+
const transformed = transformFormData(data, fields);
|
|
1200
|
+
sanitizeSubmissionData(transformed, fields);
|
|
1201
|
+
if (!validate) {
|
|
1202
|
+
return { data: transformed };
|
|
1203
|
+
}
|
|
1204
|
+
const result = generateZodSchema(fields).safeParse(transformed);
|
|
1205
|
+
if (!result.success) {
|
|
1206
|
+
return { data: transformed, validationErrors: getValidationErrors(result) };
|
|
1207
|
+
}
|
|
1208
|
+
return { data: result.data };
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
// src/plugin.ts
|
|
1212
|
+
var { version: PLUGIN_VERSION } = createRequire(import.meta.url)(
|
|
1213
|
+
"../package.json"
|
|
1214
|
+
);
|
|
1215
|
+
function resolveConfig(options) {
|
|
1216
|
+
const formOverrides = options.formOverrides || {};
|
|
1217
|
+
const submissionOverrides = options.formSubmissionOverrides || {};
|
|
1218
|
+
return {
|
|
1219
|
+
formOverrides: {
|
|
1220
|
+
...formOverrides,
|
|
1221
|
+
slug: formOverrides.slug || "forms",
|
|
1222
|
+
labels: {
|
|
1223
|
+
singular: formOverrides.labels?.singular || "Form",
|
|
1224
|
+
plural: formOverrides.labels?.plural || "Forms"
|
|
1225
|
+
}
|
|
1226
|
+
},
|
|
1227
|
+
formSubmissionOverrides: {
|
|
1228
|
+
...submissionOverrides,
|
|
1229
|
+
slug: submissionOverrides.slug || "form-submissions",
|
|
1230
|
+
labels: {
|
|
1231
|
+
singular: submissionOverrides.labels?.singular || "Submission",
|
|
1232
|
+
plural: submissionOverrides.labels?.plural || "Submissions"
|
|
1233
|
+
}
|
|
1234
|
+
},
|
|
1235
|
+
fields: {
|
|
1236
|
+
text: options.fields?.text ?? true,
|
|
1237
|
+
email: options.fields?.email ?? true,
|
|
1238
|
+
number: options.fields?.number ?? true,
|
|
1239
|
+
phone: options.fields?.phone ?? true,
|
|
1240
|
+
url: options.fields?.url ?? true,
|
|
1241
|
+
textarea: options.fields?.textarea ?? true,
|
|
1242
|
+
select: options.fields?.select ?? true,
|
|
1243
|
+
checkbox: options.fields?.checkbox ?? true,
|
|
1244
|
+
radio: options.fields?.radio ?? true,
|
|
1245
|
+
file: options.fields?.file ?? true,
|
|
1246
|
+
date: options.fields?.date ?? true,
|
|
1247
|
+
time: options.fields?.time ?? true,
|
|
1248
|
+
hidden: options.fields?.hidden ?? true
|
|
1249
|
+
},
|
|
1250
|
+
redirectRelationships: normalizeRedirectRelationships(
|
|
1251
|
+
options.redirectRelationships
|
|
1252
|
+
),
|
|
1253
|
+
// Filled at `init` from each collection's own metadata. Empty until then,
|
|
1254
|
+
// and a slug that stays absent reads as unknown rather than as not
|
|
1255
|
+
// localized — see `ResolvedFormBuilderConfig`.
|
|
1256
|
+
redirectTargetLocalization: {},
|
|
1257
|
+
beforeEmail: options.beforeEmail,
|
|
1258
|
+
notifications: {
|
|
1259
|
+
defaultFrom: options.notifications?.defaultFrom,
|
|
1260
|
+
defaultToEmail: options.notifications?.defaultToEmail,
|
|
1261
|
+
enabled: options.notifications?.enabled ?? true
|
|
1262
|
+
},
|
|
1263
|
+
spamProtection: {
|
|
1264
|
+
honeypot: options.spamProtection?.honeypot ?? true,
|
|
1265
|
+
recaptcha: options.spamProtection?.recaptcha ?? { enabled: false },
|
|
1266
|
+
rateLimit: {
|
|
1267
|
+
maxSubmissions: options.spamProtection?.rateLimit?.maxSubmissions ?? 10,
|
|
1268
|
+
windowMs: options.spamProtection?.rateLimit?.windowMs ?? 6e4
|
|
1269
|
+
}
|
|
1270
|
+
},
|
|
1271
|
+
uploads: {
|
|
1272
|
+
maxFileSize: options.uploads?.maxFileSize ?? 10485760,
|
|
1273
|
+
// 10 MB
|
|
1274
|
+
allowedMimeTypes: options.uploads?.allowedMimeTypes ?? [
|
|
1275
|
+
"image/*",
|
|
1276
|
+
"application/pdf",
|
|
1277
|
+
"text/*"
|
|
1278
|
+
],
|
|
1279
|
+
uploadCollection: options.uploads?.uploadCollection ?? "media"
|
|
1280
|
+
},
|
|
1281
|
+
features: {
|
|
1282
|
+
builder: options.features?.builder ?? true,
|
|
1283
|
+
conditionalLogic: options.features?.conditionalLogic ?? true,
|
|
1284
|
+
fileUploads: options.features?.fileUploads ?? true
|
|
1285
|
+
}
|
|
1286
|
+
};
|
|
1287
|
+
}
|
|
1288
|
+
function formBuilder(options = {}) {
|
|
1289
|
+
const resolvedConfig = resolveConfig(options);
|
|
1290
|
+
const formsCol = formsCollection(resolvedConfig);
|
|
1291
|
+
const submissionsCol = submissionsCollection(resolvedConfig);
|
|
1292
|
+
const plugin = definePlugin({
|
|
1293
|
+
name: "@nextlyhq/plugin-form-builder",
|
|
1294
|
+
version: PLUGIN_VERSION,
|
|
1295
|
+
nextly: ">=0.0.2-alpha.21",
|
|
1296
|
+
// Identity metadata for the admin plugins page, mirroring package.json.
|
|
1297
|
+
author: "Nextly",
|
|
1298
|
+
homepage: "https://nextlyhq.com",
|
|
1299
|
+
repository: "https://github.com/nextlyhq/nextly",
|
|
1300
|
+
license: "MIT",
|
|
1301
|
+
category: "forms",
|
|
1302
|
+
tags: ["forms", "submissions", "email-notifications"],
|
|
1303
|
+
// Declarative schema: the merged pipeline folds these into the
|
|
1304
|
+
// app schema — no manual `setup()` append needed. Just register the plugin.
|
|
1305
|
+
contributes: {
|
|
1306
|
+
collections: [formsCol, submissionsCol],
|
|
1307
|
+
// Custom permission — gates submission export beyond CRUD. The
|
|
1308
|
+
// canonical example third-party plugin authors copy.
|
|
1309
|
+
permissions: [
|
|
1310
|
+
{
|
|
1311
|
+
action: "export",
|
|
1312
|
+
resource: "submissions",
|
|
1313
|
+
label: "Export Submissions",
|
|
1314
|
+
description: "Export form submissions as CSV/JSON",
|
|
1315
|
+
// No `group`: the admin already files this under the plugin that
|
|
1316
|
+
// declared it, so naming the plugin again would nest it inside
|
|
1317
|
+
// itself. `group` is for a plugin with enough permissions to sort
|
|
1318
|
+
// its own into headings, which one is not.
|
|
1319
|
+
//
|
|
1320
|
+
// `danger` because the point of the permission is to take
|
|
1321
|
+
// submissions — names, emails, whatever a form asked for — out of
|
|
1322
|
+
// the site in a file.
|
|
1323
|
+
danger: true
|
|
1324
|
+
}
|
|
1325
|
+
],
|
|
1326
|
+
// HTTP route — exported at
|
|
1327
|
+
// /api/plugins/@nextlyhq/plugin-form-builder/submissions/export. Secure by
|
|
1328
|
+
// default: gated by the custom `export-submissions` permission. Reads
|
|
1329
|
+
// via the secure-by-default service path as the authed user and
|
|
1330
|
+
// resolves its OWN slug through `ctx.self`. The canonical
|
|
1331
|
+
// contributes.routes example for third-party authors.
|
|
1332
|
+
routes: [
|
|
1333
|
+
{
|
|
1334
|
+
method: "GET",
|
|
1335
|
+
path: "/submissions/export",
|
|
1336
|
+
requiredPermission: "export-submissions",
|
|
1337
|
+
handler: async (req, ctx) => {
|
|
1338
|
+
const declaredSlug = resolvedConfig.formSubmissionOverrides.slug;
|
|
1339
|
+
const slug = ctx.self.collections[declaredSlug] ?? declaredSlug;
|
|
1340
|
+
const url2 = new URL(req.url);
|
|
1341
|
+
const format = url2.searchParams.get("format") ?? "json";
|
|
1342
|
+
const formId = url2.searchParams.get("form");
|
|
1343
|
+
const status = url2.searchParams.get("status");
|
|
1344
|
+
const where = {};
|
|
1345
|
+
if (formId) where.form = { equals: formId };
|
|
1346
|
+
if (status) where.status = { equals: status };
|
|
1347
|
+
else where.status = { not_equals: "spam" };
|
|
1348
|
+
const opts = { as: "user", user: ctx.user ?? void 0 };
|
|
1349
|
+
let form;
|
|
1350
|
+
if (format === "csv") {
|
|
658
1351
|
if (!formId) {
|
|
659
1352
|
return Response.json(
|
|
660
1353
|
{
|
|
@@ -750,6 +1443,12 @@ function formBuilder(options = {}) {
|
|
|
750
1443
|
// Runtime-resolved collection slugs (through ctx.self, so a
|
|
751
1444
|
// framework .rename() is honored too) — admin components
|
|
752
1445
|
// never hardcode "forms"/"form-submissions".
|
|
1446
|
+
// Names only. A pattern may be a function, which does not
|
|
1447
|
+
// survive JSON and which the browser has no use for — the
|
|
1448
|
+
// admin picks a document, the server builds the URL.
|
|
1449
|
+
redirectCollections: Object.keys(
|
|
1450
|
+
resolvedConfig.redirectRelationships
|
|
1451
|
+
),
|
|
753
1452
|
slugs: {
|
|
754
1453
|
forms: ctx.self.collections[resolvedConfig.formOverrides.slug] ?? resolvedConfig.formOverrides.slug,
|
|
755
1454
|
submissions: ctx.self.collections[resolvedConfig.formSubmissionOverrides.slug] ?? resolvedConfig.formSubmissionOverrides.slug
|
|
@@ -776,7 +1475,7 @@ function formBuilder(options = {}) {
|
|
|
776
1475
|
},
|
|
777
1476
|
// -- Init ----------------------------------------------------------------
|
|
778
1477
|
// Registers an afterCreate hook on submissions to send email notifications.
|
|
779
|
-
init(nextly) {
|
|
1478
|
+
async init(nextly) {
|
|
780
1479
|
const declaredSubmissionsSlug = resolvedConfig.formSubmissionOverrides.slug;
|
|
781
1480
|
const submissionSlug = nextly.self.collections[declaredSubmissionsSlug] ?? declaredSubmissionsSlug;
|
|
782
1481
|
nextly.logger.info("Form Builder plugin initialized", {
|
|
@@ -784,6 +1483,30 @@ function formBuilder(options = {}) {
|
|
|
784
1483
|
submissionsCollection: submissionSlug,
|
|
785
1484
|
enabledFields: Object.entries(resolvedConfig.fields).filter(([, on]) => on).map(([name]) => name)
|
|
786
1485
|
});
|
|
1486
|
+
for (const slug of Object.keys(
|
|
1487
|
+
resolvedConfig.redirectTargetLocalization
|
|
1488
|
+
)) {
|
|
1489
|
+
delete resolvedConfig.redirectTargetLocalization[slug];
|
|
1490
|
+
}
|
|
1491
|
+
await Promise.all(
|
|
1492
|
+
Object.keys(resolvedConfig.redirectRelationships).map(
|
|
1493
|
+
async (declared) => {
|
|
1494
|
+
const slug = nextly.self.collections[declared] ?? declared;
|
|
1495
|
+
try {
|
|
1496
|
+
const collection = await nextly.services.collections.getCollection(slug, {});
|
|
1497
|
+
const localized = collection?.localized;
|
|
1498
|
+
if (typeof localized === "boolean") {
|
|
1499
|
+
resolvedConfig.redirectTargetLocalization[declared] = localized;
|
|
1500
|
+
}
|
|
1501
|
+
} catch {
|
|
1502
|
+
nextly.logger.debug?.(
|
|
1503
|
+
"Redirect target localization could not be read",
|
|
1504
|
+
{ collection: slug }
|
|
1505
|
+
);
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
)
|
|
1509
|
+
);
|
|
787
1510
|
nextly.__formBuilderConfig = resolvedConfig;
|
|
788
1511
|
if (resolvedConfig.beforeEmail) {
|
|
789
1512
|
nextly.filters.add(
|
|
@@ -796,6 +1519,13 @@ function formBuilder(options = {}) {
|
|
|
796
1519
|
})
|
|
797
1520
|
);
|
|
798
1521
|
}
|
|
1522
|
+
const declaredFormsSlug = resolvedConfig.formOverrides.slug;
|
|
1523
|
+
const formsSlug = nextly.self.collections[declaredFormsSlug] ?? declaredFormsSlug;
|
|
1524
|
+
nextly.hooks.on(
|
|
1525
|
+
"beforeChange",
|
|
1526
|
+
submissionSlug,
|
|
1527
|
+
async (context) => prepareSubmissionForWrite(context, formsSlug, nextly)
|
|
1528
|
+
);
|
|
799
1529
|
nextly.hooks.on(
|
|
800
1530
|
"afterCreate",
|
|
801
1531
|
submissionSlug,
|
|
@@ -811,8 +1541,6 @@ function formBuilder(options = {}) {
|
|
|
811
1541
|
}
|
|
812
1542
|
return ctx.data;
|
|
813
1543
|
});
|
|
814
|
-
const declaredFormsSlug = resolvedConfig.formOverrides.slug;
|
|
815
|
-
const formsSlug = nextly.self.collections[declaredFormsSlug] ?? declaredFormsSlug;
|
|
816
1544
|
nextly.hooks.on("afterRead", formsSlug, async (context) => {
|
|
817
1545
|
const data = context.data;
|
|
818
1546
|
const records = Array.isArray(data) ? data : data ? [data] : [];
|
|
@@ -850,9 +1578,9 @@ function getFormBuilderConfig(nextly) {
|
|
|
850
1578
|
return nextly.__formBuilderConfig;
|
|
851
1579
|
}
|
|
852
1580
|
function resolveFieldRef(ref, data) {
|
|
853
|
-
const
|
|
854
|
-
if (
|
|
855
|
-
const value = data[
|
|
1581
|
+
const name = parseFieldRef(ref);
|
|
1582
|
+
if (name === null) return ref;
|
|
1583
|
+
const value = data[name];
|
|
856
1584
|
return typeof value === "string" ? value : "";
|
|
857
1585
|
}
|
|
858
1586
|
function collectAttachmentInputs(fields, submittedData) {
|
|
@@ -923,858 +1651,644 @@ function buildNotificationEmails(input) {
|
|
|
923
1651
|
const bcc = normalizeAddressList(notification.bcc);
|
|
924
1652
|
const from = notification.senderEmail?.trim() || defaultFrom?.trim() || void 0;
|
|
925
1653
|
const replyTo = notification.replyTo ? resolveFieldRef(notification.replyTo, submittedData).trim() || void 0 : void 0;
|
|
926
|
-
emails.push({
|
|
927
|
-
to,
|
|
928
|
-
templateSlug: notification.templateSlug,
|
|
929
|
-
variables: {
|
|
930
|
-
...submittedData,
|
|
931
|
-
formName,
|
|
932
|
-
submissionId
|
|
933
|
-
},
|
|
934
|
-
providerId: notification.providerId,
|
|
935
|
-
from,
|
|
936
|
-
replyTo,
|
|
937
|
-
cc,
|
|
938
|
-
bcc,
|
|
939
|
-
notificationId: notification.id
|
|
940
|
-
});
|
|
941
|
-
}
|
|
942
|
-
return { emails, skipped };
|
|
943
|
-
}
|
|
944
|
-
async function handleSubmissionCreated(context, config, nextly) {
|
|
945
|
-
if (!config.notifications.enabled) return;
|
|
946
|
-
const submission = context.data;
|
|
947
|
-
if (!submission) return;
|
|
948
|
-
if (submission.status === "spam") return;
|
|
949
|
-
const rawFormId = submission.form;
|
|
950
|
-
let formId = null;
|
|
951
|
-
if (typeof rawFormId === "string") {
|
|
952
|
-
formId = rawFormId;
|
|
953
|
-
} else if (rawFormId && typeof rawFormId === "object") {
|
|
954
|
-
const maybeId = rawFormId.id;
|
|
955
|
-
if (typeof maybeId === "string") formId = maybeId;
|
|
956
|
-
}
|
|
957
|
-
if (!formId) return;
|
|
958
|
-
const form = await fetchParentForm(config, formId, nextly);
|
|
959
|
-
if (!form) return;
|
|
960
|
-
const notifications = Array.isArray(form.notifications) ? form.notifications : [];
|
|
961
|
-
if (notifications.length === 0) return;
|
|
962
|
-
const emailService = nextly.services.email;
|
|
963
|
-
if (!emailService) return;
|
|
964
|
-
const submittedData = parseJsonColumn(submission.data);
|
|
965
|
-
const formFields = Array.isArray(form.fields) ? form.fields : [];
|
|
966
|
-
const fileAttachments = collectAttachmentInputs(formFields, submittedData);
|
|
967
|
-
const { emails, skipped } = buildNotificationEmails({
|
|
968
|
-
notifications,
|
|
969
|
-
submittedData,
|
|
970
|
-
formName: form.name,
|
|
971
|
-
submissionId: submission.id,
|
|
972
|
-
defaultFrom: config.notifications.defaultFrom
|
|
973
|
-
});
|
|
974
|
-
for (const skip of skipped) {
|
|
975
|
-
if (skip.reason === "empty-recipient") {
|
|
976
|
-
nextly.logger.warn?.(
|
|
977
|
-
"Form Builder: empty recipient, skipping notification",
|
|
978
|
-
{ notificationId: skip.notificationId, formSlug: form.slug }
|
|
979
|
-
);
|
|
980
|
-
} else {
|
|
981
|
-
nextly.logger.debug?.("Form Builder: notification skipped", {
|
|
982
|
-
notificationId: skip.notificationId,
|
|
983
|
-
formSlug: form.slug,
|
|
984
|
-
reason: skip.reason
|
|
985
|
-
});
|
|
986
|
-
}
|
|
987
|
-
}
|
|
988
|
-
if (emails.length === 0) return;
|
|
989
|
-
const finalEmails = await nextly.filters.apply("form-builder.beforeEmail", emails, { form, submission });
|
|
990
|
-
for (const email2 of finalEmails) {
|
|
991
|
-
try {
|
|
992
|
-
await emailService.sendWithTemplate(
|
|
993
|
-
email2.templateSlug,
|
|
994
|
-
email2.to,
|
|
995
|
-
email2.variables,
|
|
996
|
-
{
|
|
997
|
-
providerId: email2.providerId,
|
|
998
|
-
from: email2.from,
|
|
999
|
-
replyTo: email2.replyTo,
|
|
1000
|
-
cc: email2.cc,
|
|
1001
|
-
bcc: email2.bcc,
|
|
1002
|
-
attachments: fileAttachments.length > 0 ? fileAttachments : void 0
|
|
1003
|
-
}
|
|
1004
|
-
);
|
|
1005
|
-
nextly.logger.info?.("Form Builder: notification sent", {
|
|
1006
|
-
formSlug: form.slug,
|
|
1007
|
-
to: email2.to,
|
|
1008
|
-
templateSlug: email2.templateSlug,
|
|
1009
|
-
notificationId: email2.notificationId,
|
|
1010
|
-
attachmentCount: fileAttachments.length
|
|
1011
|
-
});
|
|
1012
|
-
} catch (err) {
|
|
1013
|
-
nextly.logger.error?.("Form Builder: notification failed", {
|
|
1014
|
-
to: email2.to,
|
|
1015
|
-
templateSlug: email2.templateSlug,
|
|
1016
|
-
notificationId: email2.notificationId,
|
|
1017
|
-
error: err instanceof Error ? err.message : String(err)
|
|
1018
|
-
});
|
|
1019
|
-
}
|
|
1020
|
-
}
|
|
1021
|
-
}
|
|
1022
|
-
async function fetchParentForm(config, formId, nextly) {
|
|
1023
|
-
try {
|
|
1024
|
-
const form = await nextly.services.collections.findEntryById(
|
|
1025
|
-
config.formOverrides.slug,
|
|
1026
|
-
formId,
|
|
1027
|
-
{ as: "system" }
|
|
1028
|
-
);
|
|
1029
|
-
return form;
|
|
1030
|
-
} catch (err) {
|
|
1031
|
-
nextly.logger.error?.("Form Builder: failed to fetch form", {
|
|
1032
|
-
formId,
|
|
1033
|
-
error: err instanceof Error ? err.message : String(err)
|
|
1034
|
-
});
|
|
1035
|
-
return null;
|
|
1036
|
-
}
|
|
1037
|
-
}
|
|
1038
|
-
|
|
1039
|
-
// src/config/validate-form.ts
|
|
1040
|
-
import { isPluginFieldTypeOnSurface } from "nextly";
|
|
1041
|
-
var RESERVED_FORM_SLUGS = [
|
|
1042
|
-
// API routes
|
|
1043
|
-
"api",
|
|
1044
|
-
"submit",
|
|
1045
|
-
"submissions",
|
|
1046
|
-
"export",
|
|
1047
|
-
// System slugs
|
|
1048
|
-
"admin",
|
|
1049
|
-
"new",
|
|
1050
|
-
"create",
|
|
1051
|
-
"edit",
|
|
1052
|
-
"delete"
|
|
1053
|
-
];
|
|
1054
|
-
var RESERVED_SLUGS_SET = new Set(RESERVED_FORM_SLUGS);
|
|
1055
|
-
var SLUG_PATTERN = /^[a-z][a-z0-9_-]*$/;
|
|
1056
|
-
var FIELD_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]*$/;
|
|
1057
|
-
var VALID_FIELD_TYPES = BUILT_IN_FORM_FIELD_TYPES;
|
|
1058
|
-
var VALID_FIELD_TYPES_SET = new Set(VALID_FIELD_TYPES);
|
|
1059
|
-
function isReservedSlug(slug) {
|
|
1060
|
-
return RESERVED_SLUGS_SET.has(slug.toLowerCase());
|
|
1061
|
-
}
|
|
1062
|
-
function validateSlug(slug, errors) {
|
|
1063
|
-
const path = "slug";
|
|
1064
|
-
if (!slug) {
|
|
1065
|
-
errors.push({
|
|
1066
|
-
path,
|
|
1067
|
-
message: "Form slug is required",
|
|
1068
|
-
code: "SLUG_REQUIRED"
|
|
1069
|
-
});
|
|
1070
|
-
return;
|
|
1071
|
-
}
|
|
1072
|
-
if (typeof slug !== "string") {
|
|
1073
|
-
errors.push({
|
|
1074
|
-
path,
|
|
1075
|
-
message: "Form slug must be a string",
|
|
1076
|
-
code: "SLUG_INVALID_TYPE"
|
|
1077
|
-
});
|
|
1078
|
-
return;
|
|
1079
|
-
}
|
|
1080
|
-
if (slug.length < 1) {
|
|
1081
|
-
errors.push({
|
|
1082
|
-
path,
|
|
1083
|
-
message: "Form slug must be at least 1 character",
|
|
1084
|
-
code: "SLUG_TOO_SHORT"
|
|
1085
|
-
});
|
|
1086
|
-
}
|
|
1087
|
-
if (slug.length > 50) {
|
|
1088
|
-
errors.push({
|
|
1089
|
-
path,
|
|
1090
|
-
message: "Form slug must be at most 50 characters",
|
|
1091
|
-
code: "SLUG_TOO_LONG"
|
|
1092
|
-
});
|
|
1093
|
-
}
|
|
1094
|
-
if (!SLUG_PATTERN.test(slug)) {
|
|
1095
|
-
errors.push({
|
|
1096
|
-
path,
|
|
1097
|
-
message: "Form slug must start with a lowercase letter and contain only lowercase letters, numbers, hyphens, and underscores",
|
|
1098
|
-
code: "SLUG_INVALID_FORMAT"
|
|
1099
|
-
});
|
|
1100
|
-
}
|
|
1101
|
-
if (isReservedSlug(slug)) {
|
|
1102
|
-
errors.push({
|
|
1103
|
-
path,
|
|
1104
|
-
message: `Form slug '${slug}' is reserved and cannot be used`,
|
|
1105
|
-
code: "SLUG_RESERVED"
|
|
1106
|
-
});
|
|
1107
|
-
}
|
|
1108
|
-
}
|
|
1109
|
-
function validateFieldName(name, path, errors, seenNames) {
|
|
1110
|
-
if (!name) {
|
|
1111
|
-
errors.push({
|
|
1112
|
-
path: `${path}.name`,
|
|
1113
|
-
message: "Field name is required",
|
|
1114
|
-
code: "FIELD_NAME_REQUIRED"
|
|
1115
|
-
});
|
|
1116
|
-
return;
|
|
1117
|
-
}
|
|
1118
|
-
if (typeof name !== "string") {
|
|
1119
|
-
errors.push({
|
|
1120
|
-
path: `${path}.name`,
|
|
1121
|
-
message: "Field name must be a string",
|
|
1122
|
-
code: "FIELD_NAME_REQUIRED"
|
|
1123
|
-
});
|
|
1124
|
-
return;
|
|
1125
|
-
}
|
|
1126
|
-
if (!FIELD_NAME_PATTERN.test(name)) {
|
|
1127
|
-
errors.push({
|
|
1128
|
-
path: `${path}.name`,
|
|
1129
|
-
message: `Field name '${name}' must start with a letter and contain only letters, numbers, and underscores`,
|
|
1130
|
-
code: "FIELD_NAME_INVALID_FORMAT"
|
|
1131
|
-
});
|
|
1132
|
-
}
|
|
1133
|
-
const nameLower = name.toLowerCase();
|
|
1134
|
-
if (seenNames.has(nameLower)) {
|
|
1135
|
-
errors.push({
|
|
1136
|
-
path: `${path}.name`,
|
|
1137
|
-
message: `Duplicate field name '${name}'`,
|
|
1138
|
-
code: "FIELD_NAME_DUPLICATE"
|
|
1654
|
+
emails.push({
|
|
1655
|
+
to,
|
|
1656
|
+
templateSlug: notification.templateSlug,
|
|
1657
|
+
variables: {
|
|
1658
|
+
...submittedData,
|
|
1659
|
+
formName,
|
|
1660
|
+
submissionId
|
|
1661
|
+
},
|
|
1662
|
+
providerId: notification.providerId,
|
|
1663
|
+
from,
|
|
1664
|
+
replyTo,
|
|
1665
|
+
cc,
|
|
1666
|
+
bcc,
|
|
1667
|
+
notificationId: notification.id
|
|
1139
1668
|
});
|
|
1140
|
-
} else {
|
|
1141
|
-
seenNames.add(nameLower);
|
|
1142
1669
|
}
|
|
1670
|
+
return { emails, skipped };
|
|
1143
1671
|
}
|
|
1144
|
-
function
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
});
|
|
1672
|
+
function parentFormId(submission) {
|
|
1673
|
+
const raw = submission.form;
|
|
1674
|
+
if (typeof raw === "string") return raw;
|
|
1675
|
+
if (raw && typeof raw === "object") {
|
|
1676
|
+
const maybeId = raw.id;
|
|
1677
|
+
if (typeof maybeId === "string") return maybeId;
|
|
1151
1678
|
}
|
|
1679
|
+
return null;
|
|
1152
1680
|
}
|
|
1153
|
-
function
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
}
|
|
1162
|
-
|
|
1681
|
+
function submittedPayload(raw) {
|
|
1682
|
+
if (typeof raw === "string") {
|
|
1683
|
+
try {
|
|
1684
|
+
const parsed = JSON.parse(raw);
|
|
1685
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
1686
|
+
return parsed;
|
|
1687
|
+
}
|
|
1688
|
+
} catch {
|
|
1689
|
+
}
|
|
1690
|
+
} else if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
1691
|
+
return raw;
|
|
1163
1692
|
}
|
|
1164
|
-
|
|
1165
|
-
errors
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1693
|
+
throw NextlyError2.validation({
|
|
1694
|
+
errors: [
|
|
1695
|
+
{
|
|
1696
|
+
path: "data",
|
|
1697
|
+
code: "INVALID",
|
|
1698
|
+
message: "Submission data must be an object."
|
|
1699
|
+
}
|
|
1700
|
+
]
|
|
1701
|
+
});
|
|
1702
|
+
}
|
|
1703
|
+
function updateNeedsChecking(submission, stored) {
|
|
1704
|
+
if (submission.data !== void 0) return true;
|
|
1705
|
+
if (stored?.data === void 0) return false;
|
|
1706
|
+
if (submission.form !== void 0) return true;
|
|
1707
|
+
return stored.status === "spam" && submission.status !== void 0 && submission.status !== "spam";
|
|
1708
|
+
}
|
|
1709
|
+
async function prepareSubmissionForWrite(context, formsSlug, nextly) {
|
|
1710
|
+
const ctx = context;
|
|
1711
|
+
const submission = ctx.data;
|
|
1712
|
+
if (!submission || typeof submission !== "object") return ctx.data;
|
|
1713
|
+
const stored = ctx.originalData;
|
|
1714
|
+
const formId = parentFormId(submission) ?? (stored ? parentFormId(stored) : null);
|
|
1715
|
+
if (!formId) return ctx.data;
|
|
1716
|
+
if (ctx.operation !== "create" && !updateNeedsChecking(submission, stored)) {
|
|
1717
|
+
return ctx.data;
|
|
1718
|
+
}
|
|
1719
|
+
const incoming = submittedPayload(
|
|
1720
|
+
submission.data !== void 0 ? submission.data : stored?.data
|
|
1721
|
+
);
|
|
1722
|
+
const marks = submissionMarks(submission);
|
|
1723
|
+
const fields = await fieldsToCheckAgainst(marks, formsSlug, formId, nextly);
|
|
1724
|
+
const prepared = prepareSubmission({
|
|
1725
|
+
data: incoming,
|
|
1726
|
+
fields,
|
|
1727
|
+
validate: marks?.keepAsEvidence !== true
|
|
1728
|
+
});
|
|
1729
|
+
if (prepared.validationErrors) {
|
|
1730
|
+
throw NextlyError2.validation({
|
|
1731
|
+
errors: Object.entries(prepared.validationErrors).map(
|
|
1732
|
+
([path, message]) => ({ path, code: "INVALID", message })
|
|
1733
|
+
)
|
|
1169
1734
|
});
|
|
1170
|
-
return;
|
|
1171
1735
|
}
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
message: `${field.type} field must have at least one option`,
|
|
1176
|
-
code: "OPTIONS_EMPTY"
|
|
1177
|
-
});
|
|
1736
|
+
ctx.data = { ...submission, data: prepared.data };
|
|
1737
|
+
if (ctx.operation !== "create" && submission.data === void 0) {
|
|
1738
|
+
stampDerivedEdit(ctx.data, ctx.user, incoming, prepared.data);
|
|
1178
1739
|
}
|
|
1740
|
+
return ctx.data;
|
|
1179
1741
|
}
|
|
1180
|
-
function
|
|
1181
|
-
const
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1742
|
+
async function fieldsToCheckAgainst(marks, formsSlug, formId, nextly) {
|
|
1743
|
+
const handedOver = marks?.form?.id === formId ? marks.form : null;
|
|
1744
|
+
const form = handedOver ?? await fetchParentForm(formsSlug, formId, nextly);
|
|
1745
|
+
if (!form || !Array.isArray(form.fields)) {
|
|
1746
|
+
throw NextlyError2.validation({
|
|
1747
|
+
errors: [
|
|
1748
|
+
{
|
|
1749
|
+
path: "form",
|
|
1750
|
+
code: "INVALID",
|
|
1751
|
+
message: "The form this submission belongs to could not be read, so the submission could not be checked against it."
|
|
1752
|
+
}
|
|
1753
|
+
]
|
|
1190
1754
|
});
|
|
1191
|
-
return;
|
|
1192
1755
|
}
|
|
1193
|
-
|
|
1194
|
-
const conditionPath = `${path}.conditionalLogic.conditions[${index}]`;
|
|
1195
|
-
if (!condition.field) {
|
|
1196
|
-
errors.push({
|
|
1197
|
-
path: `${conditionPath}.field`,
|
|
1198
|
-
message: "Condition must specify a field name",
|
|
1199
|
-
code: "CONDITION_FIELD_REQUIRED"
|
|
1200
|
-
});
|
|
1201
|
-
}
|
|
1202
|
-
if (!condition.comparison) {
|
|
1203
|
-
errors.push({
|
|
1204
|
-
path: `${conditionPath}.comparison`,
|
|
1205
|
-
message: "Condition must specify a comparison operator",
|
|
1206
|
-
code: "CONDITION_COMPARISON_REQUIRED"
|
|
1207
|
-
});
|
|
1208
|
-
}
|
|
1209
|
-
});
|
|
1756
|
+
return form.fields;
|
|
1210
1757
|
}
|
|
1211
|
-
function
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1758
|
+
function stampDerivedEdit(row, user, before, after) {
|
|
1759
|
+
if (sameSubmittedPayload(before, after)) return;
|
|
1760
|
+
row.editedAt = /* @__PURE__ */ new Date();
|
|
1761
|
+
row.editedBy = user?.id ?? null;
|
|
1762
|
+
}
|
|
1763
|
+
async function handleSubmissionCreated(context, config, nextly) {
|
|
1764
|
+
if (!config.notifications.enabled) return;
|
|
1765
|
+
const submission = context.data;
|
|
1766
|
+
if (!submission) return;
|
|
1767
|
+
if (submission.status === "spam") return;
|
|
1768
|
+
const formId = parentFormId(submission);
|
|
1769
|
+
if (!formId) return;
|
|
1770
|
+
const declaredFormsSlug = config.formOverrides.slug;
|
|
1771
|
+
const form = await fetchParentForm(
|
|
1772
|
+
nextly.self.collections[declaredFormsSlug] ?? declaredFormsSlug,
|
|
1773
|
+
formId,
|
|
1774
|
+
nextly
|
|
1775
|
+
);
|
|
1776
|
+
if (!form) return;
|
|
1777
|
+
const notifications = Array.isArray(form.notifications) ? form.notifications : [];
|
|
1778
|
+
if (notifications.length === 0) return;
|
|
1779
|
+
const emailService = nextly.services.email;
|
|
1780
|
+
if (!emailService) return;
|
|
1781
|
+
const submittedData = parseJsonColumn(submission.data);
|
|
1782
|
+
const formFields = Array.isArray(form.fields) ? form.fields : [];
|
|
1783
|
+
const fileAttachments = collectAttachmentInputs(formFields, submittedData);
|
|
1784
|
+
const { emails, skipped } = buildNotificationEmails({
|
|
1785
|
+
notifications,
|
|
1786
|
+
submittedData,
|
|
1787
|
+
formName: form.name,
|
|
1788
|
+
submissionId: submission.id,
|
|
1789
|
+
defaultFrom: config.notifications.defaultFrom
|
|
1790
|
+
});
|
|
1791
|
+
for (const skip of skipped) {
|
|
1792
|
+
if (skip.reason === "empty-recipient") {
|
|
1793
|
+
nextly.logger.warn?.(
|
|
1794
|
+
"Form Builder: empty recipient, skipping notification",
|
|
1795
|
+
{ notificationId: skip.notificationId, formSlug: form.slug }
|
|
1796
|
+
);
|
|
1797
|
+
} else {
|
|
1798
|
+
nextly.logger.debug?.("Form Builder: notification skipped", {
|
|
1799
|
+
notificationId: skip.notificationId,
|
|
1800
|
+
formSlug: form.slug,
|
|
1801
|
+
reason: skip.reason
|
|
1802
|
+
});
|
|
1230
1803
|
}
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1804
|
+
}
|
|
1805
|
+
if (emails.length === 0) return;
|
|
1806
|
+
const finalEmails = await nextly.filters.apply("form-builder.beforeEmail", emails, { form, submission });
|
|
1807
|
+
for (const email2 of finalEmails) {
|
|
1808
|
+
try {
|
|
1809
|
+
await emailService.sendWithTemplate(
|
|
1810
|
+
email2.templateSlug,
|
|
1811
|
+
email2.to,
|
|
1812
|
+
email2.variables,
|
|
1813
|
+
{
|
|
1814
|
+
providerId: email2.providerId,
|
|
1815
|
+
from: email2.from,
|
|
1816
|
+
replyTo: email2.replyTo,
|
|
1817
|
+
cc: email2.cc,
|
|
1818
|
+
bcc: email2.bcc,
|
|
1819
|
+
attachments: fileAttachments.length > 0 ? fileAttachments : void 0
|
|
1242
1820
|
}
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1821
|
+
);
|
|
1822
|
+
nextly.logger.info?.("Form Builder: notification sent", {
|
|
1823
|
+
formSlug: form.slug,
|
|
1824
|
+
to: email2.to,
|
|
1825
|
+
templateSlug: email2.templateSlug,
|
|
1826
|
+
notificationId: email2.notificationId,
|
|
1827
|
+
attachmentCount: fileAttachments.length
|
|
1828
|
+
});
|
|
1829
|
+
} catch (err) {
|
|
1830
|
+
nextly.logger.error?.("Form Builder: notification failed", {
|
|
1831
|
+
to: email2.to,
|
|
1832
|
+
templateSlug: email2.templateSlug,
|
|
1833
|
+
notificationId: email2.notificationId,
|
|
1834
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1835
|
+
});
|
|
1256
1836
|
}
|
|
1257
1837
|
}
|
|
1258
1838
|
}
|
|
1259
|
-
function
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
}
|
|
1273
|
-
const isValidType = typeof fieldType === "string" && (VALID_FIELD_TYPES_SET.has(fieldType) || isPluginFieldTypeOnSurface(fieldType, "forms"));
|
|
1274
|
-
if (!isValidType) {
|
|
1275
|
-
let fieldTypeRepr;
|
|
1276
|
-
if (typeof fieldType === "object" && fieldType !== null) {
|
|
1277
|
-
fieldTypeRepr = JSON.stringify(fieldType);
|
|
1278
|
-
} else {
|
|
1279
|
-
fieldTypeRepr = String(fieldType);
|
|
1280
|
-
}
|
|
1281
|
-
errors.push({
|
|
1282
|
-
path: `${path}.type`,
|
|
1283
|
-
message: `Invalid field type '${fieldTypeRepr}'. Valid types: ${VALID_FIELD_TYPES.join(", ")}`,
|
|
1284
|
-
code: "FIELD_TYPE_INVALID"
|
|
1839
|
+
async function fetchParentForm(formsSlug, formId, nextly) {
|
|
1840
|
+
try {
|
|
1841
|
+
const form = await nextly.services.collections.findEntryById(
|
|
1842
|
+
formsSlug,
|
|
1843
|
+
formId,
|
|
1844
|
+
{ as: "system" }
|
|
1845
|
+
);
|
|
1846
|
+
return form;
|
|
1847
|
+
} catch (err) {
|
|
1848
|
+
if (!NextlyError2.isNotFound(err)) throw err;
|
|
1849
|
+
nextly.logger.error?.("Form Builder: form not found", {
|
|
1850
|
+
formId,
|
|
1851
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1285
1852
|
});
|
|
1286
|
-
return;
|
|
1287
|
-
}
|
|
1288
|
-
validateFieldName(f.name, path, errors, seenNames);
|
|
1289
|
-
if (fieldType !== "hidden") {
|
|
1290
|
-
validateFieldLabel(f.label, path, errors);
|
|
1291
|
-
}
|
|
1292
|
-
validateFieldByType(field, path, errors);
|
|
1293
|
-
if (f.conditionalLogic) {
|
|
1294
|
-
validateConditionalLogic(field, path, errors);
|
|
1853
|
+
return null;
|
|
1295
1854
|
}
|
|
1296
1855
|
}
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1856
|
+
|
|
1857
|
+
// src/config/validate-form.ts
|
|
1858
|
+
import { isPluginFieldTypeOnSurface } from "nextly";
|
|
1859
|
+
var RESERVED_FORM_SLUGS = [
|
|
1860
|
+
// API routes
|
|
1861
|
+
"api",
|
|
1862
|
+
"submit",
|
|
1863
|
+
"submissions",
|
|
1864
|
+
"export",
|
|
1865
|
+
// System slugs
|
|
1866
|
+
"admin",
|
|
1867
|
+
"new",
|
|
1868
|
+
"create",
|
|
1869
|
+
"edit",
|
|
1870
|
+
"delete"
|
|
1871
|
+
];
|
|
1872
|
+
var RESERVED_SLUGS_SET = new Set(RESERVED_FORM_SLUGS);
|
|
1873
|
+
var SLUG_PATTERN = /^[a-z][a-z0-9_-]*$/;
|
|
1874
|
+
var FIELD_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]*$/;
|
|
1875
|
+
var VALID_FIELD_TYPES = BUILT_IN_FORM_FIELD_TYPES;
|
|
1876
|
+
var VALID_FIELD_TYPES_SET = new Set(VALID_FIELD_TYPES);
|
|
1877
|
+
function isReservedSlug(slug) {
|
|
1878
|
+
return RESERVED_SLUGS_SET.has(slug.toLowerCase());
|
|
1303
1879
|
}
|
|
1304
|
-
function
|
|
1305
|
-
const path = "
|
|
1306
|
-
if (!
|
|
1880
|
+
function validateSlug(slug, errors) {
|
|
1881
|
+
const path = "slug";
|
|
1882
|
+
if (!slug) {
|
|
1307
1883
|
errors.push({
|
|
1308
1884
|
path,
|
|
1309
|
-
message: "Form
|
|
1310
|
-
code: "
|
|
1885
|
+
message: "Form slug is required",
|
|
1886
|
+
code: "SLUG_REQUIRED"
|
|
1311
1887
|
});
|
|
1312
1888
|
return;
|
|
1313
1889
|
}
|
|
1314
|
-
if (
|
|
1890
|
+
if (typeof slug !== "string") {
|
|
1315
1891
|
errors.push({
|
|
1316
1892
|
path,
|
|
1317
|
-
message: "Form
|
|
1318
|
-
code: "
|
|
1893
|
+
message: "Form slug must be a string",
|
|
1894
|
+
code: "SLUG_INVALID_TYPE"
|
|
1319
1895
|
});
|
|
1320
1896
|
return;
|
|
1321
1897
|
}
|
|
1322
|
-
if (
|
|
1898
|
+
if (slug.length < 1) {
|
|
1323
1899
|
errors.push({
|
|
1324
1900
|
path,
|
|
1325
|
-
message: "Form must
|
|
1326
|
-
code: "
|
|
1901
|
+
message: "Form slug must be at least 1 character",
|
|
1902
|
+
code: "SLUG_TOO_SHORT"
|
|
1327
1903
|
});
|
|
1328
|
-
return;
|
|
1329
1904
|
}
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1905
|
+
if (slug.length > 50) {
|
|
1906
|
+
errors.push({
|
|
1907
|
+
path,
|
|
1908
|
+
message: "Form slug must be at most 50 characters",
|
|
1909
|
+
code: "SLUG_TOO_LONG"
|
|
1910
|
+
});
|
|
1335
1911
|
}
|
|
1336
|
-
if (
|
|
1912
|
+
if (!SLUG_PATTERN.test(slug)) {
|
|
1337
1913
|
errors.push({
|
|
1338
|
-
path
|
|
1339
|
-
message: "Form
|
|
1340
|
-
code: "
|
|
1914
|
+
path,
|
|
1915
|
+
message: "Form slug must start with a lowercase letter and contain only lowercase letters, numbers, hyphens, and underscores",
|
|
1916
|
+
code: "SLUG_INVALID_FORMAT"
|
|
1341
1917
|
});
|
|
1342
|
-
return;
|
|
1343
1918
|
}
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
code: "REDIRECT_URL_REQUIRED"
|
|
1351
|
-
});
|
|
1352
|
-
}
|
|
1919
|
+
if (isReservedSlug(slug)) {
|
|
1920
|
+
errors.push({
|
|
1921
|
+
path,
|
|
1922
|
+
message: `Form slug '${slug}' is reserved and cannot be used`,
|
|
1923
|
+
code: "SLUG_RESERVED"
|
|
1924
|
+
});
|
|
1353
1925
|
}
|
|
1354
1926
|
}
|
|
1355
|
-
function
|
|
1356
|
-
if (!
|
|
1927
|
+
function validateFieldName(name, path, errors, seenNames) {
|
|
1928
|
+
if (!name) {
|
|
1929
|
+
errors.push({
|
|
1930
|
+
path: `${path}.name`,
|
|
1931
|
+
message: "Field name is required",
|
|
1932
|
+
code: "FIELD_NAME_REQUIRED"
|
|
1933
|
+
});
|
|
1357
1934
|
return;
|
|
1358
1935
|
}
|
|
1359
|
-
if (typeof
|
|
1936
|
+
if (typeof name !== "string") {
|
|
1360
1937
|
errors.push({
|
|
1361
|
-
path:
|
|
1362
|
-
message: "
|
|
1363
|
-
code: "
|
|
1938
|
+
path: `${path}.name`,
|
|
1939
|
+
message: "Field name must be a string",
|
|
1940
|
+
code: "FIELD_NAME_REQUIRED"
|
|
1364
1941
|
});
|
|
1365
1942
|
return;
|
|
1366
1943
|
}
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
path: `access.${key}`,
|
|
1374
|
-
message: `Access control '${key}' must be a function`,
|
|
1375
|
-
code: "ACCESS_FUNCTION_INVALID"
|
|
1376
|
-
});
|
|
1377
|
-
}
|
|
1378
|
-
}
|
|
1379
|
-
}
|
|
1380
|
-
function validateFormConfig(config) {
|
|
1381
|
-
const errors = [];
|
|
1382
|
-
validateSlug(config.slug, errors);
|
|
1383
|
-
validateFields(config.fields, errors);
|
|
1384
|
-
validateSettings(config.settings, errors);
|
|
1385
|
-
validateAccess(config.access, errors);
|
|
1386
|
-
return {
|
|
1387
|
-
valid: errors.length === 0,
|
|
1388
|
-
errors
|
|
1389
|
-
};
|
|
1390
|
-
}
|
|
1391
|
-
function assertValidFormConfig(config) {
|
|
1392
|
-
const result = validateFormConfig(config);
|
|
1393
|
-
if (!result.valid) {
|
|
1394
|
-
const errorMessages = result.errors.map((err) => ` - [${err.code}] ${err.path}: ${err.message}`).join("\n");
|
|
1395
|
-
throw new Error(
|
|
1396
|
-
`Invalid form config for '${config.slug || "unknown"}':
|
|
1397
|
-
${errorMessages}`
|
|
1398
|
-
);
|
|
1399
|
-
}
|
|
1400
|
-
}
|
|
1401
|
-
|
|
1402
|
-
// src/config/field-helpers.ts
|
|
1403
|
-
var option = (label, value) => ({
|
|
1404
|
-
label,
|
|
1405
|
-
value: value ?? label.toLowerCase().replace(/\s+/g, "_")
|
|
1406
|
-
});
|
|
1407
|
-
var text3 = (config) => ({
|
|
1408
|
-
...config,
|
|
1409
|
-
type: "text"
|
|
1410
|
-
});
|
|
1411
|
-
var email = (config) => ({
|
|
1412
|
-
...config,
|
|
1413
|
-
type: "email"
|
|
1414
|
-
});
|
|
1415
|
-
var phone = (config) => ({
|
|
1416
|
-
...config,
|
|
1417
|
-
type: "phone"
|
|
1418
|
-
});
|
|
1419
|
-
var url = (config) => ({
|
|
1420
|
-
...config,
|
|
1421
|
-
type: "url"
|
|
1422
|
-
});
|
|
1423
|
-
var textarea3 = (config) => ({
|
|
1424
|
-
...config,
|
|
1425
|
-
type: "textarea"
|
|
1426
|
-
});
|
|
1427
|
-
var number = (config) => ({
|
|
1428
|
-
...config,
|
|
1429
|
-
type: "number"
|
|
1430
|
-
});
|
|
1431
|
-
var select3 = (config) => ({
|
|
1432
|
-
...config,
|
|
1433
|
-
type: "select"
|
|
1434
|
-
});
|
|
1435
|
-
var checkbox2 = (config) => ({
|
|
1436
|
-
...config,
|
|
1437
|
-
type: "checkbox"
|
|
1438
|
-
});
|
|
1439
|
-
var radio = (config) => ({
|
|
1440
|
-
...config,
|
|
1441
|
-
type: "radio"
|
|
1442
|
-
});
|
|
1443
|
-
var file = (config) => ({
|
|
1444
|
-
...config,
|
|
1445
|
-
type: "file"
|
|
1446
|
-
});
|
|
1447
|
-
var date2 = (config) => ({
|
|
1448
|
-
...config,
|
|
1449
|
-
type: "date"
|
|
1450
|
-
});
|
|
1451
|
-
var time = (config) => ({
|
|
1452
|
-
...config,
|
|
1453
|
-
type: "time"
|
|
1454
|
-
});
|
|
1455
|
-
var hidden = (config) => ({
|
|
1456
|
-
...config,
|
|
1457
|
-
type: "hidden"
|
|
1458
|
-
});
|
|
1459
|
-
|
|
1460
|
-
// src/utils/generate-schema.ts
|
|
1461
|
-
import { z } from "zod";
|
|
1462
|
-
function generateZodSchema(fields) {
|
|
1463
|
-
const schemaShape = {};
|
|
1464
|
-
for (const field of fields) {
|
|
1465
|
-
if (!isKnownFormField(field)) {
|
|
1466
|
-
schemaShape[field.name] = field.required ? z.unknown().refine(
|
|
1467
|
-
(value) => value !== void 0 && value !== null && value !== "",
|
|
1468
|
-
{ message: `${field.label || field.name} is required` }
|
|
1469
|
-
) : z.unknown();
|
|
1470
|
-
continue;
|
|
1471
|
-
}
|
|
1472
|
-
if (field.type === "hidden" && !field.required) {
|
|
1473
|
-
schemaShape[field.name] = z.string().optional();
|
|
1474
|
-
continue;
|
|
1475
|
-
}
|
|
1476
|
-
const fieldSchema = generateFieldSchema(field);
|
|
1477
|
-
if (fieldSchema) {
|
|
1478
|
-
schemaShape[field.name] = fieldSchema;
|
|
1479
|
-
}
|
|
1480
|
-
}
|
|
1481
|
-
return z.object(schemaShape);
|
|
1482
|
-
}
|
|
1483
|
-
function generateFieldSchema(field) {
|
|
1484
|
-
switch (field.type) {
|
|
1485
|
-
case "text":
|
|
1486
|
-
return generateTextSchema(field);
|
|
1487
|
-
case "email":
|
|
1488
|
-
return generateEmailSchema(field);
|
|
1489
|
-
case "number":
|
|
1490
|
-
return generateNumberSchema(field);
|
|
1491
|
-
case "phone":
|
|
1492
|
-
return generatePhoneSchema(field);
|
|
1493
|
-
case "url":
|
|
1494
|
-
return generateUrlSchema(field);
|
|
1495
|
-
case "textarea":
|
|
1496
|
-
return generateTextareaSchema(field);
|
|
1497
|
-
case "select":
|
|
1498
|
-
return generateSelectSchema(field);
|
|
1499
|
-
case "checkbox":
|
|
1500
|
-
return generateCheckboxSchema(field);
|
|
1501
|
-
case "radio":
|
|
1502
|
-
return generateRadioSchema(field);
|
|
1503
|
-
case "file":
|
|
1504
|
-
return generateFileSchema(field);
|
|
1505
|
-
case "date":
|
|
1506
|
-
return generateDateSchema(field);
|
|
1507
|
-
case "time":
|
|
1508
|
-
return generateTimeSchema(field);
|
|
1509
|
-
case "hidden":
|
|
1510
|
-
return generateHiddenSchema(field);
|
|
1511
|
-
default:
|
|
1512
|
-
return z.unknown();
|
|
1513
|
-
}
|
|
1514
|
-
}
|
|
1515
|
-
function generateTextSchema(field) {
|
|
1516
|
-
let schema = z.string();
|
|
1517
|
-
if (field.validation?.minLength !== void 0) {
|
|
1518
|
-
schema = schema.min(
|
|
1519
|
-
field.validation.minLength,
|
|
1520
|
-
field.validation.errorMessage || `Minimum ${field.validation.minLength} characters required`
|
|
1521
|
-
);
|
|
1522
|
-
}
|
|
1523
|
-
if (field.validation?.maxLength !== void 0) {
|
|
1524
|
-
schema = schema.max(
|
|
1525
|
-
field.validation.maxLength,
|
|
1526
|
-
field.validation.errorMessage || `Maximum ${field.validation.maxLength} characters allowed`
|
|
1527
|
-
);
|
|
1944
|
+
if (!FIELD_NAME_PATTERN.test(name)) {
|
|
1945
|
+
errors.push({
|
|
1946
|
+
path: `${path}.name`,
|
|
1947
|
+
message: `Field name '${name}' must start with a letter and contain only letters, numbers, and underscores`,
|
|
1948
|
+
code: "FIELD_NAME_INVALID_FORMAT"
|
|
1949
|
+
});
|
|
1528
1950
|
}
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1951
|
+
const nameLower = name.toLowerCase();
|
|
1952
|
+
if (seenNames.has(nameLower)) {
|
|
1953
|
+
errors.push({
|
|
1954
|
+
path: `${path}.name`,
|
|
1955
|
+
message: `Duplicate field name '${name}'`,
|
|
1956
|
+
code: "FIELD_NAME_DUPLICATE"
|
|
1957
|
+
});
|
|
1958
|
+
} else {
|
|
1959
|
+
seenNames.add(nameLower);
|
|
1534
1960
|
}
|
|
1535
|
-
return applyRequired(schema, field.required, field.validation?.errorMessage);
|
|
1536
1961
|
}
|
|
1537
|
-
function
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
);
|
|
1962
|
+
function validateFieldLabel(label, path, errors) {
|
|
1963
|
+
if (!label || typeof label === "string" && label.trim() === "") {
|
|
1964
|
+
errors.push({
|
|
1965
|
+
path: `${path}.label`,
|
|
1966
|
+
message: "Field label is required",
|
|
1967
|
+
code: "FIELD_LABEL_REQUIRED"
|
|
1968
|
+
});
|
|
1544
1969
|
}
|
|
1545
|
-
return applyRequired(schema, field.required, field.validation?.errorMessage);
|
|
1546
1970
|
}
|
|
1547
|
-
function
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
field.
|
|
1554
|
-
|
|
1555
|
-
);
|
|
1971
|
+
function validateOptions(field, path, errors) {
|
|
1972
|
+
const fieldWithOptions = field;
|
|
1973
|
+
const options = fieldWithOptions.options;
|
|
1974
|
+
if (!options) {
|
|
1975
|
+
errors.push({
|
|
1976
|
+
path: `${path}.options`,
|
|
1977
|
+
message: `${field.type} field must have an 'options' array`,
|
|
1978
|
+
code: "OPTIONS_REQUIRED"
|
|
1979
|
+
});
|
|
1980
|
+
return;
|
|
1556
1981
|
}
|
|
1557
|
-
if (
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
field.
|
|
1561
|
-
|
|
1982
|
+
if (!Array.isArray(options)) {
|
|
1983
|
+
errors.push({
|
|
1984
|
+
path: `${path}.options`,
|
|
1985
|
+
message: `${field.type} field 'options' must be an array`,
|
|
1986
|
+
code: "OPTIONS_REQUIRED"
|
|
1987
|
+
});
|
|
1988
|
+
return;
|
|
1562
1989
|
}
|
|
1563
|
-
if (
|
|
1564
|
-
|
|
1990
|
+
if (options.length === 0) {
|
|
1991
|
+
errors.push({
|
|
1992
|
+
path: `${path}.options`,
|
|
1993
|
+
message: `${field.type} field must have at least one option`,
|
|
1994
|
+
code: "OPTIONS_EMPTY"
|
|
1995
|
+
});
|
|
1565
1996
|
}
|
|
1566
|
-
return schema.optional().nullable();
|
|
1567
1997
|
}
|
|
1568
|
-
function
|
|
1569
|
-
|
|
1570
|
-
if (
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1998
|
+
function validateConditionalLogic(field, path, errors) {
|
|
1999
|
+
const { conditionalLogic } = field;
|
|
2000
|
+
if (!conditionalLogic || !conditionalLogic.enabled) {
|
|
2001
|
+
return;
|
|
2002
|
+
}
|
|
2003
|
+
if (!conditionalLogic.conditions || conditionalLogic.conditions.length === 0) {
|
|
2004
|
+
errors.push({
|
|
2005
|
+
path: `${path}.conditionalLogic.conditions`,
|
|
2006
|
+
message: "Conditional logic must have at least one condition",
|
|
2007
|
+
code: "CONDITIONS_REQUIRED"
|
|
2008
|
+
});
|
|
2009
|
+
return;
|
|
1580
2010
|
}
|
|
1581
|
-
|
|
2011
|
+
conditionalLogic.conditions.forEach((condition, index) => {
|
|
2012
|
+
const conditionPath = `${path}.conditionalLogic.conditions[${index}]`;
|
|
2013
|
+
if (!condition.field) {
|
|
2014
|
+
errors.push({
|
|
2015
|
+
path: `${conditionPath}.field`,
|
|
2016
|
+
message: "Condition must specify a field name",
|
|
2017
|
+
code: "CONDITION_FIELD_REQUIRED"
|
|
2018
|
+
});
|
|
2019
|
+
}
|
|
2020
|
+
if (!condition.comparison) {
|
|
2021
|
+
errors.push({
|
|
2022
|
+
path: `${conditionPath}.comparison`,
|
|
2023
|
+
message: "Condition must specify a comparison operator",
|
|
2024
|
+
code: "CONDITION_COMPARISON_REQUIRED"
|
|
2025
|
+
});
|
|
2026
|
+
}
|
|
2027
|
+
});
|
|
1582
2028
|
}
|
|
1583
|
-
function
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
2029
|
+
function validateFieldByType(field, path, errors) {
|
|
2030
|
+
switch (field.type) {
|
|
2031
|
+
case "select":
|
|
2032
|
+
case "radio":
|
|
2033
|
+
validateOptions(field, path, errors);
|
|
2034
|
+
break;
|
|
2035
|
+
case "number": {
|
|
2036
|
+
const numberField = field;
|
|
2037
|
+
if (numberField.validation) {
|
|
2038
|
+
const { min, max } = numberField.validation;
|
|
2039
|
+
if (min !== void 0 && max !== void 0 && min > max) {
|
|
2040
|
+
errors.push({
|
|
2041
|
+
path: `${path}.validation`,
|
|
2042
|
+
message: "Min value cannot be greater than max value",
|
|
2043
|
+
code: "MIN_GREATER_THAN_MAX"
|
|
2044
|
+
});
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
break;
|
|
2048
|
+
}
|
|
2049
|
+
case "text":
|
|
2050
|
+
case "textarea": {
|
|
2051
|
+
const textField = field;
|
|
2052
|
+
if (textField.validation) {
|
|
2053
|
+
const { minLength, maxLength } = textField.validation;
|
|
2054
|
+
if (minLength !== void 0 && maxLength !== void 0 && minLength > maxLength) {
|
|
2055
|
+
errors.push({
|
|
2056
|
+
path: `${path}.validation`,
|
|
2057
|
+
message: "Min length cannot be greater than max length",
|
|
2058
|
+
code: "MIN_LENGTH_GREATER_THAN_MAX"
|
|
2059
|
+
});
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
break;
|
|
2063
|
+
}
|
|
2064
|
+
case "file": {
|
|
2065
|
+
const fileField = field;
|
|
2066
|
+
if (fileField.maxFileSize !== void 0 && fileField.maxFileSize <= 0) {
|
|
2067
|
+
errors.push({
|
|
2068
|
+
path: `${path}.maxFileSize`,
|
|
2069
|
+
message: "File maxFileSize must be a positive number",
|
|
2070
|
+
code: "FILE_MAX_SIZE_INVALID"
|
|
2071
|
+
});
|
|
2072
|
+
}
|
|
2073
|
+
break;
|
|
2074
|
+
}
|
|
1590
2075
|
}
|
|
1591
|
-
return applyRequired(schema, field.required, field.validation?.errorMessage);
|
|
1592
2076
|
}
|
|
1593
|
-
function
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
schema = schema.min(
|
|
1597
|
-
field.validation.minLength,
|
|
1598
|
-
field.validation.errorMessage || `Minimum ${field.validation.minLength} characters required`
|
|
1599
|
-
);
|
|
2077
|
+
function validateField(field, path, errors, seenNames) {
|
|
2078
|
+
if (!field || typeof field !== "object") {
|
|
2079
|
+
return;
|
|
1600
2080
|
}
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
2081
|
+
const f = field;
|
|
2082
|
+
const fieldType = f.type;
|
|
2083
|
+
if (!fieldType) {
|
|
2084
|
+
errors.push({
|
|
2085
|
+
path: `${path}.type`,
|
|
2086
|
+
message: "Field type is required",
|
|
2087
|
+
code: "FIELD_TYPE_REQUIRED"
|
|
2088
|
+
});
|
|
2089
|
+
return;
|
|
1606
2090
|
}
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
return schema2.min(
|
|
1615
|
-
1,
|
|
1616
|
-
field.validation?.errorMessage || "Please select at least one option"
|
|
1617
|
-
);
|
|
2091
|
+
const isValidType = typeof fieldType === "string" && (VALID_FIELD_TYPES_SET.has(fieldType) || isPluginFieldTypeOnSurface(fieldType, "forms"));
|
|
2092
|
+
if (!isValidType) {
|
|
2093
|
+
let fieldTypeRepr;
|
|
2094
|
+
if (typeof fieldType === "object" && fieldType !== null) {
|
|
2095
|
+
fieldTypeRepr = JSON.stringify(fieldType);
|
|
2096
|
+
} else {
|
|
2097
|
+
fieldTypeRepr = String(fieldType);
|
|
1618
2098
|
}
|
|
1619
|
-
|
|
2099
|
+
errors.push({
|
|
2100
|
+
path: `${path}.type`,
|
|
2101
|
+
message: `Invalid field type '${fieldTypeRepr}'. Valid types: ${VALID_FIELD_TYPES.join(", ")}`,
|
|
2102
|
+
code: "FIELD_TYPE_INVALID"
|
|
2103
|
+
});
|
|
2104
|
+
return;
|
|
1620
2105
|
}
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
if (field.required) {
|
|
1625
|
-
return schema;
|
|
2106
|
+
validateFieldName(f.name, path, errors, seenNames);
|
|
2107
|
+
if (fieldType !== "hidden") {
|
|
2108
|
+
validateFieldLabel(f.label, path, errors);
|
|
1626
2109
|
}
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
const schema = z.boolean();
|
|
1631
|
-
if (field.required) {
|
|
1632
|
-
return schema.refine((val) => val === true, {
|
|
1633
|
-
message: field.validation?.errorMessage || "This field is required"
|
|
1634
|
-
});
|
|
2110
|
+
validateFieldByType(field, path, errors);
|
|
2111
|
+
if (f.conditionalLogic) {
|
|
2112
|
+
validateConditionalLogic(field, path, errors);
|
|
1635
2113
|
}
|
|
1636
|
-
return schema.optional();
|
|
1637
2114
|
}
|
|
1638
|
-
function
|
|
1639
|
-
const
|
|
1640
|
-
|
|
1641
|
-
|
|
2115
|
+
function validateFieldsArray(fields, basePath, errors) {
|
|
2116
|
+
const seenNames = /* @__PURE__ */ new Set();
|
|
2117
|
+
fields.forEach((field, index) => {
|
|
2118
|
+
const fieldPath = `${basePath}[${index}]`;
|
|
2119
|
+
validateField(field, fieldPath, errors, seenNames);
|
|
1642
2120
|
});
|
|
1643
|
-
if (field.required) {
|
|
1644
|
-
return schema;
|
|
1645
|
-
}
|
|
1646
|
-
return schema.optional();
|
|
1647
|
-
}
|
|
1648
|
-
function generateFileSchema(field) {
|
|
1649
|
-
if (field.multiple) {
|
|
1650
|
-
const schema2 = z.array(z.string());
|
|
1651
|
-
if (field.required) {
|
|
1652
|
-
return schema2.min(
|
|
1653
|
-
1,
|
|
1654
|
-
field.validation?.errorMessage || "Please upload at least one file"
|
|
1655
|
-
);
|
|
1656
|
-
}
|
|
1657
|
-
return schema2.optional();
|
|
1658
|
-
}
|
|
1659
|
-
const schema = z.string();
|
|
1660
|
-
return applyRequired(schema, field.required, field.validation?.errorMessage);
|
|
1661
2121
|
}
|
|
1662
|
-
function
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
(
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
}
|
|
1670
|
-
|
|
1671
|
-
);
|
|
1672
|
-
if (field.min || field.max) {
|
|
1673
|
-
schema = schema.refine(
|
|
1674
|
-
(val) => {
|
|
1675
|
-
if (!val) return true;
|
|
1676
|
-
const date3 = new Date(val);
|
|
1677
|
-
if (field.min && date3 < new Date(field.min)) return false;
|
|
1678
|
-
if (field.max && date3 > new Date(field.max)) return false;
|
|
1679
|
-
return true;
|
|
1680
|
-
},
|
|
1681
|
-
{
|
|
1682
|
-
message: field.validation?.errorMessage || `Date must be between ${field.min || "any"} and ${field.max || "any"}`
|
|
1683
|
-
}
|
|
1684
|
-
);
|
|
2122
|
+
function validateFields(fields, errors) {
|
|
2123
|
+
const path = "fields";
|
|
2124
|
+
if (!fields) {
|
|
2125
|
+
errors.push({
|
|
2126
|
+
path,
|
|
2127
|
+
message: "Form must have at least one field",
|
|
2128
|
+
code: "FIELDS_REQUIRED"
|
|
2129
|
+
});
|
|
2130
|
+
return;
|
|
1685
2131
|
}
|
|
1686
|
-
if (
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
2132
|
+
if (!Array.isArray(fields)) {
|
|
2133
|
+
errors.push({
|
|
2134
|
+
path,
|
|
2135
|
+
message: "Form fields must be an array",
|
|
2136
|
+
code: "FIELDS_INVALID_TYPE"
|
|
2137
|
+
});
|
|
2138
|
+
return;
|
|
1691
2139
|
}
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
return applyRequired(schema, field.required, field.validation?.errorMessage);
|
|
1700
|
-
}
|
|
1701
|
-
function generateHiddenSchema(field) {
|
|
1702
|
-
const schema = z.string();
|
|
1703
|
-
return applyRequired(schema, field.required, field.validation?.errorMessage);
|
|
1704
|
-
}
|
|
1705
|
-
function applyRequired(schema, required, errorMessage) {
|
|
1706
|
-
if (required) {
|
|
1707
|
-
return schema.min(1, errorMessage || "This field is required");
|
|
2140
|
+
if (fields.length === 0) {
|
|
2141
|
+
errors.push({
|
|
2142
|
+
path,
|
|
2143
|
+
message: "Form must have at least one field",
|
|
2144
|
+
code: "FIELDS_EMPTY"
|
|
2145
|
+
});
|
|
2146
|
+
return;
|
|
1708
2147
|
}
|
|
1709
|
-
|
|
2148
|
+
validateFieldsArray(fields, path, errors);
|
|
1710
2149
|
}
|
|
1711
|
-
function
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
const value = data[field.name];
|
|
1715
|
-
if (value === void 0) {
|
|
1716
|
-
continue;
|
|
1717
|
-
}
|
|
1718
|
-
transformed[field.name] = isKnownFormField(field) ? transformFieldValue(value, field) : value;
|
|
2150
|
+
function validateSettings(settings, errors) {
|
|
2151
|
+
if (!settings) {
|
|
2152
|
+
return;
|
|
1719
2153
|
}
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
2154
|
+
if (typeof settings !== "object" || Array.isArray(settings)) {
|
|
2155
|
+
errors.push({
|
|
2156
|
+
path: "settings",
|
|
2157
|
+
message: "Form settings must be an object",
|
|
2158
|
+
code: "SETTINGS_INVALID_TYPE"
|
|
2159
|
+
});
|
|
2160
|
+
return;
|
|
1725
2161
|
}
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
case "textarea":
|
|
1743
|
-
case "hidden":
|
|
1744
|
-
if (typeof value === "string") {
|
|
1745
|
-
return value.trim();
|
|
1746
|
-
}
|
|
1747
|
-
return value;
|
|
1748
|
-
case "select":
|
|
1749
|
-
if (field.allowMultiple) {
|
|
1750
|
-
if (!Array.isArray(value)) {
|
|
1751
|
-
return value ? [value] : [];
|
|
1752
|
-
}
|
|
1753
|
-
}
|
|
1754
|
-
return value;
|
|
1755
|
-
default:
|
|
1756
|
-
return value;
|
|
2162
|
+
const s = settings;
|
|
2163
|
+
if (s.confirmationType === "redirect") {
|
|
2164
|
+
if (!s.redirectUrl && !s.redirectRelation) {
|
|
2165
|
+
errors.push({
|
|
2166
|
+
path: "settings.redirectUrl",
|
|
2167
|
+
message: "Redirect URL or redirect relation is required when confirmation type is 'redirect'",
|
|
2168
|
+
code: "REDIRECT_URL_REQUIRED"
|
|
2169
|
+
});
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
if (s.confirmationType === "relationship" && !namesADocument(s.redirectPage)) {
|
|
2173
|
+
errors.push({
|
|
2174
|
+
path: "settings.redirectPage",
|
|
2175
|
+
message: "A page is required when confirmation type is 'relationship'",
|
|
2176
|
+
code: "REDIRECT_PAGE_REQUIRED"
|
|
2177
|
+
});
|
|
1757
2178
|
}
|
|
1758
2179
|
}
|
|
1759
|
-
function
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
return schema.safeParse(transformed);
|
|
2180
|
+
function namesADocument(value) {
|
|
2181
|
+
if (typeof value === "string") return value.length > 0;
|
|
2182
|
+
return parseRedirectReference(value, []) !== null;
|
|
1763
2183
|
}
|
|
1764
|
-
function
|
|
1765
|
-
if (
|
|
1766
|
-
return
|
|
2184
|
+
function validateAccess(access, errors) {
|
|
2185
|
+
if (!access) {
|
|
2186
|
+
return;
|
|
1767
2187
|
}
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
2188
|
+
if (typeof access !== "object" || Array.isArray(access)) {
|
|
2189
|
+
errors.push({
|
|
2190
|
+
path: "access",
|
|
2191
|
+
message: "Access control must be an object",
|
|
2192
|
+
code: "ACCESS_INVALID_TYPE"
|
|
2193
|
+
});
|
|
2194
|
+
return;
|
|
2195
|
+
}
|
|
2196
|
+
const accessObj = access;
|
|
2197
|
+
const validAccessKeys = ["submit", "read"];
|
|
2198
|
+
for (const key of validAccessKeys) {
|
|
2199
|
+
const fn = accessObj[key];
|
|
2200
|
+
if (fn !== void 0 && typeof fn !== "function") {
|
|
2201
|
+
errors.push({
|
|
2202
|
+
path: `access.${key}`,
|
|
2203
|
+
message: `Access control '${key}' must be a function`,
|
|
2204
|
+
code: "ACCESS_FUNCTION_INVALID"
|
|
2205
|
+
});
|
|
1773
2206
|
}
|
|
1774
2207
|
}
|
|
1775
|
-
|
|
2208
|
+
}
|
|
2209
|
+
function validateFormConfig(config) {
|
|
2210
|
+
const errors = [];
|
|
2211
|
+
validateSlug(config.slug, errors);
|
|
2212
|
+
validateFields(config.fields, errors);
|
|
2213
|
+
validateSettings(config.settings, errors);
|
|
2214
|
+
validateAccess(config.access, errors);
|
|
2215
|
+
return {
|
|
2216
|
+
valid: errors.length === 0,
|
|
2217
|
+
errors
|
|
2218
|
+
};
|
|
2219
|
+
}
|
|
2220
|
+
function assertValidFormConfig(config) {
|
|
2221
|
+
const result = validateFormConfig(config);
|
|
2222
|
+
if (!result.valid) {
|
|
2223
|
+
const errorMessages = result.errors.map((err) => ` - [${err.code}] ${err.path}: ${err.message}`).join("\n");
|
|
2224
|
+
throw new Error(
|
|
2225
|
+
`Invalid form config for '${config.slug || "unknown"}':
|
|
2226
|
+
${errorMessages}`
|
|
2227
|
+
);
|
|
2228
|
+
}
|
|
1776
2229
|
}
|
|
1777
2230
|
|
|
2231
|
+
// src/config/field-helpers.ts
|
|
2232
|
+
var option = (label, value) => ({
|
|
2233
|
+
label,
|
|
2234
|
+
value: value ?? label.toLowerCase().replace(/\s+/g, "_")
|
|
2235
|
+
});
|
|
2236
|
+
var text3 = (config) => ({
|
|
2237
|
+
...config,
|
|
2238
|
+
type: "text"
|
|
2239
|
+
});
|
|
2240
|
+
var email = (config) => ({
|
|
2241
|
+
...config,
|
|
2242
|
+
type: "email"
|
|
2243
|
+
});
|
|
2244
|
+
var phone = (config) => ({
|
|
2245
|
+
...config,
|
|
2246
|
+
type: "phone"
|
|
2247
|
+
});
|
|
2248
|
+
var url = (config) => ({
|
|
2249
|
+
...config,
|
|
2250
|
+
type: "url"
|
|
2251
|
+
});
|
|
2252
|
+
var textarea3 = (config) => ({
|
|
2253
|
+
...config,
|
|
2254
|
+
type: "textarea"
|
|
2255
|
+
});
|
|
2256
|
+
var number = (config) => ({
|
|
2257
|
+
...config,
|
|
2258
|
+
type: "number"
|
|
2259
|
+
});
|
|
2260
|
+
var select3 = (config) => ({
|
|
2261
|
+
...config,
|
|
2262
|
+
type: "select"
|
|
2263
|
+
});
|
|
2264
|
+
var checkbox2 = (config) => ({
|
|
2265
|
+
...config,
|
|
2266
|
+
type: "checkbox"
|
|
2267
|
+
});
|
|
2268
|
+
var radio = (config) => ({
|
|
2269
|
+
...config,
|
|
2270
|
+
type: "radio"
|
|
2271
|
+
});
|
|
2272
|
+
var file = (config) => ({
|
|
2273
|
+
...config,
|
|
2274
|
+
type: "file"
|
|
2275
|
+
});
|
|
2276
|
+
var date3 = (config) => ({
|
|
2277
|
+
...config,
|
|
2278
|
+
type: "date"
|
|
2279
|
+
});
|
|
2280
|
+
var time = (config) => ({
|
|
2281
|
+
...config,
|
|
2282
|
+
type: "time"
|
|
2283
|
+
});
|
|
2284
|
+
var hidden = (config) => ({
|
|
2285
|
+
...config,
|
|
2286
|
+
type: "hidden"
|
|
2287
|
+
});
|
|
2288
|
+
|
|
2289
|
+
// src/handlers/submit-form.ts
|
|
2290
|
+
import { formAvailability, NO_SUCH_FORM } from "nextly";
|
|
2291
|
+
|
|
1778
2292
|
// src/handlers/spam-detection.ts
|
|
1779
2293
|
var rateLimitStore = /* @__PURE__ */ new Map();
|
|
1780
2294
|
async function checkSpam(options) {
|
|
@@ -1899,19 +2413,18 @@ async function submitForm(options, context) {
|
|
|
1899
2413
|
logger.warn?.("Form submission attempted for non-existent form", {
|
|
1900
2414
|
formSlug
|
|
1901
2415
|
});
|
|
1902
|
-
return {
|
|
1903
|
-
success: false,
|
|
1904
|
-
error: "Form not found"
|
|
1905
|
-
};
|
|
2416
|
+
return { success: false, error: NO_SUCH_FORM };
|
|
1906
2417
|
}
|
|
1907
|
-
|
|
1908
|
-
|
|
2418
|
+
const availability = formAvailability(form);
|
|
2419
|
+
if (availability.kind !== "open") {
|
|
2420
|
+
logger.info?.("Form submission rejected - form not accepting", {
|
|
1909
2421
|
formSlug,
|
|
1910
|
-
status: form.status
|
|
2422
|
+
status: form.status,
|
|
2423
|
+
availability: availability.kind
|
|
1911
2424
|
});
|
|
1912
2425
|
return {
|
|
1913
2426
|
success: false,
|
|
1914
|
-
error:
|
|
2427
|
+
error: availability.kind === "closed" ? availability.message : NO_SUCH_FORM
|
|
1915
2428
|
};
|
|
1916
2429
|
}
|
|
1917
2430
|
const settings = normalizeFormSettings(form.settings);
|
|
@@ -1970,34 +2483,30 @@ async function submitForm(options, context) {
|
|
|
1970
2483
|
return { success: true };
|
|
1971
2484
|
}
|
|
1972
2485
|
const isContentSpam = spamResult.isSpam;
|
|
1973
|
-
const transformedData = transformFormData(data, form.fields);
|
|
1974
|
-
let storedData;
|
|
1975
2486
|
if (isContentSpam) {
|
|
1976
2487
|
logger.info?.("Spam submission detected \u2014 storing flagged", {
|
|
1977
2488
|
formSlug,
|
|
1978
2489
|
reason: spamResult.reason,
|
|
1979
2490
|
ipAddress: metadata?.ipAddress
|
|
1980
2491
|
});
|
|
1981
|
-
sanitizeSubmissionData(transformedData, form.fields);
|
|
1982
|
-
storedData = transformedData;
|
|
1983
|
-
} else {
|
|
1984
|
-
const schema = generateZodSchema(form.fields);
|
|
1985
|
-
const validationResult = schema.safeParse(transformedData);
|
|
1986
|
-
if (!validationResult.success) {
|
|
1987
|
-
const validationErrors = getValidationErrors(validationResult);
|
|
1988
|
-
logger.debug?.("Form validation failed", {
|
|
1989
|
-
formSlug,
|
|
1990
|
-
errors: validationErrors
|
|
1991
|
-
});
|
|
1992
|
-
return {
|
|
1993
|
-
success: false,
|
|
1994
|
-
error: "Validation failed",
|
|
1995
|
-
validationErrors
|
|
1996
|
-
};
|
|
1997
|
-
}
|
|
1998
|
-
sanitizeSubmissionData(validationResult.data, form.fields);
|
|
1999
|
-
storedData = validationResult.data;
|
|
2000
2492
|
}
|
|
2493
|
+
const prepared = prepareSubmission({
|
|
2494
|
+
data,
|
|
2495
|
+
fields: form.fields,
|
|
2496
|
+
validate: !isContentSpam
|
|
2497
|
+
});
|
|
2498
|
+
if (prepared.validationErrors) {
|
|
2499
|
+
logger.debug?.("Form validation failed", {
|
|
2500
|
+
formSlug,
|
|
2501
|
+
errors: prepared.validationErrors
|
|
2502
|
+
});
|
|
2503
|
+
return {
|
|
2504
|
+
success: false,
|
|
2505
|
+
error: "Validation failed",
|
|
2506
|
+
validationErrors: prepared.validationErrors
|
|
2507
|
+
};
|
|
2508
|
+
}
|
|
2509
|
+
const storedData = prepared.data;
|
|
2001
2510
|
const submissionData = {
|
|
2002
2511
|
form: form.id,
|
|
2003
2512
|
data: storedData,
|
|
@@ -2009,7 +2518,10 @@ async function submitForm(options, context) {
|
|
|
2009
2518
|
};
|
|
2010
2519
|
const submission = await collections.createEntry(
|
|
2011
2520
|
pluginConfig.formSubmissionOverrides.slug,
|
|
2012
|
-
submissionData,
|
|
2521
|
+
asPluginSubmission(submissionData, {
|
|
2522
|
+
keepAsEvidence: isContentSpam,
|
|
2523
|
+
form: { id: form.id, fields: form.fields }
|
|
2524
|
+
}),
|
|
2013
2525
|
// Public form submission — create as system. No ambient user; an
|
|
2014
2526
|
// empty context already resolves to system, but be explicit.
|
|
2015
2527
|
{ as: "system" }
|
|
@@ -2026,7 +2538,11 @@ async function submitForm(options, context) {
|
|
|
2026
2538
|
warnings: submission.warnings
|
|
2027
2539
|
});
|
|
2028
2540
|
}
|
|
2029
|
-
const redirect =
|
|
2541
|
+
const redirect = await resolveRedirectUrl(
|
|
2542
|
+
form,
|
|
2543
|
+
pluginConfig,
|
|
2544
|
+
pluginContext
|
|
2545
|
+
);
|
|
2030
2546
|
if (isContentSpam) {
|
|
2031
2547
|
return { success: true, redirect };
|
|
2032
2548
|
}
|
|
@@ -2064,21 +2580,100 @@ async function fetchFormBySlug(slug, pluginConfig, pluginContext) {
|
|
|
2064
2580
|
return null;
|
|
2065
2581
|
}
|
|
2066
2582
|
}
|
|
2067
|
-
function
|
|
2068
|
-
|
|
2583
|
+
async function resolveRedirectUrl(form, pluginConfig, pluginContext) {
|
|
2584
|
+
const settings = form.settings;
|
|
2585
|
+
if (!settings) return void 0;
|
|
2586
|
+
if (settings.confirmationType === "redirect" && settings.redirectUrl) {
|
|
2587
|
+
return settings.redirectUrl;
|
|
2588
|
+
}
|
|
2589
|
+
const field = pickedDocumentField(settings);
|
|
2590
|
+
if (!field) return void 0;
|
|
2591
|
+
return urlForPickedDocument(
|
|
2592
|
+
settings[field],
|
|
2593
|
+
form,
|
|
2594
|
+
pluginConfig,
|
|
2595
|
+
pluginContext
|
|
2596
|
+
);
|
|
2597
|
+
}
|
|
2598
|
+
async function urlForPickedDocument(stored, form, pluginConfig, pluginContext) {
|
|
2599
|
+
const { logger } = pluginContext;
|
|
2600
|
+
const patterns = pluginConfig.redirectRelationships;
|
|
2601
|
+
const reference = parseRedirectReference(stored, Object.keys(patterns));
|
|
2602
|
+
if (!reference) {
|
|
2603
|
+
logger.warn?.("Form redirects to a page, but names no readable document", {
|
|
2604
|
+
form: form.slug
|
|
2605
|
+
});
|
|
2606
|
+
return void 0;
|
|
2607
|
+
}
|
|
2608
|
+
const pattern = patterns[reference.collection];
|
|
2609
|
+
if (!pattern) {
|
|
2610
|
+
logger.warn?.(
|
|
2611
|
+
"Form redirects to a collection that is not in redirectRelationships",
|
|
2612
|
+
{ form: form.slug, collection: reference.collection }
|
|
2613
|
+
);
|
|
2614
|
+
return void 0;
|
|
2615
|
+
}
|
|
2616
|
+
const target = await readTarget(reference, form, pluginContext);
|
|
2617
|
+
if (!target) return void 0;
|
|
2618
|
+
if (documentReachability(
|
|
2619
|
+
target,
|
|
2620
|
+
pluginConfig.redirectTargetLocalization[reference.collection]
|
|
2621
|
+
) === "unreachable") {
|
|
2622
|
+
logger.warn?.("Form redirects to a page that has never been published", {
|
|
2623
|
+
form: form.slug,
|
|
2624
|
+
collection: reference.collection,
|
|
2625
|
+
target: reference.id
|
|
2626
|
+
});
|
|
2069
2627
|
return void 0;
|
|
2070
2628
|
}
|
|
2071
|
-
|
|
2629
|
+
return buildUrl(pattern, target, reference.collection, form, pluginContext);
|
|
2630
|
+
}
|
|
2631
|
+
function buildUrl(pattern, target, collection, form, pluginContext) {
|
|
2632
|
+
const { logger } = pluginContext;
|
|
2633
|
+
try {
|
|
2634
|
+
const url2 = applyRedirectPattern(pattern, target);
|
|
2635
|
+
if (!url2) {
|
|
2636
|
+
logger.warn?.(
|
|
2637
|
+
"Form redirect pattern could not be filled from the target document",
|
|
2638
|
+
{ form: form.slug, collection }
|
|
2639
|
+
);
|
|
2640
|
+
}
|
|
2641
|
+
return url2;
|
|
2642
|
+
} catch (error) {
|
|
2643
|
+
logger.warn?.("Form redirect pattern threw while building a URL", {
|
|
2644
|
+
form: form.slug,
|
|
2645
|
+
collection,
|
|
2646
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2647
|
+
});
|
|
2072
2648
|
return void 0;
|
|
2073
2649
|
}
|
|
2074
|
-
|
|
2075
|
-
|
|
2650
|
+
}
|
|
2651
|
+
async function readTarget(reference, form, pluginContext) {
|
|
2652
|
+
const { logger } = pluginContext;
|
|
2653
|
+
const context = {
|
|
2654
|
+
form: form.slug,
|
|
2655
|
+
collection: reference.collection,
|
|
2656
|
+
id: reference.id
|
|
2657
|
+
};
|
|
2658
|
+
let row;
|
|
2659
|
+
try {
|
|
2660
|
+
row = await pluginContext.services.collections.findEntryById(
|
|
2661
|
+
reference.collection,
|
|
2662
|
+
reference.id,
|
|
2663
|
+
{ as: "system" }
|
|
2664
|
+
);
|
|
2665
|
+
} catch (error) {
|
|
2666
|
+
logger.warn?.("Form redirect target could not be read", {
|
|
2667
|
+
...context,
|
|
2668
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2669
|
+
});
|
|
2670
|
+
return void 0;
|
|
2076
2671
|
}
|
|
2077
|
-
if (
|
|
2078
|
-
|
|
2079
|
-
return
|
|
2672
|
+
if (!row || typeof row !== "object") {
|
|
2673
|
+
logger.warn?.("Form redirect target no longer exists", context);
|
|
2674
|
+
return void 0;
|
|
2080
2675
|
}
|
|
2081
|
-
return
|
|
2676
|
+
return row;
|
|
2082
2677
|
}
|
|
2083
2678
|
async function validateSubmission(formSlug, data, context) {
|
|
2084
2679
|
const { pluginContext, pluginConfig } = context;
|
|
@@ -2086,15 +2681,17 @@ async function validateSubmission(formSlug, data, context) {
|
|
|
2086
2681
|
if (!form) {
|
|
2087
2682
|
return { valid: false, errors: { _form: "Form not found" } };
|
|
2088
2683
|
}
|
|
2089
|
-
const
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2684
|
+
const prepared = prepareSubmission({
|
|
2685
|
+
data,
|
|
2686
|
+
fields: form.fields,
|
|
2687
|
+
validate: true
|
|
2688
|
+
});
|
|
2689
|
+
if (!prepared.validationErrors) {
|
|
2093
2690
|
return { valid: true };
|
|
2094
2691
|
}
|
|
2095
2692
|
return {
|
|
2096
2693
|
valid: false,
|
|
2097
|
-
errors:
|
|
2694
|
+
errors: prepared.validationErrors
|
|
2098
2695
|
};
|
|
2099
2696
|
}
|
|
2100
2697
|
async function isFormAcceptingSubmissions(formSlug, context) {
|
|
@@ -2105,25 +2702,6 @@ async function isFormAcceptingSubmissions(formSlug, context) {
|
|
|
2105
2702
|
}
|
|
2106
2703
|
return form.status === "published";
|
|
2107
2704
|
}
|
|
2108
|
-
var TEXT_FORM_FIELDS = /* @__PURE__ */ new Set([
|
|
2109
|
-
"text",
|
|
2110
|
-
"email",
|
|
2111
|
-
"textarea",
|
|
2112
|
-
"phone",
|
|
2113
|
-
"url",
|
|
2114
|
-
"hidden"
|
|
2115
|
-
]);
|
|
2116
|
-
function stripHtmlTags(input) {
|
|
2117
|
-
return input.replace(/<[^>]*(?:>|$)/g, "").replace(/\s+/g, " ").trim();
|
|
2118
|
-
}
|
|
2119
|
-
function sanitizeSubmissionData(data, fields) {
|
|
2120
|
-
for (const field of fields) {
|
|
2121
|
-
if (!TEXT_FORM_FIELDS.has(field.type)) continue;
|
|
2122
|
-
const value = data[field.name];
|
|
2123
|
-
if (typeof value !== "string") continue;
|
|
2124
|
-
data[field.name] = stripHtmlTags(value);
|
|
2125
|
-
}
|
|
2126
|
-
}
|
|
2127
2705
|
async function getFormSubmissionStats(formSlug, context) {
|
|
2128
2706
|
const { pluginContext, pluginConfig } = context;
|
|
2129
2707
|
const { collections } = pluginContext.services;
|
|
@@ -2352,7 +2930,7 @@ export {
|
|
|
2352
2930
|
cleanupRateLimitStore,
|
|
2353
2931
|
clearRateLimitStore,
|
|
2354
2932
|
createFormConfig,
|
|
2355
|
-
|
|
2933
|
+
date3 as date,
|
|
2356
2934
|
downloadFile,
|
|
2357
2935
|
email,
|
|
2358
2936
|
evaluateConditions,
|