@nextlyhq/plugin-form-builder 0.0.2-alpha.62 → 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
@@ -31,11 +31,14 @@ import {
31
31
  pickedDocumentField,
32
32
  pluralize,
33
33
  toTitleCase
34
- } from "./chunk-H5X3FKOZ.js";
34
+ } from "./chunk-W6NLZFHH.js";
35
35
 
36
36
  // src/plugin.ts
37
37
  import { createRequire } from "module";
38
- import { definePlugin } from "@nextlyhq/plugin-sdk";
38
+ import {
39
+ definePlugin,
40
+ NextlyError as NextlyError2
41
+ } from "@nextlyhq/plugin-sdk";
39
42
 
40
43
  // src/collections/forms.ts
41
44
  import { NextlyError } from "@nextlyhq/plugin-sdk";
@@ -824,241 +827,622 @@ function asFormDocument(row) {
824
827
  return row;
825
828
  }
826
829
 
827
- // src/plugin.ts
828
- var { version: PLUGIN_VERSION } = createRequire(import.meta.url)(
829
- "../package.json"
830
- );
831
- function resolveConfig(options) {
832
- const formOverrides = options.formOverrides || {};
833
- const submissionOverrides = options.formSubmissionOverrides || {};
834
- return {
835
- formOverrides: {
836
- ...formOverrides,
837
- slug: formOverrides.slug || "forms",
838
- labels: {
839
- singular: formOverrides.labels?.singular || "Form",
840
- plural: formOverrides.labels?.plural || "Forms"
841
- }
842
- },
843
- formSubmissionOverrides: {
844
- ...submissionOverrides,
845
- slug: submissionOverrides.slug || "form-submissions",
846
- labels: {
847
- singular: submissionOverrides.labels?.singular || "Submission",
848
- plural: submissionOverrides.labels?.plural || "Submissions"
849
- }
850
- },
851
- fields: {
852
- text: options.fields?.text ?? true,
853
- email: options.fields?.email ?? true,
854
- number: options.fields?.number ?? true,
855
- phone: options.fields?.phone ?? true,
856
- url: options.fields?.url ?? true,
857
- textarea: options.fields?.textarea ?? true,
858
- select: options.fields?.select ?? true,
859
- checkbox: options.fields?.checkbox ?? true,
860
- radio: options.fields?.radio ?? true,
861
- file: options.fields?.file ?? true,
862
- date: options.fields?.date ?? true,
863
- time: options.fields?.time ?? true,
864
- hidden: options.fields?.hidden ?? true
865
- },
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: {},
873
- beforeEmail: options.beforeEmail,
874
- notifications: {
875
- defaultFrom: options.notifications?.defaultFrom,
876
- defaultToEmail: options.notifications?.defaultToEmail,
877
- enabled: options.notifications?.enabled ?? true
878
- },
879
- spamProtection: {
880
- honeypot: options.spamProtection?.honeypot ?? true,
881
- recaptcha: options.spamProtection?.recaptcha ?? { enabled: false },
882
- rateLimit: {
883
- maxSubmissions: options.spamProtection?.rateLimit?.maxSubmissions ?? 10,
884
- windowMs: options.spamProtection?.rateLimit?.windowMs ?? 6e4
885
- }
886
- },
887
- uploads: {
888
- maxFileSize: options.uploads?.maxFileSize ?? 10485760,
889
- // 10 MB
890
- allowedMimeTypes: options.uploads?.allowedMimeTypes ?? [
891
- "image/*",
892
- "application/pdf",
893
- "text/*"
894
- ],
895
- uploadCollection: options.uploads?.uploadCollection ?? "media"
896
- },
897
- features: {
898
- builder: options.features?.builder ?? true,
899
- conditionalLogic: options.features?.conditionalLogic ?? true,
900
- 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;
901
844
  }
902
- };
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);
903
855
  }
