@nextlyhq/plugin-form-builder 0.0.2-alpha.34 → 0.0.2-alpha.35

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 (38) hide show
  1. package/README.md +4 -5
  2. package/dist/admin/index.d.ts +101 -303
  3. package/dist/admin/index.js +3600 -3243
  4. package/dist/admin/index.js.map +1 -1
  5. package/dist/chunk-I4VFGPNR.js +506 -0
  6. package/dist/chunk-I4VFGPNR.js.map +1 -0
  7. package/dist/index.d.ts +4 -4
  8. package/dist/index.js +417 -571
  9. package/dist/index.js.map +1 -1
  10. package/dist/{index-BRumOEsE.d.ts → types-C-DcuYGF.d.ts} +61 -93
  11. package/package.json +22 -30
  12. package/dist/CheckboxFieldEditor-K4T6QX52.js +0 -121
  13. package/dist/CheckboxFieldEditor-K4T6QX52.js.map +0 -1
  14. package/dist/DateFieldEditor-JFJFAVOH.js +0 -187
  15. package/dist/DateFieldEditor-JFJFAVOH.js.map +0 -1
  16. package/dist/EmailFieldEditor-ZZ4VSKKB.js +0 -138
  17. package/dist/EmailFieldEditor-ZZ4VSKKB.js.map +0 -1
  18. package/dist/NumberFieldEditor-UISAYR6Z.js +0 -175
  19. package/dist/NumberFieldEditor-UISAYR6Z.js.map +0 -1
  20. package/dist/RadioFieldEditor-6UQFH4WT.js +0 -165
  21. package/dist/RadioFieldEditor-6UQFH4WT.js.map +0 -1
  22. package/dist/SelectFieldEditor-PSO4KHV4.js +0 -213
  23. package/dist/SelectFieldEditor-PSO4KHV4.js.map +0 -1
  24. package/dist/TextFieldEditor-5EU64EA5.js +0 -187
  25. package/dist/TextFieldEditor-5EU64EA5.js.map +0 -1
  26. package/dist/TextareaFieldEditor-773G3LNM.js +0 -190
  27. package/dist/TextareaFieldEditor-773G3LNM.js.map +0 -1
  28. package/dist/admin/index.css +0 -91
  29. package/dist/admin/index.css.map +0 -1
  30. package/dist/chunk-2LLUO3TV.js +0 -547
  31. package/dist/chunk-2LLUO3TV.js.map +0 -1
  32. package/dist/chunk-XTCEPJ7B.js +0 -256
  33. package/dist/chunk-XTCEPJ7B.js.map +0 -1
  34. package/dist/components/index.d.ts +0 -3
  35. package/dist/components/index.js +0 -9
  36. package/dist/components/index.js.map +0 -1
  37. package/dist/styles/form-builder.css +0 -128
  38. package/dist/styles/submissions-filter.css +0 -74
package/dist/index.js CHANGED
@@ -1,3 +1,24 @@
1
+ import {
2
+ DEFAULT_FORM_SETTINGS,
3
+ DEFAULT_NOTIFICATION_SETTINGS,
4
+ applyFieldDefaults,
5
+ applyFormDefaults,
6
+ createFormConfig,
7
+ downloadFile,
8
+ evaluateConditions,
9
+ evaluateSingleCondition,
10
+ exportAndDownload,
11
+ exportToCSV,
12
+ exportToJSON,
13
+ formatExportValue,
14
+ generateExportFilename,
15
+ getSupportedComparisonOperators,
16
+ isValidComparisonOperator,
17
+ normalizeFormSettings,
18
+ pluralize,
19
+ toTitleCase
20
+ } from "./chunk-I4VFGPNR.js";
21
+
1
22
  // src/plugin.ts
2
23
  import { createRequire } from "module";
3
24
  import { definePlugin } from "@nextlyhq/plugin-sdk";
@@ -91,12 +112,18 @@ function formsCollection(pluginConfig) {
91
112
  description: "Allow the same user/IP to submit this form multiple times"
92
113
  }
93
114
  }),
115
+ checkbox({
116
+ name: "honeypotEnabled",
117
+ label: "Honeypot",
118
+ admin: {
119
+ description: "Per-form honeypot override; unset inherits the plugin default"
120
+ }
121
+ }),
94
122
  checkbox({
95
123
  name: "captchaEnabled",
96
124
  label: "Enable reCAPTCHA",
97
- defaultValue: false,
98
125
  admin: {
99
- description: "Protect this form with Google reCAPTCHA v3"
126
+ description: "Per-form reCAPTCHA override; unset inherits the plugin default"
100
127
  }
101
128
  }),
102
129
  text({
@@ -164,10 +191,10 @@ function formsCollection(pluginConfig) {
164
191
  // ============================================================
165
192
  json({
166
193
  name: "notifications",
167
- label: "Email Integrations",
194
+ label: "Notifications",
168
195
  defaultValue: [],
169
196
  admin: {
170
- description: "Email notification integrations. Managed by the Form Builder UI."
197
+ description: "Email notification rules. Managed by the Form Builder UI."
171
198
  }
172
199
  }),
173
200
  // ============================================================
@@ -253,16 +280,6 @@ function formsCollection(pluginConfig) {
253
280
  }
254
281
  return data;
255
282
  }
256
- ],
257
- // Add virtual submission count field
258
- afterRead: [
259
- (context) => {
260
- const { data } = context;
261
- if (data) {
262
- data.submissionCount = 0;
263
- }
264
- return data;
265
- }
266
283
  ]
267
284
  },
268
285
  // Spread any additional overrides (excluding already used properties)
@@ -318,12 +335,23 @@ function submissionsCollection(pluginConfig) {
318
335
  options: [
319
336
  { label: "New", value: "new" },
320
337
  { label: "Read", value: "read" },
321
- { label: "Archived", value: "archived" }
338
+ { label: "Archived", value: "archived" },
339
+ // Spam is stored and flagged, never silently dropped: a false
340
+ // positive stays reviewable and recoverable ("Not spam" = back to new).
341
+ { label: "Spam", value: "spam" }
322
342
  ],
323
343
  admin: {
324
344
  description: "Track whether this submission has been reviewed"
325
345
  }
326
346
  }),
347
+ text2({
348
+ name: "spamReason",
349
+ label: "Spam Reason",
350
+ admin: {
351
+ readOnly: true,
352
+ description: "Which spam check flagged this submission (empty for clean submissions)"
353
+ }
354
+ }),
327
355
  // ============================================================
