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

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,32 +1541,11 @@ 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
- nextly.hooks.on("afterRead", formsSlug, async (context) => {
1156
- const data = context.data;
1157
- const records = Array.isArray(data) ? data : data ? [data] : [];
1158
- await Promise.all(
1159
- records.map(async (record) => {
1160
- const form = record;
1161
- if (typeof form.id !== "string") return;
1162
- try {
1163
- form.submissionCount = await nextly.services.collections.count(
1164
- submissionSlug,
1165
- {
1166
- where: {
1167
- form: { equals: form.id },
1168
- status: { not_equals: "spam" }
1169
- }
1170
- },
1171
- { as: "system" }
1172
- );
1173
- } catch {
1174
- form.submissionCount = 0;
1175
- }
1176
- })
1177
- );
1178
- });
1544
+ nextly.hooks.on(
1545
+ "afterRead",
1546
+ formsSlug,
1547
+ (context) => injectSubmissionCount(context, submissionSlug, nextly)
1548
+ );
1179
1549
  }
1180
1550
  });
1181
1551
  return {
@@ -1184,946 +1554,744 @@ function formBuilder(options = {}) {
1184
1554
  config: resolvedConfig
1185
1555
  };
1186
1556
  }
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
- }
1376
- }
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());
1557
+ function getFormBuilderConfig(nextly) {
1558
+ if (!nextly || typeof nextly !== "object") return void 0;
1559
+ return nextly.__formBuilderConfig;
1400
1560
  }
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
- }
1561
+ function resolveFieldRef(ref, data) {
1562
+ const name = parseFieldRef(ref);
1563
+ if (name === null) return ref;
1564
+ const value = data[name];
1565
+ return typeof value === "string" ? value : "";
1447
1566
  }
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);
1567
+ function collectAttachmentInputs(fields, submittedData) {
1568
+ const attachments = [];
1569
+ for (const field of fields) {
1570
+ if (field.type !== "file" || !field.attachToEmail) continue;
1571
+ const name = field.name;
1572
+ const value = submittedData[name];
1573
+ if (value == null) continue;
1574
+ if (Array.isArray(value)) {
1575
+ for (const item of value) {
1576
+ if (typeof item === "string" && item) {
1577
+ attachments.push({ mediaId: item });
1578
+ }
1579
+ }
1580
+ } else if (typeof value === "string" && value) {
1581
+ attachments.push({ mediaId: value });
1582
+ }
1481
1583
  }
1584
+ return attachments;
1482
1585
  }
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
- });
1586
+ function parseJsonColumn(value) {
1587
+ if (typeof value === "string") {
1588
+ try {
1589
+ const parsed = JSON.parse(value);
1590
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
1591
+ } catch {
1592
+ return {};
1593
+ }
1490
1594
  }
1595
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
1491
1596
  }
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
- }
1597
+ function normalizeAddressList(addresses) {
1598
+ if (!Array.isArray(addresses)) return void 0;
1599
+ const cleaned = addresses.map((address) => typeof address === "string" ? address.trim() : "").filter(Boolean);
1600
+ return cleaned.length > 0 ? cleaned : void 0;
1518
1601
  }
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
- });
1602
+ function buildNotificationEmails(input) {
1603
+ const { notifications, submittedData, formName, submissionId, defaultFrom } = input;
1604
+ const seen = /* @__PURE__ */ new Set();
1605
+ const emails = [];
1606
+ const skipped = [];
1607
+ for (const notification of notifications) {
1608
+ if (!notification.enabled) continue;
1609
+ if (!notification.templateSlug) {
1610
+ skipped.push({ notificationId: notification.id, reason: "no-template" });
1611
+ continue;
1540
1612
  }
1541
- if (!condition.comparison) {
1542
- errors.push({
1543
- path: `${conditionPath}.comparison`,
1544
- message: "Condition must specify a comparison operator",
1545
- code: "CONDITION_COMPARISON_REQUIRED"
1613
+ const key = notification.id || JSON.stringify(notification);
1614
+ if (seen.has(key)) continue;
1615
+ seen.add(key);
1616
+ if (notification.condition && !evaluateSingleCondition(notification.condition, submittedData)) {
1617
+ skipped.push({
1618
+ notificationId: notification.id,
1619
+ reason: "condition-unmet"
1546
1620
  });
1621
+ continue;
1547
1622
  }
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;
1623
+ const to = (notification.recipientType === "field" ? resolveFieldRef(notification.to, submittedData) : notification.to).trim();
1624
+ if (!to) {
1625
+ skipped.push({
1626
+ notificationId: notification.id,
1627
+ reason: "empty-recipient"
1628
+ });
1629
+ continue;
1584
1630
  }
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
- });
1631
+ const cc = normalizeAddressList(notification.cc);
1632
+ const bcc = normalizeAddressList(notification.bcc);
1633
+ const from = notification.senderEmail?.trim() || defaultFrom?.trim() || void 0;
1634
+ const replyTo = notification.replyTo ? resolveFieldRef(notification.replyTo, submittedData).trim() || void 0 : void 0;
1635
+ emails.push({
1636
+ to,
1637
+ templateSlug: notification.templateSlug,
1638
+ variables: {
1639
+ ...submittedData,
1640
+ formName,
1641
+ submissionId
1642
+ },
1643
+ providerId: notification.providerId,
1644
+ from,
1645
+ replyTo,
1646
+ cc,
1647
+ bcc,
1648
+ notificationId: notification.id
1649
+ });
1650
+ }
1651
+ return { emails, skipped };
1652
+ }
1653
+ function parentFormId(submission) {
1654
+ const raw = submission.form;
1655
+ if (typeof raw === "string") return raw;
1656
+ if (raw && typeof raw === "object") {
1657
+ const maybeId = raw.id;
1658
+ if (typeof maybeId === "string") return maybeId;
1659
+ }
1660
+ return null;
1661
+ }
1662
+ function submittedPayload(raw) {
1663
+ if (typeof raw === "string") {
1664
+ try {
1665
+ const parsed = JSON.parse(raw);
1666
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
1667
+ return parsed;
1593
1668
  }
1594
- break;
1669
+ } catch {
1595
1670
  }
1671
+ } else if (raw && typeof raw === "object" && !Array.isArray(raw)) {
1672
+ return raw;
1596
1673
  }
1674
+ throw NextlyError2.validation({
1675
+ errors: [
1676
+ {
1677
+ path: "data",
1678
+ code: "INVALID",
1679
+ message: "Submission data must be an object."
1680
+ }
1681
+ ]
1682
+ });
1597
1683
  }