904
- function formBuilder(options = {}) {
905
- const resolvedConfig = resolveConfig(options);
906
- const formsCol = formsCollection(resolvedConfig);
907
- const submissionsCol = submissionsCollection(resolvedConfig);
908
- const plugin = definePlugin({
909
- name: "@nextlyhq/plugin-form-builder",
910
- version: PLUGIN_VERSION,
911
- nextly: ">=0.0.2-alpha.21",
912
- // Identity metadata for the admin plugins page, mirroring package.json.
913
- author: "Nextly",
914
- homepage: "https://nextlyhq.com",
915
- repository: "https://github.com/nextlyhq/nextly",
916
- license: "MIT",
917
- category: "forms",
918
- tags: ["forms", "submissions", "email-notifications"],
919
- // Declarative schema: the merged pipeline folds these into the
920
- // app schema — no manual `setup()` append needed. Just register the plugin.
921
- contributes: {
922
- collections: [formsCol, submissionsCol],
923
- // Custom permission — gates submission export beyond CRUD. The
924
- // canonical example third-party plugin authors copy.
925
- permissions: [
926
- {
927
- action: "export",
928
- resource: "submissions",
929
- label: "Export Submissions",
930
- description: "Export form submissions as CSV/JSON",
931
- // No `group`: the admin already files this under the plugin that
932
- // declared it, so naming the plugin again would nest it inside
933
- // itself. `group` is for a plugin with enough permissions to sort
934
- // its own into headings, which one is not.
935
- //
936
- // `danger` because the point of the permission is to take
937
- // submissions — names, emails, whatever a form asked for — out of
938
- // the site in a file.
939
- danger: true
940
- }
941
- ],
942
- // HTTP route — exported at
943
- // /api/plugins/@nextlyhq/plugin-form-builder/submissions/export. Secure by
944
- // default: gated by the custom `export-submissions` permission. Reads
945
- // via the secure-by-default service path as the authed user and
946
- // resolves its OWN slug through `ctx.self`. The canonical
947
- // contributes.routes example for third-party authors.
948
- routes: [
949
- {
950
- method: "GET",
951
- path: "/submissions/export",
952
- requiredPermission: "export-submissions",
953
- handler: async (req, ctx) => {
954
- const declaredSlug = resolvedConfig.formSubmissionOverrides.slug;
955
- const slug = ctx.self.collections[declaredSlug] ?? declaredSlug;
956
- const url2 = new URL(req.url);
957
- const format = url2.searchParams.get("format") ?? "json";
958
- const formId = url2.searchParams.get("form");
959
- const status = url2.searchParams.get("status");
960
- const where = {};
961
- if (formId) where.form = { equals: formId };
962
- if (status) where.status = { equals: status };
963
- else where.status = { not_equals: "spam" };
964
- const opts = { as: "user", user: ctx.user ?? void 0 };
965
- let form;
966
- if (format === "csv") {
967
- if (!formId) {
968
- return Response.json(
969
- {
970
- error: {
971
- code: "VALIDATION_ERROR",
972
- message: "CSV export requires a form parameter."
973
- }
974
- },
975
- { status: 400 }
976
- );
977
- }
978
- const formsSlugDeclared = resolvedConfig.formOverrides.slug;
979
- const formsSlug = ctx.self.collections[formsSlugDeclared] ?? formsSlugDeclared;
980
- const formResult = await ctx.services.collections.listEntries(
981
- formsSlug,
982
- { where: { id: { equals: formId } } },
983
- opts
984
- );
985
- form = formResult.data[0];
986
- if (!form) {
987
- return Response.json(
988
- { error: { code: "NOT_FOUND", message: "Form not found." } },
989
- { status: 404 }
990
- );
991
- }
992
- }
993
- const MAX_EXPORT_ROWS = 5e4;
994
- const items = [];
995
- const pageSize = 200;
996
- let truncated = false;
997
- for (let page = 1; ; page += 1) {
998
- const result = await ctx.services.collections.listEntries(
999
- slug,
1000
- { where, pagination: { limit: pageSize, page } },
1001
- opts
1002
- );
1003
- items.push(...result.data);
1004
- const pageWasFull = result.data.length === pageSize;
1005
- if (items.length >= MAX_EXPORT_ROWS) {
1006
- truncated = pageWasFull || items.length > MAX_EXPORT_ROWS;
1007
- items.length = MAX_EXPORT_ROWS;
1008
- break;
1009
- }
1010
- if (!pageWasFull) break;
1011
- }
1012
- const truncationHeaders = truncated ? { "X-Export-Truncated": "true" } : {};
1013
- if (format !== "csv" || !form) {
1014
- return Response.json({ items }, { headers: truncationHeaders });
1015
- }
1016
- const csv = exportToCSV(
1017
- asSubmissionDocuments(items),
1018
- asFormDocument(form)
1019
- );
1020
- const filename = generateExportFilename(
1021
- typeof form.slug === "string" ? form.slug : "form",
1022
- "csv"
1023
- );
1024
- return new Response(csv, {
1025
- headers: {
1026
- "Content-Type": "text/csv; charset=utf-8",
1027
- "Content-Disposition": `attachment; filename="${filename}"`,
1028
- ...truncationHeaders
1029
- }
1030
- });
1031
- }
1032
- },
1033
- {
1034
- // The builder UI reads the host's resolved field enable/disable map
1035
- // from here, so the plugin option actually gates the type picker.
1036
- // Options resolve server-side only; this is the one channel the
1037
- // admin client has to them.
1038
- method: "GET",
1039
- path: "/builder-config",
1040
- // Derived from the forms slug like the menu entry: with an
1041
- // overridden slug, users hold read-<slug>, not read-forms.
1042
- requiredPermission: `read-${resolvedConfig.formOverrides.slug}`,
1043
- handler: (_req, ctx) => Promise.resolve(
1044
- Response.json({
1045
- fields: resolvedConfig.fields,
1046
- // Notification defaults the builder surfaces: defaultToEmail
1047
- // seeds a new form's first rule, defaultFrom renders as the
1048
- // inherited sender placeholder.
1049
- notifications: {
1050
- defaultFrom: resolvedConfig.notifications.defaultFrom,
1051
- defaultToEmail: resolvedConfig.notifications.defaultToEmail
1052
- },
1053
- // Spam defaults the Settings tab surfaces, so per-form
1054
- // override selects can show what "inherit" resolves to.
1055
- spamProtection: {
1056
- honeypot: resolvedConfig.spamProtection.honeypot,
1057
- recaptchaEnabled: resolvedConfig.spamProtection.recaptcha?.enabled ?? false
1058
- },
1059
- // Runtime-resolved collection slugs (through ctx.self, so a
1060
- // framework .rename() is honored too) — admin components
1061
- // never hardcode "forms"/"form-submissions".
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") {
1351
+ if (!formId) {
1352
+ return Response.json(
1353
+ {
1354
+ error: {
1355
+ code: "VALIDATION_ERROR",
1356
+ message: "CSV export requires a form parameter."
1357
+ }
1358
+ },
1359
+ { status: 400 }
1360
+ );
1361
+ }
1362
+ const formsSlugDeclared = resolvedConfig.formOverrides.slug;
1363
+ const formsSlug = ctx.self.collections[formsSlugDeclared] ?? formsSlugDeclared;
1364
+ const formResult = await ctx.services.collections.listEntries(
1365
+ formsSlug,
1366
+ { where: { id: { equals: formId } } },
1367
+ opts
1368
+ );
1369
+ form = formResult.data[0];
1370
+ if (!form) {
1371
+ return Response.json(
1372
+ { error: { code: "NOT_FOUND", message: "Form not found." } },
1373
+ { status: 404 }
1374
+ );
1375
+ }
1376
+ }
1377
+ const MAX_EXPORT_ROWS = 5e4;
1378
+ const items = [];
1379
+ const pageSize = 200;
1380
+ let truncated = false;
1381
+ for (let page = 1; ; page += 1) {
1382
+ const result = await ctx.services.collections.listEntries(
1383
+ slug,
1384
+ { where, pagination: { limit: pageSize, page } },
1385
+ opts
1386
+ );
1387
+ items.push(...result.data);
1388
+ const pageWasFull = result.data.length === pageSize;
1389
+ if (items.length >= MAX_EXPORT_ROWS) {
1390
+ truncated = pageWasFull || items.length > MAX_EXPORT_ROWS;
1391
+ items.length = MAX_EXPORT_ROWS;
1392
+ break;
1393
+ }
1394
+ if (!pageWasFull) break;
1395
+ }
1396
+ const truncationHeaders = truncated ? { "X-Export-Truncated": "true" } : {};
1397
+ if (format !== "csv" || !form) {
1398
+ return Response.json({ items }, { headers: truncationHeaders });
1399
+ }
1400
+ const csv = exportToCSV(
1401
+ asSubmissionDocuments(items),
1402
+ asFormDocument(form)
1403
+ );
1404
+ const filename = generateExportFilename(
1405
+ typeof form.slug === "string" ? form.slug : "form",
1406
+ "csv"
1407
+ );
1408
+ return new Response(csv, {
1409
+ headers: {
1410
+ "Content-Type": "text/csv; charset=utf-8",
1411
+ "Content-Disposition": `attachment; filename="${filename}"`,
1412
+ ...truncationHeaders
1413
+ }
1414
+ });
1415
+ }
1416
+ },
1417
+ {
1418
+ // The builder UI reads the host's resolved field enable/disable map
1419
+ // from here, so the plugin option actually gates the type picker.
1420
+ // Options resolve server-side only; this is the one channel the
1421
+ // admin client has to them.
1422
+ method: "GET",
1423
+ path: "/builder-config",
1424
+ // Derived from the forms slug like the menu entry: with an
1425
+ // overridden slug, users hold read-<slug>, not read-forms.
1426
+ requiredPermission: `read-${resolvedConfig.formOverrides.slug}`,
1427
+ handler: (_req, ctx) => Promise.resolve(
1428
+ Response.json({
1429
+ fields: resolvedConfig.fields,
1430
+ // Notification defaults the builder surfaces: defaultToEmail
1431
+ // seeds a new form's first rule, defaultFrom renders as the
1432
+ // inherited sender placeholder.
1433
+ notifications: {
1434
+ defaultFrom: resolvedConfig.notifications.defaultFrom,
1435
+ defaultToEmail: resolvedConfig.notifications.defaultToEmail
1436
+ },
1437
+ // Spam defaults the Settings tab surfaces, so per-form
1438
+ // override selects can show what "inherit" resolves to.
1439
+ spamProtection: {
1440
+ honeypot: resolvedConfig.spamProtection.honeypot,
1441
+ recaptchaEnabled: resolvedConfig.spamProtection.recaptcha?.enabled ?? false
1442
+ },
1443
+ // Runtime-resolved collection slugs (through ctx.self, so a
1444
+ // framework .rename() is honored too) — admin components
1445
+ // never hardcode "forms"/"form-submissions".
1062
1446
  // Names only. A pattern may be a function, which does not
1063
1447
  // survive JSON and which the browser has no use for — the
1064
1448
  // admin picks a document, the server builds the URL.
@@ -1135,6 +1519,13 @@ function formBuilder(options = {}) {
1135
1519
  })
1136
1520
  );
1137
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
+ );
1138
1529
  nextly.hooks.on(
1139
1530
  "afterCreate",
1140
1531
  submissionSlug,
@@ -1150,8 +1541,6 @@ function formBuilder(options = {}) {
1150
1541
  }
1151
1542
  return ctx.data;
1152
1543
  });
1153
- const declaredFormsSlug = resolvedConfig.formOverrides.slug;
1154
- const formsSlug = nextly.self.collections[declaredFormsSlug] ?? declaredFormsSlug;
1155
1544
  nextly.hooks.on("afterRead", formsSlug, async (context) => {
1156
1545
  const data = context.data;
1157
1546
  const records = Array.isArray(data) ? data : data ? [data] : [];
@@ -1180,950 +1569,722 @@ function formBuilder(options = {}) {
1180
1569
  });
1181
1570
  return {
1182
1571
  plugin,
1183
- collections: [formsCol, submissionsCol],
1184
- config: resolvedConfig
1185
- };
1186
- }
1187
- function getFormBuilderConfig(nextly) {
1188
- if (!nextly || typeof nextly !== "object") return void 0;
1189
- return nextly.__formBuilderConfig;
1190
- }
1191
- function resolveFieldRef(ref, data) {
1192
- const name = parseFieldRef(ref);
1193
- if (name === null) return ref;
1194
- const value = data[name];
1195
- return typeof value === "string" ? value : "";
1196
- }
1197
- function collectAttachmentInputs(fields, submittedData) {
1198
- const attachments = [];
1199
- for (const field of fields) {
1200
- if (field.type !== "file" || !field.attachToEmail) continue;
1201
- const name = field.name;
1202
- const value = submittedData[name];
1203
- if (value == null) continue;
1204
- if (Array.isArray(value)) {
1205
- for (const item of value) {
1206
- if (typeof item === "string" && item) {
1207
- attachments.push({ mediaId: item });
1208
- }
1209
- }
1210
- } else if (typeof value === "string" && value) {
1211
- attachments.push({ mediaId: value });
1212
- }
1213
- }
1214
- return attachments;
1215
- }
1216
- function parseJsonColumn(value) {
1217
- if (typeof value === "string") {
1218
- try {
1219
- const parsed = JSON.parse(value);
1220
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
1221
- } catch {
1222
- return {};
1223
- }
1224
- }
1225
- return value && typeof value === "object" && !Array.isArray(value) ? value : {};
1226
- }
1227
- function normalizeAddressList(addresses) {
1228
- if (!Array.isArray(addresses)) return void 0;
1229
- const cleaned = addresses.map((address) => typeof address === "string" ? address.trim() : "").filter(Boolean);
1230
- return cleaned.length > 0 ? cleaned : void 0;
1231
- }
1232
- function buildNotificationEmails(input) {
1233
- const { notifications, submittedData, formName, submissionId, defaultFrom } = input;
1234
- const seen = /* @__PURE__ */ new Set();
1235
- const emails = [];
1236
- const skipped = [];
1237
- for (const notification of notifications) {
1238
- if (!notification.enabled) continue;
1239
- if (!notification.templateSlug) {
1240
- skipped.push({ notificationId: notification.id, reason: "no-template" });
1241
- continue;
1242
- }
1243
- const key = notification.id || JSON.stringify(notification);
1244
- if (seen.has(key)) continue;
1245
- seen.add(key);
1246
- if (notification.condition && !evaluateSingleCondition(notification.condition, submittedData)) {
1247
- skipped.push({
1248
- notificationId: notification.id,
1249
- reason: "condition-unmet"
1250
- });
1251
- continue;
1252
- }
1253
- const to = (notification.recipientType === "field" ? resolveFieldRef(notification.to, submittedData) : notification.to).trim();
1254
- if (!to) {
1255
- skipped.push({
1256
- notificationId: notification.id,
1257
- reason: "empty-recipient"
1258
- });
1259
- continue;
1260
- }
1261
- const cc = normalizeAddressList(notification.cc);
1262
- const bcc = normalizeAddressList(notification.bcc);
1263
- const from = notification.senderEmail?.trim() || defaultFrom?.trim() || void 0;
1264
- const replyTo = notification.replyTo ? resolveFieldRef(notification.replyTo, submittedData).trim() || void 0 : void 0;
1265
- emails.push({
1266
- to,
1267
- templateSlug: notification.templateSlug,
1268
- variables: {
1269
- ...submittedData,
1270
- formName,
1271
- submissionId
1272
- },
1273
- providerId: notification.providerId,
1274
- from,
1275
- replyTo,
1276
- cc,
1277
- bcc,
1278
- notificationId: notification.id
1279
- });
1280
- }
1281
- return { emails, skipped };
1282
- }
1283
- async function handleSubmissionCreated(context, config, nextly) {
1284
- if (!config.notifications.enabled) return;
1285
- const submission = context.data;
1286
- if (!submission) return;
1287
- if (submission.status === "spam") return;
1288
- const rawFormId = submission.form;
1289
- let formId = null;
1290
- if (typeof rawFormId === "string") {
1291
- formId = rawFormId;
1292
- } else if (rawFormId && typeof rawFormId === "object") {
1293
- const maybeId = rawFormId.id;
1294
- if (typeof maybeId === "string") formId = maybeId;
1295
- }
1296
- if (!formId) return;
1297
- const form = await fetchParentForm(config, formId, nextly);
1298
- if (!form) return;
1299
- const notifications = Array.isArray(form.notifications) ? form.notifications : [];
1300
- if (notifications.length === 0) return;
1301
- const emailService = nextly.services.email;
1302
- if (!emailService) return;
1303
- const submittedData = parseJsonColumn(submission.data);
1304
- const formFields = Array.isArray(form.fields) ? form.fields : [];
1305
- const fileAttachments = collectAttachmentInputs(formFields, submittedData);
1306
- const { emails, skipped } = buildNotificationEmails({
1307
- notifications,
1308
- submittedData,
1309
- formName: form.name,
1310
- submissionId: submission.id,
1311
- defaultFrom: config.notifications.defaultFrom
1312
- });
1313
- for (const skip of skipped) {
1314
- if (skip.reason === "empty-recipient") {
1315
- nextly.logger.warn?.(
1316
- "Form Builder: empty recipient, skipping notification",
1317
- { notificationId: skip.notificationId, formSlug: form.slug }
1318
- );
1319
- } else {
1320
- nextly.logger.debug?.("Form Builder: notification skipped", {
1321
- notificationId: skip.notificationId,
1322
- formSlug: form.slug,
1323
- reason: skip.reason
1324
- });
1325
- }
1326
- }
1327
- if (emails.length === 0) return;
1328
- const finalEmails = await nextly.filters.apply("form-builder.beforeEmail", emails, { form, submission });
1329
- for (const email2 of finalEmails) {
1330
- try {
1331
- await emailService.sendWithTemplate(
1332
- email2.templateSlug,
1333
- email2.to,
1334
- email2.variables,
1335
- {
1336
- providerId: email2.providerId,
1337
- from: email2.from,
1338
- replyTo: email2.replyTo,
1339
- cc: email2.cc,
1340
- bcc: email2.bcc,
1341
- attachments: fileAttachments.length > 0 ? fileAttachments : void 0
1342
- }
1343
- );
1344
- nextly.logger.info?.("Form Builder: notification sent", {
1345
- formSlug: form.slug,
1346
- to: email2.to,
1347
- templateSlug: email2.templateSlug,
1348
- notificationId: email2.notificationId,
1349
- attachmentCount: fileAttachments.length
1350
- });
1351
- } catch (err) {
1352
- nextly.logger.error?.("Form Builder: notification failed", {
1353
- to: email2.to,
1354
- templateSlug: email2.templateSlug,
1355
- notificationId: email2.notificationId,
1356
- error: err instanceof Error ? err.message : String(err)
1357
- });
1358
- }
1359
- }
1360
- }
1361
- async function fetchParentForm(config, formId, nextly) {
1362
- try {
1363
- const form = await nextly.services.collections.findEntryById(
1364
- config.formOverrides.slug,
1365
- formId,
1366
- { as: "system" }
1367
- );
1368
- return form;
1369
- } catch (err) {
1370
- nextly.logger.error?.("Form Builder: failed to fetch form", {
1371
- formId,
1372
- error: err instanceof Error ? err.message : String(err)
1373
- });
1374
- return null;
1375
- }
1572
+ collections: [formsCol, submissionsCol],
1573
+ config: resolvedConfig
1574
+ };
1376
1575
  }
1377
-
1378
- // src/config/validate-form.ts
1379
- import { isPluginFieldTypeOnSurface } from "nextly";
1380
- var RESERVED_FORM_SLUGS = [
1381
- // API routes
1382
- "api",
1383
- "submit",
1384
- "submissions",
1385
- "export",
1386
- // System slugs
1387
- "admin",
1388
- "new",
1389
- "create",
1390
- "edit",
1391
- "delete"
1392
- ];
1393
- var RESERVED_SLUGS_SET = new Set(RESERVED_FORM_SLUGS);
1394
- var SLUG_PATTERN = /^[a-z][a-z0-9_-]*$/;
1395
- var FIELD_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]*$/;
1396
- var VALID_FIELD_TYPES = BUILT_IN_FORM_FIELD_TYPES;
1397
- var VALID_FIELD_TYPES_SET = new Set(VALID_FIELD_TYPES);
1398
- function isReservedSlug(slug) {
1399
- return RESERVED_SLUGS_SET.has(slug.toLowerCase());
1576
+ function getFormBuilderConfig(nextly) {
1577
+ if (!nextly || typeof nextly !== "object") return void 0;
1578
+ return nextly.__formBuilderConfig;
1400
1579
  }
1401
- function validateSlug(slug, errors) {
1402
- const path = "slug";
1403
- if (!slug) {
1404
- errors.push({
1405
- path,
1406
- message: "Form slug is required",
1407
- code: "SLUG_REQUIRED"
1408
- });
1409
- return;
1410
- }
1411
- if (typeof slug !== "string") {
1412
- errors.push({
1413
- path,
1414
- message: "Form slug must be a string",
1415
- code: "SLUG_INVALID_TYPE"
1416
- });
1417
- return;
1418
- }
1419
- if (slug.length < 1) {
1420
- errors.push({
1421
- path,
1422
- message: "Form slug must be at least 1 character",
1423
- code: "SLUG_TOO_SHORT"
1424
- });
1425
- }
1426
- if (slug.length > 50) {
1427
- errors.push({
1428
- path,
1429
- message: "Form slug must be at most 50 characters",
1430
- code: "SLUG_TOO_LONG"
1431
- });
1432
- }
1433
- if (!SLUG_PATTERN.test(slug)) {
1434
- errors.push({
1435
- path,
1436
- message: "Form slug must start with a lowercase letter and contain only lowercase letters, numbers, hyphens, and underscores",
1437
- code: "SLUG_INVALID_FORMAT"
1438
- });
1439
- }
1440
- if (isReservedSlug(slug)) {
1441
- errors.push({
1442
- path,
1443
- message: `Form slug '${slug}' is reserved and cannot be used`,
1444
- code: "SLUG_RESERVED"
1445
- });
1446
- }
1580
+ function resolveFieldRef(ref, data) {
1581
+ const name = parseFieldRef(ref);
1582
+ if (name === null) return ref;
1583
+ const value = data[name];
1584
+ return typeof value === "string" ? value : "";
1447
1585
  }
1448
- function validateFieldName(name, path, errors, seenNames) {
1449
- if (!name) {
1450
- errors.push({
1451
- path: `${path}.name`,
1452
- message: "Field name is required",
1453
- code: "FIELD_NAME_REQUIRED"
1454
- });
1455
- return;
1456
- }
1457
- if (typeof name !== "string") {
1458
- errors.push({
1459
- path: `${path}.name`,
1460
- message: "Field name must be a string",
1461
- code: "FIELD_NAME_REQUIRED"
1462
- });
1463
- return;
1464
- }
1465
- if (!FIELD_NAME_PATTERN.test(name)) {
1466
- errors.push({
1467
- path: `${path}.name`,
1468
- message: `Field name '${name}' must start with a letter and contain only letters, numbers, and underscores`,
1469
- code: "FIELD_NAME_INVALID_FORMAT"
1470
- });
1471
- }
1472
- const nameLower = name.toLowerCase();
1473
- if (seenNames.has(nameLower)) {
1474
- errors.push({
1475
- path: `${path}.name`,
1476
- message: `Duplicate field name '${name}'`,
1477
- code: "FIELD_NAME_DUPLICATE"
1478
- });
1479
- } else {
1480
- seenNames.add(nameLower);
1586
+ function collectAttachmentInputs(fields, submittedData) {
1587
+ const attachments = [];
1588
+ for (const field of fields) {
1589
+ if (field.type !== "file" || !field.attachToEmail) continue;
1590
+ const name = field.name;
1591
+ const value = submittedData[name];
1592
+ if (value == null) continue;
1593
+ if (Array.isArray(value)) {
1594
+ for (const item of value) {
1595
+ if (typeof item === "string" && item) {
1596
+ attachments.push({ mediaId: item });
1597
+ }
1598
+ }
1599
+ } else if (typeof value === "string" && value) {
1600
+ attachments.push({ mediaId: value });
1601
+ }
1481
1602
  }
1603
+ return attachments;
1482
1604
  }
1483
- function validateFieldLabel(label, path, errors) {
1484
- if (!label || typeof label === "string" && label.trim() === "") {
1485
- errors.push({
1486
- path: `${path}.label`,
1487
- message: "Field label is required",
1488
- code: "FIELD_LABEL_REQUIRED"
1489
- });
1605
+ function parseJsonColumn(value) {
1606
+ if (typeof value === "string") {
1607
+ try {
1608
+ const parsed = JSON.parse(value);
1609
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
1610
+ } catch {
1611
+ return {};
1612
+ }
1490
1613
  }
1614
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
1491
1615
  }
1492
- function validateOptions(field, path, errors) {
1493
- const fieldWithOptions = field;
1494
- const options = fieldWithOptions.options;
1495
- if (!options) {
1496
- errors.push({
1497
- path: `${path}.options`,
1498
- message: `${field.type} field must have an 'options' array`,
1499
- code: "OPTIONS_REQUIRED"
1500
- });
1501
- return;
1502
- }
1503
- if (!Array.isArray(options)) {
1504
- errors.push({
1505
- path: `${path}.options`,
1506
- message: `${field.type} field 'options' must be an array`,
1507
- code: "OPTIONS_REQUIRED"
1508
- });
1509
- return;
1510
- }
1511
- if (options.length === 0) {
1512
- errors.push({
1513
- path: `${path}.options`,
1514
- message: `${field.type} field must have at least one option`,
1515
- code: "OPTIONS_EMPTY"
1516
- });
1517
- }
1616
+ function normalizeAddressList(addresses) {
1617
+ if (!Array.isArray(addresses)) return void 0;
1618
+ const cleaned = addresses.map((address) => typeof address === "string" ? address.trim() : "").filter(Boolean);
1619
+ return cleaned.length > 0 ? cleaned : void 0;
1518
1620
  }
1519
- function validateConditionalLogic(field, path, errors) {
1520
- const { conditionalLogic } = field;
1521
- if (!conditionalLogic || !conditionalLogic.enabled) {
1522
- return;
1523
- }
1524
- if (!conditionalLogic.conditions || conditionalLogic.conditions.length === 0) {
1525
- errors.push({
1526
- path: `${path}.conditionalLogic.conditions`,
1527
- message: "Conditional logic must have at least one condition",
1528
- code: "CONDITIONS_REQUIRED"
1529
- });
1530
- return;
1531
- }
1532
- conditionalLogic.conditions.forEach((condition, index) => {
1533
- const conditionPath = `${path}.conditionalLogic.conditions[${index}]`;
1534
- if (!condition.field) {
1535
- errors.push({
1536
- path: `${conditionPath}.field`,
1537
- message: "Condition must specify a field name",
1538
- code: "CONDITION_FIELD_REQUIRED"
1539
- });
1621
+ function buildNotificationEmails(input) {
1622
+ const { notifications, submittedData, formName, submissionId, defaultFrom } = input;
1623
+ const seen = /* @__PURE__ */ new Set();
1624
+ const emails = [];
1625
+ const skipped = [];
1626
+ for (const notification of notifications) {
1627
+ if (!notification.enabled) continue;
1628
+ if (!notification.templateSlug) {
1629
+ skipped.push({ notificationId: notification.id, reason: "no-template" });
1630
+ continue;
1540
1631
  }
1541
- if (!condition.comparison) {
1542
- errors.push({
1543
- path: `${conditionPath}.comparison`,
1544
- message: "Condition must specify a comparison operator",
1545
- code: "CONDITION_COMPARISON_REQUIRED"
1632
+ const key = notification.id || JSON.stringify(notification);
1633
+ if (seen.has(key)) continue;
1634
+ seen.add(key);
1635
+ if (notification.condition && !evaluateSingleCondition(notification.condition, submittedData)) {
1636
+ skipped.push({
1637
+ notificationId: notification.id,
1638
+ reason: "condition-unmet"
1546
1639
  });
1640
+ continue;
1547
1641
  }
1548
- });
1549
- }
1550
- function validateFieldByType(field, path, errors) {
1551
- switch (field.type) {
1552
- case "select":
1553
- case "radio":
1554
- validateOptions(field, path, errors);
1555
- break;
1556
- case "number": {
1557
- const numberField = field;
1558
- if (numberField.validation) {
1559
- const { min, max } = numberField.validation;
1560
- if (min !== void 0 && max !== void 0 && min > max) {
1561
- errors.push({
1562
- path: `${path}.validation`,
1563
- message: "Min value cannot be greater than max value",
1564
- code: "MIN_GREATER_THAN_MAX"
1565
- });
1566
- }
1567
- }
1568
- break;
1569
- }
1570
- case "text":
1571
- case "textarea": {
1572
- const textField = field;
1573
- if (textField.validation) {
1574
- const { minLength, maxLength } = textField.validation;
1575
- if (minLength !== void 0 && maxLength !== void 0 && minLength > maxLength) {
1576
- errors.push({
1577
- path: `${path}.validation`,
1578
- message: "Min length cannot be greater than max length",
1579
- code: "MIN_LENGTH_GREATER_THAN_MAX"
1580
- });
1581
- }
1582
- }
1583
- break;
1584
- }
1585
- case "file": {
1586
- const fileField = field;
1587
- if (fileField.maxFileSize !== void 0 && fileField.maxFileSize <= 0) {
1588
- errors.push({
1589
- path: `${path}.maxFileSize`,
1590
- message: "File maxFileSize must be a positive number",
1591
- code: "FILE_MAX_SIZE_INVALID"
1592
- });
1593
- }
1594
- break;
1642
+ const to = (notification.recipientType === "field" ? resolveFieldRef(notification.to, submittedData) : notification.to).trim();
1643
+ if (!to) {
1644
+ skipped.push({
1645
+ notificationId: notification.id,
1646
+ reason: "empty-recipient"
1647
+ });
1648
+ continue;
1595
1649
  }
1650
+ const cc = normalizeAddressList(notification.cc);
1651
+ const bcc = normalizeAddressList(notification.bcc);
1652
+ const from = notification.senderEmail?.trim() || defaultFrom?.trim() || void 0;
1653
+ const replyTo = notification.replyTo ? resolveFieldRef(notification.replyTo, submittedData).trim() || void 0 : void 0;
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
1668
+ });
1596
1669
  }
1670
+ return { emails, skipped };
1597
1671
  }
1598
- function validateField(field, path, errors, seenNames) {
1599
- if (!field || typeof field !== "object") {
1600
- return;
1601
- }
1602
- const f = field;
1603
- const fieldType = f.type;
1604
- if (!fieldType) {
1605
- errors.push({
1606
- path: `${path}.type`,
1607
- message: "Field type is required",
1608
- code: "FIELD_TYPE_REQUIRED"
1609
- });
1610
- return;
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;
1611
1678
  }
1612
- const isValidType = typeof fieldType === "string" && (VALID_FIELD_TYPES_SET.has(fieldType) || isPluginFieldTypeOnSurface(fieldType, "forms"));
1613
- if (!isValidType) {
1614
- let fieldTypeRepr;
1615
- if (typeof fieldType === "object" && fieldType !== null) {
1616
- fieldTypeRepr = JSON.stringify(fieldType);
1617
- } else {
1618
- fieldTypeRepr = String(fieldType);
1679
+ return null;
1680
+ }
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 {
1619
1689
  }
1620
- errors.push({
1621
- path: `${path}.type`,
1622
- message: `Invalid field type '${fieldTypeRepr}'. Valid types: ${VALID_FIELD_TYPES.join(", ")}`,
1623
- code: "FIELD_TYPE_INVALID"
1624
- });
1625
- return;
1626
- }
1627
- validateFieldName(f.name, path, errors, seenNames);
1628
- if (fieldType !== "hidden") {
1629
- validateFieldLabel(f.label, path, errors);
1690
+ } else if (raw && typeof raw === "object" && !Array.isArray(raw)) {
1691
+ return raw;
1630
1692
  }
1631
- validateFieldByType(field, path, errors);
1632
- if (f.conditionalLogic) {
1633
- validateConditionalLogic(field, path, errors);
1634
- }
1635
- }
1636
- function validateFieldsArray(fields, basePath, errors) {
1637
- const seenNames = /* @__PURE__ */ new Set();
1638
- fields.forEach((field, index) => {
1639
- const fieldPath = `${basePath}[${index}]`;
1640
- validateField(field, fieldPath, errors, seenNames);
1693
+ throw NextlyError2.validation({
1694
+ errors: [
1695
+ {
1696
+ path: "data",
1697
+ code: "INVALID",
1698
+ message: "Submission data must be an object."
1699
+ }
1700
+ ]
1641
1701
  });
1642
1702
  }
1643
- function validateFields(fields, errors) {
1644
- const path = "fields";
1645
- if (!fields) {
1646
- errors.push({
1647
- path,
1648
- message: "Form must have at least one field",
1649
- code: "FIELDS_REQUIRED"
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
+ )
1650
1734
  });
1651
- return;
1652
1735
  }
1653
- if (!Array.isArray(fields)) {
1654
- errors.push({
1655
- path,
1656
- message: "Form fields must be an array",
1657
- code: "FIELDS_INVALID_TYPE"
1658
- });
1659
- return;
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);
1660
1739
  }
1661
- if (fields.length === 0) {
1662
- errors.push({
1663
- path,
1664
- message: "Form must have at least one field",
1665
- code: "FIELDS_EMPTY"
1740
+ return ctx.data;
1741
+ }
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
+ ]
1666
1754
  });
1667
- return;
1668
1755
  }
1669
- validateFieldsArray(fields, path, errors);
1756
+ return form.fields;
1670
1757
  }
1671
- function validateSettings(settings, errors) {
1672
- if (!settings) {
1673
- return;
1674
- }
1675
- if (typeof settings !== "object" || Array.isArray(settings)) {
1676
- errors.push({
1677
- path: "settings",
1678
- message: "Form settings must be an object",
1679
- code: "SETTINGS_INVALID_TYPE"
1680
- });
1681
- return;
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
+ });
1803
+ }
1682
1804
  }
1683
- const s = settings;
1684
- if (s.confirmationType === "redirect") {
1685
- if (!s.redirectUrl && !s.redirectRelation) {
1686
- errors.push({
1687
- path: "settings.redirectUrl",
1688
- message: "Redirect URL or redirect relation is required when confirmation type is 'redirect'",
1689
- code: "REDIRECT_URL_REQUIRED"
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
1820
+ }
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)
1690
1835
  });
1691
1836
  }
1692
1837
  }
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"
1838
+ }
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)
1698
1852
  });
1853
+ return null;
1699
1854
  }
1700
1855
  }
1701
- function namesADocument(value) {
1702
- if (typeof value === "string") return value.length > 0;
1703
- return parseRedirectReference(value, []) !== null;
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());
1704
1879
  }
1705
- function validateAccess(access, errors) {
1706
- if (!access) {
1880
+ function validateSlug(slug, errors) {
1881
+ const path = "slug";
1882
+ if (!slug) {
1883
+ errors.push({
1884
+ path,
1885
+ message: "Form slug is required",
1886
+ code: "SLUG_REQUIRED"
1887
+ });
1888
+ return;
1889
+ }
1890
+ if (typeof slug !== "string") {
1891
+ errors.push({
1892
+ path,
1893
+ message: "Form slug must be a string",
1894
+ code: "SLUG_INVALID_TYPE"
1895
+ });
1707
1896
  return;
1708
1897
  }
1709
- if (typeof access !== "object" || Array.isArray(access)) {
1898
+ if (slug.length < 1) {
1710
1899
  errors.push({
1711
- path: "access",
1712
- message: "Access control must be an object",
1713
- code: "ACCESS_INVALID_TYPE"
1900
+ path,
1901
+ message: "Form slug must be at least 1 character",
1902
+ code: "SLUG_TOO_SHORT"
1714
1903
  });
1715
- return;
1716
1904
  }
1717
- const accessObj = access;
1718
- const validAccessKeys = ["submit", "read"];
1719
- for (const key of validAccessKeys) {
1720
- const fn = accessObj[key];
1721
- if (fn !== void 0 && typeof fn !== "function") {
1722
- errors.push({
1723
- path: `access.${key}`,
1724
- message: `Access control '${key}' must be a function`,
1725
- code: "ACCESS_FUNCTION_INVALID"
1726
- });
1727
- }
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
+ });
1728
1911
  }
1729
- }
1730
- function validateFormConfig(config) {
1731
- const errors = [];
1732
- validateSlug(config.slug, errors);
1733
- validateFields(config.fields, errors);
1734
- validateSettings(config.settings, errors);
1735
- validateAccess(config.access, errors);
1736
- return {
1737
- valid: errors.length === 0,
1738
- errors
1739
- };
1740
- }
1741
- function assertValidFormConfig(config) {
1742
- const result = validateFormConfig(config);
1743
- if (!result.valid) {
1744
- const errorMessages = result.errors.map((err) => ` - [${err.code}] ${err.path}: ${err.message}`).join("\n");
1745
- throw new Error(
1746
- `Invalid form config for '${config.slug || "unknown"}':
1747
- ${errorMessages}`
1748
- );
1912
+ if (!SLUG_PATTERN.test(slug)) {
1913
+ errors.push({
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"
1917
+ });
1749
1918
  }
1750
- }
1751
-
1752
- // src/config/field-helpers.ts
1753
- var option = (label, value) => ({
1754
- label,
1755
- value: value ?? label.toLowerCase().replace(/\s+/g, "_")
1756
- });
1757
- var text3 = (config) => ({
1758
- ...config,
1759
- type: "text"
1760
- });
1761
- var email = (config) => ({
1762
- ...config,
1763
- type: "email"
1764
- });
1765
- var phone = (config) => ({
1766
- ...config,
1767
- type: "phone"
1768
- });
1769
- var url = (config) => ({
1770
- ...config,
1771
- type: "url"
1772
- });
1773
- var textarea3 = (config) => ({
1774
- ...config,
1775
- type: "textarea"
1776
- });
1777
- var number = (config) => ({
1778
- ...config,
1779
- type: "number"
1780
- });
1781
- var select3 = (config) => ({
1782
- ...config,
1783
- type: "select"
1784
- });
1785
- var checkbox2 = (config) => ({
1786
- ...config,
1787
- type: "checkbox"
1788
- });
1789
- var radio = (config) => ({
1790
- ...config,
1791
- type: "radio"
1792
- });
1793
- var file = (config) => ({
1794
- ...config,
1795
- type: "file"
1796
- });
1797
- var date3 = (config) => ({
1798
- ...config,
1799
- type: "date"
1800
- });
1801
- var time = (config) => ({
1802
- ...config,
1803
- type: "time"
1804
- });
1805
- var hidden = (config) => ({
1806
- ...config,
1807
- type: "hidden"
1808
- });
1809
-
1810
- // src/utils/generate-schema.ts
1811
- import { z } from "zod";
1812
- function generateZodSchema(fields) {
1813
- const schemaShape = {};
1814
- for (const field of fields) {
1815
- if (!isKnownFormField(field)) {
1816
- schemaShape[field.name] = field.required ? z.unknown().refine(
1817
- (value) => value !== void 0 && value !== null && value !== "",
1818
- { message: `${field.label || field.name} is required` }
1819
- ) : z.unknown();
1820
- continue;
1821
- }
1822
- if (field.type === "hidden" && !field.required) {
1823
- schemaShape[field.name] = z.string().optional();
1824
- continue;
1825
- }
1826
- const fieldSchema = generateFieldSchema(field);
1827
- if (fieldSchema) {
1828
- schemaShape[field.name] = fieldSchema;
1829
- }
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
+ });
1830
1925
  }
1831
- return z.object(schemaShape);
1832
1926
  }
1833
- function generateFieldSchema(field) {
1834
- switch (field.type) {
1835
- case "text":
1836
- return generateTextSchema(field);
1837
- case "email":
1838
- return generateEmailSchema(field);
1839
- case "number":
1840
- return generateNumberSchema(field);
1841
- case "phone":
1842
- return generatePhoneSchema(field);
1843
- case "url":
1844
- return generateUrlSchema(field);
1845
- case "textarea":
1846
- return generateTextareaSchema(field);
1847
- case "select":
1848
- return generateSelectSchema(field);
1849
- case "checkbox":
1850
- return generateCheckboxSchema(field);
1851
- case "radio":
1852
- return generateRadioSchema(field);
1853
- case "file":
1854
- return generateFileSchema(field);
1855
- case "date":
1856
- return generateDateSchema(field);
1857
- case "time":
1858
- return generateTimeSchema(field);
1859
- case "hidden":
1860
- return generateHiddenSchema(field);
1861
- default:
1862
- return z.unknown();
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
+ });
1934
+ return;
1863
1935
  }
1864
- }
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);
1936
+ if (typeof name !== "string") {
1937
+ errors.push({
1938
+ path: `${path}.name`,
1939
+ message: "Field name must be a string",
1940
+ code: "FIELD_NAME_REQUIRED"
1941
+ });
1942
+ return;
1910
1943
  }
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));
1916
- }
1917
- function generateEmailSchema(field) {
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));
1924
- }
1925
- function generateNumberSchema(field) {
1926
- let schema = z.number({
1927
- error: (issue) => issue.input === void 0 ? customMessage(field) || "This field is required" : "Must be a number"
1928
- });
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);
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
+ });
1933
1950
  }