328
356
  // Internal Notes
329
357
  // ============================================================
@@ -361,6 +389,24 @@ function submissionsCollection(pluginConfig) {
361
389
  readOnly: true,
362
390
  description: "When the form was submitted"
363
391
  }
392
+ }),
393
+ // Honesty stamps for admin edits of submitted data: a corrected
394
+ // submission must never be indistinguishable from what the visitor sent.
395
+ date({
396
+ name: "editedAt",
397
+ label: "Edited At",
398
+ admin: {
399
+ readOnly: true,
400
+ description: "When an admin last edited the submitted data"
401
+ }
402
+ }),
403
+ text2({
404
+ name: "editedBy",
405
+ label: "Edited By",
406
+ admin: {
407
+ readOnly: true,
408
+ description: "ID of the admin who last edited the submitted data"
409
+ }
364
410
  })
365
411
  ];
366
412
  let finalFields;
@@ -382,10 +428,17 @@ function submissionsCollection(pluginConfig) {
382
428
  order: 51,
383
429
  useAsTitle: "id",
384
430
  description: "View and manage form submissions",
385
- // Custom components for submissions list
431
+ // Submissions are created by visitors submitting forms — a hand-typed
432
+ // "New Submission" would be fiction, so the admin never offers one.
433
+ disableCreate: true,
386
434
  components: {
387
- // Filter dropdown to filter submissions by form
388
- BeforeListTable: "@nextlyhq/plugin-form-builder/admin#SubmissionsFilter"
435
+ // The full submissions experience (per-form columns, status tabs,
436
+ // drawer detail, export) replaces the generic list.
437
+ views: {
438
+ List: {
439
+ Component: "@nextlyhq/plugin-form-builder/admin#SubmissionsView"
440
+ }
441
+ }
389
442
  },
390
443
  ...overrides.admin
391
444
  },
@@ -402,7 +455,9 @@ function submissionsCollection(pluginConfig) {
402
455
  delete: accessOverrides?.delete ?? (({ roles }) => roles.includes("admin") || roles.includes("super-admin"))
403
456
  },
