@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.
Files changed (37) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +122 -0
  3. package/dist/CheckboxFieldEditor-K4T6QX52.js +121 -0
  4. package/dist/CheckboxFieldEditor-K4T6QX52.js.map +1 -0
  5. package/dist/DateFieldEditor-JFJFAVOH.js +187 -0
  6. package/dist/DateFieldEditor-JFJFAVOH.js.map +1 -0
  7. package/dist/EmailFieldEditor-MMOXXL7P.js +138 -0
  8. package/dist/EmailFieldEditor-MMOXXL7P.js.map +1 -0
  9. package/dist/NumberFieldEditor-UISAYR6Z.js +175 -0
  10. package/dist/NumberFieldEditor-UISAYR6Z.js.map +1 -0
  11. package/dist/RadioFieldEditor-6UQFH4WT.js +165 -0
  12. package/dist/RadioFieldEditor-6UQFH4WT.js.map +1 -0
  13. package/dist/SelectFieldEditor-PSO4KHV4.js +213 -0
  14. package/dist/SelectFieldEditor-PSO4KHV4.js.map +1 -0
  15. package/dist/TextFieldEditor-5EU64EA5.js +187 -0
  16. package/dist/TextFieldEditor-5EU64EA5.js.map +1 -0
  17. package/dist/TextareaFieldEditor-773G3LNM.js +190 -0
  18. package/dist/TextareaFieldEditor-773G3LNM.js.map +1 -0
  19. package/dist/admin/index.css +934 -0
  20. package/dist/admin/index.css.map +1 -0
  21. package/dist/admin/index.d.ts +547 -0
  22. package/dist/admin/index.js +4030 -0
  23. package/dist/admin/index.js.map +1 -0
  24. package/dist/chunk-ABNZFTQK.js +586 -0
  25. package/dist/chunk-ABNZFTQK.js.map +1 -0
  26. package/dist/chunk-XTCEPJ7B.js +256 -0
  27. package/dist/chunk-XTCEPJ7B.js.map +1 -0
  28. package/dist/components/index.d.ts +3 -0
  29. package/dist/components/index.js +9 -0
  30. package/dist/components/index.js.map +1 -0
  31. package/dist/index-C0kBvYjm.d.ts +1012 -0
  32. package/dist/index.d.ts +1663 -0
  33. package/dist/index.js +2470 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/styles/form-builder.css +1150 -0
  36. package/dist/styles/submissions-filter.css +75 -0
  37. 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