1934
- if (field.required) {
1935
- return schema;
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);
1936
1960
  }
1937
- return schema.optional().nullable();
1938
- }
1939
- function generatePhoneSchema(field) {
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));
1946
- }
1947
- function generateUrlSchema(field) {
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));
1954
1961
  }
1955
- function generateTextareaSchema(field) {
1956
- const schema = applyStringRules(z.string(), field, "Invalid format");
1957
- return applyRequired(schema, field.required, customMessage(field));
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
+ });
1969
+ }
1958
1970
  }
1959
- function generateSelectSchema(field) {
1960
- const validValues = field.options.map((opt) => opt.value);
1961
- if (field.allowMultiple) {
1962
- const schema2 = z.array(z.enum(validValues));
1963
- if (field.required) {
1964
- return schema2.min(
1965
- 1,
1966
- customMessage(field) || "Please select at least one option"
1967
- );
1968
- }
1969
- return schema2.optional();
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;
1970
1981
  }
1971
- const schema = z.enum(validValues, {
1972
- error: () => customMessage(field) || "Please select a valid option"
1973
- });
1974
- if (field.required) {
1975
- return schema;
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;
1976
1989
  }
1977
- return schema.optional();
1978
- }
1979
- function generateCheckboxSchema(field) {
1980
- const schema = z.boolean();
1981
- if (field.required) {
1982
- return schema.refine((val) => val === true, {
1983
- message: customMessage(field) || "This field is required"
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"
1984
1995
  });
1985
1996
  }
1986
- return schema.optional();
1987
1997
  }
