@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.
Files changed (38) hide show
  1. package/README.md +12 -5
  2. package/dist/admin/index.d.ts +139 -331
  3. package/dist/admin/index.js +4410 -3532
  4. package/dist/admin/index.js.map +1 -1
  5. package/dist/chunk-H5X3FKOZ.js +689 -0
  6. package/dist/chunk-H5X3FKOZ.js.map +1 -0
  7. package/dist/index.d.ts +13 -27
  8. package/dist/index.js +1187 -814
  9. package/dist/index.js.map +1 -1
  10. package/dist/{index-C0kBvYjm.d.ts → types-hLfLe81K.d.ts} +269 -203
  11. package/package.json +33 -31
  12. package/dist/CheckboxFieldEditor-K4T6QX52.js +0 -121
  13. package/dist/CheckboxFieldEditor-K4T6QX52.js.map +0 -1
  14. package/dist/DateFieldEditor-JFJFAVOH.js +0 -187
  15. package/dist/DateFieldEditor-JFJFAVOH.js.map +0 -1
  16. package/dist/EmailFieldEditor-MMOXXL7P.js +0 -138
  17. package/dist/EmailFieldEditor-MMOXXL7P.js.map +0 -1
  18. package/dist/NumberFieldEditor-UISAYR6Z.js +0 -175
  19. package/dist/NumberFieldEditor-UISAYR6Z.js.map +0 -1
  20. package/dist/RadioFieldEditor-6UQFH4WT.js +0 -165
  21. package/dist/RadioFieldEditor-6UQFH4WT.js.map +0 -1
  22. package/dist/SelectFieldEditor-PSO4KHV4.js +0 -213
  23. package/dist/SelectFieldEditor-PSO4KHV4.js.map +0 -1
  24. package/dist/TextFieldEditor-5EU64EA5.js +0 -187
  25. package/dist/TextFieldEditor-5EU64EA5.js.map +0 -1
  26. package/dist/TextareaFieldEditor-773G3LNM.js +0 -190
  27. package/dist/TextareaFieldEditor-773G3LNM.js.map +0 -1
  28. package/dist/admin/index.css +0 -934
  29. package/dist/admin/index.css.map +0 -1
  30. package/dist/chunk-ABNZFTQK.js +0 -586
  31. package/dist/chunk-ABNZFTQK.js.map +0 -1
  32. package/dist/chunk-XTCEPJ7B.js +0 -256
  33. package/dist/chunk-XTCEPJ7B.js.map +0 -1
  34. package/dist/components/index.d.ts +0 -3
  35. package/dist/components/index.js +0 -9
  36. package/dist/components/index.js.map +0 -1
  37. package/dist/styles/form-builder.css +0 -1150
  38. package/dist/styles/submissions-filter.css +0 -75
