@nextlyhq/plugin-form-builder 0.0.2-alpha.5 → 0.0.2-alpha.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -5
- package/dist/admin/index.d.ts +123 -331
- package/dist/admin/index.js +3824 -3420
- 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 +12 -11
- package/dist/index.js +592 -664
- package/dist/index.js.map +1 -1
- package/dist/{index-C0kBvYjm.d.ts → types-r8uBeB0u.d.ts} +126 -125
- package/package.json +32 -30
- 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-MMOXXL7P.js +0 -138
- package/dist/EmailFieldEditor-MMOXXL7P.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,7 +1,39 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BUILT_IN_FORM_FIELD_TYPES,
|
|
3
|
+
DEFAULT_FORM_SETTINGS,
|
|
4
|
+
DEFAULT_NOTIFICATION_SETTINGS,
|
|
5
|
+
applyFieldDefaults,
|
|
6
|
+
applyFormDefaults,
|
|
7
|
+
createFormConfig,
|
|
8
|
+
downloadFile,
|
|
9
|
+
evaluateConditions,
|
|
10
|
+
evaluateSingleCondition,
|
|
11
|
+
exportAndDownload,
|
|
12
|
+
exportToCSV,
|
|
13
|
+
exportToJSON,
|
|
14
|
+
formatExportValue,
|
|
15
|
+
generateExportFilename,
|
|
16
|
+
getSupportedComparisonOperators,
|
|
17
|
+
isCheckboxFormField,
|
|
18
|
+
isEmailFormField,
|
|
19
|
+
isFileFormField,
|
|
20
|
+
isFormField,
|
|
21
|
+
isFormFieldType,
|
|
22
|
+
isKnownFormField,
|
|
23
|
+
isSelectFormField,
|
|
24
|
+
isTextFormField,
|
|
25
|
+
isValidComparisonOperator,
|
|
26
|
+
normalizeFormSettings,
|
|
27
|
+
pluralize,
|
|
28
|
+
toTitleCase
|
|
29
|
+
} from "./chunk-WVI7N3QO.js";
|
|
30
|
+
|
|
1
31
|
// src/plugin.ts
|
|
2
|
-
import {
|
|
32
|
+
import { createRequire } from "module";
|
|
33
|
+
import { definePlugin } from "@nextlyhq/plugin-sdk";
|
|
3
34
|
|
|
4
35
|
// src/collections/forms.ts
|
|
36
|
+
import { NextlyError } from "@nextlyhq/plugin-sdk";
|
|
5
37
|
import {
|
|
6
38
|
text,
|
|
7
39
|
textarea,
|
|
@@ -90,12 +122,18 @@ function formsCollection(pluginConfig) {
|
|
|
90
122
|
description: "Allow the same user/IP to submit this form multiple times"
|
|
91
123
|
}
|
|
92
124
|
}),
|
|
125
|
+
checkbox({
|
|
126
|
+
name: "honeypotEnabled",
|
|
127
|
+
label: "Honeypot",
|
|
128
|
+
admin: {
|
|
129
|
+
description: "Per-form honeypot override; unset inherits the plugin default"
|
|
130
|
+
}
|
|
131
|
+
}),
|
|
93
132
|
checkbox({
|
|
94
133
|
name: "captchaEnabled",
|
|
95
134
|
label: "Enable reCAPTCHA",
|
|
96
|
-
defaultValue: false,
|
|
97
135
|
admin: {
|
|
98
|
-
description: "
|
|
136
|
+
description: "Per-form reCAPTCHA override; unset inherits the plugin default"
|
|
99
137
|
}
|
|
100
138
|
}),
|
|
101
139
|
text({
|
|
@@ -163,10 +201,10 @@ function formsCollection(pluginConfig) {
|
|
|
163
201
|
// ============================================================
|
|
164
202
|
json({
|
|
165
203
|
name: "notifications",
|
|
166
|
-
label: "
|
|
204
|
+
label: "Notifications",
|
|
167
205
|
defaultValue: [],
|
|
168
206
|
admin: {
|
|
169
|
-
description: "Email notification
|
|
207
|
+
description: "Email notification rules. Managed by the Form Builder UI."
|
|
170
208
|
}
|
|
171
209
|
}),
|
|
172
210
|
// ============================================================
|
|
@@ -247,18 +285,17 @@ function formsCollection(pluginConfig) {
|
|
|
247
285
|
if (operation === "create" && data && !data.slug && data.name) {
|
|
248
286
|
data.slug = String(data.name).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
249
287
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
data.submissionCount = 0;
|
|
288
|
+
const setsFields = operation === "create" || data?.fields !== void 0;
|
|
289
|
+
if (data && setsFields && (data.fields === void 0 || Array.isArray(data.fields) && data.fields.length === 0)) {
|
|
290
|
+
throw NextlyError.validation({
|
|
291
|
+
errors: [
|
|
292
|
+
{
|
|
293
|
+
path: "fields",
|
|
294
|
+
code: "REQUIRED",
|
|
295
|
+
message: "Form must have at least one field."
|
|
296
|
+
}
|
|
297
|
+
]
|
|
298
|
+
});
|
|
262
299
|
}
|
|
263
300
|
return data;
|
|
264
301
|
}
|
|
@@ -317,12 +354,23 @@ function submissionsCollection(pluginConfig) {
|
|
|
317
354
|
options: [
|
|
318
355
|
{ label: "New", value: "new" },
|
|
319
356
|
{ label: "Read", value: "read" },
|
|
320
|
-
{ label: "Archived", value: "archived" }
|
|
357
|
+
{ label: "Archived", value: "archived" },
|
|
358
|
+
// Spam is stored and flagged, never silently dropped: a false
|
|
359
|
+
// positive stays reviewable and recoverable ("Not spam" = back to new).
|
|
360
|
+
{ label: "Spam", value: "spam" }
|
|
321
361
|
],
|
|
322
362
|
admin: {
|
|
323
363
|
description: "Track whether this submission has been reviewed"
|
|
324
364
|
}
|
|
325
365
|
}),
|
|
366
|
+
text2({
|
|
367
|
+
name: "spamReason",
|
|
368
|
+
label: "Spam Reason",
|
|
369
|
+
admin: {
|
|
370
|
+
readOnly: true,
|
|
371
|
+
description: "Which spam check flagged this submission (empty for clean submissions)"
|
|
372
|
+
}
|
|
373
|
+
}),
|
|
326
374
|
// ============================================================
|
|
327
375
|
// Internal Notes
|
|
328
376
|
// ============================================================
|
|
@@ -360,6 +408,24 @@ function submissionsCollection(pluginConfig) {
|
|
|
360
408
|
readOnly: true,
|
|
361
409
|
description: "When the form was submitted"
|
|
362
410
|
}
|
|
411
|
+
}),
|
|
412
|
+
// Honesty stamps for admin edits of submitted data: a corrected
|
|
413
|
+
// submission must never be indistinguishable from what the visitor sent.
|
|
414
|
+
date({
|
|
415
|
+
name: "editedAt",
|
|
416
|
+
label: "Edited At",
|
|
417
|
+
admin: {
|
|
418
|
+
readOnly: true,
|
|
419
|
+
description: "When an admin last edited the submitted data"
|
|
420
|
+
}
|
|
421
|
+
}),
|
|
422
|
+
text2({
|
|
423
|
+
name: "editedBy",
|
|
424
|
+
label: "Edited By",
|
|
425
|
+
admin: {
|
|
426
|
+
readOnly: true,
|
|
427
|
+
description: "ID of the admin who last edited the submitted data"
|
|
428
|
+
}
|
|
363
429
|
})
|
|
364
430
|
];
|
|
365
431
|
let finalFields;
|
|
@@ -375,16 +441,35 @@ function submissionsCollection(pluginConfig) {
|
|
|
375
441
|
labels,
|
|
376
442
|
fields: finalFields,
|
|
377
443
|
timestamps: true,
|
|
444
|
+
// Submissions carry visitor-entered answers plus ipAddress/userAgent, so the
|
|
445
|
+
// default `entry.*` events stay suppressed — otherwise every submission would
|
|
446
|
+
// be delivered to any endpoint subscribed to `entry.created` or `*`. Instead
|
|
447
|
+
// emit a curated `form.submission.created` carrying ONLY safe metadata (which
|
|
448
|
+
// form, when, and the status) — never the answers, IP, or user agent.
|
|
449
|
+
webhooks: {
|
|
450
|
+
record: false,
|
|
451
|
+
emit: {
|
|
452
|
+
event: "form.submission.created",
|
|
453
|
+
fields: ["form", "submittedAt", "status"]
|
|
454
|
+
}
|
|
455
|
+
},
|
|
378
456
|
admin: {
|
|
379
457
|
isPlugin: true,
|
|
380
458
|
group: "Forms",
|
|
381
459
|
order: 51,
|
|
382
460
|
useAsTitle: "id",
|
|
383
461
|
description: "View and manage form submissions",
|
|
384
|
-
//
|
|
462
|
+
// Submissions are created by visitors submitting forms — a hand-typed
|
|
463
|
+
// "New Submission" would be fiction, so the admin never offers one.
|
|
464
|
+
disableCreate: true,
|
|
385
465
|
components: {
|
|
386
|
-
//
|
|
387
|
-
|
|
466
|
+
// The full submissions experience (per-form columns, status tabs,
|
|
467
|
+
// drawer detail, export) replaces the generic list.
|
|
468
|
+
views: {
|
|
469
|
+
List: {
|
|
470
|
+
Component: "@nextlyhq/plugin-form-builder/admin#SubmissionsView"
|
|
471
|
+
}
|
|
472
|
+
}
|
|
388
473
|
},
|
|
389
474
|
...overrides.admin
|
|
390
475
|
},
|
|
@@ -401,7 +486,9 @@ function submissionsCollection(pluginConfig) {
|
|
|
401
486
|
delete: accessOverrides?.delete ?? (({ roles }) => roles.includes("admin") || roles.includes("super-admin"))
|
|
402
487
|
},
|
|
403
488
|
hooks: {
|
|
404
|
-
// Auto-set submittedAt timestamp on creation
|
|
489
|
+
// Auto-set submittedAt timestamp on creation. (The edit stamp for
|
|
490
|
+
// updates is registered in plugin.ts init — direct registry hooks are
|
|
491
|
+
// the path that runs for every API surface.)
|
|
405
492
|
beforeValidate: [
|
|
406
493
|
(context) => {
|
|
407
494
|
const { data, operation } = context;
|
|
@@ -424,9 +511,12 @@ function submissionsCollection(pluginConfig) {
|
|
|
424
511
|
}
|
|
425
512
|
|
|
426
513
|
// src/plugin.ts
|
|
514
|
+
var { version: PLUGIN_VERSION } = createRequire(import.meta.url)(
|
|
515
|
+
"../package.json"
|
|
516
|
+
);
|
|
427
517
|
function resolveConfig(options) {
|
|
428
|
-
const formOverrides = options.formOverrides ||
|
|
429
|
-
const submissionOverrides = options.formSubmissionOverrides ||
|
|
518
|
+
const formOverrides = options.formOverrides || {};
|
|
519
|
+
const submissionOverrides = options.formSubmissionOverrides || {};
|
|
430
520
|
return {
|
|
431
521
|
formOverrides: {
|
|
432
522
|
...formOverrides,
|
|
@@ -495,41 +585,206 @@ function formBuilder(options = {}) {
|
|
|
495
585
|
const resolvedConfig = resolveConfig(options);
|
|
496
586
|
const formsCol = formsCollection(resolvedConfig);
|
|
497
587
|
const submissionsCol = submissionsCollection(resolvedConfig);
|
|
498
|
-
const plugin = {
|
|
588
|
+
const plugin = definePlugin({
|
|
499
589
|
name: "@nextlyhq/plugin-form-builder",
|
|
500
|
-
version:
|
|
501
|
-
|
|
590
|
+
version: PLUGIN_VERSION,
|
|
591
|
+
nextly: ">=0.0.2-alpha.21",
|
|
592
|
+
// Identity metadata for the admin plugins page, mirroring package.json.
|
|
593
|
+
author: "Nextly",
|
|
594
|
+
homepage: "https://nextlyhq.com",
|
|
595
|
+
repository: "https://github.com/nextlyhq/nextly",
|
|
596
|
+
license: "MIT",
|
|
597
|
+
category: "forms",
|
|
598
|
+
tags: ["forms", "submissions", "email-notifications"],
|
|
599
|
+
// Declarative schema: the merged pipeline folds these into the
|
|
600
|
+
// app schema — no manual `setup()` append needed. Just register the plugin.
|
|
601
|
+
contributes: {
|
|
602
|
+
collections: [formsCol, submissionsCol],
|
|
603
|
+
// Custom permission — gates submission export beyond CRUD. The
|
|
604
|
+
// canonical example third-party plugin authors copy.
|
|
605
|
+
permissions: [
|
|
606
|
+
{
|
|
607
|
+
action: "export",
|
|
608
|
+
resource: "submissions",
|
|
609
|
+
label: "Export Submissions",
|
|
610
|
+
description: "Export form submissions as CSV/JSON",
|
|
611
|
+
// No `group`: the admin already files this under the plugin that
|
|
612
|
+
// declared it, so naming the plugin again would nest it inside
|
|
613
|
+
// itself. `group` is for a plugin with enough permissions to sort
|
|
614
|
+
// its own into headings, which one is not.
|
|
615
|
+
//
|
|
616
|
+
// `danger` because the point of the permission is to take
|
|
617
|
+
// submissions — names, emails, whatever a form asked for — out of
|
|
618
|
+
// the site in a file.
|
|
619
|
+
danger: true
|
|
620
|
+
}
|
|
621
|
+
],
|
|
622
|
+
// HTTP route — exported at
|
|
623
|
+
// /api/plugins/@nextlyhq/plugin-form-builder/submissions/export. Secure by
|
|
624
|
+
// default: gated by the custom `export-submissions` permission. Reads
|
|
625
|
+
// via the secure-by-default service path as the authed user and
|
|
626
|
+
// resolves its OWN slug through `ctx.self`. The canonical
|
|
627
|
+
// contributes.routes example for third-party authors.
|
|
628
|
+
routes: [
|
|
629
|
+
{
|
|
630
|
+
method: "GET",
|
|
631
|
+
path: "/submissions/export",
|
|
632
|
+
requiredPermission: "export-submissions",
|
|
633
|
+
handler: async (req, ctx) => {
|
|
634
|
+
const declaredSlug = resolvedConfig.formSubmissionOverrides.slug;
|
|
635
|
+
const slug = ctx.self.collections[declaredSlug] ?? declaredSlug;
|
|
636
|
+
const url2 = new URL(req.url);
|
|
637
|
+
const format = url2.searchParams.get("format") ?? "json";
|
|
638
|
+
const formId = url2.searchParams.get("form");
|
|
639
|
+
const status = url2.searchParams.get("status");
|
|
640
|
+
const where = {};
|
|
641
|
+
if (formId) where.form = { equals: formId };
|
|
642
|
+
if (status) where.status = { equals: status };
|
|
643
|
+
else where.status = { not_equals: "spam" };
|
|
644
|
+
const opts = { as: "user", user: ctx.user ?? void 0 };
|
|
645
|
+
let form;
|
|
646
|
+
if (format === "csv") {
|
|
647
|
+
if (!formId) {
|
|
648
|
+
return Response.json(
|
|
649
|
+
{
|
|
650
|
+
error: {
|
|
651
|
+
code: "VALIDATION_ERROR",
|
|
652
|
+
message: "CSV export requires a form parameter."
|
|
653
|
+
}
|
|
654
|
+
},
|
|
655
|
+
{ status: 400 }
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
const formsSlugDeclared = resolvedConfig.formOverrides.slug;
|
|
659
|
+
const formsSlug = ctx.self.collections[formsSlugDeclared] ?? formsSlugDeclared;
|
|
660
|
+
const formResult = await ctx.services.collections.listEntries(
|
|
661
|
+
formsSlug,
|
|
662
|
+
{ where: { id: { equals: formId } } },
|
|
663
|
+
opts
|
|
664
|
+
);
|
|
665
|
+
form = formResult.data[0];
|
|
666
|
+
if (!form) {
|
|
667
|
+
return Response.json(
|
|
668
|
+
{ error: { code: "NOT_FOUND", message: "Form not found." } },
|
|
669
|
+
{ status: 404 }
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
const MAX_EXPORT_ROWS = 5e4;
|
|
674
|
+
const items = [];
|
|
675
|
+
const pageSize = 200;
|
|
676
|
+
let truncated = false;
|
|
677
|
+
for (let page = 1; ; page += 1) {
|
|
678
|
+
const result = await ctx.services.collections.listEntries(
|
|
679
|
+
slug,
|
|
680
|
+
{ where, pagination: { limit: pageSize, page } },
|
|
681
|
+
opts
|
|
682
|
+
);
|
|
683
|
+
items.push(...result.data);
|
|
684
|
+
const pageWasFull = result.data.length === pageSize;
|
|
685
|
+
if (items.length >= MAX_EXPORT_ROWS) {
|
|
686
|
+
truncated = pageWasFull || items.length > MAX_EXPORT_ROWS;
|
|
687
|
+
items.length = MAX_EXPORT_ROWS;
|
|
688
|
+
break;
|
|
689
|
+
}
|
|
690
|
+
if (!pageWasFull) break;
|
|
691
|
+
}
|
|
692
|
+
const truncationHeaders = truncated ? { "X-Export-Truncated": "true" } : {};
|
|
693
|
+
if (format !== "csv" || !form) {
|
|
694
|
+
return Response.json({ items }, { headers: truncationHeaders });
|
|
695
|
+
}
|
|
696
|
+
const csv = exportToCSV(
|
|
697
|
+
items,
|
|
698
|
+
form
|
|
699
|
+
);
|
|
700
|
+
const filename = generateExportFilename(
|
|
701
|
+
typeof form.slug === "string" ? form.slug : "form",
|
|
702
|
+
"csv"
|
|
703
|
+
);
|
|
704
|
+
return new Response(csv, {
|
|
705
|
+
headers: {
|
|
706
|
+
"Content-Type": "text/csv; charset=utf-8",
|
|
707
|
+
"Content-Disposition": `attachment; filename="${filename}"`,
|
|
708
|
+
...truncationHeaders
|
|
709
|
+
}
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
},
|
|
713
|
+
{
|
|
714
|
+
// The builder UI reads the host's resolved field enable/disable map
|
|
715
|
+
// from here, so the plugin option actually gates the type picker.
|
|
716
|
+
// Options resolve server-side only; this is the one channel the
|
|
717
|
+
// admin client has to them.
|
|
718
|
+
method: "GET",
|
|
719
|
+
path: "/builder-config",
|
|
720
|
+
// Derived from the forms slug like the menu entry: with an
|
|
721
|
+
// overridden slug, users hold read-<slug>, not read-forms.
|
|
722
|
+
requiredPermission: `read-${resolvedConfig.formOverrides.slug}`,
|
|
723
|
+
handler: (_req, ctx) => Promise.resolve(
|
|
724
|
+
Response.json({
|
|
725
|
+
fields: resolvedConfig.fields,
|
|
726
|
+
// Notification defaults the builder surfaces: defaultToEmail
|
|
727
|
+
// seeds a new form's first rule, defaultFrom renders as the
|
|
728
|
+
// inherited sender placeholder.
|
|
729
|
+
notifications: {
|
|
730
|
+
defaultFrom: resolvedConfig.notifications.defaultFrom,
|
|
731
|
+
defaultToEmail: resolvedConfig.notifications.defaultToEmail
|
|
732
|
+
},
|
|
733
|
+
// Spam defaults the Settings tab surfaces, so per-form
|
|
734
|
+
// override selects can show what "inherit" resolves to.
|
|
735
|
+
spamProtection: {
|
|
736
|
+
honeypot: resolvedConfig.spamProtection.honeypot,
|
|
737
|
+
recaptchaEnabled: resolvedConfig.spamProtection.recaptcha?.enabled ?? false
|
|
738
|
+
},
|
|
739
|
+
// Runtime-resolved collection slugs (through ctx.self, so a
|
|
740
|
+
// framework .rename() is honored too) — admin components
|
|
741
|
+
// never hardcode "forms"/"form-submissions".
|
|
742
|
+
slugs: {
|
|
743
|
+
forms: ctx.self.collections[resolvedConfig.formOverrides.slug] ?? resolvedConfig.formOverrides.slug,
|
|
744
|
+
submissions: ctx.self.collections[resolvedConfig.formSubmissionOverrides.slug] ?? resolvedConfig.formSubmissionOverrides.slug
|
|
745
|
+
}
|
|
746
|
+
})
|
|
747
|
+
)
|
|
748
|
+
}
|
|
749
|
+
]
|
|
750
|
+
},
|
|
751
|
+
// Forms is a first-class destination, not a plugin detail: standalone
|
|
752
|
+
// placement gives it its own main-rail icon after Media, and its
|
|
753
|
+
// sub-sidebar lists the plugin's collections (Forms, Submissions) —
|
|
754
|
+
// no separate menu contribution, so "Forms" exists exactly once.
|
|
755
|
+
// Hosts preferring the Plugins section override placement in config.
|
|
756
|
+
// (The old admin.settings.component that rendered a second full builder
|
|
757
|
+
// at /admin/plugins/<slug> is gone; the collection Edit-view override
|
|
758
|
+
// is the single FormBuilderView mount.)
|
|
502
759
|
admin: {
|
|
760
|
+
placement: "standalone",
|
|
761
|
+
after: "media",
|
|
503
762
|
order: 50,
|
|
763
|
+
appearance: { icon: "FileText", label: "Forms" },
|
|
504
764
|
description: "Create and manage forms with submission tracking"
|
|
505
765
|
},
|
|
506
|
-
// -- Config transformer --------------------------------------------------
|
|
507
|
-
// Automatically adds plugin collections so users don't have to spread them.
|
|
508
|
-
config(config) {
|
|
509
|
-
const existing = config.collections || [];
|
|
510
|
-
const formsSlug = resolvedConfig.formOverrides.slug;
|
|
511
|
-
const submissionsSlug = resolvedConfig.formSubmissionOverrides.slug;
|
|
512
|
-
const toAdd = [];
|
|
513
|
-
if (!existing.some((c) => c.slug === formsSlug))
|
|
514
|
-
toAdd.push(formsCol);
|
|
515
|
-
if (!existing.some((c) => c.slug === submissionsSlug))
|
|
516
|
-
toAdd.push(submissionsCol);
|
|
517
|
-
return { ...config, collections: [...existing, ...toAdd] };
|
|
518
|
-
},
|
|
519
766
|
// -- Init ----------------------------------------------------------------
|
|
520
767
|
// Registers an afterCreate hook on submissions to send email notifications.
|
|
521
768
|
init(nextly) {
|
|
522
|
-
const
|
|
523
|
-
const
|
|
524
|
-
|
|
525
|
-
if (g[guardKey]) return;
|
|
526
|
-
g[guardKey] = true;
|
|
527
|
-
nextly.infra.logger.info("Form Builder plugin initialized", {
|
|
769
|
+
const declaredSubmissionsSlug = resolvedConfig.formSubmissionOverrides.slug;
|
|
770
|
+
const submissionSlug = nextly.self.collections[declaredSubmissionsSlug] ?? declaredSubmissionsSlug;
|
|
771
|
+
nextly.logger.info("Form Builder plugin initialized", {
|
|
528
772
|
formsCollection: resolvedConfig.formOverrides.slug,
|
|
529
773
|
submissionsCollection: submissionSlug,
|
|
530
774
|
enabledFields: Object.entries(resolvedConfig.fields).filter(([, on]) => on).map(([name]) => name)
|
|
531
775
|
});
|
|
532
776
|
nextly.__formBuilderConfig = resolvedConfig;
|
|
777
|
+
if (resolvedConfig.beforeEmail) {
|
|
778
|
+
nextly.filters.add(
|
|
779
|
+
"form-builder.beforeEmail",
|
|
780
|
+
(emails, ctx) => resolvedConfig.beforeEmail({
|
|
781
|
+
emails,
|
|
782
|
+
// Boundary: loose runtime documents → the user's typed contract.
|
|
783
|
+
form: ctx.form,
|
|
784
|
+
submission: ctx.submission
|
|
785
|
+
})
|
|
786
|
+
);
|
|
787
|
+
}
|
|
533
788
|
nextly.hooks.on(
|
|
534
789
|
"afterCreate",
|
|
535
790
|
submissionSlug,
|
|
@@ -537,8 +792,42 @@ function formBuilder(options = {}) {
|
|
|
537
792
|
await handleSubmissionCreated(context, resolvedConfig, nextly);
|
|
538
793
|
}
|
|
539
794
|
);
|
|
795
|
+
nextly.hooks.on("beforeUpdate", submissionSlug, (context) => {
|
|
796
|
+
const ctx = context;
|
|
797
|
+
if (ctx.data && ctx.data.data !== void 0) {
|
|
798
|
+
ctx.data.editedAt = /* @__PURE__ */ new Date();
|
|
799
|
+
ctx.data.editedBy = ctx.user?.id ?? null;
|
|
800
|
+
}
|
|
801
|
+
return ctx.data;
|
|
802
|
+
});
|
|
803
|
+
const declaredFormsSlug = resolvedConfig.formOverrides.slug;
|
|
804
|
+
const formsSlug = nextly.self.collections[declaredFormsSlug] ?? declaredFormsSlug;
|
|
805
|
+
nextly.hooks.on("afterRead", formsSlug, async (context) => {
|
|
806
|
+
const data = context.data;
|
|
807
|
+
const records = Array.isArray(data) ? data : data ? [data] : [];
|
|
808
|
+
await Promise.all(
|
|
809
|
+
records.map(async (record) => {
|
|
810
|
+
const form = record;
|
|
811
|
+
if (typeof form.id !== "string") return;
|
|
812
|
+
try {
|
|
813
|
+
form.submissionCount = await nextly.services.collections.count(
|
|
814
|
+
submissionSlug,
|
|
815
|
+
{
|
|
816
|
+
where: {
|
|
817
|
+
form: { equals: form.id },
|
|
818
|
+
status: { not_equals: "spam" }
|
|
819
|
+
}
|
|
820
|
+
},
|
|
821
|
+
{ as: "system" }
|
|
822
|
+
);
|
|
823
|
+
} catch {
|
|
824
|
+
form.submissionCount = 0;
|
|
825
|
+
}
|
|
826
|
+
})
|
|
827
|
+
);
|
|
828
|
+
});
|
|
540
829
|
}
|
|
541
|
-
};
|
|
830
|
+
});
|
|
542
831
|
return {
|
|
543
832
|
plugin,
|
|
544
833
|
collections: [formsCol, submissionsCol],
|
|
@@ -574,9 +863,78 @@ function collectAttachmentInputs(fields, submittedData) {
|
|
|
574
863
|
}
|
|
575
864
|
return attachments;
|
|
576
865
|
}
|
|
866
|
+
function parseJsonColumn(value) {
|
|
867
|
+
if (typeof value === "string") {
|
|
868
|
+
try {
|
|
869
|
+
const parsed = JSON.parse(value);
|
|
870
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
871
|
+
} catch {
|
|
872
|
+
return {};
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
876
|
+
}
|
|
877
|
+
function normalizeAddressList(addresses) {
|
|
878
|
+
if (!Array.isArray(addresses)) return void 0;
|
|
879
|
+
const cleaned = addresses.map((address) => typeof address === "string" ? address.trim() : "").filter(Boolean);
|
|
880
|
+
return cleaned.length > 0 ? cleaned : void 0;
|
|
881
|
+
}
|
|
882
|
+
function buildNotificationEmails(input) {
|
|
883
|
+
const { notifications, submittedData, formName, submissionId, defaultFrom } = input;
|
|
884
|
+
const seen = /* @__PURE__ */ new Set();
|
|
885
|
+
const emails = [];
|
|
886
|
+
const skipped = [];
|
|
887
|
+
for (const notification of notifications) {
|
|
888
|
+
if (!notification.enabled) continue;
|
|
889
|
+
if (!notification.templateSlug) {
|
|
890
|
+
skipped.push({ notificationId: notification.id, reason: "no-template" });
|
|
891
|
+
continue;
|
|
892
|
+
}
|
|
893
|
+
const key = notification.id || JSON.stringify(notification);
|
|
894
|
+
if (seen.has(key)) continue;
|
|
895
|
+
seen.add(key);
|
|
896
|
+
if (notification.condition && !evaluateSingleCondition(notification.condition, submittedData)) {
|
|
897
|
+
skipped.push({
|
|
898
|
+
notificationId: notification.id,
|
|
899
|
+
reason: "condition-unmet"
|
|
900
|
+
});
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
const to = (notification.recipientType === "field" ? resolveFieldRef(notification.to, submittedData) : notification.to).trim();
|
|
904
|
+
if (!to) {
|
|
905
|
+
skipped.push({
|
|
906
|
+
notificationId: notification.id,
|
|
907
|
+
reason: "empty-recipient"
|
|
908
|
+
});
|
|
909
|
+
continue;
|
|
910
|
+
}
|
|
911
|
+
const cc = normalizeAddressList(notification.cc);
|
|
912
|
+
const bcc = normalizeAddressList(notification.bcc);
|
|
913
|
+
const from = notification.senderEmail?.trim() || defaultFrom?.trim() || void 0;
|
|
914
|
+
const replyTo = notification.replyTo ? resolveFieldRef(notification.replyTo, submittedData).trim() || void 0 : void 0;
|
|
915
|
+
emails.push({
|
|
916
|
+
to,
|
|
917
|
+
templateSlug: notification.templateSlug,
|
|
918
|
+
variables: {
|
|
919
|
+
...submittedData,
|
|
920
|
+
formName,
|
|
921
|
+
submissionId
|
|
922
|
+
},
|
|
923
|
+
providerId: notification.providerId,
|
|
924
|
+
from,
|
|
925
|
+
replyTo,
|
|
926
|
+
cc,
|
|
927
|
+
bcc,
|
|
928
|
+
notificationId: notification.id
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
return { emails, skipped };
|
|
932
|
+
}
|
|
577
933
|
async function handleSubmissionCreated(context, config, nextly) {
|
|
934
|
+
if (!config.notifications.enabled) return;
|
|
578
935
|
const submission = context.data;
|
|
579
936
|
if (!submission) return;
|
|
937
|
+
if (submission.status === "spam") return;
|
|
580
938
|
const rawFormId = submission.form;
|
|
581
939
|
let formId = null;
|
|
582
940
|
if (typeof rawFormId === "string") {
|
|
@@ -592,46 +950,59 @@ async function handleSubmissionCreated(context, config, nextly) {
|
|
|
592
950
|
if (notifications.length === 0) return;
|
|
593
951
|
const emailService = nextly.services.email;
|
|
594
952
|
if (!emailService) return;
|
|
595
|
-
const submittedData = submission.data
|
|
953
|
+
const submittedData = parseJsonColumn(submission.data);
|
|
596
954
|
const formFields = Array.isArray(form.fields) ? form.fields : [];
|
|
597
955
|
const fileAttachments = collectAttachmentInputs(formFields, submittedData);
|
|
598
|
-
const
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
956
|
+
const { emails, skipped } = buildNotificationEmails({
|
|
957
|
+
notifications,
|
|
958
|
+
submittedData,
|
|
959
|
+
formName: form.name,
|
|
960
|
+
submissionId: submission.id,
|
|
961
|
+
defaultFrom: config.notifications.defaultFrom
|
|
962
|
+
});
|
|
963
|
+
for (const skip of skipped) {
|
|
964
|
+
if (skip.reason === "empty-recipient") {
|
|
965
|
+
nextly.logger.warn?.(
|
|
966
|
+
"Form Builder: empty recipient, skipping notification",
|
|
967
|
+
{ notificationId: skip.notificationId, formSlug: form.slug }
|
|
968
|
+
);
|
|
969
|
+
} else {
|
|
970
|
+
nextly.logger.debug?.("Form Builder: notification skipped", {
|
|
971
|
+
notificationId: skip.notificationId,
|
|
972
|
+
formSlug: form.slug,
|
|
973
|
+
reason: skip.reason
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
if (emails.length === 0) return;
|
|
978
|
+
const finalEmails = await nextly.filters.apply("form-builder.beforeEmail", emails, { form, submission });
|
|
979
|
+
for (const email2 of finalEmails) {
|
|
604
980
|
try {
|
|
605
|
-
const to = notification.recipientType === "field" ? resolveFieldRef(notification.to, submittedData) : notification.to;
|
|
606
|
-
if (!to) {
|
|
607
|
-
nextly.infra.logger.warn?.(
|
|
608
|
-
"Form Builder: empty recipient, skipping notification",
|
|
609
|
-
{ notificationId: notification.id, formSlug: form.slug }
|
|
610
|
-
);
|
|
611
|
-
continue;
|
|
612
|
-
}
|
|
613
|
-
const cc = Array.isArray(notification.cc) && notification.cc.length ? notification.cc : void 0;
|
|
614
|
-
const bcc = Array.isArray(notification.bcc) && notification.bcc.length ? notification.bcc : void 0;
|
|
615
981
|
await emailService.sendWithTemplate(
|
|
616
|
-
|
|
617
|
-
to,
|
|
618
|
-
|
|
982
|
+
email2.templateSlug,
|
|
983
|
+
email2.to,
|
|
984
|
+
email2.variables,
|
|
619
985
|
{
|
|
620
|
-
providerId:
|
|
621
|
-
|
|
622
|
-
|
|
986
|
+
providerId: email2.providerId,
|
|
987
|
+
from: email2.from,
|
|
988
|
+
replyTo: email2.replyTo,
|
|
989
|
+
cc: email2.cc,
|
|
990
|
+
bcc: email2.bcc,
|
|
623
991
|
attachments: fileAttachments.length > 0 ? fileAttachments : void 0
|
|
624
992
|
}
|
|
625
993
|
);
|
|
626
|
-
nextly.
|
|
994
|
+
nextly.logger.info?.("Form Builder: notification sent", {
|
|
627
995
|
formSlug: form.slug,
|
|
628
|
-
to,
|
|
629
|
-
templateSlug:
|
|
996
|
+
to: email2.to,
|
|
997
|
+
templateSlug: email2.templateSlug,
|
|
998
|
+
notificationId: email2.notificationId,
|
|
630
999
|
attachmentCount: fileAttachments.length
|
|
631
1000
|
});
|
|
632
1001
|
} catch (err) {
|
|
633
|
-
nextly.
|
|
634
|
-
|
|
1002
|
+
nextly.logger.error?.("Form Builder: notification failed", {
|
|
1003
|
+
to: email2.to,
|
|
1004
|
+
templateSlug: email2.templateSlug,
|
|
1005
|
+
notificationId: email2.notificationId,
|
|
635
1006
|
error: err instanceof Error ? err.message : String(err)
|
|
636
1007
|
});
|
|
637
1008
|
}
|
|
@@ -639,27 +1010,14 @@ async function handleSubmissionCreated(context, config, nextly) {
|
|
|
639
1010
|
}
|
|
640
1011
|
async function fetchParentForm(config, formId, nextly) {
|
|
641
1012
|
try {
|
|
642
|
-
const
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
}
|
|
649
|
-
const result = await handler.getEntry({
|
|
650
|
-
collectionName: config.formOverrides.slug,
|
|
651
|
-
entryId: formId,
|
|
652
|
-
overrideAccess: true
|
|
653
|
-
});
|
|
654
|
-
const fromData = result?.data;
|
|
655
|
-
if (fromData) return fromData;
|
|
656
|
-
const fromDoc = result?.doc;
|
|
657
|
-
if (fromDoc && typeof fromDoc === "object") {
|
|
658
|
-
return fromDoc;
|
|
659
|
-
}
|
|
660
|
-
return null;
|
|
1013
|
+
const form = await nextly.services.collections.findEntryById(
|
|
1014
|
+
config.formOverrides.slug,
|
|
1015
|
+
formId,
|
|
1016
|
+
{ as: "system" }
|
|
1017
|
+
);
|
|
1018
|
+
return form;
|
|
661
1019
|
} catch (err) {
|
|
662
|
-
nextly.
|
|
1020
|
+
nextly.logger.error?.("Form Builder: failed to fetch form", {
|
|
663
1021
|
formId,
|
|
664
1022
|
error: err instanceof Error ? err.message : String(err)
|
|
665
1023
|
});
|
|
@@ -667,30 +1025,8 @@ async function fetchParentForm(config, formId, nextly) {
|
|
|
667
1025
|
}
|
|
668
1026
|
}
|
|
669
1027
|
|
|
670
|
-
// src/types.ts
|
|
671
|
-
function isFormField(value) {
|
|
672
|
-
return typeof value === "object" && value !== null && "type" in value && "name" in value && "label" in value;
|
|
673
|
-
}
|
|
674
|
-
function isFormFieldType(field, type) {
|
|
675
|
-
return field.type === type;
|
|
676
|
-
}
|
|
677
|
-
function isTextFormField(field) {
|
|
678
|
-
return field.type === "text";
|
|
679
|
-
}
|
|
680
|
-
function isEmailFormField(field) {
|
|
681
|
-
return field.type === "email";
|
|
682
|
-
}
|
|
683
|
-
function isSelectFormField(field) {
|
|
684
|
-
return field.type === "select";
|
|
685
|
-
}
|
|
686
|
-
function isCheckboxFormField(field) {
|
|
687
|
-
return field.type === "checkbox";
|
|
688
|
-
}
|
|
689
|
-
function isFileFormField(field) {
|
|
690
|
-
return field.type === "file";
|
|
691
|
-
}
|
|
692
|
-
|
|
693
1028
|
// src/config/validate-form.ts
|
|
1029
|
+
import { isPluginFieldTypeOnSurface } from "nextly";
|
|
694
1030
|
var RESERVED_FORM_SLUGS = [
|
|
695
1031
|
// API routes
|
|
696
1032
|
"api",
|
|
@@ -707,21 +1043,7 @@ var RESERVED_FORM_SLUGS = [
|
|
|
707
1043
|
var RESERVED_SLUGS_SET = new Set(RESERVED_FORM_SLUGS);
|
|
708
1044
|
var SLUG_PATTERN = /^[a-z][a-z0-9_-]*$/;
|
|
709
1045
|
var FIELD_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]*$/;
|
|
710
|
-
var VALID_FIELD_TYPES =
|
|
711
|
-
"text",
|
|
712
|
-
"email",
|
|
713
|
-
"number",
|
|
714
|
-
"phone",
|
|
715
|
-
"url",
|
|
716
|
-
"textarea",
|
|
717
|
-
"select",
|
|
718
|
-
"checkbox",
|
|
719
|
-
"radio",
|
|
720
|
-
"file",
|
|
721
|
-
"date",
|
|
722
|
-
"time",
|
|
723
|
-
"hidden"
|
|
724
|
-
];
|
|
1046
|
+
var VALID_FIELD_TYPES = BUILT_IN_FORM_FIELD_TYPES;
|
|
725
1047
|
var VALID_FIELD_TYPES_SET = new Set(VALID_FIELD_TYPES);
|
|
726
1048
|
function isReservedSlug(slug) {
|
|
727
1049
|
return RESERVED_SLUGS_SET.has(slug.toLowerCase());
|
|
@@ -937,7 +1259,8 @@ function validateField(field, path, errors, seenNames) {
|
|
|
937
1259
|
});
|
|
938
1260
|
return;
|
|
939
1261
|
}
|
|
940
|
-
|
|
1262
|
+
const isValidType = typeof fieldType === "string" && (VALID_FIELD_TYPES_SET.has(fieldType) || isPluginFieldTypeOnSurface(fieldType, "forms"));
|
|
1263
|
+
if (!isValidType) {
|
|
941
1264
|
let fieldTypeRepr;
|
|
942
1265
|
if (typeof fieldType === "object" && fieldType !== null) {
|
|
943
1266
|
fieldTypeRepr = JSON.stringify(fieldType);
|
|
@@ -1065,82 +1388,6 @@ ${errorMessages}`
|
|
|
1065
1388
|
}
|
|
1066
1389
|
}
|
|
1067
1390
|
|
|
1068
|
-
// src/config/defaults.ts
|
|
1069
|
-
var DEFAULT_FORM_SETTINGS = {
|
|
1070
|
-
submitButtonText: "Submit",
|
|
1071
|
-
confirmationType: "message",
|
|
1072
|
-
successMessage: "Thank you for your submission!",
|
|
1073
|
-
allowMultipleSubmissions: true
|
|
1074
|
-
};
|
|
1075
|
-
var DEFAULT_NOTIFICATION_SETTINGS = {
|
|
1076
|
-
enabled: true,
|
|
1077
|
-
replyTo: true
|
|
1078
|
-
};
|
|
1079
|
-
function toTitleCase(str) {
|
|
1080
|
-
return str.replace(/[-_]/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
|
|
1081
|
-
}
|
|
1082
|
-
function pluralize(singular) {
|
|
1083
|
-
if (singular.endsWith("y") && !/[aeiou]y$/i.test(singular)) {
|
|
1084
|
-
return singular.slice(0, -1) + "ies";
|
|
1085
|
-
}
|
|
1086
|
-
if (singular.endsWith("s") || singular.endsWith("x") || singular.endsWith("z") || singular.endsWith("ch") || singular.endsWith("sh")) {
|
|
1087
|
-
return singular + "es";
|
|
1088
|
-
}
|
|
1089
|
-
return singular + "s";
|
|
1090
|
-
}
|
|
1091
|
-
function applyFormDefaults(config) {
|
|
1092
|
-
const singularLabel = config.labels?.singular || toTitleCase(config.slug);
|
|
1093
|
-
return {
|
|
1094
|
-
...config,
|
|
1095
|
-
// Generate labels from slug if not provided
|
|
1096
|
-
labels: {
|
|
1097
|
-
singular: singularLabel,
|
|
1098
|
-
plural: config.labels?.plural || pluralize(singularLabel)
|
|
1099
|
-
},
|
|
1100
|
-
// Apply settings defaults
|
|
1101
|
-
settings: {
|
|
1102
|
-
submitButtonText: config.settings?.submitButtonText || DEFAULT_FORM_SETTINGS.submitButtonText,
|
|
1103
|
-
confirmationType: config.settings?.confirmationType || DEFAULT_FORM_SETTINGS.confirmationType,
|
|
1104
|
-
successMessage: config.settings?.successMessage || DEFAULT_FORM_SETTINGS.successMessage,
|
|
1105
|
-
allowMultipleSubmissions: config.settings?.allowMultipleSubmissions ?? DEFAULT_FORM_SETTINGS.allowMultipleSubmissions,
|
|
1106
|
-
// Preserve other settings that don't have defaults
|
|
1107
|
-
redirectUrl: config.settings?.redirectUrl,
|
|
1108
|
-
redirectRelation: config.settings?.redirectRelation,
|
|
1109
|
-
captcha: config.settings?.captcha
|
|
1110
|
-
},
|
|
1111
|
-
// Apply notification defaults if notifications are configured
|
|
1112
|
-
notifications: config.notifications ? {
|
|
1113
|
-
enabled: config.notifications.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.enabled,
|
|
1114
|
-
recipients: config.notifications.recipients || [],
|
|
1115
|
-
subject: config.notifications.subject || `New submission for ${singularLabel}`,
|
|
1116
|
-
replyTo: config.notifications.replyTo ?? DEFAULT_NOTIFICATION_SETTINGS.replyTo,
|
|
1117
|
-
template: config.notifications.template
|
|
1118
|
-
} : void 0,
|
|
1119
|
-
// Apply access defaults
|
|
1120
|
-
access: {
|
|
1121
|
-
// Default: anyone can submit the form
|
|
1122
|
-
submit: config.access?.submit || (() => true),
|
|
1123
|
-
// Default: only authenticated users can view submissions
|
|
1124
|
-
read: config.access?.read || (({ req }) => !!req.user)
|
|
1125
|
-
}
|
|
1126
|
-
};
|
|
1127
|
-
}
|
|
1128
|
-
function applyFieldDefaults(field) {
|
|
1129
|
-
const label = field.label || (field.name ? toTitleCase(field.name.replace(/([A-Z])/g, " $1").trim()) : "");
|
|
1130
|
-
return {
|
|
1131
|
-
...field,
|
|
1132
|
-
label,
|
|
1133
|
-
required: field.required ?? false
|
|
1134
|
-
};
|
|
1135
|
-
}
|
|
1136
|
-
function createFormConfig(slug, fields, options) {
|
|
1137
|
-
return applyFormDefaults({
|
|
1138
|
-
slug,
|
|
1139
|
-
fields,
|
|
1140
|
-
...options
|
|
1141
|
-
});
|
|
1142
|
-
}
|
|
1143
|
-
|
|
1144
1391
|
// src/config/field-helpers.ts
|
|
1145
1392
|
var option = (label, value) => ({
|
|
1146
1393
|
label,
|
|
@@ -1199,189 +1446,18 @@ var hidden = (config) => ({
|
|
|
1199
1446
|
type: "hidden"
|
|
1200
1447
|
});
|
|
1201
1448
|
|
|
1202
|
-
// src/utils/evaluate-conditions.ts
|
|
1203
|
-
function isEmptyValue(value) {
|
|
1204
|
-
if (value === void 0 || value === null) {
|
|
1205
|
-
return true;
|
|
1206
|
-
}
|
|
1207
|
-
if (typeof value === "string" && value.trim() === "") {
|
|
1208
|
-
return true;
|
|
1209
|
-
}
|
|
1210
|
-
if (Array.isArray(value) && value.length === 0) {
|
|
1211
|
-
return true;
|
|
1212
|
-
}
|
|
1213
|
-
return false;
|
|
1214
|
-
}
|
|
1215
|
-
function toNumber(value) {
|
|
1216
|
-
if (typeof value === "number") {
|
|
1217
|
-
return value;
|
|
1218
|
-
}
|
|
1219
|
-
if (typeof value === "string") {
|
|
1220
|
-
const trimmed = value.trim();
|
|
1221
|
-
if (trimmed === "") {
|
|
1222
|
-
return NaN;
|
|
1223
|
-
}
|
|
1224
|
-
return Number(trimmed);
|
|
1225
|
-
}
|
|
1226
|
-
return NaN;
|
|
1227
|
-
}
|
|
1228
|
-
function toString(value) {
|
|
1229
|
-
if (value === void 0 || value === null) {
|
|
1230
|
-
return "";
|
|
1231
|
-
}
|
|
1232
|
-
if (typeof value === "string") {
|
|
1233
|
-
return value;
|
|
1234
|
-
}
|
|
1235
|
-
if (Array.isArray(value)) {
|
|
1236
|
-
return value.join(", ");
|
|
1237
|
-
}
|
|
1238
|
-
if (typeof value === "object") {
|
|
1239
|
-
return JSON.stringify(value);
|
|
1240
|
-
}
|
|
1241
|
-
return String(value);
|
|
1242
|
-
}
|
|
1243
|
-
function compareValues(operator, fieldValue, conditionValue) {
|
|
1244
|
-
switch (operator) {
|
|
1245
|
-
case "equals": {
|
|
1246
|
-
if (Array.isArray(fieldValue)) {
|
|
1247
|
-
if (Array.isArray(conditionValue)) {
|
|
1248
|
-
if (fieldValue.length !== conditionValue.length) {
|
|
1249
|
-
return false;
|
|
1250
|
-
}
|
|
1251
|
-
return fieldValue.every((v, i) => v === conditionValue[i]);
|
|
1252
|
-
}
|
|
1253
|
-
return fieldValue.includes(conditionValue);
|
|
1254
|
-
}
|
|
1255
|
-
return toString(fieldValue) === toString(conditionValue);
|
|
1256
|
-
}
|
|
1257
|
-
case "notEquals": {
|
|
1258
|
-
if (Array.isArray(fieldValue)) {
|
|
1259
|
-
if (Array.isArray(conditionValue)) {
|
|
1260
|
-
if (fieldValue.length !== conditionValue.length) {
|
|
1261
|
-
return true;
|
|
1262
|
-
}
|
|
1263
|
-
return !fieldValue.every((v, i) => v === conditionValue[i]);
|
|
1264
|
-
}
|
|
1265
|
-
return !fieldValue.includes(conditionValue);
|
|
1266
|
-
}
|
|
1267
|
-
return toString(fieldValue) !== toString(conditionValue);
|
|
1268
|
-
}
|
|
1269
|
-
case "contains": {
|
|
1270
|
-
const fieldStr = toString(fieldValue).toLowerCase();
|
|
1271
|
-
const conditionStr = toString(conditionValue).toLowerCase();
|
|
1272
|
-
if (Array.isArray(fieldValue)) {
|
|
1273
|
-
return fieldValue.some(
|
|
1274
|
-
(v) => toString(v).toLowerCase().includes(conditionStr)
|
|
1275
|
-
);
|
|
1276
|
-
}
|
|
1277
|
-
return fieldStr.includes(conditionStr);
|
|
1278
|
-
}
|
|
1279
|
-
case "isEmpty": {
|
|
1280
|
-
return isEmptyValue(fieldValue);
|
|
1281
|
-
}
|
|
1282
|
-
case "isNotEmpty": {
|
|
1283
|
-
return !isEmptyValue(fieldValue);
|
|
1284
|
-
}
|
|
1285
|
-
case "greaterThan": {
|
|
1286
|
-
const fieldNum = toNumber(fieldValue);
|
|
1287
|
-
const conditionNum = toNumber(conditionValue);
|
|
1288
|
-
if (!Number.isNaN(fieldNum) && !Number.isNaN(conditionNum)) {
|
|
1289
|
-
return fieldNum > conditionNum;
|
|
1290
|
-
}
|
|
1291
|
-
return toString(fieldValue) > toString(conditionValue);
|
|
1292
|
-
}
|
|
1293
|
-
case "lessThan": {
|
|
1294
|
-
const fieldNum = toNumber(fieldValue);
|
|
1295
|
-
const conditionNum = toNumber(conditionValue);
|
|
1296
|
-
if (!Number.isNaN(fieldNum) && !Number.isNaN(conditionNum)) {
|
|
1297
|
-
return fieldNum < conditionNum;
|
|
1298
|
-
}
|
|
1299
|
-
return toString(fieldValue) < toString(conditionValue);
|
|
1300
|
-
}
|
|
1301
|
-
default: {
|
|
1302
|
-
console.warn(
|
|
1303
|
-
`[evaluateConditions] Unknown comparison operator: ${String(operator)}`
|
|
1304
|
-
);
|
|
1305
|
-
return false;
|
|
1306
|
-
}
|
|
1307
|
-
}
|
|
1308
|
-
}
|
|
1309
|
-
function evaluateSingleCondition(condition, formData) {
|
|
1310
|
-
const { field, comparison, value: conditionValue } = condition;
|
|
1311
|
-
const fieldValue = getNestedValue(formData, field);
|
|
1312
|
-
return compareValues(comparison, fieldValue, conditionValue);
|
|
1313
|
-
}
|
|
1314
|
-
function getNestedValue(obj, path) {
|
|
1315
|
-
if (!path) {
|
|
1316
|
-
return void 0;
|
|
1317
|
-
}
|
|
1318
|
-
if (!path.includes(".")) {
|
|
1319
|
-
return obj[path];
|
|
1320
|
-
}
|
|
1321
|
-
const parts = path.split(".");
|
|
1322
|
-
let current = obj;
|
|
1323
|
-
for (const part of parts) {
|
|
1324
|
-
if (current === void 0 || current === null) {
|
|
1325
|
-
return void 0;
|
|
1326
|
-
}
|
|
1327
|
-
if (typeof current !== "object") {
|
|
1328
|
-
return void 0;
|
|
1329
|
-
}
|
|
1330
|
-
current = current[part];
|
|
1331
|
-
}
|
|
1332
|
-
return current;
|
|
1333
|
-
}
|
|
1334
|
-
function evaluateConditions(logic, formData) {
|
|
1335
|
-
if (!logic || !logic.enabled) {
|
|
1336
|
-
return true;
|
|
1337
|
-
}
|
|
1338
|
-
const { action, operator, conditions } = logic;
|
|
1339
|
-
if (!conditions || conditions.length === 0) {
|
|
1340
|
-
return true;
|
|
1341
|
-
}
|
|
1342
|
-
const results = conditions.map(
|
|
1343
|
-
(condition) => evaluateSingleCondition(condition, formData)
|
|
1344
|
-
);
|
|
1345
|
-
let conditionsMet;
|
|
1346
|
-
if (operator === "OR") {
|
|
1347
|
-
conditionsMet = results.some((result) => result);
|
|
1348
|
-
} else {
|
|
1349
|
-
conditionsMet = results.every((result) => result);
|
|
1350
|
-
}
|
|
1351
|
-
if (action === "hide") {
|
|
1352
|
-
return !conditionsMet;
|
|
1353
|
-
}
|
|
1354
|
-
return conditionsMet;
|
|
1355
|
-
}
|
|
1356
|
-
function isValidComparisonOperator(operator) {
|
|
1357
|
-
const validOperators = [
|
|
1358
|
-
"equals",
|
|
1359
|
-
"notEquals",
|
|
1360
|
-
"contains",
|
|
1361
|
-
"isEmpty",
|
|
1362
|
-
"isNotEmpty",
|
|
1363
|
-
"greaterThan",
|
|
1364
|
-
"lessThan"
|
|
1365
|
-
];
|
|
1366
|
-
return validOperators.includes(operator);
|
|
1367
|
-
}
|
|
1368
|
-
function getSupportedComparisonOperators() {
|
|
1369
|
-
return [
|
|
1370
|
-
"equals",
|
|
1371
|
-
"notEquals",
|
|
1372
|
-
"contains",
|
|
1373
|
-
"isEmpty",
|
|
1374
|
-
"isNotEmpty",
|
|
1375
|
-
"greaterThan",
|
|
1376
|
-
"lessThan"
|
|
1377
|
-
];
|
|
1378
|
-
}
|
|
1379
|
-
|
|
1380
1449
|
// src/utils/generate-schema.ts
|
|
1381
1450
|
import { z } from "zod";
|
|
1382
1451
|
function generateZodSchema(fields) {
|
|
1383
1452
|
const schemaShape = {};
|
|
1384
1453
|
for (const field of fields) {
|
|
1454
|
+
if (!isKnownFormField(field)) {
|
|
1455
|
+
schemaShape[field.name] = field.required ? z.unknown().refine(
|
|
1456
|
+
(value) => value !== void 0 && value !== null && value !== "",
|
|
1457
|
+
{ message: `${field.label || field.name} is required` }
|
|
1458
|
+
) : z.unknown();
|
|
1459
|
+
continue;
|
|
1460
|
+
}
|
|
1385
1461
|
if (field.type === "hidden" && !field.required) {
|
|
1386
1462
|
schemaShape[field.name] = z.string().optional();
|
|
1387
1463
|
continue;
|
|
@@ -1459,8 +1535,7 @@ function generateEmailSchema(field) {
|
|
|
1459
1535
|
}
|
|
1460
1536
|
function generateNumberSchema(field) {
|
|
1461
1537
|
let schema = z.number({
|
|
1462
|
-
|
|
1463
|
-
invalid_type_error: "Must be a number"
|
|
1538
|
+
error: (issue) => issue.input === void 0 ? field.validation?.errorMessage || "This field is required" : "Must be a number"
|
|
1464
1539
|
});
|
|
1465
1540
|
if (field.validation?.min !== void 0) {
|
|
1466
1541
|
schema = schema.min(
|
|
@@ -1533,9 +1608,7 @@ function generateSelectSchema(field) {
|
|
|
1533
1608
|
return schema2.optional();
|
|
1534
1609
|
}
|
|
1535
1610
|
const schema = z.enum(validValues, {
|
|
1536
|
-
|
|
1537
|
-
message: field.validation?.errorMessage || "Please select a valid option"
|
|
1538
|
-
})
|
|
1611
|
+
error: () => field.validation?.errorMessage || "Please select a valid option"
|
|
1539
1612
|
});
|
|
1540
1613
|
if (field.required) {
|
|
1541
1614
|
return schema;
|
|
@@ -1554,9 +1627,7 @@ function generateCheckboxSchema(field) {
|
|
|
1554
1627
|
function generateRadioSchema(field) {
|
|
1555
1628
|
const validValues = field.options.map((opt) => opt.value);
|
|
1556
1629
|
const schema = z.enum(validValues, {
|
|
1557
|
-
|
|
1558
|
-
message: field.validation?.errorMessage || "Please select an option"
|
|
1559
|
-
})
|
|
1630
|
+
error: () => field.validation?.errorMessage || "Please select an option"
|
|
1560
1631
|
});
|
|
1561
1632
|
if (field.required) {
|
|
1562
1633
|
return schema;
|
|
@@ -1633,7 +1704,7 @@ function transformFormData(data, fields) {
|
|
|
1633
1704
|
if (value === void 0) {
|
|
1634
1705
|
continue;
|
|
1635
1706
|
}
|
|
1636
|
-
transformed[field.name] = transformFieldValue(value, field);
|
|
1707
|
+
transformed[field.name] = isKnownFormField(field) ? transformFieldValue(value, field) : value;
|
|
1637
1708
|
}
|
|
1638
1709
|
return transformed;
|
|
1639
1710
|
}
|
|
@@ -1684,7 +1755,7 @@ function getValidationErrors(result) {
|
|
|
1684
1755
|
return {};
|
|
1685
1756
|
}
|
|
1686
1757
|
const errors = {};
|
|
1687
|
-
for (const error of result.error.
|
|
1758
|
+
for (const error of result.error.issues) {
|
|
1688
1759
|
const path = error.path.join(".");
|
|
1689
1760
|
if (!errors[path]) {
|
|
1690
1761
|
errors[path] = error.message;
|
|
@@ -1693,203 +1764,6 @@ function getValidationErrors(result) {
|
|
|
1693
1764
|
return errors;
|
|
1694
1765
|
}
|
|
1695
1766
|
|
|
1696
|
-
// src/utils/export-formats.ts
|
|
1697
|
-
var UTF8_BOM = "\uFEFF";
|
|
1698
|
-
function safeString(value) {
|
|
1699
|
-
if (typeof value === "object" && value !== null) {
|
|
1700
|
-
return JSON.stringify(value);
|
|
1701
|
-
}
|
|
1702
|
-
return String(value);
|
|
1703
|
-
}
|
|
1704
|
-
function formatExportValue(value, field) {
|
|
1705
|
-
if (value === null || value === void 0) {
|
|
1706
|
-
return "";
|
|
1707
|
-
}
|
|
1708
|
-
if (field) {
|
|
1709
|
-
switch (field.type) {
|
|
1710
|
-
case "checkbox":
|
|
1711
|
-
return value ? "Yes" : "No";
|
|
1712
|
-
case "select":
|
|
1713
|
-
if (Array.isArray(value)) {
|
|
1714
|
-
return value.join(", ");
|
|
1715
|
-
}
|
|
1716
|
-
return safeString(value);
|
|
1717
|
-
case "file":
|
|
1718
|
-
if (Array.isArray(value)) {
|
|
1719
|
-
return value.map((v) => safeString(v)).join(", ");
|
|
1720
|
-
}
|
|
1721
|
-
return safeString(value);
|
|
1722
|
-
case "date":
|
|
1723
|
-
if (value instanceof Date) {
|
|
1724
|
-
return value.toISOString().split("T")[0];
|
|
1725
|
-
}
|
|
1726
|
-
if (typeof value === "string") {
|
|
1727
|
-
const parsed = new Date(value);
|
|
1728
|
-
if (!isNaN(parsed.getTime())) {
|
|
1729
|
-
return parsed.toISOString().split("T")[0];
|
|
1730
|
-
}
|
|
1731
|
-
}
|
|
1732
|
-
return safeString(value);
|
|
1733
|
-
case "time":
|
|
1734
|
-
return safeString(value);
|
|
1735
|
-
case "hidden":
|
|
1736
|
-
return safeString(value);
|
|
1737
|
-
default:
|
|
1738
|
-
break;
|
|
1739
|
-
}
|
|
1740
|
-
}
|
|
1741
|
-
if (Array.isArray(value)) {
|
|
1742
|
-
return value.join(", ");
|
|
1743
|
-
}
|
|
1744
|
-
if (typeof value === "boolean") {
|
|
1745
|
-
return value ? "Yes" : "No";
|
|
1746
|
-
}
|
|
1747
|
-
if (value instanceof Date) {
|
|
1748
|
-
return value.toISOString();
|
|
1749
|
-
}
|
|
1750
|
-
return safeString(value);
|
|
1751
|
-
}
|
|
1752
|
-
function formatDate(date3, format = "iso") {
|
|
1753
|
-
if (!date3) return "";
|
|
1754
|
-
const d = typeof date3 === "string" ? new Date(date3) : date3;
|
|
1755
|
-
if (isNaN(d.getTime())) {
|
|
1756
|
-
return "";
|
|
1757
|
-
}
|
|
1758
|
-
return format === "iso" ? d.toISOString() : d.toLocaleString();
|
|
1759
|
-
}
|
|
1760
|
-
function escapeCSVValue(value, delimiter = ",") {
|
|
1761
|
-
const needsQuoting = value.includes(delimiter) || value.includes('"') || value.includes("\n") || value.includes("\r");
|
|
1762
|
-
if (!needsQuoting) {
|
|
1763
|
-
return value;
|
|
1764
|
-
}
|
|
1765
|
-
const escaped = value.replace(/"/g, '""');
|
|
1766
|
-
return `"${escaped}"`;
|
|
1767
|
-
}
|
|
1768
|
-
function exportToCSV(submissions, form, options = {}) {
|
|
1769
|
-
const {
|
|
1770
|
-
includeMetadata = true,
|
|
1771
|
-
delimiter = ",",
|
|
1772
|
-
includeBOM = true,
|
|
1773
|
-
dateFormat = "iso"
|
|
1774
|
-
} = options;
|
|
1775
|
-
const exportableFields = form.fields;
|
|
1776
|
-
const headers = [];
|
|
1777
|
-
for (const field of exportableFields) {
|
|
1778
|
-
headers.push(field.label);
|
|
1779
|
-
}
|
|
1780
|
-
if (includeMetadata) {
|
|
1781
|
-
headers.push("ID", "Status", "Submitted At", "IP Address", "User Agent");
|
|
1782
|
-
}
|
|
1783
|
-
const rows = [];
|
|
1784
|
-
for (const submission of submissions) {
|
|
1785
|
-
const row = [];
|
|
1786
|
-
const data = submission.data;
|
|
1787
|
-
for (const field of exportableFields) {
|
|
1788
|
-
const value = data[field.name];
|
|
1789
|
-
const formatted = formatExportValue(value, field);
|
|
1790
|
-
row.push(escapeCSVValue(formatted, delimiter));
|
|
1791
|
-
}
|
|
1792
|
-
if (includeMetadata) {
|
|
1793
|
-
row.push(escapeCSVValue(submission.id, delimiter));
|
|
1794
|
-
row.push(escapeCSVValue(submission.status, delimiter));
|
|
1795
|
-
row.push(
|
|
1796
|
-
escapeCSVValue(
|
|
1797
|
-
formatDate(submission.submittedAt, dateFormat),
|
|
1798
|
-
delimiter
|
|
1799
|
-
)
|
|
1800
|
-
);
|
|
1801
|
-
row.push(escapeCSVValue(submission.ipAddress || "", delimiter));
|
|
1802
|
-
row.push(escapeCSVValue(submission.userAgent || "", delimiter));
|
|
1803
|
-
}
|
|
1804
|
-
rows.push(row);
|
|
1805
|
-
}
|
|
1806
|
-
const escapedHeaders = headers.map((h) => escapeCSVValue(h, delimiter));
|
|
1807
|
-
const lines = [
|
|
1808
|
-
escapedHeaders.join(delimiter),
|
|
1809
|
-
...rows.map((row) => row.join(delimiter))
|
|
1810
|
-
];
|
|
1811
|
-
const csvContent = lines.join("\r\n");
|
|
1812
|
-
return includeBOM ? UTF8_BOM + csvContent : csvContent;
|
|
1813
|
-
}
|
|
1814
|
-
function exportToJSON(submissions, form, options = {}) {
|
|
1815
|
-
const {
|
|
1816
|
-
includeMetadata = true,
|
|
1817
|
-
includeFormDefinition = true,
|
|
1818
|
-
indent = 2
|
|
1819
|
-
} = options;
|
|
1820
|
-
const exportableFields = form.fields;
|
|
1821
|
-
const formDefinition = includeFormDefinition ? {
|
|
1822
|
-
id: form.id,
|
|
1823
|
-
name: form.name,
|
|
1824
|
-
slug: form.slug,
|
|
1825
|
-
fields: exportableFields.map((f) => ({
|
|
1826
|
-
name: f.name,
|
|
1827
|
-
label: f.label,
|
|
1828
|
-
type: f.type
|
|
1829
|
-
}))
|
|
1830
|
-
} : void 0;
|
|
1831
|
-
const exportedSubmissions = submissions.map((submission) => {
|
|
1832
|
-
const data = submission.data;
|
|
1833
|
-
const fieldData = {};
|
|
1834
|
-
for (const field of exportableFields) {
|
|
1835
|
-
const value = data[field.name];
|
|
1836
|
-
fieldData[field.name] = value;
|
|
1837
|
-
}
|
|
1838
|
-
if (includeMetadata) {
|
|
1839
|
-
return {
|
|
1840
|
-
...fieldData,
|
|
1841
|
-
_metadata: {
|
|
1842
|
-
id: submission.id,
|
|
1843
|
-
status: submission.status,
|
|
1844
|
-
submittedAt: submission.submittedAt,
|
|
1845
|
-
ipAddress: submission.ipAddress || null,
|
|
1846
|
-
userAgent: submission.userAgent || null,
|
|
1847
|
-
createdAt: submission.createdAt,
|
|
1848
|
-
updatedAt: submission.updatedAt
|
|
1849
|
-
}
|
|
1850
|
-
};
|
|
1851
|
-
}
|
|
1852
|
-
return fieldData;
|
|
1853
|
-
});
|
|
1854
|
-
const exportData = {
|
|
1855
|
-
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1856
|
-
totalSubmissions: submissions.length,
|
|
1857
|
-
submissions: exportedSubmissions
|
|
1858
|
-
};
|
|
1859
|
-
if (formDefinition) {
|
|
1860
|
-
exportData.form = formDefinition;
|
|
1861
|
-
}
|
|
1862
|
-
return JSON.stringify(exportData, null, indent);
|
|
1863
|
-
}
|
|
1864
|
-
function downloadFile(content, filename, mimeType) {
|
|
1865
|
-
if (typeof window === "undefined" || typeof document === "undefined") {
|
|
1866
|
-
throw new Error("downloadFile is only available in browser environments");
|
|
1867
|
-
}
|
|
1868
|
-
const blob = new Blob([content], { type: mimeType });
|
|
1869
|
-
const url2 = URL.createObjectURL(blob);
|
|
1870
|
-
const link = document.createElement("a");
|
|
1871
|
-
link.href = url2;
|
|
1872
|
-
link.download = filename;
|
|
1873
|
-
document.body.appendChild(link);
|
|
1874
|
-
link.click();
|
|
1875
|
-
document.body.removeChild(link);
|
|
1876
|
-
URL.revokeObjectURL(url2);
|
|
1877
|
-
}
|
|
1878
|
-
function generateExportFilename(formSlug, format) {
|
|
1879
|
-
const date3 = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
1880
|
-
return `${formSlug}-submissions-${date3}.${format}`;
|
|
1881
|
-
}
|
|
1882
|
-
function exportAndDownload(submissions, form, format, options) {
|
|
1883
|
-
const filename = generateExportFilename(form.slug, format);
|
|
1884
|
-
if (format === "csv") {
|
|
1885
|
-
const content = exportToCSV(submissions, form, options);
|
|
1886
|
-
downloadFile(content, filename, "text/csv;charset=utf-8");
|
|
1887
|
-
} else {
|
|
1888
|
-
const content = exportToJSON(submissions, form, options);
|
|
1889
|
-
downloadFile(content, filename, "application/json");
|
|
1890
|
-
}
|
|
1891
|
-
}
|
|
1892
|
-
|
|
1893
1767
|
// src/handlers/spam-detection.ts
|
|
1894
1768
|
var rateLimitStore = /* @__PURE__ */ new Map();
|
|
1895
1769
|
async function checkSpam(options) {
|
|
@@ -2007,7 +1881,7 @@ async function submitForm(options, context) {
|
|
|
2007
1881
|
const { formSlug, data, metadata } = options;
|
|
2008
1882
|
const { pluginContext, pluginConfig } = context;
|
|
2009
1883
|
const { collections } = pluginContext.services;
|
|
2010
|
-
const { logger } = pluginContext
|
|
1884
|
+
const { logger } = pluginContext;
|
|
2011
1885
|
try {
|
|
2012
1886
|
const form = await fetchFormBySlug(formSlug, pluginConfig, pluginContext);
|
|
2013
1887
|
if (!form) {
|
|
@@ -2029,46 +1903,95 @@ async function submitForm(options, context) {
|
|
|
2029
1903
|
error: "This form is not currently accepting submissions"
|
|
2030
1904
|
};
|
|
2031
1905
|
}
|
|
2032
|
-
const
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
formSlug,
|
|
2039
|
-
errors: validationErrors
|
|
2040
|
-
});
|
|
2041
|
-
return {
|
|
2042
|
-
success: false,
|
|
2043
|
-
error: "Validation failed",
|
|
2044
|
-
validationErrors
|
|
2045
|
-
};
|
|
1906
|
+
const settings = normalizeFormSettings(form.settings);
|
|
1907
|
+
if (settings.allowMultipleSubmissions === false && !metadata?.ipAddress) {
|
|
1908
|
+
logger.warn?.(
|
|
1909
|
+
"Single-submission form received a submission without an IP \u2014 the restriction cannot apply",
|
|
1910
|
+
{ formSlug }
|
|
1911
|
+
);
|
|
2046
1912
|
}
|
|
2047
|
-
|
|
1913
|
+
if (settings.allowMultipleSubmissions === false && metadata?.ipAddress) {
|
|
1914
|
+
const existing = await collections.count(
|
|
1915
|
+
pluginConfig.formSubmissionOverrides.slug,
|
|
1916
|
+
{
|
|
1917
|
+
where: {
|
|
1918
|
+
form: { equals: form.id },
|
|
1919
|
+
ipAddress: { equals: metadata.ipAddress },
|
|
1920
|
+
status: { not_equals: "spam" }
|
|
1921
|
+
}
|
|
1922
|
+
},
|
|
1923
|
+
{ as: "system" }
|
|
1924
|
+
);
|
|
1925
|
+
if (existing > 0) {
|
|
1926
|
+
logger.info?.("Repeat submission rejected (single-submission form)", {
|
|
1927
|
+
formSlug,
|
|
1928
|
+
ipAddress: metadata.ipAddress
|
|
1929
|
+
});
|
|
1930
|
+
return {
|
|
1931
|
+
success: false,
|
|
1932
|
+
error: "You have already submitted this form."
|
|
1933
|
+
};
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
const declaredFieldNames = new Set(form.fields.map((field) => field.name));
|
|
1937
|
+
const undeclaredData = Object.fromEntries(
|
|
1938
|
+
Object.entries(data).filter(([key]) => !declaredFieldNames.has(key))
|
|
1939
|
+
);
|
|
2048
1940
|
const spamResult = await checkSpam({
|
|
2049
|
-
data:
|
|
1941
|
+
data: undeclaredData,
|
|
2050
1942
|
ipAddress: metadata?.ipAddress,
|
|
2051
1943
|
formSlug,
|
|
2052
1944
|
config: {
|
|
2053
|
-
|
|
1945
|
+
// Per-form overrides win where set; blank inherits the plugin config.
|
|
1946
|
+
honeypot: settings.honeypotEnabled ?? pluginConfig.spamProtection.honeypot,
|
|
2054
1947
|
rateLimit: pluginConfig.spamProtection.rateLimit,
|
|
2055
|
-
recaptcha:
|
|
1948
|
+
recaptcha: {
|
|
1949
|
+
...pluginConfig.spamProtection.recaptcha,
|
|
1950
|
+
enabled: settings.captchaEnabled ?? pluginConfig.spamProtection.recaptcha?.enabled ?? false
|
|
1951
|
+
}
|
|
2056
1952
|
}
|
|
2057
1953
|
});
|
|
2058
|
-
if (spamResult.isSpam) {
|
|
2059
|
-
logger.info?.("Spam submission
|
|
1954
|
+
if (spamResult.isSpam && spamResult.reason === "rate_limit") {
|
|
1955
|
+
logger.info?.("Spam submission rejected (rate limit)", {
|
|
1956
|
+
formSlug,
|
|
1957
|
+
ipAddress: metadata?.ipAddress
|
|
1958
|
+
});
|
|
1959
|
+
return { success: true };
|
|
1960
|
+
}
|
|
1961
|
+
const isContentSpam = spamResult.isSpam;
|
|
1962
|
+
const transformedData = transformFormData(data, form.fields);
|
|
1963
|
+
let storedData;
|
|
1964
|
+
if (isContentSpam) {
|
|
1965
|
+
logger.info?.("Spam submission detected \u2014 storing flagged", {
|
|
2060
1966
|
formSlug,
|
|
2061
1967
|
reason: spamResult.reason,
|
|
2062
1968
|
ipAddress: metadata?.ipAddress
|
|
2063
1969
|
});
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
1970
|
+
sanitizeSubmissionData(transformedData, form.fields);
|
|
1971
|
+
storedData = transformedData;
|
|
1972
|
+
} else {
|
|
1973
|
+
const schema = generateZodSchema(form.fields);
|
|
1974
|
+
const validationResult = schema.safeParse(transformedData);
|
|
1975
|
+
if (!validationResult.success) {
|
|
1976
|
+
const validationErrors = getValidationErrors(validationResult);
|
|
1977
|
+
logger.debug?.("Form validation failed", {
|
|
1978
|
+
formSlug,
|
|
1979
|
+
errors: validationErrors
|
|
1980
|
+
});
|
|
1981
|
+
return {
|
|
1982
|
+
success: false,
|
|
1983
|
+
error: "Validation failed",
|
|
1984
|
+
validationErrors
|
|
1985
|
+
};
|
|
1986
|
+
}
|
|
1987
|
+
sanitizeSubmissionData(validationResult.data, form.fields);
|
|
1988
|
+
storedData = validationResult.data;
|
|
2067
1989
|
}
|
|
2068
1990
|
const submissionData = {
|
|
2069
1991
|
form: form.id,
|
|
2070
|
-
data:
|
|
2071
|
-
status: "new",
|
|
1992
|
+
data: storedData,
|
|
1993
|
+
status: isContentSpam ? "spam" : "new",
|
|
1994
|
+
spamReason: isContentSpam ? spamResult.reason ?? null : null,
|
|
2072
1995
|
ipAddress: metadata?.ipAddress || null,
|
|
2073
1996
|
userAgent: metadata?.userAgent || null,
|
|
2074
1997
|
submittedAt: /* @__PURE__ */ new Date()
|
|
@@ -2076,17 +1999,34 @@ async function submitForm(options, context) {
|
|
|
2076
1999
|
const submission = await collections.createEntry(
|
|
2077
2000
|
pluginConfig.formSubmissionOverrides.slug,
|
|
2078
2001
|
submissionData,
|
|
2079
|
-
|
|
2080
|
-
//
|
|
2002
|
+
// Public form submission — create as system. No ambient user; an
|
|
2003
|
+
// empty context already resolves to system, but be explicit.
|
|
2004
|
+
{ as: "system" }
|
|
2081
2005
|
);
|
|
2082
2006
|
logger.info?.("Form submission created successfully", {
|
|
2083
2007
|
formSlug,
|
|
2084
|
-
submissionId: submission.id
|
|
2008
|
+
submissionId: submission.item.id,
|
|
2009
|
+
flaggedAsSpam: isContentSpam
|
|
2085
2010
|
});
|
|
2011
|
+
if (submission.warnings) {
|
|
2012
|
+
logger.warn?.("Form submission side effects failed", {
|
|
2013
|
+
formSlug,
|
|
2014
|
+
submissionId: submission.item.id,
|
|
2015
|
+
warnings: submission.warnings
|
|
2016
|
+
});
|
|
2017
|
+
}
|
|
2086
2018
|
const redirect = determineRedirectUrl(form);
|
|
2019
|
+
if (isContentSpam) {
|
|
2020
|
+
return { success: true, redirect };
|
|
2021
|
+
}
|
|
2087
2022
|
return {
|
|
2088
2023
|
success: true,
|
|
2089
|
-
|
|
2024
|
+
// The created ROW, not the envelope around it. The service's loose
|
|
2025
|
+
// `CollectionEntry` and this declared document do not overlap enough for
|
|
2026
|
+
// a checked assertion, so the conversion goes through `unknown` and
|
|
2027
|
+
// TypeScript verifies nothing here — which is why the integration test
|
|
2028
|
+
// reads `submission.id` off the result rather than only `success`.
|
|
2029
|
+
submission: submission.item,
|
|
2090
2030
|
redirect
|
|
2091
2031
|
};
|
|
2092
2032
|
} catch (error) {
|
|
@@ -2105,19 +2045,10 @@ async function fetchFormBySlug(slug, pluginConfig, pluginContext) {
|
|
|
2105
2045
|
const { collections } = pluginContext.services;
|
|
2106
2046
|
const result = await collections.listEntries(
|
|
2107
2047
|
pluginConfig.formOverrides.slug,
|
|
2108
|
-
{
|
|
2109
|
-
|
|
2110
|
-
// Fetch enough to find the form
|
|
2111
|
-
},
|
|
2112
|
-
{}
|
|
2113
|
-
// Empty context for public access
|
|
2114
|
-
);
|
|
2115
|
-
if (!result.data || result.data.length === 0) {
|
|
2116
|
-
return null;
|
|
2117
|
-
}
|
|
2118
|
-
const form = result.data.find(
|
|
2119
|
-
(entry) => entry.slug === slug
|
|
2048
|
+
{ where: { slug: { equals: slug } }, pagination: { limit: 1 } },
|
|
2049
|
+
{ as: "system" }
|
|
2120
2050
|
);
|
|
2051
|
+
const form = result.data?.[0];
|
|
2121
2052
|
return form ? form : null;
|
|
2122
2053
|
} catch {
|
|
2123
2054
|
return null;
|
|
@@ -2191,33 +2122,28 @@ async function getFormSubmissionStats(formSlug, context) {
|
|
|
2191
2122
|
return null;
|
|
2192
2123
|
}
|
|
2193
2124
|
try {
|
|
2194
|
-
const
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
if (status === "new") stats.new++;
|
|
2217
|
-
else if (status === "read") stats.read++;
|
|
2218
|
-
else if (status === "archived") stats.archived++;
|
|
2219
|
-
}
|
|
2220
|
-
return stats;
|
|
2125
|
+
const submissionsSlug = pluginConfig.formSubmissionOverrides.slug;
|
|
2126
|
+
const sys = { as: "system" };
|
|
2127
|
+
const base = { form: { equals: form.id } };
|
|
2128
|
+
const [total, newCount, read, archived] = await Promise.all([
|
|
2129
|
+
collections.count(submissionsSlug, { where: base }, sys),
|
|
2130
|
+
collections.count(
|
|
2131
|
+
submissionsSlug,
|
|
2132
|
+
{ where: { ...base, status: { equals: "new" } } },
|
|
2133
|
+
sys
|
|
2134
|
+
),
|
|
2135
|
+
collections.count(
|
|
2136
|
+
submissionsSlug,
|
|
2137
|
+
{ where: { ...base, status: { equals: "read" } } },
|
|
2138
|
+
sys
|
|
2139
|
+
),
|
|
2140
|
+
collections.count(
|
|
2141
|
+
submissionsSlug,
|
|
2142
|
+
{ where: { ...base, status: { equals: "archived" } } },
|
|
2143
|
+
sys
|
|
2144
|
+
)
|
|
2145
|
+
]);
|
|
2146
|
+
return { total, new: newCount, read, archived };
|
|
2221
2147
|
} catch {
|
|
2222
2148
|
return null;
|
|
2223
2149
|
}
|
|
@@ -2404,6 +2330,7 @@ function getSupportedWebhookEvents() {
|
|
|
2404
2330
|
// src/index.ts
|
|
2405
2331
|
var formBuilderPlugin = formBuilder().plugin;
|
|
2406
2332
|
export {
|
|
2333
|
+
BUILT_IN_FORM_FIELD_TYPES,
|
|
2407
2334
|
DEFAULT_FORM_SETTINGS,
|
|
2408
2335
|
DEFAULT_NOTIFICATION_SETTINGS,
|
|
2409
2336
|
RESERVED_FORM_SLUGS,
|
|
@@ -2443,6 +2370,7 @@ export {
|
|
|
2443
2370
|
isFormAcceptingSubmissions,
|
|
2444
2371
|
isFormField,
|
|
2445
2372
|
isFormFieldType,
|
|
2373
|
+
isKnownFormField,
|
|
2446
2374
|
isRateLimited,
|
|
2447
2375
|
isSelectFormField,
|
|
2448
2376
|
isTextFormField,
|