1598
- function validateField(field, path, errors, seenNames) {
1599
- if (!field || typeof field !== "object") {
1600
- return;
1684
+ function updateNeedsChecking(submission, stored) {
1685
+ if (submission.data !== void 0) return true;
1686
+ if (stored?.data === void 0) return false;
1687
+ if (submission.form !== void 0) return true;
1688
+ return stored.status === "spam" && submission.status !== void 0 && submission.status !== "spam";
1689
+ }
1690
+ async function prepareSubmissionForWrite(context, formsSlug, nextly) {
1691
+ const ctx = context;
1692
+ const submission = ctx.data;
1693
+ if (!submission || typeof submission !== "object") return ctx.data;
1694
+ const stored = ctx.originalData;
1695
+ const formId = parentFormId(submission) ?? (stored ? parentFormId(stored) : null);
1696
+ if (!formId) return ctx.data;
1697
+ if (ctx.operation !== "create" && !updateNeedsChecking(submission, stored)) {
1698
+ return ctx.data;
1699
+ }
1700
+ const incoming = submittedPayload(
1701
+ submission.data !== void 0 ? submission.data : stored?.data
1702
+ );
1703
+ const marks = submissionMarks(submission);
1704
+ const fields = await fieldsToCheckAgainst(marks, formsSlug, formId, nextly);
1705
+ const prepared = prepareSubmission({
1706
+ data: incoming,
1707
+ fields,
1708
+ validate: marks?.keepAsEvidence !== true
1709
+ });
1710
+ if (prepared.validationErrors) {
1711
+ throw NextlyError2.validation({
1712
+ errors: Object.entries(prepared.validationErrors).map(
1713
+ ([path, message]) => ({ path, code: "INVALID", message })
1714
+ )
1715
+ });
1601
1716
  }
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"
1717
+ ctx.data = { ...submission, data: prepared.data };
1718
+ if (ctx.operation !== "create" && submission.data === void 0) {
1719
+ stampDerivedEdit(ctx.data, ctx.user, incoming, prepared.data);
1720
+ }
1721
+ return ctx.data;
1722
+ }
1723
+ async function fieldsToCheckAgainst(marks, formsSlug, formId, nextly) {
1724
+ const handedOver = marks?.form?.id === formId ? marks.form : null;
1725
+ const form = handedOver ?? await fetchParentForm(formsSlug, formId, nextly);
1726
+ if (!form || !Array.isArray(form.fields)) {
1727
+ throw NextlyError2.validation({
1728
+ errors: [
1729
+ {
1730
+ path: "form",
1731
+ code: "INVALID",
1732
+ message: "The form this submission belongs to could not be read, so the submission could not be checked against it."
1733
+ }
1734
+ ]
1609
1735
  });
1610
- return;
1611
1736
  }
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);
1737
+ return form.fields;
1738
+ }
1739
+ function stampDerivedEdit(row, user, before, after) {
1740
+ if (sameSubmittedPayload(before, after)) return;
1741
+ row.editedAt = /* @__PURE__ */ new Date();
1742
+ row.editedBy = user?.id ?? null;
1743
+ }
1744
+ async function handleSubmissionCreated(context, config, nextly) {
1745
+ if (!config.notifications.enabled) return;
1746
+ const submission = context.data;
1747
+ if (!submission) return;
1748
+ if (submission.status === "spam") return;
1749
+ const formId = parentFormId(submission);
1750
+ if (!formId) return;
1751
+ const declaredFormsSlug = config.formOverrides.slug;
1752
+ const form = await fetchParentForm(
1753
+ nextly.self.collections[declaredFormsSlug] ?? declaredFormsSlug,
1754
+ formId,
1755
+ nextly
1756
+ );
1757
+ if (!form) return;
1758
+ const notifications = Array.isArray(form.notifications) ? form.notifications : [];
1759
+ if (notifications.length === 0) return;
1760
+ const emailService = nextly.services.email;
1761
+ if (!emailService) return;
1762
+ const submittedData = parseJsonColumn(submission.data);
1763
+ const formFields = Array.isArray(form.fields) ? form.fields : [];
1764
+ const fileAttachments = collectAttachmentInputs(formFields, submittedData);
1765
+ const { emails, skipped } = buildNotificationEmails({
1766
+ notifications,
1767
+ submittedData,
1768
+ formName: form.name,
1769
+ submissionId: submission.id,
1770
+ defaultFrom: config.notifications.defaultFrom
1771
+ });
1772
+ for (const skip of skipped) {
1773
+ if (skip.reason === "empty-recipient") {
1774
+ nextly.logger.warn?.(
1775
+ "Form Builder: empty recipient, skipping notification",
1776
+ { notificationId: skip.notificationId, formSlug: form.slug }
1777
+ );
1617
1778
  } else {
1618
- fieldTypeRepr = String(fieldType);
1779
+ nextly.logger.debug?.("Form Builder: notification skipped", {
1780
+ notificationId: skip.notificationId,
1781
+ formSlug: form.slug,
1782
+ reason: skip.reason
1783
+ });
1619
1784
  }
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);
1630
1785
  }
1631
- validateFieldByType(field, path, errors);
1632
- if (f.conditionalLogic) {
1633
- validateConditionalLogic(field, path, errors);
1786
+ if (emails.length === 0) return;
1787
+ const finalEmails = await nextly.filters.apply("form-builder.beforeEmail", emails, { form, submission });
1788
+ for (const email2 of finalEmails) {
1789
+ try {
1790
+ await emailService.sendWithTemplate(
1791
+ email2.templateSlug,
1792
+ email2.to,
1793
+ email2.variables,
1794
+ {
1795
+ providerId: email2.providerId,
1796
+ from: email2.from,
1797
+ replyTo: email2.replyTo,
1798
+ cc: email2.cc,
1799
+ bcc: email2.bcc,
1800
+ attachments: fileAttachments.length > 0 ? fileAttachments : void 0
1801
+ }
1802
+ );
1803
+ nextly.logger.info?.("Form Builder: notification sent", {
1804
+ formSlug: form.slug,
1805
+ to: email2.to,
1806
+ templateSlug: email2.templateSlug,
1807
+ notificationId: email2.notificationId,
1808
+ attachmentCount: fileAttachments.length
1809
+ });
1810
+ } catch (err) {
1811
+ nextly.logger.error?.("Form Builder: notification failed", {
1812
+ to: email2.to,
1813
+ templateSlug: email2.templateSlug,
1814
+ notificationId: email2.notificationId,
1815
+ error: err instanceof Error ? err.message : String(err)
1816
+ });
1817
+ }
1634
1818
  }
1635
1819
  }
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);
1641
- });
1820
+ async function injectSubmissionCount(context, submissionSlug, nextly) {
1821
+ const hook = context;
1822
+ if (hook.context?.[SCHEMA_ONLY_READ] === true) return;
1823
+ const records = Array.isArray(hook.data) ? hook.data : hook.data ? [hook.data] : [];
1824
+ await Promise.all(
1825
+ records.map(async (record) => {
1826
+ const form = record;
1827
+ if (typeof form.id !== "string") return;
1828
+ try {
1829
+ form.submissionCount = await nextly.services.collections.count(
1830
+ submissionSlug,
1831
+ {
1832
+ where: {
1833
+ form: { equals: form.id },
1834
+ status: { not_equals: "spam" }
1835
+ }
1836
+ },
1837
+ { as: "system" }
1838
+ );
1839
+ } catch {
1840
+ form.submissionCount = 0;
1841
+ }
1842
+ })
1843
+ );
1642
1844
  }
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"
1845
+ var SCHEMA_ONLY_READ = "formBuilder.schemaOnlyRead";
1846
+ async function fetchParentForm(formsSlug, formId, nextly) {
1847
+ try {
1848
+ const form = await nextly.services.collections.findEntryById(
1849
+ formsSlug,
1850
+ formId,
1851
+ { as: "system", context: { [SCHEMA_ONLY_READ]: true } }
1852
+ );
1853
+ return form;
1854
+ } catch (err) {
1855
+ if (!NextlyError2.isNotFound(err)) throw err;
1856
+ nextly.logger.error?.("Form Builder: form not found", {
1857
+ formId,
1858
+ error: err instanceof Error ? err.message : String(err)
1650
1859
  });
1651
- return;
1860
+ return null;
1652
1861
  }
1653
- if (!Array.isArray(fields)) {
1862
+ }
1863
+
1864
+ // src/config/validate-form.ts
1865
+ import { isPluginFieldTypeOnSurface } from "nextly";
1866
+ var RESERVED_FORM_SLUGS = [
1867
+ // API routes
1868
+ "api",
1869
+ "submit",
1870
+ "submissions",
1871
+ "export",
1872
+ // System slugs
1873
+ "admin",
1874
+ "new",
1875
+ "create",
1876
+ "edit",
1877
+ "delete"
1878
+ ];
1879
+ var RESERVED_SLUGS_SET = new Set(RESERVED_FORM_SLUGS);
1880
+ var SLUG_PATTERN = /^[a-z][a-z0-9_-]*$/;
1881
+ var FIELD_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]*$/;
1882
+ var VALID_FIELD_TYPES = BUILT_IN_FORM_FIELD_TYPES;
1883
+ var VALID_FIELD_TYPES_SET = new Set(VALID_FIELD_TYPES);
1884
+ function isReservedSlug(slug) {
1885
+ return RESERVED_SLUGS_SET.has(slug.toLowerCase());
1886
+ }
1887
+ function validateSlug(slug, errors) {
1888
+ const path = "slug";
1889
+ if (!slug) {
1654
1890
  errors.push({
1655
1891
  path,
1656
- message: "Form fields must be an array",
1657
- code: "FIELDS_INVALID_TYPE"
1892
+ message: "Form slug is required",
1893
+ code: "SLUG_REQUIRED"
1658
1894
  });
1659
1895
  return;
1660
1896
  }
1661
- if (fields.length === 0) {
1897
+ if (typeof slug !== "string") {
1662
1898
  errors.push({
1663
1899
  path,
1664
- message: "Form must have at least one field",
1665
- code: "FIELDS_EMPTY"
1666
- });
1667
- return;
1668
- }
1669
- validateFieldsArray(fields, path, errors);
1670
- }
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;
1682
- }
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"
1690
- });
1691
- }
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;
1704
- }
1705
- function validateAccess(access, errors) {
1706
- if (!access) {
1900
+ message: "Form slug must be a string",
1901
+ code: "SLUG_INVALID_TYPE"
1902
+ });
1707
1903
  return;