1988
- function generateRadioSchema(field) {
1989
- const validValues = field.options.map((opt) => opt.value);
1990
- const schema = z.enum(validValues, {
1991
- error: () => customMessage(field) || "Please select an option"
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;
2010
+ }
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
+ }
1992
2027
  });
1993
- if (field.required) {
1994
- return schema;
2028
+ }
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
+ }
1995
2075
  }
1996
- return schema.optional();
1997
2076
  }
1998
- function generateFileSchema(field) {
1999
- if (field.multiple) {
2000
- const schema2 = z.array(z.string());
2001
- if (field.required) {
2002
- return schema2.min(
2003
- 1,
2004
- customMessage(field) || "Please upload at least one file"
2005
- );
2077
+ function validateField(field, path, errors, seenNames) {
2078
+ if (!field || typeof field !== "object") {
2079
+ return;
2080
+ }
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;
2090
+ }
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);
2006
2098
  }
2007
- 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;
2105
+ }
2106
+ validateFieldName(f.name, path, errors, seenNames);
2107
+ if (fieldType !== "hidden") {
2108
+ validateFieldLabel(f.label, path, errors);
2109
+ }
2110
+ validateFieldByType(field, path, errors);
2111
+ if (f.conditionalLogic) {
2112
+ validateConditionalLogic(field, path, errors);
2008
2113
  }
