@nextlyhq/plugin-form-builder 0.0.2-alpha.34 → 0.0.2-alpha.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -5
- package/dist/admin/index.d.ts +122 -315
- package/dist/admin/index.js +3836 -3421
- package/dist/admin/index.js.map +1 -1
- package/dist/chunk-WVI7N3QO.js +559 -0
- package/dist/chunk-WVI7N3QO.js.map +1 -0
- package/dist/index.d.ts +8 -8
- package/dist/index.js +440 -611
- package/dist/index.js.map +1 -1
- package/dist/{index-BRumOEsE.d.ts → types-r8uBeB0u.d.ts} +97 -96
- package/package.json +23 -31
- package/dist/CheckboxFieldEditor-K4T6QX52.js +0 -121
- package/dist/CheckboxFieldEditor-K4T6QX52.js.map +0 -1
- package/dist/DateFieldEditor-JFJFAVOH.js +0 -187
- package/dist/DateFieldEditor-JFJFAVOH.js.map +0 -1
- package/dist/EmailFieldEditor-ZZ4VSKKB.js +0 -138
- package/dist/EmailFieldEditor-ZZ4VSKKB.js.map +0 -1
- package/dist/NumberFieldEditor-UISAYR6Z.js +0 -175
- package/dist/NumberFieldEditor-UISAYR6Z.js.map +0 -1
- package/dist/RadioFieldEditor-6UQFH4WT.js +0 -165
- package/dist/RadioFieldEditor-6UQFH4WT.js.map +0 -1
- package/dist/SelectFieldEditor-PSO4KHV4.js +0 -213
- package/dist/SelectFieldEditor-PSO4KHV4.js.map +0 -1
- package/dist/TextFieldEditor-5EU64EA5.js +0 -187
- package/dist/TextFieldEditor-5EU64EA5.js.map +0 -1
- package/dist/TextareaFieldEditor-773G3LNM.js +0 -190
- package/dist/TextareaFieldEditor-773G3LNM.js.map +0 -1
- package/dist/admin/index.css +0 -91
- package/dist/admin/index.css.map +0 -1
- package/dist/chunk-2LLUO3TV.js +0 -547
- package/dist/chunk-2LLUO3TV.js.map +0 -1
- package/dist/chunk-XTCEPJ7B.js +0 -256
- package/dist/chunk-XTCEPJ7B.js.map +0 -1
- package/dist/components/index.d.ts +0 -3
- package/dist/components/index.js +0 -9
- package/dist/components/index.js.map +0 -1
- package/dist/styles/form-builder.css +0 -128
- package/dist/styles/submissions-filter.css +0 -74
package/dist/index.js
CHANGED
|
@@ -1,3 +1,33 @@
|
|
|
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
32
|
import { createRequire } from "module";
|
|
3
33
|
import { definePlugin } from "@nextlyhq/plugin-sdk";
|
|
@@ -91,12 +121,18 @@ function formsCollection(pluginConfig) {
|
|
|
91
121
|
description: "Allow the same user/IP to submit this form multiple times"
|
|
92
122
|
}
|
|
93
123
|
}),
|
|
124
|
+
checkbox({
|
|
125
|
+
name: "honeypotEnabled",
|
|
126
|
+
label: "Honeypot",
|
|
127
|
+
admin: {
|
|
128
|
+
description: "Per-form honeypot override; unset inherits the plugin default"
|
|
129
|
+
}
|
|
130
|
+
}),
|
|
94
131
|
checkbox({
|
|
95
132
|
name: "captchaEnabled",
|
|
96
133
|
label: "Enable reCAPTCHA",
|
|
97
|
-
defaultValue: false,
|
|
98
134
|
admin: {
|
|
99
|
-
description: "
|
|
135
|
+
description: "Per-form reCAPTCHA override; unset inherits the plugin default"
|
|
100
136
|
}
|
|
101
137
|
}),
|
|
102
138
|
text({
|
|
@@ -164,10 +200,10 @@ function formsCollection(pluginConfig) {
|
|
|
164
200
|
// ============================================================
|
|
165
201
|
json({
|
|
166
202
|
name: "notifications",
|
|
167
|
-
label: "
|
|
203
|
+
label: "Notifications",
|
|
168
204
|
defaultValue: [],
|
|
169
205
|
admin: {
|
|
170
|
-
description: "Email notification
|
|
206
|
+
description: "Email notification rules. Managed by the Form Builder UI."
|
|
171
207
|
}
|
|
172
208
|
}),
|
|
173
209
|
// ============================================================
|
|
@@ -253,16 +289,6 @@ function formsCollection(pluginConfig) {
|
|
|
253
289
|
}
|
|
254
290
|
return data;
|
|
255
291
|
}
|
|
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
292
|
]
|
|
267
293
|
},
|
|
268
294
|
// Spread any additional overrides (excluding already used properties)
|
|
@@ -318,12 +344,23 @@ function submissionsCollection(pluginConfig) {
|
|
|
318
344
|
options: [
|
|
319
345
|
{ label: "New", value: "new" },
|
|
320
346
|
{ label: "Read", value: "read" },
|
|
321
|
-
{ label: "Archived", value: "archived" }
|
|
347
|
+
{ label: "Archived", value: "archived" },
|
|
348
|
+
// Spam is stored and flagged, never silently dropped: a false
|
|
349
|
+
// positive stays reviewable and recoverable ("Not spam" = back to new).
|
|
350
|
+
{ label: "Spam", value: "spam" }
|
|
322
351
|
],
|
|
323
352
|
admin: {
|
|
324
353
|
description: "Track whether this submission has been reviewed"
|
|
325
354
|
}
|
|
326
355
|
}),
|
|
356
|
+
text2({
|
|
357
|
+
name: "spamReason",
|
|
358
|
+
label: "Spam Reason",
|
|
359
|
+
admin: {
|
|
360
|
+
readOnly: true,
|
|
361
|
+
description: "Which spam check flagged this submission (empty for clean submissions)"
|
|
362
|
+
}
|
|
363
|
+
}),
|
|
327
364
|
// ============================================================
|
|
328
365
|
// Internal Notes
|
|
329
366
|
// ============================================================
|
|
@@ -361,6 +398,24 @@ function submissionsCollection(pluginConfig) {
|
|
|
361
398
|
readOnly: true,
|
|
362
399
|
description: "When the form was submitted"
|
|
363
400
|
}
|
|
401
|
+
}),
|
|
402
|
+
// Honesty stamps for admin edits of submitted data: a corrected
|
|
403
|
+
// submission must never be indistinguishable from what the visitor sent.
|
|
404
|
+
date({
|
|
405
|
+
name: "editedAt",
|
|
406
|
+
label: "Edited At",
|
|
407
|
+
admin: {
|
|
408
|
+
readOnly: true,
|
|
409
|
+
description: "When an admin last edited the submitted data"
|
|
410
|
+
}
|
|
411
|
+
}),
|
|
412
|
+
text2({
|
|
413
|
+
name: "editedBy",
|
|
414
|
+
label: "Edited By",
|
|
415
|
+
admin: {
|
|
416
|
+
readOnly: true,
|
|
417
|
+
description: "ID of the admin who last edited the submitted data"
|
|
418
|
+
}
|
|
364
419
|
})
|
|
365
420
|
];
|
|
366
421
|
let finalFields;
|
|
@@ -382,10 +437,17 @@ function submissionsCollection(pluginConfig) {
|
|
|
382
437
|
order: 51,
|
|
383
438
|
useAsTitle: "id",
|
|
384
439
|
description: "View and manage form submissions",
|
|
385
|
-
//
|
|
440
|
+
// Submissions are created by visitors submitting forms — a hand-typed
|
|
441
|
+
// "New Submission" would be fiction, so the admin never offers one.
|
|
442
|
+
disableCreate: true,
|
|
386
443
|
components: {
|
|
387
|
-
//
|
|
388
|
-
|
|
444
|
+
// The full submissions experience (per-form columns, status tabs,
|
|
445
|
+
// drawer detail, export) replaces the generic list.
|
|
446
|
+
views: {
|
|
447
|
+
List: {
|
|
448
|
+
Component: "@nextlyhq/plugin-form-builder/admin#SubmissionsView"
|
|
449
|
+
}
|
|
450
|
+
}
|
|
389
451
|
},
|
|
390
452
|
...overrides.admin
|
|
391
453
|
},
|
|
@@ -402,7 +464,9 @@ function submissionsCollection(pluginConfig) {
|
|
|
402
464
|
delete: accessOverrides?.delete ?? (({ roles }) => roles.includes("admin") || roles.includes("super-admin"))
|
|
403
465
|
},
|
|
404
466
|
hooks: {
|
|
405
|
-
// Auto-set submittedAt timestamp on creation
|
|
467
|
+
// Auto-set submittedAt timestamp on creation. (The edit stamp for
|
|
468
|
+
// updates is registered in plugin.ts init — direct registry hooks are
|
|
469
|
+
// the path that runs for every API surface.)
|
|
406
470
|
beforeValidate: [
|
|
407
471
|
(context) => {
|
|
408
472
|
const { data, operation } = context;
|
|
@@ -503,6 +567,13 @@ function formBuilder(options = {}) {
|
|
|
503
567
|
name: "@nextlyhq/plugin-form-builder",
|
|
504
568
|
version: PLUGIN_VERSION,
|
|
505
569
|
nextly: ">=0.0.2-alpha.21",
|
|
570
|
+
// Identity metadata for the admin plugins page, mirroring package.json.
|
|
571
|
+
author: "Nextly",
|
|
572
|
+
homepage: "https://nextlyhq.com",
|
|
573
|
+
repository: "https://github.com/nextlyhq/nextly",
|
|
574
|
+
license: "MIT",
|
|
575
|
+
category: "forms",
|
|
576
|
+
tags: ["forms", "submissions", "email-notifications"],
|
|
506
577
|
// Declarative schema: the merged pipeline folds these into the
|
|
507
578
|
// app schema — no manual `setup()` append needed. Just register the plugin.
|
|
508
579
|
contributes: {
|
|
@@ -515,7 +586,15 @@ function formBuilder(options = {}) {
|
|
|
515
586
|
resource: "submissions",
|
|
516
587
|
label: "Export Submissions",
|
|
517
588
|
description: "Export form submissions as CSV/JSON",
|
|
518
|
-
group
|
|
589
|
+
// No `group`: the admin already files this under the plugin that
|
|
590
|
+
// declared it, so naming the plugin again would nest it inside
|
|
591
|
+
// itself. `group` is for a plugin with enough permissions to sort
|
|
592
|
+
// its own into headings, which one is not.
|
|
593
|
+
//
|
|
594
|
+
// `danger` because the point of the permission is to take
|
|
595
|
+
// submissions — names, emails, whatever a form asked for — out of
|
|
596
|
+
// the site in a file.
|
|
597
|
+
danger: true
|
|
519
598
|
}
|
|
520
599
|
],
|
|
521
600
|
// HTTP route — exported at
|
|
@@ -529,53 +608,137 @@ function formBuilder(options = {}) {
|
|
|
529
608
|
method: "GET",
|
|
530
609
|
path: "/submissions/export",
|
|
531
610
|
requiredPermission: "export-submissions",
|
|
532
|
-
handler: async (
|
|
611
|
+
handler: async (req, ctx) => {
|
|
533
612
|
const declaredSlug = resolvedConfig.formSubmissionOverrides.slug;
|
|
534
613
|
const slug = ctx.self.collections[declaredSlug] ?? declaredSlug;
|
|
535
|
-
const
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
614
|
+
const url2 = new URL(req.url);
|
|
615
|
+
const format = url2.searchParams.get("format") ?? "json";
|
|
616
|
+
const formId = url2.searchParams.get("form");
|
|
617
|
+
const status = url2.searchParams.get("status");
|
|
618
|
+
const where = {};
|
|
619
|
+
if (formId) where.form = { equals: formId };
|
|
620
|
+
if (status) where.status = { equals: status };
|
|
621
|
+
else where.status = { not_equals: "spam" };
|
|
622
|
+
const opts = { as: "user", user: ctx.user ?? void 0 };
|
|
623
|
+
let form;
|
|
624
|
+
if (format === "csv") {
|
|
625
|
+
if (!formId) {
|
|
626
|
+
return Response.json(
|
|
627
|
+
{
|
|
628
|
+
error: {
|
|
629
|
+
code: "VALIDATION_ERROR",
|
|
630
|
+
message: "CSV export requires a form parameter."
|
|
631
|
+
}
|
|
632
|
+
},
|
|
633
|
+
{ status: 400 }
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
const formsSlugDeclared = resolvedConfig.formOverrides.slug;
|
|
637
|
+
const formsSlug = ctx.self.collections[formsSlugDeclared] ?? formsSlugDeclared;
|
|
638
|
+
const formResult = await ctx.services.collections.listEntries(
|
|
639
|
+
formsSlug,
|
|
640
|
+
{ where: { id: { equals: formId } } },
|
|
641
|
+
opts
|
|
642
|
+
);
|
|
643
|
+
form = formResult.data[0];
|
|
644
|
+
if (!form) {
|
|
645
|
+
return Response.json(
|
|
646
|
+
{ error: { code: "NOT_FOUND", message: "Form not found." } },
|
|
647
|
+
{ status: 404 }
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
const MAX_EXPORT_ROWS = 5e4;
|
|
652
|
+
const items = [];
|
|
653
|
+
const pageSize = 200;
|
|
654
|
+
let truncated = false;
|
|
655
|
+
for (let page = 1; ; page += 1) {
|
|
656
|
+
const result = await ctx.services.collections.listEntries(
|
|
657
|
+
slug,
|
|
658
|
+
{ where, pagination: { limit: pageSize, page } },
|
|
659
|
+
opts
|
|
660
|
+
);
|
|
661
|
+
items.push(...result.data);
|
|
662
|
+
const pageWasFull = result.data.length === pageSize;
|
|
663
|
+
if (items.length >= MAX_EXPORT_ROWS) {
|
|
664
|
+
truncated = pageWasFull || items.length > MAX_EXPORT_ROWS;
|
|
665
|
+
items.length = MAX_EXPORT_ROWS;
|
|
666
|
+
break;
|
|
667
|
+
}
|
|
668
|
+
if (!pageWasFull) break;
|
|
669
|
+
}
|
|
670
|
+
const truncationHeaders = truncated ? { "X-Export-Truncated": "true" } : {};
|
|
671
|
+
if (format !== "csv" || !form) {
|
|
672
|
+
return Response.json({ items }, { headers: truncationHeaders });
|
|
673
|
+
}
|
|
674
|
+
const csv = exportToCSV(
|
|
675
|
+
items,
|
|
676
|
+
form
|
|
539
677
|
);
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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}`
|
|
678
|
+
const filename = generateExportFilename(
|
|
679
|
+
typeof form.slug === "string" ? form.slug : "form",
|
|
680
|
+
"csv"
|
|
681
|
+
);
|
|
682
|
+
return new Response(csv, {
|
|
683
|
+
headers: {
|
|
684
|
+
"Content-Type": "text/csv; charset=utf-8",
|
|
685
|
+
"Content-Disposition": `attachment; filename="${filename}"`,
|
|
686
|
+
...truncationHeaders
|
|
687
|
+
}
|
|
688
|
+
});
|
|
558
689
|
}
|
|
559
|
-
],
|
|
560
|
-
settings: {
|
|
561
|
-
component: "@nextlyhq/plugin-form-builder/admin#FormBuilderView"
|
|
562
690
|
},
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
691
|
+
{
|
|
692
|
+
// The builder UI reads the host's resolved field enable/disable map
|
|
693
|
+
// from here, so the plugin option actually gates the type picker.
|
|
694
|
+
// Options resolve server-side only; this is the one channel the
|
|
695
|
+
// admin client has to them.
|
|
696
|
+
method: "GET",
|
|
697
|
+
path: "/builder-config",
|
|
698
|
+
// Derived from the forms slug like the menu entry: with an
|
|
699
|
+
// overridden slug, users hold read-<slug>, not read-forms.
|
|
700
|
+
requiredPermission: `read-${resolvedConfig.formOverrides.slug}`,
|
|
701
|
+
handler: (_req, ctx) => Promise.resolve(
|
|
702
|
+
Response.json({
|
|
703
|
+
fields: resolvedConfig.fields,
|
|
704
|
+
// Notification defaults the builder surfaces: defaultToEmail
|
|
705
|
+
// seeds a new form's first rule, defaultFrom renders as the
|
|
706
|
+
// inherited sender placeholder.
|
|
707
|
+
notifications: {
|
|
708
|
+
defaultFrom: resolvedConfig.notifications.defaultFrom,
|
|
709
|
+
defaultToEmail: resolvedConfig.notifications.defaultToEmail
|
|
710
|
+
},
|
|
711
|
+
// Spam defaults the Settings tab surfaces, so per-form
|
|
712
|
+
// override selects can show what "inherit" resolves to.
|
|
713
|
+
spamProtection: {
|
|
714
|
+
honeypot: resolvedConfig.spamProtection.honeypot,
|
|
715
|
+
recaptchaEnabled: resolvedConfig.spamProtection.recaptcha?.enabled ?? false
|
|
716
|
+
},
|
|
717
|
+
// Runtime-resolved collection slugs (through ctx.self, so a
|
|
718
|
+
// framework .rename() is honored too) — admin components
|
|
719
|
+
// never hardcode "forms"/"form-submissions".
|
|
720
|
+
slugs: {
|
|
721
|
+
forms: ctx.self.collections[resolvedConfig.formOverrides.slug] ?? resolvedConfig.formOverrides.slug,
|
|
722
|
+
submissions: ctx.self.collections[resolvedConfig.formSubmissionOverrides.slug] ?? resolvedConfig.formSubmissionOverrides.slug
|
|
723
|
+
}
|
|
724
|
+
})
|
|
725
|
+
)
|
|
574
726
|
}
|
|
575
|
-
|
|
727
|
+
]
|
|
576
728
|
},
|
|
729
|
+
// Forms is a first-class destination, not a plugin detail: standalone
|
|
730
|
+
// placement gives it its own main-rail icon after Media, and its
|
|
731
|
+
// sub-sidebar lists the plugin's collections (Forms, Submissions) —
|
|
732
|
+
// no separate menu contribution, so "Forms" exists exactly once.
|
|
733
|
+
// Hosts preferring the Plugins section override placement in config.
|
|
734
|
+
// (The old admin.settings.component that rendered a second full builder
|
|
735
|
+
// at /admin/plugins/<slug> is gone; the collection Edit-view override
|
|
736
|
+
// is the single FormBuilderView mount.)
|
|
577
737
|
admin: {
|
|
738
|
+
placement: "standalone",
|
|
739
|
+
after: "media",
|
|
578
740
|
order: 50,
|
|
741
|
+
appearance: { icon: "FileText", label: "Forms" },
|
|
579
742
|
description: "Create and manage forms with submission tracking"
|
|
580
743
|
},
|
|
581
744
|
// -- Init ----------------------------------------------------------------
|
|
@@ -607,6 +770,40 @@ function formBuilder(options = {}) {
|
|
|
607
770
|
await handleSubmissionCreated(context, resolvedConfig, nextly);
|
|
608
771
|
}
|
|
609
772
|
);
|
|
773
|
+
nextly.hooks.on("beforeUpdate", submissionSlug, (context) => {
|
|
774
|
+
const ctx = context;
|
|
775
|
+
if (ctx.data && ctx.data.data !== void 0) {
|
|
776
|
+
ctx.data.editedAt = /* @__PURE__ */ new Date();
|
|
777
|
+
ctx.data.editedBy = ctx.user?.id ?? null;
|
|
778
|
+
}
|
|
779
|
+
return ctx.data;
|
|
780
|
+
});
|
|
781
|
+
const declaredFormsSlug = resolvedConfig.formOverrides.slug;
|
|
782
|
+
const formsSlug = nextly.self.collections[declaredFormsSlug] ?? declaredFormsSlug;
|
|
783
|
+
nextly.hooks.on("afterRead", formsSlug, async (context) => {
|
|
784
|
+
const data = context.data;
|
|
785
|
+
const records = Array.isArray(data) ? data : data ? [data] : [];
|
|
786
|
+
await Promise.all(
|
|
787
|
+
records.map(async (record) => {
|
|
788
|
+
const form = record;
|
|
789
|
+
if (typeof form.id !== "string") return;
|
|
790
|
+
try {
|
|
791
|
+
form.submissionCount = await nextly.services.collections.count(
|
|
792
|
+
submissionSlug,
|
|
793
|
+
{
|
|
794
|
+
where: {
|
|
795
|
+
form: { equals: form.id },
|
|
796
|
+
status: { not_equals: "spam" }
|
|
797
|
+
}
|
|
798
|
+
},
|
|
799
|
+
{ as: "system" }
|
|
800
|
+
);
|
|
801
|
+
} catch {
|
|
802
|
+
form.submissionCount = 0;
|
|
803
|
+
}
|
|
804
|
+
})
|
|
805
|
+
);
|
|
806
|
+
});
|
|
610
807
|
}
|
|
611
808
|
});
|
|
612
809
|
return {
|
|
@@ -644,9 +841,78 @@ function collectAttachmentInputs(fields, submittedData) {
|
|
|
644
841
|
}
|
|
645
842
|
return attachments;
|
|
646
843
|
}
|
|
844
|
+
function parseJsonColumn(value) {
|
|
845
|
+
if (typeof value === "string") {
|
|
846
|
+
try {
|
|
847
|
+
const parsed = JSON.parse(value);
|
|
848
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
849
|
+
} catch {
|
|
850
|
+
return {};
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
854
|
+
}
|
|
855
|
+
function normalizeAddressList(addresses) {
|
|
856
|
+
if (!Array.isArray(addresses)) return void 0;
|
|
857
|
+
const cleaned = addresses.map((address) => typeof address === "string" ? address.trim() : "").filter(Boolean);
|
|
858
|
+
return cleaned.length > 0 ? cleaned : void 0;
|
|
859
|
+
}
|
|
860
|
+
function buildNotificationEmails(input) {
|
|
861
|
+
const { notifications, submittedData, formName, submissionId, defaultFrom } = input;
|
|
862
|
+
const seen = /* @__PURE__ */ new Set();
|
|
863
|
+
const emails = [];
|
|
864
|
+
const skipped = [];
|
|
865
|
+
for (const notification of notifications) {
|
|
866
|
+
if (!notification.enabled) continue;
|
|
867
|
+
if (!notification.templateSlug) {
|
|
868
|
+
skipped.push({ notificationId: notification.id, reason: "no-template" });
|
|
869
|
+
continue;
|
|
870
|
+
}
|
|
871
|
+
const key = notification.id || JSON.stringify(notification);
|
|
872
|
+
if (seen.has(key)) continue;
|
|
873
|
+
seen.add(key);
|
|
874
|
+
if (notification.condition && !evaluateSingleCondition(notification.condition, submittedData)) {
|
|
875
|
+
skipped.push({
|
|
876
|
+
notificationId: notification.id,
|
|
877
|
+
reason: "condition-unmet"
|
|
878
|
+
});
|
|
879
|
+
continue;
|
|
880
|
+
}
|
|
881
|
+
const to = (notification.recipientType === "field" ? resolveFieldRef(notification.to, submittedData) : notification.to).trim();
|
|
882
|
+
if (!to) {
|
|
883
|
+
skipped.push({
|
|
884
|
+
notificationId: notification.id,
|
|
885
|
+
reason: "empty-recipient"
|
|
886
|
+
});
|
|
887
|
+
continue;
|
|
888
|
+
}
|
|
889
|
+
const cc = normalizeAddressList(notification.cc);
|
|
890
|
+
const bcc = normalizeAddressList(notification.bcc);
|
|
891
|
+
const from = notification.senderEmail?.trim() || defaultFrom?.trim() || void 0;
|
|
892
|
+
const replyTo = notification.replyTo ? resolveFieldRef(notification.replyTo, submittedData).trim() || void 0 : void 0;
|
|
893
|
+
emails.push({
|
|
894
|
+
to,
|
|
895
|
+
templateSlug: notification.templateSlug,
|
|
896
|
+
variables: {
|
|
897
|
+
...submittedData,
|
|
898
|
+
formName,
|
|
899
|
+
submissionId
|
|
900
|
+
},
|
|
901
|
+
providerId: notification.providerId,
|
|
902
|
+
from,
|
|
903
|
+
replyTo,
|
|
904
|
+
cc,
|
|
905
|
+
bcc,
|
|
906
|
+
notificationId: notification.id
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
return { emails, skipped };
|
|
910
|
+
}
|
|
647
911
|
async function handleSubmissionCreated(context, config, nextly) {
|
|
912
|
+
if (!config.notifications.enabled) return;
|
|
648
913
|
const submission = context.data;
|
|
649
914
|
if (!submission) return;
|
|
915
|
+
if (submission.status === "spam") return;
|
|
650
916
|
const rawFormId = submission.form;
|
|
651
917
|
let formId = null;
|
|
652
918
|
if (typeof rawFormId === "string") {
|
|
@@ -662,39 +928,29 @@ async function handleSubmissionCreated(context, config, nextly) {
|
|
|
662
928
|
if (notifications.length === 0) return;
|
|
663
929
|
const emailService = nextly.services.email;
|
|
664
930
|
if (!emailService) return;
|
|
665
|
-
const submittedData = submission.data
|
|
931
|
+
const submittedData = parseJsonColumn(submission.data);
|
|
666
932
|
const formFields = Array.isArray(form.fields) ? form.fields : [];
|
|
667
933
|
const fileAttachments = collectAttachmentInputs(formFields, submittedData);
|
|
668
|
-
const
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
if (
|
|
934
|
+
const { emails, skipped } = buildNotificationEmails({
|
|
935
|
+
notifications,
|
|
936
|
+
submittedData,
|
|
937
|
+
formName: form.name,
|
|
938
|
+
submissionId: submission.id,
|
|
939
|
+
defaultFrom: config.notifications.defaultFrom
|
|
940
|
+
});
|
|
941
|
+
for (const skip of skipped) {
|
|
942
|
+
if (skip.reason === "empty-recipient") {
|
|
677
943
|
nextly.logger.warn?.(
|
|
678
944
|
"Form Builder: empty recipient, skipping notification",
|
|
679
|
-
{ notificationId:
|
|
945
|
+
{ notificationId: skip.notificationId, formSlug: form.slug }
|
|
680
946
|
);
|
|
681
|
-
|
|
947
|
+
} else {
|
|
948
|
+
nextly.logger.debug?.("Form Builder: notification skipped", {
|
|
949
|
+
notificationId: skip.notificationId,
|
|
950
|
+
formSlug: form.slug,
|
|
951
|
+
reason: skip.reason
|
|
952
|
+
});
|
|
682
953
|
}
|
|
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
954
|
}
|
|
699
955
|
if (emails.length === 0) return;
|
|
700
956
|
const finalEmails = await nextly.filters.apply("form-builder.beforeEmail", emails, { form, submission });
|
|
@@ -706,6 +962,8 @@ async function handleSubmissionCreated(context, config, nextly) {
|
|
|
706
962
|
email2.variables,
|
|
707
963
|
{
|
|
708
964
|
providerId: email2.providerId,
|
|
965
|
+
from: email2.from,
|
|
966
|
+
replyTo: email2.replyTo,
|
|
709
967
|
cc: email2.cc,
|
|
710
968
|
bcc: email2.bcc,
|
|
711
969
|
attachments: fileAttachments.length > 0 ? fileAttachments : void 0
|
|
@@ -745,30 +1003,8 @@ async function fetchParentForm(config, formId, nextly) {
|
|
|
745
1003
|
}
|
|
746
1004
|
}
|
|
747
1005
|
|
|
748
|
-
// src/types.ts
|
|
749
|
-
function isFormField(value) {
|
|
750
|
-
return typeof value === "object" && value !== null && "type" in value && "name" in value && "label" in value;
|
|
751
|
-
}
|
|
752
|
-
function isFormFieldType(field, type) {
|
|
753
|
-
return field.type === type;
|
|
754
|
-
}
|
|
755
|
-
function isTextFormField(field) {
|
|
756
|
-
return field.type === "text";
|
|
757
|
-
}
|
|
758
|
-
function isEmailFormField(field) {
|
|
759
|
-
return field.type === "email";
|
|
760
|
-
}
|
|
761
|
-
function isSelectFormField(field) {
|
|
762
|
-
return field.type === "select";
|
|
763
|
-
}
|
|
764
|
-
function isCheckboxFormField(field) {
|
|
765
|
-
return field.type === "checkbox";
|
|
766
|
-
}
|
|
767
|
-
function isFileFormField(field) {
|
|
768
|
-
return field.type === "file";
|
|
769
|
-
}
|
|
770
|
-
|
|
771
1006
|
// src/config/validate-form.ts
|
|
1007
|
+
import { isPluginFieldTypeOnSurface } from "nextly";
|
|
772
1008
|
var RESERVED_FORM_SLUGS = [
|
|
773
1009
|
// API routes
|
|
774
1010
|
"api",
|
|
@@ -785,21 +1021,7 @@ var RESERVED_FORM_SLUGS = [
|
|
|
785
1021
|
var RESERVED_SLUGS_SET = new Set(RESERVED_FORM_SLUGS);
|
|
786
1022
|
var SLUG_PATTERN = /^[a-z][a-z0-9_-]*$/;
|
|
787
1023
|
var FIELD_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]*$/;
|
|
788
|
-
var VALID_FIELD_TYPES =
|
|
789
|
-
"text",
|
|
790
|
-
"email",
|
|
791
|
-
"number",
|
|
792
|
-
"phone",
|
|
793
|
-
"url",
|
|
794
|
-
"textarea",
|
|
795
|
-
"select",
|
|
796
|
-
"checkbox",
|
|
797
|
-
"radio",
|
|
798
|
-
"file",
|
|
799
|
-
"date",
|
|
800
|
-
"time",
|
|
801
|
-
"hidden"
|
|
802
|
-
];
|
|
1024
|
+
var VALID_FIELD_TYPES = BUILT_IN_FORM_FIELD_TYPES;
|
|
803
1025
|
var VALID_FIELD_TYPES_SET = new Set(VALID_FIELD_TYPES);
|
|
804
1026
|
function isReservedSlug(slug) {
|
|
805
1027
|
return RESERVED_SLUGS_SET.has(slug.toLowerCase());
|
|
@@ -1015,7 +1237,8 @@ function validateField(field, path, errors, seenNames) {
|
|
|
1015
1237
|
});
|
|
1016
1238
|
return;
|
|
1017
1239
|
}
|
|
1018
|
-
|
|
1240
|
+
const isValidType = typeof fieldType === "string" && (VALID_FIELD_TYPES_SET.has(fieldType) || isPluginFieldTypeOnSurface(fieldType, "forms"));
|
|
1241
|
+
if (!isValidType) {
|
|
1019
1242
|
let fieldTypeRepr;
|
|
1020
1243
|
if (typeof fieldType === "object" && fieldType !== null) {
|
|
1021
1244
|
fieldTypeRepr = JSON.stringify(fieldType);
|
|
@@ -1143,82 +1366,6 @@ ${errorMessages}`
|
|
|
1143
1366
|
}
|
|
1144
1367
|
}
|
|
1145
1368
|
|
|
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
1369
|
// src/config/field-helpers.ts
|
|
1223
1370
|
var option = (label, value) => ({
|
|
1224
1371
|
label,
|
|
@@ -1277,189 +1424,18 @@ var hidden = (config) => ({
|
|
|
1277
1424
|
type: "hidden"
|
|
1278
1425
|
});
|
|
1279
1426
|
|
|
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
1427
|
// src/utils/generate-schema.ts
|
|
1459
1428
|
import { z } from "zod";
|
|
1460
1429
|
function generateZodSchema(fields) {
|
|
1461
1430
|
const schemaShape = {};
|
|
1462
1431
|
for (const field of fields) {
|
|
1432
|
+
if (!isKnownFormField(field)) {
|
|
1433
|
+
schemaShape[field.name] = field.required ? z.unknown().refine(
|
|
1434
|
+
(value) => value !== void 0 && value !== null && value !== "",
|
|
1435
|
+
{ message: `${field.label || field.name} is required` }
|
|
1436
|
+
) : z.unknown();
|
|
1437
|
+
continue;
|
|
1438
|
+
}
|
|
1463
1439
|
if (field.type === "hidden" && !field.required) {
|
|
1464
1440
|
schemaShape[field.name] = z.string().optional();
|
|
1465
1441
|
continue;
|
|
@@ -1537,8 +1513,7 @@ function generateEmailSchema(field) {
|
|
|
1537
1513
|
}
|
|
1538
1514
|
function generateNumberSchema(field) {
|
|
1539
1515
|
let schema = z.number({
|
|
1540
|
-
|
|
1541
|
-
invalid_type_error: "Must be a number"
|
|
1516
|
+
error: (issue) => issue.input === void 0 ? field.validation?.errorMessage || "This field is required" : "Must be a number"
|
|
1542
1517
|
});
|
|
1543
1518
|
if (field.validation?.min !== void 0) {
|
|
1544
1519
|
schema = schema.min(
|
|
@@ -1611,9 +1586,7 @@ function generateSelectSchema(field) {
|
|
|
1611
1586
|
return schema2.optional();
|
|
1612
1587
|
}
|
|
1613
1588
|
const schema = z.enum(validValues, {
|
|
1614
|
-
|
|
1615
|
-
message: field.validation?.errorMessage || "Please select a valid option"
|
|
1616
|
-
})
|
|
1589
|
+
error: () => field.validation?.errorMessage || "Please select a valid option"
|
|
1617
1590
|
});
|
|
1618
1591
|
if (field.required) {
|
|
1619
1592
|
return schema;
|
|
@@ -1632,9 +1605,7 @@ function generateCheckboxSchema(field) {
|
|
|
1632
1605
|
function generateRadioSchema(field) {
|
|
1633
1606
|
const validValues = field.options.map((opt) => opt.value);
|
|
1634
1607
|
const schema = z.enum(validValues, {
|
|
1635
|
-
|
|
1636
|
-
message: field.validation?.errorMessage || "Please select an option"
|
|
1637
|
-
})
|
|
1608
|
+
error: () => field.validation?.errorMessage || "Please select an option"
|
|
1638
1609
|
});
|
|
1639
1610
|
if (field.required) {
|
|
1640
1611
|
return schema;
|
|
@@ -1711,7 +1682,7 @@ function transformFormData(data, fields) {
|
|
|
1711
1682
|
if (value === void 0) {
|
|
1712
1683
|
continue;
|
|
1713
1684
|
}
|
|
1714
|
-
transformed[field.name] = transformFieldValue(value, field);
|
|
1685
|
+
transformed[field.name] = isKnownFormField(field) ? transformFieldValue(value, field) : value;
|
|
1715
1686
|
}
|
|
1716
1687
|
return transformed;
|
|
1717
1688
|
}
|
|
@@ -1762,7 +1733,7 @@ function getValidationErrors(result) {
|
|
|
1762
1733
|
return {};
|
|
1763
1734
|
}
|
|
1764
1735
|
const errors = {};
|
|
1765
|
-
for (const error of result.error.
|
|
1736
|
+
for (const error of result.error.issues) {
|
|
1766
1737
|
const path = error.path.join(".");
|
|
1767
1738
|
if (!errors[path]) {
|
|
1768
1739
|
errors[path] = error.message;
|
|
@@ -1771,203 +1742,6 @@ function getValidationErrors(result) {
|
|
|
1771
1742
|
return errors;
|
|
1772
1743
|
}
|
|
1773
1744
|
|
|
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
1745
|
// src/handlers/spam-detection.ts
|
|
1972
1746
|
var rateLimitStore = /* @__PURE__ */ new Map();
|
|
1973
1747
|
async function checkSpam(options) {
|
|
@@ -2107,46 +1881,95 @@ async function submitForm(options, context) {
|
|
|
2107
1881
|
error: "This form is not currently accepting submissions"
|
|
2108
1882
|
};
|
|
2109
1883
|
}
|
|
2110
|
-
const
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
1884
|
+
const settings = normalizeFormSettings(form.settings);
|
|
1885
|
+
if (settings.allowMultipleSubmissions === false && !metadata?.ipAddress) {
|
|
1886
|
+
logger.warn?.(
|
|
1887
|
+
"Single-submission form received a submission without an IP \u2014 the restriction cannot apply",
|
|
1888
|
+
{ formSlug }
|
|
1889
|
+
);
|
|
1890
|
+
}
|
|
1891
|
+
if (settings.allowMultipleSubmissions === false && metadata?.ipAddress) {
|
|
1892
|
+
const existing = await collections.count(
|
|
1893
|
+
pluginConfig.formSubmissionOverrides.slug,
|
|
1894
|
+
{
|
|
1895
|
+
where: {
|
|
1896
|
+
form: { equals: form.id },
|
|
1897
|
+
ipAddress: { equals: metadata.ipAddress },
|
|
1898
|
+
status: { not_equals: "spam" }
|
|
1899
|
+
}
|
|
1900
|
+
},
|
|
1901
|
+
{ as: "system" }
|
|
1902
|
+
);
|
|
1903
|
+
if (existing > 0) {
|
|
1904
|
+
logger.info?.("Repeat submission rejected (single-submission form)", {
|
|
1905
|
+
formSlug,
|
|
1906
|
+
ipAddress: metadata.ipAddress
|
|
1907
|
+
});
|
|
1908
|
+
return {
|
|
1909
|
+
success: false,
|
|
1910
|
+
error: "You have already submitted this form."
|
|
1911
|
+
};
|
|
1912
|
+
}
|
|
2124
1913
|
}
|
|
2125
|
-
|
|
1914
|
+
const declaredFieldNames = new Set(form.fields.map((field) => field.name));
|
|
1915
|
+
const undeclaredData = Object.fromEntries(
|
|
1916
|
+
Object.entries(data).filter(([key]) => !declaredFieldNames.has(key))
|
|
1917
|
+
);
|
|
2126
1918
|
const spamResult = await checkSpam({
|
|
2127
|
-
data:
|
|
1919
|
+
data: undeclaredData,
|
|
2128
1920
|
ipAddress: metadata?.ipAddress,
|
|
2129
1921
|
formSlug,
|
|
2130
1922
|
config: {
|
|
2131
|
-
|
|
1923
|
+
// Per-form overrides win where set; blank inherits the plugin config.
|
|
1924
|
+
honeypot: settings.honeypotEnabled ?? pluginConfig.spamProtection.honeypot,
|
|
2132
1925
|
rateLimit: pluginConfig.spamProtection.rateLimit,
|
|
2133
|
-
recaptcha:
|
|
1926
|
+
recaptcha: {
|
|
1927
|
+
...pluginConfig.spamProtection.recaptcha,
|
|
1928
|
+
enabled: settings.captchaEnabled ?? pluginConfig.spamProtection.recaptcha?.enabled ?? false
|
|
1929
|
+
}
|
|
2134
1930
|
}
|
|
2135
1931
|
});
|
|
2136
|
-
if (spamResult.isSpam) {
|
|
2137
|
-
logger.info?.("Spam submission
|
|
1932
|
+
if (spamResult.isSpam && spamResult.reason === "rate_limit") {
|
|
1933
|
+
logger.info?.("Spam submission rejected (rate limit)", {
|
|
1934
|
+
formSlug,
|
|
1935
|
+
ipAddress: metadata?.ipAddress
|
|
1936
|
+
});
|
|
1937
|
+
return { success: true };
|
|
1938
|
+
}
|
|
1939
|
+
const isContentSpam = spamResult.isSpam;
|
|
1940
|
+
const transformedData = transformFormData(data, form.fields);
|
|
1941
|
+
let storedData;
|
|
1942
|
+
if (isContentSpam) {
|
|
1943
|
+
logger.info?.("Spam submission detected \u2014 storing flagged", {
|
|
2138
1944
|
formSlug,
|
|
2139
1945
|
reason: spamResult.reason,
|
|
2140
1946
|
ipAddress: metadata?.ipAddress
|
|
2141
1947
|
});
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
1948
|
+
sanitizeSubmissionData(transformedData, form.fields);
|
|
1949
|
+
storedData = transformedData;
|
|
1950
|
+
} else {
|
|
1951
|
+
const schema = generateZodSchema(form.fields);
|
|
1952
|
+
const validationResult = schema.safeParse(transformedData);
|
|
1953
|
+
if (!validationResult.success) {
|
|
1954
|
+
const validationErrors = getValidationErrors(validationResult);
|
|
1955
|
+
logger.debug?.("Form validation failed", {
|
|
1956
|
+
formSlug,
|
|
1957
|
+
errors: validationErrors
|
|
1958
|
+
});
|
|
1959
|
+
return {
|
|
1960
|
+
success: false,
|
|
1961
|
+
error: "Validation failed",
|
|
1962
|
+
validationErrors
|
|
1963
|
+
};
|
|
1964
|
+
}
|
|
1965
|
+
sanitizeSubmissionData(validationResult.data, form.fields);
|
|
1966
|
+
storedData = validationResult.data;
|
|
2145
1967
|
}
|
|
2146
1968
|
const submissionData = {
|
|
2147
1969
|
form: form.id,
|
|
2148
|
-
data:
|
|
2149
|
-
status: "new",
|
|
1970
|
+
data: storedData,
|
|
1971
|
+
status: isContentSpam ? "spam" : "new",
|
|
1972
|
+
spamReason: isContentSpam ? spamResult.reason ?? null : null,
|
|
2150
1973
|
ipAddress: metadata?.ipAddress || null,
|
|
2151
1974
|
userAgent: metadata?.userAgent || null,
|
|
2152
1975
|
submittedAt: /* @__PURE__ */ new Date()
|
|
@@ -2160,9 +1983,13 @@ async function submitForm(options, context) {
|
|
|
2160
1983
|
);
|
|
2161
1984
|
logger.info?.("Form submission created successfully", {
|
|
2162
1985
|
formSlug,
|
|
2163
|
-
submissionId: submission.id
|
|
1986
|
+
submissionId: submission.id,
|
|
1987
|
+
flaggedAsSpam: isContentSpam
|
|
2164
1988
|
});
|
|
2165
1989
|
const redirect = determineRedirectUrl(form);
|
|
1990
|
+
if (isContentSpam) {
|
|
1991
|
+
return { success: true, redirect };
|
|
1992
|
+
}
|
|
2166
1993
|
return {
|
|
2167
1994
|
success: true,
|
|
2168
1995
|
submission,
|
|
@@ -2469,6 +2296,7 @@ function getSupportedWebhookEvents() {
|
|
|
2469
2296
|
// src/index.ts
|
|
2470
2297
|
var formBuilderPlugin = formBuilder().plugin;
|
|
2471
2298
|
export {
|
|
2299
|
+
BUILT_IN_FORM_FIELD_TYPES,
|
|
2472
2300
|
DEFAULT_FORM_SETTINGS,
|
|
2473
2301
|
DEFAULT_NOTIFICATION_SETTINGS,
|
|
2474
2302
|
RESERVED_FORM_SLUGS,
|
|
@@ -2508,6 +2336,7 @@ export {
|
|
|
2508
2336
|
isFormAcceptingSubmissions,
|
|
2509
2337
|
isFormField,
|
|
2510
2338
|
isFormFieldType,
|
|
2339
|
+
isKnownFormField,
|
|
2511
2340
|
isRateLimited,
|
|
2512
2341
|
isSelectFormField,
|
|
2513
2342
|
isTextFormField,
|