1708
1904
  }
1709
- if (typeof access !== "object" || Array.isArray(access)) {
1905
+ if (slug.length < 1) {
1710
1906
  errors.push({
1711
- path: "access",
1712
- message: "Access control must be an object",
1713
- code: "ACCESS_INVALID_TYPE"
1907
+ path,
1908
+ message: "Form slug must be at least 1 character",
1909
+ code: "SLUG_TOO_SHORT"
1714
1910
  });
1715
- return;
1716
1911
  }
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
- }
1912
+ if (slug.length > 50) {
1913
+ errors.push({
1914
+ path,
1915
+ message: "Form slug must be at most 50 characters",
1916
+ code: "SLUG_TOO_LONG"
1917
+ });
1728
1918
  }
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
- );
1919
+ if (!SLUG_PATTERN.test(slug)) {
1920
+ errors.push({
1921
+ path,
1922
+ message: "Form slug must start with a lowercase letter and contain only lowercase letters, numbers, hyphens, and underscores",
1923
+ code: "SLUG_INVALID_FORMAT"
1924
+ });
1749
1925
  }
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
- }
1926
+ if (isReservedSlug(slug)) {
1927
+ errors.push({
1928
+ path,
1929
+ message: `Form slug '${slug}' is reserved and cannot be used`,
1930
+ code: "SLUG_RESERVED"
1931
+ });
1830
1932
  }
