@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/README.md +4 -5
- package/dist/admin/index.d.ts +24 -3
- package/dist/admin/index.js +27 -8
- package/dist/admin/index.js.map +1 -1
- package/dist/{chunk-H5X3FKOZ.js → chunk-W6NLZFHH.js} +82 -82
- package/dist/chunk-W6NLZFHH.js.map +1 -0
- package/dist/index.js +1325 -1175
- package/dist/index.js.map +1 -1
- package/package.json +13 -13
- package/dist/chunk-H5X3FKOZ.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -31,11 +31,14 @@ import {
|
|
|
31
31
|
pickedDocumentField,
|
|
32
32
|
pluralize,
|
|
33
33
|
toTitleCase
|
|
34
|
-
} from "./chunk-
|
|
34
|
+
} from "./chunk-W6NLZFHH.js";
|
|
35
35
|
|
|
36
36
|
// src/plugin.ts
|
|
37
37
|
import { createRequire } from "module";
|
|
38
|
-
import {
|
|
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/
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
const
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
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
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
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
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
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
|
|
1402
|
-
const
|
|
1403
|
-
if (
|
|
1404
|
-
|
|
1405
|
-
|
|
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
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
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
|
|
1484
|
-
if (
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
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
|
|
1493
|
-
|
|
1494
|
-
const
|
|
1495
|
-
|
|
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
|
|
1520
|
-
const {
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
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
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
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
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
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
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
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
|
-
|
|
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
|
|
1599
|
-
if (
|
|
1600
|
-
|
|
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
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
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
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
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
|
|
1637
|
-
const
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
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
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
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
|
-
|
|
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
|
|
1657
|
-
code: "
|
|
1892
|
+
message: "Form slug is required",
|
|
1893
|
+
code: "SLUG_REQUIRED"
|
|
1658
1894
|
});
|
|
1659
1895
|
return;
|
|
1660
1896
|
}
|
|
1661
|
-
if (
|
|
1897
|
+
if (typeof slug !== "string") {
|
|
1662
1898
|
errors.push({
|
|
1663
1899
|
path,
|
|
1664
|
-
message: "Form must
|
|
1665
|
-
code: "
|
|
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 (
|
|
1905
|
+
if (slug.length < 1) {
|
|
1710
1906
|
errors.push({
|
|
1711
|
-
path
|
|
1712
|
-
message: "
|
|
1713
|
-
code: "
|
|
1907
|
+
path,
|
|
1908
|
+
message: "Form slug must be at least 1 character",
|
|
1909
|
+
code: "SLUG_TOO_SHORT"
|
|
1714
1910
|
});
|
|
1715
|
-
return;
|
|
1716
1911
|
}
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
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
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
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
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
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
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
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
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
}
|
|
1870
|
-
|
|
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
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
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
|
-
|
|
1935
|
-
|
|
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
|
|
1956
|
-
|
|
1957
|
-
|
|
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
|
|
1960
|
-
const
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
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
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
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
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
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
|
|
1989
|
-
const
|
|
1990
|
-
|
|
1991
|
-
|
|
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
|
-
|
|
1994
|
-
|
|
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
|
|
1999
|
-
if (field
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
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
|
-
|
|
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
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
)
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
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 (
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
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
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
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
|
-
|
|
2155
|
+
validateFieldsArray(fields, path, errors);
|
|
2060
2156
|
}
|
|
2061
|
-
function
|
|
2062
|
-
|
|
2063
|
-
|
|
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
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
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
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
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
|
|
2110
|
-
|
|
2111
|
-
|
|
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
|
|
2115
|
-
if (
|
|
2116
|
-
return
|
|
2191
|
+
function validateAccess(access, errors) {
|
|
2192
|
+
if (!access) {
|
|
2193
|
+
return;
|
|
2117
2194
|
}
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
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
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
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:
|
|
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;
|