1
17import { FORM_QUESTION_TYPES, type FormQuestionType, type FormConfig } from "@factory/shared";
18
19export interface StoredQuestion {
20 id: string;
21 label: string;
22 type: string;
23 required: boolean;
24 validation: unknown;
25}
26
27export interface AnswerViolation {
28 questionId: string;
29 field?: string;
30 code: string;
31 message: string;
32}
33
34export interface AnswerValidationResult {
35
36 answers: Record<string, unknown>;
37 violations: AnswerViolation[];
38}
39
40const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
41const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
42
43type Val = Record<string, unknown>;
44
45function asVal(v: unknown): Val {
46 return v && typeof v === "object" ? (v as Val) : {};
47}
48
49function isEmpty(v: unknown): boolean {
50 if (v === undefined || v === null) return true;
51 if (typeof v === "string") return v.trim() === "";
52 if (Array.isArray(v)) return v.length === 0;
53 return false;
54}
55
56
61export function buildAnswerResult(
62 questions: StoredQuestion[],
63 raw: Record<string, unknown> | undefined
64): AnswerValidationResult {
65 const answers: Record<string, unknown> = {};
66 const violations: AnswerViolation[] = [];
67 const body = raw && typeof raw === "object" ? raw : {};
68
69 for (const q of questions) {
70 const type = q.type as FormQuestionType;
71 if (!FORM_QUESTION_TYPES.includes(type)) continue;
72 const v = asVal(q.validation);
73 const rawValue = body[q.id];
74 const missing = isEmpty(rawValue);
75
76 if (missing) {
77 if (q.required) {
78 violations.push({ questionId: q.id, code: "required", message: `“${q.label}” is required.` });
79 }
80 continue;
81 }
82
83 switch (type) {
84 case "SHORT_TEXT":
85 case "LONG_TEXT": {
86 if (typeof rawValue !== "string") {
87 violations.push({ questionId: q.id, code: "type", message: "text expected." });
88 break;
89 }
90 const s = rawValue;
91 const min = typeof v.minLength === "number" ? v.minLength : 0;
92 const max = typeof v.maxLength === "number" ? v.maxLength : null;
93 if (s.length < min) violations.push({ questionId: q.id, code: "min_length", message: `must be at least ${min} characters.` });
94 if (max !== null && s.length > max) violations.push({ questionId: q.id, code: "max_length", message: `must be at most ${max} characters.` });
95 if (typeof v.pattern === "string" && v.pattern.length > 0) {
96 try {
97 if (!new RegExp(v.pattern).test(s)) {
98 violations.push({ questionId: q.id, code: "pattern", message: "does not match the required pattern." });
99 }
100 } catch {
101
102 violations.push({ questionId: q.id, code: "config", message: "question configuration is invalid." });
103 }
104 }
105 answers[q.id] = s;
106 break;
107 }
108 case "EMAIL": {
109 if (typeof rawValue !== "string" || !EMAIL_RE.test(rawValue.trim())) {
110 violations.push({ questionId: q.id, code: "email", message: "a valid email address is required." });
111 break;
112 }
113 answers[q.id] = rawValue.trim();
114 break;
115 }
116 case "NUMBER": {
117 const n = typeof rawValue === "number" ? rawValue : typeof rawValue === "string" && rawValue.trim() !== "" ? Number(rawValue) : NaN;
118 if (!Number.isFinite(n)) {
119 violations.push({ questionId: q.id, code: "number", message: "a number is required." });
120 break;
121 }
122 if (v.integer === true && !Number.isInteger(n)) {
123 violations.push({ questionId: q.id, code: "integer", message: "a whole number is required." });
124 }
125 if (typeof v.min === "number" && n < v.min) violations.push({ questionId: q.id, code: "min", message: `must be ≥ ${v.min}.` });
126 if (typeof v.max === "number" && n > v.max) violations.push({ questionId: q.id, code: "max", message: `must be ≤ ${v.max}.` });
127 answers[q.id] = n;
128 break;
129 }
130 case "RATING": {
131 const max = typeof v.max === "number" ? v.max : 5;
132 const n = typeof rawValue === "number" ? rawValue : Number(rawValue);
133 if (!Number.isInteger(n) || n < 1 || n > max) {
134 violations.push({ questionId: q.id, code: "range", message: `choose a rating between 1 and ${max}.` });
135 break;
136 }
137 answers[q.id] = n;
138 break;
139 }
140 case "DATE": {
141 const s = typeof rawValue === "string" ? rawValue : "";
142 if (!ISO_DATE_RE.test(s)) {
143 violations.push({ questionId: q.id, code: "date", message: "a date (YYYY-MM-DD) is required." });
144 break;
145 }
146 if (typeof v.minDate === "string" && s < v.minDate) {
147 violations.push({ questionId: q.id, code: "min_date", message: `must be on or after ${v.minDate}.` });
148 }
149 if (typeof v.maxDate === "string" && s > v.maxDate) {
150 violations.push({ questionId: q.id, code: "max_date", message: `must be on or before ${v.maxDate}.` });
151 }
152 answers[q.id] = s;
153 break;
154 }
155 case "CHECKBOX": {
156 if (typeof rawValue !== "boolean") {
157 violations.push({ questionId: q.id, code: "boolean", message: "true or false expected." });
158 break;
159 }
160 answers[q.id] = rawValue;
161 break;
162 }
163 case "SINGLE_SELECT": {
164 const options: string[] = Array.isArray(v.options) ? (v.options as unknown[]).filter((o): o is string => typeof o === "string") : [];
165 if (typeof rawValue !== "string" || (options.length > 0 && !options.includes(rawValue))) {
166 violations.push({
167 questionId: q.id,
168 code: "option",
169 message: options.length > 0 ? `choose one of: ${options.join(", ")}.` : "choose an option."
170 });
171 break;
172 }
173 answers[q.id] = rawValue;
174 break;
175 }
176 case "MULTI_SELECT": {
177 const options: string[] = Array.isArray(v.options) ? (v.options as unknown[]).filter((o): o is string => typeof o === "string") : [];
178 const arr = Array.isArray(rawValue) ? rawValue : [rawValue];
179 const bad = arr.filter((o): o is string => typeof o !== "string" || (options.length > 0 && !options.includes(o)));
180 if (bad.length > 0) {
181 violations.push({ questionId: q.id, code: "option", message: "all selections must be one of the options." });
182 break;
183 }
184 const unique = [...new Set(arr as string[])];
185 if (unique.length !== arr.length) {
186 violations.push({ questionId: q.id, code: "duplicate", message: "duplicate selections are not allowed." });
187 break;
188 }
189 answers[q.id] = unique;
190 break;
191 }
192 case "FILE": {
193 const maxFiles = typeof v.maxFiles === "number" ? v.maxFiles : 1;
194 const arr = Array.isArray(rawValue) ? rawValue : [rawValue];
195 if (arr.length === 0) {
196 if (q.required) violations.push({ questionId: q.id, code: "required", message: `“${q.label}” is required.` });
197 break;
198 }
199 if (arr.length > maxFiles) {
200 violations.push({ questionId: q.id, code: "max_files", message: `at most ${maxFiles} file${maxFiles > 1 ? "s" : ""} allowed.` });
201 break;
202 }
203 if (!arr.every((f) => typeof f === "string" && f.length > 0)) {
204 violations.push({ questionId: q.id, code: "file_ids", message: "file ids must be strings." });
205 break;
206 }
207 answers[q.id] = arr;
208 break;
209 }
210 default:
211
212 violations.push({ questionId: q.id, code: "config", message: "question configuration is invalid." });
213 }
214 }
215
216 return { answers, violations };
217}
218
219
225export function configIntegrityIssue(questions: StoredQuestion[]): string | null {
226 for (const q of questions) {
227 if (!FORM_QUESTION_TYPES.includes(q.type as FormQuestionType)) {
228 return `question ${q.id} has unknown type ${q.type}`;
229 }
230
231
232
233 }
234 void ({} as FormConfig);
235 return null;
236}
237