2009
- const schema = z.string();
2010
- return applyRequired(schema, field.required, customMessage(field));
2011
2114
  }
2012
- function generateDateSchema(field) {
2013
- let schema = z.string();
2014
- schema = schema.refine(
2015
- (val) => {
2016
- if (!val) return true;
2017
- const date4 = new Date(val);
2018
- return !isNaN(date4.getTime());
2019
- },
2020
- { message: customMessage(field) || "Invalid date" }
2021
- );
2022
- if (field.min || field.max) {
2023
- schema = schema.refine(
2024
- (val) => {
2025
- if (!val) return true;
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;
2029
- return true;
2030
- },
2031
- {
2032
- message: customMessage(field) || `Date must be between ${field.min || "any"} and ${field.max || "any"}`
2033
- }
2034
- );
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);
2120
+ });
2121
+ }
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;
2035
2131
  }
2036
- if (field.required) {
2037
- return schema.refine(
2038
- (val) => val !== void 0 && val !== null && val !== "",
2039
- { message: customMessage(field) || "This field is required" }
2040
- );
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;
2041
2139
  }
2042
- return schema.optional().or(z.literal(""));
2043
- }
2044
- function generateTimeSchema(field) {
2045
- const schema = z.string().regex(
2046
- /^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/,
2047
- customMessage(field) || "Invalid time format (HH:mm)"
2048
- );
2049
- return applyRequired(schema, field.required, customMessage(field));
2050
- }
2051
- function generateHiddenSchema(field) {
2052
- const schema = z.string();
2053
- return applyRequired(schema, field.required, customMessage(field));
2054
- }
2055
- function applyRequired(schema, required, errorMessage) {
2056
- if (required) {
2057
- 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;
2058
2147
  }
2059
- return schema.optional().or(z.literal(""));
2148
+ validateFieldsArray(fields, path, errors);
2060
2149
  }