404
457
  hooks: {
405
- // Auto-set submittedAt timestamp on creation
458
+ // Auto-set submittedAt timestamp on creation. (The edit stamp for
459
+ // updates is registered in plugin.ts init — direct registry hooks are
460
+ // the path that runs for every API surface.)
406
461
  beforeValidate: [
407
462
  (context) => {
408
463
  const { data, operation } = context;
@@ -503,6 +558,13 @@ function formBuilder(options = {}) {
503
558
  name: "@nextlyhq/plugin-form-builder",
504
559
  version: PLUGIN_VERSION,
505
560
  nextly: ">=0.0.2-alpha.21",
561
+ // Identity metadata for the admin plugins page, mirroring package.json.
562
+ author: "Nextly",
563
+ homepage: "https://nextlyhq.com",
564
+ repository: "https://github.com/nextlyhq/nextly",
565
+ license: "MIT",
566
+ category: "forms",
567
+ tags: ["forms", "submissions", "email-notifications"],
506
568
  // Declarative schema: the merged pipeline folds these into the
507
569
  // app schema — no manual `setup()` append needed. Just register the plugin.
508
570
  contributes: {
@@ -515,7 +577,15 @@ function formBuilder(options = {}) {
515
577
  resource: "submissions",
516
578
  label: "Export Submissions",
517
579
  description: "Export form submissions as CSV/JSON",
518
- group: "Form Builder"
580
+ // No `group`: the admin already files this under the plugin that
581
+ // declared it, so naming the plugin again would nest it inside
582
+ // itself. `group` is for a plugin with enough permissions to sort
583
+ // its own into headings, which one is not.
584
+ //
585
+ // `danger` because the point of the permission is to take
586
+ // submissions — names, emails, whatever a form asked for — out of
587
+ // the site in a file.
588
+ danger: true
519
589
  }
520
590
  ],
521
591
  // HTTP route — exported at
@@ -529,53 +599,137 @@ function formBuilder(options = {}) {
529
599
  method: "GET",
530
600
  path: "/submissions/export",
531
601
  requiredPermission: "export-submissions",
532
- handler: async (_req, ctx) => {
602
+ handler: async (req, ctx) => {
533
603
  const declaredSlug = resolvedConfig.formSubmissionOverrides.slug;
534
604
  const slug = ctx.self.collections[declaredSlug] ?? declaredSlug;
535
- const result = await ctx.services.collections.listEntries(
536
- slug,
537
- {},
538
- { as: "user", user: ctx.user ?? void 0 }
605
+ const url2 = new URL(req.url);
606
+ const format = url2.searchParams.get("format") ?? "json";
607
+ const formId = url2.searchParams.get("form");
608
+ const status = url2.searchParams.get("status");
609
+ const where = {};
610
+ if (formId) where.form = { equals: formId };
611
+ if (status) where.status = { equals: status };
612
+ else where.status = { not_equals: "spam" };
613
+ const opts = { as: "user", user: ctx.user ?? void 0 };
614
+ let form;
615
+ if (format === "csv") {
616
+ if (!formId) {
617
+ return Response.json(
618
+ {
619
+ error: {
620
+ code: "VALIDATION_ERROR",
621
+ message: "CSV export requires a form parameter."
622
+ }
623
+ },
624
+ { status: 400 }
625
+ );
626
+ }
627
+ const formsSlugDeclared = resolvedConfig.formOverrides.slug;
628
+ const formsSlug = ctx.self.collections[formsSlugDeclared] ?? formsSlugDeclared;
629
+ const formResult = await ctx.services.collections.listEntries(
630
+ formsSlug,
631
+ { where: { id: { equals: formId } } },
632
+ opts
633
+ );
634
+ form = formResult.data[0];
635
+ if (!form) {
636
+ return Response.json(
637
+ { error: { code: "NOT_FOUND", message: "Form not found." } },
638
+ { status: 404 }
639
+ );
640
+ }
641
+ }
642
+ const MAX_EXPORT_ROWS = 5e4;
643
+ const items = [];
644
+ const pageSize = 200;
645
+ let truncated = false;
646
+ for (let page = 1; ; page += 1) {
647
+ const result = await ctx.services.collections.listEntries(
648
+ slug,
649
+ { where, pagination: { limit: pageSize, page } },
650
+ opts
651
+ );
652
+ items.push(...result.data);
653
+ const pageWasFull = result.data.length === pageSize;
654
+ if (items.length >= MAX_EXPORT_ROWS) {
655
+ truncated = pageWasFull || items.length > MAX_EXPORT_ROWS;
656
+ items.length = MAX_EXPORT_ROWS;
657
+ break;
658
+ }
659
+ if (!pageWasFull) break;
660
+ }
661
+ const truncationHeaders = truncated ? { "X-Export-Truncated": "true" } : {};
662
+ if (format !== "csv" || !form) {
663
+ return Response.json({ items }, { headers: truncationHeaders });
664
+ }
665
+ const csv = exportToCSV(
666
+ items,
667
+ form
539
668
  );
540
- return Response.json({ items: result.data });
541
- }
542
- }
543
- ],
544
- // Admin UI — the canonical contributes.admin example. Paths
545
- // are the components form-builder's `/admin` module self-registers (kept
546
- // as literals so this node entry stays React-free). menu links to
547
- // the forms collection; settings renders the builder UI at
548
- // /admin/plugins/<slug>; a custom page is gated by export-submissions;
549
- // a submissions beforeList view injects the filter above the list.
550
- admin: {
551
- menu: [
552
- {
553
- label: "Forms",
554
- to: `/admin/collections/${resolvedConfig.formOverrides.slug}`,
555
- icon: "file-text",
556
- order: 50,
557
- requiredPermission: `read-${resolvedConfig.formOverrides.slug}`
669
+ const filename = generateExportFilename(
670
+ typeof form.slug === "string" ? form.slug : "form",
671
+ "csv"
672
+ );
673
+ return new Response(csv, {
674
+ headers: {
675
+ "Content-Type": "text/csv; charset=utf-8",
676
+ "Content-Disposition": `attachment; filename="${filename}"`,
677
+ ...truncationHeaders
678
+ }
679
+ });
558
680
  }
559
- ],
560
- settings: {
561
- component: "@nextlyhq/plugin-form-builder/admin#FormBuilderView"
562
681
  },
563
- pages: [
564
- {
565
- path: "submissions",
566
- component: "@nextlyhq/plugin-form-builder/admin#SubmissionsFilter",
567
- requiredPermission: "export-submissions"
568
- }
569
- ],
570
- views: {
571
- [resolvedConfig.formSubmissionOverrides.slug]: {
572
- beforeList: "@nextlyhq/plugin-form-builder/admin#SubmissionsFilter"
573
- }
682
+ {
683
+ // The builder UI reads the host's resolved field enable/disable map
684
+ // from here, so the plugin option actually gates the type picker.
685
+ // Options resolve server-side only; this is the one channel the
686
+ // admin client has to them.
687
+ method: "GET",
688
+ path: "/builder-config",
689
+ // Derived from the forms slug like the menu entry: with an
690
+ // overridden slug, users hold read-<slug>, not read-forms.
691
+ requiredPermission: `read-${resolvedConfig.formOverrides.slug}`,
692
+ handler: (_req, ctx) => Promise.resolve(
693
+ Response.json({
694
+ fields: resolvedConfig.fields,
695
+ // Notification defaults the builder surfaces: defaultToEmail
696
+ // seeds a new form's first rule, defaultFrom renders as the
697
+ // inherited sender placeholder.
698
+ notifications: {
699
+ defaultFrom: resolvedConfig.notifications.defaultFrom,
700
+ defaultToEmail: resolvedConfig.notifications.defaultToEmail
701
+ },
702
+ // Spam defaults the Settings tab surfaces, so per-form
703
+ // override selects can show what "inherit" resolves to.
704
+ spamProtection: {
705
+ honeypot: resolvedConfig.spamProtection.honeypot,
706
+ recaptchaEnabled: resolvedConfig.spamProtection.recaptcha?.enabled ?? false
707
+ },
708
+ // Runtime-resolved collection slugs (through ctx.self, so a
709
+ // framework .rename() is honored too) — admin components
710
+ // never hardcode "forms"/"form-submissions".
711
+ slugs: {
712
+ forms: ctx.self.collections[resolvedConfig.formOverrides.slug] ?? resolvedConfig.formOverrides.slug,
713
+ submissions: ctx.self.collections[resolvedConfig.formSubmissionOverrides.slug] ?? resolvedConfig.formSubmissionOverrides.slug
714
+ }
715
+ })
716
+ )
574
717
  }
575
- }
718
+ ]
576
719
  },
720
+ // Forms is a first-class destination, not a plugin detail: standalone
721
+ // placement gives it its own main-rail icon after Media, and its
722
+ // sub-sidebar lists the plugin's collections (Forms, Submissions) —
723
+ // no separate menu contribution, so "Forms" exists exactly once.
724
+ // Hosts preferring the Plugins section override placement in config.
725
+ // (The old admin.settings.component that rendered a second full builder
726
+ // at /admin/plugins/<slug> is gone; the collection Edit-view override
727
+ // is the single FormBuilderView mount.)
577
728
  admin: {
729
+ placement: "standalone",
730
+ after: "media",
578
731
  order: 50,
732
+ appearance: { icon: "FileText", label: "Forms" },
579
733
  description: "Create and manage forms with submission tracking"
580
734
  },
581
735
  // -- Init ----------------------------------------------------------------
@@ -607,6 +761,40 @@ function formBuilder(options = {}) {
607
761
  await handleSubmissionCreated(context, resolvedConfig, nextly);
608
762
  }
609
763
  );
764
+ nextly.hooks.on("beforeUpdate", submissionSlug, (context) => {
765
+ const ctx = context;
766
+ if (ctx.data && ctx.data.data !== void 0) {
767
+ ctx.data.editedAt = /* @__PURE__ */ new Date();
768
+ ctx.data.editedBy = ctx.user?.id ?? null;
769
+ }
770
+ return ctx.data;
771
+ });
772
+ const declaredFormsSlug = resolvedConfig.formOverrides.slug;
773
+ const formsSlug = nextly.self.collections[declaredFormsSlug] ?? declaredFormsSlug;
774
+ nextly.hooks.on("afterRead", formsSlug, async (context) => {
775
+ const data = context.data;
776
+ const records = Array.isArray(data) ? data : data ? [data] : [];
777
+ await Promise.all(
778
+ records.map(async (record) => {
779
+ const form = record;
780
+ if (typeof form.id !== "string") return;
781
+ try {
782
+ form.submissionCount = await nextly.services.collections.count(
783
+ submissionSlug,
784
+ {
785
+ where: {
786
+ form: { equals: form.id },
787
+ status: { not_equals: "spam" }
788
+ }
789
+ },
790
+ { as: "system" }
791
+ );
792
+ } catch {
793
+ form.submissionCount = 0;
794
+ }
795
+ })
796
+ );
797
+ });
610
798
  }
611
799
  });