package/dist/index.js CHANGED
@@ -1,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 { getCollectionsHandler } from "nextly/runtime";
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
- ...pluginConfig.redirectRelationships.length > 0 ? [{ label: "Redirect to Page", value: "relationship" }] : []
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 (pluginConfig.redirectRelationships.length > 0) {
371
+ if (redirectCollections.length > 0) {
69
372
  settingsFields.push(
70
373
  relationship({
71
374
  name: "redirectPage",
72
375
  label: "Redirect Page",
73
- relationTo: pluginConfig.redirectRelationships,
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: "Protect this form with Google reCAPTCHA v3"
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: "Email Integrations",
475
+ label: "Notifications",
167
476
  defaultValue: [],
168
477
  admin: {
169
- description: "Email notification integrations. Managed by the Form Builder UI."
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
- ...overrides.admin
552
+ ...adminOverrides
231
553
  },
232
554
  // Access control with sensible defaults
233
- access: {
234
- // Anyone can read forms (needed for frontend rendering)
235
- read: accessOverrides?.read ?? true,
236
- // Only authenticated users can create/update forms
237
- create: accessOverrides?.create ?? (({ user }) => !!user),
238
- update: accessOverrides?.update ?? (({ user }) => !!user),
239
- // Only admins can delete forms (falls back to DB permissions)
240
- delete: accessOverrides?.delete ?? (({ roles }) => roles.includes("admin") || roles.includes("super-admin"))
241
- },
242
- hooks: {
243
- // Auto-generate slug from name if not provided
244
- beforeValidate: [
245
- (context) => {
246
- const { data, operation } = context;
247
- if (operation === "create" && data && !data.slug && data.name) {
248
- data.slug = String(data.name).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
249
- }
250
- if (data && (data.fields === void 0 || Array.isArray(data.fields) && data.fields.length === 0)) {
251
- throw new Error("Form must have at least one field.");
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
- return data;
254
- }
255
- ],
256
- // Add virtual submission count field
257
- afterRead: [
258
- (context) => {
259
- const { data } = context;
260
- if (data) {
261
- data.submissionCount = 0;
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
- return data;
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
- date({
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
- // Custom components for submissions list
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
- // Filter dropdown to filter submissions by form
387
- BeforeListTable: "@nextlyhq/plugin-form-builder/admin#SubmissionsFilter"
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
- access: {
394
- // Anyone can submit forms (public access)
395
- create: accessOverrides?.create ?? true,
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 || options.collections?.forms || {};
429
- const submissionOverrides = options.formSubmissionOverrides || options.collections?.submissions || {};
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: options.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: "0.0.8",
501
- collections: [formsCol, submissionsCol],
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 submissionSlug = resolvedConfig.formSubmissionOverrides.slug;
523
- const guardKey = `__formBuilder_afterCreate_${submissionSlug}`;
524
- const g = globalThis;
525
- if (g[guardKey]) return;
526
- g[guardKey] = true;
527
- nextly.infra.logger.info("Form Builder plugin initialized", {
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 match = ref.match(/^\{\{(\w+)\}\}$/);
554
- if (!match) return ref;
555
- const value = data[match[1]];
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 seen = /* @__PURE__ */ new Set();
599
- for (const notification of notifications) {
600
- if (!notification.enabled || !notification.templateSlug) continue;
601
- const key = notification.id || `${notification.to}::${notification.templateSlug}`;
602
- if (seen.has(key)) continue;
603
- seen.add(key);
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
- notification.templateSlug,
617
- to,
618
- { ...submittedData, formName: form.name, submissionId: submission.id },
1332
+ email2.templateSlug,
1333
+ email2.to,
1334
+ email2.variables,
619
1335
  {
620
- providerId: notification.providerId,
621
- cc,
622
- bcc,
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.infra.logger.info?.("Form Builder: notification sent", {
1344
+ nextly.logger.info?.("Form Builder: notification sent", {
627
1345
  formSlug: form.slug,
628
- to,
629
- templateSlug: notification.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.infra.logger.error?.("Form Builder: notification failed", {
634
- notificationId: notification.id,
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 handler = getCollectionsHandler();
643
- if (!handler) {
644
- nextly.infra.logger.warn?.(
645
- "Form Builder: CollectionsHandler unavailable, skipping notifications"
646
- );
647
- return null;
648
- }
649
- const result = await handler.getEntry({
650
- collectionName: config.formOverrides.slug,
651
- entryId: formId,
652
- overrideAccess: true
653
- });
654
- const fromData = result?.data;
655
- if (fromData) return fromData;
656
- const fromDoc = result?.doc;
657
- if (fromDoc && typeof fromDoc === "object") {
658
- return fromDoc;
659
- }
660
- return null;
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.infra.logger.error?.("Form Builder: failed to fetch form", {
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
- if (typeof fieldType !== "string" || !VALID_FIELD_TYPES_SET.has(fieldType)) {
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 date2 = (config) => ({
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 generateTextSchema(field) {
1429
- let schema = z.string();
1430
- if (field.validation?.minLength !== void 0) {
1431
- schema = schema.min(
1432
- field.validation.minLength,
1433
- field.validation.errorMessage || `Minimum ${field.validation.minLength} characters required`
1434
- );
1435
- }
1436
- if (field.validation?.maxLength !== void 0) {
1437
- schema = schema.max(
1438
- field.validation.maxLength,
1439
- field.validation.errorMessage || `Maximum ${field.validation.maxLength} characters allowed`
1440
- );
1441
- }
1442
- if (field.validation?.pattern) {
1443
- schema = schema.regex(
1444
- new RegExp(field.validation.pattern),
1445
- field.validation.errorMessage || "Invalid format"
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 applyRequired(schema, field.required, field.validation?.errorMessage);
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
- let schema = z.string().email(field.validation?.errorMessage || "Invalid email address");
1452
- if (field.validation?.pattern) {
1453
- schema = schema.regex(
1454
- new RegExp(field.validation.pattern),
1455
- field.validation.errorMessage || "Invalid email format"
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
- required_error: field.validation?.errorMessage || "This field is required",
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
- if (field.validation?.min !== void 0) {
1466
- schema = schema.min(
1467
- field.validation.min,
1468
- field.validation.errorMessage || `Minimum value is ${field.validation.min}`
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
- let schema = z.string();
1484
- if (field.validation?.pattern) {
1485
- schema = schema.regex(
1486
- new RegExp(field.validation.pattern),
1487
- field.validation.errorMessage || "Invalid phone number format"
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
- let schema = z.string().url(field.validation?.errorMessage || "Invalid URL");
1499
- if (field.validation?.pattern) {
1500
- schema = schema.regex(
1501
- new RegExp(field.validation.pattern),
1502
- field.validation.errorMessage || "Invalid URL format"
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
- let schema = z.string();
1509
- if (field.validation?.minLength !== void 0) {
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.validation?.errorMessage || "Please select at least one option"
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
- errorMap: () => ({
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.validation?.errorMessage || "This field is required"
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
- errorMap: () => ({
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.validation?.errorMessage || "Please upload at least one file"
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.validation?.errorMessage);
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 date3 = new Date(val);
1586
- return !isNaN(date3.getTime());
2017
+ const date4 = new Date(val);
2018
+ return !isNaN(date4.getTime());
1587
2019
  },
1588
- { message: field.validation?.errorMessage || "Invalid date" }
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 date3 = new Date(val);
1595
- if (field.min && date3 < new Date(field.min)) return false;
1596
- if (field.max && date3 > new Date(field.max)) return false;
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.validation?.errorMessage || `Date must be between ${field.min || "any"} and ${field.max || "any"}`
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.validation?.errorMessage || "This field is required" }
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.validation?.errorMessage || "Invalid time format (HH:mm)"
2047
+ customMessage(field) || "Invalid time format (HH:mm)"
1616
2048
  );
1617
- return applyRequired(schema, field.required, field.validation?.errorMessage);
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.validation?.errorMessage);
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.errors) {
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/utils/export-formats.ts
1697
- var UTF8_BOM = "\uFEFF";
1698
- function safeString(value) {
1699
- if (typeof value === "object" && value !== null) {
1700
- return JSON.stringify(value);
1701
- }
1702
- return String(value);
1703
- }
1704
- function formatExportValue(value, field) {
1705
- if (value === null || value === void 0) {
1706
- return "";
1707
- }
1708
- if (field) {
1709
- switch (field.type) {
1710
- case "checkbox":
1711
- return value ? "Yes" : "No";
1712
- case "select":
1713
- if (Array.isArray(value)) {
1714
- return value.join(", ");
1715
- }
1716
- return safeString(value);
1717
- case "file":
1718
- if (Array.isArray(value)) {
1719
- return value.map((v) => safeString(v)).join(", ");
1720
- }
1721
- return safeString(value);
1722
- case "date":
1723
- if (value instanceof Date) {
1724
- return value.toISOString().split("T")[0];
1725
- }
1726
- if (typeof value === "string") {
1727
- const parsed = new Date(value);
1728
- if (!isNaN(parsed.getTime())) {
1729
- return parsed.toISOString().split("T")[0];
1730
- }
1731
- }
1732
- return safeString(value);
1733
- case "time":
1734
- return safeString(value);
1735
- case "hidden":
1736
- return safeString(value);
1737
- default:
1738
- break;
1739
- }
1740
- }
1741
- if (Array.isArray(value)) {
1742
- return value.join(", ");
1743
- }
1744
- if (typeof value === "boolean") {
1745
- return value ? "Yes" : "No";
1746
- }
1747
- if (value instanceof Date) {
1748
- return value.toISOString();
1749
- }
1750
- return safeString(value);
1751
- }
1752
- function formatDate(date3, format = "iso") {
1753
- if (!date3) return "";
1754
- const d = typeof date3 === "string" ? new Date(date3) : date3;
1755
- if (isNaN(d.getTime())) {
1756
- return "";
1757
- }
1758
- return format === "iso" ? d.toISOString() : d.toLocaleString();
1759
- }
1760
- function escapeCSVValue(value, delimiter = ",") {
1761
- const needsQuoting = value.includes(delimiter) || value.includes('"') || value.includes("\n") || value.includes("\r");
1762
- if (!needsQuoting) {
1763
- return value;
1764
- }
1765
- const escaped = value.replace(/"/g, '""');
1766
- return `"${escaped}"`;
1767
- }
1768
- function exportToCSV(submissions, form, options = {}) {
1769
- const {
1770
- includeMetadata = true,
1771
- delimiter = ",",
1772
- includeBOM = true,
1773
- dateFormat = "iso"
1774
- } = options;
1775
- const exportableFields = form.fields;
1776
- const headers = [];
1777
- for (const field of exportableFields) {
1778
- headers.push(field.label);
1779
- }
1780
- if (includeMetadata) {
1781
- headers.push("ID", "Status", "Submitted At", "IP Address", "User Agent");
1782
- }
1783
- const rows = [];
1784
- for (const submission of submissions) {
1785
- const row = [];
1786
- const data = submission.data;
1787
- for (const field of exportableFields) {
1788
- const value = data[field.name];
1789
- const formatted = formatExportValue(value, field);
1790
- row.push(escapeCSVValue(formatted, delimiter));
1791
- }
1792
- if (includeMetadata) {
1793
- row.push(escapeCSVValue(submission.id, delimiter));
1794
- row.push(escapeCSVValue(submission.status, delimiter));
1795
- row.push(
1796
- escapeCSVValue(
1797
- formatDate(submission.submittedAt, dateFormat),
1798
- delimiter
1799
- )
1800
- );
1801
- row.push(escapeCSVValue(submission.ipAddress || "", delimiter));
1802
- row.push(escapeCSVValue(submission.userAgent || "", delimiter));
1803
- }
1804
- rows.push(row);
1805
- }
1806
- const escapedHeaders = headers.map((h) => escapeCSVValue(h, delimiter));
1807
- const lines = [
1808
- escapedHeaders.join(delimiter),
1809
- ...rows.map((row) => row.join(delimiter))
1810
- ];
1811
- const csvContent = lines.join("\r\n");
1812
- return includeBOM ? UTF8_BOM + csvContent : csvContent;
1813
- }
1814
- function exportToJSON(submissions, form, options = {}) {
1815
- const {
1816
- includeMetadata = true,
1817
- includeFormDefinition = true,
1818
- indent = 2
1819
- } = options;
1820
- const exportableFields = form.fields;
1821
- const formDefinition = includeFormDefinition ? {
1822
- id: form.id,
1823
- name: form.name,
1824
- slug: form.slug,
1825
- fields: exportableFields.map((f) => ({
1826
- name: f.name,
1827
- label: f.label,
1828
- type: f.type
1829
- }))
1830
- } : void 0;
1831
- const exportedSubmissions = submissions.map((submission) => {
1832
- const data = submission.data;
1833
- const fieldData = {};
1834
- for (const field of exportableFields) {
1835
- const value = data[field.name];
1836
- fieldData[field.name] = value;
1837
- }
1838
- if (includeMetadata) {
1839
- return {
1840
- ...fieldData,
1841
- _metadata: {
1842
- id: submission.id,
1843
- status: submission.status,
1844
- submittedAt: submission.submittedAt,
1845
- ipAddress: submission.ipAddress || null,
1846
- userAgent: submission.userAgent || null,
1847
- createdAt: submission.createdAt,
1848
- updatedAt: submission.updatedAt
1849
- }
1850
- };
1851
- }
1852
- return fieldData;
1853
- });
1854
- const exportData = {
1855
- exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
1856
- totalSubmissions: submissions.length,
1857
- submissions: exportedSubmissions
1858
- };
1859
- if (formDefinition) {
1860
- exportData.form = formDefinition;
1861
- }
1862
- return JSON.stringify(exportData, null, indent);
1863
- }
1864
- function downloadFile(content, filename, mimeType) {
1865
- if (typeof window === "undefined" || typeof document === "undefined") {
1866
- throw new Error("downloadFile is only available in browser environments");
1867
- }
1868
- const blob = new Blob([content], { type: mimeType });
1869
- const url2 = URL.createObjectURL(blob);
1870
- const link = document.createElement("a");
1871
- link.href = url2;
1872
- link.download = filename;
1873
- document.body.appendChild(link);
1874
- link.click();
1875
- document.body.removeChild(link);
1876
- URL.revokeObjectURL(url2);
1877
- }
1878
- function generateExportFilename(formSlug, format) {
1879
- const date3 = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
1880
- return `${formSlug}-submissions-${date3}.${format}`;
1881
- }
1882
- function exportAndDownload(submissions, form, format, options) {
1883
- const filename = generateExportFilename(form.slug, format);
1884
- if (format === "csv") {
1885
- const content = exportToCSV(submissions, form, options);
1886
- downloadFile(content, filename, "text/csv;charset=utf-8");
1887
- } else {
1888
- const content = exportToJSON(submissions, form, options);
1889
- downloadFile(content, filename, "application/json");
1890
- }
1891
- }
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.infra;
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
- if (form.status !== "published") {
2023
- logger.info?.("Form submission rejected - form not published", {
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: "This form is not currently accepting submissions"
2266
+ error: availability.kind === "closed" ? availability.message : NO_SUCH_FORM
2030
2267
  };
2031
2268
  }
2032
- const transformedData = transformFormData(data, form.fields);
2033
- const schema = generateZodSchema(form.fields);
2034
- const validationResult = schema.safeParse(transformedData);
2035
- if (!validationResult.success) {
2036
- const validationErrors = getValidationErrors(validationResult);
2037
- logger.debug?.("Form validation failed", {
2038
- formSlug,
2039
- errors: validationErrors
2040
- });
2041
- return {
2042
- success: false,
2043
- error: "Validation failed",
2044
- validationErrors
2045
- };
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
- sanitizeSubmissionData(validationResult.data, form.fields);
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: transformedData,
2304
+ data: undeclaredData,
2050
2305
  ipAddress: metadata?.ipAddress,
2051
2306
  formSlug,
2052
2307
  config: {
2053
- honeypot: pluginConfig.spamProtection.honeypot,
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: pluginConfig.spamProtection.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 detected and rejected", {
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
- return {
2065
- success: true
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: validationResult.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
- // Empty context - submission creation is public
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
- const redirect = determineRedirectUrl(form);
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
- submission,
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
- pagination: { limit: 100 }
2110
- // Fetch enough to find the form
2111
- },
2112
- {}
2113
- // Empty context for public access
2114
- );
2115
- if (!result.data || result.data.length === 0) {
2116
- return null;
2117
- }
2118
- const form = result.data.find(
2119
- (entry) => entry.slug === slug
2414
+ { where: { slug: { equals: slug } }, pagination: { limit: 1 } },
2415
+ { as: "system" }
2120
2416
  );
2121
- return form ? form : null;
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 determineRedirectUrl(form) {
2127
- if (!form.settings) {
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
- if (form.settings.confirmationType !== "redirect") {
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
- if (form.settings.redirectUrl) {
2134
- return form.settings.redirectUrl;
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
- if (form.settings.redirectRelation) {
2137
- const { relationTo, value } = form.settings.redirectRelation;
2138
- return `relationship://${relationTo}/${value}`;
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
- return void 0;
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 result = await collections.listEntries(
2195
- pluginConfig.formSubmissionOverrides.slug,
2196
- {
2197
- pagination: { limit: 1e3 }
2198
- // Reasonable limit for stats
2199
- },
2200
- {}
2201
- );
2202
- if (!result.data) {
2203
- return { total: 0, new: 0, read: 0, archived: 0 };
2204
- }
2205
- const submissions = result.data.filter(
2206
- (entry) => entry.form === form.id
2207
- );
2208
- const stats = {
2209
- total: submissions.length,
2210
- new: 0,
2211
- read: 0,
2212
- archived: 0
2213
- };
2214
- for (const sub of submissions) {
2215
- const status = sub.status;
2216
- if (status === "new") stats.new++;
2217
- else if (status === "read") stats.read++;
2218
- else if (status === "archived") stats.archived++;
2219
- }
2220
- return stats;
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
- date2 as date,
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,