2061
- function transformFormData(data, fields) {
2062
- const transformed = {};
2063
- for (const field of fields) {
2064
- const value = data[field.name];
2065
- if (value === void 0) {
2066
- continue;
2067
- }
2068
- transformed[field.name] = isKnownFormField(field) ? transformFieldValue(value, field) : value;
2150
+ function validateSettings(settings, errors) {
2151
+ if (!settings) {
2152
+ return;
2069
2153
  }
2070
- return transformed;
2071
- }
2072
- function transformFieldValue(value, field) {
2073
- if (value === null || value === void 0) {
2074
- 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;
2075
2161
  }
2076
- switch (field.type) {
2077
- case "number":
2078
- if (typeof value === "string") {
2079
- const num = parseFloat(value);
2080
- return isNaN(num) ? value : num;
2081
- }
2082
- return value;
2083
- case "checkbox":
2084
- if (typeof value === "string") {
2085
- return value === "true" || value === "1" || value === "on";
2086
- }
2087
- return Boolean(value);
2088
- case "text":
2089
- case "email":
2090
- case "phone":
2091
- case "url":
2092
- case "textarea":
2093
- case "hidden":
2094
- if (typeof value === "string") {
2095
- return value.trim();
2096
- }
2097
- return value;
2098
- case "select":
2099
- if (field.allowMultiple) {
2100
- if (!Array.isArray(value)) {
2101
- return value ? [value] : [];
2102
- }
2103
- }
2104
- return value;
2105
- default:
2106
- 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
+ });
2107
2178
  }
2108
2179
  }
