@nextlyhq/plugin-form-builder 0.0.2-alpha.5 → 0.0.2-alpha.51

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 +12 -5
  2. package/dist/admin/index.d.ts +123 -331
  3. package/dist/admin/index.js +3824 -3420
  4. package/dist/admin/index.js.map +1 -1
  5. package/dist/chunk-WVI7N3QO.js +559 -0
  6. package/dist/chunk-WVI7N3QO.js.map +1 -0
  7. package/dist/index.d.ts +12 -11
  8. package/dist/index.js +592 -664
  9. package/dist/index.js.map +1 -1
  10. package/dist/{index-C0kBvYjm.d.ts → types-r8uBeB0u.d.ts} +126 -125
  11. package/package.json +32 -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-MMOXXL7P.js +0 -138
  17. package/dist/EmailFieldEditor-MMOXXL7P.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 -934
  29. package/dist/admin/index.css.map +0 -1
  30. package/dist/chunk-ABNZFTQK.js +0 -586
  31. package/dist/chunk-ABNZFTQK.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 -1150
  38. package/dist/styles/submissions-filter.css +0 -75
package/dist/index.js CHANGED
@@ -1,7 +1,39 @@
1
+ import {
2
+ BUILT_IN_FORM_FIELD_TYPES,
3
+ DEFAULT_FORM_SETTINGS,
4
+ DEFAULT_NOTIFICATION_SETTINGS,
5
+ applyFieldDefaults,
6
+ applyFormDefaults,
7
+ createFormConfig,
8
+ downloadFile,
9
+ evaluateConditions,
10
+ evaluateSingleCondition,
11
+ exportAndDownload,
12
+ exportToCSV,
13
+ exportToJSON,
14
+ formatExportValue,
15
+ generateExportFilename,
16
+ getSupportedComparisonOperators,
17
+ isCheckboxFormField,
18
+ isEmailFormField,
19
+ isFileFormField,
20
+ isFormField,
21
+ isFormFieldType,
22
+ isKnownFormField,
23
+ isSelectFormField,
24
+ isTextFormField,
25
+ isValidComparisonOperator,
26
+ normalizeFormSettings,
27
+ pluralize,
28
+ toTitleCase
29
+ } from "./chunk-WVI7N3QO.js";
30
+
1
31
  // src/plugin.ts
2
- import { getCollectionsHandler } from "nextly/runtime";
32
+ import { createRequire } from "module";
33
+ import { definePlugin } from "@nextlyhq/plugin-sdk";
3
34
 
4
35
  // src/collections/forms.ts
36
+ import { NextlyError } from "@nextlyhq/plugin-sdk";
5
37
  import {
6
38
  text,
7
39
  textarea,
@@ -90,12 +122,18 @@ function formsCollection(pluginConfig) {
90
122
  description: "Allow the same user/IP to submit this form multiple times"
91
123
  }
92
124
  }),
125
+ checkbox({
126
+ name: "honeypotEnabled",
127
+ label: "Honeypot",
128
+ admin: {
129
+ description: "Per-form honeypot override; unset inherits the plugin default"
130
+ }
131
+ }),
93
132
  checkbox({
94
133
  name: "captchaEnabled",
95
134
  label: "Enable reCAPTCHA",
96
- defaultValue: false,
97
135
  admin: {
98
- description: "Protect this form with Google reCAPTCHA v3"
136
+ description: "Per-form reCAPTCHA override; unset inherits the plugin default"
99
137
  }
100
138
  }),
101
139
  text({
@@ -163,10 +201,10 @@ function formsCollection(pluginConfig) {
163
201
  // ============================================================
164
202
  json({
165
203
  name: "notifications",
166
- label: "Email Integrations",
204
+ label: "Notifications",
167
205
  defaultValue: [],
168
206
  admin: {
169
- description: "Email notification integrations. Managed by the Form Builder UI."
207
+ description: "Email notification rules. Managed by the Form Builder UI."
170
208
  }
171
209
  }),
172
210
  // ============================================================
@@ -247,18 +285,17 @@ function formsCollection(pluginConfig) {
247
285
  if (operation === "create" && data && !data.slug && data.name) {
248
286
  data.slug = String(data.name).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
249
287
  }
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;
288
+ const setsFields = operation === "create" || data?.fields !== void 0;
289
+ if (data && setsFields && (data.fields === void 0 || Array.isArray(data.fields) && data.fields.length === 0)) {
290
+ throw NextlyError.validation({
291
+ errors: [
292
+ {
293
+ path: "fields",
294
+ code: "REQUIRED",
295
+ message: "Form must have at least one field."
296
+ }
297
+ ]
298
+ });
262
299
  }
263
300
  return data;
264
301
  }
@@ -317,12 +354,23 @@ function submissionsCollection(pluginConfig) {
317
354
  options: [
318
355
  { label: "New", value: "new" },
319
356
  { label: "Read", value: "read" },
320
- { label: "Archived", value: "archived" }
357
+ { label: "Archived", value: "archived" },
358
+ // Spam is stored and flagged, never silently dropped: a false
359
+ // positive stays reviewable and recoverable ("Not spam" = back to new).
360
+ { label: "Spam", value: "spam" }
321
361
  ],
322
362
  admin: {
323
363
  description: "Track whether this submission has been reviewed"
324
364
  }
325
365
  }),
366
+ text2({
367
+ name: "spamReason",
368
+ label: "Spam Reason",
369
+ admin: {
370
+ readOnly: true,
371
+ description: "Which spam check flagged this submission (empty for clean submissions)"
372
+ }
373
+ }),
326
374
  // ============================================================
327
375
  // Internal Notes
328
376
  // ============================================================
@@ -360,6 +408,24 @@ function submissionsCollection(pluginConfig) {
360
408
  readOnly: true,
361
409
  description: "When the form was submitted"
362
410
  }
411
+ }),
412
+ // Honesty stamps for admin edits of submitted data: a corrected
413
+ // submission must never be indistinguishable from what the visitor sent.
414
+ date({
415
+ name: "editedAt",
416
+ label: "Edited At",
417
+ admin: {
418
+ readOnly: true,
419
+ description: "When an admin last edited the submitted data"
420
+ }
421
+ }),
422
+ text2({
423
+ name: "editedBy",
424
+ label: "Edited By",
425
+ admin: {
426
+ readOnly: true,
427
+ description: "ID of the admin who last edited the submitted data"
428
+ }
363
429
  })
