@nextlyhq/plugin-form-builder 0.0.2-alpha.60 → 0.0.2-alpha.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2,9 +2,11 @@ import {
2
2
  BUILT_IN_FORM_FIELD_TYPES,
3
3
  DEFAULT_FORM_SETTINGS,
4
4
  DEFAULT_NOTIFICATION_SETTINGS,
5
+ ENFORCED_VALIDATION_RULES,
5
6
  applyFieldDefaults,
6
7
  applyFormDefaults,
7
8
  createFormConfig,
9
+ documentReachability,
8
10
  downloadFile,
9
11
  evaluateConditions,
10
12
  evaluateSingleCondition,
@@ -24,13 +26,19 @@ import {
24
26
  isTextFormField,
25
27
  isValidComparisonOperator,
26
28
  normalizeFormSettings,
29
+ parseFieldRef,
30
+ parseRedirectReference,
31
+ pickedDocumentField,
27
32
  pluralize,
28
33
  toTitleCase
29
- } from "./chunk-XCICDELK.js";
34
+ } from "./chunk-W6NLZFHH.js";
30
35
 
31
36
  // src/plugin.ts
32
37
  import { createRequire } from "module";
33
- import { definePlugin } from "@nextlyhq/plugin-sdk";
38
+ import {
39
+ definePlugin,
40
+ NextlyError as NextlyError2
41
+ } from "@nextlyhq/plugin-sdk";
34
42
 
35
43
  // src/collections/forms.ts
36
44
  import { NextlyError } from "@nextlyhq/plugin-sdk";
@@ -39,18 +47,284 @@ import {
39
47
  textarea,
40
48
  select,
41
49
  checkbox,
50
+ date,
42
51
  group,
43
52
  json,
44
53
  relationship
45
54
  } from "nextly";
