@nextlyhq/plugin-form-builder 0.0.2-alpha.6 → 0.0.2-alpha.62
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 +139 -331
- package/dist/admin/index.js +4410 -3532
- package/dist/admin/index.js.map +1 -1
- package/dist/chunk-H5X3FKOZ.js +689 -0
- package/dist/chunk-H5X3FKOZ.js.map +1 -0
- package/dist/index.d.ts +13 -27
- package/dist/index.js +1187 -814
- package/dist/index.js.map +1 -1
- package/dist/{index-C0kBvYjm.d.ts → types-hLfLe81K.d.ts} +269 -203
- package/package.json +33 -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-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,24 +1,327 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BUILT_IN_FORM_FIELD_TYPES,
|
|
3
|
+
DEFAULT_FORM_SETTINGS,
|
|
4
|
+
DEFAULT_NOTIFICATION_SETTINGS,
|
|
5
|
+
ENFORCED_VALIDATION_RULES,
|
|
6
|
+
applyFieldDefaults,
|
|
7
|
+
applyFormDefaults,
|
|
8
|
+
createFormConfig,
|
|
9
|
+
documentReachability,
|
|
10
|
+
downloadFile,
|
|
11
|
+
evaluateConditions,
|
|
12
|
+
evaluateSingleCondition,
|
|
13
|
+
exportAndDownload,
|
|
14
|
+
exportToCSV,
|
|
15
|
+
exportToJSON,
|
|
16
|
+
formatExportValue,
|
|
17
|
+
generateExportFilename,
|
|
18
|
+
getSupportedComparisonOperators,
|
|
19
|
+
isCheckboxFormField,
|
|
20
|
+
isEmailFormField,
|
|
21
|
+
isFileFormField,
|
|
22
|
+
isFormField,
|
|
23
|
+
isFormFieldType,
|
|
24
|
+
isKnownFormField,
|
|
25
|
+
isSelectFormField,
|
|
26
|
+
isTextFormField,
|
|
27
|
+
isValidComparisonOperator,
|
|
28
|
+
normalizeFormSettings,
|
|
29
|
+
parseFieldRef,
|
|
30
|
+
parseRedirectReference,
|
|
31
|
+
pickedDocumentField,
|
|
32
|
+
pluralize,
|
|
33
|
+
toTitleCase
|
|
34
|
+
} from "./chunk-H5X3FKOZ.js";
|
|
35
|
+
|
|
1
36
|
// src/plugin.ts
|
|
2
|
-
import {
|
|
37
|
+
import { createRequire } from "module";
|
|
38
|
+
import { definePlugin } from "@nextlyhq/plugin-sdk";
|
|
3
39
|
|
|
4
40
|
// src/collections/forms.ts
|
|
41
|
+
import { NextlyError } from "@nextlyhq/plugin-sdk";
|
|
5
42
|
import {
|
|
6
43
|
text,
|
|
7
44
|
textarea,
|
|
8
45
|
select,
|
|
9
46
|
checkbox,
|
|
47
|
+
date,
|
|
10
48
|
group,
|
|
11
49
|
json,
|
|
12
50
|
relationship
|
|
13
51
|
} from "nextly";
|
|
52
|
+
|
|
53
|
+
// src/utils/redirect-target.ts
|
|
54
|
+
import { previewUrlFromTemplate } from "nextly/config";
|
|
55
|
+
var DEFAULT_REDIRECT_PATTERN = "/{slug}";
|
|
56
|
+
function normalizeRedirectRelationships(option2) {
|
|
57
|
+
if (!option2) return {};
|
|
58
|
+
const entries = Array.isArray(option2) ? option2.map((collection) => [collection, DEFAULT_REDIRECT_PATTERN]) : Object.entries(option2);
|
|
59
|
+
return Object.fromEntries(
|
|
60
|
+
entries.filter(
|
|
61
|
+
([, pattern]) => typeof pattern !== "string" || pattern.trim()
|
|
62
|
+
)
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
function applyRedirectPattern(pattern, document) {
|
|
66
|
+
if (typeof pattern === "function") {
|
|
67
|
+
const produced = pattern(document);
|
|
68
|
+
return produced ? produced : void 0;
|
|
69
|
+
}
|
|
70
|
+
return previewUrlFromTemplate(pattern)(document) ?? void 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// src/collections/access-defaults.ts
|
|
74
|
+
var authenticated = ({ user }) => !!user;
|
|
75
|
+
var administrative = ({ roles }) => roles.includes("admin") || roles.includes("super-admin");
|
|
76
|
+
function accessWithDefaults(overrides, defaults = {}) {
|
|
77
|
+
return {
|
|
78
|
+
read: overrides?.read ?? defaults.read ?? authenticated,
|
|
79
|
+
create: overrides?.create ?? defaults.create ?? authenticated,
|
|
80
|
+
update: overrides?.update ?? authenticated,
|
|
81
|
+
// Deleting is administrative in both collections; the DB permission layer
|
|
82
|
+
// remains the backstop underneath it.
|
|
83
|
+
delete: overrides?.delete ?? administrative
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// src/collections/forms.ts
|
|
88
|
+
function withHostHooks(pluginHooks, hostHooks, trailing = {}) {
|
|
89
|
+
const host = { ...hostHooks };
|
|
90
|
+
const asList = (value) => Array.isArray(value) ? value : value === void 0 ? [] : [value];
|
|
91
|
+
const merged = { ...host };
|
|
92
|
+
for (const [phase, handlers] of Object.entries(
|
|
93
|
+
pluginHooks
|
|
94
|
+
)) {
|
|
95
|
+
merged[phase] = [...asList(handlers), ...asList(host[phase])];
|
|
96
|
+
}
|
|
97
|
+
for (const [phase, handlers] of Object.entries(trailing)) {
|
|
98
|
+
merged[phase] = [...asList(merged[phase]), ...asList(handlers)];
|
|
99
|
+
}
|
|
100
|
+
return merged;
|
|
101
|
+
}
|
|
102
|
+
function documentRedirectSettings(settings) {
|
|
103
|
+
if (typeof settings !== "object" || settings === null) return null;
|
|
104
|
+
const record = settings;
|
|
105
|
+
const field = pickedDocumentField(record);
|
|
106
|
+
return field ? { settings: record, field } : null;
|
|
107
|
+
}
|
|
108
|
+
function pageRedirectSettings(data, operation) {
|
|
109
|
+
if (!data) return null;
|
|
110
|
+
if (operation !== "create" && data.settings === void 0) return null;
|
|
111
|
+
return documentRedirectSettings(data.settings);
|
|
112
|
+
}
|
|
113
|
+
function formAcceptsSubmissions(data, originalData) {
|
|
114
|
+
return (data?.status ?? originalData?.status) === "published";
|
|
115
|
+
}
|
|
116
|
+
function storedSettings(value) {
|
|
117
|
+
if (typeof value === "string") {
|
|
118
|
+
try {
|
|
119
|
+
return documentRedirectSettings(JSON.parse(value));
|
|
120
|
+
} catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return documentRedirectSettings(value);
|
|
125
|
+
}
|
|
126
|
+
function storedRedirectReference(originalData, patterns) {
|
|
127
|
+
const picked = storedSettings(originalData?.settings);
|
|
128
|
+
if (!picked) return null;
|
|
129
|
+
const collections = Object.keys(patterns);
|
|
130
|
+
const reference = parseRedirectReference(
|
|
131
|
+
picked.settings[picked.field],
|
|
132
|
+
collections
|
|
133
|
+
);
|
|
134
|
+
if (!reference) return null;
|
|
135
|
+
return {
|
|
136
|
+
reference,
|
|
137
|
+
field: picked.field,
|
|
138
|
+
configured: collections.includes(reference.collection)
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function normalizeStoredReference(settings, field, reference) {
|
|
142
|
+
const stored = settings[field];
|
|
143
|
+
if (typeof stored === "string") {
|
|
144
|
+
if (stored !== reference.id) settings[field] = reference.id;
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (typeof stored !== "object" || stored === null || Array.isArray(stored)) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const record = stored;
|
|
151
|
+
if (record.relationTo !== reference.collection) {
|
|
152
|
+
settings[field] = { ...record, relationTo: reference.collection };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function namedRedirectReference(data, operation, patterns) {
|
|
156
|
+
const picked = pageRedirectSettings(data, operation);
|
|
157
|
+
if (!picked) return null;
|
|
158
|
+
const { settings, field } = picked;
|
|
159
|
+
const collections = Object.keys(patterns);
|
|
160
|
+
const reference = parseRedirectReference(settings[field], collections);
|
|
161
|
+
if (reference) normalizeStoredReference(settings, field, reference);
|
|
162
|
+
return {
|
|
163
|
+
settings,
|
|
164
|
+
field,
|
|
165
|
+
reference,
|
|
166
|
+
configured: reference !== null && collections.includes(reference.collection)
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
function wouldStrandVisitors(data, originalData, target, targetCollectionIsLocalized) {
|
|
170
|
+
return formAcceptsSubmissions(data, originalData) && documentReachability(target, targetCollectionIsLocalized) === "unreachable";
|
|
171
|
+
}
|
|
172
|
+
function publishesForm(data, originalData) {
|
|
173
|
+
if (data?.status !== "published") return false;
|
|
174
|
+
return originalData?.status !== "published";
|
|
175
|
+
}
|
|
176
|
+
function sameTarget(named, stored) {
|
|
177
|
+
if (!named.reference || !stored) return false;
|
|
178
|
+
return stored.field === named.field && stored.reference.collection === named.reference.collection && stored.reference.id === named.reference.id;
|
|
179
|
+
}
|
|
180
|
+
function judgeNamedReference(named, stored) {
|
|
181
|
+
if (sameTarget(named, stored) && named.reference) {
|
|
182
|
+
return {
|
|
183
|
+
reference: named.reference,
|
|
184
|
+
field: named.field,
|
|
185
|
+
chosen: false,
|
|
186
|
+
configured: named.configured
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
if (!named.reference || !named.configured) {
|
|
190
|
+
throw redirectRefusal(
|
|
191
|
+
"Choose a page to redirect to, or pick a different confirmation.",
|
|
192
|
+
named.field
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
reference: named.reference,
|
|
197
|
+
field: named.field,
|
|
198
|
+
chosen: true,
|
|
199
|
+
configured: true
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
function redirectReferenceForWrite(context, patterns) {
|
|
203
|
+
const stored = storedRedirectReference(
|
|
204
|
+
context.originalData,
|
|
205
|
+
patterns
|
|
206
|
+
);
|
|
207
|
+
const named = namedRedirectReference(
|
|
208
|
+
context.data,
|
|
209
|
+
context.operation,
|
|
210
|
+
patterns
|
|
211
|
+
);
|
|
212
|
+
if (named) return judgeNamedReference(named, stored);
|
|
213
|
+
const data = context.data;
|
|
214
|
+
if (data?.settings !== void 0) return null;
|
|
215
|
+
return stored ? { ...stored, chosen: false } : null;
|
|
216
|
+
}
|
|
217
|
+
function assertPatternBuildsUrl(patterns, reference, field, target) {
|
|
218
|
+
let url2;
|
|
219
|
+
try {
|
|
220
|
+
url2 = applyRedirectPattern(patterns[reference.collection], target);
|
|
221
|
+
} catch {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (url2) return;
|
|
225
|
+
throw redirectRefusal(
|
|
226
|
+
`No URL could be built for that page from the pattern configured for "${reference.collection}". Choose another page, or ask a developer to check that collection's redirect pattern.`,
|
|
227
|
+
field
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
async function assertRedirectTargetUsable(context, patterns, localization) {
|
|
231
|
+
const write = redirectReferenceForWrite(context, patterns);
|
|
232
|
+
if (!write) return;
|
|
233
|
+
const { reference, field, chosen, configured } = write;
|
|
234
|
+
if (!configured) {
|
|
235
|
+
if (publishesForm(
|
|
236
|
+
context.data,
|
|
237
|
+
context.originalData
|
|
238
|
+
)) {
|
|
239
|
+
throw redirectRefusal(
|
|
240
|
+
`This form redirects to "${reference.collection}", which is no longer configured as a redirect target, so it has nowhere to send anyone. Choose another page, or pick a different confirmation.`,
|
|
241
|
+
field
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const nextly = context.req?.nextly;
|
|
247
|
+
if (!nextly) return;
|
|
248
|
+
if (context.executor) return;
|
|
249
|
+
const found = await readRedirectTarget(nextly, reference);
|
|
250
|
+
if (found.status === "unreadable") return;
|
|
251
|
+
if (found.status === "missing") {
|
|
252
|
+
if (!chosen && !publishesForm(
|
|
253
|
+
context.data,
|
|
254
|
+
context.originalData
|
|
255
|
+
)) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
throw redirectRefusal(
|
|
259
|
+
`That page no longer exists in "${reference.collection}". Pick another.`,
|
|
260
|
+
field
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
const judged = chosen || publishesForm(
|
|
264
|
+
context.data,
|
|
265
|
+
context.originalData
|
|
266
|
+
);
|
|
267
|
+
if (judged && wouldStrandVisitors(
|
|
268
|
+
context.data,
|
|
269
|
+
context.originalData,
|
|
270
|
+
found.document,
|
|
271
|
+
localization[reference.collection]
|
|
272
|
+
)) {
|
|
273
|
+
throw redirectRefusal(
|
|
274
|
+
`That page is not published yet, and this form is. A visitor sent there would reach a "page not found". Publish the page, or save this form as a draft until you do.`,
|
|
275
|
+
field
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
if (chosen)
|
|
279
|
+
assertPatternBuildsUrl(patterns, reference, field, found.document);
|
|
280
|
+
}
|
|
281
|
+
async function readRedirectTarget(nextly, reference) {
|
|
282
|
+
try {
|
|
283
|
+
const row = await nextly.findByID({
|
|
284
|
+
collection: reference.collection,
|
|
285
|
+
id: reference.id
|
|
286
|
+
});
|
|
287
|
+
return row ? { status: "ok", document: row } : { status: "missing" };
|
|
288
|
+
} catch (error) {
|
|
289
|
+
return isMissingTarget(error) ? { status: "missing" } : { status: "unreadable" };
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
function isMissingTarget(error) {
|
|
293
|
+
return typeof error === "object" && error !== null && error.code === "NOT_FOUND";
|
|
294
|
+
}
|
|
295
|
+
function stampFirstPublish(context) {
|
|
296
|
+
const data = context.data;
|
|
297
|
+
if (!data) return;
|
|
298
|
+
const stored = context.originalData;
|
|
299
|
+
delete data.wentLiveAt;
|
|
300
|
+
if (recordedStamp(stored) !== void 0) return;
|
|
301
|
+
if (data.status === "published" || stored?.status === "published") {
|
|
302
|
+
data.wentLiveAt = /* @__PURE__ */ new Date();
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
function recordedStamp(stored) {
|
|
306
|
+
const value = stored?.wentLiveAt;
|
|
307
|
+
return value === null || value === "" ? void 0 : value;
|
|
308
|
+
}
|
|
309
|
+
function redirectRefusal(message, field) {
|
|
310
|
+
return NextlyError.validation({
|
|
311
|
+
errors: [{ path: `settings.${field}`, code: "REQUIRED", message }]
|
|
312
|
+
});
|
|
313
|
+
}
|
|
14
314
|
function formsCollection(pluginConfig) {
|
|
15
315
|
const {
|
|
16
316
|
slug,
|
|
17
317
|
labels,
|
|
18
318
|
fields: additionalFields,
|
|
19
319
|
access: accessOverrides,
|
|
320
|
+
hooks: hookOverrides,
|
|
321
|
+
admin: adminOverrides,
|
|
20
322
|
...overrides
|
|
21
323
|
} = pluginConfig.formOverrides;
|
|
324
|
+
const redirectCollections = Object.keys(pluginConfig.redirectRelationships);
|
|
22
325
|
const settingsFields = [
|
|
23
326
|
text({
|
|
24
327
|
name: "submitButtonText",
|
|
@@ -35,7 +338,7 @@ function formsCollection(pluginConfig) {
|
|
|
35
338
|
options: [
|
|
36
339
|
{ label: "Show Message", value: "message" },
|
|
37
340
|
{ label: "Redirect to URL", value: "redirect" },
|
|
38
|
-
...
|
|
341
|
+
...redirectCollections.length > 0 ? [{ label: "Redirect to Page", value: "relationship" }] : []
|
|
39
342
|
],
|
|
40
343
|
admin: {
|
|
41
344
|
description: "What happens after a successful form submission"
|
|
@@ -65,12 +368,12 @@ function formsCollection(pluginConfig) {
|
|
|
65
368
|
}
|
|
66
369
|
})
|
|
67
370
|
];
|
|
68
|
-
if (
|
|
371
|
+
if (redirectCollections.length > 0) {
|
|
69
372
|
settingsFields.push(
|
|
70
373
|
relationship({
|
|
71
374
|
name: "redirectPage",
|
|
72
375
|
label: "Redirect Page",
|
|
73
|
-
relationTo:
|
|
376
|
+
relationTo: redirectCollections,
|
|
74
377
|
admin: {
|
|
75
378
|
description: "Select a page to redirect to after submission",
|
|
76
379
|
condition: {
|
|
@@ -90,12 +393,18 @@ function formsCollection(pluginConfig) {
|
|
|
90
393
|
description: "Allow the same user/IP to submit this form multiple times"
|
|
91
394
|
}
|
|
92
395
|
}),
|
|
396
|
+
checkbox({
|
|
397
|
+
name: "honeypotEnabled",
|
|
398
|
+
label: "Honeypot",
|
|
399
|
+
admin: {
|
|
400
|
+
description: "Per-form honeypot override; unset inherits the plugin default"
|
|
401
|
+
}
|
|
402
|
+
}),
|
|
93
403
|
checkbox({
|
|
94
404
|
name: "captchaEnabled",
|
|
95
405
|
label: "Enable reCAPTCHA",
|
|
96
|
-
defaultValue: false,
|
|
97
406
|
admin: {
|
|
98
|
-
description: "
|
|
407
|
+
description: "Per-form reCAPTCHA override; unset inherits the plugin default"
|
|
99
408
|
}
|
|
100
409
|
}),
|
|
101
410
|
text({
|
|
@@ -163,10 +472,10 @@ function formsCollection(pluginConfig) {
|
|
|
163
472
|
// ============================================================
|
|
164
473
|
json({
|
|
165
474
|
name: "notifications",
|
|
166
|
-
label: "
|
|
475
|
+
label: "Notifications",
|
|
167
476
|
defaultValue: [],
|
|
168
477
|
admin: {
|
|
169
|
-
description: "Email notification
|
|
478
|
+
description: "Email notification rules. Managed by the Form Builder UI."
|
|
170
479
|
}
|
|
171
480
|
}),
|
|
172
481
|
// ============================================================
|
|
@@ -186,6 +495,19 @@ function formsCollection(pluginConfig) {
|
|
|
186
495
|
description: "Only published forms can receive submissions. Closed forms display a message instead."
|
|
187
496
|
}
|
|
188
497
|
}),
|
|
498
|
+
// NOT `firstPublishedAt`, which is the obvious name and the one to reach
|
|
499
|
+
// for next. The framework owns that name on collections it manages the
|
|
500
|
+
// publish lifecycle for, and a write naming it from a plugin collection is
|
|
501
|
+
// stripped on the way to the insert — silently, leaving the column null and
|
|
502
|
+
// every closed form reading as one that was never public.
|
|
503
|
+
date({
|
|
504
|
+
name: "wentLiveAt",
|
|
505
|
+
label: "First Published",
|
|
506
|
+
admin: {
|
|
507
|
+
readOnly: true,
|
|
508
|
+
description: "When this form first went live. Written once and never changed."
|
|
509
|
+
}
|
|
510
|
+
}),
|
|
189
511
|
textarea({
|
|
190
512
|
name: "closedMessage",
|
|
191
513
|
label: "Closed Form Message",
|
|
@@ -227,50 +549,75 @@ function formsCollection(pluginConfig) {
|
|
|
227
549
|
}
|
|
228
550
|
}
|
|
229
551
|
},
|
|
230
|
-
...
|
|
552
|
+
...adminOverrides
|
|
231
553
|
},
|
|
232
554
|
// Access control with sensible defaults
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
555
|
+
// `read: true` — a form is public because a site renders it. The rest are
|
|
556
|
+
// the shared defaults.
|
|
557
|
+
access: accessWithDefaults(accessOverrides, { read: true }),
|
|
558
|
+
hooks: withHostHooks(
|
|
559
|
+
{
|
|
560
|
+
// Auto-generate slug from name if not provided
|
|
561
|
+
beforeValidate: [
|
|
562
|
+
(context) => {
|
|
563
|
+
const { data, operation } = context;
|
|
564
|
+
if (operation === "create" && data && !data.slug && data.name) {
|
|
565
|
+
data.slug = String(data.name).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
566
|
+
}
|
|
567
|
+
const setsFields = operation === "create" || data?.fields !== void 0;
|
|
568
|
+
if (data && setsFields && (data.fields === void 0 || Array.isArray(data.fields) && data.fields.length === 0)) {
|
|
569
|
+
throw NextlyError.validation({
|
|
570
|
+
errors: [
|
|
571
|
+
{
|
|
572
|
+
path: "fields",
|
|
573
|
+
code: "REQUIRED",
|
|
574
|
+
message: "Form must have at least one field."
|
|
575
|
+
}
|
|
576
|
+
]
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
namedRedirectReference(
|
|
580
|
+
data,
|
|
581
|
+
operation,
|
|
582
|
+
pluginConfig.redirectRelationships
|
|
583
|
+
);
|
|
584
|
+
return data;
|
|
252
585
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
586
|
+
]
|
|
587
|
+
},
|
|
588
|
+
hookOverrides,
|
|
589
|
+
{
|
|
590
|
+
// The last COLLECTION-level phase, after every host handler in it. A
|
|
591
|
+
// host `beforeValidate` may rewrite the payload once the earlier call
|
|
592
|
+
// has passed, so the check has to run again here.
|
|
593
|
+
//
|
|
594
|
+
// It is not the last mutating point in the write, and saying so would
|
|
595
|
+
// be false: `collection-mutation-service` runs FIELD-level
|
|
596
|
+
// `beforeChange` hooks after this one, and a host may contribute a
|
|
597
|
+
// field through `formOverrides.fields`. Nothing collection-level can
|
|
598
|
+
// sit after that — the next steps are hashing and the insert. What
|
|
599
|
+
// bounds the consequence is the submit path, which resolves an
|
|
600
|
+
// unusable target to NO redirect and logs it, never to a wrong one.
|
|
601
|
+
beforeChange: [
|
|
602
|
+
async (context) => {
|
|
603
|
+
await assertRedirectTargetUsable(
|
|
604
|
+
context,
|
|
605
|
+
pluginConfig.redirectRelationships,
|
|
606
|
+
pluginConfig.redirectTargetLocalization
|
|
607
|
+
);
|
|
608
|
+
stampFirstPublish(context);
|
|
609
|
+
return context.data;
|
|
262
610
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
},
|
|
611
|
+
]
|
|
612
|
+
}
|
|
613
|
+
),
|
|
267
614
|
// Spread any additional overrides (excluding already used properties)
|
|
268
615
|
...overrides
|
|
269
616
|
};
|
|
270
617
|
}
|
|
271
618
|
|
|
272
619
|
// src/collections/submissions.ts
|
|
273
|
-
import { text as text2, textarea as textarea2, select as select2, json as json2, date, relationship as relationship2 } from "nextly";
|
|
620
|
+
import { text as text2, textarea as textarea2, select as select2, json as json2, date as date2, relationship as relationship2 } from "nextly";
|
|
274
621
|
function submissionsCollection(pluginConfig) {
|
|
275
622
|
const {
|
|
276
623
|
slug,
|
|
@@ -317,12 +664,23 @@ function submissionsCollection(pluginConfig) {
|
|
|
317
664
|
options: [
|
|
318
665
|
{ label: "New", value: "new" },
|
|
319
666
|
{ label: "Read", value: "read" },
|
|
320
|
-
{ label: "Archived", value: "archived" }
|
|
667
|
+
{ label: "Archived", value: "archived" },
|
|
668
|
+
// Spam is stored and flagged, never silently dropped: a false
|
|
669
|
+
// positive stays reviewable and recoverable ("Not spam" = back to new).
|
|
670
|
+
{ label: "Spam", value: "spam" }
|
|
321
671
|
],
|
|
322
672
|
admin: {
|
|
323
673
|
description: "Track whether this submission has been reviewed"
|
|
324
674
|
}
|
|
325
675
|
}),
|
|
676
|
+
text2({
|
|
677
|
+
name: "spamReason",
|
|
678
|
+
label: "Spam Reason",
|
|
679
|
+
admin: {
|
|
680
|
+
readOnly: true,
|
|
681
|
+
description: "Which spam check flagged this submission (empty for clean submissions)"
|
|
682
|
+
}
|
|
683
|
+
}),
|
|
326
684
|
// ============================================================
|
|
327
685
|
// Internal Notes
|
|
328
686
|
// ============================================================
|
|
@@ -352,7 +710,7 @@ function submissionsCollection(pluginConfig) {
|
|
|
352
710
|
description: "Browser/device information of the submitter"
|
|
353
711
|
}
|
|
354
712
|
}),
|
|
355
|
-
|
|
713
|
+
date2({
|
|
356
714
|
name: "submittedAt",
|
|
357
715
|
label: "Submitted At",
|
|
358
716
|
required: true,
|
|
@@ -360,6 +718,24 @@ function submissionsCollection(pluginConfig) {
|
|
|
360
718
|
readOnly: true,
|
|
361
719
|
description: "When the form was submitted"
|
|
362
720
|
}
|
|
721
|
+
}),
|
|
722
|
+
// Honesty stamps for admin edits of submitted data: a corrected
|
|
723
|
+
// submission must never be indistinguishable from what the visitor sent.
|
|
724
|
+
date2({
|
|
725
|
+
name: "editedAt",
|
|
726
|
+
label: "Edited At",
|
|
727
|
+
admin: {
|
|
728
|
+
readOnly: true,
|
|
729
|
+
description: "When an admin last edited the submitted data"
|
|
730
|
+
}
|
|
731
|
+
}),
|
|
732
|
+
text2({
|
|
733
|
+
name: "editedBy",
|
|
734
|
+
label: "Edited By",
|
|
735
|
+
admin: {
|
|
736
|
+
readOnly: true,
|
|
737
|
+
description: "ID of the admin who last edited the submitted data"
|
|
738
|
+
}
|
|
363
739
|
})
|
|
364
740
|
];
|
|
365
741
|
let finalFields;
|
|
@@ -375,33 +751,47 @@ function submissionsCollection(pluginConfig) {
|
|
|
375
751
|
labels,
|
|
376
752
|
fields: finalFields,
|
|
377
753
|
timestamps: true,
|
|
754
|
+
// Submissions carry visitor-entered answers plus ipAddress/userAgent, so the
|
|
755
|
+
// default `entry.*` events stay suppressed — otherwise every submission would
|
|
756
|
+
// be delivered to any endpoint subscribed to `entry.created` or `*`. Instead
|
|
757
|
+
// emit a curated `form.submission.created` carrying ONLY safe metadata (which
|
|
758
|
+
// form, when, and the status) — never the answers, IP, or user agent.
|
|
759
|
+
webhooks: {
|
|
760
|
+
record: false,
|
|
761
|
+
emit: {
|
|
762
|
+
event: "form.submission.created",
|
|
763
|
+
fields: ["form", "submittedAt", "status"]
|
|
764
|
+
}
|
|
765
|
+
},
|
|
378
766
|
admin: {
|
|
379
767
|
isPlugin: true,
|
|
380
768
|
group: "Forms",
|
|
381
769
|
order: 51,
|
|
382
770
|
useAsTitle: "id",
|
|
383
771
|
description: "View and manage form submissions",
|
|
384
|
-
//
|
|
772
|
+
// Submissions are created by visitors submitting forms — a hand-typed
|
|
773
|
+
// "New Submission" would be fiction, so the admin never offers one.
|
|
774
|
+
disableCreate: true,
|
|
385
775
|
components: {
|
|
386
|
-
//
|
|
387
|
-
|
|
776
|
+
// The full submissions experience (per-form columns, status tabs,
|
|
777
|
+
// drawer detail, export) replaces the generic list.
|
|
778
|
+
views: {
|
|
779
|
+
List: {
|
|
780
|
+
Component: "@nextlyhq/plugin-form-builder/admin#SubmissionsView"
|
|
781
|
+
}
|
|
782
|
+
}
|
|
388
783
|
},
|
|
389
784
|
...overrides.admin
|
|
390
785
|
},
|
|
391
786
|
// Access control with sensible defaults
|
|
392
787
|
// Public can create (submit forms), but only authenticated users can read/update/delete
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
// Only authenticated users can view submissions
|
|
397
|
-
read: accessOverrides?.read ?? (({ user }) => !!user),
|
|
398
|
-
// Only authenticated users can update submissions (e.g., change status, add notes)
|
|
399
|
-
update: accessOverrides?.update ?? (({ user }) => !!user),
|
|
400
|
-
// Only admins can delete submissions (falls back to DB permissions)
|
|
401
|
-
delete: accessOverrides?.delete ?? (({ roles }) => roles.includes("admin") || roles.includes("super-admin"))
|
|
402
|
-
},
|
|
788
|
+
// `create: true` — a visitor submits without an account. Reading, updating
|
|
789
|
+
// and deleting are the shared defaults.
|
|
790
|
+
access: accessWithDefaults(accessOverrides, { create: true }),
|
|
403
791
|
hooks: {
|
|
404
|
-
// Auto-set submittedAt timestamp on creation
|
|
792
|
+
// Auto-set submittedAt timestamp on creation. (The edit stamp for
|
|
793
|
+
// updates is registered in plugin.ts init — direct registry hooks are
|
|
794
|
+
// the path that runs for every API surface.)
|
|
405
795
|
beforeValidate: [
|
|
406
796
|
(context) => {
|
|
407
797
|
const { data, operation } = context;
|
|
@@ -423,10 +813,24 @@ function submissionsCollection(pluginConfig) {
|
|
|
423
813
|
};
|
|
424
814
|
}
|
|
425
815
|
|
|
816
|
+
// src/document-shapes.ts
|
|
817
|
+
function asSubmissionDocument(row) {
|
|
818
|
+
return row;
|
|
819
|
+
}
|
|
820
|
+
function asSubmissionDocuments(rows) {
|
|
821
|
+
return rows;
|
|
822
|
+
}
|
|
823
|
+
function asFormDocument(row) {
|
|
824
|
+
return row;
|
|
825
|
+
}
|
|
826
|
+
|
|
426
827
|
// src/plugin.ts
|
|
828
|
+
var { version: PLUGIN_VERSION } = createRequire(import.meta.url)(
|
|
829
|
+
"../package.json"
|
|
830
|
+
);
|
|
427
831
|
function resolveConfig(options) {
|
|
428
|
-
const formOverrides = options.formOverrides ||
|
|
429
|
-
const submissionOverrides = options.formSubmissionOverrides ||
|
|
832
|
+
const formOverrides = options.formOverrides || {};
|
|
833
|
+
const submissionOverrides = options.formSubmissionOverrides || {};
|
|
430
834
|
return {
|
|
431
835
|
formOverrides: {
|
|
432
836
|
...formOverrides,
|
|
@@ -459,7 +863,13 @@ function resolveConfig(options) {
|
|
|
459
863
|
time: options.fields?.time ?? true,
|
|
460
864
|
hidden: options.fields?.hidden ?? true
|
|
461
865
|
},
|
|
462
|
-
redirectRelationships:
|
|
866
|
+
redirectRelationships: normalizeRedirectRelationships(
|
|
867
|
+
options.redirectRelationships
|
|
868
|
+
),
|
|
869
|
+
// Filled at `init` from each collection's own metadata. Empty until then,
|
|
870
|
+
// and a slug that stays absent reads as unknown rather than as not
|
|
871
|
+
// localized — see `ResolvedFormBuilderConfig`.
|
|
872
|
+
redirectTargetLocalization: {},
|
|
463
873
|
beforeEmail: options.beforeEmail,
|
|
464
874
|
notifications: {
|
|
465
875
|
defaultFrom: options.notifications?.defaultFrom,
|
|
@@ -495,41 +905,236 @@ function formBuilder(options = {}) {
|
|
|
495
905
|
const resolvedConfig = resolveConfig(options);
|
|
496
906
|
const formsCol = formsCollection(resolvedConfig);
|
|
497
907
|
const submissionsCol = submissionsCollection(resolvedConfig);
|
|
498
|
-
const plugin = {
|
|
908
|
+
const plugin = definePlugin({
|
|
499
909
|
name: "@nextlyhq/plugin-form-builder",
|
|
500
|
-
version:
|
|
501
|
-
|
|
910
|
+
version: PLUGIN_VERSION,
|
|
911
|
+
nextly: ">=0.0.2-alpha.21",
|
|
912
|
+
// Identity metadata for the admin plugins page, mirroring package.json.
|
|
913
|
+
author: "Nextly",
|
|
914
|
+
homepage: "https://nextlyhq.com",
|
|
915
|
+
repository: "https://github.com/nextlyhq/nextly",
|
|
916
|
+
license: "MIT",
|
|
917
|
+
category: "forms",
|
|
918
|
+
tags: ["forms", "submissions", "email-notifications"],
|
|
919
|
+
// Declarative schema: the merged pipeline folds these into the
|
|
920
|
+
// app schema — no manual `setup()` append needed. Just register the plugin.
|
|
921
|
+
contributes: {
|
|
922
|
+
collections: [formsCol, submissionsCol],
|
|
923
|
+
// Custom permission — gates submission export beyond CRUD. The
|
|
924
|
+
// canonical example third-party plugin authors copy.
|
|
925
|
+
permissions: [
|
|
926
|
+
{
|
|
927
|
+
action: "export",
|
|
928
|
+
resource: "submissions",
|
|
929
|
+
label: "Export Submissions",
|
|
930
|
+
description: "Export form submissions as CSV/JSON",
|
|
931
|
+
// No `group`: the admin already files this under the plugin that
|
|
932
|
+
// declared it, so naming the plugin again would nest it inside
|
|
933
|
+
// itself. `group` is for a plugin with enough permissions to sort
|
|
934
|
+
// its own into headings, which one is not.
|
|
935
|
+
//
|
|
936
|
+
// `danger` because the point of the permission is to take
|
|
937
|
+
// submissions — names, emails, whatever a form asked for — out of
|
|
938
|
+
// the site in a file.
|
|
939
|
+
danger: true
|
|
940
|
+
}
|
|
941
|
+
],
|
|
942
|
+
// HTTP route — exported at
|
|
943
|
+
// /api/plugins/@nextlyhq/plugin-form-builder/submissions/export. Secure by
|
|
944
|
+
// default: gated by the custom `export-submissions` permission. Reads
|
|
945
|
+
// via the secure-by-default service path as the authed user and
|
|
946
|
+
// resolves its OWN slug through `ctx.self`. The canonical
|
|
947
|
+
// contributes.routes example for third-party authors.
|
|
948
|
+
routes: [
|
|
949
|
+
{
|
|
950
|
+
method: "GET",
|
|
951
|
+
path: "/submissions/export",
|
|
952
|
+
requiredPermission: "export-submissions",
|
|
953
|
+
handler: async (req, ctx) => {
|
|
954
|
+
const declaredSlug = resolvedConfig.formSubmissionOverrides.slug;
|
|
955
|
+
const slug = ctx.self.collections[declaredSlug] ?? declaredSlug;
|
|
956
|
+
const url2 = new URL(req.url);
|
|
957
|
+
const format = url2.searchParams.get("format") ?? "json";
|
|
958
|
+
const formId = url2.searchParams.get("form");
|
|
959
|
+
const status = url2.searchParams.get("status");
|
|
960
|
+
const where = {};
|
|
961
|
+
if (formId) where.form = { equals: formId };
|
|
962
|
+
if (status) where.status = { equals: status };
|
|
963
|
+
else where.status = { not_equals: "spam" };
|
|
964
|
+
const opts = { as: "user", user: ctx.user ?? void 0 };
|
|
965
|
+
let form;
|
|
966
|
+
if (format === "csv") {
|
|
967
|
+
if (!formId) {
|
|
968
|
+
return Response.json(
|
|
969
|
+
{
|
|
970
|
+
error: {
|
|
971
|
+
code: "VALIDATION_ERROR",
|
|
972
|
+
message: "CSV export requires a form parameter."
|
|
973
|
+
}
|
|
974
|
+
},
|
|
975
|
+
{ status: 400 }
|
|
976
|
+
);
|
|
977
|
+
}
|
|
978
|
+
const formsSlugDeclared = resolvedConfig.formOverrides.slug;
|
|
979
|
+
const formsSlug = ctx.self.collections[formsSlugDeclared] ?? formsSlugDeclared;
|
|
980
|
+
const formResult = await ctx.services.collections.listEntries(
|
|
981
|
+
formsSlug,
|
|
982
|
+
{ where: { id: { equals: formId } } },
|
|
983
|
+
opts
|
|
984
|
+
);
|
|
985
|
+
form = formResult.data[0];
|
|
986
|
+
if (!form) {
|
|
987
|
+
return Response.json(
|
|
988
|
+
{ error: { code: "NOT_FOUND", message: "Form not found." } },
|
|
989
|
+
{ status: 404 }
|
|
990
|
+
);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
const MAX_EXPORT_ROWS = 5e4;
|
|
994
|
+
const items = [];
|
|
995
|
+
const pageSize = 200;
|
|
996
|
+
let truncated = false;
|
|
997
|
+
for (let page = 1; ; page += 1) {
|
|
998
|
+
const result = await ctx.services.collections.listEntries(
|
|
999
|
+
slug,
|
|
1000
|
+
{ where, pagination: { limit: pageSize, page } },
|
|
1001
|
+
opts
|
|
1002
|
+
);
|
|
1003
|
+
items.push(...result.data);
|
|
1004
|
+
const pageWasFull = result.data.length === pageSize;
|
|
1005
|
+
if (items.length >= MAX_EXPORT_ROWS) {
|
|
1006
|
+
truncated = pageWasFull || items.length > MAX_EXPORT_ROWS;
|
|
1007
|
+
items.length = MAX_EXPORT_ROWS;
|
|
1008
|
+
break;
|
|
1009
|
+
}
|
|
1010
|
+
if (!pageWasFull) break;
|
|
1011
|
+
}
|
|
1012
|
+
const truncationHeaders = truncated ? { "X-Export-Truncated": "true" } : {};
|
|
1013
|
+
if (format !== "csv" || !form) {
|
|
1014
|
+
return Response.json({ items }, { headers: truncationHeaders });
|
|
1015
|
+
}
|
|
1016
|
+
const csv = exportToCSV(
|
|
1017
|
+
asSubmissionDocuments(items),
|
|
1018
|
+
asFormDocument(form)
|
|
1019
|
+
);
|
|
1020
|
+
const filename = generateExportFilename(
|
|
1021
|
+
typeof form.slug === "string" ? form.slug : "form",
|
|
1022
|
+
"csv"
|
|
1023
|
+
);
|
|
1024
|
+
return new Response(csv, {
|
|
1025
|
+
headers: {
|
|
1026
|
+
"Content-Type": "text/csv; charset=utf-8",
|
|
1027
|
+
"Content-Disposition": `attachment; filename="${filename}"`,
|
|
1028
|
+
...truncationHeaders
|
|
1029
|
+
}
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
1032
|
+
},
|
|
1033
|
+
{
|
|
1034
|
+
// The builder UI reads the host's resolved field enable/disable map
|
|
1035
|
+
// from here, so the plugin option actually gates the type picker.
|
|
1036
|
+
// Options resolve server-side only; this is the one channel the
|
|
1037
|
+
// admin client has to them.
|
|
1038
|
+
method: "GET",
|
|
1039
|
+
path: "/builder-config",
|
|
1040
|
+
// Derived from the forms slug like the menu entry: with an
|
|
1041
|
+
// overridden slug, users hold read-<slug>, not read-forms.
|
|
1042
|
+
requiredPermission: `read-${resolvedConfig.formOverrides.slug}`,
|
|
1043
|
+
handler: (_req, ctx) => Promise.resolve(
|
|
1044
|
+
Response.json({
|
|
1045
|
+
fields: resolvedConfig.fields,
|
|
1046
|
+
// Notification defaults the builder surfaces: defaultToEmail
|
|
1047
|
+
// seeds a new form's first rule, defaultFrom renders as the
|
|
1048
|
+
// inherited sender placeholder.
|
|
1049
|
+
notifications: {
|
|
1050
|
+
defaultFrom: resolvedConfig.notifications.defaultFrom,
|
|
1051
|
+
defaultToEmail: resolvedConfig.notifications.defaultToEmail
|
|
1052
|
+
},
|
|
1053
|
+
// Spam defaults the Settings tab surfaces, so per-form
|
|
1054
|
+
// override selects can show what "inherit" resolves to.
|
|
1055
|
+
spamProtection: {
|
|
1056
|
+
honeypot: resolvedConfig.spamProtection.honeypot,
|
|
1057
|
+
recaptchaEnabled: resolvedConfig.spamProtection.recaptcha?.enabled ?? false
|
|
1058
|
+
},
|
|
1059
|
+
// Runtime-resolved collection slugs (through ctx.self, so a
|
|
1060
|
+
// framework .rename() is honored too) — admin components
|
|
1061
|
+
// never hardcode "forms"/"form-submissions".
|
|
1062
|
+
// Names only. A pattern may be a function, which does not
|
|
1063
|
+
// survive JSON and which the browser has no use for — the
|
|
1064
|
+
// admin picks a document, the server builds the URL.
|
|
1065
|
+
redirectCollections: Object.keys(
|
|
1066
|
+
resolvedConfig.redirectRelationships
|
|
1067
|
+
),
|
|
1068
|
+
slugs: {
|
|
1069
|
+
forms: ctx.self.collections[resolvedConfig.formOverrides.slug] ?? resolvedConfig.formOverrides.slug,
|
|
1070
|
+
submissions: ctx.self.collections[resolvedConfig.formSubmissionOverrides.slug] ?? resolvedConfig.formSubmissionOverrides.slug
|
|
1071
|
+
}
|
|
1072
|
+
})
|
|
1073
|
+
)
|
|
1074
|
+
}
|
|
1075
|
+
]
|
|
1076
|
+
},
|
|
1077
|
+
// Forms is a first-class destination, not a plugin detail: standalone
|
|
1078
|
+
// placement gives it its own main-rail icon after Media, and its
|
|
1079
|
+
// sub-sidebar lists the plugin's collections (Forms, Submissions) —
|
|
1080
|
+
// no separate menu contribution, so "Forms" exists exactly once.
|
|
1081
|
+
// Hosts preferring the Plugins section override placement in config.
|
|
1082
|
+
// (The old admin.settings.component that rendered a second full builder
|
|
1083
|
+
// at /admin/plugins/<slug> is gone; the collection Edit-view override
|
|
1084
|
+
// is the single FormBuilderView mount.)
|
|
502
1085
|
admin: {
|
|
1086
|
+
placement: "standalone",
|
|
1087
|
+
after: "media",
|
|
503
1088
|
order: 50,
|
|
1089
|
+
appearance: { icon: "FileText", label: "Forms" },
|
|
504
1090
|
description: "Create and manage forms with submission tracking"
|
|
505
1091
|
},
|
|
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
1092
|
// -- Init ----------------------------------------------------------------
|
|
520
1093
|
// Registers an afterCreate hook on submissions to send email notifications.
|
|
521
|
-
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", {
|
|
1094
|
+
async init(nextly) {
|
|
1095
|
+
const declaredSubmissionsSlug = resolvedConfig.formSubmissionOverrides.slug;
|
|
1096
|
+
const submissionSlug = nextly.self.collections[declaredSubmissionsSlug] ?? declaredSubmissionsSlug;
|
|
1097
|
+
nextly.logger.info("Form Builder plugin initialized", {
|
|
528
1098
|
formsCollection: resolvedConfig.formOverrides.slug,
|
|
529
1099
|
submissionsCollection: submissionSlug,
|
|
530
1100
|
enabledFields: Object.entries(resolvedConfig.fields).filter(([, on]) => on).map(([name]) => name)
|
|
531
1101
|
});
|
|
1102
|
+
for (const slug of Object.keys(
|
|
1103
|
+
resolvedConfig.redirectTargetLocalization
|
|
1104
|
+
)) {
|
|
1105
|
+
delete resolvedConfig.redirectTargetLocalization[slug];
|
|
1106
|
+
}
|
|
1107
|
+
await Promise.all(
|
|
1108
|
+
Object.keys(resolvedConfig.redirectRelationships).map(
|
|
1109
|
+
async (declared) => {
|
|
1110
|
+
const slug = nextly.self.collections[declared] ?? declared;
|
|
1111
|
+
try {
|
|
1112
|
+
const collection = await nextly.services.collections.getCollection(slug, {});
|
|
1113
|
+
const localized = collection?.localized;
|
|
1114
|
+
if (typeof localized === "boolean") {
|
|
1115
|
+
resolvedConfig.redirectTargetLocalization[declared] = localized;
|
|
1116
|
+
}
|
|
1117
|
+
} catch {
|
|
1118
|
+
nextly.logger.debug?.(
|
|
1119
|
+
"Redirect target localization could not be read",
|
|
1120
|
+
{ collection: slug }
|
|
1121
|
+
);
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
)
|
|
1125
|
+
);
|
|
532
1126
|
nextly.__formBuilderConfig = resolvedConfig;
|
|
1127
|
+
if (resolvedConfig.beforeEmail) {
|
|
1128
|
+
nextly.filters.add(
|
|
1129
|
+
"form-builder.beforeEmail",
|
|
1130
|
+
(emails, ctx) => resolvedConfig.beforeEmail({
|
|
1131
|
+
emails,
|
|
1132
|
+
// Boundary: loose runtime documents → the user's typed contract.
|
|
1133
|
+
form: asFormDocument(ctx.form),
|
|
1134
|
+
submission: asSubmissionDocument(ctx.submission)
|
|
1135
|
+
})
|
|
1136
|
+
);
|
|
1137
|
+
}
|
|
533
1138
|
nextly.hooks.on(
|
|
534
1139
|
"afterCreate",
|
|
535
1140
|
submissionSlug,
|
|
@@ -537,8 +1142,42 @@ function formBuilder(options = {}) {
|
|
|
537
1142
|
await handleSubmissionCreated(context, resolvedConfig, nextly);
|
|
538
1143
|
}
|
|
539
1144
|
);
|
|
1145
|
+
nextly.hooks.on("beforeUpdate", submissionSlug, (context) => {
|
|
1146
|
+
const ctx = context;
|
|
1147
|
+
if (ctx.data && ctx.data.data !== void 0) {
|
|
1148
|
+
ctx.data.editedAt = /* @__PURE__ */ new Date();
|
|
1149
|
+
ctx.data.editedBy = ctx.user?.id ?? null;
|
|
1150
|
+
}
|
|
1151
|
+
return ctx.data;
|
|
1152
|
+
});
|
|
1153
|
+
const declaredFormsSlug = resolvedConfig.formOverrides.slug;
|
|
1154
|
+
const formsSlug = nextly.self.collections[declaredFormsSlug] ?? declaredFormsSlug;
|
|
1155
|
+
nextly.hooks.on("afterRead", formsSlug, async (context) => {
|
|
1156
|
+
const data = context.data;
|
|
1157
|
+
const records = Array.isArray(data) ? data : data ? [data] : [];
|
|
1158
|
+
await Promise.all(
|
|
1159
|
+
records.map(async (record) => {
|
|
1160
|
+
const form = record;
|
|
1161
|
+
if (typeof form.id !== "string") return;
|
|
1162
|
+
try {
|
|
1163
|
+
form.submissionCount = await nextly.services.collections.count(
|
|
1164
|
+
submissionSlug,
|
|
1165
|
+
{
|
|
1166
|
+
where: {
|
|
1167
|
+
form: { equals: form.id },
|
|
1168
|
+
status: { not_equals: "spam" }
|
|
1169
|
+
}
|
|
1170
|
+
},
|
|
1171
|
+
{ as: "system" }
|
|
1172
|
+
);
|
|
1173
|
+
} catch {
|
|
1174
|
+
form.submissionCount = 0;
|
|
1175
|
+
}
|
|
1176
|
+
})
|
|
1177
|
+
);
|
|
1178
|
+
});
|
|
540
1179
|
}
|
|
541
|
-
};
|
|
1180
|
+
});
|
|
542
1181
|
return {
|
|
543
1182
|
plugin,
|
|
544
1183
|
collections: [formsCol, submissionsCol],
|
|
@@ -550,9 +1189,9 @@ function getFormBuilderConfig(nextly) {
|
|
|
550
1189
|
return nextly.__formBuilderConfig;
|
|
551
1190
|
}
|
|
552
1191
|
function resolveFieldRef(ref, data) {
|
|
553
|
-
const
|
|
554
|
-
if (
|
|
555
|
-
const value = data[
|
|
1192
|
+
const name = parseFieldRef(ref);
|
|
1193
|
+
if (name === null) return ref;
|
|
1194
|
+
const value = data[name];
|
|
556
1195
|
return typeof value === "string" ? value : "";
|
|
557
1196
|
}
|
|
558
1197
|
function collectAttachmentInputs(fields, submittedData) {
|
|
@@ -574,9 +1213,78 @@ function collectAttachmentInputs(fields, submittedData) {
|
|
|
574
1213
|
}
|
|
575
1214
|
return attachments;
|
|
576
1215
|
}
|
|
1216
|
+
function parseJsonColumn(value) {
|
|
1217
|
+
if (typeof value === "string") {
|
|
1218
|
+
try {
|
|
1219
|
+
const parsed = JSON.parse(value);
|
|
1220
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
1221
|
+
} catch {
|
|
1222
|
+
return {};
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
1226
|
+
}
|
|
1227
|
+
function normalizeAddressList(addresses) {
|
|
1228
|
+
if (!Array.isArray(addresses)) return void 0;
|
|
1229
|
+
const cleaned = addresses.map((address) => typeof address === "string" ? address.trim() : "").filter(Boolean);
|
|
1230
|
+
return cleaned.length > 0 ? cleaned : void 0;
|
|
1231
|
+
}
|
|
1232
|
+
function buildNotificationEmails(input) {
|
|
1233
|
+
const { notifications, submittedData, formName, submissionId, defaultFrom } = input;
|
|
1234
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1235
|
+
const emails = [];
|
|
1236
|
+
const skipped = [];
|
|
1237
|
+
for (const notification of notifications) {
|
|
1238
|
+
if (!notification.enabled) continue;
|
|
1239
|
+
if (!notification.templateSlug) {
|
|
1240
|
+
skipped.push({ notificationId: notification.id, reason: "no-template" });
|
|
1241
|
+
continue;
|
|
1242
|
+
}
|
|
1243
|
+
const key = notification.id || JSON.stringify(notification);
|
|
1244
|
+
if (seen.has(key)) continue;
|
|
1245
|
+
seen.add(key);
|
|
1246
|
+
if (notification.condition && !evaluateSingleCondition(notification.condition, submittedData)) {
|
|
1247
|
+
skipped.push({
|
|
1248
|
+
notificationId: notification.id,
|
|
1249
|
+
reason: "condition-unmet"
|
|
1250
|
+
});
|
|
1251
|
+
continue;
|
|
1252
|
+
}
|
|
1253
|
+
const to = (notification.recipientType === "field" ? resolveFieldRef(notification.to, submittedData) : notification.to).trim();
|
|
1254
|
+
if (!to) {
|
|
1255
|
+
skipped.push({
|
|
1256
|
+
notificationId: notification.id,
|
|
1257
|
+
reason: "empty-recipient"
|
|
1258
|
+
});
|
|
1259
|
+
continue;
|
|
1260
|
+
}
|
|
1261
|
+
const cc = normalizeAddressList(notification.cc);
|
|
1262
|
+
const bcc = normalizeAddressList(notification.bcc);
|
|
1263
|
+
const from = notification.senderEmail?.trim() || defaultFrom?.trim() || void 0;
|
|
1264
|
+
const replyTo = notification.replyTo ? resolveFieldRef(notification.replyTo, submittedData).trim() || void 0 : void 0;
|
|
1265
|
+
emails.push({
|
|
1266
|
+
to,
|
|
1267
|
+
templateSlug: notification.templateSlug,
|
|
1268
|
+
variables: {
|
|
1269
|
+
...submittedData,
|
|
1270
|
+
formName,
|
|
1271
|
+
submissionId
|
|
1272
|
+
},
|
|
1273
|
+
providerId: notification.providerId,
|
|
1274
|
+
from,
|
|
1275
|
+
replyTo,
|
|
1276
|
+
cc,
|
|
1277
|
+
bcc,
|
|
1278
|
+
notificationId: notification.id
|
|
1279
|
+
});
|
|
1280
|
+
}
|
|
1281
|
+
return { emails, skipped };
|
|
1282
|
+
}
|
|
577
1283
|
async function handleSubmissionCreated(context, config, nextly) {
|
|
1284
|
+
if (!config.notifications.enabled) return;
|
|
578
1285
|
const submission = context.data;
|
|
579
1286
|
if (!submission) return;
|
|
1287
|
+
if (submission.status === "spam") return;
|
|
580
1288
|
const rawFormId = submission.form;
|
|
581
1289
|
let formId = null;
|
|
582
1290
|
if (typeof rawFormId === "string") {
|
|
@@ -592,46 +1300,59 @@ async function handleSubmissionCreated(context, config, nextly) {
|
|
|
592
1300
|
if (notifications.length === 0) return;
|
|
593
1301
|
const emailService = nextly.services.email;
|
|
594
1302
|
if (!emailService) return;
|
|
595
|
-
const submittedData = submission.data
|
|
1303
|
+
const submittedData = parseJsonColumn(submission.data);
|
|
596
1304
|
const formFields = Array.isArray(form.fields) ? form.fields : [];
|
|
597
1305
|
const fileAttachments = collectAttachmentInputs(formFields, submittedData);
|
|
598
|
-
const
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
1306
|
+
const { emails, skipped } = buildNotificationEmails({
|
|
1307
|
+
notifications,
|
|
1308
|
+
submittedData,
|
|
1309
|
+
formName: form.name,
|
|
1310
|
+
submissionId: submission.id,
|
|
1311
|
+
defaultFrom: config.notifications.defaultFrom
|
|
1312
|
+
});
|
|
1313
|
+
for (const skip of skipped) {
|
|
1314
|
+
if (skip.reason === "empty-recipient") {
|
|
1315
|
+
nextly.logger.warn?.(
|
|
1316
|
+
"Form Builder: empty recipient, skipping notification",
|
|
1317
|
+
{ notificationId: skip.notificationId, formSlug: form.slug }
|
|
1318
|
+
);
|
|
1319
|
+
} else {
|
|
1320
|
+
nextly.logger.debug?.("Form Builder: notification skipped", {
|
|
1321
|
+
notificationId: skip.notificationId,
|
|
1322
|
+
formSlug: form.slug,
|
|
1323
|
+
reason: skip.reason
|
|
1324
|
+
});
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
if (emails.length === 0) return;
|
|
1328
|
+
const finalEmails = await nextly.filters.apply("form-builder.beforeEmail", emails, { form, submission });
|
|
1329
|
+
for (const email2 of finalEmails) {
|
|
604
1330
|
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
1331
|
await emailService.sendWithTemplate(
|
|
616
|
-
|
|
617
|
-
to,
|
|
618
|
-
|
|
1332
|
+
email2.templateSlug,
|
|
1333
|
+
email2.to,
|
|
1334
|
+
email2.variables,
|
|
619
1335
|
{
|
|
620
|
-
providerId:
|
|
621
|
-
|
|
622
|
-
|
|
1336
|
+
providerId: email2.providerId,
|
|
1337
|
+
from: email2.from,
|
|
1338
|
+
replyTo: email2.replyTo,
|
|
1339
|
+
cc: email2.cc,
|
|
1340
|
+
bcc: email2.bcc,
|
|
623
1341
|
attachments: fileAttachments.length > 0 ? fileAttachments : void 0
|
|
624
1342
|
}
|
|
625
1343
|
);
|
|
626
|
-
nextly.
|
|
1344
|
+
nextly.logger.info?.("Form Builder: notification sent", {
|
|
627
1345
|
formSlug: form.slug,
|
|
628
|
-
to,
|
|
629
|
-
templateSlug:
|
|
1346
|
+
to: email2.to,
|
|
1347
|
+
templateSlug: email2.templateSlug,
|
|
1348
|
+
notificationId: email2.notificationId,
|
|
630
1349
|
attachmentCount: fileAttachments.length
|
|
631
1350
|
});
|
|
632
1351
|
} catch (err) {
|
|
633
|
-
nextly.
|
|
634
|
-
|
|
1352
|
+
nextly.logger.error?.("Form Builder: notification failed", {
|
|
1353
|
+
to: email2.to,
|
|
1354
|
+
templateSlug: email2.templateSlug,
|
|
1355
|
+
notificationId: email2.notificationId,
|
|
635
1356
|
error: err instanceof Error ? err.message : String(err)
|
|
636
1357
|
});
|
|
637
1358
|
}
|
|
@@ -639,27 +1360,14 @@ async function handleSubmissionCreated(context, config, nextly) {
|
|
|
639
1360
|
}
|
|
640
1361
|
async function fetchParentForm(config, formId, nextly) {
|
|
641
1362
|
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;
|
|
1363
|
+
const form = await nextly.services.collections.findEntryById(
|
|
1364
|
+
config.formOverrides.slug,
|
|
1365
|
+
formId,
|
|
1366
|
+
{ as: "system" }
|
|
1367
|
+
);
|
|
1368
|
+
return form;
|
|
661
1369
|
} catch (err) {
|
|
662
|
-
nextly.
|
|
1370
|
+
nextly.logger.error?.("Form Builder: failed to fetch form", {
|
|
663
1371
|
formId,
|
|
664
1372
|
error: err instanceof Error ? err.message : String(err)
|
|
665
1373
|
});
|
|
@@ -667,30 +1375,8 @@ async function fetchParentForm(config, formId, nextly) {
|
|
|
667
1375
|
}
|
|
668
1376
|
}
|
|
669
1377
|
|
|
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
1378
|
// src/config/validate-form.ts
|
|
1379
|
+
import { isPluginFieldTypeOnSurface } from "nextly";
|
|
694
1380
|
var RESERVED_FORM_SLUGS = [
|
|
695
1381
|
// API routes
|
|
696
1382
|
"api",
|
|
@@ -707,21 +1393,7 @@ var RESERVED_FORM_SLUGS = [
|
|
|
707
1393
|
var RESERVED_SLUGS_SET = new Set(RESERVED_FORM_SLUGS);
|
|
708
1394
|
var SLUG_PATTERN = /^[a-z][a-z0-9_-]*$/;
|
|
709
1395
|
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
|
-
];
|
|
1396
|
+
var VALID_FIELD_TYPES = BUILT_IN_FORM_FIELD_TYPES;
|
|
725
1397
|
var VALID_FIELD_TYPES_SET = new Set(VALID_FIELD_TYPES);
|
|
726
1398
|
function isReservedSlug(slug) {
|
|
727
1399
|
return RESERVED_SLUGS_SET.has(slug.toLowerCase());
|
|
@@ -937,7 +1609,8 @@ function validateField(field, path, errors, seenNames) {
|
|
|
937
1609
|
});
|
|
938
1610
|
return;
|
|
939
1611
|
}
|
|
940
|
-
|
|
1612
|
+
const isValidType = typeof fieldType === "string" && (VALID_FIELD_TYPES_SET.has(fieldType) || isPluginFieldTypeOnSurface(fieldType, "forms"));
|
|
1613
|
+
if (!isValidType) {
|
|
941
1614
|
let fieldTypeRepr;
|
|
942
1615
|
if (typeof fieldType === "object" && fieldType !== null) {
|
|
943
1616
|
fieldTypeRepr = JSON.stringify(fieldType);
|
|
@@ -1017,6 +1690,17 @@ function validateSettings(settings, errors) {
|
|
|
1017
1690
|
});
|
|
1018
1691
|
}
|
|
1019
1692
|
}
|
|
1693
|
+
if (s.confirmationType === "relationship" && !namesADocument(s.redirectPage)) {
|
|
1694
|
+
errors.push({
|
|
1695
|
+
path: "settings.redirectPage",
|
|
1696
|
+
message: "A page is required when confirmation type is 'relationship'",
|
|
1697
|
+
code: "REDIRECT_PAGE_REQUIRED"
|
|
1698
|
+
});
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
function namesADocument(value) {
|
|
1702
|
+
if (typeof value === "string") return value.length > 0;
|
|
1703
|
+
return parseRedirectReference(value, []) !== null;
|
|
1020
1704
|
}
|
|
1021
1705
|
function validateAccess(access, errors) {
|
|
1022
1706
|
if (!access) {
|
|
@@ -1065,82 +1749,6 @@ ${errorMessages}`
|
|
|
1065
1749
|
}
|
|
1066
1750
|
}
|
|
1067
1751
|
|
|
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
1752
|
// src/config/field-helpers.ts
|
|
1145
1753
|
var option = (label, value) => ({
|
|
1146
1754
|
label,
|
|
@@ -1186,7 +1794,7 @@ var file = (config) => ({
|
|
|
1186
1794
|
...config,
|
|
1187
1795
|
type: "file"
|
|
1188
1796
|
});
|
|
1189
|
-
var
|
|
1797
|
+
var date3 = (config) => ({
|
|
1190
1798
|
...config,
|
|
1191
1799
|
type: "date"
|
|
1192
1800
|
});
|
|
@@ -1199,189 +1807,18 @@ var hidden = (config) => ({
|
|
|
1199
1807
|
type: "hidden"
|
|
1200
1808
|
});
|
|
1201
1809
|
|
|
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
1810
|
// src/utils/generate-schema.ts
|
|
1381
1811
|
import { z } from "zod";
|
|
1382
1812
|
function generateZodSchema(fields) {
|
|
1383
1813
|
const schemaShape = {};
|
|
1384
1814
|
for (const field of fields) {
|
|
1815
|
+
if (!isKnownFormField(field)) {
|
|
1816
|
+
schemaShape[field.name] = field.required ? z.unknown().refine(
|
|
1817
|
+
(value) => value !== void 0 && value !== null && value !== "",
|
|
1818
|
+
{ message: `${field.label || field.name} is required` }
|
|
1819
|
+
) : z.unknown();
|
|
1820
|
+
continue;
|
|
1821
|
+
}
|
|
1385
1822
|
if (field.type === "hidden" && !field.required) {
|
|
1386
1823
|
schemaShape[field.name] = z.string().optional();
|
|
1387
1824
|
continue;
|
|
@@ -1425,54 +1862,74 @@ function generateFieldSchema(field) {
|
|
|
1425
1862
|
return z.unknown();
|
|
1426
1863
|
}
|
|
1427
1864
|
}
|
|
1428
|
-
function
|
|
1429
|
-
|
|
1430
|
-
if (
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
}
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1865
|
+
function customMessage(field) {
|
|
1866
|
+
const enforced = ENFORCED_VALIDATION_RULES[field.type] ?? [];
|
|
1867
|
+
if (!enforced.includes("message")) return void 0;
|
|
1868
|
+
return field.validation?.errorMessage;
|
|
1869
|
+
}
|
|
1870
|
+
function enforcedValidation(field) {
|
|
1871
|
+
const stored = field.validation ?? {};
|
|
1872
|
+
return { ...stored, errorMessage: customMessage(field) };
|
|
1873
|
+
}
|
|
1874
|
+
var STRING_RULE_APPLIERS = {
|
|
1875
|
+
minLength: (schema, validation) => validation.minLength === void 0 ? schema : schema.min(
|
|
1876
|
+
validation.minLength,
|
|
1877
|
+
validation.errorMessage || `Minimum ${validation.minLength} characters required`
|
|
1878
|
+
),
|
|
1879
|
+
maxLength: (schema, validation) => validation.maxLength === void 0 ? schema : schema.max(
|
|
1880
|
+
validation.maxLength,
|
|
1881
|
+
validation.errorMessage || `Maximum ${validation.maxLength} characters allowed`
|
|
1882
|
+
),
|
|
1883
|
+
pattern: (schema, validation, fallback) => validation.pattern ? schema.regex(
|
|
1884
|
+
new RegExp(validation.pattern),
|
|
1885
|
+
validation.errorMessage || fallback
|
|
1886
|
+
) : schema
|
|
1887
|
+
};
|
|
1888
|
+
var NUMBER_RULE_APPLIERS = {
|
|
1889
|
+
min: (schema, validation) => validation.min === void 0 ? schema : schema.min(
|
|
1890
|
+
validation.min,
|
|
1891
|
+
validation.errorMessage || `Minimum value is ${validation.min}`
|
|
1892
|
+
),
|
|
1893
|
+
max: (schema, validation) => validation.max === void 0 ? schema : schema.max(
|
|
1894
|
+
validation.max,
|
|
1895
|
+
validation.errorMessage || `Maximum value is ${validation.max}`
|
|
1896
|
+
)
|
|
1897
|
+
};
|
|
1898
|
+
function overridesPattern(field) {
|
|
1899
|
+
const enforced = ENFORCED_VALIDATION_RULES[field.type] ?? [];
|
|
1900
|
+
if (!enforced.includes("pattern")) return false;
|
|
1901
|
+
const validation = field.validation ?? {};
|
|
1902
|
+
return Boolean(validation.pattern);
|
|
1903
|
+
}
|
|
1904
|
+
function applyStringRules(schema, field, patternMessage) {
|
|
1905
|
+
const validation = enforcedValidation(field);
|
|
1906
|
+
let constrained = schema;
|
|
1907
|
+
for (const rule of ENFORCED_VALIDATION_RULES[field.type] ?? []) {
|
|
1908
|
+
const apply = STRING_RULE_APPLIERS[rule];
|
|
1909
|
+
if (apply) constrained = apply(constrained, validation, patternMessage);
|
|
1447
1910
|
}
|
|
1448
|
-
return
|
|
1911
|
+
return constrained;
|
|
1912
|
+
}
|
|
1913
|
+
function generateTextSchema(field) {
|
|
1914
|
+
const schema = applyStringRules(z.string(), field, "Invalid format");
|
|
1915
|
+
return applyRequired(schema, field.required, customMessage(field));
|
|
1449
1916
|
}
|
|
1450
1917
|
function generateEmailSchema(field) {
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
}
|
|
1458
|
-
return applyRequired(schema, field.required, field.validation?.errorMessage);
|
|
1918
|
+
const schema = applyStringRules(
|
|
1919
|
+
z.string().email(customMessage(field) || "Invalid email address"),
|
|
1920
|
+
field,
|
|
1921
|
+
"Invalid email format"
|
|
1922
|
+
);
|
|
1923
|
+
return applyRequired(schema, field.required, customMessage(field));
|
|
1459
1924
|
}
|
|
1460
1925
|
function generateNumberSchema(field) {
|
|
1461
1926
|
let schema = z.number({
|
|
1462
|
-
|
|
1463
|
-
invalid_type_error: "Must be a number"
|
|
1927
|
+
error: (issue) => issue.input === void 0 ? customMessage(field) || "This field is required" : "Must be a number"
|
|
1464
1928
|
});
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
);
|
|
1470
|
-
}
|
|
1471
|
-
if (field.validation?.max !== void 0) {
|
|
1472
|
-
schema = schema.max(
|
|
1473
|
-
field.validation.max,
|
|
1474
|
-
field.validation.errorMessage || `Maximum value is ${field.validation.max}`
|
|
1475
|
-
);
|
|
1929
|
+
const validation = enforcedValidation(field);
|
|
1930
|
+
for (const rule of ENFORCED_VALIDATION_RULES[field.type] ?? []) {
|
|
1931
|
+
const apply = NUMBER_RULE_APPLIERS[rule];
|
|
1932
|
+
if (apply) schema = apply(schema, validation);
|
|
1476
1933
|
}
|
|
1477
1934
|
if (field.required) {
|
|
1478
1935
|
return schema;
|
|
@@ -1480,45 +1937,24 @@ function generateNumberSchema(field) {
|
|
|
1480
1937
|
return schema.optional().nullable();
|
|
1481
1938
|
}
|
|
1482
1939
|
function generatePhoneSchema(field) {
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
} else {
|
|
1490
|
-
schema = schema.regex(
|
|
1491
|
-
/^[\d\s\-+()]+$/,
|
|
1492
|
-
field.validation?.errorMessage || "Invalid phone number"
|
|
1493
|
-
);
|
|
1494
|
-
}
|
|
1495
|
-
return applyRequired(schema, field.required, field.validation?.errorMessage);
|
|
1940
|
+
const base = overridesPattern(field) ? z.string() : z.string().regex(
|
|
1941
|
+
/^[\d\s\-+()]+$/,
|
|
1942
|
+
customMessage(field) || "Invalid phone number"
|
|
1943
|
+
);
|
|
1944
|
+
const schema = applyStringRules(base, field, "Invalid phone number format");
|
|
1945
|
+
return applyRequired(schema, field.required, customMessage(field));
|
|
1496
1946
|
}
|
|
1497
1947
|
function generateUrlSchema(field) {
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
}
|
|
1505
|
-
return applyRequired(schema, field.required, field.validation?.errorMessage);
|
|
1948
|
+
const schema = applyStringRules(
|
|
1949
|
+
z.string().url(customMessage(field) || "Invalid URL"),
|
|
1950
|
+
field,
|
|
1951
|
+
"Invalid URL format"
|
|
1952
|
+
);
|
|
1953
|
+
return applyRequired(schema, field.required, customMessage(field));
|
|
1506
1954
|
}
|
|
1507
1955
|
function generateTextareaSchema(field) {
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
schema = schema.min(
|
|
1511
|
-
field.validation.minLength,
|
|
1512
|
-
field.validation.errorMessage || `Minimum ${field.validation.minLength} characters required`
|
|
1513
|
-
);
|
|
1514
|
-
}
|
|
1515
|
-
if (field.validation?.maxLength !== void 0) {
|
|
1516
|
-
schema = schema.max(
|
|
1517
|
-
field.validation.maxLength,
|
|
1518
|
-
field.validation.errorMessage || `Maximum ${field.validation.maxLength} characters allowed`
|
|
1519
|
-
);
|
|
1520
|
-
}
|
|
1521
|
-
return applyRequired(schema, field.required, field.validation?.errorMessage);
|
|
1956
|
+
const schema = applyStringRules(z.string(), field, "Invalid format");
|
|
1957
|
+
return applyRequired(schema, field.required, customMessage(field));
|
|
1522
1958
|
}
|
|
1523
1959
|
function generateSelectSchema(field) {
|
|
1524
1960
|
const validValues = field.options.map((opt) => opt.value);
|
|
@@ -1527,15 +1963,13 @@ function generateSelectSchema(field) {
|
|
|
1527
1963
|
if (field.required) {
|
|
1528
1964
|
return schema2.min(
|
|
1529
1965
|
1,
|
|
1530
|
-
field
|
|
1966
|
+
customMessage(field) || "Please select at least one option"
|
|
1531
1967
|
);
|
|
1532
1968
|
}
|
|
1533
1969
|
return schema2.optional();
|
|
1534
1970
|
}
|
|
1535
1971
|
const schema = z.enum(validValues, {
|
|
1536
|
-
|
|
1537
|
-
message: field.validation?.errorMessage || "Please select a valid option"
|
|
1538
|
-
})
|
|
1972
|
+
error: () => customMessage(field) || "Please select a valid option"
|
|
1539
1973
|
});
|
|
1540
1974
|
if (field.required) {
|
|
1541
1975
|
return schema;
|
|
@@ -1546,7 +1980,7 @@ function generateCheckboxSchema(field) {
|
|
|
1546
1980
|
const schema = z.boolean();
|
|
1547
1981
|
if (field.required) {
|
|
1548
1982
|
return schema.refine((val) => val === true, {
|
|
1549
|
-
message: field
|
|
1983
|
+
message: customMessage(field) || "This field is required"
|
|
1550
1984
|
});
|
|
1551
1985
|
}
|
|
1552
1986
|
return schema.optional();
|
|
@@ -1554,9 +1988,7 @@ function generateCheckboxSchema(field) {
|
|
|
1554
1988
|
function generateRadioSchema(field) {
|
|
1555
1989
|
const validValues = field.options.map((opt) => opt.value);
|
|
1556
1990
|
const schema = z.enum(validValues, {
|
|
1557
|
-
|
|
1558
|
-
message: field.validation?.errorMessage || "Please select an option"
|
|
1559
|
-
})
|
|
1991
|
+
error: () => customMessage(field) || "Please select an option"
|
|
1560
1992
|
});
|
|
1561
1993
|
if (field.required) {
|
|
1562
1994
|
return schema;
|
|
@@ -1569,42 +2001,42 @@ function generateFileSchema(field) {
|
|
|
1569
2001
|
if (field.required) {
|
|
1570
2002
|
return schema2.min(
|
|
1571
2003
|
1,
|
|
1572
|
-
field
|
|
2004
|
+
customMessage(field) || "Please upload at least one file"
|
|
1573
2005
|
);
|
|
1574
2006
|
}
|
|
1575
2007
|
return schema2.optional();
|
|
1576
2008
|
}
|
|
1577
2009
|
const schema = z.string();
|
|
1578
|
-
return applyRequired(schema, field.required, field
|
|
2010
|
+
return applyRequired(schema, field.required, customMessage(field));
|
|
1579
2011
|
}
|
|
1580
2012
|
function generateDateSchema(field) {
|
|
1581
2013
|
let schema = z.string();
|
|
1582
2014
|
schema = schema.refine(
|
|
1583
2015
|
(val) => {
|
|
1584
2016
|
if (!val) return true;
|
|
1585
|
-
const
|
|
1586
|
-
return !isNaN(
|
|
2017
|
+
const date4 = new Date(val);
|
|
2018
|
+
return !isNaN(date4.getTime());
|
|
1587
2019
|
},
|
|
1588
|
-
{ message: field
|
|
2020
|
+
{ message: customMessage(field) || "Invalid date" }
|
|
1589
2021
|
);
|
|
1590
2022
|
if (field.min || field.max) {
|
|
1591
2023
|
schema = schema.refine(
|
|
1592
2024
|
(val) => {
|
|
1593
2025
|
if (!val) return true;
|
|
1594
|
-
const
|
|
1595
|
-
if (field.min &&
|
|
1596
|
-
if (field.max &&
|
|
2026
|
+
const date4 = new Date(val);
|
|
2027
|
+
if (field.min && date4 < new Date(field.min)) return false;
|
|
2028
|
+
if (field.max && date4 > new Date(field.max)) return false;
|
|
1597
2029
|
return true;
|
|
1598
2030
|
},
|
|
1599
2031
|
{
|
|
1600
|
-
message: field
|
|
2032
|
+
message: customMessage(field) || `Date must be between ${field.min || "any"} and ${field.max || "any"}`
|
|
1601
2033
|
}
|
|
1602
2034
|
);
|
|
1603
2035
|
}
|
|
1604
2036
|
if (field.required) {
|
|
1605
2037
|
return schema.refine(
|
|
1606
2038
|
(val) => val !== void 0 && val !== null && val !== "",
|
|
1607
|
-
{ message: field
|
|
2039
|
+
{ message: customMessage(field) || "This field is required" }
|
|
1608
2040
|
);
|
|
1609
2041
|
}
|
|
1610
2042
|
return schema.optional().or(z.literal(""));
|
|
@@ -1612,13 +2044,13 @@ function generateDateSchema(field) {
|
|
|
1612
2044
|
function generateTimeSchema(field) {
|
|
1613
2045
|
const schema = z.string().regex(
|
|
1614
2046
|
/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/,
|
|
1615
|
-
field
|
|
2047
|
+
customMessage(field) || "Invalid time format (HH:mm)"
|
|
1616
2048
|
);
|
|
1617
|
-
return applyRequired(schema, field.required, field
|
|
2049
|
+
return applyRequired(schema, field.required, customMessage(field));
|
|
1618
2050
|
}
|
|
1619
2051
|
function generateHiddenSchema(field) {
|
|
1620
2052
|
const schema = z.string();
|
|
1621
|
-
return applyRequired(schema, field.required, field
|
|
2053
|
+
return applyRequired(schema, field.required, customMessage(field));
|
|
1622
2054
|
}
|
|
1623
2055
|
function applyRequired(schema, required, errorMessage) {
|
|
1624
2056
|
if (required) {
|
|
@@ -1633,7 +2065,7 @@ function transformFormData(data, fields) {
|
|
|
1633
2065
|
if (value === void 0) {
|
|
1634
2066
|
continue;
|
|
1635
2067
|
}
|
|
1636
|
-
transformed[field.name] = transformFieldValue(value, field);
|
|
2068
|
+
transformed[field.name] = isKnownFormField(field) ? transformFieldValue(value, field) : value;
|
|
1637
2069
|
}
|
|
1638
2070
|
return transformed;
|
|
1639
2071
|
}
|
|
@@ -1684,7 +2116,7 @@ function getValidationErrors(result) {
|
|
|
1684
2116
|
return {};
|
|
1685
2117
|
}
|
|
1686
2118
|
const errors = {};
|
|
1687
|
-
for (const error of result.error.
|
|
2119
|
+
for (const error of result.error.issues) {
|
|
1688
2120
|
const path = error.path.join(".");
|
|
1689
2121
|
if (!errors[path]) {
|
|
1690
2122
|
errors[path] = error.message;
|
|
@@ -1693,202 +2125,8 @@ function getValidationErrors(result) {
|
|
|
1693
2125
|
return errors;
|
|
1694
2126
|
}
|
|
1695
2127
|
|
|
1696
|
-
// src/
|
|
1697
|
-
|
|
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
|
-
}
|
|
2128
|
+
// src/handlers/submit-form.ts
|
|
2129
|
+
import { formAvailability, NO_SUCH_FORM } from "nextly";
|
|
1892
2130
|
|
|
1893
2131
|
// src/handlers/spam-detection.ts
|
|
1894
2132
|
var rateLimitStore = /* @__PURE__ */ new Map();
|
|
@@ -2007,68 +2245,116 @@ async function submitForm(options, context) {
|
|
|
2007
2245
|
const { formSlug, data, metadata } = options;
|
|
2008
2246
|
const { pluginContext, pluginConfig } = context;
|
|
2009
2247
|
const { collections } = pluginContext.services;
|
|
2010
|
-
const { logger } = pluginContext
|
|
2248
|
+
const { logger } = pluginContext;
|
|
2011
2249
|
try {
|
|
2012
2250
|
const form = await fetchFormBySlug(formSlug, pluginConfig, pluginContext);
|
|
2013
2251
|
if (!form) {
|
|
2014
2252
|
logger.warn?.("Form submission attempted for non-existent form", {
|
|
2015
2253
|
formSlug
|
|
2016
2254
|
});
|
|
2017
|
-
return {
|
|
2018
|
-
success: false,
|
|
2019
|
-
error: "Form not found"
|
|
2020
|
-
};
|
|
2255
|
+
return { success: false, error: NO_SUCH_FORM };
|
|
2021
2256
|
}
|
|
2022
|
-
|
|
2023
|
-
|
|
2257
|
+
const availability = formAvailability(form);
|
|
2258
|
+
if (availability.kind !== "open") {
|
|
2259
|
+
logger.info?.("Form submission rejected - form not accepting", {
|
|
2024
2260
|
formSlug,
|
|
2025
|
-
status: form.status
|
|
2261
|
+
status: form.status,
|
|
2262
|
+
availability: availability.kind
|
|
2026
2263
|
});
|
|
2027
2264
|
return {
|
|
2028
2265
|
success: false,
|
|
2029
|
-
error:
|
|
2266
|
+
error: availability.kind === "closed" ? availability.message : NO_SUCH_FORM
|
|
2030
2267
|
};
|
|
2031
2268
|
}
|
|
2032
|
-
const
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2269
|
+
const settings = normalizeFormSettings(form.settings);
|
|
2270
|
+
if (settings.allowMultipleSubmissions === false && !metadata?.ipAddress) {
|
|
2271
|
+
logger.warn?.(
|
|
2272
|
+
"Single-submission form received a submission without an IP \u2014 the restriction cannot apply",
|
|
2273
|
+
{ formSlug }
|
|
2274
|
+
);
|
|
2275
|
+
}
|
|
2276
|
+
if (settings.allowMultipleSubmissions === false && metadata?.ipAddress) {
|
|
2277
|
+
const existing = await collections.count(
|
|
2278
|
+
pluginConfig.formSubmissionOverrides.slug,
|
|
2279
|
+
{
|
|
2280
|
+
where: {
|
|
2281
|
+
form: { equals: form.id },
|
|
2282
|
+
ipAddress: { equals: metadata.ipAddress },
|
|
2283
|
+
status: { not_equals: "spam" }
|
|
2284
|
+
}
|
|
2285
|
+
},
|
|
2286
|
+
{ as: "system" }
|
|
2287
|
+
);
|
|
2288
|
+
if (existing > 0) {
|
|
2289
|
+
logger.info?.("Repeat submission rejected (single-submission form)", {
|
|
2290
|
+
formSlug,
|
|
2291
|
+
ipAddress: metadata.ipAddress
|
|
2292
|
+
});
|
|
2293
|
+
return {
|
|
2294
|
+
success: false,
|
|
2295
|
+
error: "You have already submitted this form."
|
|
2296
|
+
};
|
|
2297
|
+
}
|
|
2046
2298
|
}
|
|
2047
|
-
|
|
2299
|
+
const declaredFieldNames = new Set(form.fields.map((field) => field.name));
|
|
2300
|
+
const undeclaredData = Object.fromEntries(
|
|
2301
|
+
Object.entries(data).filter(([key]) => !declaredFieldNames.has(key))
|
|
2302
|
+
);
|
|
2048
2303
|
const spamResult = await checkSpam({
|
|
2049
|
-
data:
|
|
2304
|
+
data: undeclaredData,
|
|
2050
2305
|
ipAddress: metadata?.ipAddress,
|
|
2051
2306
|
formSlug,
|
|
2052
2307
|
config: {
|
|
2053
|
-
|
|
2308
|
+
// Per-form overrides win where set; blank inherits the plugin config.
|
|
2309
|
+
honeypot: settings.honeypotEnabled ?? pluginConfig.spamProtection.honeypot,
|
|
2054
2310
|
rateLimit: pluginConfig.spamProtection.rateLimit,
|
|
2055
|
-
recaptcha:
|
|
2311
|
+
recaptcha: {
|
|
2312
|
+
...pluginConfig.spamProtection.recaptcha,
|
|
2313
|
+
enabled: settings.captchaEnabled ?? pluginConfig.spamProtection.recaptcha?.enabled ?? false
|
|
2314
|
+
}
|
|
2056
2315
|
}
|
|
2057
2316
|
});
|
|
2058
|
-
if (spamResult.isSpam) {
|
|
2059
|
-
logger.info?.("Spam submission
|
|
2317
|
+
if (spamResult.isSpam && spamResult.reason === "rate_limit") {
|
|
2318
|
+
logger.info?.("Spam submission rejected (rate limit)", {
|
|
2319
|
+
formSlug,
|
|
2320
|
+
ipAddress: metadata?.ipAddress
|
|
2321
|
+
});
|
|
2322
|
+
return { success: true };
|
|
2323
|
+
}
|
|
2324
|
+
const isContentSpam = spamResult.isSpam;
|
|
2325
|
+
const transformedData = transformFormData(data, form.fields);
|
|
2326
|
+
let storedData;
|
|
2327
|
+
if (isContentSpam) {
|
|
2328
|
+
logger.info?.("Spam submission detected \u2014 storing flagged", {
|
|
2060
2329
|
formSlug,
|
|
2061
2330
|
reason: spamResult.reason,
|
|
2062
2331
|
ipAddress: metadata?.ipAddress
|
|
2063
2332
|
});
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2333
|
+
sanitizeSubmissionData(transformedData, form.fields);
|
|
2334
|
+
storedData = transformedData;
|
|
2335
|
+
} else {
|
|
2336
|
+
const schema = generateZodSchema(form.fields);
|
|
2337
|
+
const validationResult = schema.safeParse(transformedData);
|
|
2338
|
+
if (!validationResult.success) {
|
|
2339
|
+
const validationErrors = getValidationErrors(validationResult);
|
|
2340
|
+
logger.debug?.("Form validation failed", {
|
|
2341
|
+
formSlug,
|
|
2342
|
+
errors: validationErrors
|
|
2343
|
+
});
|
|
2344
|
+
return {
|
|
2345
|
+
success: false,
|
|
2346
|
+
error: "Validation failed",
|
|
2347
|
+
validationErrors
|
|
2348
|
+
};
|
|
2349
|
+
}
|
|
2350
|
+
sanitizeSubmissionData(validationResult.data, form.fields);
|
|
2351
|
+
storedData = validationResult.data;
|
|
2067
2352
|
}
|
|
2068
2353
|
const submissionData = {
|
|
2069
2354
|
form: form.id,
|
|
2070
|
-
data:
|
|
2071
|
-
status: "new",
|
|
2355
|
+
data: storedData,
|
|
2356
|
+
status: isContentSpam ? "spam" : "new",
|
|
2357
|
+
spamReason: isContentSpam ? spamResult.reason ?? null : null,
|
|
2072
2358
|
ipAddress: metadata?.ipAddress || null,
|
|
2073
2359
|
userAgent: metadata?.userAgent || null,
|
|
2074
2360
|
submittedAt: /* @__PURE__ */ new Date()
|
|
@@ -2076,17 +2362,37 @@ async function submitForm(options, context) {
|
|
|
2076
2362
|
const submission = await collections.createEntry(
|
|
2077
2363
|
pluginConfig.formSubmissionOverrides.slug,
|
|
2078
2364
|
submissionData,
|
|
2079
|
-
|
|
2080
|
-
//
|
|
2365
|
+
// Public form submission — create as system. No ambient user; an
|
|
2366
|
+
// empty context already resolves to system, but be explicit.
|
|
2367
|
+
{ as: "system" }
|
|
2081
2368
|
);
|
|
2082
2369
|
logger.info?.("Form submission created successfully", {
|
|
2083
2370
|
formSlug,
|
|
2084
|
-
submissionId: submission.id
|
|
2371
|
+
submissionId: submission.item.id,
|
|
2372
|
+
flaggedAsSpam: isContentSpam
|
|
2085
2373
|
});
|
|
2086
|
-
|
|
2374
|
+
if (submission.warnings) {
|
|
2375
|
+
logger.warn?.("Form submission side effects failed", {
|
|
2376
|
+
formSlug,
|
|
2377
|
+
submissionId: submission.item.id,
|
|
2378
|
+
warnings: submission.warnings
|
|
2379
|
+
});
|
|
2380
|
+
}
|
|
2381
|
+
const redirect = await resolveRedirectUrl(
|
|
2382
|
+
form,
|
|
2383
|
+
pluginConfig,
|
|
2384
|
+
pluginContext
|
|
2385
|
+
);
|
|
2386
|
+
if (isContentSpam) {
|
|
2387
|
+
return { success: true, redirect };
|
|
2388
|
+
}
|
|
2087
2389
|
return {
|
|
2088
2390
|
success: true,
|
|
2089
|
-
|
|
2391
|
+
// The created ROW, not the envelope around it. Through the shared
|
|
2392
|
+
// conversion, which is where the unchecked step lives and says so; the
|
|
2393
|
+
// integration test reads `submission.id` off the result because nothing
|
|
2394
|
+
// at compile time will.
|
|
2395
|
+
submission: asSubmissionDocument(submission.item),
|
|
2090
2396
|
redirect
|
|
2091
2397
|
};
|
|
2092
2398
|
} catch (error) {
|
|
@@ -2105,39 +2411,109 @@ async function fetchFormBySlug(slug, pluginConfig, pluginContext) {
|
|
|
2105
2411
|
const { collections } = pluginContext.services;
|
|
2106
2412
|
const result = await collections.listEntries(
|
|
2107
2413
|
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
|
|
2414
|
+
{ where: { slug: { equals: slug } }, pagination: { limit: 1 } },
|
|
2415
|
+
{ as: "system" }
|
|
2120
2416
|
);
|
|
2121
|
-
|
|
2417
|
+
const form = result.data?.[0];
|
|
2418
|
+
return form ? asFormDocument(form) : null;
|
|
2122
2419
|
} catch {
|
|
2123
2420
|
return null;
|
|
2124
2421
|
}
|
|
2125
2422
|
}
|
|
2126
|
-
function
|
|
2127
|
-
|
|
2423
|
+
async function resolveRedirectUrl(form, pluginConfig, pluginContext) {
|
|
2424
|
+
const settings = form.settings;
|
|
2425
|
+
if (!settings) return void 0;
|
|
2426
|
+
if (settings.confirmationType === "redirect" && settings.redirectUrl) {
|
|
2427
|
+
return settings.redirectUrl;
|
|
2428
|
+
}
|
|
2429
|
+
const field = pickedDocumentField(settings);
|
|
2430
|
+
if (!field) return void 0;
|
|
2431
|
+
return urlForPickedDocument(
|
|
2432
|
+
settings[field],
|
|
2433
|
+
form,
|
|
2434
|
+
pluginConfig,
|
|
2435
|
+
pluginContext
|
|
2436
|
+
);
|
|
2437
|
+
}
|
|
2438
|
+
async function urlForPickedDocument(stored, form, pluginConfig, pluginContext) {
|
|
2439
|
+
const { logger } = pluginContext;
|
|
2440
|
+
const patterns = pluginConfig.redirectRelationships;
|
|
2441
|
+
const reference = parseRedirectReference(stored, Object.keys(patterns));
|
|
2442
|
+
if (!reference) {
|
|
2443
|
+
logger.warn?.("Form redirects to a page, but names no readable document", {
|
|
2444
|
+
form: form.slug
|
|
2445
|
+
});
|
|
2446
|
+
return void 0;
|
|
2447
|
+
}
|
|
2448
|
+
const pattern = patterns[reference.collection];
|
|
2449
|
+
if (!pattern) {
|
|
2450
|
+
logger.warn?.(
|
|
2451
|
+
"Form redirects to a collection that is not in redirectRelationships",
|
|
2452
|
+
{ form: form.slug, collection: reference.collection }
|
|
2453
|
+
);
|
|
2128
2454
|
return void 0;
|
|
2129
2455
|
}
|
|
2130
|
-
|
|
2456
|
+
const target = await readTarget(reference, form, pluginContext);
|
|
2457
|
+
if (!target) return void 0;
|
|
2458
|
+
if (documentReachability(
|
|
2459
|
+
target,
|
|
2460
|
+
pluginConfig.redirectTargetLocalization[reference.collection]
|
|
2461
|
+
) === "unreachable") {
|
|
2462
|
+
logger.warn?.("Form redirects to a page that has never been published", {
|
|
2463
|
+
form: form.slug,
|
|
2464
|
+
collection: reference.collection,
|
|
2465
|
+
target: reference.id
|
|
2466
|
+
});
|
|
2131
2467
|
return void 0;
|
|
2132
2468
|
}
|
|
2133
|
-
|
|
2134
|
-
|
|
2469
|
+
return buildUrl(pattern, target, reference.collection, form, pluginContext);
|
|
2470
|
+
}
|
|
2471
|
+
function buildUrl(pattern, target, collection, form, pluginContext) {
|
|
2472
|
+
const { logger } = pluginContext;
|
|
2473
|
+
try {
|
|
2474
|
+
const url2 = applyRedirectPattern(pattern, target);
|
|
2475
|
+
if (!url2) {
|
|
2476
|
+
logger.warn?.(
|
|
2477
|
+
"Form redirect pattern could not be filled from the target document",
|
|
2478
|
+
{ form: form.slug, collection }
|
|
2479
|
+
);
|
|
2480
|
+
}
|
|
2481
|
+
return url2;
|
|
2482
|
+
} catch (error) {
|
|
2483
|
+
logger.warn?.("Form redirect pattern threw while building a URL", {
|
|
2484
|
+
form: form.slug,
|
|
2485
|
+
collection,
|
|
2486
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2487
|
+
});
|
|
2488
|
+
return void 0;
|
|
2135
2489
|
}
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2490
|
+
}
|
|
2491
|
+
async function readTarget(reference, form, pluginContext) {
|
|
2492
|
+
const { logger } = pluginContext;
|
|
2493
|
+
const context = {
|
|
2494
|
+
form: form.slug,
|
|
2495
|
+
collection: reference.collection,
|
|
2496
|
+
id: reference.id
|
|
2497
|
+
};
|
|
2498
|
+
let row;
|
|
2499
|
+
try {
|
|
2500
|
+
row = await pluginContext.services.collections.findEntryById(
|
|
2501
|
+
reference.collection,
|
|
2502
|
+
reference.id,
|
|
2503
|
+
{ as: "system" }
|
|
2504
|
+
);
|
|
2505
|
+
} catch (error) {
|
|
2506
|
+
logger.warn?.("Form redirect target could not be read", {
|
|
2507
|
+
...context,
|
|
2508
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2509
|
+
});
|
|
2510
|
+
return void 0;
|
|
2139
2511
|
}
|
|
2140
|
-
|
|
2512
|
+
if (!row || typeof row !== "object") {
|
|
2513
|
+
logger.warn?.("Form redirect target no longer exists", context);
|
|
2514
|
+
return void 0;
|
|
2515
|
+
}
|
|
2516
|
+
return row;
|
|
2141
2517
|
}
|
|
2142
2518
|
async function validateSubmission(formSlug, data, context) {
|
|
2143
2519
|
const { pluginContext, pluginConfig } = context;
|
|
@@ -2191,33 +2567,28 @@ async function getFormSubmissionStats(formSlug, context) {
|
|
|
2191
2567
|
return null;
|
|
2192
2568
|
}
|
|
2193
2569
|
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;
|
|
2570
|
+
const submissionsSlug = pluginConfig.formSubmissionOverrides.slug;
|
|
2571
|
+
const sys = { as: "system" };
|
|
2572
|
+
const base = { form: { equals: form.id } };
|
|
2573
|
+
const [total, newCount, read, archived] = await Promise.all([
|
|
2574
|
+
collections.count(submissionsSlug, { where: base }, sys),
|
|
2575
|
+
collections.count(
|
|
2576
|
+
submissionsSlug,
|
|
2577
|
+
{ where: { ...base, status: { equals: "new" } } },
|
|
2578
|
+
sys
|
|
2579
|
+
),
|
|
2580
|
+
collections.count(
|
|
2581
|
+
submissionsSlug,
|
|
2582
|
+
{ where: { ...base, status: { equals: "read" } } },
|
|
2583
|
+
sys
|
|
2584
|
+
),
|
|
2585
|
+
collections.count(
|
|
2586
|
+
submissionsSlug,
|
|
2587
|
+
{ where: { ...base, status: { equals: "archived" } } },
|
|
2588
|
+
sys
|
|
2589
|
+
)
|
|
2590
|
+
]);
|
|
2591
|
+
return { total, new: newCount, read, archived };
|
|
2221
2592
|
} catch {
|
|
2222
2593
|
return null;
|
|
2223
2594
|
}
|
|
@@ -2404,6 +2775,7 @@ function getSupportedWebhookEvents() {
|
|
|
2404
2775
|
// src/index.ts
|
|
2405
2776
|
var formBuilderPlugin = formBuilder().plugin;
|
|
2406
2777
|
export {
|
|
2778
|
+
BUILT_IN_FORM_FIELD_TYPES,
|
|
2407
2779
|
DEFAULT_FORM_SETTINGS,
|
|
2408
2780
|
DEFAULT_NOTIFICATION_SETTINGS,
|
|
2409
2781
|
RESERVED_FORM_SLUGS,
|
|
@@ -2415,7 +2787,7 @@ export {
|
|
|
2415
2787
|
cleanupRateLimitStore,
|
|
2416
2788
|
clearRateLimitStore,
|
|
2417
2789
|
createFormConfig,
|
|
2418
|
-
|
|
2790
|
+
date3 as date,
|
|
2419
2791
|
downloadFile,
|
|
2420
2792
|
email,
|
|
2421
2793
|
evaluateConditions,
|
|
@@ -2443,6 +2815,7 @@ export {
|
|
|
2443
2815
|
isFormAcceptingSubmissions,
|
|
2444
2816
|
isFormField,
|
|
2445
2817
|
isFormFieldType,
|
|
2818
|
+
isKnownFormField,
|
|
2446
2819
|
isRateLimited,
|
|
2447
2820
|
isSelectFormField,
|
|
2448
2821
|
isTextFormField,
|