1831
- return z.object(schemaShape);
1832
1933
  }
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();
1934
+ function validateFieldName(name, path, errors, seenNames) {
1935
+ if (!name) {
1936
+ errors.push({
1937
+ path: `${path}.name`,
1938
+ message: "Field name is required",
1939
+ code: "FIELD_NAME_REQUIRED"
1940
+ });
1941
+ return;
1863
1942
  }
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);
1943
+ if (typeof name !== "string") {
1944
+ errors.push({
1945
+ path: `${path}.name`,
1946
+ message: "Field name must be a string",
1947
+ code: "FIELD_NAME_REQUIRED"
1948
+ });
1949
+ return;
1910
1950
  }
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);
1951
+ if (!FIELD_NAME_PATTERN.test(name)) {
1952
+ errors.push({
1953
+ path: `${path}.name`,
1954
+ message: `Field name '${name}' must start with a letter and contain only letters, numbers, and underscores`,
1955
+ code: "FIELD_NAME_INVALID_FORMAT"
1956
+ });
1933
1957
  }
1934
- if (field.required) {
1935
- return schema;
1958
+ const nameLower = name.toLowerCase();
1959
+ if (seenNames.has(nameLower)) {
1960
+ errors.push({
1961
+ path: `${path}.name`,
1962
+ message: `Duplicate field name '${name}'`,
1963
+ code: "FIELD_NAME_DUPLICATE"
1964
+ });
1965
+ } else {
1966
+ seenNames.add(nameLower);
1936
1967
  }
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
1968
  }
