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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,9 +26,12 @@ 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-H5X3FKOZ.js";
30
35
 
31
36
  // src/plugin.ts
32
37
  import { createRequire } from "module";
@@ -39,18 +44,284 @@ import {
39
44
  textarea,
40
45
  select,
41
46
  checkbox,
47
+ date,
42
48
  group,
43
49
  json,
44
50
  relationship
45
51
  } from "nextly";
52
+
53
+ // src/utils/redirect-target.ts
54
+ import { previewUrlFromTemplate } from "nextly/config";
55
+ var DEFAULT_REDIRECT_PATTERN = "/{slug}";
56
+ function normalizeRedirectRelationships(option2) {
57
+ if (!option2) return {};
58
+ const entries = Array.isArray(option2) ? option2.map((collection) => [collection, DEFAULT_REDIRECT_PATTERN]) : Object.entries(option2);
59
+ return Object.fromEntries(
60
+ entries.filter(
61
+ ([, pattern]) => typeof pattern !== "string" || pattern.trim()
62
+ )
63
+ );
64
+ }
65
+ function applyRedirectPattern(pattern, document) {
66
+ if (typeof pattern === "function") {
67
+ const produced = pattern(document);
68
+ return produced ? produced : void 0;
69
+ }
70
+ return previewUrlFromTemplate(pattern)(document) ?? void 0;
71
+ }
72
+
73
+ // src/collections/access-defaults.ts
74
+ var authenticated = ({ user }) => !!user;
75
+ var administrative = ({ roles }) => roles.includes("admin") || roles.includes("super-admin");
76
+ function accessWithDefaults(overrides, defaults = {}) {
77
+ return {
78
+ read: overrides?.read ?? defaults.read ?? authenticated,
79
+ create: overrides?.create ?? defaults.create ?? authenticated,
80
+ update: overrides?.update ?? authenticated,
81
+ // Deleting is administrative in both collections; the DB permission layer
82
+ // remains the backstop underneath it.
83
+ delete: overrides?.delete ?? administrative
84
+ };
85
+ }
86
+
87
+ // src/collections/forms.ts
88
+ function withHostHooks(pluginHooks, hostHooks, trailing = {}) {
89
+ const host = { ...hostHooks };
90
+ const asList = (value) => Array.isArray(value) ? value : value === void 0 ? [] : [value];
91
+ const merged = { ...host };
92
+ for (const [phase, handlers] of Object.entries(
93
+ pluginHooks
94
+ )) {
95
+ merged[phase] = [...asList(handlers), ...asList(host[phase])];
96
+ }
97
+ for (const [phase, handlers] of Object.entries(trailing)) {
98
+ merged[phase] = [...asList(merged[phase]), ...asList(handlers)];
99
+ }
100
+ return merged;
101
+ }
102
+ function documentRedirectSettings(settings) {
103
+ if (typeof settings !== "object" || settings === null) return null;
104
+ const record = settings;
105
+ const field = pickedDocumentField(record);
106
+ return field ? { settings: record, field } : null;
107
+ }
108
+ function pageRedirectSettings(data, operation) {
109
+ if (!data) return null;
110
+ if (operation !== "create" && data.settings === void 0) return null;
111
+ return documentRedirectSettings(data.settings);
112
+ }
113
+ function formAcceptsSubmissions(data, originalData) {
114
+ return (data?.status ?? originalData?.status) === "published";
115
+ }
116
+ function storedSettings(value) {
117
+ if (typeof value === "string") {
118
+ try {
119
+ return documentRedirectSettings(JSON.parse(value));
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+ return documentRedirectSettings(value);
125
+ }
126
+ function storedRedirectReference(originalData, patterns) {
127
+ const picked = storedSettings(originalData?.settings);
128
+ if (!picked) return null;
129
+ const collections = Object.keys(patterns);
130
+ const reference = parseRedirectReference(
131
+ picked.settings[picked.field],
132
+ collections
133
+ );
134
+ if (!reference) return null;
135
+ return {
136
+ reference,
137
+ field: picked.field,
138
+ configured: collections.includes(reference.collection)
139
+ };
140
+ }
141
+ function normalizeStoredReference(settings, field, reference) {
142
+ const stored = settings[field];
143
+ if (typeof stored === "string") {
144
+ if (stored !== reference.id) settings[field] = reference.id;
145
+ return;
146
+ }
147
+ if (typeof stored !== "object" || stored === null || Array.isArray(stored)) {
148
+ return;
149
+ }
150
+ const record = stored;
151
+ if (record.relationTo !== reference.collection) {
152
+ settings[field] = { ...record, relationTo: reference.collection };
153
+ }
154
+ }
155
+ function namedRedirectReference(data, operation, patterns) {
156
+ const picked = pageRedirectSettings(data, operation);
157
+ if (!picked) return null;
158
+ const { settings, field } = picked;
159
+ const collections = Object.keys(patterns);
160
+ const reference = parseRedirectReference(settings[field], collections);
161
+ if (reference) normalizeStoredReference(settings, field, reference);
162
+ return {
163
+ settings,
164
+ field,
165
+ reference,
166
+ configured: reference !== null && collections.includes(reference.collection)
167
+ };
168
+ }
169
+ function wouldStrandVisitors(data, originalData, target, targetCollectionIsLocalized) {
170
+ return formAcceptsSubmissions(data, originalData) && documentReachability(target, targetCollectionIsLocalized) === "unreachable";
171
+ }
172
+ function publishesForm(data, originalData) {
173
+ if (data?.status !== "published") return false;
174
+ return originalData?.status !== "published";
175
+ }
176
+ function sameTarget(named, stored) {
177
+ if (!named.reference || !stored) return false;
178
+ return stored.field === named.field && stored.reference.collection === named.reference.collection && stored.reference.id === named.reference.id;
179
+ }
180
+ function judgeNamedReference(named, stored) {
181
+ if (sameTarget(named, stored) && named.reference) {
182
+ return {
183
+ reference: named.reference,
184
+ field: named.field,
185
+ chosen: false,
186
+ configured: named.configured
187
+ };
188
+ }
189
+ if (!named.reference || !named.configured) {
190
+ throw redirectRefusal(
191
+ "Choose a page to redirect to, or pick a different confirmation.",
192
+ named.field
193
+ );
194
+ }
195
+ return {
196
+ reference: named.reference,
197
+ field: named.field,
198
+ chosen: true,
199
+ configured: true
200
+ };
201
+ }
202
+ function redirectReferenceForWrite(context, patterns) {
203
+ const stored = storedRedirectReference(
204
+ context.originalData,
205
+ patterns
206
+ );
207
+ const named = namedRedirectReference(
208
+ context.data,
209
+ context.operation,
210
+ patterns
211
+ );
212
+ if (named) return judgeNamedReference(named, stored);
213
+ const data = context.data;
214
+ if (data?.settings !== void 0) return null;
215
+ return stored ? { ...stored, chosen: false } : null;
216
+ }
217
+ function assertPatternBuildsUrl(patterns, reference, field, target) {
218
+ let url2;
219
+ try {
220
+ url2 = applyRedirectPattern(patterns[reference.collection], target);
221
+ } catch {
222
+ return;
223
+ }
224
+ if (url2) return;
225
+ throw redirectRefusal(
226
+ `No URL could be built for that page from the pattern configured for "${reference.collection}". Choose another page, or ask a developer to check that collection's redirect pattern.`,
227
+ field
228
+ );
229
+ }
230
+ async function assertRedirectTargetUsable(context, patterns, localization) {
231
+ const write = redirectReferenceForWrite(context, patterns);
232
+ if (!write) return;
233
+ const { reference, field, chosen, configured } = write;
234
+ if (!configured) {
235
+ if (publishesForm(
236
+ context.data,
237
+ context.originalData
238
+ )) {
239
+ throw redirectRefusal(
240
+ `This form redirects to "${reference.collection}", which is no longer configured as a redirect target, so it has nowhere to send anyone. Choose another page, or pick a different confirmation.`,
241
+ field
242
+ );
243
+ }
244
+ return;
245
+ }
246
+ const nextly = context.req?.nextly;
247
+ if (!nextly) return;
248
+ if (context.executor) return;
249
+ const found = await readRedirectTarget(nextly, reference);
250
+ if (found.status === "unreadable") return;
251
+ if (found.status === "missing") {
252
+ if (!chosen && !publishesForm(
253
+ context.data,
254
+ context.originalData
255
+ )) {
256
+ return;
257
+ }
258
+ throw redirectRefusal(
259
+ `That page no longer exists in "${reference.collection}". Pick another.`,
260
+ field
261
+ );
262
+ }
263
+ const judged = chosen || publishesForm(
264
+ context.data,
265
+ context.originalData
266
+ );
267
+ if (judged && wouldStrandVisitors(
268
+ context.data,
269
+ context.originalData,
270
+ found.document,
271
+ localization[reference.collection]
272
+ )) {
273
+ throw redirectRefusal(
274
+ `That page is not published yet, and this form is. A visitor sent there would reach a "page not found". Publish the page, or save this form as a draft until you do.`,
275
+ field
276
+ );
277
+ }
278
+ if (chosen)
279
+ assertPatternBuildsUrl(patterns, reference, field, found.document);
280
+ }
281
+ async function readRedirectTarget(nextly, reference) {
282
+ try {
283
+ const row = await nextly.findByID({
284
+ collection: reference.collection,
285
+ id: reference.id
286
+ });
287
+ return row ? { status: "ok", document: row } : { status: "missing" };
288
+ } catch (error) {
289
+ return isMissingTarget(error) ? { status: "missing" } : { status: "unreadable" };
290
+ }
291
+ }
292
+ function isMissingTarget(error) {
293
+ return typeof error === "object" && error !== null && error.code === "NOT_FOUND";
294
+ }
295
+ function stampFirstPublish(context) {
296
+ const data = context.data;
297
+ if (!data) return;
298
+ const stored = context.originalData;
299
+ delete data.wentLiveAt;
300
+ if (recordedStamp(stored) !== void 0) return;
301
+ if (data.status === "published" || stored?.status === "published") {
302
+ data.wentLiveAt = /* @__PURE__ */ new Date();
303
+ }
304
+ }
305
+ function recordedStamp(stored) {
306
+ const value = stored?.wentLiveAt;
307
+ return value === null || value === "" ? void 0 : value;
308
+ }
309
+ function redirectRefusal(message, field) {
310
+ return NextlyError.validation({
311
+ errors: [{ path: `settings.${field}`, code: "REQUIRED", message }]
312
+ });
313
+ }
46
314
  function formsCollection(pluginConfig) {
47
315
  const {
48
316
  slug,
49
317
  labels,
50
318
  fields: additionalFields,
51
319
  access: accessOverrides,
320
+ hooks: hookOverrides,
321
+ admin: adminOverrides,
52
322
  ...overrides
53
323
  } = pluginConfig.formOverrides;
324
+ const redirectCollections = Object.keys(pluginConfig.redirectRelationships);
54
325
  const settingsFields = [
55
326
  text({
56
327
  name: "submitButtonText",
@@ -67,7 +338,7 @@ function formsCollection(pluginConfig) {
67
338
  options: [
68
339
  { label: "Show Message", value: "message" },
69
340
  { label: "Redirect to URL", value: "redirect" },
70
- ...pluginConfig.redirectRelationships.length > 0 ? [{ label: "Redirect to Page", value: "relationship" }] : []
341
+ ...redirectCollections.length > 0 ? [{ label: "Redirect to Page", value: "relationship" }] : []
71
342
  ],
72
343
  admin: {
73
344
  description: "What happens after a successful form submission"
@@ -97,12 +368,12 @@ function formsCollection(pluginConfig) {
97
368
  }
98
369
  })
99
370
  ];
100
- if (pluginConfig.redirectRelationships.length > 0) {
371
+ if (redirectCollections.length > 0) {
101
372
  settingsFields.push(
102
373
  relationship({
103
374
  name: "redirectPage",
104
375
  label: "Redirect Page",
105
- relationTo: pluginConfig.redirectRelationships,
376
+ relationTo: redirectCollections,
106
377
  admin: {
107
378
  description: "Select a page to redirect to after submission",
108
379
  condition: {
@@ -224,6 +495,19 @@ function formsCollection(pluginConfig) {
224
495
  description: "Only published forms can receive submissions. Closed forms display a message instead."
225
496
  }
226
497
  }),
498
+ // NOT `firstPublishedAt`, which is the obvious name and the one to reach
499
+ // for next. The framework owns that name on collections it manages the
500
+ // publish lifecycle for, and a write naming it from a plugin collection is
501
+ // stripped on the way to the insert — silently, leaving the column null and
502
+ // every closed form reading as one that was never public.
503
+ date({
504
+ name: "wentLiveAt",
505
+ label: "First Published",
506
+ admin: {
507
+ readOnly: true,
508
+ description: "When this form first went live. Written once and never changed."
509
+ }
510
+ }),
227
511
  textarea({
228
512
  name: "closedMessage",
229
513
  label: "Closed Form Message",
@@ -265,49 +549,75 @@ function formsCollection(pluginConfig) {
265
549
  }
266
550
  }
267
551
  },
268
- ...overrides.admin
552
+ ...adminOverrides
269
553
  },
270
554
  // 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, "");
555
+ // `read: true` — a form is public because a site renders it. The rest are
556
+ // the shared defaults.
557
+ access: accessWithDefaults(accessOverrides, { read: true }),
558
+ hooks: withHostHooks(
559
+ {
560
+ // Auto-generate slug from name if not provided
561
+ beforeValidate: [
562
+ (context) => {
563
+ const { data, operation } = context;
564
+ if (operation === "create" && data && !data.slug && data.name) {
565
+ data.slug = String(data.name).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
566
+ }
567
+ const setsFields = operation === "create" || data?.fields !== void 0;
568
+ if (data && setsFields && (data.fields === void 0 || Array.isArray(data.fields) && data.fields.length === 0)) {
569
+ throw NextlyError.validation({
570
+ errors: [
571
+ {
572
+ path: "fields",
573
+ code: "REQUIRED",
574
+ message: "Form must have at least one field."
575
+ }
576
+ ]
577
+ });
578
+ }
579
+ namedRedirectReference(
580
+ data,
581
+ operation,
582
+ pluginConfig.redirectRelationships
583
+ );
584
+ return data;
287
585
  }
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
- });
586
+ ]
587
+ },
588
+ hookOverrides,
589
+ {
590
+ // The last COLLECTION-level phase, after every host handler in it. A
591
+ // host `beforeValidate` may rewrite the payload once the earlier call
592
+ // has passed, so the check has to run again here.
593
+ //
594
+ // It is not the last mutating point in the write, and saying so would
595
+ // be false: `collection-mutation-service` runs FIELD-level
596
+ // `beforeChange` hooks after this one, and a host may contribute a
597
+ // field through `formOverrides.fields`. Nothing collection-level can
598
+ // sit after that — the next steps are hashing and the insert. What
599
+ // bounds the consequence is the submit path, which resolves an
600
+ // unusable target to NO redirect and logs it, never to a wrong one.
601
+ beforeChange: [
602
+ async (context) => {
603
+ await assertRedirectTargetUsable(
604
+ context,
605
+ pluginConfig.redirectRelationships,
606
+ pluginConfig.redirectTargetLocalization
607
+ );
608
+ stampFirstPublish(context);
609
+ return context.data;
299
610
  }
300
- return data;
301
- }
302
- ]
303
- },
611
+ ]
612
+ }
613
+ ),
304
614
  // Spread any additional overrides (excluding already used properties)
305
615
  ...overrides
306
616
  };
307
617
  }
308
618
 
309
619
  // src/collections/submissions.ts
310
- import { text as text2, textarea as textarea2, select as select2, json as json2, date, relationship as relationship2 } from "nextly";
620
+ import { text as text2, textarea as textarea2, select as select2, json as json2, date as date2, relationship as relationship2 } from "nextly";
311
621
  function submissionsCollection(pluginConfig) {
312
622
  const {
313
623
  slug,
@@ -400,7 +710,7 @@ function submissionsCollection(pluginConfig) {
400
710
  description: "Browser/device information of the submitter"
401
711
  }
402
712
  }),
403
- date({
713
+ date2({
404
714
  name: "submittedAt",
405
715
  label: "Submitted At",
406
716
  required: true,
@@ -411,7 +721,7 @@ function submissionsCollection(pluginConfig) {
411
721
  }),
412
722
  // Honesty stamps for admin edits of submitted data: a corrected
413
723
  // submission must never be indistinguishable from what the visitor sent.
414
- date({
724
+ date2({
415
725
  name: "editedAt",
416
726
  label: "Edited At",
417
727
  admin: {
@@ -475,16 +785,9 @@ function submissionsCollection(pluginConfig) {
475
785
  },
476
786
  // Access control with sensible defaults
477
787
  // 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
- },
788
+ // `create: true` — a visitor submits without an account. Reading, updating
789
+ // and deleting are the shared defaults.
790
+ access: accessWithDefaults(accessOverrides, { create: true }),
488
791
  hooks: {
489
792
  // Auto-set submittedAt timestamp on creation. (The edit stamp for
490
793
  // updates is registered in plugin.ts init — direct registry hooks are
@@ -560,7 +863,13 @@ function resolveConfig(options) {
560
863
  time: options.fields?.time ?? true,
561
864
  hidden: options.fields?.hidden ?? true
562
865
  },
563
- redirectRelationships: options.redirectRelationships || [],
866
+ redirectRelationships: normalizeRedirectRelationships(
867
+ options.redirectRelationships
868
+ ),
869
+ // Filled at `init` from each collection's own metadata. Empty until then,
870
+ // and a slug that stays absent reads as unknown rather than as not
871
+ // localized — see `ResolvedFormBuilderConfig`.
872
+ redirectTargetLocalization: {},
564
873
  beforeEmail: options.beforeEmail,
565
874
  notifications: {
566
875
  defaultFrom: options.notifications?.defaultFrom,
@@ -750,6 +1059,12 @@ function formBuilder(options = {}) {
750
1059
  // Runtime-resolved collection slugs (through ctx.self, so a
751
1060
  // framework .rename() is honored too) — admin components
752
1061
  // never hardcode "forms"/"form-submissions".
1062
+ // Names only. A pattern may be a function, which does not
1063
+ // survive JSON and which the browser has no use for — the
1064
+ // admin picks a document, the server builds the URL.
1065
+ redirectCollections: Object.keys(
1066
+ resolvedConfig.redirectRelationships
1067
+ ),
753
1068
  slugs: {
754
1069
  forms: ctx.self.collections[resolvedConfig.formOverrides.slug] ?? resolvedConfig.formOverrides.slug,
755
1070
  submissions: ctx.self.collections[resolvedConfig.formSubmissionOverrides.slug] ?? resolvedConfig.formSubmissionOverrides.slug
@@ -776,7 +1091,7 @@ function formBuilder(options = {}) {
776
1091
  },
777
1092
  // -- Init ----------------------------------------------------------------
778
1093
  // Registers an afterCreate hook on submissions to send email notifications.
779
- init(nextly) {
1094
+ async init(nextly) {
780
1095
  const declaredSubmissionsSlug = resolvedConfig.formSubmissionOverrides.slug;
781
1096
  const submissionSlug = nextly.self.collections[declaredSubmissionsSlug] ?? declaredSubmissionsSlug;
782
1097
  nextly.logger.info("Form Builder plugin initialized", {
@@ -784,6 +1099,30 @@ function formBuilder(options = {}) {
784
1099
  submissionsCollection: submissionSlug,
785
1100
  enabledFields: Object.entries(resolvedConfig.fields).filter(([, on]) => on).map(([name]) => name)
786
1101
  });
1102
+ for (const slug of Object.keys(
1103
+ resolvedConfig.redirectTargetLocalization
1104
+ )) {
1105
+ delete resolvedConfig.redirectTargetLocalization[slug];
1106
+ }
1107
+ await Promise.all(
1108
+ Object.keys(resolvedConfig.redirectRelationships).map(
1109
+ async (declared) => {
1110
+ const slug = nextly.self.collections[declared] ?? declared;
1111
+ try {
1112
+ const collection = await nextly.services.collections.getCollection(slug, {});
1113
+ const localized = collection?.localized;
1114
+ if (typeof localized === "boolean") {
1115
+ resolvedConfig.redirectTargetLocalization[declared] = localized;
1116
+ }
1117
+ } catch {
1118
+ nextly.logger.debug?.(
1119
+ "Redirect target localization could not be read",
1120
+ { collection: slug }
1121
+ );
1122
+ }
1123
+ }
1124
+ )
1125
+ );
787
1126
  nextly.__formBuilderConfig = resolvedConfig;
788
1127
  if (resolvedConfig.beforeEmail) {
789
1128
  nextly.filters.add(
@@ -850,9 +1189,9 @@ function getFormBuilderConfig(nextly) {
850
1189
  return nextly.__formBuilderConfig;
851
1190
  }
852
1191
  function resolveFieldRef(ref, data) {
853
- const match = ref.match(/^\{\{(\w+)\}\}$/);
854
- if (!match) return ref;
855
- const value = data[match[1]];
1192
+ const name = parseFieldRef(ref);
1193
+ if (name === null) return ref;
1194
+ const value = data[name];
856
1195
  return typeof value === "string" ? value : "";
857
1196
  }
858
1197
  function collectAttachmentInputs(fields, submittedData) {
@@ -1351,6 +1690,17 @@ function validateSettings(settings, errors) {
1351
1690
  });
1352
1691
  }
1353
1692
  }
1693
+ if (s.confirmationType === "relationship" && !namesADocument(s.redirectPage)) {
1694
+ errors.push({
1695
+ path: "settings.redirectPage",
1696
+ message: "A page is required when confirmation type is 'relationship'",
1697
+ code: "REDIRECT_PAGE_REQUIRED"
1698
+ });
1699
+ }
1700
+ }
1701
+ function namesADocument(value) {
1702
+ if (typeof value === "string") return value.length > 0;
1703
+ return parseRedirectReference(value, []) !== null;
1354
1704
  }
1355
1705
  function validateAccess(access, errors) {
1356
1706
  if (!access) {
@@ -1444,7 +1794,7 @@ var file = (config) => ({
1444
1794
  ...config,
1445
1795
  type: "file"
1446
1796
  });
1447
- var date2 = (config) => ({
1797
+ var date3 = (config) => ({
1448
1798
  ...config,
1449
1799
  type: "date"
1450
1800
  });
@@ -1512,53 +1862,74 @@ function generateFieldSchema(field) {
1512
1862
  return z.unknown();
1513
1863
  }
1514
1864
  }
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
- );
1528
- }
1529
- if (field.validation?.pattern) {
1530
- schema = schema.regex(
1531
- new RegExp(field.validation.pattern),
1532
- field.validation.errorMessage || "Invalid format"
1533
- );
1865
+ function customMessage(field) {
1866
+ const enforced = ENFORCED_VALIDATION_RULES[field.type] ?? [];
1867
+ if (!enforced.includes("message")) return void 0;
1868
+ return field.validation?.errorMessage;
1869
+ }
1870
+ function enforcedValidation(field) {
1871
+ const stored = field.validation ?? {};
1872
+ return { ...stored, errorMessage: customMessage(field) };
1873
+ }
1874
+ var STRING_RULE_APPLIERS = {
1875
+ minLength: (schema, validation) => validation.minLength === void 0 ? schema : schema.min(
1876
+ validation.minLength,
1877
+ validation.errorMessage || `Minimum ${validation.minLength} characters required`
1878
+ ),
1879
+ maxLength: (schema, validation) => validation.maxLength === void 0 ? schema : schema.max(
1880
+ validation.maxLength,
1881
+ validation.errorMessage || `Maximum ${validation.maxLength} characters allowed`
1882
+ ),
1883
+ pattern: (schema, validation, fallback) => validation.pattern ? schema.regex(
1884
+ new RegExp(validation.pattern),
1885
+ validation.errorMessage || fallback
1886
+ ) : schema
1887
+ };
1888
+ var NUMBER_RULE_APPLIERS = {
1889
+ min: (schema, validation) => validation.min === void 0 ? schema : schema.min(
1890
+ validation.min,
1891
+ validation.errorMessage || `Minimum value is ${validation.min}`
1892
+ ),
1893
+ max: (schema, validation) => validation.max === void 0 ? schema : schema.max(
1894
+ validation.max,
1895
+ validation.errorMessage || `Maximum value is ${validation.max}`
1896
+ )
1897
+ };
1898
+ function overridesPattern(field) {
1899
+ const enforced = ENFORCED_VALIDATION_RULES[field.type] ?? [];
1900
+ if (!enforced.includes("pattern")) return false;
1901
+ const validation = field.validation ?? {};
1902
+ return Boolean(validation.pattern);
1903
+ }
1904
+ function applyStringRules(schema, field, patternMessage) {
1905
+ const validation = enforcedValidation(field);
1906
+ let constrained = schema;
1907
+ for (const rule of ENFORCED_VALIDATION_RULES[field.type] ?? []) {
1908
+ const apply = STRING_RULE_APPLIERS[rule];
1909
+ if (apply) constrained = apply(constrained, validation, patternMessage);
1534
1910
  }
1535
- return applyRequired(schema, field.required, field.validation?.errorMessage);
1911
+ return constrained;
1912
+ }
1913
+ function generateTextSchema(field) {
1914
+ const schema = applyStringRules(z.string(), field, "Invalid format");
1915
+ return applyRequired(schema, field.required, customMessage(field));
1536
1916
  }
1537
1917
  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
- );
1544
- }
1545
- return applyRequired(schema, field.required, field.validation?.errorMessage);
1918
+ const schema = applyStringRules(
1919
+ z.string().email(customMessage(field) || "Invalid email address"),
1920
+ field,
1921
+ "Invalid email format"
1922
+ );
1923
+ return applyRequired(schema, field.required, customMessage(field));
1546
1924
  }
1547
1925
  function generateNumberSchema(field) {
1548
1926
  let schema = z.number({
1549
- error: (issue) => issue.input === void 0 ? field.validation?.errorMessage || "This field is required" : "Must be a number"
1927
+ error: (issue) => issue.input === void 0 ? customMessage(field) || "This field is required" : "Must be a number"
1550
1928
  });
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
- );
1556
- }
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
- );
1929
+ const validation = enforcedValidation(field);
1930
+ for (const rule of ENFORCED_VALIDATION_RULES[field.type] ?? []) {
1931
+ const apply = NUMBER_RULE_APPLIERS[rule];
1932
+ if (apply) schema = apply(schema, validation);
1562
1933
  }
1563
1934
  if (field.required) {
1564
1935
  return schema;
@@ -1566,45 +1937,24 @@ function generateNumberSchema(field) {
1566
1937
  return schema.optional().nullable();
1567
1938
  }
1568
1939
  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
- );
1580
- }
1581
- return applyRequired(schema, field.required, field.validation?.errorMessage);
1940
+ const base = overridesPattern(field) ? z.string() : z.string().regex(
1941
+ /^[\d\s\-+()]+$/,
1942
+ customMessage(field) || "Invalid phone number"
1943
+ );
1944
+ const schema = applyStringRules(base, field, "Invalid phone number format");
1945
+ return applyRequired(schema, field.required, customMessage(field));
1582
1946
  }
1583
1947
  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
- );
1590
- }
1591
- return applyRequired(schema, field.required, field.validation?.errorMessage);
1948
+ const schema = applyStringRules(
1949
+ z.string().url(customMessage(field) || "Invalid URL"),
1950
+ field,
1951
+ "Invalid URL format"
1952
+ );
1953
+ return applyRequired(schema, field.required, customMessage(field));
1592
1954
  }
1593
1955
  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
- );
1600
- }
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
- );
1606
- }
1607
- return applyRequired(schema, field.required, field.validation?.errorMessage);
1956
+ const schema = applyStringRules(z.string(), field, "Invalid format");
1957
+ return applyRequired(schema, field.required, customMessage(field));
1608
1958
  }
1609
1959
  function generateSelectSchema(field) {
1610
1960
  const validValues = field.options.map((opt) => opt.value);
@@ -1613,13 +1963,13 @@ function generateSelectSchema(field) {
1613
1963
  if (field.required) {
1614
1964
  return schema2.min(
1615
1965
  1,
1616
- field.validation?.errorMessage || "Please select at least one option"
1966
+ customMessage(field) || "Please select at least one option"
1617
1967
  );
1618
1968
  }
1619
1969
  return schema2.optional();
1620
1970
  }
1621
1971
  const schema = z.enum(validValues, {
1622
- error: () => field.validation?.errorMessage || "Please select a valid option"
1972
+ error: () => customMessage(field) || "Please select a valid option"
1623
1973
  });
1624
1974
  if (field.required) {
1625
1975
  return schema;
@@ -1630,7 +1980,7 @@ function generateCheckboxSchema(field) {
1630
1980
  const schema = z.boolean();
1631
1981
  if (field.required) {
1632
1982
  return schema.refine((val) => val === true, {
1633
- message: field.validation?.errorMessage || "This field is required"
1983
+ message: customMessage(field) || "This field is required"
1634
1984
  });
1635
1985
  }
1636
1986
  return schema.optional();
@@ -1638,7 +1988,7 @@ function generateCheckboxSchema(field) {
1638
1988
  function generateRadioSchema(field) {
1639
1989
  const validValues = field.options.map((opt) => opt.value);
1640
1990
  const schema = z.enum(validValues, {
1641
- error: () => field.validation?.errorMessage || "Please select an option"
1991
+ error: () => customMessage(field) || "Please select an option"
1642
1992
  });
1643
1993
  if (field.required) {
1644
1994
  return schema;
@@ -1651,42 +2001,42 @@ function generateFileSchema(field) {
1651
2001
  if (field.required) {
1652
2002
  return schema2.min(
1653
2003
  1,
1654
- field.validation?.errorMessage || "Please upload at least one file"
2004
+ customMessage(field) || "Please upload at least one file"
1655
2005
  );
1656
2006
  }
1657
2007
  return schema2.optional();
1658
2008
  }
1659
2009
  const schema = z.string();
1660
- return applyRequired(schema, field.required, field.validation?.errorMessage);
2010
+ return applyRequired(schema, field.required, customMessage(field));
1661
2011
  }
1662
2012
  function generateDateSchema(field) {
1663
2013
  let schema = z.string();
1664
2014
  schema = schema.refine(
1665
2015
  (val) => {
1666
2016
  if (!val) return true;
1667
- const date3 = new Date(val);
1668
- return !isNaN(date3.getTime());
2017
+ const date4 = new Date(val);
2018
+ return !isNaN(date4.getTime());
1669
2019
  },
1670
- { message: field.validation?.errorMessage || "Invalid date" }
2020
+ { message: customMessage(field) || "Invalid date" }
1671
2021
  );
1672
2022
  if (field.min || field.max) {
1673
2023
  schema = schema.refine(
1674
2024
  (val) => {
1675
2025
  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;
2026
+ const date4 = new Date(val);
2027
+ if (field.min && date4 < new Date(field.min)) return false;
2028
+ if (field.max && date4 > new Date(field.max)) return false;
1679
2029
  return true;
1680
2030
  },
1681
2031
  {
1682
- message: field.validation?.errorMessage || `Date must be between ${field.min || "any"} and ${field.max || "any"}`
2032
+ message: customMessage(field) || `Date must be between ${field.min || "any"} and ${field.max || "any"}`
1683
2033
  }
1684
2034
  );
1685
2035
  }
1686
2036
  if (field.required) {
1687
2037
  return schema.refine(
1688
2038
  (val) => val !== void 0 && val !== null && val !== "",
1689
- { message: field.validation?.errorMessage || "This field is required" }
2039
+ { message: customMessage(field) || "This field is required" }
1690
2040
  );
1691
2041
  }
1692
2042
  return schema.optional().or(z.literal(""));
@@ -1694,13 +2044,13 @@ function generateDateSchema(field) {
1694
2044
  function generateTimeSchema(field) {
1695
2045
  const schema = z.string().regex(
1696
2046
  /^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/,
1697
- field.validation?.errorMessage || "Invalid time format (HH:mm)"
2047
+ customMessage(field) || "Invalid time format (HH:mm)"
1698
2048
  );
1699
- return applyRequired(schema, field.required, field.validation?.errorMessage);
2049
+ return applyRequired(schema, field.required, customMessage(field));
1700
2050
  }
1701
2051
  function generateHiddenSchema(field) {
1702
2052
  const schema = z.string();
1703
- return applyRequired(schema, field.required, field.validation?.errorMessage);
2053
+ return applyRequired(schema, field.required, customMessage(field));
1704
2054
  }
1705
2055
  function applyRequired(schema, required, errorMessage) {
1706
2056
  if (required) {
@@ -1775,6 +2125,9 @@ function getValidationErrors(result) {
1775
2125
  return errors;
1776
2126
  }
1777
2127
 
2128
+ // src/handlers/submit-form.ts
2129
+ import { formAvailability, NO_SUCH_FORM } from "nextly";
2130
+
1778
2131
  // src/handlers/spam-detection.ts
1779
2132
  var rateLimitStore = /* @__PURE__ */ new Map();
1780
2133
  async function checkSpam(options) {
@@ -1899,19 +2252,18 @@ async function submitForm(options, context) {
1899
2252
  logger.warn?.("Form submission attempted for non-existent form", {
1900
2253
  formSlug
1901
2254
  });
1902
- return {
1903
- success: false,
1904
- error: "Form not found"
1905
- };
2255
+ return { success: false, error: NO_SUCH_FORM };
1906
2256
  }
1907
- if (form.status !== "published") {
1908
- logger.info?.("Form submission rejected - form not published", {
2257
+ const availability = formAvailability(form);
2258
+ if (availability.kind !== "open") {
2259
+ logger.info?.("Form submission rejected - form not accepting", {
1909
2260
  formSlug,
1910
- status: form.status
2261
+ status: form.status,
2262
+ availability: availability.kind
1911
2263
  });
1912
2264
  return {
1913
2265
  success: false,
1914
- error: "This form is not currently accepting submissions"
2266
+ error: availability.kind === "closed" ? availability.message : NO_SUCH_FORM
1915
2267
  };
1916
2268
  }
1917
2269
  const settings = normalizeFormSettings(form.settings);
@@ -2026,7 +2378,11 @@ async function submitForm(options, context) {
2026
2378
  warnings: submission.warnings
2027
2379
  });
2028
2380
  }
2029
- const redirect = determineRedirectUrl(form);
2381
+ const redirect = await resolveRedirectUrl(
2382
+ form,
2383
+ pluginConfig,
2384
+ pluginContext
2385
+ );
2030
2386
  if (isContentSpam) {
2031
2387
  return { success: true, redirect };
2032
2388
  }
@@ -2064,21 +2420,100 @@ async function fetchFormBySlug(slug, pluginConfig, pluginContext) {
2064
2420
  return null;
2065
2421
  }
2066
2422
  }
2067
- function determineRedirectUrl(form) {
2068
- if (!form.settings) {
2423
+ async function resolveRedirectUrl(form, pluginConfig, pluginContext) {
2424
+ const settings = form.settings;
2425
+ if (!settings) return void 0;
2426
+ if (settings.confirmationType === "redirect" && settings.redirectUrl) {
2427
+ return settings.redirectUrl;
2428
+ }
2429
+ const field = pickedDocumentField(settings);
2430
+ if (!field) return void 0;
2431
+ return urlForPickedDocument(
2432
+ settings[field],
2433
+ form,
2434
+ pluginConfig,
2435
+ pluginContext
2436
+ );
2437
+ }
2438
+ async function urlForPickedDocument(stored, form, pluginConfig, pluginContext) {
2439
+ const { logger } = pluginContext;
2440
+ const patterns = pluginConfig.redirectRelationships;
2441
+ const reference = parseRedirectReference(stored, Object.keys(patterns));
2442
+ if (!reference) {
2443
+ logger.warn?.("Form redirects to a page, but names no readable document", {
2444
+ form: form.slug
2445
+ });
2446
+ return void 0;
2447
+ }
2448
+ const pattern = patterns[reference.collection];
2449
+ if (!pattern) {
2450
+ logger.warn?.(
2451
+ "Form redirects to a collection that is not in redirectRelationships",
2452
+ { form: form.slug, collection: reference.collection }
2453
+ );
2454
+ return void 0;
2455
+ }
2456
+ const target = await readTarget(reference, form, pluginContext);
2457
+ if (!target) return void 0;
2458
+ if (documentReachability(
2459
+ target,
2460
+ pluginConfig.redirectTargetLocalization[reference.collection]
2461
+ ) === "unreachable") {
2462
+ logger.warn?.("Form redirects to a page that has never been published", {
2463
+ form: form.slug,
2464
+ collection: reference.collection,
2465
+ target: reference.id
2466
+ });
2069
2467
  return void 0;
2070
2468
  }
2071
- if (form.settings.confirmationType !== "redirect") {
2469
+ return buildUrl(pattern, target, reference.collection, form, pluginContext);
2470
+ }
2471
+ function buildUrl(pattern, target, collection, form, pluginContext) {
2472
+ const { logger } = pluginContext;
2473
+ try {
2474
+ const url2 = applyRedirectPattern(pattern, target);
2475
+ if (!url2) {
2476
+ logger.warn?.(
2477
+ "Form redirect pattern could not be filled from the target document",
2478
+ { form: form.slug, collection }
2479
+ );
2480
+ }
2481
+ return url2;
2482
+ } catch (error) {
2483
+ logger.warn?.("Form redirect pattern threw while building a URL", {
2484
+ form: form.slug,
2485
+ collection,
2486
+ error: error instanceof Error ? error.message : String(error)
2487
+ });
2072
2488
  return void 0;
2073
2489
  }
2074
- if (form.settings.redirectUrl) {
2075
- return form.settings.redirectUrl;
2490
+ }
2491
+ async function readTarget(reference, form, pluginContext) {
2492
+ const { logger } = pluginContext;
2493
+ const context = {
2494
+ form: form.slug,
2495
+ collection: reference.collection,
2496
+ id: reference.id
2497
+ };
2498
+ let row;
2499
+ try {
2500
+ row = await pluginContext.services.collections.findEntryById(
2501
+ reference.collection,
2502
+ reference.id,
2503
+ { as: "system" }
2504
+ );
2505
+ } catch (error) {
2506
+ logger.warn?.("Form redirect target could not be read", {
2507
+ ...context,
2508
+ error: error instanceof Error ? error.message : String(error)
2509
+ });
2510
+ return void 0;
2076
2511
  }
2077
- if (form.settings.redirectRelation) {
2078
- const { relationTo, value } = form.settings.redirectRelation;
2079
- return `relationship://${relationTo}/${value}`;
2512
+ if (!row || typeof row !== "object") {
2513
+ logger.warn?.("Form redirect target no longer exists", context);
2514
+ return void 0;
2080
2515
  }
2081
- return void 0;
2516
+ return row;
2082
2517
  }
2083
2518
  async function validateSubmission(formSlug, data, context) {
2084
2519
  const { pluginContext, pluginConfig } = context;
@@ -2352,7 +2787,7 @@ export {
2352
2787
  cleanupRateLimitStore,
2353
2788
  clearRateLimitStore,
2354
2789
  createFormConfig,
2355
- date2 as date,
2790
+ date3 as date,
2356
2791
  downloadFile,
2357
2792
  email,
2358
2793
  evaluateConditions,