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