55
+
56
+ // src/utils/redirect-target.ts
57
+ import { previewUrlFromTemplate } from "nextly/config";
58
+ var DEFAULT_REDIRECT_PATTERN = "/{slug}";
59
+ function normalizeRedirectRelationships(option2) {
60
+ if (!option2) return {};
61
+ const entries = Array.isArray(option2) ? option2.map((collection) => [collection, DEFAULT_REDIRECT_PATTERN]) : Object.entries(option2);
62
+ return Object.fromEntries(
63
+ entries.filter(
64
+ ([, pattern]) => typeof pattern !== "string" || pattern.trim()
65
+ )
66
+ );
67
+ }
68
+ function applyRedirectPattern(pattern, document) {
69
+ if (typeof pattern === "function") {
70
+ const produced = pattern(document);
71
+ return produced ? produced : void 0;
72
+ }
73
+ return previewUrlFromTemplate(pattern)(document) ?? void 0;
74
+ }
75
+
76
+ // src/collections/access-defaults.ts
77
+ var authenticated = ({ user }) => !!user;
78
+ var administrative = ({ roles }) => roles.includes("admin") || roles.includes("super-admin");
79
+ function accessWithDefaults(overrides, defaults = {}) {
80
+ return {
81
+ read: overrides?.read ?? defaults.read ?? authenticated,
82
+ create: overrides?.create ?? defaults.create ?? authenticated,
83
+ update: overrides?.update ?? authenticated,
84
+ // Deleting is administrative in both collections; the DB permission layer
85
+ // remains the backstop underneath it.
86
+ delete: overrides?.delete ?? administrative
87
+ };
88
+ }
89
+
90
+ // src/collections/forms.ts
91
+ function withHostHooks(pluginHooks, hostHooks, trailing = {}) {
92
+ const host = { ...hostHooks };
93
+ const asList = (value) => Array.isArray(value) ? value : value === void 0 ? [] : [value];
94
+ const merged = { ...host };
95
+ for (const [phase, handlers] of Object.entries(
96
+ pluginHooks
97
+ )) {
98
+ merged[phase] = [...asList(handlers), ...asList(host[phase])];
99
+ }
100
+ for (const [phase, handlers] of Object.entries(trailing)) {
101
+ merged[phase] = [...asList(merged[phase]), ...asList(handlers)];
102
+ }
103
+ return merged;
104
+ }
105
+ function documentRedirectSettings(settings) {
106
+ if (typeof settings !== "object" || settings === null) return null;
107
+ const record = settings;
108
+ const field = pickedDocumentField(record);
109
+ return field ? { settings: record, field } : null;
110
+ }
111
+ function pageRedirectSettings(data, operation) {
112
+ if (!data) return null;
113
+ if (operation !== "create" && data.settings === void 0) return null;
114
+ return documentRedirectSettings(data.settings);
115
+ }
116
+ function formAcceptsSubmissions(data, originalData) {
117
+ return (data?.status ?? originalData?.status) === "published";
118
+ }
119
+ function storedSettings(value) {
120
+ if (typeof value === "string") {
121
+ try {
122
+ return documentRedirectSettings(JSON.parse(value));
123
+ } catch {
124
+ return null;
125
+ }
126
+ }
127
+ return documentRedirectSettings(value);
128
+ }
129
+ function storedRedirectReference(originalData, patterns) {
130
+ const picked = storedSettings(originalData?.settings);
131
+ if (!picked) return null;
132
+ const collections = Object.keys(patterns);
133
+ const reference = parseRedirectReference(
134
+ picked.settings[picked.field],
135
+ collections
136
+ );
137
+ if (!reference) return null;
138
+ return {
139
+ reference,
140
+ field: picked.field,
141
+ configured: collections.includes(reference.collection)
142
+ };
143
+ }
144
+ function normalizeStoredReference(settings, field, reference) {
145
+ const stored = settings[field];
146
+ if (typeof stored === "string") {
147
+ if (stored !== reference.id) settings[field] = reference.id;
148
+ return;
149
+ }
150
+ if (typeof stored !== "object" || stored === null || Array.isArray(stored)) {
151
+ return;
152
+ }
153
+ const record = stored;
154
+ if (record.relationTo !== reference.collection) {
155
+ settings[field] = { ...record, relationTo: reference.collection };
156
+ }
157
+ }
158
+ function namedRedirectReference(data, operation, patterns) {
159
+ const picked = pageRedirectSettings(data, operation);
160
+ if (!picked) return null;
161
+ const { settings, field } = picked;
162
+ const collections = Object.keys(patterns);
163
+ const reference = parseRedirectReference(settings[field], collections);
164
+ if (reference) normalizeStoredReference(settings, field, reference);
165
+ return {
166
+ settings,
167
+ field,
168
+ reference,
169
+ configured: reference !== null && collections.includes(reference.collection)
170
+ };
171
+ }
172
+ function wouldStrandVisitors(data, originalData, target, targetCollectionIsLocalized) {
173
+ return formAcceptsSubmissions(data, originalData) && documentReachability(target, targetCollectionIsLocalized) === "unreachable";
174
+ }
175
+ function publishesForm(data, originalData) {
176
+ if (data?.status !== "published") return false;
177
+ return originalData?.status !== "published";
178
+ }
179
+ function sameTarget(named, stored) {
180
+ if (!named.reference || !stored) return false;
181
+ return stored.field === named.field && stored.reference.collection === named.reference.collection && stored.reference.id === named.reference.id;
182
+ }
183
+ function judgeNamedReference(named, stored) {
184
+ if (sameTarget(named, stored) && named.reference) {
185
+ return {
186
+ reference: named.reference,
187
+ field: named.field,
188
+ chosen: false,
189
+ configured: named.configured
190
+ };
191
+ }
192
+ if (!named.reference || !named.configured) {
193
+ throw redirectRefusal(
194
+ "Choose a page to redirect to, or pick a different confirmation.",
195
+ named.field
196
+ );
197
+ }
198
+ return {
199
+ reference: named.reference,
200
+ field: named.field,
201
+ chosen: true,
202
+ configured: true
203
+ };
204
+ }
205
+ function redirectReferenceForWrite(context, patterns) {
206
+ const stored = storedRedirectReference(
207
+ context.originalData,
208
+ patterns
209
+ );
210
+ const named = namedRedirectReference(
211
+ context.data,
212
+ context.operation,
213
+ patterns
214
+ );
215
+ if (named) return judgeNamedReference(named, stored);
216
+ const data = context.data;
217
+ if (data?.settings !== void 0) return null;
218
+ return stored ? { ...stored, chosen: false } : null;
219
+ }
220
+ function assertPatternBuildsUrl(patterns, reference, field, target) {
221
+ let url2;
222
+ try {
223
+ url2 = applyRedirectPattern(patterns[reference.collection], target);
224
+ } catch {
225
+ return;
226
+ }
227
+ if (url2) return;
228
+ throw redirectRefusal(
229
+ `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.`,
230
+ field
231
+ );
232
+ }
233
+ async function assertRedirectTargetUsable(context, patterns, localization) {
234
+ const write = redirectReferenceForWrite(context, patterns);
235
+ if (!write) return;
236
+ const { reference, field, chosen, configured } = write;
237
+ if (!configured) {
238
+ if (publishesForm(
239
+ context.data,
240
+ context.originalData
241
+ )) {
242
+ throw redirectRefusal(
243
+ `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.`,
244
+ field
245
+ );
246
+ }
247
+ return;
248
+ }
249
+ const nextly = context.req?.nextly;
250
+ if (!nextly) return;
251
+ if (context.executor) return;
252
+ const found = await readRedirectTarget(nextly, reference);
253
+ if (found.status === "unreadable") return;
254
+ if (found.status === "missing") {
255
+ if (!chosen && !publishesForm(
256
+ context.data,
257
+ context.originalData
258
+ )) {
259
+ return;
260
+ }
261
+ throw redirectRefusal(
262
+ `That page no longer exists in "${reference.collection}". Pick another.`,
263
+ field
264
+ );
265
+ }
266
+ const judged = chosen || publishesForm(
267
+ context.data,
268
+ context.originalData
269
+ );
270
+ if (judged && wouldStrandVisitors(
271
+ context.data,
272
+ context.originalData,
273
+ found.document,
274
+ localization[reference.collection]
275
+ )) {
276
+ throw redirectRefusal(
277
+ `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.`,
278
+ field
279
+ );
280
+ }
281
+ if (chosen)
282
+ assertPatternBuildsUrl(patterns, reference, field, found.document);
283
+ }
284
+ async function readRedirectTarget(nextly, reference) {
285
+ try {
286
+ const row = await nextly.findByID({
287
+ collection: reference.collection,
288
+ id: reference.id
289
+ });
290
+ return row ? { status: "ok", document: row } : { status: "missing" };
291
+ } catch (error) {
292
+ return isMissingTarget(error) ? { status: "missing" } : { status: "unreadable" };
293
+ }
294
+ }
295
+ function isMissingTarget(error) {
296
+ return typeof error === "object" && error !== null && error.code === "NOT_FOUND";
297
+ }
298
+ function stampFirstPublish(context) {
299
+ const data = context.data;
300
+ if (!data) return;
301
+ const stored = context.originalData;
302
+ delete data.wentLiveAt;
303
+ if (recordedStamp(stored) !== void 0) return;
304
+ if (data.status === "published" || stored?.status === "published") {
305
+ data.wentLiveAt = /* @__PURE__ */ new Date();
306
+ }
307
+ }
308
+ function recordedStamp(stored) {
309
+ const value = stored?.wentLiveAt;
310
+ return value === null || value === "" ? void 0 : value;
311
+ }
312
+ function redirectRefusal(message, field) {
313
+ return NextlyError.validation({
314
+ errors: [{ path: `settings.${field}`, code: "REQUIRED", message }]
315
+ });
316
+ }
46
317
  function formsCollection(pluginConfig) {
47
318
  const {
48
319
  slug,
49
320
  labels,
50
321
  fields: additionalFields,
51
322
  access: accessOverrides,
323
+ hooks: hookOverrides,
324
+ admin: adminOverrides,
52
325
  ...overrides
53
326
  } = pluginConfig.formOverrides;
327
+ const redirectCollections = Object.keys(pluginConfig.redirectRelationships);
54
328
  const settingsFields = [
55
329
  text({
56
330
  name: "submitButtonText",
@@ -67,7 +341,7 @@ function formsCollection(pluginConfig) {
67
341
  options: [
68
342
  { label: "Show Message", value: "message" },
69
343
  { label: "Redirect to URL", value: "redirect" },
70
- ...pluginConfig.redirectRelationships.length > 0 ? [{ label: "Redirect to Page", value: "relationship" }] : []
344
+ ...redirectCollections.length > 0 ? [{ label: "Redirect to Page", value: "relationship" }] : []
71
345
  ],
72
346
  admin: {
73
347
  description: "What happens after a successful form submission"
@@ -97,12 +371,12 @@ function formsCollection(pluginConfig) {
97
371
  }
98
372
  })
99
373
  ];
100
- if (pluginConfig.redirectRelationships.length > 0) {
374
+ if (redirectCollections.length > 0) {
101
375
  settingsFields.push(
102
376
  relationship({
103
377
  name: "redirectPage",
104
378
  label: "Redirect Page",
105
- relationTo: pluginConfig.redirectRelationships,
379
+ relationTo: redirectCollections,
106
380
  admin: {
107
381
  description: "Select a page to redirect to after submission",
108
382
  condition: {
@@ -224,6 +498,19 @@ function formsCollection(pluginConfig) {
224
498
  description: "Only published forms can receive submissions. Closed forms display a message instead."
225
499
  }
226
500
  }),
501
+ // NOT `firstPublishedAt`, which is the obvious name and the one to reach
502
+ // for next. The framework owns that name on collections it manages the
503
+ // publish lifecycle for, and a write naming it from a plugin collection is
504
+ // stripped on the way to the insert — silently, leaving the column null and
505
+ // every closed form reading as one that was never public.
506
+ date({
507
+ name: "wentLiveAt",
508
+ label: "First Published",
509
+ admin: {
510
+ readOnly: true,
511
+ description: "When this form first went live. Written once and never changed."
512
+ }
513
+ }),
227
514
  textarea({
228
515
  name: "closedMessage",
229
516
  label: "Closed Form Message",
@@ -265,49 +552,75 @@ function formsCollection(pluginConfig) {
265
552
  }
266
553
  }
267
554
  },
268
- ...overrides.admin
555
+ ...adminOverrides
269
556
  },
270
557
  // Access control with sensible defaults
271
- access: {
272
- // Anyone can read forms (needed for frontend rendering)
273
- read: accessOverrides?.read ?? true,
274
- // Only authenticated users can create/update forms
275
- create: accessOverrides?.create ?? (({ user }) => !!user),
276
- update: accessOverrides?.update ?? (({ user }) => !!user),
277
- // Only admins can delete forms (falls back to DB permissions)
278
- delete: accessOverrides?.delete ?? (({ roles }) => roles.includes("admin") || roles.includes("super-admin"))
279
- },
280
- hooks: {
281
- // Auto-generate slug from name if not provided
282
- beforeValidate: [
283
- (context) => {
284
- const { data, operation } = context;
285
- if (operation === "create" && data && !data.slug && data.name) {
286
- data.slug = String(data.name).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
558
+ // `read: true` — a form is public because a site renders it. The rest are
559
+ // the shared defaults.
560
+ access: accessWithDefaults(accessOverrides, { read: true }),
561
+ hooks: withHostHooks(
562
+ {
563
+ // Auto-generate slug from name if not provided
564
+ beforeValidate: [
565
+ (context) => {
566
+ const { data, operation } = context;
567
+ if (operation === "create" && data && !data.slug && data.name) {
568
+ data.slug = String(data.name).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
569
+ }
570
+ const setsFields = operation === "create" || data?.fields !== void 0;
571
+ if (data && setsFields && (data.fields === void 0 || Array.isArray(data.fields) && data.fields.length === 0)) {
572
+ throw NextlyError.validation({
573
+ errors: [
574
+ {
575
+ path: "fields",
576
+ code: "REQUIRED",
577
+ message: "Form must have at least one field."
578
+ }
579
+ ]
580
+ });
581
+ }
582
+ namedRedirectReference(
583
+ data,
584
+ operation,
585
+ pluginConfig.redirectRelationships
586
+ );
587
+ return data;
287
588
  }
288
- const setsFields = operation === "create" || data?.fields !== void 0;
289
- if (data && setsFields && (data.fields === void 0 || Array.isArray(data.fields) && data.fields.length === 0)) {
290
- throw NextlyError.validation({
291
- errors: [
292
- {
293
- path: "fields",
294
- code: "REQUIRED",
295
- message: "Form must have at least one field."
296
- }
297
- ]
298
- });
589
+ ]
590
+ },
591
+ hookOverrides,
592
+ {
593
+ // The last COLLECTION-level phase, after every host handler in it. A
594
+ // host `beforeValidate` may rewrite the payload once the earlier call
595
+ // has passed, so the check has to run again here.
596
+ //
597
+ // It is not the last mutating point in the write, and saying so would
598
+ // be false: `collection-mutation-service` runs FIELD-level
599
+ // `beforeChange` hooks after this one, and a host may contribute a
600
+ // field through `formOverrides.fields`. Nothing collection-level can
601
+ // sit after that — the next steps are hashing and the insert. What
602
+ // bounds the consequence is the submit path, which resolves an
603
+ // unusable target to NO redirect and logs it, never to a wrong one.
604
+ beforeChange: [
605
+ async (context) => {
606
+ await assertRedirectTargetUsable(
607
+ context,
608
+ pluginConfig.redirectRelationships,
609
+ pluginConfig.redirectTargetLocalization
610
+ );
611
+ stampFirstPublish(context);
612
+ return context.data;
299
613
  }
300
- return data;
301
- }
302
- ]
303
- },
614
+ ]
615
+ }
616
+ ),
304
617
  // Spread any additional overrides (excluding already used properties)
305
618
  ...overrides
306
619
  };
307
620
  }
308
621
 
309
622
  // src/collections/submissions.ts
310
- import { text as text2, textarea as textarea2, select as select2, json as json2, date, relationship as relationship2 } from "nextly";
623
+ import { text as text2, textarea as textarea2, select as select2, json as json2, date as date2, relationship as relationship2 } from "nextly";
311
624
  function submissionsCollection(pluginConfig) {
312
625
  const {
313
626
  slug,
@@ -400,7 +713,7 @@ function submissionsCollection(pluginConfig) {
400
713
  description: "Browser/device information of the submitter"
401
714
  }
402
715
  }),
403
- date({
716
+ date2({
404
717
  name: "submittedAt",
405
718
  label: "Submitted At",
406
719
  required: true,
@@ -411,7 +724,7 @@ function submissionsCollection(pluginConfig) {
411
724
  }),
412
725
  // Honesty stamps for admin edits of submitted data: a corrected
413
726
  // submission must never be indistinguishable from what the visitor sent.
414
- date({
727
+ date2({
415
728
  name: "editedAt",
416
729
  label: "Edited At",
417
730
  admin: {
@@ -475,16 +788,9 @@ function submissionsCollection(pluginConfig) {
475
788
  },
476
789
  // Access control with sensible defaults
477
790
  // Public can create (submit forms), but only authenticated users can read/update/delete
478
- access: {
479
- // Anyone can submit forms (public access)
480
- create: accessOverrides?.create ?? true,
481
- // Only authenticated users can view submissions
482
- read: accessOverrides?.read ?? (({ user }) => !!user),
483
- // Only authenticated users can update submissions (e.g., change status, add notes)
484
- update: accessOverrides?.update ?? (({ user }) => !!user),
485
- // Only admins can delete submissions (falls back to DB permissions)
486
- delete: accessOverrides?.delete ?? (({ roles }) => roles.includes("admin") || roles.includes("super-admin"))
487
- },
791
+ // `create: true` — a visitor submits without an account. Reading, updating
792
+ // and deleting are the shared defaults.
793
+ access: accessWithDefaults(accessOverrides, { create: true }),
488
794
  hooks: {
489
795
  // Auto-set submittedAt timestamp on creation. (The edit stamp for
490
796
  // updates is registered in plugin.ts init — direct registry hooks are
@@ -521,140 +827,527 @@ function asFormDocument(row) {
521
827
  return row;
522
828
  }
523
829
 
524
- // src/plugin.ts
525
- var { version: PLUGIN_VERSION } = createRequire(import.meta.url)(
526
- "../package.json"
527
- );
528
- function resolveConfig(options) {
529
- const formOverrides = options.formOverrides || {};
530
- const submissionOverrides = options.formSubmissionOverrides || {};
531
- return {
532
- formOverrides: {
533
- ...formOverrides,
534
- slug: formOverrides.slug || "forms",
535
- labels: {
536
- singular: formOverrides.labels?.singular || "Form",
537
- plural: formOverrides.labels?.plural || "Forms"
538
- }
539
- },
540
- formSubmissionOverrides: {
541
- ...submissionOverrides,
542
- slug: submissionOverrides.slug || "form-submissions",
543
- labels: {
544
- singular: submissionOverrides.labels?.singular || "Submission",
545
- plural: submissionOverrides.labels?.plural || "Submissions"
546
- }
547
- },
548
- fields: {
549
- text: options.fields?.text ?? true,
550
- email: options.fields?.email ?? true,
551
- number: options.fields?.number ?? true,
552
- phone: options.fields?.phone ?? true,
553
- url: options.fields?.url ?? true,
554
- textarea: options.fields?.textarea ?? true,
555
- select: options.fields?.select ?? true,
556
- checkbox: options.fields?.checkbox ?? true,
557
- radio: options.fields?.radio ?? true,
558
- file: options.fields?.file ?? true,
559
- date: options.fields?.date ?? true,
560
- time: options.fields?.time ?? true,
561
- hidden: options.fields?.hidden ?? true
562
- },
563
- redirectRelationships: options.redirectRelationships || [],
564
- beforeEmail: options.beforeEmail,
565
- notifications: {
566
- defaultFrom: options.notifications?.defaultFrom,
567
- defaultToEmail: options.notifications?.defaultToEmail,
568
- enabled: options.notifications?.enabled ?? true
569
- },
570
- spamProtection: {
571
- honeypot: options.spamProtection?.honeypot ?? true,
572
- recaptcha: options.spamProtection?.recaptcha ?? { enabled: false },
573
- rateLimit: {
574
- maxSubmissions: options.spamProtection?.rateLimit?.maxSubmissions ?? 10,
575
- windowMs: options.spamProtection?.rateLimit?.windowMs ?? 6e4
576
- }
577
- },
578
- uploads: {
579
- maxFileSize: options.uploads?.maxFileSize ?? 10485760,
580
- // 10 MB
581
- allowedMimeTypes: options.uploads?.allowedMimeTypes ?? [
582
- "image/*",
583
- "application/pdf",
584
- "text/*"
585
- ],
586
- uploadCollection: options.uploads?.uploadCollection ?? "media"
587
- },
588
- features: {
589
- builder: options.features?.builder ?? true,
590
- conditionalLogic: options.features?.conditionalLogic ?? true,
591
- fileUploads: options.features?.fileUploads ?? true
830
+ // src/handlers/prepare-submission.ts
831
+ import { stripHtmlTags } from "nextly";
832
+
833
+ // src/utils/generate-schema.ts
834
+ import { z } from "zod";
835
+ function generateZodSchema(fields) {
836
+ const schemaShape = {};
837
+ for (const field of fields) {
838
+ if (!isKnownFormField(field)) {
839
+ schemaShape[field.name] = field.required ? z.unknown().refine(
840
+ (value) => value !== void 0 && value !== null && value !== "",
841
+ { message: `${field.label || field.name} is required` }
842
+ ) : z.unknown();
843
+ continue;
592
844
  }
593
- };
845
+ if (field.type === "hidden" && !field.required) {
846
+ schemaShape[field.name] = z.string().optional();
847
+ continue;
848
+ }
849
+ const fieldSchema = generateFieldSchema(field);
850
+ if (fieldSchema) {
851
+ schemaShape[field.name] = fieldSchema;
852
+ }
853
+ }
854
+ return z.object(schemaShape);
594
855
  }
595
- function formBuilder(options = {}) {
596
- const resolvedConfig = resolveConfig(options);
597
- const formsCol = formsCollection(resolvedConfig);
598
- const submissionsCol = submissionsCollection(resolvedConfig);
599
- const plugin = definePlugin({
600
- name: "@nextlyhq/plugin-form-builder",
601
- version: PLUGIN_VERSION,
602
- nextly: ">=0.0.2-alpha.21",
603
- // Identity metadata for the admin plugins page, mirroring package.json.
604
- author: "Nextly",
605
- homepage: "https://nextlyhq.com",
606
- repository: "https://github.com/nextlyhq/nextly",
607
- license: "MIT",
608
- category: "forms",
609
- tags: ["forms", "submissions", "email-notifications"],
610
- // Declarative schema: the merged pipeline folds these into the
611
- // app schema — no manual `setup()` append needed. Just register the plugin.
612
- contributes: {
613
- collections: [formsCol, submissionsCol],
614
- // Custom permission — gates submission export beyond CRUD. The
615
- // canonical example third-party plugin authors copy.
616
- permissions: [
617
- {
618
- action: "export",
619
- resource: "submissions",
620
- label: "Export Submissions",
621
- description: "Export form submissions as CSV/JSON",
622
- // No `group`: the admin already files this under the plugin that
623
- // declared it, so naming the plugin again would nest it inside
624
- // itself. `group` is for a plugin with enough permissions to sort
625
- // its own into headings, which one is not.
626
- //
627
- // `danger` because the point of the permission is to take
628
- // submissions — names, emails, whatever a form asked for — out of
629
- // the site in a file.
630
- danger: true
631
- }
632
- ],
633
- // HTTP route — exported at
634
- // /api/plugins/@nextlyhq/plugin-form-builder/submissions/export. Secure by
635
- // default: gated by the custom `export-submissions` permission. Reads
636
- // via the secure-by-default service path as the authed user and
637
- // resolves its OWN slug through `ctx.self`. The canonical
638
- // contributes.routes example for third-party authors.
639
- routes: [
640
- {
641
- method: "GET",
642
- path: "/submissions/export",
643
- requiredPermission: "export-submissions",
644
- handler: async (req, ctx) => {
645
- const declaredSlug = resolvedConfig.formSubmissionOverrides.slug;
646
- const slug = ctx.self.collections[declaredSlug] ?? declaredSlug;
647
- const url2 = new URL(req.url);
648
- const format = url2.searchParams.get("format") ?? "json";
649
- const formId = url2.searchParams.get("form");
650
- const status = url2.searchParams.get("status");
651
- const where = {};
652
- if (formId) where.form = { equals: formId };
653
- if (status) where.status = { equals: status };
654
- else where.status = { not_equals: "spam" };
655
- const opts = { as: "user", user: ctx.user ?? void 0 };
656
- let form;
657
- if (format === "csv") {
856
+ function generateFieldSchema(field) {
857
+ switch (field.type) {
858
+ case "text":
859
+ return generateTextSchema(field);
860
+ case "email":
861
+ return generateEmailSchema(field);
862
+ case "number":
863
+ return generateNumberSchema(field);
864
+ case "phone":
865
+ return generatePhoneSchema(field);
866
+ case "url":
867
+ return generateUrlSchema(field);
868
+ case "textarea":
869
+ return generateTextareaSchema(field);
870
+ case "select":
871
+ return generateSelectSchema(field);
872
+ case "checkbox":
873
+ return generateCheckboxSchema(field);
874
+ case "radio":
875
+ return generateRadioSchema(field);
876
+ case "file":
877
+ return generateFileSchema(field);
878
+ case "date":
879
+ return generateDateSchema(field);
880
+ case "time":
881
+ return generateTimeSchema(field);
882
+ case "hidden":
883
+ return generateHiddenSchema(field);
884
+ default:
885
+ return z.unknown();
886
+ }
887
+ }
888
+ function customMessage(field) {
889
+ const enforced = ENFORCED_VALIDATION_RULES[field.type] ?? [];
890
+ if (!enforced.includes("message")) return void 0;
891
+ return field.validation?.errorMessage;
892
+ }
893
+ function enforcedValidation(field) {
894
+ const stored = field.validation ?? {};
895
+ return { ...stored, errorMessage: customMessage(field) };
896
+ }
897
+ var STRING_RULE_APPLIERS = {
898
+ minLength: (schema, validation) => validation.minLength === void 0 ? schema : schema.min(
899
+ validation.minLength,
900
+ validation.errorMessage || `Minimum ${validation.minLength} characters required`
901
+ ),
902
+ maxLength: (schema, validation) => validation.maxLength === void 0 ? schema : schema.max(
903
+ validation.maxLength,
904
+ validation.errorMessage || `Maximum ${validation.maxLength} characters allowed`
905
+ ),
906
+ pattern: (schema, validation, fallback) => validation.pattern ? schema.regex(
907
+ new RegExp(validation.pattern),
908
+ validation.errorMessage || fallback
909
+ ) : schema
910
+ };
911
+ var NUMBER_RULE_APPLIERS = {
912
+ min: (schema, validation) => validation.min === void 0 ? schema : schema.min(
913
+ validation.min,
914
+ validation.errorMessage || `Minimum value is ${validation.min}`
915
+ ),
916
+ max: (schema, validation) => validation.max === void 0 ? schema : schema.max(
917
+ validation.max,
918
+ validation.errorMessage || `Maximum value is ${validation.max}`
919
+ )
920
+ };
921
+ function overridesPattern(field) {
922
+ const enforced = ENFORCED_VALIDATION_RULES[field.type] ?? [];
923
+ if (!enforced.includes("pattern")) return false;
924
+ const validation = field.validation ?? {};
925
+ return Boolean(validation.pattern);
926
+ }
927
+ function applyStringRules(schema, field, patternMessage) {
928
+ const validation = enforcedValidation(field);
929
+ let constrained = schema;
930
+ for (const rule of ENFORCED_VALIDATION_RULES[field.type] ?? []) {
931
+ const apply = STRING_RULE_APPLIERS[rule];
932
+ if (apply) constrained = apply(constrained, validation, patternMessage);
933
+ }
934
+ return constrained;
935
+ }
936
+ function generateTextSchema(field) {
937
+ const schema = applyStringRules(z.string(), field, "Invalid format");
938
+ return applyRequired(schema, field.required, customMessage(field));
939
+ }
940
+ function generateEmailSchema(field) {
941
+ const schema = applyStringRules(
942
+ z.string().email(customMessage(field) || "Invalid email address"),
943
+ field,
944
+ "Invalid email format"
945
+ );
946
+ return applyRequired(schema, field.required, customMessage(field));
947
+ }
948
+ function generateNumberSchema(field) {
949
+ let schema = z.number({
950
+ error: (issue) => issue.input === void 0 ? customMessage(field) || "This field is required" : "Must be a number"
951
+ });
952
+ const validation = enforcedValidation(field);
953
+ for (const rule of ENFORCED_VALIDATION_RULES[field.type] ?? []) {
954
+ const apply = NUMBER_RULE_APPLIERS[rule];
955
+ if (apply) schema = apply(schema, validation);
956
+ }
957
+ if (field.required) {
958
+ return schema;
959
+ }
960
+ return schema.optional().nullable();
961
+ }
962
+ function generatePhoneSchema(field) {
963
+ const base = overridesPattern(field) ? z.string() : z.string().regex(
964
+ /^[\d\s\-+()]+$/,
965
+ customMessage(field) || "Invalid phone number"
966
+ );
967
+ const schema = applyStringRules(base, field, "Invalid phone number format");
968
+ return applyRequired(schema, field.required, customMessage(field));
969
+ }
970
+ function generateUrlSchema(field) {
971
+ const schema = applyStringRules(
972
+ z.string().url(customMessage(field) || "Invalid URL"),
973
+ field,
974
+ "Invalid URL format"
975
+ );
976
+ return applyRequired(schema, field.required, customMessage(field));
977
+ }
978
+ function generateTextareaSchema(field) {
979
+ const schema = applyStringRules(z.string(), field, "Invalid format");
980
+ return applyRequired(schema, field.required, customMessage(field));
981
+ }
982
+ function generateSelectSchema(field) {
983
+ const validValues = field.options.map((opt) => opt.value);
984
+ if (field.allowMultiple) {
985
+ const schema2 = z.array(z.enum(validValues));
986
+ if (field.required) {
987
+ return schema2.min(
988
+ 1,
989
+ customMessage(field) || "Please select at least one option"
990
+ );
991
+ }
992
+ return schema2.optional();
993
+ }
994
+ const schema = z.enum(validValues, {
995
+ error: () => customMessage(field) || "Please select a valid option"
996
+ });
997
+ if (field.required) {
998
+ return schema;
999
+ }
1000
+ return schema.optional();
1001
+ }
1002
+ function generateCheckboxSchema(field) {
1003
+ const schema = z.boolean();
1004
+ if (field.required) {
1005
+ return schema.refine((val) => val === true, {
1006
+ message: customMessage(field) || "This field is required"
1007
+ });
1008
+ }
1009
+ return schema.optional();
1010
+ }
1011
+ function generateRadioSchema(field) {
1012
+ const validValues = field.options.map((opt) => opt.value);
1013
+ const schema = z.enum(validValues, {
1014
+ error: () => customMessage(field) || "Please select an option"
1015
+ });
1016
+ if (field.required) {
1017
+ return schema;
1018
+ }
1019
+ return schema.optional();
1020
+ }
1021
+ function generateFileSchema(field) {
1022
+ if (field.multiple) {
1023
+ const schema2 = z.array(z.string());
1024
+ if (field.required) {
1025
+ return schema2.min(
1026
+ 1,
1027
+ customMessage(field) || "Please upload at least one file"
1028
+ );
1029
+ }
1030
+ return schema2.optional();
1031
+ }
1032
+ const schema = z.string();
1033
+ return applyRequired(schema, field.required, customMessage(field));
1034
+ }
1035
+ function generateDateSchema(field) {
1036
+ let schema = z.string();
1037
+ schema = schema.refine(
1038
+ (val) => {
1039
+ if (!val) return true;
1040
+ const date4 = new Date(val);
1041
+ return !isNaN(date4.getTime());
1042
+ },
1043
+ { message: customMessage(field) || "Invalid date" }
1044
+ );
1045
+ if (field.min || field.max) {
1046
+ schema = schema.refine(
1047
+ (val) => {
1048
+ if (!val) return true;
1049
+ const date4 = new Date(val);
1050
+ if (field.min && date4 < new Date(field.min)) return false;
1051
+ if (field.max && date4 > new Date(field.max)) return false;
1052
+ return true;
1053
+ },
1054
+ {
1055
+ message: customMessage(field) || `Date must be between ${field.min || "any"} and ${field.max || "any"}`
1056
+ }
1057
+ );
1058
+ }
1059
+ if (field.required) {
1060
+ return schema.refine(
1061
+ (val) => val !== void 0 && val !== null && val !== "",
1062
+ { message: customMessage(field) || "This field is required" }
1063
+ );
1064
+ }
1065
+ return schema.optional().or(z.literal(""));
1066
+ }
1067
+ function generateTimeSchema(field) {
1068
+ const schema = z.string().regex(
1069
+ /^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/,
1070
+ customMessage(field) || "Invalid time format (HH:mm)"
1071
+ );
1072
+ return applyRequired(schema, field.required, customMessage(field));
1073
+ }
1074
+ function generateHiddenSchema(field) {
1075
+ const schema = z.string();
1076
+ return applyRequired(schema, field.required, customMessage(field));
1077
+ }
1078
+ function applyRequired(schema, required, errorMessage) {
1079
+ if (required) {
1080
+ return schema.min(1, errorMessage || "This field is required");
1081
+ }
1082
+ return schema.optional().or(z.literal(""));
1083
+ }
1084
+ function transformFormData(data, fields) {
1085
+ const transformed = {};
1086
+ for (const field of fields) {
1087
+ const value = data[field.name];
1088
+ if (value === void 0) {
1089
+ continue;
1090
+ }
1091
+ transformed[field.name] = isKnownFormField(field) ? transformFieldValue(value, field) : value;
1092
+ }
1093
+ return transformed;
1094
+ }
1095
+ function transformFieldValue(value, field) {
1096
+ if (value === null || value === void 0) {
1097
+ return value;
1098
+ }
1099
+ switch (field.type) {
1100
+ case "number":
1101
+ if (typeof value === "string") {
1102
+ const num = parseFloat(value);
1103
+ return isNaN(num) ? value : num;
1104
+ }
1105
+ return value;
1106
+ case "checkbox":
1107
+ if (typeof value === "string") {
1108
+ return value === "true" || value === "1" || value === "on";
1109
+ }
1110
+ return Boolean(value);
1111
+ case "text":
1112
+ case "email":
1113
+ case "phone":
1114
+ case "url":
1115
+ case "textarea":
1116
+ case "hidden":
1117
+ if (typeof value === "string") {
1118
+ return value.trim();
1119
+ }
1120
+ return value;
1121
+ case "select":
1122
+ if (field.allowMultiple) {
1123
+ if (!Array.isArray(value)) {
1124
+ return value ? [value] : [];
1125
+ }
1126
+ }
1127
+ return value;
1128
+ default:
1129
+ return value;
1130
+ }
1131
+ }
1132
+ function validateFormData(data, fields) {
1133
+ const transformed = transformFormData(data, fields);
1134
+ const schema = generateZodSchema(fields);
1135
+ return schema.safeParse(transformed);
1136
+ }
1137
+ function getValidationErrors(result) {
1138
+ if (result.success) {
1139
+ return {};
1140
+ }
1141
+ const errors = {};
1142
+ for (const error of result.error.issues) {
1143
+ const path = error.path.join(".");
1144
+ if (!errors[path]) {
1145
+ errors[path] = error.message;
1146
+ }
1147
+ }
1148
+ return errors;
1149
+ }
1150
+
1151
+ // src/handlers/prepare-submission.ts
1152
+ var TEXT_FORM_FIELDS = /* @__PURE__ */ new Set([
1153
+ "text",
1154
+ "email",
1155
+ "textarea",
1156
+ "phone",
1157
+ "url",
1158
+ "hidden"
1159
+ ]);
1160
+ function sanitizeSubmissionData(data, fields) {
1161
+ for (const field of fields) {
1162
+ if (!TEXT_FORM_FIELDS.has(field.type)) continue;
1163
+ const value = data[field.name];
1164
+ if (typeof value !== "string") continue;
1165
+ data[field.name] = stripHtmlTags(value);
1166
+ }
1167
+ }
1168
+ var PLUGIN_SUBMISSION = Symbol.for("nextly.plugin-form-builder.submission");
1169
+ function asPluginSubmission(row, marks) {
1170
+ return Object.defineProperty(row, PLUGIN_SUBMISSION, {
1171
+ value: marks,
1172
+ enumerable: true,
1173
+ configurable: true
1174
+ });
1175
+ }
1176
+ function submissionMarks(row) {
1177
+ return row[PLUGIN_SUBMISSION];
1178
+ }
1179
+ function sameValue(left, right) {
1180
+ if (Object.is(left, right)) return true;
1181
+ if (left instanceof Date && right instanceof Date) {
1182
+ return left.getTime() === right.getTime();
1183
+ }
1184
+ if (Array.isArray(left) && Array.isArray(right)) {
1185
+ return left.length === right.length && left.every((value, index) => sameValue(value, right[index]));
1186
+ }
1187
+ return false;
1188
+ }
1189
+ function sameSubmittedPayload(granted, incoming) {
1190
+ const answers = Object.entries(granted);
1191
+ if (answers.length !== Object.keys(incoming).length) return false;
1192
+ return answers.every(([field, answer]) => sameValue(answer, incoming[field]));
1193
+ }
1194
+ function prepareSubmission({
1195
+ data,
1196
+ fields,
1197
+ validate
1198
+ }) {
1199
+ const transformed = transformFormData(data, fields);
1200
+ sanitizeSubmissionData(transformed, fields);
1201
+ if (!validate) {
1202
+ return { data: transformed };
1203
+ }
1204
+ const result = generateZodSchema(fields).safeParse(transformed);
1205
+ if (!result.success) {
1206
+ return { data: transformed, validationErrors: getValidationErrors(result) };
1207
+ }
1208
+ return { data: result.data };
1209
+ }
1210
+
1211
+ // src/plugin.ts
1212
+ var { version: PLUGIN_VERSION } = createRequire(import.meta.url)(
1213
+ "../package.json"
1214
+ );
1215
+ function resolveConfig(options) {
1216
+ const formOverrides = options.formOverrides || {};
1217
+ const submissionOverrides = options.formSubmissionOverrides || {};
1218
+ return {
1219
+ formOverrides: {
1220
+ ...formOverrides,
1221
+ slug: formOverrides.slug || "forms",
1222
+ labels: {
1223
+ singular: formOverrides.labels?.singular || "Form",
1224
+ plural: formOverrides.labels?.plural || "Forms"
1225
+ }
1226
+ },
1227
+ formSubmissionOverrides: {
1228
+ ...submissionOverrides,
1229
+ slug: submissionOverrides.slug || "form-submissions",
1230
+ labels: {
1231
+ singular: submissionOverrides.labels?.singular || "Submission",
1232
+ plural: submissionOverrides.labels?.plural || "Submissions"
1233
+ }
1234
+ },
1235
+ fields: {
1236
+ text: options.fields?.text ?? true,
1237
+ email: options.fields?.email ?? true,
1238
+ number: options.fields?.number ?? true,
1239
+ phone: options.fields?.phone ?? true,
1240
+ url: options.fields?.url ?? true,
1241
+ textarea: options.fields?.textarea ?? true,
1242
+ select: options.fields?.select ?? true,
1243
+ checkbox: options.fields?.checkbox ?? true,
1244
+ radio: options.fields?.radio ?? true,
1245
+ file: options.fields?.file ?? true,
1246
+ date: options.fields?.date ?? true,
1247
+ time: options.fields?.time ?? true,
1248
+ hidden: options.fields?.hidden ?? true
1249
+ },
1250
+ redirectRelationships: normalizeRedirectRelationships(
1251
+ options.redirectRelationships
1252
+ ),
1253
+ // Filled at `init` from each collection's own metadata. Empty until then,
1254
+ // and a slug that stays absent reads as unknown rather than as not
1255
+ // localized — see `ResolvedFormBuilderConfig`.
1256
+ redirectTargetLocalization: {},
1257
+ beforeEmail: options.beforeEmail,
1258
+ notifications: {
1259
+ defaultFrom: options.notifications?.defaultFrom,
1260
+ defaultToEmail: options.notifications?.defaultToEmail,
1261
+ enabled: options.notifications?.enabled ?? true
1262
+ },
1263
+ spamProtection: {
1264
+ honeypot: options.spamProtection?.honeypot ?? true,
1265
+ recaptcha: options.spamProtection?.recaptcha ?? { enabled: false },
1266
+ rateLimit: {
1267
+ maxSubmissions: options.spamProtection?.rateLimit?.maxSubmissions ?? 10,
1268
+ windowMs: options.spamProtection?.rateLimit?.windowMs ?? 6e4
1269
+ }
1270
+ },
1271
+ uploads: {
1272
+ maxFileSize: options.uploads?.maxFileSize ?? 10485760,
1273
+ // 10 MB
1274
+ allowedMimeTypes: options.uploads?.allowedMimeTypes ?? [
1275
+ "image/*",
1276
+ "application/pdf",
1277
+ "text/*"
1278
+ ],
1279
+ uploadCollection: options.uploads?.uploadCollection ?? "media"
1280
+ },
1281
+ features: {
1282
+ builder: options.features?.builder ?? true,
1283
+ conditionalLogic: options.features?.conditionalLogic ?? true,
1284
+ fileUploads: options.features?.fileUploads ?? true
1285
+ }
1286
+ };
1287
+ }
1288
+ function formBuilder(options = {}) {
1289
+ const resolvedConfig = resolveConfig(options);
1290
+ const formsCol = formsCollection(resolvedConfig);
1291
+ const submissionsCol = submissionsCollection(resolvedConfig);
1292
+ const plugin = definePlugin({
1293
+ name: "@nextlyhq/plugin-form-builder",
1294
+ version: PLUGIN_VERSION,
1295
+ nextly: ">=0.0.2-alpha.21",
1296
+ // Identity metadata for the admin plugins page, mirroring package.json.
1297
+ author: "Nextly",
1298
+ homepage: "https://nextlyhq.com",
1299
+ repository: "https://github.com/nextlyhq/nextly",
1300
+ license: "MIT",
1301
+ category: "forms",
1302
+ tags: ["forms", "submissions", "email-notifications"],
1303
+ // Declarative schema: the merged pipeline folds these into the
1304
+ // app schema — no manual `setup()` append needed. Just register the plugin.
1305
+ contributes: {
1306
+ collections: [formsCol, submissionsCol],
1307
+ // Custom permission — gates submission export beyond CRUD. The
1308
+ // canonical example third-party plugin authors copy.
1309
+ permissions: [
1310
+ {
1311
+ action: "export",
1312
+ resource: "submissions",
1313
+ label: "Export Submissions",
1314
+ description: "Export form submissions as CSV/JSON",
1315
+ // No `group`: the admin already files this under the plugin that
1316
+ // declared it, so naming the plugin again would nest it inside
1317
+ // itself. `group` is for a plugin with enough permissions to sort
1318
+ // its own into headings, which one is not.
1319
+ //
1320
+ // `danger` because the point of the permission is to take
1321
+ // submissions — names, emails, whatever a form asked for — out of
1322
+ // the site in a file.
1323
+ danger: true
1324
+ }
1325
+ ],
1326
+ // HTTP route — exported at
1327
+ // /api/plugins/@nextlyhq/plugin-form-builder/submissions/export. Secure by
1328
+ // default: gated by the custom `export-submissions` permission. Reads
1329
+ // via the secure-by-default service path as the authed user and
1330
+ // resolves its OWN slug through `ctx.self`. The canonical
1331
+ // contributes.routes example for third-party authors.
1332
+ routes: [
1333
+ {
1334
+ method: "GET",
1335
+ path: "/submissions/export",
1336
+ requiredPermission: "export-submissions",
1337
+ handler: async (req, ctx) => {
1338
+ const declaredSlug = resolvedConfig.formSubmissionOverrides.slug;
1339
+ const slug = ctx.self.collections[declaredSlug] ?? declaredSlug;
1340
+ const url2 = new URL(req.url);
1341
+ const format = url2.searchParams.get("format") ?? "json";
1342
+ const formId = url2.searchParams.get("form");
1343
+ const status = url2.searchParams.get("status");
1344
+ const where = {};
1345
+ if (formId) where.form = { equals: formId };
1346
+ if (status) where.status = { equals: status };
1347
+ else where.status = { not_equals: "spam" };
1348
+ const opts = { as: "user", user: ctx.user ?? void 0 };
1349
+ let form;
1350
+ if (format === "csv") {
658
1351
  if (!formId) {
659
1352
  return Response.json(
660
1353
  {
@@ -750,6 +1443,12 @@ function formBuilder(options = {}) {
750
1443
  // Runtime-resolved collection slugs (through ctx.self, so a
751
1444
  // framework .rename() is honored too) — admin components
752
1445
  // never hardcode "forms"/"form-submissions".
1446
+ // Names only. A pattern may be a function, which does not
1447
+ // survive JSON and which the browser has no use for — the
1448
+ // admin picks a document, the server builds the URL.
1449
+ redirectCollections: Object.keys(
1450
+ resolvedConfig.redirectRelationships
1451
+ ),
753
1452
  slugs: {
754
1453
  forms: ctx.self.collections[resolvedConfig.formOverrides.slug] ?? resolvedConfig.formOverrides.slug,
755
1454
  submissions: ctx.self.collections[resolvedConfig.formSubmissionOverrides.slug] ?? resolvedConfig.formSubmissionOverrides.slug
@@ -776,7 +1475,7 @@ function formBuilder(options = {}) {
776
1475
  },
777
1476
  // -- Init ----------------------------------------------------------------
778
1477
  // Registers an afterCreate hook on submissions to send email notifications.
779
- init(nextly) {
1478
+ async init(nextly) {
780
1479
  const declaredSubmissionsSlug = resolvedConfig.formSubmissionOverrides.slug;
781
1480
  const submissionSlug = nextly.self.collections[declaredSubmissionsSlug] ?? declaredSubmissionsSlug;
782
1481
  nextly.logger.info("Form Builder plugin initialized", {
@@ -784,6 +1483,30 @@ function formBuilder(options = {}) {
784
1483
  submissionsCollection: submissionSlug,
785
1484
  enabledFields: Object.entries(resolvedConfig.fields).filter(([, on]) => on).map(([name]) => name)
786
1485
  });
1486
+ for (const slug of Object.keys(
1487
+ resolvedConfig.redirectTargetLocalization
1488
+ )) {
1489
+ delete resolvedConfig.redirectTargetLocalization[slug];
1490
+ }
1491
+ await Promise.all(
1492
+ Object.keys(resolvedConfig.redirectRelationships).map(
1493
+ async (declared) => {
1494
+ const slug = nextly.self.collections[declared] ?? declared;
1495
+ try {
1496
+ const collection = await nextly.services.collections.getCollection(slug, {});
1497
+ const localized = collection?.localized;
1498
+ if (typeof localized === "boolean") {
1499
+ resolvedConfig.redirectTargetLocalization[declared] = localized;
1500
+ }
1501
+ } catch {
1502
+ nextly.logger.debug?.(
1503
+ "Redirect target localization could not be read",
1504
+ { collection: slug }
1505
+ );
1506
+ }
1507
+ }
1508
+ )
1509
+ );
787
1510
  nextly.__formBuilderConfig = resolvedConfig;
788
1511
  if (resolvedConfig.beforeEmail) {
789
1512
  nextly.filters.add(
@@ -796,6 +1519,13 @@ function formBuilder(options = {}) {
796
1519
  })
797
1520
  );
798
1521
  }
1522
+ const declaredFormsSlug = resolvedConfig.formOverrides.slug;
1523
+ const formsSlug = nextly.self.collections[declaredFormsSlug] ?? declaredFormsSlug;
1524
+ nextly.hooks.on(
1525
+ "beforeChange",
1526
+ submissionSlug,
1527
+ async (context) => prepareSubmissionForWrite(context, formsSlug, nextly)
1528
+ );
799
1529
  nextly.hooks.on(
800
1530
  "afterCreate",
801
1531
  submissionSlug,
@@ -811,8 +1541,6 @@ function formBuilder(options = {}) {
811
1541
  }
812
1542
  return ctx.data;
813
1543
  });
814
- const declaredFormsSlug = resolvedConfig.formOverrides.slug;
815
- const formsSlug = nextly.self.collections[declaredFormsSlug] ?? declaredFormsSlug;
816
1544
  nextly.hooks.on("afterRead", formsSlug, async (context) => {
817
1545
  const data = context.data;
818
1546
  const records = Array.isArray(data) ? data : data ? [data] : [];
@@ -850,9 +1578,9 @@ function getFormBuilderConfig(nextly) {
850
1578
  return nextly.__formBuilderConfig;
851
1579
  }
852
1580
  function resolveFieldRef(ref, data) {
853
- const match = ref.match(/^\{\{(\w+)\}\}$/);
854
- if (!match) return ref;
855
- const value = data[match[1]];
1581
+ const name = parseFieldRef(ref);
1582
+ if (name === null) return ref;
1583
+ const value = data[name];
856
1584
  return typeof value === "string" ? value : "";
857
1585
  }
858
1586
  function collectAttachmentInputs(fields, submittedData) {
@@ -923,858 +1651,644 @@ function buildNotificationEmails(input) {
923
1651
  const bcc = normalizeAddressList(notification.bcc);
924
1652
  const from = notification.senderEmail?.trim() || defaultFrom?.trim() || void 0;
925
1653
  const replyTo = notification.replyTo ? resolveFieldRef(notification.replyTo, submittedData).trim() || void 0 : void 0;
926
- emails.push({
927
- to,
928
- templateSlug: notification.templateSlug,
929
- variables: {
930
- ...submittedData,
931
- formName,
932
- submissionId
933
- },
934
- providerId: notification.providerId,
935
- from,
936
- replyTo,
937
- cc,
938
- bcc,
939
- notificationId: notification.id
940
- });
941
- }
942
- return { emails, skipped };
943
- }
944
- async function handleSubmissionCreated(context, config, nextly) {
945
- if (!config.notifications.enabled) return;
946
- const submission = context.data;
947
- if (!submission) return;
948
- if (submission.status === "spam") return;
949
- const rawFormId = submission.form;
950
- let formId = null;
951
- if (typeof rawFormId === "string") {
952
- formId = rawFormId;
953
- } else if (rawFormId && typeof rawFormId === "object") {
954
- const maybeId = rawFormId.id;
955
- if (typeof maybeId === "string") formId = maybeId;
956
- }
957
- if (!formId) return;
958
- const form = await fetchParentForm(config, formId, nextly);
959
- if (!form) return;
960
- const notifications = Array.isArray(form.notifications) ? form.notifications : [];
961
- if (notifications.length === 0) return;
962
- const emailService = nextly.services.email;
963
- if (!emailService) return;
964
- const submittedData = parseJsonColumn(submission.data);
965
- const formFields = Array.isArray(form.fields) ? form.fields : [];
966
- const fileAttachments = collectAttachmentInputs(formFields, submittedData);
967
- const { emails, skipped } = buildNotificationEmails({
968
- notifications,
969
- submittedData,
970
- formName: form.name,
971
- submissionId: submission.id,
972
- defaultFrom: config.notifications.defaultFrom
973
- });
974
- for (const skip of skipped) {
975
- if (skip.reason === "empty-recipient") {
976
- nextly.logger.warn?.(
977
- "Form Builder: empty recipient, skipping notification",
978
- { notificationId: skip.notificationId, formSlug: form.slug }
979
- );
980
- } else {
981
- nextly.logger.debug?.("Form Builder: notification skipped", {
982
- notificationId: skip.notificationId,
983
- formSlug: form.slug,
984
- reason: skip.reason
985
- });
986
- }
987
- }
988
- if (emails.length === 0) return;
989
- const finalEmails = await nextly.filters.apply("form-builder.beforeEmail", emails, { form, submission });
990
- for (const email2 of finalEmails) {
991
- try {
992
- await emailService.sendWithTemplate(
993
- email2.templateSlug,
994
- email2.to,
995
- email2.variables,
996
- {
997
- providerId: email2.providerId,
998
- from: email2.from,
999
- replyTo: email2.replyTo,
1000
- cc: email2.cc,
1001
- bcc: email2.bcc,
1002
- attachments: fileAttachments.length > 0 ? fileAttachments : void 0
1003
- }
1004
- );
1005
- nextly.logger.info?.("Form Builder: notification sent", {
1006
- formSlug: form.slug,
1007
- to: email2.to,
1008
- templateSlug: email2.templateSlug,
1009
- notificationId: email2.notificationId,
1010
- attachmentCount: fileAttachments.length
1011
- });
1012
- } catch (err) {
1013
- nextly.logger.error?.("Form Builder: notification failed", {
1014
- to: email2.to,
1015
- templateSlug: email2.templateSlug,
1016
- notificationId: email2.notificationId,
1017
- error: err instanceof Error ? err.message : String(err)
1018
- });
1019
- }
1020
- }
1021
- }
1022
- async function fetchParentForm(config, formId, nextly) {
1023
- try {
1024
- const form = await nextly.services.collections.findEntryById(
1025
- config.formOverrides.slug,
1026
- formId,
1027
- { as: "system" }
1028
- );
1029
- return form;
1030
- } catch (err) {
1031
- nextly.logger.error?.("Form Builder: failed to fetch form", {
1032
- formId,
1033
- error: err instanceof Error ? err.message : String(err)
1034
- });
1035
- return null;
1036
- }
1037
- }
1038
-
1039
- // src/config/validate-form.ts
1040
- import { isPluginFieldTypeOnSurface } from "nextly";
1041
- var RESERVED_FORM_SLUGS = [
1042
- // API routes
1043
- "api",
1044
- "submit",
1045
- "submissions",
1046
- "export",
1047
- // System slugs
1048
- "admin",
1049
- "new",
1050
- "create",
1051
- "edit",
1052
- "delete"
1053
- ];
1054
- var RESERVED_SLUGS_SET = new Set(RESERVED_FORM_SLUGS);
1055
- var SLUG_PATTERN = /^[a-z][a-z0-9_-]*$/;
1056
- var FIELD_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]*$/;
1057
- var VALID_FIELD_TYPES = BUILT_IN_FORM_FIELD_TYPES;
1058
- var VALID_FIELD_TYPES_SET = new Set(VALID_FIELD_TYPES);
1059
- function isReservedSlug(slug) {
1060
- return RESERVED_SLUGS_SET.has(slug.toLowerCase());
1061
- }
1062
- function validateSlug(slug, errors) {
1063
- const path = "slug";
1064
- if (!slug) {
1065
- errors.push({
1066
- path,
1067
- message: "Form slug is required",
1068
- code: "SLUG_REQUIRED"
1069
- });
1070
- return;
1071
- }
1072
- if (typeof slug !== "string") {
1073
- errors.push({
1074
- path,
1075
- message: "Form slug must be a string",
1076
- code: "SLUG_INVALID_TYPE"
1077
- });
1078
- return;
1079
- }
1080
- if (slug.length < 1) {
1081
- errors.push({
1082
- path,
1083
- message: "Form slug must be at least 1 character",
1084
- code: "SLUG_TOO_SHORT"
1085
- });
1086
- }
1087
- if (slug.length > 50) {
1088
- errors.push({
1089
- path,
1090
- message: "Form slug must be at most 50 characters",
1091
- code: "SLUG_TOO_LONG"
1092
- });
1093
- }
1094
- if (!SLUG_PATTERN.test(slug)) {
1095
- errors.push({
1096
- path,
1097
- message: "Form slug must start with a lowercase letter and contain only lowercase letters, numbers, hyphens, and underscores",
1098
- code: "SLUG_INVALID_FORMAT"
1099
- });
1100
- }
1101
- if (isReservedSlug(slug)) {
1102
- errors.push({
1103
- path,
1104
- message: `Form slug '${slug}' is reserved and cannot be used`,
1105
- code: "SLUG_RESERVED"
1106
- });
1107
- }
1108
- }
1109
- function validateFieldName(name, path, errors, seenNames) {
1110
- if (!name) {
1111
- errors.push({
1112
- path: `${path}.name`,
1113
- message: "Field name is required",
1114
- code: "FIELD_NAME_REQUIRED"
1115
- });
1116
- return;
1117
- }
1118
- if (typeof name !== "string") {
1119
- errors.push({
1120
- path: `${path}.name`,
1121
- message: "Field name must be a string",
1122
- code: "FIELD_NAME_REQUIRED"
1123
- });
1124
- return;
1125
- }
1126
- if (!FIELD_NAME_PATTERN.test(name)) {
1127
- errors.push({
1128
- path: `${path}.name`,
1129
- message: `Field name '${name}' must start with a letter and contain only letters, numbers, and underscores`,
1130
- code: "FIELD_NAME_INVALID_FORMAT"
1131
- });
1132
- }
1133
- const nameLower = name.toLowerCase();
1134
- if (seenNames.has(nameLower)) {
1135
- errors.push({
1136
- path: `${path}.name`,
1137
- message: `Duplicate field name '${name}'`,
1138
- code: "FIELD_NAME_DUPLICATE"
1654
+ emails.push({
1655
+ to,
1656
+ templateSlug: notification.templateSlug,
1657
+ variables: {
1658
+ ...submittedData,
1659
+ formName,
1660
+ submissionId
1661
+ },
1662
+ providerId: notification.providerId,
1663
+ from,
1664
+ replyTo,
1665
+ cc,
1666
+ bcc,
1667
+ notificationId: notification.id
1139
1668
  });
1140
- } else {
1141
- seenNames.add(nameLower);
1142
1669
  }
1670
+ return { emails, skipped };
1143
1671
  }
1144
- function validateFieldLabel(label, path, errors) {
1145
- if (!label || typeof label === "string" && label.trim() === "") {
1146
- errors.push({
1147
- path: `${path}.label`,
1148
- message: "Field label is required",
1149
- code: "FIELD_LABEL_REQUIRED"
1150
- });
1672
+ function parentFormId(submission) {
1673
+ const raw = submission.form;
1674
+ if (typeof raw === "string") return raw;
1675
+ if (raw && typeof raw === "object") {
1676
+ const maybeId = raw.id;
1677
+ if (typeof maybeId === "string") return maybeId;
1151
1678
  }
1679
+ return null;
1152
1680
  }
1153
- function validateOptions(field, path, errors) {
1154
- const fieldWithOptions = field;
1155
- const options = fieldWithOptions.options;
1156
- if (!options) {
1157
- errors.push({
1158
- path: `${path}.options`,
1159
- message: `${field.type} field must have an 'options' array`,
1160
- code: "OPTIONS_REQUIRED"
1161
- });
1162
- return;
1681
+ function submittedPayload(raw) {
1682
+ if (typeof raw === "string") {
1683
+ try {
1684
+ const parsed = JSON.parse(raw);
1685
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
1686
+ return parsed;
1687
+ }
1688
+ } catch {
1689
+ }
1690
+ } else if (raw && typeof raw === "object" && !Array.isArray(raw)) {
1691
+ return raw;
1163
1692
  }
1164
- if (!Array.isArray(options)) {
1165
- errors.push({
1166
- path: `${path}.options`,
1167
- message: `${field.type} field 'options' must be an array`,
1168
- code: "OPTIONS_REQUIRED"
1693
+ throw NextlyError2.validation({
1694
+ errors: [
1695
+ {
1696
+ path: "data",
1697
+ code: "INVALID",
1698
+ message: "Submission data must be an object."
1699
+ }
1700
+ ]
1701
+ });
1702
+ }
1703
+ function updateNeedsChecking(submission, stored) {
1704
+ if (submission.data !== void 0) return true;
1705
+ if (stored?.data === void 0) return false;
1706
+ if (submission.form !== void 0) return true;
1707
+ return stored.status === "spam" && submission.status !== void 0 && submission.status !== "spam";
1708
+ }
1709
+ async function prepareSubmissionForWrite(context, formsSlug, nextly) {
1710
+ const ctx = context;
1711
+ const submission = ctx.data;
1712
+ if (!submission || typeof submission !== "object") return ctx.data;
1713
+ const stored = ctx.originalData;
1714
+ const formId = parentFormId(submission) ?? (stored ? parentFormId(stored) : null);
1715
+ if (!formId) return ctx.data;
1716
+ if (ctx.operation !== "create" && !updateNeedsChecking(submission, stored)) {
1717
+ return ctx.data;
1718
+ }
1719
+ const incoming = submittedPayload(
1720
+ submission.data !== void 0 ? submission.data : stored?.data
1721
+ );
1722
+ const marks = submissionMarks(submission);
1723
+ const fields = await fieldsToCheckAgainst(marks, formsSlug, formId, nextly);
1724
+ const prepared = prepareSubmission({
1725
+ data: incoming,
1726
+ fields,
1727
+ validate: marks?.keepAsEvidence !== true
1728
+ });
1729
+ if (prepared.validationErrors) {
1730
+ throw NextlyError2.validation({
1731
+ errors: Object.entries(prepared.validationErrors).map(
1732
+ ([path, message]) => ({ path, code: "INVALID", message })
1733
+ )
1169
1734
  });
1170
- return;
1171
1735
  }
1172
- if (options.length === 0) {
1173
- errors.push({
1174
- path: `${path}.options`,
1175
- message: `${field.type} field must have at least one option`,
1176
- code: "OPTIONS_EMPTY"
1177
- });
1736
+ ctx.data = { ...submission, data: prepared.data };
1737
+ if (ctx.operation !== "create" && submission.data === void 0) {
1738
+ stampDerivedEdit(ctx.data, ctx.user, incoming, prepared.data);
1178
1739
  }
1740
+ return ctx.data;
1179
1741
  }
1180
- function validateConditionalLogic(field, path, errors) {
1181
- const { conditionalLogic } = field;
1182
- if (!conditionalLogic || !conditionalLogic.enabled) {
1183
- return;
1184
- }
1185
- if (!conditionalLogic.conditions || conditionalLogic.conditions.length === 0) {
1186
- errors.push({
1187
- path: `${path}.conditionalLogic.conditions`,
1188
- message: "Conditional logic must have at least one condition",
1189
- code: "CONDITIONS_REQUIRED"
1742
+ async function fieldsToCheckAgainst(marks, formsSlug, formId, nextly) {
1743
+ const handedOver = marks?.form?.id === formId ? marks.form : null;
1744
+ const form = handedOver ?? await fetchParentForm(formsSlug, formId, nextly);
1745
+ if (!form || !Array.isArray(form.fields)) {
1746
+ throw NextlyError2.validation({
1747
+ errors: [
1748
+ {
1749
+ path: "form",
1750
+ code: "INVALID",
1751
+ message: "The form this submission belongs to could not be read, so the submission could not be checked against it."
1752
+ }
1753
+ ]
1190
1754
  });
1191
- return;
1192
1755
  }
1193
- conditionalLogic.conditions.forEach((condition, index) => {
1194
- const conditionPath = `${path}.conditionalLogic.conditions[${index}]`;
1195
- if (!condition.field) {
1196
- errors.push({
1197
- path: `${conditionPath}.field`,
1198
- message: "Condition must specify a field name",
1199
- code: "CONDITION_FIELD_REQUIRED"
1200
- });
1201
- }
1202
- if (!condition.comparison) {
1203
- errors.push({
1204
- path: `${conditionPath}.comparison`,
1205
- message: "Condition must specify a comparison operator",
1206
- code: "CONDITION_COMPARISON_REQUIRED"
1207
- });
1208
- }
1209
- });
1756
+ return form.fields;
1210
1757
  }
1211
- function validateFieldByType(field, path, errors) {
1212
- switch (field.type) {
1213
- case "select":
1214
- case "radio":
1215
- validateOptions(field, path, errors);
1216
- break;
1217
- case "number": {
1218
- const numberField = field;
1219
- if (numberField.validation) {
1220
- const { min, max } = numberField.validation;
1221
- if (min !== void 0 && max !== void 0 && min > max) {
1222
- errors.push({
1223
- path: `${path}.validation`,
1224
- message: "Min value cannot be greater than max value",
1225
- code: "MIN_GREATER_THAN_MAX"
1226
- });
1227
- }
1228
- }
1229
- break;
1758
+ function stampDerivedEdit(row, user, before, after) {
1759
+ if (sameSubmittedPayload(before, after)) return;
1760
+ row.editedAt = /* @__PURE__ */ new Date();
1761
+ row.editedBy = user?.id ?? null;
1762
+ }
1763
+ async function handleSubmissionCreated(context, config, nextly) {
1764
+ if (!config.notifications.enabled) return;
1765
+ const submission = context.data;
1766
+ if (!submission) return;
1767
+ if (submission.status === "spam") return;
1768
+ const formId = parentFormId(submission);
1769
+ if (!formId) return;
1770
+ const declaredFormsSlug = config.formOverrides.slug;
1771
+ const form = await fetchParentForm(
1772
+ nextly.self.collections[declaredFormsSlug] ?? declaredFormsSlug,
1773
+ formId,
1774
+ nextly
1775
+ );
1776
+ if (!form) return;
1777
+ const notifications = Array.isArray(form.notifications) ? form.notifications : [];
1778
+ if (notifications.length === 0) return;
1779
+ const emailService = nextly.services.email;
1780
+ if (!emailService) return;
1781
+ const submittedData = parseJsonColumn(submission.data);
1782
+ const formFields = Array.isArray(form.fields) ? form.fields : [];
1783
+ const fileAttachments = collectAttachmentInputs(formFields, submittedData);
1784
+ const { emails, skipped } = buildNotificationEmails({
1785
+ notifications,
1786
+ submittedData,
1787
+ formName: form.name,
1788
+ submissionId: submission.id,
1789
+ defaultFrom: config.notifications.defaultFrom
1790
+ });
1791
+ for (const skip of skipped) {
1792
+ if (skip.reason === "empty-recipient") {
1793
+ nextly.logger.warn?.(
1794
+ "Form Builder: empty recipient, skipping notification",
1795
+ { notificationId: skip.notificationId, formSlug: form.slug }
1796
+ );
1797
+ } else {
1798
+ nextly.logger.debug?.("Form Builder: notification skipped", {
1799
+ notificationId: skip.notificationId,
1800
+ formSlug: form.slug,
1801
+ reason: skip.reason
1802
+ });
1230
1803
  }
1231
- case "text":
1232
- case "textarea": {
1233
- const textField = field;
1234
- if (textField.validation) {
1235
- const { minLength, maxLength } = textField.validation;
1236
- if (minLength !== void 0 && maxLength !== void 0 && minLength > maxLength) {
1237
- errors.push({
1238
- path: `${path}.validation`,
1239
- message: "Min length cannot be greater than max length",
1240
- code: "MIN_LENGTH_GREATER_THAN_MAX"
1241
- });
1804
+ }
1805
+ if (emails.length === 0) return;
1806
+ const finalEmails = await nextly.filters.apply("form-builder.beforeEmail", emails, { form, submission });
1807
+ for (const email2 of finalEmails) {
1808
+ try {
1809
+ await emailService.sendWithTemplate(
1810
+ email2.templateSlug,
1811
+ email2.to,
1812
+ email2.variables,
1813
+ {
1814
+ providerId: email2.providerId,
1815
+ from: email2.from,
1816
+ replyTo: email2.replyTo,
1817
+ cc: email2.cc,
1818
+ bcc: email2.bcc,
1819
+ attachments: fileAttachments.length > 0 ? fileAttachments : void 0
1242
1820
  }
1243
- }
1244
- break;
1245
- }
1246
- case "file": {
1247
- const fileField = field;
1248
- if (fileField.maxFileSize !== void 0 && fileField.maxFileSize <= 0) {
1249
- errors.push({
1250
- path: `${path}.maxFileSize`,
1251
- message: "File maxFileSize must be a positive number",
1252
- code: "FILE_MAX_SIZE_INVALID"
1253
- });
1254
- }
1255
- break;
1821
+ );
1822
+ nextly.logger.info?.("Form Builder: notification sent", {
1823
+ formSlug: form.slug,
1824
+ to: email2.to,
1825
+ templateSlug: email2.templateSlug,
1826
+ notificationId: email2.notificationId,
1827
+ attachmentCount: fileAttachments.length
1828
+ });
1829
+ } catch (err) {
1830
+ nextly.logger.error?.("Form Builder: notification failed", {
1831
+ to: email2.to,
1832
+ templateSlug: email2.templateSlug,
1833
+ notificationId: email2.notificationId,
1834
+ error: err instanceof Error ? err.message : String(err)
1835
+ });
1256
1836
  }
1257
1837
  }
1258
1838
  }
1259
- function validateField(field, path, errors, seenNames) {
1260
- if (!field || typeof field !== "object") {
1261
- return;
1262
- }
1263
- const f = field;
1264
- const fieldType = f.type;
1265
- if (!fieldType) {
1266
- errors.push({
1267
- path: `${path}.type`,
1268
- message: "Field type is required",
1269
- code: "FIELD_TYPE_REQUIRED"
1270
- });
1271
- return;
1272
- }
1273
- const isValidType = typeof fieldType === "string" && (VALID_FIELD_TYPES_SET.has(fieldType) || isPluginFieldTypeOnSurface(fieldType, "forms"));
1274
- if (!isValidType) {
1275
- let fieldTypeRepr;
1276
- if (typeof fieldType === "object" && fieldType !== null) {
1277
- fieldTypeRepr = JSON.stringify(fieldType);
1278
- } else {
1279
- fieldTypeRepr = String(fieldType);
1280
- }
1281
- errors.push({
1282
- path: `${path}.type`,
1283
- message: `Invalid field type '${fieldTypeRepr}'. Valid types: ${VALID_FIELD_TYPES.join(", ")}`,
1284
- code: "FIELD_TYPE_INVALID"
1839
+ async function fetchParentForm(formsSlug, formId, nextly) {
1840
+ try {
1841
+ const form = await nextly.services.collections.findEntryById(
1842
+ formsSlug,
1843
+ formId,
1844
+ { as: "system" }
1845
+ );
1846
+ return form;
1847
+ } catch (err) {
1848
+ if (!NextlyError2.isNotFound(err)) throw err;
1849
+ nextly.logger.error?.("Form Builder: form not found", {
1850
+ formId,
1851
+ error: err instanceof Error ? err.message : String(err)
1285
1852
  });
1286
- return;
1287
- }
1288
- validateFieldName(f.name, path, errors, seenNames);
1289
- if (fieldType !== "hidden") {
1290
- validateFieldLabel(f.label, path, errors);
1291
- }
1292
- validateFieldByType(field, path, errors);
1293
- if (f.conditionalLogic) {
1294
- validateConditionalLogic(field, path, errors);
1853
+ return null;
1295
1854
  }
1296
1855
  }
1297
- function validateFieldsArray(fields, basePath, errors) {
1298
- const seenNames = /* @__PURE__ */ new Set();
1299
- fields.forEach((field, index) => {
1300
- const fieldPath = `${basePath}[${index}]`;
1301
- validateField(field, fieldPath, errors, seenNames);
1302
- });
1856
+
1857
+ // src/config/validate-form.ts
1858
+ import { isPluginFieldTypeOnSurface } from "nextly";
1859
+ var RESERVED_FORM_SLUGS = [
1860
+ // API routes
1861
+ "api",
1862
+ "submit",
1863
+ "submissions",
1864
+ "export",
1865
+ // System slugs
1866
+ "admin",
1867
+ "new",
1868
+ "create",
1869
+ "edit",
1870
+ "delete"
1871
+ ];
1872
+ var RESERVED_SLUGS_SET = new Set(RESERVED_FORM_SLUGS);
1873
+ var SLUG_PATTERN = /^[a-z][a-z0-9_-]*$/;
1874
+ var FIELD_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]*$/;
1875
+ var VALID_FIELD_TYPES = BUILT_IN_FORM_FIELD_TYPES;
1876
+ var VALID_FIELD_TYPES_SET = new Set(VALID_FIELD_TYPES);
1877
+ function isReservedSlug(slug) {
1878
+ return RESERVED_SLUGS_SET.has(slug.toLowerCase());
1303
1879
  }
1304
- function validateFields(fields, errors) {
1305
- const path = "fields";
1306
- if (!fields) {
1880
+ function validateSlug(slug, errors) {
1881
+ const path = "slug";
1882
+ if (!slug) {
1307
1883
  errors.push({
1308
1884
  path,
1309
- message: "Form must have at least one field",
1310
- code: "FIELDS_REQUIRED"
1885
+ message: "Form slug is required",
1886
+ code: "SLUG_REQUIRED"
1311
1887
  });
1312
1888
  return;
1313
1889
  }
1314
- if (!Array.isArray(fields)) {
1890
+ if (typeof slug !== "string") {
1315
1891
  errors.push({
1316
1892
  path,
1317
- message: "Form fields must be an array",
1318
- code: "FIELDS_INVALID_TYPE"
1893
+ message: "Form slug must be a string",
1894
+ code: "SLUG_INVALID_TYPE"
1319
1895
  });
1320
1896
  return;
1321
1897
  }
1322
- if (fields.length === 0) {
1898
+ if (slug.length < 1) {
1323
1899
  errors.push({
1324
1900
  path,
1325
- message: "Form must have at least one field",
1326
- code: "FIELDS_EMPTY"
1901
+ message: "Form slug must be at least 1 character",
1902
+ code: "SLUG_TOO_SHORT"
1327
1903
  });
1328
- return;
1329
1904
  }
1330
- validateFieldsArray(fields, path, errors);
1331
- }
1332
- function validateSettings(settings, errors) {
1333
- if (!settings) {
1334
- return;
1905
+ if (slug.length > 50) {
1906
+ errors.push({
1907
+ path,
1908
+ message: "Form slug must be at most 50 characters",
1909
+ code: "SLUG_TOO_LONG"
1910
+ });
1335
1911
  }
1336
- if (typeof settings !== "object" || Array.isArray(settings)) {
1912
+ if (!SLUG_PATTERN.test(slug)) {
1337
1913
  errors.push({
1338
- path: "settings",
1339
- message: "Form settings must be an object",
1340
- code: "SETTINGS_INVALID_TYPE"
1914
+ path,
1915
+ message: "Form slug must start with a lowercase letter and contain only lowercase letters, numbers, hyphens, and underscores",
1916
+ code: "SLUG_INVALID_FORMAT"
1341
1917
  });
1342
- return;
1343
1918
  }
1344
- const s = settings;
1345
- if (s.confirmationType === "redirect") {
1346
- if (!s.redirectUrl && !s.redirectRelation) {
1347
- errors.push({
1348
- path: "settings.redirectUrl",
1349
- message: "Redirect URL or redirect relation is required when confirmation type is 'redirect'",
1350
- code: "REDIRECT_URL_REQUIRED"
1351
- });
1352
- }
1919
+ if (isReservedSlug(slug)) {
1920
+ errors.push({
1921
+ path,
1922
+ message: `Form slug '${slug}' is reserved and cannot be used`,
1923
+ code: "SLUG_RESERVED"
1924
+ });
1353
1925
  }
1354
1926
  }
1355
- function validateAccess(access, errors) {
1356
- if (!access) {
1927
+ function validateFieldName(name, path, errors, seenNames) {
1928
+ if (!name) {
1929
+ errors.push({
1930
+ path: `${path}.name`,
1931
+ message: "Field name is required",
1932
+ code: "FIELD_NAME_REQUIRED"
1933
+ });
1357
1934
  return;
1358
1935
  }
1359
- if (typeof access !== "object" || Array.isArray(access)) {
1936
+ if (typeof name !== "string") {
1360
1937
  errors.push({
1361
- path: "access",
1362
- message: "Access control must be an object",
1363
- code: "ACCESS_INVALID_TYPE"
1938
+ path: `${path}.name`,
1939
+ message: "Field name must be a string",
1940
+ code: "FIELD_NAME_REQUIRED"
1364
1941
  });
1365
1942
  return;
1366
1943
  }
1367
- const accessObj = access;
1368
- const validAccessKeys = ["submit", "read"];
1369
- for (const key of validAccessKeys) {
1370
- const fn = accessObj[key];
1371
- if (fn !== void 0 && typeof fn !== "function") {
1372
- errors.push({
1373
- path: `access.${key}`,
1374
- message: `Access control '${key}' must be a function`,
1375
- code: "ACCESS_FUNCTION_INVALID"
1376
- });
1377
- }
1378
- }
1379
- }
1380
- function validateFormConfig(config) {
1381
- const errors = [];
1382
- validateSlug(config.slug, errors);
1383
- validateFields(config.fields, errors);
1384
- validateSettings(config.settings, errors);
1385
- validateAccess(config.access, errors);
1386
- return {
1387
- valid: errors.length === 0,
1388
- errors
1389
- };
1390
- }
1391
- function assertValidFormConfig(config) {
1392
- const result = validateFormConfig(config);
1393
- if (!result.valid) {
1394
- const errorMessages = result.errors.map((err) => ` - [${err.code}] ${err.path}: ${err.message}`).join("\n");
1395
- throw new Error(
1396
- `Invalid form config for '${config.slug || "unknown"}':
1397
- ${errorMessages}`
1398
- );
1399
- }
1400
- }
1401
-
1402
- // src/config/field-helpers.ts
1403
- var option = (label, value) => ({
1404
- label,
1405
- value: value ?? label.toLowerCase().replace(/\s+/g, "_")
1406
- });
1407
- var text3 = (config) => ({
1408
- ...config,
1409
- type: "text"
1410
- });
1411
- var email = (config) => ({
1412
- ...config,
1413
- type: "email"
1414
- });
1415
- var phone = (config) => ({
1416
- ...config,
1417
- type: "phone"
1418
- });
1419
- var url = (config) => ({
1420
- ...config,
1421
- type: "url"
1422
- });
1423
- var textarea3 = (config) => ({
1424
- ...config,
1425
- type: "textarea"
1426
- });
1427
- var number = (config) => ({
1428
- ...config,
1429
- type: "number"
1430
- });
1431
- var select3 = (config) => ({
1432
- ...config,
1433
- type: "select"
1434
- });
1435
- var checkbox2 = (config) => ({
1436
- ...config,
1437
- type: "checkbox"
1438
- });
1439
- var radio = (config) => ({
1440
- ...config,
1441
- type: "radio"
1442
- });
1443
- var file = (config) => ({
1444
- ...config,
1445
- type: "file"
1446
- });
1447
- var date2 = (config) => ({
1448
- ...config,
1449
- type: "date"
1450
- });
1451
- var time = (config) => ({
1452
- ...config,
1453
- type: "time"
1454
- });
1455
- var hidden = (config) => ({
1456
- ...config,
1457
- type: "hidden"
1458
- });
1459
-
1460
- // src/utils/generate-schema.ts
1461
- import { z } from "zod";
1462
- function generateZodSchema(fields) {
1463
- const schemaShape = {};
1464
- for (const field of fields) {
1465
- if (!isKnownFormField(field)) {
1466
- schemaShape[field.name] = field.required ? z.unknown().refine(
1467
- (value) => value !== void 0 && value !== null && value !== "",
1468
- { message: `${field.label || field.name} is required` }
1469
- ) : z.unknown();
1470
- continue;
1471
- }
1472
- if (field.type === "hidden" && !field.required) {
1473
- schemaShape[field.name] = z.string().optional();
1474
- continue;
1475
- }
1476
- const fieldSchema = generateFieldSchema(field);
1477
- if (fieldSchema) {
1478
- schemaShape[field.name] = fieldSchema;
1479
- }
1480
- }
1481
- return z.object(schemaShape);
1482
- }
1483
- function generateFieldSchema(field) {
1484
- switch (field.type) {
1485
- case "text":
1486
- return generateTextSchema(field);
1487
- case "email":
1488
- return generateEmailSchema(field);
1489
- case "number":
1490
- return generateNumberSchema(field);
1491
- case "phone":
1492
- return generatePhoneSchema(field);
1493
- case "url":
1494
- return generateUrlSchema(field);
1495
- case "textarea":
1496
- return generateTextareaSchema(field);
1497
- case "select":
1498
- return generateSelectSchema(field);
1499
- case "checkbox":
1500
- return generateCheckboxSchema(field);
1501
- case "radio":
1502
- return generateRadioSchema(field);
1503
- case "file":
1504
- return generateFileSchema(field);
1505
- case "date":
1506
- return generateDateSchema(field);
1507
- case "time":
1508
- return generateTimeSchema(field);
1509
- case "hidden":
1510
- return generateHiddenSchema(field);
1511
- default:
1512
- return z.unknown();
1513
- }
1514
- }
1515
- function generateTextSchema(field) {
1516
- let schema = z.string();
1517
- if (field.validation?.minLength !== void 0) {
1518
- schema = schema.min(
1519
- field.validation.minLength,
1520
- field.validation.errorMessage || `Minimum ${field.validation.minLength} characters required`
1521
- );
1522
- }
1523
- if (field.validation?.maxLength !== void 0) {
1524
- schema = schema.max(
1525
- field.validation.maxLength,
1526
- field.validation.errorMessage || `Maximum ${field.validation.maxLength} characters allowed`
1527
- );
1944
+ if (!FIELD_NAME_PATTERN.test(name)) {
1945
+ errors.push({
1946
+ path: `${path}.name`,
1947
+ message: `Field name '${name}' must start with a letter and contain only letters, numbers, and underscores`,
1948
+ code: "FIELD_NAME_INVALID_FORMAT"
1949
+ });
1528
1950
  }
1529
- if (field.validation?.pattern) {
1530
- schema = schema.regex(
1531
- new RegExp(field.validation.pattern),
1532
- field.validation.errorMessage || "Invalid format"
1533
- );
1951
+ const nameLower = name.toLowerCase();
1952
+ if (seenNames.has(nameLower)) {
1953
+ errors.push({
1954
+ path: `${path}.name`,
1955
+ message: `Duplicate field name '${name}'`,
1956
+ code: "FIELD_NAME_DUPLICATE"
1957
+ });
1958
+ } else {
1959
+ seenNames.add(nameLower);
1534
1960
  }
1535
- return applyRequired(schema, field.required, field.validation?.errorMessage);
1536
1961
  }
1537
- function generateEmailSchema(field) {
1538
- let schema = z.string().email(field.validation?.errorMessage || "Invalid email address");
1539
- if (field.validation?.pattern) {
1540
- schema = schema.regex(
1541
- new RegExp(field.validation.pattern),
1542
- field.validation.errorMessage || "Invalid email format"
1543
- );
1962
+ function validateFieldLabel(label, path, errors) {
1963
+ if (!label || typeof label === "string" && label.trim() === "") {
1964
+ errors.push({
1965
+ path: `${path}.label`,
1966
+ message: "Field label is required",
1967
+ code: "FIELD_LABEL_REQUIRED"
1968
+ });
1544
1969
  }
1545
- return applyRequired(schema, field.required, field.validation?.errorMessage);
1546
1970
  }
1547
- function generateNumberSchema(field) {
1548
- let schema = z.number({
1549
- error: (issue) => issue.input === void 0 ? field.validation?.errorMessage || "This field is required" : "Must be a number"
1550
- });
1551
- if (field.validation?.min !== void 0) {
1552
- schema = schema.min(
1553
- field.validation.min,
1554
- field.validation.errorMessage || `Minimum value is ${field.validation.min}`
1555
- );
1971
+ function validateOptions(field, path, errors) {
1972
+ const fieldWithOptions = field;
1973
+ const options = fieldWithOptions.options;
1974
+ if (!options) {
1975
+ errors.push({
1976
+ path: `${path}.options`,
1977
+ message: `${field.type} field must have an 'options' array`,
1978
+ code: "OPTIONS_REQUIRED"
1979
+ });
1980
+ return;
1556
1981
  }
1557
- if (field.validation?.max !== void 0) {
1558
- schema = schema.max(
1559
- field.validation.max,
1560
- field.validation.errorMessage || `Maximum value is ${field.validation.max}`
1561
- );
1982
+ if (!Array.isArray(options)) {
1983
+ errors.push({
1984
+ path: `${path}.options`,
1985
+ message: `${field.type} field 'options' must be an array`,
1986
+ code: "OPTIONS_REQUIRED"
1987
+ });
1988
+ return;
1562
1989
  }
1563
- if (field.required) {
1564
- return schema;
1990
+ if (options.length === 0) {
1991
+ errors.push({
1992
+ path: `${path}.options`,
1993
+ message: `${field.type} field must have at least one option`,
1994
+ code: "OPTIONS_EMPTY"
1995
+ });
1565
1996
  }
1566
- return schema.optional().nullable();
1567
1997
  }
1568
- function generatePhoneSchema(field) {
1569
- let schema = z.string();
1570
- if (field.validation?.pattern) {
1571
- schema = schema.regex(
1572
- new RegExp(field.validation.pattern),
1573
- field.validation.errorMessage || "Invalid phone number format"
1574
- );
1575
- } else {
1576
- schema = schema.regex(
1577
- /^[\d\s\-+()]+$/,
1578
- field.validation?.errorMessage || "Invalid phone number"
1579
- );
1998
+ function validateConditionalLogic(field, path, errors) {
1999
+ const { conditionalLogic } = field;
2000
+ if (!conditionalLogic || !conditionalLogic.enabled) {
2001
+ return;
2002
+ }
2003
+ if (!conditionalLogic.conditions || conditionalLogic.conditions.length === 0) {
2004
+ errors.push({
2005
+ path: `${path}.conditionalLogic.conditions`,
2006
+ message: "Conditional logic must have at least one condition",
2007
+ code: "CONDITIONS_REQUIRED"
2008
+ });
2009
+ return;
1580
2010
  }
1581
- return applyRequired(schema, field.required, field.validation?.errorMessage);
2011
+ conditionalLogic.conditions.forEach((condition, index) => {
2012
+ const conditionPath = `${path}.conditionalLogic.conditions[${index}]`;
2013
+ if (!condition.field) {
2014
+ errors.push({
2015
+ path: `${conditionPath}.field`,
2016
+ message: "Condition must specify a field name",
2017
+ code: "CONDITION_FIELD_REQUIRED"
2018
+ });
2019
+ }
2020
+ if (!condition.comparison) {
2021
+ errors.push({
2022
+ path: `${conditionPath}.comparison`,
2023
+ message: "Condition must specify a comparison operator",
2024
+ code: "CONDITION_COMPARISON_REQUIRED"
2025
+ });
2026
+ }
2027
+ });
1582
2028
  }
1583
- function generateUrlSchema(field) {
1584
- let schema = z.string().url(field.validation?.errorMessage || "Invalid URL");
1585
- if (field.validation?.pattern) {
1586
- schema = schema.regex(
1587
- new RegExp(field.validation.pattern),
1588
- field.validation.errorMessage || "Invalid URL format"
1589
- );
2029
+ function validateFieldByType(field, path, errors) {
2030
+ switch (field.type) {
2031
+ case "select":
2032
+ case "radio":
2033
+ validateOptions(field, path, errors);
2034
+ break;
2035
+ case "number": {
2036
+ const numberField = field;
2037
+ if (numberField.validation) {
2038
+ const { min, max } = numberField.validation;
2039
+ if (min !== void 0 && max !== void 0 && min > max) {
2040
+ errors.push({
2041
+ path: `${path}.validation`,
2042
+ message: "Min value cannot be greater than max value",
2043
+ code: "MIN_GREATER_THAN_MAX"
2044
+ });
2045
+ }
2046
+ }
2047
+ break;
2048
+ }
2049
+ case "text":
2050
+ case "textarea": {
2051
+ const textField = field;
2052
+ if (textField.validation) {
2053
+ const { minLength, maxLength } = textField.validation;
2054
+ if (minLength !== void 0 && maxLength !== void 0 && minLength > maxLength) {
2055
+ errors.push({
2056
+ path: `${path}.validation`,
2057
+ message: "Min length cannot be greater than max length",
2058
+ code: "MIN_LENGTH_GREATER_THAN_MAX"
2059
+ });
2060
+ }
2061
+ }
2062
+ break;
2063
+ }
2064
+ case "file": {
2065
+ const fileField = field;
2066
+ if (fileField.maxFileSize !== void 0 && fileField.maxFileSize <= 0) {
2067
+ errors.push({
2068
+ path: `${path}.maxFileSize`,
2069
+ message: "File maxFileSize must be a positive number",
2070
+ code: "FILE_MAX_SIZE_INVALID"
2071
+ });
2072
+ }
2073
+ break;
2074
+ }
1590
2075
  }
1591
- return applyRequired(schema, field.required, field.validation?.errorMessage);
1592
2076
  }
1593
- function generateTextareaSchema(field) {
1594
- let schema = z.string();
1595
- if (field.validation?.minLength !== void 0) {
1596
- schema = schema.min(
1597
- field.validation.minLength,
1598
- field.validation.errorMessage || `Minimum ${field.validation.minLength} characters required`
1599
- );
2077
+ function validateField(field, path, errors, seenNames) {
2078
+ if (!field || typeof field !== "object") {
2079
+ return;
1600
2080
  }
1601
- if (field.validation?.maxLength !== void 0) {
1602
- schema = schema.max(
1603
- field.validation.maxLength,
1604
- field.validation.errorMessage || `Maximum ${field.validation.maxLength} characters allowed`
1605
- );
2081
+ const f = field;
2082
+ const fieldType = f.type;
2083
+ if (!fieldType) {
2084
+ errors.push({
2085
+ path: `${path}.type`,
2086
+ message: "Field type is required",
2087
+ code: "FIELD_TYPE_REQUIRED"
2088
+ });
2089
+ return;
1606
2090
  }
1607
- return applyRequired(schema, field.required, field.validation?.errorMessage);
1608
- }
1609
- function generateSelectSchema(field) {
1610
- const validValues = field.options.map((opt) => opt.value);
1611
- if (field.allowMultiple) {
1612
- const schema2 = z.array(z.enum(validValues));
1613
- if (field.required) {
1614
- return schema2.min(
1615
- 1,
1616
- field.validation?.errorMessage || "Please select at least one option"
1617
- );
2091
+ const isValidType = typeof fieldType === "string" && (VALID_FIELD_TYPES_SET.has(fieldType) || isPluginFieldTypeOnSurface(fieldType, "forms"));
2092
+ if (!isValidType) {
2093
+ let fieldTypeRepr;
2094
+ if (typeof fieldType === "object" && fieldType !== null) {
2095
+ fieldTypeRepr = JSON.stringify(fieldType);
2096
+ } else {
2097
+ fieldTypeRepr = String(fieldType);
1618
2098
  }
1619
- return schema2.optional();
2099
+ errors.push({
2100
+ path: `${path}.type`,
2101
+ message: `Invalid field type '${fieldTypeRepr}'. Valid types: ${VALID_FIELD_TYPES.join(", ")}`,
2102
+ code: "FIELD_TYPE_INVALID"
2103
+ });
2104
+ return;
1620
2105
  }
1621
- const schema = z.enum(validValues, {
1622
- error: () => field.validation?.errorMessage || "Please select a valid option"
1623
- });
1624
- if (field.required) {
1625
- return schema;
2106
+ validateFieldName(f.name, path, errors, seenNames);
2107
+ if (fieldType !== "hidden") {
2108
+ validateFieldLabel(f.label, path, errors);
1626
2109
  }
1627
- return schema.optional();
1628
- }
1629
- function generateCheckboxSchema(field) {
1630
- const schema = z.boolean();
1631
- if (field.required) {
1632
- return schema.refine((val) => val === true, {
1633
- message: field.validation?.errorMessage || "This field is required"
1634
- });
2110
+ validateFieldByType(field, path, errors);
2111
+ if (f.conditionalLogic) {
2112
+ validateConditionalLogic(field, path, errors);
1635
2113
  }
1636
- return schema.optional();
1637
2114
  }
1638
- function generateRadioSchema(field) {
1639
- const validValues = field.options.map((opt) => opt.value);
1640
- const schema = z.enum(validValues, {
1641
- error: () => field.validation?.errorMessage || "Please select an option"
2115
+ function validateFieldsArray(fields, basePath, errors) {
2116
+ const seenNames = /* @__PURE__ */ new Set();
2117
+ fields.forEach((field, index) => {
2118
+ const fieldPath = `${basePath}[${index}]`;
2119
+ validateField(field, fieldPath, errors, seenNames);
1642
2120
  });
1643
- if (field.required) {
1644
- return schema;
1645
- }
1646
- return schema.optional();
1647
- }
1648
- function generateFileSchema(field) {
1649
- if (field.multiple) {
1650
- const schema2 = z.array(z.string());
1651
- if (field.required) {
1652
- return schema2.min(
1653
- 1,
1654
- field.validation?.errorMessage || "Please upload at least one file"
1655
- );
1656
- }
1657
- return schema2.optional();
1658
- }
1659
- const schema = z.string();
1660
- return applyRequired(schema, field.required, field.validation?.errorMessage);
1661
2121
  }
1662
- function generateDateSchema(field) {
1663
- let schema = z.string();
1664
- schema = schema.refine(
1665
- (val) => {
1666
- if (!val) return true;
1667
- const date3 = new Date(val);
1668
- return !isNaN(date3.getTime());
1669
- },
1670
- { message: field.validation?.errorMessage || "Invalid date" }
1671
- );
1672
- if (field.min || field.max) {
1673
- schema = schema.refine(
1674
- (val) => {
1675
- if (!val) return true;
1676
- const date3 = new Date(val);
1677
- if (field.min && date3 < new Date(field.min)) return false;
1678
- if (field.max && date3 > new Date(field.max)) return false;
1679
- return true;
1680
- },
1681
- {
1682
- message: field.validation?.errorMessage || `Date must be between ${field.min || "any"} and ${field.max || "any"}`
1683
- }
1684
- );
2122
+ function validateFields(fields, errors) {
2123
+ const path = "fields";
2124
+ if (!fields) {
2125
+ errors.push({
2126
+ path,
2127
+ message: "Form must have at least one field",
2128
+ code: "FIELDS_REQUIRED"
2129
+ });
2130
+ return;
1685
2131
  }
1686
- if (field.required) {
1687
- return schema.refine(
1688
- (val) => val !== void 0 && val !== null && val !== "",
1689
- { message: field.validation?.errorMessage || "This field is required" }
1690
- );
2132
+ if (!Array.isArray(fields)) {
2133
+ errors.push({
2134
+ path,
2135
+ message: "Form fields must be an array",
2136
+ code: "FIELDS_INVALID_TYPE"
2137
+ });
2138
+ return;
1691
2139
  }
1692
- return schema.optional().or(z.literal(""));
1693
- }
1694
- function generateTimeSchema(field) {
1695
- const schema = z.string().regex(
1696
- /^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/,
1697
- field.validation?.errorMessage || "Invalid time format (HH:mm)"
1698
- );
1699
- return applyRequired(schema, field.required, field.validation?.errorMessage);
1700
- }
1701
- function generateHiddenSchema(field) {
1702
- const schema = z.string();
1703
- return applyRequired(schema, field.required, field.validation?.errorMessage);
1704
- }
1705
- function applyRequired(schema, required, errorMessage) {
1706
- if (required) {
1707
- return schema.min(1, errorMessage || "This field is required");
2140
+ if (fields.length === 0) {
2141
+ errors.push({
2142
+ path,
2143
+ message: "Form must have at least one field",
2144
+ code: "FIELDS_EMPTY"
2145
+ });
2146
+ return;
1708
2147
  }
1709
- return schema.optional().or(z.literal(""));
2148
+ validateFieldsArray(fields, path, errors);
1710
2149
  }
1711
- function transformFormData(data, fields) {
1712
- const transformed = {};
1713
- for (const field of fields) {
1714
- const value = data[field.name];
1715
- if (value === void 0) {
1716
- continue;
1717
- }
1718
- transformed[field.name] = isKnownFormField(field) ? transformFieldValue(value, field) : value;
2150
+ function validateSettings(settings, errors) {
2151
+ if (!settings) {
2152
+ return;
1719
2153
  }
1720
- return transformed;
1721
- }
1722
- function transformFieldValue(value, field) {
1723
- if (value === null || value === void 0) {
1724
- return value;
2154
+ if (typeof settings !== "object" || Array.isArray(settings)) {
2155
+ errors.push({
2156
+ path: "settings",
2157
+ message: "Form settings must be an object",
2158
+ code: "SETTINGS_INVALID_TYPE"
2159
+ });
2160
+ return;
1725
2161
  }
1726
- switch (field.type) {
1727
- case "number":
1728
- if (typeof value === "string") {
1729
- const num = parseFloat(value);
1730
- return isNaN(num) ? value : num;
1731
- }
1732
- return value;
1733
- case "checkbox":
1734
- if (typeof value === "string") {
1735
- return value === "true" || value === "1" || value === "on";
1736
- }
1737
- return Boolean(value);
1738
- case "text":
1739
- case "email":
1740
- case "phone":
1741
- case "url":
1742
- case "textarea":
1743
- case "hidden":
1744
- if (typeof value === "string") {
1745
- return value.trim();
1746
- }
1747
- return value;
1748
- case "select":
1749
- if (field.allowMultiple) {
1750
- if (!Array.isArray(value)) {
1751
- return value ? [value] : [];
1752
- }
1753
- }
1754
- return value;
1755
- default:
1756
- return value;
2162
+ const s = settings;
2163
+ if (s.confirmationType === "redirect") {
2164
+ if (!s.redirectUrl && !s.redirectRelation) {
2165
+ errors.push({
2166
+ path: "settings.redirectUrl",
2167
+ message: "Redirect URL or redirect relation is required when confirmation type is 'redirect'",
2168
+ code: "REDIRECT_URL_REQUIRED"
2169
+ });
2170
+ }
2171
+ }
2172
+ if (s.confirmationType === "relationship" && !namesADocument(s.redirectPage)) {
2173
+ errors.push({
2174
+ path: "settings.redirectPage",
2175
+ message: "A page is required when confirmation type is 'relationship'",
2176
+ code: "REDIRECT_PAGE_REQUIRED"
2177
+ });
1757
2178
  }
1758
2179
  }
1759
- function validateFormData(data, fields) {
1760
- const transformed = transformFormData(data, fields);
1761
- const schema = generateZodSchema(fields);
1762
- return schema.safeParse(transformed);
2180
+ function namesADocument(value) {
2181
+ if (typeof value === "string") return value.length > 0;
2182
+ return parseRedirectReference(value, []) !== null;
1763
2183
  }
1764
- function getValidationErrors(result) {
1765
- if (result.success) {
1766
- return {};
2184
+ function validateAccess(access, errors) {
2185
+ if (!access) {
2186
+ return;
1767
2187
  }
1768
- const errors = {};
1769
- for (const error of result.error.issues) {
1770
- const path = error.path.join(".");
1771
- if (!errors[path]) {
1772
- errors[path] = error.message;
2188
+ if (typeof access !== "object" || Array.isArray(access)) {
2189
+ errors.push({
2190
+ path: "access",
2191
+ message: "Access control must be an object",
2192
+ code: "ACCESS_INVALID_TYPE"
2193
+ });
2194
+ return;
2195
+ }
2196
+ const accessObj = access;
2197
+ const validAccessKeys = ["submit", "read"];
2198
+ for (const key of validAccessKeys) {
2199
+ const fn = accessObj[key];
2200
+ if (fn !== void 0 && typeof fn !== "function") {
2201
+ errors.push({
2202
+ path: `access.${key}`,
2203
+ message: `Access control '${key}' must be a function`,
2204
+ code: "ACCESS_FUNCTION_INVALID"
2205
+ });
1773
2206
  }
1774
2207
  }
1775
- return errors;
2208
+ }
2209
+ function validateFormConfig(config) {
2210
+ const errors = [];
2211
+ validateSlug(config.slug, errors);
2212
+ validateFields(config.fields, errors);
2213
+ validateSettings(config.settings, errors);
2214
+ validateAccess(config.access, errors);
2215
+ return {
2216
+ valid: errors.length === 0,
2217
+ errors
2218
+ };
2219
+ }
2220
+ function assertValidFormConfig(config) {
2221
+ const result = validateFormConfig(config);
2222
+ if (!result.valid) {
2223
+ const errorMessages = result.errors.map((err) => ` - [${err.code}] ${err.path}: ${err.message}`).join("\n");
2224
+ throw new Error(
2225
+ `Invalid form config for '${config.slug || "unknown"}':
2226
+ ${errorMessages}`
2227
+ );
2228
+ }
1776
2229
  }
1777
2230
 
2231
+ // src/config/field-helpers.ts
2232
+ var option = (label, value) => ({
2233
+ label,
2234
+ value: value ?? label.toLowerCase().replace(/\s+/g, "_")
2235
+ });
2236
+ var text3 = (config) => ({
2237
+ ...config,
2238
+ type: "text"
2239
+ });
2240
+ var email = (config) => ({
2241
+ ...config,
2242
+ type: "email"
2243
+ });
2244
+ var phone = (config) => ({
2245
+ ...config,
2246
+ type: "phone"
2247
+ });
2248
+ var url = (config) => ({
2249
+ ...config,
2250
+ type: "url"
2251
+ });
2252
+ var textarea3 = (config) => ({
2253
+ ...config,
2254
+ type: "textarea"
2255
+ });
2256
+ var number = (config) => ({
2257
+ ...config,
2258
+ type: "number"
2259
+ });
2260
+ var select3 = (config) => ({
2261
+ ...config,
2262
+ type: "select"
2263
+ });
2264
+ var checkbox2 = (config) => ({
2265
+ ...config,
2266
+ type: "checkbox"
2267
+ });
2268
+ var radio = (config) => ({
2269
+ ...config,
2270
+ type: "radio"
2271
+ });
2272
+ var file = (config) => ({
2273
+ ...config,
2274
+ type: "file"
2275
+ });
2276
+ var date3 = (config) => ({
2277
+ ...config,
2278
+ type: "date"
2279
+ });
2280
+ var time = (config) => ({
2281
+ ...config,
2282
+ type: "time"
2283
+ });
2284
+ var hidden = (config) => ({
2285
+ ...config,
2286
+ type: "hidden"
2287
+ });
2288
+
2289
+ // src/handlers/submit-form.ts
2290
+ import { formAvailability, NO_SUCH_FORM } from "nextly";
2291
+
1778
2292
  // src/handlers/spam-detection.ts
1779
2293
  var rateLimitStore = /* @__PURE__ */ new Map();
1780
2294
  async function checkSpam(options) {
@@ -1899,19 +2413,18 @@ async function submitForm(options, context) {
1899
2413
  logger.warn?.("Form submission attempted for non-existent form", {
1900
2414
  formSlug
1901
2415
  });
1902
- return {
1903
- success: false,
1904
- error: "Form not found"
1905
- };
2416
+ return { success: false, error: NO_SUCH_FORM };
1906
2417
  }
1907
- if (form.status !== "published") {
1908
- logger.info?.("Form submission rejected - form not published", {
2418
+ const availability = formAvailability(form);
2419
+ if (availability.kind !== "open") {
2420
+ logger.info?.("Form submission rejected - form not accepting", {
1909
2421
  formSlug,
1910
- status: form.status
2422
+ status: form.status,
2423
+ availability: availability.kind
1911
2424
  });
1912
2425
  return {
1913
2426
  success: false,
1914
- error: "This form is not currently accepting submissions"
2427
+ error: availability.kind === "closed" ? availability.message : NO_SUCH_FORM
1915
2428
  };
1916
2429
  }
1917
2430
  const settings = normalizeFormSettings(form.settings);
@@ -1970,34 +2483,30 @@ async function submitForm(options, context) {
1970
2483
  return { success: true };
1971
2484
  }
1972
2485
  const isContentSpam = spamResult.isSpam;
1973
- const transformedData = transformFormData(data, form.fields);
1974
- let storedData;
1975
2486
  if (isContentSpam) {
1976
2487
  logger.info?.("Spam submission detected \u2014 storing flagged", {
1977
2488
  formSlug,
1978
2489
  reason: spamResult.reason,
1979
2490
  ipAddress: metadata?.ipAddress
1980
2491
  });
1981
- sanitizeSubmissionData(transformedData, form.fields);
1982
- storedData = transformedData;
1983
- } else {
1984
- const schema = generateZodSchema(form.fields);
1985
- const validationResult = schema.safeParse(transformedData);
1986
- if (!validationResult.success) {
1987
- const validationErrors = getValidationErrors(validationResult);
1988
- logger.debug?.("Form validation failed", {
1989
- formSlug,
1990
- errors: validationErrors
1991
- });
1992
- return {
1993
- success: false,
1994
- error: "Validation failed",
1995
- validationErrors
1996
- };
1997
- }
1998
- sanitizeSubmissionData(validationResult.data, form.fields);
1999
- storedData = validationResult.data;
2000
2492
  }
2493
+ const prepared = prepareSubmission({
2494
+ data,
2495
+ fields: form.fields,
2496
+ validate: !isContentSpam
2497
+ });
2498
+ if (prepared.validationErrors) {
2499
+ logger.debug?.("Form validation failed", {
2500
+ formSlug,
2501
+ errors: prepared.validationErrors
2502
+ });
2503
+ return {
2504
+ success: false,
2505
+ error: "Validation failed",
2506
+ validationErrors: prepared.validationErrors
2507
+ };
2508
+ }
2509
+ const storedData = prepared.data;
2001
2510
  const submissionData = {
2002
2511
  form: form.id,
2003
2512
  data: storedData,
@@ -2009,7 +2518,10 @@ async function submitForm(options, context) {
2009
2518
  };
2010
2519
  const submission = await collections.createEntry(
2011
2520
  pluginConfig.formSubmissionOverrides.slug,
2012
- submissionData,
2521
+ asPluginSubmission(submissionData, {
2522
+ keepAsEvidence: isContentSpam,
2523
+ form: { id: form.id, fields: form.fields }
2524
+ }),
2013
2525
  // Public form submission — create as system. No ambient user; an
2014
2526
  // empty context already resolves to system, but be explicit.
2015
2527
  { as: "system" }
@@ -2026,7 +2538,11 @@ async function submitForm(options, context) {
2026
2538
  warnings: submission.warnings
2027
2539
  });
2028
2540
  }
2029
- const redirect = determineRedirectUrl(form);
2541
+ const redirect = await resolveRedirectUrl(
2542
+ form,
2543
+ pluginConfig,
2544
+ pluginContext
2545
+ );
2030
2546
  if (isContentSpam) {
2031
2547
  return { success: true, redirect };
2032
2548
  }
@@ -2064,21 +2580,100 @@ async function fetchFormBySlug(slug, pluginConfig, pluginContext) {
2064
2580
  return null;
2065
2581
  }
2066
2582
  }
2067
- function determineRedirectUrl(form) {
2068
- if (!form.settings) {
2583
+ async function resolveRedirectUrl(form, pluginConfig, pluginContext) {
2584
+ const settings = form.settings;
2585
+ if (!settings) return void 0;
2586
+ if (settings.confirmationType === "redirect" && settings.redirectUrl) {
2587
+ return settings.redirectUrl;
2588
+ }
2589
+ const field = pickedDocumentField(settings);
2590
+ if (!field) return void 0;
2591
+ return urlForPickedDocument(
2592
+ settings[field],
2593
+ form,
2594
+ pluginConfig,
2595
+ pluginContext
2596
+ );
2597
+ }
2598
+ async function urlForPickedDocument(stored, form, pluginConfig, pluginContext) {
2599
+ const { logger } = pluginContext;
2600
+ const patterns = pluginConfig.redirectRelationships;
2601
+ const reference = parseRedirectReference(stored, Object.keys(patterns));
2602
+ if (!reference) {
2603
+ logger.warn?.("Form redirects to a page, but names no readable document", {
2604
+ form: form.slug
2605
+ });
2606
+ return void 0;
2607
+ }
2608
+ const pattern = patterns[reference.collection];
2609
+ if (!pattern) {
2610
+ logger.warn?.(
2611
+ "Form redirects to a collection that is not in redirectRelationships",
2612
+ { form: form.slug, collection: reference.collection }
2613
+ );
2614
+ return void 0;
2615
+ }
2616
+ const target = await readTarget(reference, form, pluginContext);
2617
+ if (!target) return void 0;
2618
+ if (documentReachability(
2619
+ target,
2620
+ pluginConfig.redirectTargetLocalization[reference.collection]
2621
+ ) === "unreachable") {
2622
+ logger.warn?.("Form redirects to a page that has never been published", {
2623
+ form: form.slug,
2624
+ collection: reference.collection,
2625
+ target: reference.id
2626
+ });
2069
2627
  return void 0;
2070
2628
  }
2071
- if (form.settings.confirmationType !== "redirect") {
2629
+ return buildUrl(pattern, target, reference.collection, form, pluginContext);
2630
+ }
2631
+ function buildUrl(pattern, target, collection, form, pluginContext) {
2632
+ const { logger } = pluginContext;
2633
+ try {
2634
+ const url2 = applyRedirectPattern(pattern, target);
2635
+ if (!url2) {
2636
+ logger.warn?.(
2637
+ "Form redirect pattern could not be filled from the target document",
2638
+ { form: form.slug, collection }
2639
+ );
2640
+ }
2641
+ return url2;
2642
+ } catch (error) {
2643
+ logger.warn?.("Form redirect pattern threw while building a URL", {
2644
+ form: form.slug,
2645
+ collection,
2646
+ error: error instanceof Error ? error.message : String(error)
2647
+ });
2072
2648
  return void 0;
2073
2649
  }
2074
- if (form.settings.redirectUrl) {
2075
- return form.settings.redirectUrl;
2650
+ }
2651
+ async function readTarget(reference, form, pluginContext) {
2652
+ const { logger } = pluginContext;
2653
+ const context = {
2654
+ form: form.slug,
2655
+ collection: reference.collection,
2656
+ id: reference.id
2657
+ };
2658
+ let row;
2659
+ try {
2660
+ row = await pluginContext.services.collections.findEntryById(
2661
+ reference.collection,
2662
+ reference.id,
2663
+ { as: "system" }
2664
+ );
2665
+ } catch (error) {
2666
+ logger.warn?.("Form redirect target could not be read", {
2667
+ ...context,
2668
+ error: error instanceof Error ? error.message : String(error)
2669
+ });
2670
+ return void 0;
2076
2671
  }
2077
- if (form.settings.redirectRelation) {
2078
- const { relationTo, value } = form.settings.redirectRelation;
2079
- return `relationship://${relationTo}/${value}`;
2672
+ if (!row || typeof row !== "object") {
2673
+ logger.warn?.("Form redirect target no longer exists", context);
2674
+ return void 0;
2080
2675
  }
2081
- return void 0;
2676
+ return row;
2082
2677
  }
2083
2678
  async function validateSubmission(formSlug, data, context) {
2084
2679
  const { pluginContext, pluginConfig } = context;
@@ -2086,15 +2681,17 @@ async function validateSubmission(formSlug, data, context) {
2086
2681
  if (!form) {
2087
2682
  return { valid: false, errors: { _form: "Form not found" } };
2088
2683
  }
2089
- const transformedData = transformFormData(data, form.fields);
2090
- const schema = generateZodSchema(form.fields);
2091
- const result = schema.safeParse(transformedData);
2092
- if (result.success) {
2684
+ const prepared = prepareSubmission({
2685
+ data,
2686
+ fields: form.fields,
2687
+ validate: true
2688
+ });
2689
+ if (!prepared.validationErrors) {
2093
2690
  return { valid: true };
2094
2691
  }
2095
2692
  return {
2096
2693
  valid: false,
2097
- errors: getValidationErrors(result)
2694
+ errors: prepared.validationErrors
2098
2695
  };
2099
2696
  }
2100
2697
  async function isFormAcceptingSubmissions(formSlug, context) {
@@ -2105,25 +2702,6 @@ async function isFormAcceptingSubmissions(formSlug, context) {
2105
2702
  }
2106
2703
  return form.status === "published";
2107
2704
  }
2108
- var TEXT_FORM_FIELDS = /* @__PURE__ */ new Set([
2109
- "text",
2110
- "email",
2111
- "textarea",
2112
- "phone",
2113
- "url",
2114
- "hidden"
2115
- ]);
2116
- function stripHtmlTags(input) {
2117
- return input.replace(/<[^>]*(?:>|$)/g, "").replace(/\s+/g, " ").trim();
2118
- }
2119
- function sanitizeSubmissionData(data, fields) {
2120
- for (const field of fields) {
2121
- if (!TEXT_FORM_FIELDS.has(field.type)) continue;
2122
- const value = data[field.name];
2123
- if (typeof value !== "string") continue;
2124
- data[field.name] = stripHtmlTags(value);
2125
- }
2126
- }
2127
2705
  async function getFormSubmissionStats(formSlug, context) {
2128
2706
  const { pluginContext, pluginConfig } = context;
2129
2707
  const { collections } = pluginContext.services;
@@ -2352,7 +2930,7 @@ export {
2352
2930
  cleanupRateLimitStore,
2353
2931
  clearRateLimitStore,
2354
2932
  createFormConfig,
2355
- date2 as date,
2933
+ date3 as date,
2356
2934
  downloadFile,
2357
2935
  email,
2358
2936
  evaluateConditions,