364
430
  ];
365
431
  let finalFields;
@@ -375,16 +441,35 @@ function submissionsCollection(pluginConfig) {
375
441
  labels,
376
442
  fields: finalFields,
377
443
  timestamps: true,
444
+ // Submissions carry visitor-entered answers plus ipAddress/userAgent, so the
445
+ // default `entry.*` events stay suppressed — otherwise every submission would
446
+ // be delivered to any endpoint subscribed to `entry.created` or `*`. Instead
447
+ // emit a curated `form.submission.created` carrying ONLY safe metadata (which
448
+ // form, when, and the status) — never the answers, IP, or user agent.
449
+ webhooks: {
450
+ record: false,
451
+ emit: {
452
+ event: "form.submission.created",
453
+ fields: ["form", "submittedAt", "status"]
454
+ }
455
+ },
378
456
  admin: {
379
457
  isPlugin: true,
380
458
  group: "Forms",
381
459
  order: 51,
382
460
  useAsTitle: "id",
383
461
  description: "View and manage form submissions",
384
- // Custom components for submissions list
462
+ // Submissions are created by visitors submitting forms — a hand-typed
463
+ // "New Submission" would be fiction, so the admin never offers one.
464
+ disableCreate: true,
385
465
  components: {
386
- // Filter dropdown to filter submissions by form
387
- BeforeListTable: "@nextlyhq/plugin-form-builder/admin#SubmissionsFilter"
466
+ // The full submissions experience (per-form columns, status tabs,
467
+ // drawer detail, export) replaces the generic list.
468
+ views: {
469
+ List: {
470
+ Component: "@nextlyhq/plugin-form-builder/admin#SubmissionsView"
471
+ }
472
+ }
388
473
  },
389
474
  ...overrides.admin
390
475
  },
@@ -401,7 +486,9 @@ function submissionsCollection(pluginConfig) {
401
486
  delete: accessOverrides?.delete ?? (({ roles }) => roles.includes("admin") || roles.includes("super-admin"))
402
487
  },
403
488
  hooks: {
404
- // Auto-set submittedAt timestamp on creation
489
+ // Auto-set submittedAt timestamp on creation. (The edit stamp for
490
+ // updates is registered in plugin.ts init — direct registry hooks are
491
+ // the path that runs for every API surface.)
405
492
  beforeValidate: [
406
493
  (context) => {
407
494
  const { data, operation } = context;
@@ -424,9 +511,12 @@ function submissionsCollection(pluginConfig) {
424
511
  }
425
512
 
426
513
  // src/plugin.ts
514
+ var { version: PLUGIN_VERSION } = createRequire(import.meta.url)(
515
+ "../package.json"
516
+ );
427
517
  function resolveConfig(options) {
428
- const formOverrides = options.formOverrides || options.collections?.forms || {};
429
- const submissionOverrides = options.formSubmissionOverrides || options.collections?.submissions || {};
518
+ const formOverrides = options.formOverrides || {};
519
+ const submissionOverrides = options.formSubmissionOverrides || {};
430
520
  return {
431
521
  formOverrides: {
432
522
  ...formOverrides,
@@ -495,41 +585,206 @@ function formBuilder(options = {}) {
495
585
  const resolvedConfig = resolveConfig(options);
496
586
  const formsCol = formsCollection(resolvedConfig);
497
587
  const submissionsCol = submissionsCollection(resolvedConfig);
498
- const plugin = {
588
+ const plugin = definePlugin({
499
589
  name: "@nextlyhq/plugin-form-builder",
500
- version: "0.0.8",
501
- collections: [formsCol, submissionsCol],
590
+ version: PLUGIN_VERSION,
591
+ nextly: ">=0.0.2-alpha.21",
592
+ // Identity metadata for the admin plugins page, mirroring package.json.
593
+ author: "Nextly",
594
+ homepage: "https://nextlyhq.com",
595
+ repository: "https://github.com/nextlyhq/nextly",
596
+ license: "MIT",
597
+ category: "forms",
598
+ tags: ["forms", "submissions", "email-notifications"],
599
+ // Declarative schema: the merged pipeline folds these into the
600
+ // app schema — no manual `setup()` append needed. Just register the plugin.
601
+ contributes: {
602
+ collections: [formsCol, submissionsCol],
603
+ // Custom permission — gates submission export beyond CRUD. The
604
+ // canonical example third-party plugin authors copy.
605
+ permissions: [
606
+ {
607
+ action: "export",
608
+ resource: "submissions",
609
+ label: "Export Submissions",
610
+ description: "Export form submissions as CSV/JSON",
611
+ // No `group`: the admin already files this under the plugin that
612
+ // declared it, so naming the plugin again would nest it inside
613
+ // itself. `group` is for a plugin with enough permissions to sort
614
+ // its own into headings, which one is not.
615
+ //
616
+ // `danger` because the point of the permission is to take
617
+ // submissions — names, emails, whatever a form asked for — out of
618
+ // the site in a file.
619
+ danger: true
620
+ }
621
+ ],
622
+ // HTTP route — exported at
623
+ // /api/plugins/@nextlyhq/plugin-form-builder/submissions/export. Secure by
624
+ // default: gated by the custom `export-submissions` permission. Reads
625
+ // via the secure-by-default service path as the authed user and
626
+ // resolves its OWN slug through `ctx.self`. The canonical
627
+ // contributes.routes example for third-party authors.
628
+ routes: [
629
+ {
630
+ method: "GET",
631
+ path: "/submissions/export",
632
+ requiredPermission: "export-submissions",
633
+ handler: async (req, ctx) => {
634
+ const declaredSlug = resolvedConfig.formSubmissionOverrides.slug;
635
+ const slug = ctx.self.collections[declaredSlug] ?? declaredSlug;
636
+ const url2 = new URL(req.url);
637
+ const format = url2.searchParams.get("format") ?? "json";
638
+ const formId = url2.searchParams.get("form");
639
+ const status = url2.searchParams.get("status");
640
+ const where = {};
641
+ if (formId) where.form = { equals: formId };
642
+ if (status) where.status = { equals: status };
643
+ else where.status = { not_equals: "spam" };
644
+ const opts = { as: "user", user: ctx.user ?? void 0 };
645
+ let form;
646
+ if (format === "csv") {
647
+ if (!formId) {
648
+ return Response.json(
649
+ {
650
+ error: {
651
+ code: "VALIDATION_ERROR",
652
+ message: "CSV export requires a form parameter."
653
+ }
654
+ },
655
+ { status: 400 }
656
+ );
657
+ }
658
+ const formsSlugDeclared = resolvedConfig.formOverrides.slug;
659
+ const formsSlug = ctx.self.collections[formsSlugDeclared] ?? formsSlugDeclared;
660
+ const formResult = await ctx.services.collections.listEntries(
661
+ formsSlug,
662
+ { where: { id: { equals: formId } } },
663
+ opts
664
+ );
665
+ form = formResult.data[0];
666
+ if (!form) {
667
+ return Response.json(
668
+ { error: { code: "NOT_FOUND", message: "Form not found." } },
669
+ { status: 404 }
670
+ );
671
+ }
672
+ }
673
+ const MAX_EXPORT_ROWS = 5e4;
674
+ const items = [];
675
+ const pageSize = 200;
676
+ let truncated = false;
677
+ for (let page = 1; ; page += 1) {
678
+ const result = await ctx.services.collections.listEntries(
679
+ slug,
680
+ { where, pagination: { limit: pageSize, page } },
681
+ opts
682
+ );
683
+ items.push(...result.data);
684
+ const pageWasFull = result.data.length === pageSize;
685
+ if (items.length >= MAX_EXPORT_ROWS) {
686
+ truncated = pageWasFull || items.length > MAX_EXPORT_ROWS;
687
+ items.length = MAX_EXPORT_ROWS;
688
+ break;
689
+ }
690
+ if (!pageWasFull) break;
691
+ }
692
+ const truncationHeaders = truncated ? { "X-Export-Truncated": "true" } : {};
693
+ if (format !== "csv" || !form) {
694
+ return Response.json({ items }, { headers: truncationHeaders });
695
+ }
696
+ const csv = exportToCSV(
697
+ items,
698
+ form
699
+ );
700
+ const filename = generateExportFilename(
701
+ typeof form.slug === "string" ? form.slug : "form",
702
+ "csv"
703
+ );
704
+ return new Response(csv, {
705
+ headers: {
706
+ "Content-Type": "text/csv; charset=utf-8",
707
+ "Content-Disposition": `attachment; filename="${filename}"`,
708
+ ...truncationHeaders
709
+ }
710
+ });
711
+ }
712
+ },
713
+ {
714
+ // The builder UI reads the host's resolved field enable/disable map
715
+ // from here, so the plugin option actually gates the type picker.
716
+ // Options resolve server-side only; this is the one channel the
717
+ // admin client has to them.
718
+ method: "GET",
719
+ path: "/builder-config",
720
+ // Derived from the forms slug like the menu entry: with an
721
+ // overridden slug, users hold read-<slug>, not read-forms.
722
+ requiredPermission: `read-${resolvedConfig.formOverrides.slug}`,
723
+ handler: (_req, ctx) => Promise.resolve(
724
+ Response.json({
725
+ fields: resolvedConfig.fields,
726
+ // Notification defaults the builder surfaces: defaultToEmail
727
+ // seeds a new form's first rule, defaultFrom renders as the
728
+ // inherited sender placeholder.
729
+ notifications: {
730
+ defaultFrom: resolvedConfig.notifications.defaultFrom,
731
+ defaultToEmail: resolvedConfig.notifications.defaultToEmail
732
+ },
733
+ // Spam defaults the Settings tab surfaces, so per-form
734
+ // override selects can show what "inherit" resolves to.
735
+ spamProtection: {
736
+ honeypot: resolvedConfig.spamProtection.honeypot,
737
+ recaptchaEnabled: resolvedConfig.spamProtection.recaptcha?.enabled ?? false
738
+ },
739
+ // Runtime-resolved collection slugs (through ctx.self, so a
740
+ // framework .rename() is honored too) — admin components
741
+ // never hardcode "forms"/"form-submissions".
742
+ slugs: {
743
+ forms: ctx.self.collections[resolvedConfig.formOverrides.slug] ?? resolvedConfig.formOverrides.slug,
744
+ submissions: ctx.self.collections[resolvedConfig.formSubmissionOverrides.slug] ?? resolvedConfig.formSubmissionOverrides.slug
745
+ }
746
+ })
747
+ )
748
+ }
749
+ ]
750
+ },
751
+ // Forms is a first-class destination, not a plugin detail: standalone
752
+ // placement gives it its own main-rail icon after Media, and its
753
+ // sub-sidebar lists the plugin's collections (Forms, Submissions) —
754
+ // no separate menu contribution, so "Forms" exists exactly once.
755
+ // Hosts preferring the Plugins section override placement in config.
756
+ // (The old admin.settings.component that rendered a second full builder
757
+ // at /admin/plugins/<slug> is gone; the collection Edit-view override
758
+ // is the single FormBuilderView mount.)
502
759
  admin: {
760
+ placement: "standalone",
761
+ after: "media",
503
762
  order: 50,
763
+ appearance: { icon: "FileText", label: "Forms" },
504
764
  description: "Create and manage forms with submission tracking"
505
765
  },
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
766
  // -- Init ----------------------------------------------------------------
520
767
  // Registers an afterCreate hook on submissions to send email notifications.
521
768
  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", {
769
+ const declaredSubmissionsSlug = resolvedConfig.formSubmissionOverrides.slug;
770
+ const submissionSlug = nextly.self.collections[declaredSubmissionsSlug] ?? declaredSubmissionsSlug;
771
+ nextly.logger.info("Form Builder plugin initialized", {
528
772
  formsCollection: resolvedConfig.formOverrides.slug,
529
773
  submissionsCollection: submissionSlug,
530
774
  enabledFields: Object.entries(resolvedConfig.fields).filter(([, on]) => on).map(([name]) => name)
531
775
  });
532
776
  nextly.__formBuilderConfig = resolvedConfig;
777
+ if (resolvedConfig.beforeEmail) {
778
+ nextly.filters.add(
779
+ "form-builder.beforeEmail",
780
+ (emails, ctx) => resolvedConfig.beforeEmail({
781
+ emails,
782
+ // Boundary: loose runtime documents → the user's typed contract.
783
+ form: ctx.form,
784
+ submission: ctx.submission
785
+ })
786
+ );
787
+ }
533
788
  nextly.hooks.on(
534
789
  "afterCreate",
535
790
  submissionSlug,
@@ -537,8 +792,42 @@ function formBuilder(options = {}) {
537
792
  await handleSubmissionCreated(context, resolvedConfig, nextly);
538
793
  }
539
794
  );
795
+ nextly.hooks.on("beforeUpdate", submissionSlug, (context) => {
796
+ const ctx = context;
797
+ if (ctx.data && ctx.data.data !== void 0) {
798
+ ctx.data.editedAt = /* @__PURE__ */ new Date();
799
+ ctx.data.editedBy = ctx.user?.id ?? null;
800
+ }
801
+ return ctx.data;
802
+ });
803
+ const declaredFormsSlug = resolvedConfig.formOverrides.slug;
804
+ const formsSlug = nextly.self.collections[declaredFormsSlug] ?? declaredFormsSlug;
805
+ nextly.hooks.on("afterRead", formsSlug, async (context) => {
806
+ const data = context.data;
807
+ const records = Array.isArray(data) ? data : data ? [data] : [];
808
+ await Promise.all(
809
+ records.map(async (record) => {
810
+ const form = record;
811
+ if (typeof form.id !== "string") return;
812
+ try {
813
+ form.submissionCount = await nextly.services.collections.count(
814
+ submissionSlug,
815
+ {
816
+ where: {
817
+ form: { equals: form.id },
818
+ status: { not_equals: "spam" }
819
+ }
820
+ },
821
+ { as: "system" }
822
+ );
823
+ } catch {
824
+ form.submissionCount = 0;
825
+ }
826
+ })
827
+ );
828
+ });
540
829
  }
541
- };
830
+ });
542
831
  return {
543
832
  plugin,
544
833
  collections: [formsCol, submissionsCol],
@@ -574,9 +863,78 @@ function collectAttachmentInputs(fields, submittedData) {
574
863
  }
575
864
  return attachments;
576
865
  }
866
+ function parseJsonColumn(value) {
867
+ if (typeof value === "string") {
868
+ try {
869
+ const parsed = JSON.parse(value);
870
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
871
+ } catch {
872
+ return {};
873
+ }
874
+ }
875
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
876
+ }
877
+ function normalizeAddressList(addresses) {
878
+ if (!Array.isArray(addresses)) return void 0;
879
+ const cleaned = addresses.map((address) => typeof address === "string" ? address.trim() : "").filter(Boolean);
880
+ return cleaned.length > 0 ? cleaned : void 0;
881
+ }
882
+ function buildNotificationEmails(input) {
883
+ const { notifications, submittedData, formName, submissionId, defaultFrom } = input;
884
+ const seen = /* @__PURE__ */ new Set();
885
+ const emails = [];
886
+ const skipped = [];
887
+ for (const notification of notifications) {
888
+ if (!notification.enabled) continue;
889
+ if (!notification.templateSlug) {
890
+ skipped.push({ notificationId: notification.id, reason: "no-template" });
891
+ continue;
892
+ }
893
+ const key = notification.id || JSON.stringify(notification);
894
+ if (seen.has(key)) continue;
895
+ seen.add(key);
896
+ if (notification.condition && !evaluateSingleCondition(notification.condition, submittedData)) {
897
+ skipped.push({
898
+ notificationId: notification.id,
899
+ reason: "condition-unmet"
900
+ });
901
+ continue;
902
+ }
903
+ const to = (notification.recipientType === "field" ? resolveFieldRef(notification.to, submittedData) : notification.to).trim();
904
+ if (!to) {
905
+ skipped.push({
906
+ notificationId: notification.id,
907
+ reason: "empty-recipient"
908
+ });
909
+ continue;
910
+ }
911
+ const cc = normalizeAddressList(notification.cc);
912
+ const bcc = normalizeAddressList(notification.bcc);
913
+ const from = notification.senderEmail?.trim() || defaultFrom?.trim() || void 0;
914
+ const replyTo = notification.replyTo ? resolveFieldRef(notification.replyTo, submittedData).trim() || void 0 : void 0;
915
+ emails.push({
916
+ to,
917
+ templateSlug: notification.templateSlug,
918
+ variables: {
919
+ ...submittedData,
920
+ formName,
921
+ submissionId
922
+ },
923
+ providerId: notification.providerId,
924
+ from,
925
+ replyTo,
926
+ cc,
927
+ bcc,
928
+ notificationId: notification.id
929
+ });
930
+ }
931
+ return { emails, skipped };
932
+ }
577
933
  async function handleSubmissionCreated(context, config, nextly) {
934
+ if (!config.notifications.enabled) return;
578
935
  const submission = context.data;
579
936
  if (!submission) return;
937
+ if (submission.status === "spam") return;
580
938
  const rawFormId = submission.form;
581
939
  let formId = null;
582
940
  if (typeof rawFormId === "string") {
@@ -592,46 +950,59 @@ async function handleSubmissionCreated(context, config, nextly) {
592
950
  if (notifications.length === 0) return;
593
951
  const emailService = nextly.services.email;
594
952
  if (!emailService) return;
595
- const submittedData = submission.data ?? {};
953
+ const submittedData = parseJsonColumn(submission.data);
596
954
  const formFields = Array.isArray(form.fields) ? form.fields : [];
597
955
  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);
956
+ const { emails, skipped } = buildNotificationEmails({
957
+ notifications,
958
+ submittedData,
959
+ formName: form.name,
960
+ submissionId: submission.id,
961
+ defaultFrom: config.notifications.defaultFrom
962
+ });
963
+ for (const skip of skipped) {
964
+ if (skip.reason === "empty-recipient") {
965
+ nextly.logger.warn?.(
966
+ "Form Builder: empty recipient, skipping notification",
967
+ { notificationId: skip.notificationId, formSlug: form.slug }
968
+ );
969
+ } else {
970
+ nextly.logger.debug?.("Form Builder: notification skipped", {
971
+ notificationId: skip.notificationId,
972
+ formSlug: form.slug,
973
+ reason: skip.reason
974
+ });
975
+ }
976
+ }
977
+ if (emails.length === 0) return;
978
+ const finalEmails = await nextly.filters.apply("form-builder.beforeEmail", emails, { form, submission });
979
+ for (const email2 of finalEmails) {
604
980
  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
981
  await emailService.sendWithTemplate(
616
- notification.templateSlug,
617
- to,
618
- { ...submittedData, formName: form.name, submissionId: submission.id },
982
+ email2.templateSlug,
983
+ email2.to,
984
+ email2.variables,
619
985
  {
620
- providerId: notification.providerId,
621
- cc,
622
- bcc,
986
+ providerId: email2.providerId,
987
+ from: email2.from,
988
+ replyTo: email2.replyTo,
989
+ cc: email2.cc,
990
+ bcc: email2.bcc,
623
991
  attachments: fileAttachments.length > 0 ? fileAttachments : void 0
624
992
  }
625
993
  );
626
- nextly.infra.logger.info?.("Form Builder: notification sent", {
994
+ nextly.logger.info?.("Form Builder: notification sent", {
627
995
  formSlug: form.slug,
628
- to,
629
- templateSlug: notification.templateSlug,
996
+ to: email2.to,
997
+ templateSlug: email2.templateSlug,
998
+ notificationId: email2.notificationId,
630
999
  attachmentCount: fileAttachments.length
631
1000
  });
632
1001
  } catch (err) {
633
- nextly.infra.logger.error?.("Form Builder: notification failed", {
634
- notificationId: notification.id,
1002
+ nextly.logger.error?.("Form Builder: notification failed", {
1003
+ to: email2.to,
1004
+ templateSlug: email2.templateSlug,
1005
+ notificationId: email2.notificationId,
635
1006
  error: err instanceof Error ? err.message : String(err)
636
1007
  });
637
1008
  }
@@ -639,27 +1010,14 @@ async function handleSubmissionCreated(context, config, nextly) {
639
1010
  }
640
1011
  async function fetchParentForm(config, formId, nextly) {
641
1012
  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;
1013
+ const form = await nextly.services.collections.findEntryById(
1014
+ config.formOverrides.slug,
1015
+ formId,
1016
+ { as: "system" }
1017
+ );
1018
+ return form;
661
1019
  } catch (err) {
662
- nextly.infra.logger.error?.("Form Builder: failed to fetch form", {
1020
+ nextly.logger.error?.("Form Builder: failed to fetch form", {
663
1021
  formId,
664
1022
  error: err instanceof Error ? err.message : String(err)
665
1023
  });
@@ -667,30 +1025,8 @@ async function fetchParentForm(config, formId, nextly) {
667
1025
  }
668
1026
  }
669
1027
 
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
1028
  // src/config/validate-form.ts
1029
+ import { isPluginFieldTypeOnSurface } from "nextly";
694
1030
  var RESERVED_FORM_SLUGS = [
695
1031
  // API routes
696
1032
  "api",
@@ -707,21 +1043,7 @@ var RESERVED_FORM_SLUGS = [
707
1043
  var RESERVED_SLUGS_SET = new Set(RESERVED_FORM_SLUGS);
708
1044
  var SLUG_PATTERN = /^[a-z][a-z0-9_-]*$/;
709
1045
  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
- ];
1046
+ var VALID_FIELD_TYPES = BUILT_IN_FORM_FIELD_TYPES;
725
1047
  var VALID_FIELD_TYPES_SET = new Set(VALID_FIELD_TYPES);
726
1048
  function isReservedSlug(slug) {
727
1049
  return RESERVED_SLUGS_SET.has(slug.toLowerCase());
@@ -937,7 +1259,8 @@ function validateField(field, path, errors, seenNames) {
937
1259
  });
938
1260
  return;
939
1261
  }
940
- if (typeof fieldType !== "string" || !VALID_FIELD_TYPES_SET.has(fieldType)) {
1262
+ const isValidType = typeof fieldType === "string" && (VALID_FIELD_TYPES_SET.has(fieldType) || isPluginFieldTypeOnSurface(fieldType, "forms"));
1263
+ if (!isValidType) {
941
1264
  let fieldTypeRepr;
942
1265
  if (typeof fieldType === "object" && fieldType !== null) {
943
1266
  fieldTypeRepr = JSON.stringify(fieldType);
@@ -1065,82 +1388,6 @@ ${errorMessages}`
1065
1388
  }
1066
1389
  }
1067
1390
 
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
1391
  // src/config/field-helpers.ts
1145
1392
  var option = (label, value) => ({
1146
1393
  label,
@@ -1199,189 +1446,18 @@ var hidden = (config) => ({
1199
1446
  type: "hidden"
1200
1447
  });
1201
1448
 
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
1449
  // src/utils/generate-schema.ts
1381
1450
  import { z } from "zod";
1382
1451
  function generateZodSchema(fields) {
1383
1452
  const schemaShape = {};
1384
1453
  for (const field of fields) {
1454
+ if (!isKnownFormField(field)) {
1455
+ schemaShape[field.name] = field.required ? z.unknown().refine(
1456
+ (value) => value !== void 0 && value !== null && value !== "",
1457
+ { message: `${field.label || field.name} is required` }
1458
+ ) : z.unknown();
1459
+ continue;
1460
+ }
1385
1461
  if (field.type === "hidden" && !field.required) {
1386
1462
  schemaShape[field.name] = z.string().optional();
1387
1463
  continue;
@@ -1459,8 +1535,7 @@ function generateEmailSchema(field) {
1459
1535
  }
1460
1536
  function generateNumberSchema(field) {
1461
1537
  let schema = z.number({
1462
- required_error: field.validation?.errorMessage || "This field is required",
1463
- invalid_type_error: "Must be a number"
1538
+ error: (issue) => issue.input === void 0 ? field.validation?.errorMessage || "This field is required" : "Must be a number"
1464
1539
  });
1465
1540
  if (field.validation?.min !== void 0) {
1466
1541
  schema = schema.min(
@@ -1533,9 +1608,7 @@ function generateSelectSchema(field) {
1533
1608
  return schema2.optional();
1534
1609
  }
1535
1610
  const schema = z.enum(validValues, {
1536
- errorMap: () => ({
1537
- message: field.validation?.errorMessage || "Please select a valid option"
1538
- })
1611
+ error: () => field.validation?.errorMessage || "Please select a valid option"
1539
1612
  });
1540
1613
  if (field.required) {
1541
1614
  return schema;
@@ -1554,9 +1627,7 @@ function generateCheckboxSchema(field) {
1554
1627
  function generateRadioSchema(field) {
1555
1628
  const validValues = field.options.map((opt) => opt.value);
1556
1629
  const schema = z.enum(validValues, {
1557
- errorMap: () => ({
1558
- message: field.validation?.errorMessage || "Please select an option"
1559
- })
1630
+ error: () => field.validation?.errorMessage || "Please select an option"
1560
1631
  });
1561
1632
  if (field.required) {
1562
1633
  return schema;
@@ -1633,7 +1704,7 @@ function transformFormData(data, fields) {
1633
1704
  if (value === void 0) {
1634
1705
  continue;
1635
1706
  }
1636
- transformed[field.name] = transformFieldValue(value, field);
1707
+ transformed[field.name] = isKnownFormField(field) ? transformFieldValue(value, field) : value;
1637
1708
  }
1638
1709
  return transformed;
1639
1710
  }
@@ -1684,7 +1755,7 @@ function getValidationErrors(result) {
1684
1755
  return {};
1685
1756
  }
1686
1757
  const errors = {};
1687
- for (const error of result.error.errors) {
1758
+ for (const error of result.error.issues) {
1688
1759
  const path = error.path.join(".");
1689
1760
  if (!errors[path]) {
1690
1761
  errors[path] = error.message;
@@ -1693,203 +1764,6 @@ function getValidationErrors(result) {
1693
1764
  return errors;
1694
1765
  }
1695
1766
 
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
1767
  // src/handlers/spam-detection.ts
1894
1768
  var rateLimitStore = /* @__PURE__ */ new Map();
1895
1769
  async function checkSpam(options) {
@@ -2007,7 +1881,7 @@ async function submitForm(options, context) {
2007
1881
  const { formSlug, data, metadata } = options;
2008
1882
  const { pluginContext, pluginConfig } = context;
2009
1883
  const { collections } = pluginContext.services;
2010
- const { logger } = pluginContext.infra;
1884
+ const { logger } = pluginContext;
2011
1885
  try {
2012
1886
  const form = await fetchFormBySlug(formSlug, pluginConfig, pluginContext);
2013
1887
  if (!form) {
@@ -2029,46 +1903,95 @@ async function submitForm(options, context) {
2029
1903
  error: "This form is not currently accepting submissions"
2030
1904
  };
2031
1905
  }
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
- };
1906
+ const settings = normalizeFormSettings(form.settings);
1907
+ if (settings.allowMultipleSubmissions === false && !metadata?.ipAddress) {
1908
+ logger.warn?.(
1909
+ "Single-submission form received a submission without an IP \u2014 the restriction cannot apply",
1910
+ { formSlug }
1911
+ );
2046
1912
  }
2047
- sanitizeSubmissionData(validationResult.data, form.fields);
1913
+ if (settings.allowMultipleSubmissions === false && metadata?.ipAddress) {
1914
+ const existing = await collections.count(
1915
+ pluginConfig.formSubmissionOverrides.slug,
1916
+ {
1917
+ where: {
1918
+ form: { equals: form.id },
1919
+ ipAddress: { equals: metadata.ipAddress },
1920
+ status: { not_equals: "spam" }
1921
+ }
1922
+ },
1923
+ { as: "system" }
1924
+ );
1925
+ if (existing > 0) {
1926
+ logger.info?.("Repeat submission rejected (single-submission form)", {
1927
+ formSlug,
1928
+ ipAddress: metadata.ipAddress
1929
+ });
1930
+ return {
1931
+ success: false,
1932
+ error: "You have already submitted this form."
1933
+ };
1934
+ }
1935
+ }
1936
+ const declaredFieldNames = new Set(form.fields.map((field) => field.name));
1937
+ const undeclaredData = Object.fromEntries(
1938
+ Object.entries(data).filter(([key]) => !declaredFieldNames.has(key))
1939
+ );
2048
1940
  const spamResult = await checkSpam({
2049
- data: transformedData,
1941
+ data: undeclaredData,
2050
1942
  ipAddress: metadata?.ipAddress,
2051
1943
  formSlug,
2052
1944
  config: {
2053
- honeypot: pluginConfig.spamProtection.honeypot,
1945
+ // Per-form overrides win where set; blank inherits the plugin config.
1946
+ honeypot: settings.honeypotEnabled ?? pluginConfig.spamProtection.honeypot,
2054
1947
  rateLimit: pluginConfig.spamProtection.rateLimit,
2055
- recaptcha: pluginConfig.spamProtection.recaptcha
1948
+ recaptcha: {
1949
+ ...pluginConfig.spamProtection.recaptcha,
1950
+ enabled: settings.captchaEnabled ?? pluginConfig.spamProtection.recaptcha?.enabled ?? false
1951
+ }
2056
1952
  }
2057
1953
  });
2058
- if (spamResult.isSpam) {
2059
- logger.info?.("Spam submission detected and rejected", {
1954
+ if (spamResult.isSpam && spamResult.reason === "rate_limit") {
1955
+ logger.info?.("Spam submission rejected (rate limit)", {
1956
+ formSlug,
1957
+ ipAddress: metadata?.ipAddress
1958
+ });
1959
+ return { success: true };
1960
+ }
1961
+ const isContentSpam = spamResult.isSpam;
1962
+ const transformedData = transformFormData(data, form.fields);
1963
+ let storedData;
1964
+ if (isContentSpam) {
1965
+ logger.info?.("Spam submission detected \u2014 storing flagged", {
2060
1966
  formSlug,
2061
1967
  reason: spamResult.reason,
2062
1968
  ipAddress: metadata?.ipAddress
2063
1969
  });
2064
- return {
2065
- success: true
2066
- };
1970
+ sanitizeSubmissionData(transformedData, form.fields);
1971
+ storedData = transformedData;
1972
+ } else {
1973
+ const schema = generateZodSchema(form.fields);
1974
+ const validationResult = schema.safeParse(transformedData);
1975
+ if (!validationResult.success) {
1976
+ const validationErrors = getValidationErrors(validationResult);
1977
+ logger.debug?.("Form validation failed", {
1978
+ formSlug,
1979
+ errors: validationErrors
1980
+ });
1981
+ return {
1982
+ success: false,
1983
+ error: "Validation failed",
1984
+ validationErrors
1985
+ };
1986
+ }
1987
+ sanitizeSubmissionData(validationResult.data, form.fields);
1988
+ storedData = validationResult.data;
2067
1989
  }
2068
1990
  const submissionData = {
2069
1991
  form: form.id,
2070
- data: validationResult.data,
2071
- status: "new",
1992
+ data: storedData,
1993
+ status: isContentSpam ? "spam" : "new",
1994
+ spamReason: isContentSpam ? spamResult.reason ?? null : null,
2072
1995
  ipAddress: metadata?.ipAddress || null,
2073
1996
  userAgent: metadata?.userAgent || null,
2074
1997
  submittedAt: /* @__PURE__ */ new Date()
@@ -2076,17 +1999,34 @@ async function submitForm(options, context) {
2076
1999
  const submission = await collections.createEntry(
2077
2000
  pluginConfig.formSubmissionOverrides.slug,
2078
2001
  submissionData,
2079
- {}
2080
- // Empty context - submission creation is public
2002
+ // Public form submission — create as system. No ambient user; an
2003
+ // empty context already resolves to system, but be explicit.
2004
+ { as: "system" }
2081
2005
  );
2082
2006
  logger.info?.("Form submission created successfully", {
2083
2007
  formSlug,
2084
- submissionId: submission.id
2008
+ submissionId: submission.item.id,
2009
+ flaggedAsSpam: isContentSpam
2085
2010
  });
2011
+ if (submission.warnings) {
2012
+ logger.warn?.("Form submission side effects failed", {
2013
+ formSlug,
2014
+ submissionId: submission.item.id,
2015
+ warnings: submission.warnings
2016
+ });
2017
+ }
2086
2018
  const redirect = determineRedirectUrl(form);
2019
+ if (isContentSpam) {
2020
+ return { success: true, redirect };
2021
+ }
2087
2022
  return {
2088
2023
  success: true,
2089
- submission,
2024
+ // The created ROW, not the envelope around it. The service's loose
2025
+ // `CollectionEntry` and this declared document do not overlap enough for
2026
+ // a checked assertion, so the conversion goes through `unknown` and
2027
+ // TypeScript verifies nothing here — which is why the integration test
2028
+ // reads `submission.id` off the result rather than only `success`.
2029
+ submission: submission.item,
2090
2030
  redirect
2091
2031
  };
2092
2032
  } catch (error) {
@@ -2105,19 +2045,10 @@ async function fetchFormBySlug(slug, pluginConfig, pluginContext) {
2105
2045
  const { collections } = pluginContext.services;
2106
2046
  const result = await collections.listEntries(
2107
2047
  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
2048
+ { where: { slug: { equals: slug } }, pagination: { limit: 1 } },
2049
+ { as: "system" }
2120
2050
  );
2051
+ const form = result.data?.[0];
2121
2052
  return form ? form : null;
2122
2053
  } catch {
2123
2054
  return null;
@@ -2191,33 +2122,28 @@ async function getFormSubmissionStats(formSlug, context) {
2191
2122
  return null;
2192
2123
  }
2193
2124
  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;
2125
+ const submissionsSlug = pluginConfig.formSubmissionOverrides.slug;
2126
+ const sys = { as: "system" };
2127
+ const base = { form: { equals: form.id } };
2128
+ const [total, newCount, read, archived] = await Promise.all([
2129
+ collections.count(submissionsSlug, { where: base }, sys),
2130
+ collections.count(
2131
+ submissionsSlug,
2132
+ { where: { ...base, status: { equals: "new" } } },
2133
+ sys
2134
+ ),
2135
+ collections.count(
2136
+ submissionsSlug,
2137
+ { where: { ...base, status: { equals: "read" } } },
2138
+ sys
2139
+ ),
2140
+ collections.count(
2141
+ submissionsSlug,
2142
+ { where: { ...base, status: { equals: "archived" } } },
2143
+ sys
2144
+ )
2145
+ ]);
2146
+ return { total, new: newCount, read, archived };
2221
2147
  } catch {
2222
2148
  return null;
2223
2149
  }
@@ -2404,6 +2330,7 @@ function getSupportedWebhookEvents() {
2404
2330
  // src/index.ts
2405
2331
  var formBuilderPlugin = formBuilder().plugin;
2406
2332
  export {
2333
+ BUILT_IN_FORM_FIELD_TYPES,
2407
2334
  DEFAULT_FORM_SETTINGS,
2408
2335
  DEFAULT_NOTIFICATION_SETTINGS,
2409
2336
  RESERVED_FORM_SLUGS,
@@ -2443,6 +2370,7 @@ export {
2443
2370
  isFormAcceptingSubmissions,
2444
2371
  isFormField,
2445
2372
  isFormFieldType,
2373
+ isKnownFormField,
2446
2374
  isRateLimited,
2447
2375
  isSelectFormField,
2448
2376
  isTextFormField,