@nextlyhq/plugin-form-builder 0.0.1
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/LICENSE +22 -0
- package/README.md +122 -0
- package/dist/CheckboxFieldEditor-K4T6QX52.js +121 -0
- package/dist/CheckboxFieldEditor-K4T6QX52.js.map +1 -0
- package/dist/DateFieldEditor-JFJFAVOH.js +187 -0
- package/dist/DateFieldEditor-JFJFAVOH.js.map +1 -0
- package/dist/EmailFieldEditor-MMOXXL7P.js +138 -0
- package/dist/EmailFieldEditor-MMOXXL7P.js.map +1 -0
- package/dist/NumberFieldEditor-UISAYR6Z.js +175 -0
- package/dist/NumberFieldEditor-UISAYR6Z.js.map +1 -0
- package/dist/RadioFieldEditor-6UQFH4WT.js +165 -0
- package/dist/RadioFieldEditor-6UQFH4WT.js.map +1 -0
- package/dist/SelectFieldEditor-PSO4KHV4.js +213 -0
- package/dist/SelectFieldEditor-PSO4KHV4.js.map +1 -0
- package/dist/TextFieldEditor-5EU64EA5.js +187 -0
- package/dist/TextFieldEditor-5EU64EA5.js.map +1 -0
- package/dist/TextareaFieldEditor-773G3LNM.js +190 -0
- package/dist/TextareaFieldEditor-773G3LNM.js.map +1 -0
- package/dist/admin/index.css +934 -0
- package/dist/admin/index.css.map +1 -0
- package/dist/admin/index.d.ts +547 -0
- package/dist/admin/index.js +4030 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/chunk-ABNZFTQK.js +586 -0
- package/dist/chunk-ABNZFTQK.js.map +1 -0
- package/dist/chunk-XTCEPJ7B.js +256 -0
- package/dist/chunk-XTCEPJ7B.js.map +1 -0
- package/dist/components/index.d.ts +3 -0
- package/dist/components/index.js +9 -0
- package/dist/components/index.js.map +1 -0
- package/dist/index-C0kBvYjm.d.ts +1012 -0
- package/dist/index.d.ts +1663 -0
- package/dist/index.js +2470 -0
- package/dist/index.js.map +1 -0
- package/dist/styles/form-builder.css +1150 -0
- package/dist/styles/submissions-filter.css +75 -0
- package/package.json +96 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2470 @@
|
|
|
1
|
+
// src/plugin.ts
|
|
2
|
+
import { getCollectionsHandler } from "nextly/runtime";
|
|
3
|
+
|
|
4
|
+
// src/collections/forms.ts
|
|
5
|
+
import {
|
|
6
|
+
text,
|
|
7
|
+
textarea,
|
|
8
|
+
select,
|
|
9
|
+
checkbox,
|
|
10
|
+
group,
|
|
11
|
+
json,
|
|
12
|
+
relationship
|
|
13
|
+
} from "nextly";
|
|
14
|
+
function formsCollection(pluginConfig) {
|
|
15
|
+
const {
|
|
16
|
+
slug,
|
|
17
|
+
labels,
|
|
18
|
+
fields: additionalFields,
|
|
19
|
+
access: accessOverrides,
|
|
20
|
+
...overrides
|
|
21
|
+
} = pluginConfig.formOverrides;
|
|
22
|
+
const settingsFields = [
|
|
23
|
+
text({
|
|
24
|
+
name: "submitButtonText",
|
|
25
|
+
label: "Submit Button Text",
|
|
26
|
+
defaultValue: "Submit",
|
|
27
|
+
admin: {
|
|
28
|
+
description: "Text displayed on the form submit button"
|
|
29
|
+
}
|
|
30
|
+
}),
|
|
31
|
+
select({
|
|
32
|
+
name: "confirmationType",
|
|
33
|
+
label: "After Submission",
|
|
34
|
+
defaultValue: "message",
|
|
35
|
+
options: [
|
|
36
|
+
{ label: "Show Message", value: "message" },
|
|
37
|
+
{ label: "Redirect to URL", value: "redirect" },
|
|
38
|
+
...pluginConfig.redirectRelationships.length > 0 ? [{ label: "Redirect to Page", value: "relationship" }] : []
|
|
39
|
+
],
|
|
40
|
+
admin: {
|
|
41
|
+
description: "What happens after a successful form submission"
|
|
42
|
+
}
|
|
43
|
+
}),
|
|
44
|
+
textarea({
|
|
45
|
+
name: "successMessage",
|
|
46
|
+
label: "Success Message",
|
|
47
|
+
defaultValue: "Thank you for your submission!",
|
|
48
|
+
admin: {
|
|
49
|
+
description: "Message shown after successful submission",
|
|
50
|
+
condition: {
|
|
51
|
+
field: "settings.confirmationType",
|
|
52
|
+
equals: "message"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}),
|
|
56
|
+
text({
|
|
57
|
+
name: "redirectUrl",
|
|
58
|
+
label: "Redirect URL",
|
|
59
|
+
admin: {
|
|
60
|
+
description: "URL to redirect to after submission",
|
|
61
|
+
condition: {
|
|
62
|
+
field: "settings.confirmationType",
|
|
63
|
+
equals: "redirect"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
];
|
|
68
|
+
if (pluginConfig.redirectRelationships.length > 0) {
|
|
69
|
+
settingsFields.push(
|
|
70
|
+
relationship({
|
|
71
|
+
name: "redirectPage",
|
|
72
|
+
label: "Redirect Page",
|
|
73
|
+
relationTo: pluginConfig.redirectRelationships,
|
|
74
|
+
admin: {
|
|
75
|
+
description: "Select a page to redirect to after submission",
|
|
76
|
+
condition: {
|
|
77
|
+
field: "settings.confirmationType",
|
|
78
|
+
equals: "relationship"
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
settingsFields.push(
|
|
85
|
+
checkbox({
|
|
86
|
+
name: "allowMultipleSubmissions",
|
|
87
|
+
label: "Allow Multiple Submissions",
|
|
88
|
+
defaultValue: true,
|
|
89
|
+
admin: {
|
|
90
|
+
description: "Allow the same user/IP to submit this form multiple times"
|
|
91
|
+
}
|
|
92
|
+
}),
|
|
93
|
+
checkbox({
|
|
94
|
+
name: "captchaEnabled",
|
|
95
|
+
label: "Enable reCAPTCHA",
|
|
96
|
+
defaultValue: false,
|
|
97
|
+
admin: {
|
|
98
|
+
description: "Protect this form with Google reCAPTCHA v3"
|
|
99
|
+
}
|
|
100
|
+
}),
|
|
101
|
+
text({
|
|
102
|
+
name: "captchaSiteKey",
|
|
103
|
+
label: "reCAPTCHA Site Key",
|
|
104
|
+
admin: {
|
|
105
|
+
description: "Override the default reCAPTCHA site key for this form",
|
|
106
|
+
condition: {
|
|
107
|
+
field: "settings.captchaEnabled",
|
|
108
|
+
equals: true
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
);
|
|
113
|
+
const defaultFields = [
|
|
114
|
+
// ============================================================
|
|
115
|
+
// Basic Information
|
|
116
|
+
// ============================================================
|
|
117
|
+
text({
|
|
118
|
+
name: "name",
|
|
119
|
+
label: "Form Name",
|
|
120
|
+
required: true,
|
|
121
|
+
admin: {
|
|
122
|
+
description: "Internal name for this form (not shown to users)"
|
|
123
|
+
}
|
|
124
|
+
}),
|
|
125
|
+
text({
|
|
126
|
+
name: "slug",
|
|
127
|
+
label: "Slug",
|
|
128
|
+
required: true,
|
|
129
|
+
unique: true,
|
|
130
|
+
admin: {
|
|
131
|
+
description: "URL-friendly identifier. Used in API: /api/forms/{slug}"
|
|
132
|
+
}
|
|
133
|
+
}),
|
|
134
|
+
textarea({
|
|
135
|
+
name: "description",
|
|
136
|
+
label: "Description",
|
|
137
|
+
admin: {
|
|
138
|
+
description: "Internal notes about this form's purpose"
|
|
139
|
+
}
|
|
140
|
+
}),
|
|
141
|
+
// ============================================================
|
|
142
|
+
// Form Fields (JSON schema managed by Form Builder UI)
|
|
143
|
+
// ============================================================
|
|
144
|
+
json({
|
|
145
|
+
name: "fields",
|
|
146
|
+
label: "Form Fields",
|
|
147
|
+
required: true,
|
|
148
|
+
defaultValue: [],
|
|
149
|
+
admin: {
|
|
150
|
+
description: "Form field configuration. Managed by the Form Builder UI."
|
|
151
|
+
}
|
|
152
|
+
}),
|
|
153
|
+
// ============================================================
|
|
154
|
+
// Form Settings
|
|
155
|
+
// ============================================================
|
|
156
|
+
group({
|
|
157
|
+
name: "settings",
|
|
158
|
+
label: "Form Settings",
|
|
159
|
+
fields: settingsFields
|
|
160
|
+
}),
|
|
161
|
+
// ============================================================
|
|
162
|
+
// Email Notifications
|
|
163
|
+
// ============================================================
|
|
164
|
+
json({
|
|
165
|
+
name: "notifications",
|
|
166
|
+
label: "Email Integrations",
|
|
167
|
+
defaultValue: [],
|
|
168
|
+
admin: {
|
|
169
|
+
description: "Email notification integrations. Managed by the Form Builder UI."
|
|
170
|
+
}
|
|
171
|
+
}),
|
|
172
|
+
// ============================================================
|
|
173
|
+
// Status
|
|
174
|
+
// ============================================================
|
|
175
|
+
select({
|
|
176
|
+
name: "status",
|
|
177
|
+
label: "Status",
|
|
178
|
+
required: true,
|
|
179
|
+
defaultValue: "draft",
|
|
180
|
+
options: [
|
|
181
|
+
{ label: "Draft", value: "draft" },
|
|
182
|
+
{ label: "Published", value: "published" },
|
|
183
|
+
{ label: "Closed", value: "closed" }
|
|
184
|
+
],
|
|
185
|
+
admin: {
|
|
186
|
+
description: "Only published forms can receive submissions. Closed forms display a message instead."
|
|
187
|
+
}
|
|
188
|
+
}),
|
|
189
|
+
textarea({
|
|
190
|
+
name: "closedMessage",
|
|
191
|
+
label: "Closed Form Message",
|
|
192
|
+
defaultValue: "This form is no longer accepting submissions.",
|
|
193
|
+
admin: {
|
|
194
|
+
description: "Message shown when form status is 'closed'",
|
|
195
|
+
condition: {
|
|
196
|
+
field: "status",
|
|
197
|
+
equals: "closed"
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
})
|
|
201
|
+
];
|
|
202
|
+
let finalFields;
|
|
203
|
+
if (Array.isArray(additionalFields) && additionalFields.length > 0) {
|
|
204
|
+
finalFields = [...defaultFields, ...additionalFields];
|
|
205
|
+
} else {
|
|
206
|
+
finalFields = defaultFields;
|
|
207
|
+
}
|
|
208
|
+
const dbName = slug.replace(/-/g, "_");
|
|
209
|
+
return {
|
|
210
|
+
slug,
|
|
211
|
+
dbName,
|
|
212
|
+
labels,
|
|
213
|
+
fields: finalFields,
|
|
214
|
+
timestamps: true,
|
|
215
|
+
admin: {
|
|
216
|
+
isPlugin: true,
|
|
217
|
+
group: "Forms",
|
|
218
|
+
order: 50,
|
|
219
|
+
useAsTitle: "name",
|
|
220
|
+
description: "Create and manage your form templates using the visual form builder. Create and manage forms for collecting user submissions",
|
|
221
|
+
// Custom components for visual form builder
|
|
222
|
+
components: {
|
|
223
|
+
views: {
|
|
224
|
+
// Replace default edit view with visual Form Builder
|
|
225
|
+
Edit: {
|
|
226
|
+
Component: "@nextlyhq/plugin-form-builder/admin#FormBuilderView"
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
...overrides.admin
|
|
231
|
+
},
|
|
232
|
+
// Access control with sensible defaults
|
|
233
|
+
access: {
|
|
234
|
+
// Anyone can read forms (needed for frontend rendering)
|
|
235
|
+
read: accessOverrides?.read ?? true,
|
|
236
|
+
// Only authenticated users can create/update forms
|
|
237
|
+
create: accessOverrides?.create ?? (({ user }) => !!user),
|
|
238
|
+
update: accessOverrides?.update ?? (({ user }) => !!user),
|
|
239
|
+
// Only admins can delete forms (falls back to DB permissions)
|
|
240
|
+
delete: accessOverrides?.delete ?? (({ roles }) => roles.includes("admin") || roles.includes("super-admin"))
|
|
241
|
+
},
|
|
242
|
+
hooks: {
|
|
243
|
+
// Auto-generate slug from name if not provided
|
|
244
|
+
beforeValidate: [
|
|
245
|
+
(context) => {
|
|
246
|
+
const { data, operation } = context;
|
|
247
|
+
if (operation === "create" && data && !data.slug && data.name) {
|
|
248
|
+
data.slug = String(data.name).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
249
|
+
}
|
|
250
|
+
if (data && (data.fields === void 0 || Array.isArray(data.fields) && data.fields.length === 0)) {
|
|
251
|
+
throw new Error("Form must have at least one field.");
|
|
252
|
+
}
|
|
253
|
+
return data;
|
|
254
|
+
}
|
|
255
|
+
],
|
|
256
|
+
// Add virtual submission count field
|
|
257
|
+
afterRead: [
|
|
258
|
+
(context) => {
|
|
259
|
+
const { data } = context;
|
|
260
|
+
if (data) {
|
|
261
|
+
data.submissionCount = 0;
|
|
262
|
+
}
|
|
263
|
+
return data;
|
|
264
|
+
}
|
|
265
|
+
]
|
|
266
|
+
},
|
|
267
|
+
// Spread any additional overrides (excluding already used properties)
|
|
268
|
+
...overrides
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// src/collections/submissions.ts
|
|
273
|
+
import { text as text2, textarea as textarea2, select as select2, json as json2, date, relationship as relationship2 } from "nextly";
|
|
274
|
+
function submissionsCollection(pluginConfig) {
|
|
275
|
+
const {
|
|
276
|
+
slug,
|
|
277
|
+
labels,
|
|
278
|
+
fields: additionalFields,
|
|
279
|
+
access: accessOverrides,
|
|
280
|
+
...overrides
|
|
281
|
+
} = pluginConfig.formSubmissionOverrides;
|
|
282
|
+
const formSlug = pluginConfig.formOverrides.slug;
|
|
283
|
+
const defaultFields = [
|
|
284
|
+
// ============================================================
|
|
285
|
+
// Form Relationship
|
|
286
|
+
// ============================================================
|
|
287
|
+
relationship2({
|
|
288
|
+
name: "form",
|
|
289
|
+
label: "Form",
|
|
290
|
+
relationTo: formSlug,
|
|
291
|
+
required: true,
|
|
292
|
+
admin: {
|
|
293
|
+
readOnly: true,
|
|
294
|
+
description: "The form this submission belongs to"
|
|
295
|
+
}
|
|
296
|
+
}),
|
|
297
|
+
// ============================================================
|
|
298
|
+
// Submission Data (JSON)
|
|
299
|
+
// ============================================================
|
|
300
|
+
json2({
|
|
301
|
+
name: "data",
|
|
302
|
+
label: "Submission Data",
|
|
303
|
+
required: true,
|
|
304
|
+
admin: {
|
|
305
|
+
readOnly: true,
|
|
306
|
+
description: "The submitted form data as JSON"
|
|
307
|
+
}
|
|
308
|
+
}),
|
|
309
|
+
// ============================================================
|
|
310
|
+
// Status
|
|
311
|
+
// ============================================================
|
|
312
|
+
select2({
|
|
313
|
+
name: "status",
|
|
314
|
+
label: "Status",
|
|
315
|
+
required: true,
|
|
316
|
+
defaultValue: "new",
|
|
317
|
+
options: [
|
|
318
|
+
{ label: "New", value: "new" },
|
|
319
|
+
{ label: "Read", value: "read" },
|
|
320
|
+
{ label: "Archived", value: "archived" }
|
|
321
|
+
],
|
|
322
|
+
admin: {
|
|
323
|
+
description: "Track whether this submission has been reviewed"
|
|
324
|
+
}
|
|
325
|
+
}),
|
|
326
|
+
// ============================================================
|
|
327
|
+
// Internal Notes
|
|
328
|
+
// ============================================================
|
|
329
|
+
textarea2({
|
|
330
|
+
name: "notes",
|
|
331
|
+
label: "Internal Notes",
|
|
332
|
+
admin: {
|
|
333
|
+
description: "Notes visible only to admins (not shown to submitter)"
|
|
334
|
+
}
|
|
335
|
+
}),
|
|
336
|
+
// ============================================================
|
|
337
|
+
// Metadata
|
|
338
|
+
// ============================================================
|
|
339
|
+
text2({
|
|
340
|
+
name: "ipAddress",
|
|
341
|
+
label: "IP Address",
|
|
342
|
+
admin: {
|
|
343
|
+
readOnly: true,
|
|
344
|
+
description: "IP address of the submitter"
|
|
345
|
+
}
|
|
346
|
+
}),
|
|
347
|
+
text2({
|
|
348
|
+
name: "userAgent",
|
|
349
|
+
label: "User Agent",
|
|
350
|
+
admin: {
|
|
351
|
+
readOnly: true,
|
|
352
|
+
description: "Browser/device information of the submitter"
|
|
353
|
+
}
|
|
354
|
+
}),
|
|
355
|
+
date({
|
|
356
|
+
name: "submittedAt",
|
|
357
|
+
label: "Submitted At",
|
|
358
|
+
required: true,
|
|
359
|
+
admin: {
|
|
360
|
+
readOnly: true,
|
|
361
|
+
description: "When the form was submitted"
|
|
362
|
+
}
|
|
363
|
+
})
|
|
364
|
+
];
|
|
365
|
+
let finalFields;
|
|
366
|
+
if (Array.isArray(additionalFields) && additionalFields.length > 0) {
|
|
367
|
+
finalFields = [...defaultFields, ...additionalFields];
|
|
368
|
+
} else {
|
|
369
|
+
finalFields = defaultFields;
|
|
370
|
+
}
|
|
371
|
+
const dbName = slug.replace(/-/g, "_");
|
|
372
|
+
return {
|
|
373
|
+
slug,
|
|
374
|
+
dbName,
|
|
375
|
+
labels,
|
|
376
|
+
fields: finalFields,
|
|
377
|
+
timestamps: true,
|
|
378
|
+
admin: {
|
|
379
|
+
isPlugin: true,
|
|
380
|
+
group: "Forms",
|
|
381
|
+
order: 51,
|
|
382
|
+
useAsTitle: "id",
|
|
383
|
+
description: "View and manage form submissions",
|
|
384
|
+
// Custom components for submissions list
|
|
385
|
+
components: {
|
|
386
|
+
// Filter dropdown to filter submissions by form
|
|
387
|
+
BeforeListTable: "@nextlyhq/plugin-form-builder/admin#SubmissionsFilter"
|
|
388
|
+
},
|
|
389
|
+
...overrides.admin
|
|
390
|
+
},
|
|
391
|
+
// Access control with sensible defaults
|
|
392
|
+
// Public can create (submit forms), but only authenticated users can read/update/delete
|
|
393
|
+
access: {
|
|
394
|
+
// Anyone can submit forms (public access)
|
|
395
|
+
create: accessOverrides?.create ?? true,
|
|
396
|
+
// Only authenticated users can view submissions
|
|
397
|
+
read: accessOverrides?.read ?? (({ user }) => !!user),
|
|
398
|
+
// Only authenticated users can update submissions (e.g., change status, add notes)
|
|
399
|
+
update: accessOverrides?.update ?? (({ user }) => !!user),
|
|
400
|
+
// Only admins can delete submissions (falls back to DB permissions)
|
|
401
|
+
delete: accessOverrides?.delete ?? (({ roles }) => roles.includes("admin") || roles.includes("super-admin"))
|
|
402
|
+
},
|
|
403
|
+
hooks: {
|
|
404
|
+
// Auto-set submittedAt timestamp on creation
|
|
405
|
+
beforeValidate: [
|
|
406
|
+
(context) => {
|
|
407
|
+
const { data, operation } = context;
|
|
408
|
+
if (operation === "create" && data && !data.submittedAt) {
|
|
409
|
+
data.submittedAt = /* @__PURE__ */ new Date();
|
|
410
|
+
}
|
|
411
|
+
return data;
|
|
412
|
+
}
|
|
413
|
+
],
|
|
414
|
+
afterChange: [
|
|
415
|
+
(context) => {
|
|
416
|
+
const { data } = context;
|
|
417
|
+
return data;
|
|
418
|
+
}
|
|
419
|
+
]
|
|
420
|
+
},
|
|
421
|
+
// Spread any additional overrides (excluding already used properties)
|
|
422
|
+
...overrides
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// src/plugin.ts
|
|
427
|
+
function resolveConfig(options) {
|
|
428
|
+
const formOverrides = options.formOverrides || options.collections?.forms || {};
|
|
429
|
+
const submissionOverrides = options.formSubmissionOverrides || options.collections?.submissions || {};
|
|
430
|
+
return {
|
|
431
|
+
formOverrides: {
|
|
432
|
+
...formOverrides,
|
|
433
|
+
slug: formOverrides.slug || "forms",
|
|
434
|
+
labels: {
|
|
435
|
+
singular: formOverrides.labels?.singular || "Form",
|
|
436
|
+
plural: formOverrides.labels?.plural || "Forms"
|
|
437
|
+
}
|
|
438
|
+
},
|
|
439
|
+
formSubmissionOverrides: {
|
|
440
|
+
...submissionOverrides,
|
|
441
|
+
slug: submissionOverrides.slug || "form-submissions",
|
|
442
|
+
labels: {
|
|
443
|
+
singular: submissionOverrides.labels?.singular || "Submission",
|
|
444
|
+
plural: submissionOverrides.labels?.plural || "Submissions"
|
|
445
|
+
}
|
|
446
|
+
},
|
|
447
|
+
fields: {
|
|
448
|
+
text: options.fields?.text ?? true,
|
|
449
|
+
email: options.fields?.email ?? true,
|
|
450
|
+
number: options.fields?.number ?? true,
|
|
451
|
+
phone: options.fields?.phone ?? true,
|
|
452
|
+
url: options.fields?.url ?? true,
|
|
453
|
+
textarea: options.fields?.textarea ?? true,
|
|
454
|
+
select: options.fields?.select ?? true,
|
|
455
|
+
checkbox: options.fields?.checkbox ?? true,
|
|
456
|
+
radio: options.fields?.radio ?? true,
|
|
457
|
+
file: options.fields?.file ?? true,
|
|
458
|
+
date: options.fields?.date ?? true,
|
|
459
|
+
time: options.fields?.time ?? true,
|
|
460
|
+
hidden: options.fields?.hidden ?? true
|
|
461
|
+
},
|
|
462
|
+
redirectRelationships: options.redirectRelationships || [],
|
|
463
|
+
beforeEmail: options.beforeEmail,
|
|
464
|
+
notifications: {
|
|
465
|
+
defaultFrom: options.notifications?.defaultFrom,
|
|
466
|
+
defaultToEmail: options.notifications?.defaultToEmail,
|
|
467
|
+
enabled: options.notifications?.enabled ?? true
|
|
468
|
+
},
|
|
469
|
+
spamProtection: {
|
|
470
|
+
honeypot: options.spamProtection?.honeypot ?? true,
|
|
471
|
+
recaptcha: options.spamProtection?.recaptcha ?? { enabled: false },
|
|
472
|
+
rateLimit: {
|
|
473
|
+
maxSubmissions: options.spamProtection?.rateLimit?.maxSubmissions ?? 10,
|
|
474
|
+
windowMs: options.spamProtection?.rateLimit?.windowMs ?? 6e4
|
|
475
|
+
}
|
|
476
|
+
},
|
|
477
|
+
uploads: {
|
|
478
|
+
maxFileSize: options.uploads?.maxFileSize ?? 10485760,
|
|
479
|
+
// 10 MB
|
|
480
|
+
allowedMimeTypes: options.uploads?.allowedMimeTypes ?? [
|
|
481
|
+
"image/*",
|
|
482
|
+
"application/pdf",
|
|
483
|
+
"text/*"
|
|
484
|
+
],
|
|
485
|
+
uploadCollection: options.uploads?.uploadCollection ?? "media"
|
|
486
|
+
},
|
|
487
|
+
features: {
|
|
488
|
+
builder: options.features?.builder ?? true,
|
|
489
|
+
conditionalLogic: options.features?.conditionalLogic ?? true,
|
|
490
|
+
fileUploads: options.features?.fileUploads ?? true
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
function formBuilder(options = {}) {
|
|
495
|
+
const resolvedConfig = resolveConfig(options);
|
|
496
|
+
const formsCol = formsCollection(resolvedConfig);
|
|
497
|
+
const submissionsCol = submissionsCollection(resolvedConfig);
|
|
498
|
+
const plugin = {
|
|
499
|
+
name: "@nextlyhq/plugin-form-builder",
|
|
500
|
+
version: "0.0.8",
|
|
501
|
+
collections: [formsCol, submissionsCol],
|
|
502
|
+
admin: {
|
|
503
|
+
order: 50,
|
|
504
|
+
description: "Create and manage forms with submission tracking"
|
|
505
|
+
},
|
|
506
|
+
// -- Config transformer --------------------------------------------------
|
|
507
|
+
// Automatically adds plugin collections so users don't have to spread them.
|
|
508
|
+
config(config) {
|
|
509
|
+
const existing = config.collections || [];
|
|
510
|
+
const formsSlug = resolvedConfig.formOverrides.slug;
|
|
511
|
+
const submissionsSlug = resolvedConfig.formSubmissionOverrides.slug;
|
|
512
|
+
const toAdd = [];
|
|
513
|
+
if (!existing.some((c) => c.slug === formsSlug))
|
|
514
|
+
toAdd.push(formsCol);
|
|
515
|
+
if (!existing.some((c) => c.slug === submissionsSlug))
|
|
516
|
+
toAdd.push(submissionsCol);
|
|
517
|
+
return { ...config, collections: [...existing, ...toAdd] };
|
|
518
|
+
},
|
|
519
|
+
// -- Init ----------------------------------------------------------------
|
|
520
|
+
// Registers an afterCreate hook on submissions to send email notifications.
|
|
521
|
+
init(nextly) {
|
|
522
|
+
const submissionSlug = resolvedConfig.formSubmissionOverrides.slug;
|
|
523
|
+
const guardKey = `__formBuilder_afterCreate_${submissionSlug}`;
|
|
524
|
+
const g = globalThis;
|
|
525
|
+
if (g[guardKey]) return;
|
|
526
|
+
g[guardKey] = true;
|
|
527
|
+
nextly.infra.logger.info("Form Builder plugin initialized", {
|
|
528
|
+
formsCollection: resolvedConfig.formOverrides.slug,
|
|
529
|
+
submissionsCollection: submissionSlug,
|
|
530
|
+
enabledFields: Object.entries(resolvedConfig.fields).filter(([, on]) => on).map(([name]) => name)
|
|
531
|
+
});
|
|
532
|
+
nextly.__formBuilderConfig = resolvedConfig;
|
|
533
|
+
nextly.hooks.on(
|
|
534
|
+
"afterCreate",
|
|
535
|
+
submissionSlug,
|
|
536
|
+
async (context) => {
|
|
537
|
+
await handleSubmissionCreated(context, resolvedConfig, nextly);
|
|
538
|
+
}
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
};
|
|
542
|
+
return {
|
|
543
|
+
plugin,
|
|
544
|
+
collections: [formsCol, submissionsCol],
|
|
545
|
+
config: resolvedConfig
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
function getFormBuilderConfig(nextly) {
|
|
549
|
+
if (!nextly || typeof nextly !== "object") return void 0;
|
|
550
|
+
return nextly.__formBuilderConfig;
|
|
551
|
+
}
|
|
552
|
+
function resolveFieldRef(ref, data) {
|
|
553
|
+
const match = ref.match(/^\{\{(\w+)\}\}$/);
|
|
554
|
+
if (!match) return ref;
|
|
555
|
+
const value = data[match[1]];
|
|
556
|
+
return typeof value === "string" ? value : "";
|
|
557
|
+
}
|
|
558
|
+
function collectAttachmentInputs(fields, submittedData) {
|
|
559
|
+
const attachments = [];
|
|
560
|
+
for (const field of fields) {
|
|
561
|
+
if (field.type !== "file" || !field.attachToEmail) continue;
|
|
562
|
+
const name = field.name;
|
|
563
|
+
const value = submittedData[name];
|
|
564
|
+
if (value == null) continue;
|
|
565
|
+
if (Array.isArray(value)) {
|
|
566
|
+
for (const item of value) {
|
|
567
|
+
if (typeof item === "string" && item) {
|
|
568
|
+
attachments.push({ mediaId: item });
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
} else if (typeof value === "string" && value) {
|
|
572
|
+
attachments.push({ mediaId: value });
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
return attachments;
|
|
576
|
+
}
|
|
577
|
+
async function handleSubmissionCreated(context, config, nextly) {
|
|
578
|
+
const submission = context.data;
|
|
579
|
+
if (!submission) return;
|
|
580
|
+
const rawFormId = submission.form;
|
|
581
|
+
let formId = null;
|
|
582
|
+
if (typeof rawFormId === "string") {
|
|
583
|
+
formId = rawFormId;
|
|
584
|
+
} else if (rawFormId && typeof rawFormId === "object") {
|
|
585
|
+
const maybeId = rawFormId.id;
|
|
586
|
+
if (typeof maybeId === "string") formId = maybeId;
|
|
587
|
+
}
|
|
588
|
+
if (!formId) return;
|
|
589
|
+
const form = await fetchParentForm(config, formId, nextly);
|
|
590
|
+
if (!form) return;
|
|
591
|
+
const notifications = Array.isArray(form.notifications) ? form.notifications : [];
|
|
592
|
+
if (notifications.length === 0) return;
|
|
593
|
+
const emailService = nextly.services.email;
|
|
594
|
+
if (!emailService) return;
|
|
595
|
+
const submittedData = submission.data ?? {};
|
|
596
|
+
const formFields = Array.isArray(form.fields) ? form.fields : [];
|
|
597
|
+
const fileAttachments = collectAttachmentInputs(formFields, submittedData);
|
|
598
|
+
const seen = /* @__PURE__ */ new Set();
|
|
599
|
+
for (const notification of notifications) {
|
|
600
|
+
if (!notification.enabled || !notification.templateSlug) continue;
|
|
601
|
+
const key = notification.id || `${notification.to}::${notification.templateSlug}`;
|
|
602
|
+
if (seen.has(key)) continue;
|
|
603
|
+
seen.add(key);
|
|
604
|
+
try {
|
|
605
|
+
const to = notification.recipientType === "field" ? resolveFieldRef(notification.to, submittedData) : notification.to;
|
|
606
|
+
if (!to) {
|
|
607
|
+
nextly.infra.logger.warn?.(
|
|
608
|
+
"Form Builder: empty recipient, skipping notification",
|
|
609
|
+
{ notificationId: notification.id, formSlug: form.slug }
|
|
610
|
+
);
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
const cc = Array.isArray(notification.cc) && notification.cc.length ? notification.cc : void 0;
|
|
614
|
+
const bcc = Array.isArray(notification.bcc) && notification.bcc.length ? notification.bcc : void 0;
|
|
615
|
+
await emailService.sendWithTemplate(
|
|
616
|
+
notification.templateSlug,
|
|
617
|
+
to,
|
|
618
|
+
{ ...submittedData, formName: form.name, submissionId: submission.id },
|
|
619
|
+
{
|
|
620
|
+
providerId: notification.providerId,
|
|
621
|
+
cc,
|
|
622
|
+
bcc,
|
|
623
|
+
attachments: fileAttachments.length > 0 ? fileAttachments : void 0
|
|
624
|
+
}
|
|
625
|
+
);
|
|
626
|
+
nextly.infra.logger.info?.("Form Builder: notification sent", {
|
|
627
|
+
formSlug: form.slug,
|
|
628
|
+
to,
|
|
629
|
+
templateSlug: notification.templateSlug,
|
|
630
|
+
attachmentCount: fileAttachments.length
|
|
631
|
+
});
|
|
632
|
+
} catch (err) {
|
|
633
|
+
nextly.infra.logger.error?.("Form Builder: notification failed", {
|
|
634
|
+
notificationId: notification.id,
|
|
635
|
+
error: err instanceof Error ? err.message : String(err)
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
async function fetchParentForm(config, formId, nextly) {
|
|
641
|
+
try {
|
|
642
|
+
const handler = getCollectionsHandler();
|
|
643
|
+
if (!handler) {
|
|
644
|
+
nextly.infra.logger.warn?.(
|
|
645
|
+
"Form Builder: CollectionsHandler unavailable, skipping notifications"
|
|
646
|
+
);
|
|
647
|
+
return null;
|
|
648
|
+
}
|
|
649
|
+
const result = await handler.getEntry({
|
|
650
|
+
collectionName: config.formOverrides.slug,
|
|
651
|
+
entryId: formId,
|
|
652
|
+
overrideAccess: true
|
|
653
|
+
});
|
|
654
|
+
const fromData = result?.data;
|
|
655
|
+
if (fromData) return fromData;
|
|
656
|
+
const fromDoc = result?.doc;
|
|
657
|
+
if (fromDoc && typeof fromDoc === "object") {
|
|
658
|
+
return fromDoc;
|
|
659
|
+
}
|
|
660
|
+
return null;
|
|
661
|
+
} catch (err) {
|
|
662
|
+
nextly.infra.logger.error?.("Form Builder: failed to fetch form", {
|
|
663
|
+
formId,
|
|
664
|
+
error: err instanceof Error ? err.message : String(err)
|
|
665
|
+
});
|
|
666
|
+
return null;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// src/types.ts
|
|
671
|
+
function isFormField(value) {
|
|
672
|
+
return typeof value === "object" && value !== null && "type" in value && "name" in value && "label" in value;
|
|
673
|
+
}
|
|
674
|
+
function isFormFieldType(field, type) {
|
|
675
|
+
return field.type === type;
|
|
676
|
+
}
|
|
677
|
+
function isTextFormField(field) {
|
|
678
|
+
return field.type === "text";
|
|
679
|
+
}
|
|
680
|
+
function isEmailFormField(field) {
|
|
681
|
+
return field.type === "email";
|
|
682
|
+
}
|
|
683
|
+
function isSelectFormField(field) {
|
|
684
|
+
return field.type === "select";
|
|
685
|
+
}
|
|
686
|
+
function isCheckboxFormField(field) {
|
|
687
|
+
return field.type === "checkbox";
|
|
688
|
+
}
|
|
689
|
+
function isFileFormField(field) {
|
|
690
|
+
return field.type === "file";
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// src/config/validate-form.ts
|
|
694
|
+
var RESERVED_FORM_SLUGS = [
|
|
695
|
+
// API routes
|
|
696
|
+
"api",
|
|
697
|
+
"submit",
|
|
698
|
+
"submissions",
|
|
699
|
+
"export",
|
|
700
|
+
// System slugs
|
|
701
|
+
"admin",
|
|
702
|
+
"new",
|
|
703
|
+
"create",
|
|
704
|
+
"edit",
|
|
705
|
+
"delete"
|
|
706
|
+
];
|
|
707
|
+
var RESERVED_SLUGS_SET = new Set(RESERVED_FORM_SLUGS);
|
|
708
|
+
var SLUG_PATTERN = /^[a-z][a-z0-9_-]*$/;
|
|
709
|
+
var FIELD_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]*$/;
|
|
710
|
+
var VALID_FIELD_TYPES = [
|
|
711
|
+
"text",
|
|
712
|
+
"email",
|
|
713
|
+
"number",
|
|
714
|
+
"phone",
|
|
715
|
+
"url",
|
|
716
|
+
"textarea",
|
|
717
|
+
"select",
|
|
718
|
+
"checkbox",
|
|
719
|
+
"radio",
|
|
720
|
+
"file",
|
|
721
|
+
"date",
|
|
722
|
+
"time",
|
|
723
|
+
"hidden"
|
|
724
|
+
];
|
|
725
|
+
var VALID_FIELD_TYPES_SET = new Set(VALID_FIELD_TYPES);
|
|
726
|
+
function isReservedSlug(slug) {
|
|
727
|
+
return RESERVED_SLUGS_SET.has(slug.toLowerCase());
|
|
728
|
+
}
|
|
729
|
+
function validateSlug(slug, errors) {
|
|
730
|
+
const path = "slug";
|
|
731
|
+
if (!slug) {
|
|
732
|
+
errors.push({
|
|
733
|
+
path,
|
|
734
|
+
message: "Form slug is required",
|
|
735
|
+
code: "SLUG_REQUIRED"
|
|
736
|
+
});
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
if (typeof slug !== "string") {
|
|
740
|
+
errors.push({
|
|
741
|
+
path,
|
|
742
|
+
message: "Form slug must be a string",
|
|
743
|
+
code: "SLUG_INVALID_TYPE"
|
|
744
|
+
});
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
if (slug.length < 1) {
|
|
748
|
+
errors.push({
|
|
749
|
+
path,
|
|
750
|
+
message: "Form slug must be at least 1 character",
|
|
751
|
+
code: "SLUG_TOO_SHORT"
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
if (slug.length > 50) {
|
|
755
|
+
errors.push({
|
|
756
|
+
path,
|
|
757
|
+
message: "Form slug must be at most 50 characters",
|
|
758
|
+
code: "SLUG_TOO_LONG"
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
if (!SLUG_PATTERN.test(slug)) {
|
|
762
|
+
errors.push({
|
|
763
|
+
path,
|
|
764
|
+
message: "Form slug must start with a lowercase letter and contain only lowercase letters, numbers, hyphens, and underscores",
|
|
765
|
+
code: "SLUG_INVALID_FORMAT"
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
if (isReservedSlug(slug)) {
|
|
769
|
+
errors.push({
|
|
770
|
+
path,
|
|
771
|
+
message: `Form slug '${slug}' is reserved and cannot be used`,
|
|
772
|
+
code: "SLUG_RESERVED"
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
function validateFieldName(name, path, errors, seenNames) {
|
|
777
|
+
if (!name) {
|
|
778
|
+
errors.push({
|
|
779
|
+
path: `${path}.name`,
|
|
780
|
+
message: "Field name is required",
|
|
781
|
+
code: "FIELD_NAME_REQUIRED"
|
|
782
|
+
});
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
if (typeof name !== "string") {
|
|
786
|
+
errors.push({
|
|
787
|
+
path: `${path}.name`,
|
|
788
|
+
message: "Field name must be a string",
|
|
789
|
+
code: "FIELD_NAME_REQUIRED"
|
|
790
|
+
});
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
if (!FIELD_NAME_PATTERN.test(name)) {
|
|
794
|
+
errors.push({
|
|
795
|
+
path: `${path}.name`,
|
|
796
|
+
message: `Field name '${name}' must start with a letter and contain only letters, numbers, and underscores`,
|
|
797
|
+
code: "FIELD_NAME_INVALID_FORMAT"
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
const nameLower = name.toLowerCase();
|
|
801
|
+
if (seenNames.has(nameLower)) {
|
|
802
|
+
errors.push({
|
|
803
|
+
path: `${path}.name`,
|
|
804
|
+
message: `Duplicate field name '${name}'`,
|
|
805
|
+
code: "FIELD_NAME_DUPLICATE"
|
|
806
|
+
});
|
|
807
|
+
} else {
|
|
808
|
+
seenNames.add(nameLower);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
function validateFieldLabel(label, path, errors) {
|
|
812
|
+
if (!label || typeof label === "string" && label.trim() === "") {
|
|
813
|
+
errors.push({
|
|
814
|
+
path: `${path}.label`,
|
|
815
|
+
message: "Field label is required",
|
|
816
|
+
code: "FIELD_LABEL_REQUIRED"
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
function validateOptions(field, path, errors) {
|
|
821
|
+
const fieldWithOptions = field;
|
|
822
|
+
const options = fieldWithOptions.options;
|
|
823
|
+
if (!options) {
|
|
824
|
+
errors.push({
|
|
825
|
+
path: `${path}.options`,
|
|
826
|
+
message: `${field.type} field must have an 'options' array`,
|
|
827
|
+
code: "OPTIONS_REQUIRED"
|
|
828
|
+
});
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
if (!Array.isArray(options)) {
|
|
832
|
+
errors.push({
|
|
833
|
+
path: `${path}.options`,
|
|
834
|
+
message: `${field.type} field 'options' must be an array`,
|
|
835
|
+
code: "OPTIONS_REQUIRED"
|
|
836
|
+
});
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
if (options.length === 0) {
|
|
840
|
+
errors.push({
|
|
841
|
+
path: `${path}.options`,
|
|
842
|
+
message: `${field.type} field must have at least one option`,
|
|
843
|
+
code: "OPTIONS_EMPTY"
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
function validateConditionalLogic(field, path, errors) {
|
|
848
|
+
const { conditionalLogic } = field;
|
|
849
|
+
if (!conditionalLogic || !conditionalLogic.enabled) {
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
if (!conditionalLogic.conditions || conditionalLogic.conditions.length === 0) {
|
|
853
|
+
errors.push({
|
|
854
|
+
path: `${path}.conditionalLogic.conditions`,
|
|
855
|
+
message: "Conditional logic must have at least one condition",
|
|
856
|
+
code: "CONDITIONS_REQUIRED"
|
|
857
|
+
});
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
conditionalLogic.conditions.forEach((condition, index) => {
|
|
861
|
+
const conditionPath = `${path}.conditionalLogic.conditions[${index}]`;
|
|
862
|
+
if (!condition.field) {
|
|
863
|
+
errors.push({
|
|
864
|
+
path: `${conditionPath}.field`,
|
|
865
|
+
message: "Condition must specify a field name",
|
|
866
|
+
code: "CONDITION_FIELD_REQUIRED"
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
if (!condition.comparison) {
|
|
870
|
+
errors.push({
|
|
871
|
+
path: `${conditionPath}.comparison`,
|
|
872
|
+
message: "Condition must specify a comparison operator",
|
|
873
|
+
code: "CONDITION_COMPARISON_REQUIRED"
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
function validateFieldByType(field, path, errors) {
|
|
879
|
+
switch (field.type) {
|
|
880
|
+
case "select":
|
|
881
|
+
case "radio":
|
|
882
|
+
validateOptions(field, path, errors);
|
|
883
|
+
break;
|
|
884
|
+
case "number": {
|
|
885
|
+
const numberField = field;
|
|
886
|
+
if (numberField.validation) {
|
|
887
|
+
const { min, max } = numberField.validation;
|
|
888
|
+
if (min !== void 0 && max !== void 0 && min > max) {
|
|
889
|
+
errors.push({
|
|
890
|
+
path: `${path}.validation`,
|
|
891
|
+
message: "Min value cannot be greater than max value",
|
|
892
|
+
code: "MIN_GREATER_THAN_MAX"
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
break;
|
|
897
|
+
}
|
|
898
|
+
case "text":
|
|
899
|
+
case "textarea": {
|
|
900
|
+
const textField = field;
|
|
901
|
+
if (textField.validation) {
|
|
902
|
+
const { minLength, maxLength } = textField.validation;
|
|
903
|
+
if (minLength !== void 0 && maxLength !== void 0 && minLength > maxLength) {
|
|
904
|
+
errors.push({
|
|
905
|
+
path: `${path}.validation`,
|
|
906
|
+
message: "Min length cannot be greater than max length",
|
|
907
|
+
code: "MIN_LENGTH_GREATER_THAN_MAX"
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
break;
|
|
912
|
+
}
|
|
913
|
+
case "file": {
|
|
914
|
+
const fileField = field;
|
|
915
|
+
if (fileField.maxFileSize !== void 0 && fileField.maxFileSize <= 0) {
|
|
916
|
+
errors.push({
|
|
917
|
+
path: `${path}.maxFileSize`,
|
|
918
|
+
message: "File maxFileSize must be a positive number",
|
|
919
|
+
code: "FILE_MAX_SIZE_INVALID"
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
break;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
function validateField(field, path, errors, seenNames) {
|
|
927
|
+
if (!field || typeof field !== "object") {
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
const f = field;
|
|
931
|
+
const fieldType = f.type;
|
|
932
|
+
if (!fieldType) {
|
|
933
|
+
errors.push({
|
|
934
|
+
path: `${path}.type`,
|
|
935
|
+
message: "Field type is required",
|
|
936
|
+
code: "FIELD_TYPE_REQUIRED"
|
|
937
|
+
});
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
if (typeof fieldType !== "string" || !VALID_FIELD_TYPES_SET.has(fieldType)) {
|
|
941
|
+
let fieldTypeRepr;
|
|
942
|
+
if (typeof fieldType === "object" && fieldType !== null) {
|
|
943
|
+
fieldTypeRepr = JSON.stringify(fieldType);
|
|
944
|
+
} else {
|
|
945
|
+
fieldTypeRepr = String(fieldType);
|
|
946
|
+
}
|
|
947
|
+
errors.push({
|
|
948
|
+
path: `${path}.type`,
|
|
949
|
+
message: `Invalid field type '${fieldTypeRepr}'. Valid types: ${VALID_FIELD_TYPES.join(", ")}`,
|
|
950
|
+
code: "FIELD_TYPE_INVALID"
|
|
951
|
+
});
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
validateFieldName(f.name, path, errors, seenNames);
|
|
955
|
+
if (fieldType !== "hidden") {
|
|
956
|
+
validateFieldLabel(f.label, path, errors);
|
|
957
|
+
}
|
|
958
|
+
validateFieldByType(field, path, errors);
|
|
959
|
+
if (f.conditionalLogic) {
|
|
960
|
+
validateConditionalLogic(field, path, errors);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
function validateFieldsArray(fields, basePath, errors) {
|
|
964
|
+
const seenNames = /* @__PURE__ */ new Set();
|
|
965
|
+
fields.forEach((field, index) => {
|
|
966
|
+
const fieldPath = `${basePath}[${index}]`;
|
|
967
|
+
validateField(field, fieldPath, errors, seenNames);
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
function validateFields(fields, errors) {
|
|
971
|
+
const path = "fields";
|
|
972
|
+
if (!fields) {
|
|
973
|
+
errors.push({
|
|
974
|
+
path,
|
|
975
|
+
message: "Form must have at least one field",
|
|
976
|
+
code: "FIELDS_REQUIRED"
|
|
977
|
+
});
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
if (!Array.isArray(fields)) {
|
|
981
|
+
errors.push({
|
|
982
|
+
path,
|
|
983
|
+
message: "Form fields must be an array",
|
|
984
|
+
code: "FIELDS_INVALID_TYPE"
|
|
985
|
+
});
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
if (fields.length === 0) {
|
|
989
|
+
errors.push({
|
|
990
|
+
path,
|
|
991
|
+
message: "Form must have at least one field",
|
|
992
|
+
code: "FIELDS_EMPTY"
|
|
993
|
+
});
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
validateFieldsArray(fields, path, errors);
|
|
997
|
+
}
|
|
998
|
+
function validateSettings(settings, errors) {
|
|
999
|
+
if (!settings) {
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
if (typeof settings !== "object" || Array.isArray(settings)) {
|
|
1003
|
+
errors.push({
|
|
1004
|
+
path: "settings",
|
|
1005
|
+
message: "Form settings must be an object",
|
|
1006
|
+
code: "SETTINGS_INVALID_TYPE"
|
|
1007
|
+
});
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
const s = settings;
|
|
1011
|
+
if (s.confirmationType === "redirect") {
|
|
1012
|
+
if (!s.redirectUrl && !s.redirectRelation) {
|
|
1013
|
+
errors.push({
|
|
1014
|
+
path: "settings.redirectUrl",
|
|
1015
|
+
message: "Redirect URL or redirect relation is required when confirmation type is 'redirect'",
|
|
1016
|
+
code: "REDIRECT_URL_REQUIRED"
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
function validateAccess(access, errors) {
|
|
1022
|
+
if (!access) {
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
if (typeof access !== "object" || Array.isArray(access)) {
|
|
1026
|
+
errors.push({
|
|
1027
|
+
path: "access",
|
|
1028
|
+
message: "Access control must be an object",
|
|
1029
|
+
code: "ACCESS_INVALID_TYPE"
|
|
1030
|
+
});
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
const accessObj = access;
|
|
1034
|
+
const validAccessKeys = ["submit", "read"];
|
|
1035
|
+
for (const key of validAccessKeys) {
|
|
1036
|
+
const fn = accessObj[key];
|
|
1037
|
+
if (fn !== void 0 && typeof fn !== "function") {
|
|
1038
|
+
errors.push({
|
|
1039
|
+
path: `access.${key}`,
|
|
1040
|
+
message: `Access control '${key}' must be a function`,
|
|
1041
|
+
code: "ACCESS_FUNCTION_INVALID"
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
function validateFormConfig(config) {
|
|
1047
|
+
const errors = [];
|
|
1048
|
+
validateSlug(config.slug, errors);
|
|
1049
|
+
validateFields(config.fields, errors);
|
|
1050
|
+
validateSettings(config.settings, errors);
|
|
1051
|
+
validateAccess(config.access, errors);
|
|
1052
|
+
return {
|
|
1053
|
+
valid: errors.length === 0,
|
|
1054
|
+
errors
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
function assertValidFormConfig(config) {
|
|
1058
|
+
const result = validateFormConfig(config);
|
|
1059
|
+
if (!result.valid) {
|
|
1060
|
+
const errorMessages = result.errors.map((err) => ` - [${err.code}] ${err.path}: ${err.message}`).join("\n");
|
|
1061
|
+
throw new Error(
|
|
1062
|
+
`Invalid form config for '${config.slug || "unknown"}':
|
|
1063
|
+
${errorMessages}`
|
|
1064
|
+
);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// src/config/defaults.ts
|
|
1069
|
+
var DEFAULT_FORM_SETTINGS = {
|
|
1070
|
+
submitButtonText: "Submit",
|
|
1071
|
+
confirmationType: "message",
|
|
1072
|
+
successMessage: "Thank you for your submission!",
|
|
1073
|
+
allowMultipleSubmissions: true
|
|
1074
|
+
};
|
|
1075
|
+
var DEFAULT_NOTIFICATION_SETTINGS = {
|
|
1076
|
+
enabled: true,
|
|
1077
|
+
replyTo: true
|
|
1078
|
+
};
|
|
1079
|
+
function toTitleCase(str) {
|
|
1080
|
+
return str.replace(/[-_]/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
|
|
1081
|
+
}
|
|
1082
|
+
function pluralize(singular) {
|
|
1083
|
+
if (singular.endsWith("y") && !/[aeiou]y$/i.test(singular)) {
|
|
1084
|
+
return singular.slice(0, -1) + "ies";
|
|
1085
|
+
}
|
|
1086
|
+
if (singular.endsWith("s") || singular.endsWith("x") || singular.endsWith("z") || singular.endsWith("ch") || singular.endsWith("sh")) {
|
|
1087
|
+
return singular + "es";
|
|
1088
|
+
}
|
|
1089
|
+
return singular + "s";
|
|
1090
|
+
}
|
|
1091
|
+
function applyFormDefaults(config) {
|
|
1092
|
+
const singularLabel = config.labels?.singular || toTitleCase(config.slug);
|
|
1093
|
+
return {
|
|
1094
|
+
...config,
|
|
1095
|
+
// Generate labels from slug if not provided
|
|
1096
|
+
labels: {
|
|
1097
|
+
singular: singularLabel,
|
|
1098
|
+
plural: config.labels?.plural || pluralize(singularLabel)
|
|
1099
|
+
},
|
|
1100
|
+
// Apply settings defaults
|
|
1101
|
+
settings: {
|
|
1102
|
+
submitButtonText: config.settings?.submitButtonText || DEFAULT_FORM_SETTINGS.submitButtonText,
|
|
1103
|
+
confirmationType: config.settings?.confirmationType || DEFAULT_FORM_SETTINGS.confirmationType,
|
|
1104
|
+
successMessage: config.settings?.successMessage || DEFAULT_FORM_SETTINGS.successMessage,
|
|
1105
|
+
allowMultipleSubmissions: config.settings?.allowMultipleSubmissions ?? DEFAULT_FORM_SETTINGS.allowMultipleSubmissions,
|
|
1106
|
+
// Preserve other settings that don't have defaults
|
|
1107
|
+
redirectUrl: config.settings?.redirectUrl,
|
|
1108
|
+
redirectRelation: config.settings?.redirectRelation,
|
|
1109
|
+
captcha: config.settings?.captcha
|
|
1110
|
+
},
|
|
1111
|
+
// Apply notification defaults if notifications are configured
|
|
1112
|
+
notifications: config.notifications ? {
|
|
1113
|
+
enabled: config.notifications.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.enabled,
|
|
1114
|
+
recipients: config.notifications.recipients || [],
|
|
1115
|
+
subject: config.notifications.subject || `New submission for ${singularLabel}`,
|
|
1116
|
+
replyTo: config.notifications.replyTo ?? DEFAULT_NOTIFICATION_SETTINGS.replyTo,
|
|
1117
|
+
template: config.notifications.template
|
|
1118
|
+
} : void 0,
|
|
1119
|
+
// Apply access defaults
|
|
1120
|
+
access: {
|
|
1121
|
+
// Default: anyone can submit the form
|
|
1122
|
+
submit: config.access?.submit || (() => true),
|
|
1123
|
+
// Default: only authenticated users can view submissions
|
|
1124
|
+
read: config.access?.read || (({ req }) => !!req.user)
|
|
1125
|
+
}
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
function applyFieldDefaults(field) {
|
|
1129
|
+
const label = field.label || (field.name ? toTitleCase(field.name.replace(/([A-Z])/g, " $1").trim()) : "");
|
|
1130
|
+
return {
|
|
1131
|
+
...field,
|
|
1132
|
+
label,
|
|
1133
|
+
required: field.required ?? false
|
|
1134
|
+
};
|
|
1135
|
+
}
|
|
1136
|
+
function createFormConfig(slug, fields, options) {
|
|
1137
|
+
return applyFormDefaults({
|
|
1138
|
+
slug,
|
|
1139
|
+
fields,
|
|
1140
|
+
...options
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// src/config/field-helpers.ts
|
|
1145
|
+
var option = (label, value) => ({
|
|
1146
|
+
label,
|
|
1147
|
+
value: value ?? label.toLowerCase().replace(/\s+/g, "_")
|
|
1148
|
+
});
|
|
1149
|
+
var text3 = (config) => ({
|
|
1150
|
+
...config,
|
|
1151
|
+
type: "text"
|
|
1152
|
+
});
|
|
1153
|
+
var email = (config) => ({
|
|
1154
|
+
...config,
|
|
1155
|
+
type: "email"
|
|
1156
|
+
});
|
|
1157
|
+
var phone = (config) => ({
|
|
1158
|
+
...config,
|
|
1159
|
+
type: "phone"
|
|
1160
|
+
});
|
|
1161
|
+
var url = (config) => ({
|
|
1162
|
+
...config,
|
|
1163
|
+
type: "url"
|
|
1164
|
+
});
|
|
1165
|
+
var textarea3 = (config) => ({
|
|
1166
|
+
...config,
|
|
1167
|
+
type: "textarea"
|
|
1168
|
+
});
|
|
1169
|
+
var number = (config) => ({
|
|
1170
|
+
...config,
|
|
1171
|
+
type: "number"
|
|
1172
|
+
});
|
|
1173
|
+
var select3 = (config) => ({
|
|
1174
|
+
...config,
|
|
1175
|
+
type: "select"
|
|
1176
|
+
});
|
|
1177
|
+
var checkbox2 = (config) => ({
|
|
1178
|
+
...config,
|
|
1179
|
+
type: "checkbox"
|
|
1180
|
+
});
|
|
1181
|
+
var radio = (config) => ({
|
|
1182
|
+
...config,
|
|
1183
|
+
type: "radio"
|
|
1184
|
+
});
|
|
1185
|
+
var file = (config) => ({
|
|
1186
|
+
...config,
|
|
1187
|
+
type: "file"
|
|
1188
|
+
});
|
|
1189
|
+
var date2 = (config) => ({
|
|
1190
|
+
...config,
|
|
1191
|
+
type: "date"
|
|
1192
|
+
});
|
|
1193
|
+
var time = (config) => ({
|
|
1194
|
+
...config,
|
|
1195
|
+
type: "time"
|
|
1196
|
+
});
|
|
1197
|
+
var hidden = (config) => ({
|
|
1198
|
+
...config,
|
|
1199
|
+
type: "hidden"
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
// src/utils/evaluate-conditions.ts
|
|
1203
|
+
function isEmptyValue(value) {
|
|
1204
|
+
if (value === void 0 || value === null) {
|
|
1205
|
+
return true;
|
|
1206
|
+
}
|
|
1207
|
+
if (typeof value === "string" && value.trim() === "") {
|
|
1208
|
+
return true;
|
|
1209
|
+
}
|
|
1210
|
+
if (Array.isArray(value) && value.length === 0) {
|
|
1211
|
+
return true;
|
|
1212
|
+
}
|
|
1213
|
+
return false;
|
|
1214
|
+
}
|
|
1215
|
+
function toNumber(value) {
|
|
1216
|
+
if (typeof value === "number") {
|
|
1217
|
+
return value;
|
|
1218
|
+
}
|
|
1219
|
+
if (typeof value === "string") {
|
|
1220
|
+
const trimmed = value.trim();
|
|
1221
|
+
if (trimmed === "") {
|
|
1222
|
+
return NaN;
|
|
1223
|
+
}
|
|
1224
|
+
return Number(trimmed);
|
|
1225
|
+
}
|
|
1226
|
+
return NaN;
|
|
1227
|
+
}
|
|
1228
|
+
function toString(value) {
|
|
1229
|
+
if (value === void 0 || value === null) {
|
|
1230
|
+
return "";
|
|
1231
|
+
}
|
|
1232
|
+
if (typeof value === "string") {
|
|
1233
|
+
return value;
|
|
1234
|
+
}
|
|
1235
|
+
if (Array.isArray(value)) {
|
|
1236
|
+
return value.join(", ");
|
|
1237
|
+
}
|
|
1238
|
+
if (typeof value === "object") {
|
|
1239
|
+
return JSON.stringify(value);
|
|
1240
|
+
}
|
|
1241
|
+
return String(value);
|
|
1242
|
+
}
|
|
1243
|
+
function compareValues(operator, fieldValue, conditionValue) {
|
|
1244
|
+
switch (operator) {
|
|
1245
|
+
case "equals": {
|
|
1246
|
+
if (Array.isArray(fieldValue)) {
|
|
1247
|
+
if (Array.isArray(conditionValue)) {
|
|
1248
|
+
if (fieldValue.length !== conditionValue.length) {
|
|
1249
|
+
return false;
|
|
1250
|
+
}
|
|
1251
|
+
return fieldValue.every((v, i) => v === conditionValue[i]);
|
|
1252
|
+
}
|
|
1253
|
+
return fieldValue.includes(conditionValue);
|
|
1254
|
+
}
|
|
1255
|
+
return toString(fieldValue) === toString(conditionValue);
|
|
1256
|
+
}
|
|
1257
|
+
case "notEquals": {
|
|
1258
|
+
if (Array.isArray(fieldValue)) {
|
|
1259
|
+
if (Array.isArray(conditionValue)) {
|
|
1260
|
+
if (fieldValue.length !== conditionValue.length) {
|
|
1261
|
+
return true;
|
|
1262
|
+
}
|
|
1263
|
+
return !fieldValue.every((v, i) => v === conditionValue[i]);
|
|
1264
|
+
}
|
|
1265
|
+
return !fieldValue.includes(conditionValue);
|
|
1266
|
+
}
|
|
1267
|
+
return toString(fieldValue) !== toString(conditionValue);
|
|
1268
|
+
}
|
|
1269
|
+
case "contains": {
|
|
1270
|
+
const fieldStr = toString(fieldValue).toLowerCase();
|
|
1271
|
+
const conditionStr = toString(conditionValue).toLowerCase();
|
|
1272
|
+
if (Array.isArray(fieldValue)) {
|
|
1273
|
+
return fieldValue.some(
|
|
1274
|
+
(v) => toString(v).toLowerCase().includes(conditionStr)
|
|
1275
|
+
);
|
|
1276
|
+
}
|
|
1277
|
+
return fieldStr.includes(conditionStr);
|
|
1278
|
+
}
|
|
1279
|
+
case "isEmpty": {
|
|
1280
|
+
return isEmptyValue(fieldValue);
|
|
1281
|
+
}
|
|
1282
|
+
case "isNotEmpty": {
|
|
1283
|
+
return !isEmptyValue(fieldValue);
|
|
1284
|
+
}
|
|
1285
|
+
case "greaterThan": {
|
|
1286
|
+
const fieldNum = toNumber(fieldValue);
|
|
1287
|
+
const conditionNum = toNumber(conditionValue);
|
|
1288
|
+
if (!Number.isNaN(fieldNum) && !Number.isNaN(conditionNum)) {
|
|
1289
|
+
return fieldNum > conditionNum;
|
|
1290
|
+
}
|
|
1291
|
+
return toString(fieldValue) > toString(conditionValue);
|
|
1292
|
+
}
|
|
1293
|
+
case "lessThan": {
|
|
1294
|
+
const fieldNum = toNumber(fieldValue);
|
|
1295
|
+
const conditionNum = toNumber(conditionValue);
|
|
1296
|
+
if (!Number.isNaN(fieldNum) && !Number.isNaN(conditionNum)) {
|
|
1297
|
+
return fieldNum < conditionNum;
|
|
1298
|
+
}
|
|
1299
|
+
return toString(fieldValue) < toString(conditionValue);
|
|
1300
|
+
}
|
|
1301
|
+
default: {
|
|
1302
|
+
console.warn(
|
|
1303
|
+
`[evaluateConditions] Unknown comparison operator: ${String(operator)}`
|
|
1304
|
+
);
|
|
1305
|
+
return false;
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
function evaluateSingleCondition(condition, formData) {
|
|
1310
|
+
const { field, comparison, value: conditionValue } = condition;
|
|
1311
|
+
const fieldValue = getNestedValue(formData, field);
|
|
1312
|
+
return compareValues(comparison, fieldValue, conditionValue);
|
|
1313
|
+
}
|
|
1314
|
+
function getNestedValue(obj, path) {
|
|
1315
|
+
if (!path) {
|
|
1316
|
+
return void 0;
|
|
1317
|
+
}
|
|
1318
|
+
if (!path.includes(".")) {
|
|
1319
|
+
return obj[path];
|
|
1320
|
+
}
|
|
1321
|
+
const parts = path.split(".");
|
|
1322
|
+
let current = obj;
|
|
1323
|
+
for (const part of parts) {
|
|
1324
|
+
if (current === void 0 || current === null) {
|
|
1325
|
+
return void 0;
|
|
1326
|
+
}
|
|
1327
|
+
if (typeof current !== "object") {
|
|
1328
|
+
return void 0;
|
|
1329
|
+
}
|
|
1330
|
+
current = current[part];
|
|
1331
|
+
}
|
|
1332
|
+
return current;
|
|
1333
|
+
}
|
|
1334
|
+
function evaluateConditions(logic, formData) {
|
|
1335
|
+
if (!logic || !logic.enabled) {
|
|
1336
|
+
return true;
|
|
1337
|
+
}
|
|
1338
|
+
const { action, operator, conditions } = logic;
|
|
1339
|
+
if (!conditions || conditions.length === 0) {
|
|
1340
|
+
return true;
|
|
1341
|
+
}
|
|
1342
|
+
const results = conditions.map(
|
|
1343
|
+
(condition) => evaluateSingleCondition(condition, formData)
|
|
1344
|
+
);
|
|
1345
|
+
let conditionsMet;
|
|
1346
|
+
if (operator === "OR") {
|
|
1347
|
+
conditionsMet = results.some((result) => result);
|
|
1348
|
+
} else {
|
|
1349
|
+
conditionsMet = results.every((result) => result);
|
|
1350
|
+
}
|
|
1351
|
+
if (action === "hide") {
|
|
1352
|
+
return !conditionsMet;
|
|
1353
|
+
}
|
|
1354
|
+
return conditionsMet;
|
|
1355
|
+
}
|
|
1356
|
+
function isValidComparisonOperator(operator) {
|
|
1357
|
+
const validOperators = [
|
|
1358
|
+
"equals",
|
|
1359
|
+
"notEquals",
|
|
1360
|
+
"contains",
|
|
1361
|
+
"isEmpty",
|
|
1362
|
+
"isNotEmpty",
|
|
1363
|
+
"greaterThan",
|
|
1364
|
+
"lessThan"
|
|
1365
|
+
];
|
|
1366
|
+
return validOperators.includes(operator);
|
|
1367
|
+
}
|
|
1368
|
+
function getSupportedComparisonOperators() {
|
|
1369
|
+
return [
|
|
1370
|
+
"equals",
|
|
1371
|
+
"notEquals",
|
|
1372
|
+
"contains",
|
|
1373
|
+
"isEmpty",
|
|
1374
|
+
"isNotEmpty",
|
|
1375
|
+
"greaterThan",
|
|
1376
|
+
"lessThan"
|
|
1377
|
+
];
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// src/utils/generate-schema.ts
|
|
1381
|
+
import { z } from "zod";
|
|
1382
|
+
function generateZodSchema(fields) {
|
|
1383
|
+
const schemaShape = {};
|
|
1384
|
+
for (const field of fields) {
|
|
1385
|
+
if (field.type === "hidden" && !field.required) {
|
|
1386
|
+
schemaShape[field.name] = z.string().optional();
|
|
1387
|
+
continue;
|
|
1388
|
+
}
|
|
1389
|
+
const fieldSchema = generateFieldSchema(field);
|
|
1390
|
+
if (fieldSchema) {
|
|
1391
|
+
schemaShape[field.name] = fieldSchema;
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
return z.object(schemaShape);
|
|
1395
|
+
}
|
|
1396
|
+
function generateFieldSchema(field) {
|
|
1397
|
+
switch (field.type) {
|
|
1398
|
+
case "text":
|
|
1399
|
+
return generateTextSchema(field);
|
|
1400
|
+
case "email":
|
|
1401
|
+
return generateEmailSchema(field);
|
|
1402
|
+
case "number":
|
|
1403
|
+
return generateNumberSchema(field);
|
|
1404
|
+
case "phone":
|
|
1405
|
+
return generatePhoneSchema(field);
|
|
1406
|
+
case "url":
|
|
1407
|
+
return generateUrlSchema(field);
|
|
1408
|
+
case "textarea":
|
|
1409
|
+
return generateTextareaSchema(field);
|
|
1410
|
+
case "select":
|
|
1411
|
+
return generateSelectSchema(field);
|
|
1412
|
+
case "checkbox":
|
|
1413
|
+
return generateCheckboxSchema(field);
|
|
1414
|
+
case "radio":
|
|
1415
|
+
return generateRadioSchema(field);
|
|
1416
|
+
case "file":
|
|
1417
|
+
return generateFileSchema(field);
|
|
1418
|
+
case "date":
|
|
1419
|
+
return generateDateSchema(field);
|
|
1420
|
+
case "time":
|
|
1421
|
+
return generateTimeSchema(field);
|
|
1422
|
+
case "hidden":
|
|
1423
|
+
return generateHiddenSchema(field);
|
|
1424
|
+
default:
|
|
1425
|
+
return z.unknown();
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
function generateTextSchema(field) {
|
|
1429
|
+
let schema = z.string();
|
|
1430
|
+
if (field.validation?.minLength !== void 0) {
|
|
1431
|
+
schema = schema.min(
|
|
1432
|
+
field.validation.minLength,
|
|
1433
|
+
field.validation.errorMessage || `Minimum ${field.validation.minLength} characters required`
|
|
1434
|
+
);
|
|
1435
|
+
}
|
|
1436
|
+
if (field.validation?.maxLength !== void 0) {
|
|
1437
|
+
schema = schema.max(
|
|
1438
|
+
field.validation.maxLength,
|
|
1439
|
+
field.validation.errorMessage || `Maximum ${field.validation.maxLength} characters allowed`
|
|
1440
|
+
);
|
|
1441
|
+
}
|
|
1442
|
+
if (field.validation?.pattern) {
|
|
1443
|
+
schema = schema.regex(
|
|
1444
|
+
new RegExp(field.validation.pattern),
|
|
1445
|
+
field.validation.errorMessage || "Invalid format"
|
|
1446
|
+
);
|
|
1447
|
+
}
|
|
1448
|
+
return applyRequired(schema, field.required, field.validation?.errorMessage);
|
|
1449
|
+
}
|
|
1450
|
+
function generateEmailSchema(field) {
|
|
1451
|
+
let schema = z.string().email(field.validation?.errorMessage || "Invalid email address");
|
|
1452
|
+
if (field.validation?.pattern) {
|
|
1453
|
+
schema = schema.regex(
|
|
1454
|
+
new RegExp(field.validation.pattern),
|
|
1455
|
+
field.validation.errorMessage || "Invalid email format"
|
|
1456
|
+
);
|
|
1457
|
+
}
|
|
1458
|
+
return applyRequired(schema, field.required, field.validation?.errorMessage);
|
|
1459
|
+
}
|
|
1460
|
+
function generateNumberSchema(field) {
|
|
1461
|
+
let schema = z.number({
|
|
1462
|
+
required_error: field.validation?.errorMessage || "This field is required",
|
|
1463
|
+
invalid_type_error: "Must be a number"
|
|
1464
|
+
});
|
|
1465
|
+
if (field.validation?.min !== void 0) {
|
|
1466
|
+
schema = schema.min(
|
|
1467
|
+
field.validation.min,
|
|
1468
|
+
field.validation.errorMessage || `Minimum value is ${field.validation.min}`
|
|
1469
|
+
);
|
|
1470
|
+
}
|
|
1471
|
+
if (field.validation?.max !== void 0) {
|
|
1472
|
+
schema = schema.max(
|
|
1473
|
+
field.validation.max,
|
|
1474
|
+
field.validation.errorMessage || `Maximum value is ${field.validation.max}`
|
|
1475
|
+
);
|
|
1476
|
+
}
|
|
1477
|
+
if (field.required) {
|
|
1478
|
+
return schema;
|
|
1479
|
+
}
|
|
1480
|
+
return schema.optional().nullable();
|
|
1481
|
+
}
|
|
1482
|
+
function generatePhoneSchema(field) {
|
|
1483
|
+
let schema = z.string();
|
|
1484
|
+
if (field.validation?.pattern) {
|
|
1485
|
+
schema = schema.regex(
|
|
1486
|
+
new RegExp(field.validation.pattern),
|
|
1487
|
+
field.validation.errorMessage || "Invalid phone number format"
|
|
1488
|
+
);
|
|
1489
|
+
} else {
|
|
1490
|
+
schema = schema.regex(
|
|
1491
|
+
/^[\d\s\-+()]+$/,
|
|
1492
|
+
field.validation?.errorMessage || "Invalid phone number"
|
|
1493
|
+
);
|
|
1494
|
+
}
|
|
1495
|
+
return applyRequired(schema, field.required, field.validation?.errorMessage);
|
|
1496
|
+
}
|
|
1497
|
+
function generateUrlSchema(field) {
|
|
1498
|
+
let schema = z.string().url(field.validation?.errorMessage || "Invalid URL");
|
|
1499
|
+
if (field.validation?.pattern) {
|
|
1500
|
+
schema = schema.regex(
|
|
1501
|
+
new RegExp(field.validation.pattern),
|
|
1502
|
+
field.validation.errorMessage || "Invalid URL format"
|
|
1503
|
+
);
|
|
1504
|
+
}
|
|
1505
|
+
return applyRequired(schema, field.required, field.validation?.errorMessage);
|
|
1506
|
+
}
|
|
1507
|
+
function generateTextareaSchema(field) {
|
|
1508
|
+
let schema = z.string();
|
|
1509
|
+
if (field.validation?.minLength !== void 0) {
|
|
1510
|
+
schema = schema.min(
|
|
1511
|
+
field.validation.minLength,
|
|
1512
|
+
field.validation.errorMessage || `Minimum ${field.validation.minLength} characters required`
|
|
1513
|
+
);
|
|
1514
|
+
}
|
|
1515
|
+
if (field.validation?.maxLength !== void 0) {
|
|
1516
|
+
schema = schema.max(
|
|
1517
|
+
field.validation.maxLength,
|
|
1518
|
+
field.validation.errorMessage || `Maximum ${field.validation.maxLength} characters allowed`
|
|
1519
|
+
);
|
|
1520
|
+
}
|
|
1521
|
+
return applyRequired(schema, field.required, field.validation?.errorMessage);
|
|
1522
|
+
}
|
|
1523
|
+
function generateSelectSchema(field) {
|
|
1524
|
+
const validValues = field.options.map((opt) => opt.value);
|
|
1525
|
+
if (field.allowMultiple) {
|
|
1526
|
+
const schema2 = z.array(z.enum(validValues));
|
|
1527
|
+
if (field.required) {
|
|
1528
|
+
return schema2.min(
|
|
1529
|
+
1,
|
|
1530
|
+
field.validation?.errorMessage || "Please select at least one option"
|
|
1531
|
+
);
|
|
1532
|
+
}
|
|
1533
|
+
return schema2.optional();
|
|
1534
|
+
}
|
|
1535
|
+
const schema = z.enum(validValues, {
|
|
1536
|
+
errorMap: () => ({
|
|
1537
|
+
message: field.validation?.errorMessage || "Please select a valid option"
|
|
1538
|
+
})
|
|
1539
|
+
});
|
|
1540
|
+
if (field.required) {
|
|
1541
|
+
return schema;
|
|
1542
|
+
}
|
|
1543
|
+
return schema.optional();
|
|
1544
|
+
}
|
|
1545
|
+
function generateCheckboxSchema(field) {
|
|
1546
|
+
const schema = z.boolean();
|
|
1547
|
+
if (field.required) {
|
|
1548
|
+
return schema.refine((val) => val === true, {
|
|
1549
|
+
message: field.validation?.errorMessage || "This field is required"
|
|
1550
|
+
});
|
|
1551
|
+
}
|
|
1552
|
+
return schema.optional();
|
|
1553
|
+
}
|
|
1554
|
+
function generateRadioSchema(field) {
|
|
1555
|
+
const validValues = field.options.map((opt) => opt.value);
|
|
1556
|
+
const schema = z.enum(validValues, {
|
|
1557
|
+
errorMap: () => ({
|
|
1558
|
+
message: field.validation?.errorMessage || "Please select an option"
|
|
1559
|
+
})
|
|
1560
|
+
});
|
|
1561
|
+
if (field.required) {
|
|
1562
|
+
return schema;
|
|
1563
|
+
}
|
|
1564
|
+
return schema.optional();
|
|
1565
|
+
}
|
|
1566
|
+
function generateFileSchema(field) {
|
|
1567
|
+
if (field.multiple) {
|
|
1568
|
+
const schema2 = z.array(z.string());
|
|
1569
|
+
if (field.required) {
|
|
1570
|
+
return schema2.min(
|
|
1571
|
+
1,
|
|
1572
|
+
field.validation?.errorMessage || "Please upload at least one file"
|
|
1573
|
+
);
|
|
1574
|
+
}
|
|
1575
|
+
return schema2.optional();
|
|
1576
|
+
}
|
|
1577
|
+
const schema = z.string();
|
|
1578
|
+
return applyRequired(schema, field.required, field.validation?.errorMessage);
|
|
1579
|
+
}
|
|
1580
|
+
function generateDateSchema(field) {
|
|
1581
|
+
let schema = z.string();
|
|
1582
|
+
schema = schema.refine(
|
|
1583
|
+
(val) => {
|
|
1584
|
+
if (!val) return true;
|
|
1585
|
+
const date3 = new Date(val);
|
|
1586
|
+
return !isNaN(date3.getTime());
|
|
1587
|
+
},
|
|
1588
|
+
{ message: field.validation?.errorMessage || "Invalid date" }
|
|
1589
|
+
);
|
|
1590
|
+
if (field.min || field.max) {
|
|
1591
|
+
schema = schema.refine(
|
|
1592
|
+
(val) => {
|
|
1593
|
+
if (!val) return true;
|
|
1594
|
+
const date3 = new Date(val);
|
|
1595
|
+
if (field.min && date3 < new Date(field.min)) return false;
|
|
1596
|
+
if (field.max && date3 > new Date(field.max)) return false;
|
|
1597
|
+
return true;
|
|
1598
|
+
},
|
|
1599
|
+
{
|
|
1600
|
+
message: field.validation?.errorMessage || `Date must be between ${field.min || "any"} and ${field.max || "any"}`
|
|
1601
|
+
}
|
|
1602
|
+
);
|
|
1603
|
+
}
|
|
1604
|
+
if (field.required) {
|
|
1605
|
+
return schema.refine(
|
|
1606
|
+
(val) => val !== void 0 && val !== null && val !== "",
|
|
1607
|
+
{ message: field.validation?.errorMessage || "This field is required" }
|
|
1608
|
+
);
|
|
1609
|
+
}
|
|
1610
|
+
return schema.optional().or(z.literal(""));
|
|
1611
|
+
}
|
|
1612
|
+
function generateTimeSchema(field) {
|
|
1613
|
+
const schema = z.string().regex(
|
|
1614
|
+
/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/,
|
|
1615
|
+
field.validation?.errorMessage || "Invalid time format (HH:mm)"
|
|
1616
|
+
);
|
|
1617
|
+
return applyRequired(schema, field.required, field.validation?.errorMessage);
|
|
1618
|
+
}
|
|
1619
|
+
function generateHiddenSchema(field) {
|
|
1620
|
+
const schema = z.string();
|
|
1621
|
+
return applyRequired(schema, field.required, field.validation?.errorMessage);
|
|
1622
|
+
}
|
|
1623
|
+
function applyRequired(schema, required, errorMessage) {
|
|
1624
|
+
if (required) {
|
|
1625
|
+
return schema.min(1, errorMessage || "This field is required");
|
|
1626
|
+
}
|
|
1627
|
+
return schema.optional().or(z.literal(""));
|
|
1628
|
+
}
|
|
1629
|
+
function transformFormData(data, fields) {
|
|
1630
|
+
const transformed = {};
|
|
1631
|
+
for (const field of fields) {
|
|
1632
|
+
const value = data[field.name];
|
|
1633
|
+
if (value === void 0) {
|
|
1634
|
+
continue;
|
|
1635
|
+
}
|
|
1636
|
+
transformed[field.name] = transformFieldValue(value, field);
|
|
1637
|
+
}
|
|
1638
|
+
return transformed;
|
|
1639
|
+
}
|
|
1640
|
+
function transformFieldValue(value, field) {
|
|
1641
|
+
if (value === null || value === void 0) {
|
|
1642
|
+
return value;
|
|
1643
|
+
}
|
|
1644
|
+
switch (field.type) {
|
|
1645
|
+
case "number":
|
|
1646
|
+
if (typeof value === "string") {
|
|
1647
|
+
const num = parseFloat(value);
|
|
1648
|
+
return isNaN(num) ? value : num;
|
|
1649
|
+
}
|
|
1650
|
+
return value;
|
|
1651
|
+
case "checkbox":
|
|
1652
|
+
if (typeof value === "string") {
|
|
1653
|
+
return value === "true" || value === "1" || value === "on";
|
|
1654
|
+
}
|
|
1655
|
+
return Boolean(value);
|
|
1656
|
+
case "text":
|
|
1657
|
+
case "email":
|
|
1658
|
+
case "phone":
|
|
1659
|
+
case "url":
|
|
1660
|
+
case "textarea":
|
|
1661
|
+
case "hidden":
|
|
1662
|
+
if (typeof value === "string") {
|
|
1663
|
+
return value.trim();
|
|
1664
|
+
}
|
|
1665
|
+
return value;
|
|
1666
|
+
case "select":
|
|
1667
|
+
if (field.allowMultiple) {
|
|
1668
|
+
if (!Array.isArray(value)) {
|
|
1669
|
+
return value ? [value] : [];
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
return value;
|
|
1673
|
+
default:
|
|
1674
|
+
return value;
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
function validateFormData(data, fields) {
|
|
1678
|
+
const transformed = transformFormData(data, fields);
|
|
1679
|
+
const schema = generateZodSchema(fields);
|
|
1680
|
+
return schema.safeParse(transformed);
|
|
1681
|
+
}
|
|
1682
|
+
function getValidationErrors(result) {
|
|
1683
|
+
if (result.success) {
|
|
1684
|
+
return {};
|
|
1685
|
+
}
|
|
1686
|
+
const errors = {};
|
|
1687
|
+
for (const error of result.error.errors) {
|
|
1688
|
+
const path = error.path.join(".");
|
|
1689
|
+
if (!errors[path]) {
|
|
1690
|
+
errors[path] = error.message;
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
return errors;
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
// src/utils/export-formats.ts
|
|
1697
|
+
var UTF8_BOM = "\uFEFF";
|
|
1698
|
+
function safeString(value) {
|
|
1699
|
+
if (typeof value === "object" && value !== null) {
|
|
1700
|
+
return JSON.stringify(value);
|
|
1701
|
+
}
|
|
1702
|
+
return String(value);
|
|
1703
|
+
}
|
|
1704
|
+
function formatExportValue(value, field) {
|
|
1705
|
+
if (value === null || value === void 0) {
|
|
1706
|
+
return "";
|
|
1707
|
+
}
|
|
1708
|
+
if (field) {
|
|
1709
|
+
switch (field.type) {
|
|
1710
|
+
case "checkbox":
|
|
1711
|
+
return value ? "Yes" : "No";
|
|
1712
|
+
case "select":
|
|
1713
|
+
if (Array.isArray(value)) {
|
|
1714
|
+
return value.join(", ");
|
|
1715
|
+
}
|
|
1716
|
+
return safeString(value);
|
|
1717
|
+
case "file":
|
|
1718
|
+
if (Array.isArray(value)) {
|
|
1719
|
+
return value.map((v) => safeString(v)).join(", ");
|
|
1720
|
+
}
|
|
1721
|
+
return safeString(value);
|
|
1722
|
+
case "date":
|
|
1723
|
+
if (value instanceof Date) {
|
|
1724
|
+
return value.toISOString().split("T")[0];
|
|
1725
|
+
}
|
|
1726
|
+
if (typeof value === "string") {
|
|
1727
|
+
const parsed = new Date(value);
|
|
1728
|
+
if (!isNaN(parsed.getTime())) {
|
|
1729
|
+
return parsed.toISOString().split("T")[0];
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
return safeString(value);
|
|
1733
|
+
case "time":
|
|
1734
|
+
return safeString(value);
|
|
1735
|
+
case "hidden":
|
|
1736
|
+
return safeString(value);
|
|
1737
|
+
default:
|
|
1738
|
+
break;
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
if (Array.isArray(value)) {
|
|
1742
|
+
return value.join(", ");
|
|
1743
|
+
}
|
|
1744
|
+
if (typeof value === "boolean") {
|
|
1745
|
+
return value ? "Yes" : "No";
|
|
1746
|
+
}
|
|
1747
|
+
if (value instanceof Date) {
|
|
1748
|
+
return value.toISOString();
|
|
1749
|
+
}
|
|
1750
|
+
return safeString(value);
|
|
1751
|
+
}
|
|
1752
|
+
function formatDate(date3, format = "iso") {
|
|
1753
|
+
if (!date3) return "";
|
|
1754
|
+
const d = typeof date3 === "string" ? new Date(date3) : date3;
|
|
1755
|
+
if (isNaN(d.getTime())) {
|
|
1756
|
+
return "";
|
|
1757
|
+
}
|
|
1758
|
+
return format === "iso" ? d.toISOString() : d.toLocaleString();
|
|
1759
|
+
}
|
|
1760
|
+
function escapeCSVValue(value, delimiter = ",") {
|
|
1761
|
+
const needsQuoting = value.includes(delimiter) || value.includes('"') || value.includes("\n") || value.includes("\r");
|
|
1762
|
+
if (!needsQuoting) {
|
|
1763
|
+
return value;
|
|
1764
|
+
}
|
|
1765
|
+
const escaped = value.replace(/"/g, '""');
|
|
1766
|
+
return `"${escaped}"`;
|
|
1767
|
+
}
|
|
1768
|
+
function exportToCSV(submissions, form, options = {}) {
|
|
1769
|
+
const {
|
|
1770
|
+
includeMetadata = true,
|
|
1771
|
+
delimiter = ",",
|
|
1772
|
+
includeBOM = true,
|
|
1773
|
+
dateFormat = "iso"
|
|
1774
|
+
} = options;
|
|
1775
|
+
const exportableFields = form.fields;
|
|
1776
|
+
const headers = [];
|
|
1777
|
+
for (const field of exportableFields) {
|
|
1778
|
+
headers.push(field.label);
|
|
1779
|
+
}
|
|
1780
|
+
if (includeMetadata) {
|
|
1781
|
+
headers.push("ID", "Status", "Submitted At", "IP Address", "User Agent");
|
|
1782
|
+
}
|
|
1783
|
+
const rows = [];
|
|
1784
|
+
for (const submission of submissions) {
|
|
1785
|
+
const row = [];
|
|
1786
|
+
const data = submission.data;
|
|
1787
|
+
for (const field of exportableFields) {
|
|
1788
|
+
const value = data[field.name];
|
|
1789
|
+
const formatted = formatExportValue(value, field);
|
|
1790
|
+
row.push(escapeCSVValue(formatted, delimiter));
|
|
1791
|
+
}
|
|
1792
|
+
if (includeMetadata) {
|
|
1793
|
+
row.push(escapeCSVValue(submission.id, delimiter));
|
|
1794
|
+
row.push(escapeCSVValue(submission.status, delimiter));
|
|
1795
|
+
row.push(
|
|
1796
|
+
escapeCSVValue(
|
|
1797
|
+
formatDate(submission.submittedAt, dateFormat),
|
|
1798
|
+
delimiter
|
|
1799
|
+
)
|
|
1800
|
+
);
|
|
1801
|
+
row.push(escapeCSVValue(submission.ipAddress || "", delimiter));
|
|
1802
|
+
row.push(escapeCSVValue(submission.userAgent || "", delimiter));
|
|
1803
|
+
}
|
|
1804
|
+
rows.push(row);
|
|
1805
|
+
}
|
|
1806
|
+
const escapedHeaders = headers.map((h) => escapeCSVValue(h, delimiter));
|
|
1807
|
+
const lines = [
|
|
1808
|
+
escapedHeaders.join(delimiter),
|
|
1809
|
+
...rows.map((row) => row.join(delimiter))
|
|
1810
|
+
];
|
|
1811
|
+
const csvContent = lines.join("\r\n");
|
|
1812
|
+
return includeBOM ? UTF8_BOM + csvContent : csvContent;
|
|
1813
|
+
}
|
|
1814
|
+
function exportToJSON(submissions, form, options = {}) {
|
|
1815
|
+
const {
|
|
1816
|
+
includeMetadata = true,
|
|
1817
|
+
includeFormDefinition = true,
|
|
1818
|
+
indent = 2
|
|
1819
|
+
} = options;
|
|
1820
|
+
const exportableFields = form.fields;
|
|
1821
|
+
const formDefinition = includeFormDefinition ? {
|
|
1822
|
+
id: form.id,
|
|
1823
|
+
name: form.name,
|
|
1824
|
+
slug: form.slug,
|
|
1825
|
+
fields: exportableFields.map((f) => ({
|
|
1826
|
+
name: f.name,
|
|
1827
|
+
label: f.label,
|
|
1828
|
+
type: f.type
|
|
1829
|
+
}))
|
|
1830
|
+
} : void 0;
|
|
1831
|
+
const exportedSubmissions = submissions.map((submission) => {
|
|
1832
|
+
const data = submission.data;
|
|
1833
|
+
const fieldData = {};
|
|
1834
|
+
for (const field of exportableFields) {
|
|
1835
|
+
const value = data[field.name];
|
|
1836
|
+
fieldData[field.name] = value;
|
|
1837
|
+
}
|
|
1838
|
+
if (includeMetadata) {
|
|
1839
|
+
return {
|
|
1840
|
+
...fieldData,
|
|
1841
|
+
_metadata: {
|
|
1842
|
+
id: submission.id,
|
|
1843
|
+
status: submission.status,
|
|
1844
|
+
submittedAt: submission.submittedAt,
|
|
1845
|
+
ipAddress: submission.ipAddress || null,
|
|
1846
|
+
userAgent: submission.userAgent || null,
|
|
1847
|
+
createdAt: submission.createdAt,
|
|
1848
|
+
updatedAt: submission.updatedAt
|
|
1849
|
+
}
|
|
1850
|
+
};
|
|
1851
|
+
}
|
|
1852
|
+
return fieldData;
|
|
1853
|
+
});
|
|
1854
|
+
const exportData = {
|
|
1855
|
+
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1856
|
+
totalSubmissions: submissions.length,
|
|
1857
|
+
submissions: exportedSubmissions
|
|
1858
|
+
};
|
|
1859
|
+
if (formDefinition) {
|
|
1860
|
+
exportData.form = formDefinition;
|
|
1861
|
+
}
|
|
1862
|
+
return JSON.stringify(exportData, null, indent);
|
|
1863
|
+
}
|
|
1864
|
+
function downloadFile(content, filename, mimeType) {
|
|
1865
|
+
if (typeof window === "undefined" || typeof document === "undefined") {
|
|
1866
|
+
throw new Error("downloadFile is only available in browser environments");
|
|
1867
|
+
}
|
|
1868
|
+
const blob = new Blob([content], { type: mimeType });
|
|
1869
|
+
const url2 = URL.createObjectURL(blob);
|
|
1870
|
+
const link = document.createElement("a");
|
|
1871
|
+
link.href = url2;
|
|
1872
|
+
link.download = filename;
|
|
1873
|
+
document.body.appendChild(link);
|
|
1874
|
+
link.click();
|
|
1875
|
+
document.body.removeChild(link);
|
|
1876
|
+
URL.revokeObjectURL(url2);
|
|
1877
|
+
}
|
|
1878
|
+
function generateExportFilename(formSlug, format) {
|
|
1879
|
+
const date3 = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
1880
|
+
return `${formSlug}-submissions-${date3}.${format}`;
|
|
1881
|
+
}
|
|
1882
|
+
function exportAndDownload(submissions, form, format, options) {
|
|
1883
|
+
const filename = generateExportFilename(form.slug, format);
|
|
1884
|
+
if (format === "csv") {
|
|
1885
|
+
const content = exportToCSV(submissions, form, options);
|
|
1886
|
+
downloadFile(content, filename, "text/csv;charset=utf-8");
|
|
1887
|
+
} else {
|
|
1888
|
+
const content = exportToJSON(submissions, form, options);
|
|
1889
|
+
downloadFile(content, filename, "application/json");
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
// src/handlers/spam-detection.ts
|
|
1894
|
+
var rateLimitStore = /* @__PURE__ */ new Map();
|
|
1895
|
+
async function checkSpam(options) {
|
|
1896
|
+
const { data, ipAddress, formSlug, config } = options;
|
|
1897
|
+
if (config.honeypot) {
|
|
1898
|
+
const honeypotResult = checkHoneypot(data);
|
|
1899
|
+
if (honeypotResult.isSpam) {
|
|
1900
|
+
return honeypotResult;
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
if (config.rateLimit && ipAddress) {
|
|
1904
|
+
const rateLimitResult = checkRateLimit(
|
|
1905
|
+
ipAddress,
|
|
1906
|
+
formSlug,
|
|
1907
|
+
config.rateLimit
|
|
1908
|
+
);
|
|
1909
|
+
if (rateLimitResult.isSpam) {
|
|
1910
|
+
return rateLimitResult;
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
return { isSpam: false };
|
|
1914
|
+
}
|
|
1915
|
+
var HONEYPOT_FIELDS = [
|
|
1916
|
+
"__honeypot",
|
|
1917
|
+
"_honeypot",
|
|
1918
|
+
"honeypot",
|
|
1919
|
+
"__hp",
|
|
1920
|
+
"_hp",
|
|
1921
|
+
"website",
|
|
1922
|
+
// Common honeypot field name
|
|
1923
|
+
"url_field",
|
|
1924
|
+
// Another common honeypot
|
|
1925
|
+
"fax_number"
|
|
1926
|
+
// Rarely used by humans
|
|
1927
|
+
];
|
|
1928
|
+
function checkHoneypot(data) {
|
|
1929
|
+
for (const fieldName of HONEYPOT_FIELDS) {
|
|
1930
|
+
const value = data[fieldName];
|
|
1931
|
+
if (value !== void 0 && value !== null && value !== "") {
|
|
1932
|
+
let raw;
|
|
1933
|
+
if (typeof value === "object") {
|
|
1934
|
+
raw = JSON.stringify(value);
|
|
1935
|
+
} else {
|
|
1936
|
+
raw = String(value);
|
|
1937
|
+
}
|
|
1938
|
+
const stringValue = raw.trim();
|
|
1939
|
+
if (stringValue.length > 0) {
|
|
1940
|
+
return {
|
|
1941
|
+
isSpam: true,
|
|
1942
|
+
reason: "honeypot",
|
|
1943
|
+
details: `Honeypot field "${fieldName}" was filled`
|
|
1944
|
+
};
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
return { isSpam: false };
|
|
1949
|
+
}
|
|
1950
|
+
function checkRateLimit(ipAddress, formSlug, config) {
|
|
1951
|
+
const key = `${formSlug}:${ipAddress}`;
|
|
1952
|
+
const now = Date.now();
|
|
1953
|
+
const entry = rateLimitStore.get(key);
|
|
1954
|
+
if (entry) {
|
|
1955
|
+
if (now - entry.timestamp > config.windowMs) {
|
|
1956
|
+
rateLimitStore.set(key, { count: 1, timestamp: now });
|
|
1957
|
+
return { isSpam: false };
|
|
1958
|
+
}
|
|
1959
|
+
if (entry.count >= config.maxSubmissions) {
|
|
1960
|
+
return {
|
|
1961
|
+
isSpam: true,
|
|
1962
|
+
reason: "rate_limit",
|
|
1963
|
+
details: `Rate limit exceeded: ${entry.count}/${config.maxSubmissions} in ${config.windowMs}ms`
|
|
1964
|
+
};
|
|
1965
|
+
}
|
|
1966
|
+
rateLimitStore.set(key, {
|
|
1967
|
+
count: entry.count + 1,
|
|
1968
|
+
timestamp: entry.timestamp
|
|
1969
|
+
});
|
|
1970
|
+
} else {
|
|
1971
|
+
rateLimitStore.set(key, { count: 1, timestamp: now });
|
|
1972
|
+
}
|
|
1973
|
+
return { isSpam: false };
|
|
1974
|
+
}
|
|
1975
|
+
function cleanupRateLimitStore(maxAgeMs = 5 * 60 * 1e3) {
|
|
1976
|
+
const now = Date.now();
|
|
1977
|
+
let removed = 0;
|
|
1978
|
+
for (const [key, entry] of rateLimitStore.entries()) {
|
|
1979
|
+
if (now - entry.timestamp > maxAgeMs) {
|
|
1980
|
+
rateLimitStore.delete(key);
|
|
1981
|
+
removed++;
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
return removed;
|
|
1985
|
+
}
|
|
1986
|
+
function getRateLimitStoreSize() {
|
|
1987
|
+
return rateLimitStore.size;
|
|
1988
|
+
}
|
|
1989
|
+
function clearRateLimitStore() {
|
|
1990
|
+
rateLimitStore.clear();
|
|
1991
|
+
}
|
|
1992
|
+
function isRateLimited(ipAddress, formSlug, config) {
|
|
1993
|
+
const key = `${formSlug}:${ipAddress}`;
|
|
1994
|
+
const now = Date.now();
|
|
1995
|
+
const entry = rateLimitStore.get(key);
|
|
1996
|
+
if (!entry) {
|
|
1997
|
+
return false;
|
|
1998
|
+
}
|
|
1999
|
+
if (now - entry.timestamp > config.windowMs) {
|
|
2000
|
+
return false;
|
|
2001
|
+
}
|
|
2002
|
+
return entry.count >= config.maxSubmissions;
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
// src/handlers/submit-form.ts
|
|
2006
|
+
async function submitForm(options, context) {
|
|
2007
|
+
const { formSlug, data, metadata } = options;
|
|
2008
|
+
const { pluginContext, pluginConfig } = context;
|
|
2009
|
+
const { collections } = pluginContext.services;
|
|
2010
|
+
const { logger } = pluginContext.infra;
|
|
2011
|
+
try {
|
|
2012
|
+
const form = await fetchFormBySlug(formSlug, pluginConfig, pluginContext);
|
|
2013
|
+
if (!form) {
|
|
2014
|
+
logger.warn?.("Form submission attempted for non-existent form", {
|
|
2015
|
+
formSlug
|
|
2016
|
+
});
|
|
2017
|
+
return {
|
|
2018
|
+
success: false,
|
|
2019
|
+
error: "Form not found"
|
|
2020
|
+
};
|
|
2021
|
+
}
|
|
2022
|
+
if (form.status !== "published") {
|
|
2023
|
+
logger.info?.("Form submission rejected - form not published", {
|
|
2024
|
+
formSlug,
|
|
2025
|
+
status: form.status
|
|
2026
|
+
});
|
|
2027
|
+
return {
|
|
2028
|
+
success: false,
|
|
2029
|
+
error: "This form is not currently accepting submissions"
|
|
2030
|
+
};
|
|
2031
|
+
}
|
|
2032
|
+
const transformedData = transformFormData(data, form.fields);
|
|
2033
|
+
const schema = generateZodSchema(form.fields);
|
|
2034
|
+
const validationResult = schema.safeParse(transformedData);
|
|
2035
|
+
if (!validationResult.success) {
|
|
2036
|
+
const validationErrors = getValidationErrors(validationResult);
|
|
2037
|
+
logger.debug?.("Form validation failed", {
|
|
2038
|
+
formSlug,
|
|
2039
|
+
errors: validationErrors
|
|
2040
|
+
});
|
|
2041
|
+
return {
|
|
2042
|
+
success: false,
|
|
2043
|
+
error: "Validation failed",
|
|
2044
|
+
validationErrors
|
|
2045
|
+
};
|
|
2046
|
+
}
|
|
2047
|
+
sanitizeSubmissionData(validationResult.data, form.fields);
|
|
2048
|
+
const spamResult = await checkSpam({
|
|
2049
|
+
data: transformedData,
|
|
2050
|
+
ipAddress: metadata?.ipAddress,
|
|
2051
|
+
formSlug,
|
|
2052
|
+
config: {
|
|
2053
|
+
honeypot: pluginConfig.spamProtection.honeypot,
|
|
2054
|
+
rateLimit: pluginConfig.spamProtection.rateLimit,
|
|
2055
|
+
recaptcha: pluginConfig.spamProtection.recaptcha
|
|
2056
|
+
}
|
|
2057
|
+
});
|
|
2058
|
+
if (spamResult.isSpam) {
|
|
2059
|
+
logger.info?.("Spam submission detected and rejected", {
|
|
2060
|
+
formSlug,
|
|
2061
|
+
reason: spamResult.reason,
|
|
2062
|
+
ipAddress: metadata?.ipAddress
|
|
2063
|
+
});
|
|
2064
|
+
return {
|
|
2065
|
+
success: true
|
|
2066
|
+
};
|
|
2067
|
+
}
|
|
2068
|
+
const submissionData = {
|
|
2069
|
+
form: form.id,
|
|
2070
|
+
data: validationResult.data,
|
|
2071
|
+
status: "new",
|
|
2072
|
+
ipAddress: metadata?.ipAddress || null,
|
|
2073
|
+
userAgent: metadata?.userAgent || null,
|
|
2074
|
+
submittedAt: /* @__PURE__ */ new Date()
|
|
2075
|
+
};
|
|
2076
|
+
const submission = await collections.createEntry(
|
|
2077
|
+
pluginConfig.formSubmissionOverrides.slug,
|
|
2078
|
+
submissionData,
|
|
2079
|
+
{}
|
|
2080
|
+
// Empty context - submission creation is public
|
|
2081
|
+
);
|
|
2082
|
+
logger.info?.("Form submission created successfully", {
|
|
2083
|
+
formSlug,
|
|
2084
|
+
submissionId: submission.id
|
|
2085
|
+
});
|
|
2086
|
+
const redirect = determineRedirectUrl(form);
|
|
2087
|
+
return {
|
|
2088
|
+
success: true,
|
|
2089
|
+
submission,
|
|
2090
|
+
redirect
|
|
2091
|
+
};
|
|
2092
|
+
} catch (error) {
|
|
2093
|
+
logger.error?.("Form submission error", {
|
|
2094
|
+
formSlug,
|
|
2095
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2096
|
+
});
|
|
2097
|
+
return {
|
|
2098
|
+
success: false,
|
|
2099
|
+
error: "An error occurred processing your submission. Please try again."
|
|
2100
|
+
};
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
async function fetchFormBySlug(slug, pluginConfig, pluginContext) {
|
|
2104
|
+
try {
|
|
2105
|
+
const { collections } = pluginContext.services;
|
|
2106
|
+
const result = await collections.listEntries(
|
|
2107
|
+
pluginConfig.formOverrides.slug,
|
|
2108
|
+
{
|
|
2109
|
+
pagination: { limit: 100 }
|
|
2110
|
+
// Fetch enough to find the form
|
|
2111
|
+
},
|
|
2112
|
+
{}
|
|
2113
|
+
// Empty context for public access
|
|
2114
|
+
);
|
|
2115
|
+
if (!result.data || result.data.length === 0) {
|
|
2116
|
+
return null;
|
|
2117
|
+
}
|
|
2118
|
+
const form = result.data.find(
|
|
2119
|
+
(entry) => entry.slug === slug
|
|
2120
|
+
);
|
|
2121
|
+
return form ? form : null;
|
|
2122
|
+
} catch {
|
|
2123
|
+
return null;
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
function determineRedirectUrl(form) {
|
|
2127
|
+
if (!form.settings) {
|
|
2128
|
+
return void 0;
|
|
2129
|
+
}
|
|
2130
|
+
if (form.settings.confirmationType !== "redirect") {
|
|
2131
|
+
return void 0;
|
|
2132
|
+
}
|
|
2133
|
+
if (form.settings.redirectUrl) {
|
|
2134
|
+
return form.settings.redirectUrl;
|
|
2135
|
+
}
|
|
2136
|
+
if (form.settings.redirectRelation) {
|
|
2137
|
+
const { relationTo, value } = form.settings.redirectRelation;
|
|
2138
|
+
return `relationship://${relationTo}/${value}`;
|
|
2139
|
+
}
|
|
2140
|
+
return void 0;
|
|
2141
|
+
}
|
|
2142
|
+
async function validateSubmission(formSlug, data, context) {
|
|
2143
|
+
const { pluginContext, pluginConfig } = context;
|
|
2144
|
+
const form = await fetchFormBySlug(formSlug, pluginConfig, pluginContext);
|
|
2145
|
+
if (!form) {
|
|
2146
|
+
return { valid: false, errors: { _form: "Form not found" } };
|
|
2147
|
+
}
|
|
2148
|
+
const transformedData = transformFormData(data, form.fields);
|
|
2149
|
+
const schema = generateZodSchema(form.fields);
|
|
2150
|
+
const result = schema.safeParse(transformedData);
|
|
2151
|
+
if (result.success) {
|
|
2152
|
+
return { valid: true };
|
|
2153
|
+
}
|
|
2154
|
+
return {
|
|
2155
|
+
valid: false,
|
|
2156
|
+
errors: getValidationErrors(result)
|
|
2157
|
+
};
|
|
2158
|
+
}
|
|
2159
|
+
async function isFormAcceptingSubmissions(formSlug, context) {
|
|
2160
|
+
const { pluginContext, pluginConfig } = context;
|
|
2161
|
+
const form = await fetchFormBySlug(formSlug, pluginConfig, pluginContext);
|
|
2162
|
+
if (!form) {
|
|
2163
|
+
return false;
|
|
2164
|
+
}
|
|
2165
|
+
return form.status === "published";
|
|
2166
|
+
}
|
|
2167
|
+
var TEXT_FORM_FIELDS = /* @__PURE__ */ new Set([
|
|
2168
|
+
"text",
|
|
2169
|
+
"email",
|
|
2170
|
+
"textarea",
|
|
2171
|
+
"phone",
|
|
2172
|
+
"url",
|
|
2173
|
+
"hidden"
|
|
2174
|
+
]);
|
|
2175
|
+
function stripHtmlTags(input) {
|
|
2176
|
+
return input.replace(/<[^>]*(?:>|$)/g, "").replace(/\s+/g, " ").trim();
|
|
2177
|
+
}
|
|
2178
|
+
function sanitizeSubmissionData(data, fields) {
|
|
2179
|
+
for (const field of fields) {
|
|
2180
|
+
if (!TEXT_FORM_FIELDS.has(field.type)) continue;
|
|
2181
|
+
const value = data[field.name];
|
|
2182
|
+
if (typeof value !== "string") continue;
|
|
2183
|
+
data[field.name] = stripHtmlTags(value);
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
async function getFormSubmissionStats(formSlug, context) {
|
|
2187
|
+
const { pluginContext, pluginConfig } = context;
|
|
2188
|
+
const { collections } = pluginContext.services;
|
|
2189
|
+
const form = await fetchFormBySlug(formSlug, pluginConfig, pluginContext);
|
|
2190
|
+
if (!form) {
|
|
2191
|
+
return null;
|
|
2192
|
+
}
|
|
2193
|
+
try {
|
|
2194
|
+
const result = await collections.listEntries(
|
|
2195
|
+
pluginConfig.formSubmissionOverrides.slug,
|
|
2196
|
+
{
|
|
2197
|
+
pagination: { limit: 1e3 }
|
|
2198
|
+
// Reasonable limit for stats
|
|
2199
|
+
},
|
|
2200
|
+
{}
|
|
2201
|
+
);
|
|
2202
|
+
if (!result.data) {
|
|
2203
|
+
return { total: 0, new: 0, read: 0, archived: 0 };
|
|
2204
|
+
}
|
|
2205
|
+
const submissions = result.data.filter(
|
|
2206
|
+
(entry) => entry.form === form.id
|
|
2207
|
+
);
|
|
2208
|
+
const stats = {
|
|
2209
|
+
total: submissions.length,
|
|
2210
|
+
new: 0,
|
|
2211
|
+
read: 0,
|
|
2212
|
+
archived: 0
|
|
2213
|
+
};
|
|
2214
|
+
for (const sub of submissions) {
|
|
2215
|
+
const status = sub.status;
|
|
2216
|
+
if (status === "new") stats.new++;
|
|
2217
|
+
else if (status === "read") stats.read++;
|
|
2218
|
+
else if (status === "archived") stats.archived++;
|
|
2219
|
+
}
|
|
2220
|
+
return stats;
|
|
2221
|
+
} catch {
|
|
2222
|
+
return null;
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
// src/handlers/webhooks.ts
|
|
2227
|
+
import { safeFetch, ExternalUrlError } from "nextly";
|
|
2228
|
+
var WEBHOOK_TIMEOUT_MS = 1e4;
|
|
2229
|
+
var SIGNATURE_HEADER = "X-Webhook-Signature";
|
|
2230
|
+
var EVENT_HEADER = "X-Webhook-Event";
|
|
2231
|
+
var DELIVERY_ID_HEADER = "X-Webhook-Delivery-ID";
|
|
2232
|
+
async function generateSignature(payload, secret) {
|
|
2233
|
+
const encoder = new TextEncoder();
|
|
2234
|
+
const keyData = encoder.encode(secret);
|
|
2235
|
+
const messageData = encoder.encode(payload);
|
|
2236
|
+
const cryptoKey = await crypto.subtle.importKey(
|
|
2237
|
+
"raw",
|
|
2238
|
+
keyData,
|
|
2239
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
2240
|
+
false,
|
|
2241
|
+
["sign"]
|
|
2242
|
+
);
|
|
2243
|
+
const signature = await crypto.subtle.sign("HMAC", cryptoKey, messageData);
|
|
2244
|
+
const hashArray = Array.from(new Uint8Array(signature));
|
|
2245
|
+
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
2246
|
+
return `sha256=${hashHex}`;
|
|
2247
|
+
}
|
|
2248
|
+
async function sendWebhook(webhook, payload) {
|
|
2249
|
+
const startTime = Date.now();
|
|
2250
|
+
const payloadString = JSON.stringify(payload);
|
|
2251
|
+
try {
|
|
2252
|
+
const headers = {
|
|
2253
|
+
"Content-Type": "application/json",
|
|
2254
|
+
[EVENT_HEADER]: payload.event,
|
|
2255
|
+
[DELIVERY_ID_HEADER]: payload.submission.id,
|
|
2256
|
+
...webhook.headers
|
|
2257
|
+
};
|
|
2258
|
+
if (webhook.secret) {
|
|
2259
|
+
headers[SIGNATURE_HEADER] = await generateSignature(
|
|
2260
|
+
payloadString,
|
|
2261
|
+
webhook.secret
|
|
2262
|
+
);
|
|
2263
|
+
}
|
|
2264
|
+
const controller = new AbortController();
|
|
2265
|
+
const timeoutId = setTimeout(() => controller.abort(), WEBHOOK_TIMEOUT_MS);
|
|
2266
|
+
try {
|
|
2267
|
+
const response = await safeFetch(webhook.url, {
|
|
2268
|
+
method: webhook.method || "POST",
|
|
2269
|
+
headers,
|
|
2270
|
+
body: payloadString,
|
|
2271
|
+
signal: controller.signal,
|
|
2272
|
+
allowLocalhost: process.env.NODE_ENV !== "production"
|
|
2273
|
+
});
|
|
2274
|
+
clearTimeout(timeoutId);
|
|
2275
|
+
const durationMs = Date.now() - startTime;
|
|
2276
|
+
if (response.ok) {
|
|
2277
|
+
return {
|
|
2278
|
+
url: webhook.url,
|
|
2279
|
+
success: true,
|
|
2280
|
+
statusCode: response.status,
|
|
2281
|
+
durationMs
|
|
2282
|
+
};
|
|
2283
|
+
}
|
|
2284
|
+
return {
|
|
2285
|
+
url: webhook.url,
|
|
2286
|
+
success: false,
|
|
2287
|
+
statusCode: response.status,
|
|
2288
|
+
error: `HTTP ${response.status}: ${response.statusText}`,
|
|
2289
|
+
durationMs
|
|
2290
|
+
};
|
|
2291
|
+
} finally {
|
|
2292
|
+
clearTimeout(timeoutId);
|
|
2293
|
+
}
|
|
2294
|
+
} catch (error) {
|
|
2295
|
+
const durationMs = Date.now() - startTime;
|
|
2296
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
2297
|
+
return {
|
|
2298
|
+
url: webhook.url,
|
|
2299
|
+
success: false,
|
|
2300
|
+
error: `Timeout after ${WEBHOOK_TIMEOUT_MS}ms`,
|
|
2301
|
+
durationMs
|
|
2302
|
+
};
|
|
2303
|
+
}
|
|
2304
|
+
if (error instanceof ExternalUrlError) {
|
|
2305
|
+
return {
|
|
2306
|
+
url: webhook.url,
|
|
2307
|
+
success: false,
|
|
2308
|
+
error: `Webhook URL rejected for SSRF safety: ${error.message}`,
|
|
2309
|
+
durationMs
|
|
2310
|
+
};
|
|
2311
|
+
}
|
|
2312
|
+
return {
|
|
2313
|
+
url: webhook.url,
|
|
2314
|
+
success: false,
|
|
2315
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2316
|
+
durationMs
|
|
2317
|
+
};
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
async function triggerWebhooks(options) {
|
|
2321
|
+
const { event, form, submission } = options;
|
|
2322
|
+
const webhooks = form.webhooks || [];
|
|
2323
|
+
const relevantWebhooks = webhooks.filter((w) => w.events.includes(event));
|
|
2324
|
+
if (relevantWebhooks.length === 0) {
|
|
2325
|
+
return {
|
|
2326
|
+
triggered: 0,
|
|
2327
|
+
successful: 0,
|
|
2328
|
+
failed: 0,
|
|
2329
|
+
results: []
|
|
2330
|
+
};
|
|
2331
|
+
}
|
|
2332
|
+
const deliveryPromises = relevantWebhooks.map(async (webhook) => {
|
|
2333
|
+
const payload = {
|
|
2334
|
+
event,
|
|
2335
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2336
|
+
form: {
|
|
2337
|
+
id: form.id,
|
|
2338
|
+
slug: form.slug,
|
|
2339
|
+
name: form.name
|
|
2340
|
+
},
|
|
2341
|
+
submission: webhook.includeData !== false ? submission : {
|
|
2342
|
+
id: submission.id,
|
|
2343
|
+
submittedAt: submission.submittedAt
|
|
2344
|
+
}
|
|
2345
|
+
};
|
|
2346
|
+
return sendWebhook(webhook, payload);
|
|
2347
|
+
});
|
|
2348
|
+
const results = await Promise.all(deliveryPromises);
|
|
2349
|
+
const successful = results.filter((r) => r.success).length;
|
|
2350
|
+
const failed = results.filter((r) => !r.success).length;
|
|
2351
|
+
return {
|
|
2352
|
+
triggered: relevantWebhooks.length,
|
|
2353
|
+
successful,
|
|
2354
|
+
failed,
|
|
2355
|
+
results
|
|
2356
|
+
};
|
|
2357
|
+
}
|
|
2358
|
+
function fireWebhooks(options, logger) {
|
|
2359
|
+
triggerWebhooks(options).then((result) => {
|
|
2360
|
+
if (result.triggered > 0) {
|
|
2361
|
+
if (result.failed > 0) {
|
|
2362
|
+
logger?.warn?.("Some webhooks failed", {
|
|
2363
|
+
event: options.event,
|
|
2364
|
+
formSlug: options.form.slug,
|
|
2365
|
+
submissionId: options.submission.id,
|
|
2366
|
+
triggered: result.triggered,
|
|
2367
|
+
successful: result.successful,
|
|
2368
|
+
failed: result.failed,
|
|
2369
|
+
errors: result.results.filter((r) => !r.success).map((r) => ({ url: r.url, error: r.error }))
|
|
2370
|
+
});
|
|
2371
|
+
} else {
|
|
2372
|
+
logger?.info?.("Webhooks triggered successfully", {
|
|
2373
|
+
event: options.event,
|
|
2374
|
+
formSlug: options.form.slug,
|
|
2375
|
+
submissionId: options.submission.id,
|
|
2376
|
+
count: result.triggered
|
|
2377
|
+
});
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
}).catch((error) => {
|
|
2381
|
+
logger?.error?.("Unexpected error triggering webhooks", {
|
|
2382
|
+
event: options.event,
|
|
2383
|
+
formSlug: options.form.slug,
|
|
2384
|
+
submissionId: options.submission.id,
|
|
2385
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2386
|
+
});
|
|
2387
|
+
});
|
|
2388
|
+
}
|
|
2389
|
+
function isValidWebhookUrl(url2) {
|
|
2390
|
+
try {
|
|
2391
|
+
const parsed = new URL(url2);
|
|
2392
|
+
if (parsed.protocol !== "https:" && !parsed.hostname.match(/^(localhost|127\.0\.0\.1)$/)) {
|
|
2393
|
+
return false;
|
|
2394
|
+
}
|
|
2395
|
+
return true;
|
|
2396
|
+
} catch {
|
|
2397
|
+
return false;
|
|
2398
|
+
}
|
|
2399
|
+
}
|
|
2400
|
+
function getSupportedWebhookEvents() {
|
|
2401
|
+
return ["submission.created", "submission.updated", "submission.deleted"];
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
// src/index.ts
|
|
2405
|
+
var formBuilderPlugin = formBuilder().plugin;
|
|
2406
|
+
export {
|
|
2407
|
+
DEFAULT_FORM_SETTINGS,
|
|
2408
|
+
DEFAULT_NOTIFICATION_SETTINGS,
|
|
2409
|
+
RESERVED_FORM_SLUGS,
|
|
2410
|
+
applyFieldDefaults,
|
|
2411
|
+
applyFormDefaults,
|
|
2412
|
+
assertValidFormConfig,
|
|
2413
|
+
checkSpam,
|
|
2414
|
+
checkbox2 as checkbox,
|
|
2415
|
+
cleanupRateLimitStore,
|
|
2416
|
+
clearRateLimitStore,
|
|
2417
|
+
createFormConfig,
|
|
2418
|
+
date2 as date,
|
|
2419
|
+
downloadFile,
|
|
2420
|
+
email,
|
|
2421
|
+
evaluateConditions,
|
|
2422
|
+
exportAndDownload,
|
|
2423
|
+
exportToCSV,
|
|
2424
|
+
exportToJSON,
|
|
2425
|
+
file,
|
|
2426
|
+
fireWebhooks,
|
|
2427
|
+
formBuilder,
|
|
2428
|
+
formBuilderPlugin,
|
|
2429
|
+
formatExportValue,
|
|
2430
|
+
formsCollection,
|
|
2431
|
+
generateExportFilename,
|
|
2432
|
+
generateZodSchema,
|
|
2433
|
+
getFormBuilderConfig,
|
|
2434
|
+
getFormSubmissionStats,
|
|
2435
|
+
getRateLimitStoreSize,
|
|
2436
|
+
getSupportedComparisonOperators,
|
|
2437
|
+
getSupportedWebhookEvents,
|
|
2438
|
+
getValidationErrors,
|
|
2439
|
+
hidden,
|
|
2440
|
+
isCheckboxFormField,
|
|
2441
|
+
isEmailFormField,
|
|
2442
|
+
isFileFormField,
|
|
2443
|
+
isFormAcceptingSubmissions,
|
|
2444
|
+
isFormField,
|
|
2445
|
+
isFormFieldType,
|
|
2446
|
+
isRateLimited,
|
|
2447
|
+
isSelectFormField,
|
|
2448
|
+
isTextFormField,
|
|
2449
|
+
isValidComparisonOperator,
|
|
2450
|
+
isValidWebhookUrl,
|
|
2451
|
+
number,
|
|
2452
|
+
option,
|
|
2453
|
+
phone,
|
|
2454
|
+
pluralize,
|
|
2455
|
+
radio,
|
|
2456
|
+
select3 as select,
|
|
2457
|
+
submissionsCollection,
|
|
2458
|
+
submitForm,
|
|
2459
|
+
text3 as text,
|
|
2460
|
+
textarea3 as textarea,
|
|
2461
|
+
time,
|
|
2462
|
+
toTitleCase,
|
|
2463
|
+
transformFormData,
|
|
2464
|
+
triggerWebhooks,
|
|
2465
|
+
url,
|
|
2466
|
+
validateFormConfig,
|
|
2467
|
+
validateFormData,
|
|
2468
|
+
validateSubmission
|
|
2469
|
+
};
|
|
2470
|
+
//# sourceMappingURL=index.js.map
|