612
800
  return {
@@ -644,9 +832,78 @@ function collectAttachmentInputs(fields, submittedData) {
644
832
  }
645
833
  return attachments;
646
834
  }
835
+ function parseJsonColumn(value) {
836
+ if (typeof value === "string") {
837
+ try {
838
+ const parsed = JSON.parse(value);
839
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
840
+ } catch {
841
+ return {};
842
+ }
843
+ }
844
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
845
+ }
846
+ function normalizeAddressList(addresses) {
847
+ if (!Array.isArray(addresses)) return void 0;
848
+ const cleaned = addresses.map((address) => typeof address === "string" ? address.trim() : "").filter(Boolean);
849
+ return cleaned.length > 0 ? cleaned : void 0;
850
+ }
851
+ function buildNotificationEmails(input) {
852
+ const { notifications, submittedData, formName, submissionId, defaultFrom } = input;
853
+ const seen = /* @__PURE__ */ new Set();
854
+ const emails = [];
855
+ const skipped = [];
856
+ for (const notification of notifications) {
857
+ if (!notification.enabled) continue;
858
+ if (!notification.templateSlug) {
859
+ skipped.push({ notificationId: notification.id, reason: "no-template" });
860
+ continue;
861
+ }
862
+ const key = notification.id || JSON.stringify(notification);
863
+ if (seen.has(key)) continue;
864
+ seen.add(key);
865
+ if (notification.condition && !evaluateSingleCondition(notification.condition, submittedData)) {
866
+ skipped.push({
867
+ notificationId: notification.id,
868
+ reason: "condition-unmet"
869
+ });
870
+ continue;
871
+ }
872
+ const to = (notification.recipientType === "field" ? resolveFieldRef(notification.to, submittedData) : notification.to).trim();
873
+ if (!to) {
874
+ skipped.push({
875
+ notificationId: notification.id,
876
+ reason: "empty-recipient"
877
+ });
878
+ continue;
879
+ }
880
+ const cc = normalizeAddressList(notification.cc);
881
+ const bcc = normalizeAddressList(notification.bcc);
882
+ const from = notification.senderEmail?.trim() || defaultFrom?.trim() || void 0;
883
+ const replyTo = notification.replyTo ? resolveFieldRef(notification.replyTo, submittedData).trim() || void 0 : void 0;
884
+ emails.push({
885
+ to,
886
+ templateSlug: notification.templateSlug,
887
+ variables: {
888
+ ...submittedData,
889
+ formName,
890
+ submissionId
891
+ },
892
+ providerId: notification.providerId,
893
+ from,
894
+ replyTo,
895
+ cc,
896
+ bcc,
897
+ notificationId: notification.id
898
+ });
899
+ }
900
+ return { emails, skipped };
901
+ }
647
902
  async function handleSubmissionCreated(context, config, nextly) {
903
+ if (!config.notifications.enabled) return;
648
904
  const submission = context.data;
649
905
  if (!submission) return;
906
+ if (submission.status === "spam") return;
650
907
  const rawFormId = submission.form;
651
908
  let formId = null;
652
909
  if (typeof rawFormId === "string") {
@@ -662,39 +919,29 @@ async function handleSubmissionCreated(context, config, nextly) {
662
919
  if (notifications.length === 0) return;
663
920
  const emailService = nextly.services.email;
664
921
  if (!emailService) return;
665
- const submittedData = submission.data ?? {};
922
+ const submittedData = parseJsonColumn(submission.data);
666
923
  const formFields = Array.isArray(form.fields) ? form.fields : [];
667
924
  const fileAttachments = collectAttachmentInputs(formFields, submittedData);
668
- const seen = /* @__PURE__ */ new Set();
669
- const emails = [];
670
- for (const notification of notifications) {
671
- if (!notification.enabled || !notification.templateSlug) continue;
672
- const key = notification.id || `${notification.to}::${notification.templateSlug}`;
673
- if (seen.has(key)) continue;
674
- seen.add(key);
675
- const to = notification.recipientType === "field" ? resolveFieldRef(notification.to, submittedData) : notification.to;
676
- if (!to) {
925
+ const { emails, skipped } = buildNotificationEmails({
926
+ notifications,
927
+ submittedData,
928
+ formName: form.name,
929
+ submissionId: submission.id,
930
+ defaultFrom: config.notifications.defaultFrom
931
+ });
932
+ for (const skip of skipped) {
933
+ if (skip.reason === "empty-recipient") {
677
934
  nextly.logger.warn?.(
678
935
  "Form Builder: empty recipient, skipping notification",
679
- { notificationId: notification.id, formSlug: form.slug }
936
+ { notificationId: skip.notificationId, formSlug: form.slug }
680
937
  );
681
- continue;
938
+ } else {
939
+ nextly.logger.debug?.("Form Builder: notification skipped", {
940
+ notificationId: skip.notificationId,
941
+ formSlug: form.slug,
942
+ reason: skip.reason
943
+ });
682
944
  }
683
- const cc = Array.isArray(notification.cc) && notification.cc.length ? notification.cc : void 0;
684
- const bcc = Array.isArray(notification.bcc) && notification.bcc.length ? notification.bcc : void 0;
685
- emails.push({
686
- to,
687
- templateSlug: notification.templateSlug,
688
- variables: {
689
- ...submittedData,
690
- formName: form.name,
691
- submissionId: submission.id
692
- },
693
- providerId: notification.providerId,
694
- cc,
695
- bcc,
696
- notificationId: notification.id
697
- });
698
945
  }
699
946
  if (emails.length === 0) return;
700
947
  const finalEmails = await nextly.filters.apply("form-builder.beforeEmail", emails, { form, submission });
@@ -706,6 +953,8 @@ async function handleSubmissionCreated(context, config, nextly) {
706
953
  email2.variables,
707
954
  {
708
955
  providerId: email2.providerId,
956
+ from: email2.from,
957
+ replyTo: email2.replyTo,
709
958
  cc: email2.cc,
710
959
  bcc: email2.bcc,
711
960
  attachments: fileAttachments.length > 0 ? fileAttachments : void 0
@@ -1143,82 +1392,6 @@ ${errorMessages}`
1143
1392
  }
1144
1393
  }
1145
1394
 
1146
- // src/config/defaults.ts
1147
- var DEFAULT_FORM_SETTINGS = {
1148
- submitButtonText: "Submit",
1149
- confirmationType: "message",
1150
- successMessage: "Thank you for your submission!",
1151
- allowMultipleSubmissions: true
1152
- };
1153
- var DEFAULT_NOTIFICATION_SETTINGS = {
1154
- enabled: true,
1155
- replyTo: true
1156
- };
1157
- function toTitleCase(str) {
1158
- return str.replace(/[-_]/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
1159
- }
1160
- function pluralize(singular) {
1161
- if (singular.endsWith("y") && !/[aeiou]y$/i.test(singular)) {
1162
- return singular.slice(0, -1) + "ies";
1163
- }
1164
- if (singular.endsWith("s") || singular.endsWith("x") || singular.endsWith("z") || singular.endsWith("ch") || singular.endsWith("sh")) {
1165
- return singular + "es";
1166
- }
1167
- return singular + "s";
1168
- }
1169
- function applyFormDefaults(config) {
1170
- const singularLabel = config.labels?.singular || toTitleCase(config.slug);
1171
- return {
1172
- ...config,
1173
- // Generate labels from slug if not provided
1174
- labels: {
1175
- singular: singularLabel,
1176
- plural: config.labels?.plural || pluralize(singularLabel)
1177
- },
1178
- // Apply settings defaults
1179
- settings: {
1180
- submitButtonText: config.settings?.submitButtonText || DEFAULT_FORM_SETTINGS.submitButtonText,
1181
- confirmationType: config.settings?.confirmationType || DEFAULT_FORM_SETTINGS.confirmationType,
1182
- successMessage: config.settings?.successMessage || DEFAULT_FORM_SETTINGS.successMessage,
1183
- allowMultipleSubmissions: config.settings?.allowMultipleSubmissions ?? DEFAULT_FORM_SETTINGS.allowMultipleSubmissions,
1184
- // Preserve other settings that don't have defaults
1185
- redirectUrl: config.settings?.redirectUrl,
1186
- redirectRelation: config.settings?.redirectRelation,
1187
- captcha: config.settings?.captcha
1188
- },
1189
- // Apply notification defaults if notifications are configured
1190
- notifications: config.notifications ? {
1191
- enabled: config.notifications.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.enabled,
1192
- recipients: config.notifications.recipients || [],
1193
- subject: config.notifications.subject || `New submission for ${singularLabel}`,
1194
- replyTo: config.notifications.replyTo ?? DEFAULT_NOTIFICATION_SETTINGS.replyTo,
1195
- template: config.notifications.template
1196
- } : void 0,
1197
- // Apply access defaults
1198
- access: {
1199
- // Default: anyone can submit the form
1200
- submit: config.access?.submit || (() => true),
1201
- // Default: only authenticated users can view submissions
1202
- read: config.access?.read || (({ req }) => !!req.user)
1203
- }
1204
- };
1205
- }
1206
- function applyFieldDefaults(field) {
1207
- const label = field.label || (field.name ? toTitleCase(field.name.replace(/([A-Z])/g, " $1").trim()) : "");
1208
- return {
1209
- ...field,
1210
- label,
1211
- required: field.required ?? false
1212
- };
1213
- }
1214
- function createFormConfig(slug, fields, options) {
1215
- return applyFormDefaults({
1216
- slug,
1217
- fields,
1218
- ...options
1219
- });
1220
- }
1221
-
1222
1395
  // src/config/field-helpers.ts
1223
1396
  var option = (label, value) => ({
1224
1397
  label,
@@ -1277,184 +1450,6 @@ var hidden = (config) => ({
1277
1450
  type: "hidden"
1278
1451
  });
1279
1452
 
1280
- // src/utils/evaluate-conditions.ts
1281
- function isEmptyValue(value) {
1282
- if (value === void 0 || value === null) {
1283
- return true;
1284
- }
1285
- if (typeof value === "string" && value.trim() === "") {
1286
- return true;
1287
- }
1288
- if (Array.isArray(value) && value.length === 0) {
1289
- return true;
1290
- }
1291
- return false;
1292
- }
1293
- function toNumber(value) {
1294
- if (typeof value === "number") {
1295
- return value;
1296
- }
1297
- if (typeof value === "string") {
1298
- const trimmed = value.trim();
1299
- if (trimmed === "") {
1300
- return NaN;
1301
- }
1302
- return Number(trimmed);
1303
- }
1304
- return NaN;
1305
- }
1306
- function toString(value) {
1307
- if (value === void 0 || value === null) {
1308
- return "";
1309
- }
1310
- if (typeof value === "string") {
1311
- return value;
1312
- }
1313
- if (Array.isArray(value)) {
1314
- return value.join(", ");
1315
- }
1316
- if (typeof value === "object") {
1317
- return JSON.stringify(value);
1318
- }
1319
- return String(value);
1320
- }
1321
- function compareValues(operator, fieldValue, conditionValue) {
1322
- switch (operator) {
1323
- case "equals": {
1324
- if (Array.isArray(fieldValue)) {
1325
- if (Array.isArray(conditionValue)) {
1326
- if (fieldValue.length !== conditionValue.length) {
1327
- return false;
1328
- }
1329
- return fieldValue.every((v, i) => v === conditionValue[i]);
1330
- }
1331
- return fieldValue.includes(conditionValue);
1332
- }
1333
- return toString(fieldValue) === toString(conditionValue);
1334
- }
1335
- case "notEquals": {
1336
- if (Array.isArray(fieldValue)) {
1337
- if (Array.isArray(conditionValue)) {
1338
- if (fieldValue.length !== conditionValue.length) {
1339
- return true;
1340
- }
1341
- return !fieldValue.every((v, i) => v === conditionValue[i]);
1342
- }
1343
- return !fieldValue.includes(conditionValue);
1344
- }
1345
- return toString(fieldValue) !== toString(conditionValue);
1346
- }
1347
- case "contains": {
1348
- const fieldStr = toString(fieldValue).toLowerCase();
1349
- const conditionStr = toString(conditionValue).toLowerCase();
1350
- if (Array.isArray(fieldValue)) {
1351
- return fieldValue.some(
1352
- (v) => toString(v).toLowerCase().includes(conditionStr)
1353
- );
1354
- }
1355
- return fieldStr.includes(conditionStr);
1356
- }
1357
- case "isEmpty": {
1358
- return isEmptyValue(fieldValue);
1359
- }
1360
- case "isNotEmpty": {
1361
- return !isEmptyValue(fieldValue);
1362
- }
1363
- case "greaterThan": {
1364
- const fieldNum = toNumber(fieldValue);
1365
- const conditionNum = toNumber(conditionValue);
1366
- if (!Number.isNaN(fieldNum) && !Number.isNaN(conditionNum)) {
1367
- return fieldNum > conditionNum;
1368
- }
1369
- return toString(fieldValue) > toString(conditionValue);
1370
- }
1371
- case "lessThan": {
1372
- const fieldNum = toNumber(fieldValue);
1373
- const conditionNum = toNumber(conditionValue);
1374
- if (!Number.isNaN(fieldNum) && !Number.isNaN(conditionNum)) {
1375
- return fieldNum < conditionNum;
1376
- }
1377
- return toString(fieldValue) < toString(conditionValue);
1378
- }
1379
- default: {
1380
- console.warn(
1381
- `[evaluateConditions] Unknown comparison operator: ${String(operator)}`
1382
- );
1383
- return false;
1384
- }
1385
- }
1386
- }
1387
- function evaluateSingleCondition(condition, formData) {
1388
- const { field, comparison, value: conditionValue } = condition;
1389
- const fieldValue = getNestedValue(formData, field);
1390
- return compareValues(comparison, fieldValue, conditionValue);
1391
- }
1392
- function getNestedValue(obj, path) {
1393
- if (!path) {
1394
- return void 0;
1395
- }
1396
- if (!path.includes(".")) {
1397
- return obj[path];
1398
- }
1399
- const parts = path.split(".");
1400
- let current = obj;
1401
- for (const part of parts) {
1402
- if (current === void 0 || current === null) {
1403
- return void 0;
1404
- }
1405
- if (typeof current !== "object") {
1406
- return void 0;
1407
- }
1408
- current = current[part];
1409
- }
1410
- return current;
1411
- }
1412
- function evaluateConditions(logic, formData) {
1413
- if (!logic || !logic.enabled) {
1414
- return true;
1415
- }
1416
- const { action, operator, conditions } = logic;
1417
- if (!conditions || conditions.length === 0) {
1418
- return true;
1419
- }
1420
- const results = conditions.map(
1421
- (condition) => evaluateSingleCondition(condition, formData)
1422
- );
1423
- let conditionsMet;
1424
- if (operator === "OR") {
1425
- conditionsMet = results.some((result) => result);
1426
- } else {
1427
- conditionsMet = results.every((result) => result);
1428
- }
1429
- if (action === "hide") {
1430
- return !conditionsMet;
1431
- }
1432
- return conditionsMet;
1433
- }
1434
- function isValidComparisonOperator(operator) {
1435
- const validOperators = [
1436
- "equals",
1437
- "notEquals",
1438
- "contains",
1439
- "isEmpty",
1440
- "isNotEmpty",
1441
- "greaterThan",
1442
- "lessThan"
1443
- ];
1444
- return validOperators.includes(operator);
1445
- }
1446
- function getSupportedComparisonOperators() {
1447
- return [
1448
- "equals",
1449
- "notEquals",
1450
- "contains",
1451
- "isEmpty",
1452
- "isNotEmpty",
1453
- "greaterThan",
1454
- "lessThan"
1455
- ];
1456
- }
1457
-
1458
1453
  // src/utils/generate-schema.ts
1459
1454
  import { z } from "zod";
1460
1455
  function generateZodSchema(fields) {
@@ -1537,8 +1532,7 @@ function generateEmailSchema(field) {
1537
1532
  }
1538
1533
  function generateNumberSchema(field) {
1539
1534
  let schema = z.number({
1540
- required_error: field.validation?.errorMessage || "This field is required",
1541
- invalid_type_error: "Must be a number"
1535
+ error: (issue) => issue.input === void 0 ? field.validation?.errorMessage || "This field is required" : "Must be a number"
1542
1536
  });
1543
1537
  if (field.validation?.min !== void 0) {
1544
1538
  schema = schema.min(
@@ -1611,9 +1605,7 @@ function generateSelectSchema(field) {
1611
1605
  return schema2.optional();
1612
1606
  }
1613
1607
  const schema = z.enum(validValues, {
1614
- errorMap: () => ({
1615
- message: field.validation?.errorMessage || "Please select a valid option"
1616
- })
1608
+ error: () => field.validation?.errorMessage || "Please select a valid option"
1617
1609
  });
1618
1610
  if (field.required) {
1619
1611
  return schema;
@@ -1632,9 +1624,7 @@ function generateCheckboxSchema(field) {
1632
1624
  function generateRadioSchema(field) {
1633
1625
  const validValues = field.options.map((opt) => opt.value);
1634
1626
  const schema = z.enum(validValues, {
1635
- errorMap: () => ({
1636
- message: field.validation?.errorMessage || "Please select an option"
1637
- })
1627
+ error: () => field.validation?.errorMessage || "Please select an option"
1638
1628
  });
1639
1629
  if (field.required) {
1640
1630
  return schema;
@@ -1762,7 +1752,7 @@ function getValidationErrors(result) {
1762
1752
  return {};
1763
1753
  }
1764
1754
  const errors = {};
1765
- for (const error of result.error.errors) {
1755
+ for (const error of result.error.issues) {
1766
1756
  const path = error.path.join(".");
1767
1757
  if (!errors[path]) {
1768
1758
  errors[path] = error.message;
@@ -1771,203 +1761,6 @@ function getValidationErrors(result) {
1771
1761
  return errors;
1772
1762
  }
1773
1763
 
1774
- // src/utils/export-formats.ts
1775
- var UTF8_BOM = "\uFEFF";
1776
- function safeString(value) {
1777
- if (typeof value === "object" && value !== null) {
1778
- return JSON.stringify(value);
1779
- }
1780
- return String(value);
1781
- }
1782
- function formatExportValue(value, field) {
1783
- if (value === null || value === void 0) {
1784
- return "";
1785
- }
1786
- if (field) {
1787
- switch (field.type) {
1788
- case "checkbox":
1789
- return value ? "Yes" : "No";
1790
- case "select":
1791
- if (Array.isArray(value)) {
1792
- return value.join(", ");
1793
- }
1794
- return safeString(value);
1795
- case "file":
1796
- if (Array.isArray(value)) {
1797
- return value.map((v) => safeString(v)).join(", ");
1798
- }
1799
- return safeString(value);
1800
- case "date":
1801
- if (value instanceof Date) {
1802
- return value.toISOString().split("T")[0];
1803
- }
1804
- if (typeof value === "string") {
1805
- const parsed = new Date(value);
1806
- if (!isNaN(parsed.getTime())) {
1807
- return parsed.toISOString().split("T")[0];
1808
- }
1809
- }
1810
- return safeString(value);
1811
- case "time":
1812
- return safeString(value);
1813
- case "hidden":
1814
- return safeString(value);
1815
- default:
1816
- break;
1817
- }
1818
- }
1819
- if (Array.isArray(value)) {
1820
- return value.join(", ");
1821
- }
1822
- if (typeof value === "boolean") {
1823
- return value ? "Yes" : "No";
1824
- }
1825
- if (value instanceof Date) {
1826
- return value.toISOString();
1827
- }
1828
- return safeString(value);
1829
- }
1830
- function formatDate(date3, format = "iso") {
1831
- if (!date3) return "";
1832
- const d = typeof date3 === "string" ? new Date(date3) : date3;
1833
- if (isNaN(d.getTime())) {
1834
- return "";
1835
- }
1836
- return format === "iso" ? d.toISOString() : d.toLocaleString();
1837
- }
1838
- function escapeCSVValue(value, delimiter = ",") {
1839
- const needsQuoting = value.includes(delimiter) || value.includes('"') || value.includes("\n") || value.includes("\r");
1840
- if (!needsQuoting) {
1841
- return value;
1842
- }
1843
- const escaped = value.replace(/"/g, '""');
1844
- return `"${escaped}"`;
1845
- }
1846
- function exportToCSV(submissions, form, options = {}) {
1847
- const {
1848
- includeMetadata = true,
1849
- delimiter = ",",
1850
- includeBOM = true,
1851
- dateFormat = "iso"
1852
- } = options;
1853
- const exportableFields = form.fields;
1854
- const headers = [];
1855
- for (const field of exportableFields) {
1856
- headers.push(field.label);
1857
- }
1858
- if (includeMetadata) {
1859
- headers.push("ID", "Status", "Submitted At", "IP Address", "User Agent");
1860
- }
1861
- const rows = [];
1862
- for (const submission of submissions) {
1863
- const row = [];
1864
- const data = submission.data;
1865
- for (const field of exportableFields) {
1866
- const value = data[field.name];
1867
- const formatted = formatExportValue(value, field);
1868
- row.push(escapeCSVValue(formatted, delimiter));
1869
- }
1870
- if (includeMetadata) {
1871
- row.push(escapeCSVValue(submission.id, delimiter));
1872
- row.push(escapeCSVValue(submission.status, delimiter));
1873
- row.push(
1874
- escapeCSVValue(
1875
- formatDate(submission.submittedAt, dateFormat),
1876
- delimiter
1877
- )
1878
- );
1879
- row.push(escapeCSVValue(submission.ipAddress || "", delimiter));
1880
- row.push(escapeCSVValue(submission.userAgent || "", delimiter));
1881
- }
1882
- rows.push(row);
1883
- }
1884
- const escapedHeaders = headers.map((h) => escapeCSVValue(h, delimiter));
1885
- const lines = [
1886
- escapedHeaders.join(delimiter),
1887
- ...rows.map((row) => row.join(delimiter))
1888
- ];
1889
- const csvContent = lines.join("\r\n");
1890
- return includeBOM ? UTF8_BOM + csvContent : csvContent;
1891
- }
1892
- function exportToJSON(submissions, form, options = {}) {
1893
- const {
1894
- includeMetadata = true,
1895
- includeFormDefinition = true,
1896
- indent = 2
1897
- } = options;
1898
- const exportableFields = form.fields;
1899
- const formDefinition = includeFormDefinition ? {
1900
- id: form.id,
1901
- name: form.name,
1902
- slug: form.slug,
1903
- fields: exportableFields.map((f) => ({
1904
- name: f.name,
1905
- label: f.label,
1906
- type: f.type
1907
- }))
1908
- } : void 0;
1909
- const exportedSubmissions = submissions.map((submission) => {
1910
- const data = submission.data;
1911
- const fieldData = {};
1912
- for (const field of exportableFields) {
1913
- const value = data[field.name];
1914
- fieldData[field.name] = value;
1915
- }
1916
- if (includeMetadata) {
1917
- return {
1918
- ...fieldData,
1919
- _metadata: {
1920
- id: submission.id,
1921
- status: submission.status,
1922
- submittedAt: submission.submittedAt,
1923
- ipAddress: submission.ipAddress || null,
1924
- userAgent: submission.userAgent || null,
1925
- createdAt: submission.createdAt,
1926
- updatedAt: submission.updatedAt
1927
- }
1928
- };
1929
- }
1930
- return fieldData;
1931
- });
1932
- const exportData = {
1933
- exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
1934
- totalSubmissions: submissions.length,
1935
- submissions: exportedSubmissions
1936
- };
1937
- if (formDefinition) {
1938
- exportData.form = formDefinition;
1939
- }
1940
- return JSON.stringify(exportData, null, indent);
1941
- }
1942
- function downloadFile(content, filename, mimeType) {
1943
- if (typeof window === "undefined" || typeof document === "undefined") {
1944
- throw new Error("downloadFile is only available in browser environments");
1945
- }
1946
- const blob = new Blob([content], { type: mimeType });
1947
- const url2 = URL.createObjectURL(blob);
1948
- const link = document.createElement("a");
1949
- link.href = url2;
1950
- link.download = filename;
1951
- document.body.appendChild(link);
1952
- link.click();
1953
- document.body.removeChild(link);
1954
- URL.revokeObjectURL(url2);
1955
- }
1956
- function generateExportFilename(formSlug, format) {
1957
- const date3 = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
1958
- return `${formSlug}-submissions-${date3}.${format}`;
1959
- }
1960
- function exportAndDownload(submissions, form, format, options) {
1961
- const filename = generateExportFilename(form.slug, format);
1962
- if (format === "csv") {
1963
- const content = exportToCSV(submissions, form, options);
1964
- downloadFile(content, filename, "text/csv;charset=utf-8");
1965
- } else {
1966
- const content = exportToJSON(submissions, form, options);
1967
- downloadFile(content, filename, "application/json");
1968
- }
1969
- }
1970
-
1971
1764
  // src/handlers/spam-detection.ts
1972
1765
  var rateLimitStore = /* @__PURE__ */ new Map();
1973
1766
  async function checkSpam(options) {
@@ -2107,46 +1900,95 @@ async function submitForm(options, context) {
2107
1900
  error: "This form is not currently accepting submissions"
2108
1901
  };
2109
1902
  }
2110
- const transformedData = transformFormData(data, form.fields);
2111
- const schema = generateZodSchema(form.fields);
2112
- const validationResult = schema.safeParse(transformedData);
2113
- if (!validationResult.success) {
2114
- const validationErrors = getValidationErrors(validationResult);
2115
- logger.debug?.("Form validation failed", {
2116
- formSlug,
2117
- errors: validationErrors
2118
- });
2119
- return {
2120
- success: false,
2121
- error: "Validation failed",
2122
- validationErrors
2123
- };
1903
+ const settings = normalizeFormSettings(form.settings);
1904
+ if (settings.allowMultipleSubmissions === false && !metadata?.ipAddress) {
1905
+ logger.warn?.(
1906
+ "Single-submission form received a submission without an IP \u2014 the restriction cannot apply",
1907
+ { formSlug }
1908
+ );
2124
1909
  }
2125
- sanitizeSubmissionData(validationResult.data, form.fields);
1910
+ if (settings.allowMultipleSubmissions === false && metadata?.ipAddress) {
1911
+ const existing = await collections.count(
1912
+ pluginConfig.formSubmissionOverrides.slug,
1913
+ {
1914
+ where: {
1915
+ form: { equals: form.id },
1916
+ ipAddress: { equals: metadata.ipAddress },
1917
+ status: { not_equals: "spam" }
1918
+ }
1919
+ },
1920
+ { as: "system" }
1921
+ );
1922
+ if (existing > 0) {
1923
+ logger.info?.("Repeat submission rejected (single-submission form)", {
1924
+ formSlug,
1925
+ ipAddress: metadata.ipAddress
1926
+ });
1927
+ return {
1928
+ success: false,
1929
+ error: "You have already submitted this form."
1930
+ };
1931
+ }
1932
+ }
1933
+ const declaredFieldNames = new Set(form.fields.map((field) => field.name));
1934
+ const undeclaredData = Object.fromEntries(
1935
+ Object.entries(data).filter(([key]) => !declaredFieldNames.has(key))
1936
+ );
2126
1937
  const spamResult = await checkSpam({
2127
- data: transformedData,
1938
+ data: undeclaredData,
2128
1939
  ipAddress: metadata?.ipAddress,
2129
1940
  formSlug,
2130
1941
  config: {
2131
- honeypot: pluginConfig.spamProtection.honeypot,
1942
+ // Per-form overrides win where set; blank inherits the plugin config.
1943
+ honeypot: settings.honeypotEnabled ?? pluginConfig.spamProtection.honeypot,
2132
1944
  rateLimit: pluginConfig.spamProtection.rateLimit,
2133
- recaptcha: pluginConfig.spamProtection.recaptcha
1945
+ recaptcha: {
1946
+ ...pluginConfig.spamProtection.recaptcha,
1947
+ enabled: settings.captchaEnabled ?? pluginConfig.spamProtection.recaptcha?.enabled ?? false
1948
+ }
2134
1949
  }
2135
1950
  });
2136
- if (spamResult.isSpam) {
2137
- logger.info?.("Spam submission detected and rejected", {
1951
+ if (spamResult.isSpam && spamResult.reason === "rate_limit") {
1952
+ logger.info?.("Spam submission rejected (rate limit)", {
1953
+ formSlug,
1954
+ ipAddress: metadata?.ipAddress
1955
+ });
1956
+ return { success: true };
1957
+ }
1958
+ const isContentSpam = spamResult.isSpam;
1959
+ const transformedData = transformFormData(data, form.fields);
1960
+ let storedData;
1961
+ if (isContentSpam) {
1962
+ logger.info?.("Spam submission detected \u2014 storing flagged", {
2138
1963
  formSlug,
2139
1964
  reason: spamResult.reason,
2140
1965
  ipAddress: metadata?.ipAddress
2141
1966
  });
2142
- return {
2143
- success: true
2144
- };
1967
+ sanitizeSubmissionData(transformedData, form.fields);
1968
+ storedData = transformedData;
1969
+ } else {
1970
+ const schema = generateZodSchema(form.fields);
1971
+ const validationResult = schema.safeParse(transformedData);
1972
+ if (!validationResult.success) {
1973
+ const validationErrors = getValidationErrors(validationResult);
1974
+ logger.debug?.("Form validation failed", {
1975
+ formSlug,
1976
+ errors: validationErrors
1977
+ });
1978
+ return {
1979
+ success: false,
1980
+ error: "Validation failed",
1981
+ validationErrors
1982
+ };
1983
+ }
1984
+ sanitizeSubmissionData(validationResult.data, form.fields);
1985
+ storedData = validationResult.data;
2145
1986
  }
2146
1987
  const submissionData = {
2147
1988
  form: form.id,
2148
- data: validationResult.data,
2149
- status: "new",
1989
+ data: storedData,
1990
+ status: isContentSpam ? "spam" : "new",
1991
+ spamReason: isContentSpam ? spamResult.reason ?? null : null,
2150
1992
  ipAddress: metadata?.ipAddress || null,
2151
1993
  userAgent: metadata?.userAgent || null,
2152
1994
  submittedAt: /* @__PURE__ */ new Date()
@@ -2160,9 +2002,13 @@ async function submitForm(options, context) {
2160
2002
  );
2161
2003
  logger.info?.("Form submission created successfully", {
2162
2004
  formSlug,
2163
- submissionId: submission.id
2005
+ submissionId: submission.id,
2006
+ flaggedAsSpam: isContentSpam
2164
2007
  });
2165
2008
  const redirect = determineRedirectUrl(form);
2009
+ if (isContentSpam) {
2010
+ return { success: true, redirect };
2011
+ }
2166
2012
  return {
2167
2013
  success: true,
2168
2014
  submission,