2109
- function validateFormData(data, fields) {
2110
- const transformed = transformFormData(data, fields);
2111
- const schema = generateZodSchema(fields);
2112
- return schema.safeParse(transformed);
2180
+ function namesADocument(value) {
2181
+ if (typeof value === "string") return value.length > 0;
2182
+ return parseRedirectReference(value, []) !== null;
2113
2183
  }
2114
- function getValidationErrors(result) {
2115
- if (result.success) {
2116
- return {};
2184
+ function validateAccess(access, errors) {
2185
+ if (!access) {
2186
+ return;
2117
2187
  }
2118
- const errors = {};
2119
- for (const error of result.error.issues) {
2120
- const path = error.path.join(".");
2121
- if (!errors[path]) {
2122
- 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
+ });
2123
2206
  }
2124
2207
  }
2125
- return errors;
2126
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
+ }
2229
+ }
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
+ });
2127
2288
 
2128
2289
  // src/handlers/submit-form.ts
2129
2290
  import { formAvailability, NO_SUCH_FORM } from "nextly";
@@ -2322,34 +2483,30 @@ async function submitForm(options, context) {
2322
2483
  return { success: true };
2323
2484
  }
2324
2485
  const isContentSpam = spamResult.isSpam;
2325
- const transformedData = transformFormData(data, form.fields);
2326
- let storedData;
2327
2486
  if (isContentSpam) {
2328
2487
  logger.info?.("Spam submission detected \u2014 storing flagged", {
2329
2488
  formSlug,
2330
2489
  reason: spamResult.reason,
2331
2490
  ipAddress: metadata?.ipAddress
2332
2491
  });
2333
- sanitizeSubmissionData(transformedData, form.fields);
2334
- storedData = transformedData;
2335
- } else {
2336
- const schema = generateZodSchema(form.fields);
2337
- const validationResult = schema.safeParse(transformedData);
2338
- if (!validationResult.success) {
2339
- const validationErrors = getValidationErrors(validationResult);
2340
- logger.debug?.("Form validation failed", {
2341
- formSlug,
2342
- errors: validationErrors
2343
- });
2344
- return {
2345
- success: false,
2346
- error: "Validation failed",
2347
- validationErrors
2348
- };
2349
- }
2350
- sanitizeSubmissionData(validationResult.data, form.fields);
2351
- storedData = validationResult.data;
2352
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;
2353
2510
  const submissionData = {
2354
2511
  form: form.id,
2355
2512
  data: storedData,
@@ -2361,7 +2518,10 @@ async function submitForm(options, context) {
2361
2518
  };
2362
2519
  const submission = await collections.createEntry(
2363
2520
  pluginConfig.formSubmissionOverrides.slug,
2364
- submissionData,
2521
+ asPluginSubmission(submissionData, {
2522
+ keepAsEvidence: isContentSpam,
2523
+ form: { id: form.id, fields: form.fields }
2524
+ }),
2365
2525
  // Public form submission — create as system. No ambient user; an
2366
2526
  // empty context already resolves to system, but be explicit.
2367
2527
  { as: "system" }
@@ -2521,15 +2681,17 @@ async function validateSubmission(formSlug, data, context) {
2521
2681
  if (!form) {
2522
2682
  return { valid: false, errors: { _form: "Form not found" } };
2523
2683
  }
2524
- const transformedData = transformFormData(data, form.fields);
2525
- const schema = generateZodSchema(form.fields);
2526
- const result = schema.safeParse(transformedData);
2527
- if (result.success) {
2684
+ const prepared = prepareSubmission({
2685
+ data,
2686
+ fields: form.fields,
2687
+ validate: true
2688
+ });
2689
+ if (!prepared.validationErrors) {
2528
2690
  return { valid: true };
2529
2691
  }
2530
2692
  return {
2531
2693
  valid: false,
2532
- errors: getValidationErrors(result)
2694
+ errors: prepared.validationErrors
2533
2695
  };
2534
2696
  }
2535
2697
  async function isFormAcceptingSubmissions(formSlug, context) {
@@ -2540,25 +2702,6 @@ async function isFormAcceptingSubmissions(formSlug, context) {
2540
2702
  }
2541
2703
  return form.status === "published";
2542
2704
  }
2543
- var TEXT_FORM_FIELDS = /* @__PURE__ */ new Set([
2544
- "text",
2545
- "email",
2546
- "textarea",
2547
- "phone",
2548
- "url",
2549
- "hidden"
2550
- ]);
2551
- function stripHtmlTags(input) {
2552
- return input.replace(/<[^>]*(?:>|$)/g, "").replace(/\s+/g, " ").trim();
2553
- }
2554
- function sanitizeSubmissionData(data, fields) {
2555
- for (const field of fields) {
2556
- if (!TEXT_FORM_FIELDS.has(field.type)) continue;
2557
- const value = data[field.name];
2558
- if (typeof value !== "string") continue;
2559
- data[field.name] = stripHtmlTags(value);
2560
- }
2561
- }
2562
2705
  async function getFormSubmissionStats(formSlug, context) {
2563
2706
  const { pluginContext, pluginConfig } = context;
2564
2707
  const { collections } = pluginContext.services;