1955
- function generateTextareaSchema(field) {
1956
- const schema = applyStringRules(z.string(), field, "Invalid format");
1957
- return applyRequired(schema, field.required, customMessage(field));
1969
+ function validateFieldLabel(label, path, errors) {
1970
+ if (!label || typeof label === "string" && label.trim() === "") {
1971
+ errors.push({
1972
+ path: `${path}.label`,
1973
+ message: "Field label is required",
1974
+ code: "FIELD_LABEL_REQUIRED"
1975
+ });
1976
+ }
1958
1977
  }
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();
1978
+ function validateOptions(field, path, errors) {
1979
+ const fieldWithOptions = field;
1980
+ const options = fieldWithOptions.options;
1981
+ if (!options) {
1982
+ errors.push({
1983
+ path: `${path}.options`,
1984
+ message: `${field.type} field must have an 'options' array`,
1985
+ code: "OPTIONS_REQUIRED"
1986
+ });
1987
+ return;
1970
1988
  }
1971
- const schema = z.enum(validValues, {
1972
- error: () => customMessage(field) || "Please select a valid option"
1973
- });
1974
- if (field.required) {
1975
- return schema;
1989
+ if (!Array.isArray(options)) {
1990
+ errors.push({
1991
+ path: `${path}.options`,
1992
+ message: `${field.type} field 'options' must be an array`,
1993
+ code: "OPTIONS_REQUIRED"
1994
+ });
1995
+ return;
1976
1996
  }
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"
1997
+ if (options.length === 0) {
1998
+ errors.push({
1999
+ path: `${path}.options`,
2000
+ message: `${field.type} field must have at least one option`,
2001
+ code: "OPTIONS_EMPTY"
1984
2002
  });
1985
2003
  }
1986
- return schema.optional();
1987
2004
  }
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"
2005
+ function validateConditionalLogic(field, path, errors) {
2006
+ const { conditionalLogic } = field;
2007
+ if (!conditionalLogic || !conditionalLogic.enabled) {
2008
+ return;
2009
+ }
2010
+ if (!conditionalLogic.conditions || conditionalLogic.conditions.length === 0) {
2011
+ errors.push({
2012
+ path: `${path}.conditionalLogic.conditions`,
2013
+ message: "Conditional logic must have at least one condition",
2014
+ code: "CONDITIONS_REQUIRED"
2015
+ });
2016
+ return;
2017
+ }
2018
+ conditionalLogic.conditions.forEach((condition, index) => {
2019
+ const conditionPath = `${path}.conditionalLogic.conditions[${index}]`;
2020
+ if (!condition.field) {
2021
+ errors.push({
2022
+ path: `${conditionPath}.field`,
2023
+ message: "Condition must specify a field name",
2024
+ code: "CONDITION_FIELD_REQUIRED"
2025
+ });
2026
+ }
2027
+ if (!condition.comparison) {
2028
+ errors.push({
2029
+ path: `${conditionPath}.comparison`,
2030
+ message: "Condition must specify a comparison operator",
2031
+ code: "CONDITION_COMPARISON_REQUIRED"
2032
+ });
2033
+ }
1992
2034
  });
1993
- if (field.required) {
1994
- return schema;
2035
+ }
2036
+ function validateFieldByType(field, path, errors) {
2037
+ switch (field.type) {
2038
+ case "select":
2039
+ case "radio":
2040
+ validateOptions(field, path, errors);
2041
+ break;
2042
+ case "number": {
2043
+ const numberField = field;
2044
+ if (numberField.validation) {
2045
+ const { min, max } = numberField.validation;
2046
+ if (min !== void 0 && max !== void 0 && min > max) {
2047
+ errors.push({
2048
+ path: `${path}.validation`,
2049
+ message: "Min value cannot be greater than max value",
2050
+ code: "MIN_GREATER_THAN_MAX"
2051
+ });
2052
+ }
2053
+ }
2054
+ break;
2055
+ }
2056
+ case "text":
2057
+ case "textarea": {
2058
+ const textField = field;
2059
+ if (textField.validation) {
2060
+ const { minLength, maxLength } = textField.validation;
2061
+ if (minLength !== void 0 && maxLength !== void 0 && minLength > maxLength) {
2062
+ errors.push({
2063
+ path: `${path}.validation`,
2064
+ message: "Min length cannot be greater than max length",
2065
+ code: "MIN_LENGTH_GREATER_THAN_MAX"
2066
+ });
2067
+ }
2068
+ }
2069
+ break;
2070
+ }
2071
+ case "file": {
2072
+ const fileField = field;
2073
+ if (fileField.maxFileSize !== void 0 && fileField.maxFileSize <= 0) {
2074
+ errors.push({
2075
+ path: `${path}.maxFileSize`,
2076
+ message: "File maxFileSize must be a positive number",
2077
+ code: "FILE_MAX_SIZE_INVALID"
2078
+ });
2079
+ }
2080
+ break;
2081
+ }
1995
2082
  }
1996
- return schema.optional();
1997
2083
  }
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
- );
2084
+ function validateField(field, path, errors, seenNames) {
2085
+ if (!field || typeof field !== "object") {
2086
+ return;
2087
+ }
2088
+ const f = field;
2089
+ const fieldType = f.type;
2090
+ if (!fieldType) {
2091
+ errors.push({
2092
+ path: `${path}.type`,
2093
+ message: "Field type is required",
2094
+ code: "FIELD_TYPE_REQUIRED"
2095
+ });
2096
+ return;
2097
+ }
2098
+ const isValidType = typeof fieldType === "string" && (VALID_FIELD_TYPES_SET.has(fieldType) || isPluginFieldTypeOnSurface(fieldType, "forms"));
2099
+ if (!isValidType) {
2100
+ let fieldTypeRepr;
2101
+ if (typeof fieldType === "object" && fieldType !== null) {
2102
+ fieldTypeRepr = JSON.stringify(fieldType);
2103
+ } else {
2104
+ fieldTypeRepr = String(fieldType);
2006
2105
  }
2007
- return schema2.optional();
2106
+ errors.push({
2107
+ path: `${path}.type`,
2108
+ message: `Invalid field type '${fieldTypeRepr}'. Valid types: ${VALID_FIELD_TYPES.join(", ")}`,
2109
+ code: "FIELD_TYPE_INVALID"
2110
+ });
2111
+ return;
2112
+ }
2113
+ validateFieldName(f.name, path, errors, seenNames);
2114
+ if (fieldType !== "hidden") {
2115
+ validateFieldLabel(f.label, path, errors);
2116
+ }
2117
+ validateFieldByType(field, path, errors);
2118
+ if (f.conditionalLogic) {
2119
+ validateConditionalLogic(field, path, errors);
2008
2120
  }
2009
- const schema = z.string();
2010
- return applyRequired(schema, field.required, customMessage(field));
2011
2121
  }
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
- );
2122
+ function validateFieldsArray(fields, basePath, errors) {
2123
+ const seenNames = /* @__PURE__ */ new Set();
2124
+ fields.forEach((field, index) => {
2125
+ const fieldPath = `${basePath}[${index}]`;
2126
+ validateField(field, fieldPath, errors, seenNames);
2127
+ });
2128
+ }
2129
+ function validateFields(fields, errors) {
2130
+ const path = "fields";
2131
+ if (!fields) {
2132
+ errors.push({
2133
+ path,
2134
+ message: "Form must have at least one field",
2135
+ code: "FIELDS_REQUIRED"
2136
+ });
2137
+ return;
2035
2138
  }
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
- );
2139
+ if (!Array.isArray(fields)) {
2140
+ errors.push({
2141
+ path,
2142
+ message: "Form fields must be an array",
2143
+ code: "FIELDS_INVALID_TYPE"
2144
+ });
2145
+ return;
2041
2146
  }
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");
2147
+ if (fields.length === 0) {
2148
+ errors.push({
2149
+ path,
2150
+ message: "Form must have at least one field",
2151
+ code: "FIELDS_EMPTY"
2152
+ });
2153
+ return;
2058
2154
  }
2059
- return schema.optional().or(z.literal(""));
2155
+ validateFieldsArray(fields, path, errors);
2060
2156
  }
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;
2157
+ function validateSettings(settings, errors) {
2158
+ if (!settings) {
2159
+ return;
2069
2160
  }
2070
- return transformed;
2071
- }
2072
- function transformFieldValue(value, field) {
2073
- if (value === null || value === void 0) {
2074
- return value;
2161
+ if (typeof settings !== "object" || Array.isArray(settings)) {
2162
+ errors.push({
2163
+ path: "settings",
2164
+ message: "Form settings must be an object",
2165
+ code: "SETTINGS_INVALID_TYPE"
2166
+ });
2167
+ return;
2075
2168
  }
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;
2169
+ const s = settings;
2170
+ if (s.confirmationType === "redirect") {
2171
+ if (!s.redirectUrl && !s.redirectRelation) {
2172
+ errors.push({
2173
+ path: "settings.redirectUrl",
2174
+ message: "Redirect URL or redirect relation is required when confirmation type is 'redirect'",
2175
+ code: "REDIRECT_URL_REQUIRED"
2176
+ });
2177
+ }
2178
+ }
2179
+ if (s.confirmationType === "relationship" && !namesADocument(s.redirectPage)) {
2180
+ errors.push({
2181
+ path: "settings.redirectPage",
2182
+ message: "A page is required when confirmation type is 'relationship'",
2183
+ code: "REDIRECT_PAGE_REQUIRED"
2184
+ });
2107
2185
  }
2108
2186
  }
2109
- function validateFormData(data, fields) {
2110
- const transformed = transformFormData(data, fields);
2111
- const schema = generateZodSchema(fields);
2112
- return schema.safeParse(transformed);
2187
+ function namesADocument(value) {
2188
+ if (typeof value === "string") return value.length > 0;
2189
+ return parseRedirectReference(value, []) !== null;
2113
2190
  }
2114
- function getValidationErrors(result) {
2115
- if (result.success) {
2116
- return {};
2191
+ function validateAccess(access, errors) {
2192
+ if (!access) {
2193
+ return;
2117
2194
  }
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;
2195
+ if (typeof access !== "object" || Array.isArray(access)) {
2196
+ errors.push({
2197
+ path: "access",
2198
+ message: "Access control must be an object",
2199
+ code: "ACCESS_INVALID_TYPE"
2200
+ });
2201
+ return;
2202
+ }
2203
+ const accessObj = access;
2204
+ const validAccessKeys = ["submit", "read"];
2205
+ for (const key of validAccessKeys) {
2206
+ const fn = accessObj[key];
2207
+ if (fn !== void 0 && typeof fn !== "function") {
2208
+ errors.push({
2209
+ path: `access.${key}`,
2210
+ message: `Access control '${key}' must be a function`,
2211
+ code: "ACCESS_FUNCTION_INVALID"
2212
+ });
2123
2213
  }
2124
2214
  }
2125
- return errors;
2126
2215
  }
2216
+ function validateFormConfig(config) {
2217
+ const errors = [];
2218
+ validateSlug(config.slug, errors);
2219
+ validateFields(config.fields, errors);
2220
+ validateSettings(config.settings, errors);
2221
+ validateAccess(config.access, errors);
2222
+ return {
2223
+ valid: errors.length === 0,
2224
+ errors
2225
+ };
2226
+ }
2227
+ function assertValidFormConfig(config) {
2228
+ const result = validateFormConfig(config);
2229
+ if (!result.valid) {
2230
+ const errorMessages = result.errors.map((err) => ` - [${err.code}] ${err.path}: ${err.message}`).join("\n");
2231
+ throw new Error(
2232
+ `Invalid form config for '${config.slug || "unknown"}':
2233
+ ${errorMessages}`
2234
+ );
2235
+ }
2236
+ }
2237
+
2238
+ // src/config/field-helpers.ts
2239
+ var option = (label, value) => ({
2240
+ label,
2241
+ value: value ?? label.toLowerCase().replace(/\s+/g, "_")
2242
+ });
2243
+ var text3 = (config) => ({
2244
+ ...config,
2245
+ type: "text"
2246
+ });
2247
+ var email = (config) => ({
2248
+ ...config,
2249
+ type: "email"
2250
+ });
2251
+ var phone = (config) => ({
2252
+ ...config,
2253
+ type: "phone"
2254
+ });
2255
+ var url = (config) => ({
2256
+ ...config,
2257
+ type: "url"
2258
+ });
2259
+ var textarea3 = (config) => ({
2260
+ ...config,
2261
+ type: "textarea"
2262
+ });
2263
+ var number = (config) => ({
2264
+ ...config,
2265
+ type: "number"
2266
+ });
2267
+ var select3 = (config) => ({
2268
+ ...config,
2269
+ type: "select"
2270
+ });
2271
+ var checkbox2 = (config) => ({
2272
+ ...config,
2273
+ type: "checkbox"
2274
+ });
2275
+ var radio = (config) => ({
2276
+ ...config,
2277
+ type: "radio"
2278
+ });
2279
+ var file = (config) => ({
2280
+ ...config,
2281
+ type: "file"
2282
+ });
2283
+ var date3 = (config) => ({
2284
+ ...config,
2285
+ type: "date"
2286
+ });
2287
+ var time = (config) => ({
2288
+ ...config,
2289
+ type: "time"
2290
+ });
2291
+ var hidden = (config) => ({
2292
+ ...config,
2293
+ type: "hidden"
2294
+ });
2127
2295
 
2128
2296
  // src/handlers/submit-form.ts
2129
2297
  import { formAvailability, NO_SUCH_FORM } from "nextly";
@@ -2322,34 +2490,30 @@ async function submitForm(options, context) {
2322
2490
  return { success: true };
2323
2491
  }
2324
2492
  const isContentSpam = spamResult.isSpam;
2325
- const transformedData = transformFormData(data, form.fields);
2326
- let storedData;
2327
2493
  if (isContentSpam) {
2328
2494
  logger.info?.("Spam submission detected \u2014 storing flagged", {
2329
2495
  formSlug,
2330
2496
  reason: spamResult.reason,
2331
2497
  ipAddress: metadata?.ipAddress
2332
2498
  });
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
2499
  }
2500
+ const prepared = prepareSubmission({
2501
+ data,
2502
+ fields: form.fields,
2503
+ validate: !isContentSpam
2504
+ });
2505
+ if (prepared.validationErrors) {
2506
+ logger.debug?.("Form validation failed", {
2507
+ formSlug,
2508
+ errors: prepared.validationErrors
2509
+ });
2510
+ return {
2511
+ success: false,
2512
+ error: "Validation failed",
2513
+ validationErrors: prepared.validationErrors
2514
+ };
2515
+ }
2516
+ const storedData = prepared.data;
2353
2517
  const submissionData = {
2354
2518
  form: form.id,
2355
2519
  data: storedData,
@@ -2361,7 +2525,10 @@ async function submitForm(options, context) {
2361
2525
  };
2362
2526
  const submission = await collections.createEntry(
2363
2527
  pluginConfig.formSubmissionOverrides.slug,
2364
- submissionData,
2528
+ asPluginSubmission(submissionData, {
2529
+ keepAsEvidence: isContentSpam,
2530
+ form: { id: form.id, fields: form.fields }
2531
+ }),
2365
2532
  // Public form submission — create as system. No ambient user; an
2366
2533
  // empty context already resolves to system, but be explicit.
2367
2534
  { as: "system" }
@@ -2521,15 +2688,17 @@ async function validateSubmission(formSlug, data, context) {
2521
2688
  if (!form) {
2522
2689
  return { valid: false, errors: { _form: "Form not found" } };
2523
2690
  }
2524
- const transformedData = transformFormData(data, form.fields);
2525
- const schema = generateZodSchema(form.fields);
2526
- const result = schema.safeParse(transformedData);
2527
- if (result.success) {
2691
+ const prepared = prepareSubmission({
2692
+ data,
2693
+ fields: form.fields,
2694
+ validate: true
2695
+ });
2696
+ if (!prepared.validationErrors) {
2528
2697
  return { valid: true };
2529
2698
  }
2530
2699
  return {
2531
2700
  valid: false,
2532
- errors: getValidationErrors(result)
2701
+ errors: prepared.validationErrors
2533
2702
  };
2534
2703
  }
2535
2704
  async function isFormAcceptingSubmissions(formSlug, context) {
@@ -2540,25 +2709,6 @@ async function isFormAcceptingSubmissions(formSlug, context) {
2540
2709
  }
2541
2710
  return form.status === "published";
2542
2711
  }
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
2712
  async function getFormSubmissionStats(formSlug, context) {
2563
2713
  const { pluginContext, pluginConfig } = context;
2564
2714
  const { collections } = pluginContext.services;