this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor: deduplicate answer validation

+502 -309
+36 -144
components/public-form-runner.tsx
··· 29 29 import { Textarea } from "@/components/ui/textarea"; 30 30 import { 31 31 AGREEMENT_ANSWER_VALUES, 32 - getTextValidationPattern, 33 - isValidDateAnswer, 34 - isValidLinkAnswer, 35 - parseNumericAnswer, 36 32 type ChoiceBlockConfig, 37 33 type LinkBlockConfig, 38 34 type LongTextBlockConfig, ··· 40 36 type ShortTextBlockConfig, 41 37 type TextBlockConfig, 42 38 } from "@/lib/blocks"; 39 + import { validateAndNormalizeAnswer } from "@/lib/answer-validation"; 43 40 import { resolveNextBlockId } from "@/lib/branching"; 44 41 import { 45 42 isLegacyDefaultCompletionMessage, ··· 242 239 return true; 243 240 } 244 241 245 - const value = answerSet[currentBlock.id]; 246 - 247 - if ( 248 - currentBlock.type === "SHORT_TEXT" || 249 - currentBlock.type === "LONG_TEXT" 250 - ) { 251 - const textValue = typeof value === "string" ? value.trim() : ""; 252 - 253 - if (!textValue) { 254 - if (!currentBlock.required) { 255 - return true; 256 - } 257 - 258 - showToast(t("publicRunner.answerRequired"), "error"); 259 - return false; 260 - } 261 - 262 - const validationRegex = getTextValidationPattern( 263 - currentBlock.config as ShortTextBlockConfig | LongTextBlockConfig, 264 - ); 265 - 266 - if (validationRegex && !new RegExp(validationRegex).test(textValue)) { 267 - showToast(t("publicRunner.invalidTextFormat"), "error"); 268 - return false; 269 - } 242 + const result = validateAndNormalizeAnswer( 243 + currentBlock, 244 + answerSet[currentBlock.id], 245 + ); 270 246 247 + if (result.ok) { 271 248 return true; 272 249 } 273 250 274 - if (currentBlock.type === "SINGLE_CHOICE") { 275 - if (typeof value === "string" && value.trim()) { 276 - return true; 277 - } 278 - 279 - if (currentBlock.required) { 251 + switch (result.error.code) { 252 + case "answer_required": 253 + showToast(t("publicRunner.answerRequired"), "error"); 254 + return false; 255 + case "single_choice_required": 280 256 showToast(t("publicRunner.singleChoiceRequired"), "error"); 281 257 return false; 282 - } 283 - 284 - return true; 285 - } 286 - 287 - if (currentBlock.type === "MULTIPLE_CHOICE") { 288 - if (Array.isArray(value) && value.length > 0) { 289 - return true; 290 - } 291 - 292 - if (currentBlock.required) { 258 + case "multi_choice_required": 293 259 showToast(t("publicRunner.multiChoiceRequired"), "error"); 294 260 return false; 295 - } 296 - 297 - return true; 298 - } 299 - 300 - if (currentBlock.type === "NUMBER") { 301 - const textValue = typeof value === "string" ? value.trim() : ""; 302 - const config = currentBlock.config as NumberBlockConfig; 303 - 304 - if (!textValue) { 305 - if (!currentBlock.required) { 306 - return true; 307 - } 308 - 309 - showToast(t("publicRunner.answerRequired"), "error"); 261 + case "invalid_option": 262 + showToast( 263 + t( 264 + currentBlock.type === "MULTIPLE_CHOICE" 265 + ? "publicRunner.multiChoiceRequired" 266 + : "publicRunner.singleChoiceRequired", 267 + ), 268 + "error", 269 + ); 310 270 return false; 311 - } 312 - 313 - const numericValue = parseNumericAnswer(textValue); 314 - 315 - if (numericValue === null) { 271 + case "agreement_required": 272 + case "invalid_agreement": 273 + showToast(t("publicRunner.agreementRequired"), "error"); 274 + return false; 275 + case "invalid_text_format": 276 + showToast(t("publicRunner.invalidTextFormat"), "error"); 277 + return false; 278 + case "invalid_number": 316 279 showToast(t("publicRunner.invalidNumber"), "error"); 317 280 return false; 318 - } 319 - 320 - if (!config.allowFloat && !Number.isInteger(numericValue)) { 281 + case "whole_number_required": 321 282 showToast(t("publicRunner.wholeNumberRequired"), "error"); 322 283 return false; 323 - } 324 - 325 - if (config.min !== null && numericValue < config.min) { 284 + case "number_below_min": 326 285 showToast( 327 - t("publicRunner.numberAtLeast", { min: config.min }), 286 + t("publicRunner.numberAtLeast", { min: result.error.min }), 328 287 "error", 329 288 ); 330 289 return false; 331 - } 332 - 333 - if (config.max !== null && numericValue > config.max) { 290 + case "number_above_max": 334 291 showToast( 335 - t("publicRunner.numberAtMost", { max: config.max }), 292 + t("publicRunner.numberAtMost", { max: result.error.max }), 336 293 "error", 337 294 ); 338 295 return false; 339 - } 340 - 341 - return true; 342 - } 343 - 344 - if (currentBlock.type === "LINK") { 345 - const textValue = typeof value === "string" ? value.trim() : ""; 346 - 347 - if (!textValue) { 348 - if (!currentBlock.required) { 349 - return true; 350 - } 351 - 352 - showToast(t("publicRunner.answerRequired"), "error"); 353 - return false; 354 - } 355 - 356 - if (!isValidLinkAnswer(textValue)) { 296 + case "invalid_link": 357 297 showToast(t("publicRunner.invalidLink"), "error"); 358 298 return false; 359 - } 360 - 361 - return true; 362 - } 363 - 364 - if (currentBlock.type === "DATE") { 365 - const textValue = typeof value === "string" ? value.trim() : ""; 366 - 367 - if (!textValue) { 368 - if (!currentBlock.required) { 369 - return true; 370 - } 371 - 372 - showToast(t("publicRunner.answerRequired"), "error"); 373 - return false; 374 - } 375 - 376 - if (!isValidDateAnswer(textValue)) { 299 + case "invalid_date": 377 300 showToast(t("publicRunner.invalidDate"), "error"); 378 301 return false; 379 - } 380 - 381 - return true; 382 302 } 383 - 384 - if (currentBlock.type === "AGREEMENT") { 385 - const textValue = typeof value === "string" ? value.trim() : ""; 386 - 387 - if (!textValue) { 388 - if (!currentBlock.required) { 389 - return true; 390 - } 391 - 392 - showToast(t("publicRunner.agreementRequired"), "error"); 393 - return false; 394 - } 395 - 396 - if ( 397 - currentBlock.required && 398 - textValue !== AGREEMENT_ANSWER_VALUES.AGREED 399 - ) { 400 - showToast(t("publicRunner.agreementRequired"), "error"); 401 - return false; 402 - } 403 - 404 - return ( 405 - textValue === AGREEMENT_ANSWER_VALUES.AGREED || 406 - textValue === AGREEMENT_ANSWER_VALUES.NOT_AGREED 407 - ); 408 - } 409 - 410 - return true; 411 303 }, 412 304 [answers, currentBlock, showToast, t], 413 305 );
+197
lib/answer-validation.test.ts
··· 1 + import { describe, expect, test } from "bun:test"; 2 + 3 + import type { SerializedBlock } from "@/lib/blocks"; 4 + import { validateAndNormalizeAnswer } from "@/lib/answer-validation"; 5 + 6 + function createBlock( 7 + overrides: Partial<SerializedBlock> & Pick<SerializedBlock, "id" | "type">, 8 + ): SerializedBlock { 9 + const now = new Date("2026-04-14T00:00:00.000Z"); 10 + 11 + return { 12 + id: overrides.id, 13 + formId: "form-1", 14 + type: overrides.type, 15 + title: overrides.title ?? overrides.id, 16 + description: overrides.description ?? "", 17 + required: overrides.required ?? false, 18 + position: overrides.position ?? 0, 19 + createdAt: overrides.createdAt ?? now, 20 + updatedAt: overrides.updatedAt ?? now, 21 + config: 22 + overrides.config ?? 23 + (overrides.type === "TEXT" 24 + ? { body: "Intro" } 25 + : overrides.type === "SINGLE_CHOICE" 26 + ? { 27 + options: ["yes", "no"], 28 + branchRules: [], 29 + defaultNextBlockId: null, 30 + } 31 + : overrides.type === "MULTIPLE_CHOICE" 32 + ? { 33 + options: ["red", "blue", "green"], 34 + branchRules: [], 35 + defaultNextBlockId: null, 36 + } 37 + : overrides.type === "NUMBER" 38 + ? { 39 + placeholder: "", 40 + allowFloat: false, 41 + min: null, 42 + max: null, 43 + branchRules: [], 44 + defaultNextBlockId: null, 45 + } 46 + : overrides.type === "AGREEMENT" 47 + ? { 48 + label: "I agree", 49 + branchRules: [], 50 + defaultNextBlockId: null, 51 + } 52 + : { 53 + placeholder: "", 54 + validationRegex: null, 55 + branchRules: [], 56 + defaultNextBlockId: null, 57 + }), 58 + }; 59 + } 60 + 61 + describe("validateAndNormalizeAnswer", () => { 62 + test("normalizes valid text answers", () => { 63 + const block = createBlock({ 64 + id: "name", 65 + type: "SHORT_TEXT", 66 + required: true, 67 + config: { 68 + placeholder: "", 69 + validationRegex: null, 70 + branchRules: [], 71 + defaultNextBlockId: null, 72 + }, 73 + }); 74 + 75 + const result = validateAndNormalizeAnswer(block, " Ada "); 76 + 77 + expect(result.ok).toBe(true); 78 + if (!result.ok) { 79 + return; 80 + } 81 + 82 + expect(result.value).toBe("Ada"); 83 + }); 84 + 85 + test("rejects text that does not match regex", () => { 86 + const block = createBlock({ 87 + id: "code", 88 + type: "SHORT_TEXT", 89 + required: true, 90 + config: { 91 + placeholder: "", 92 + validationRegex: "^[A-Z]+$", 93 + branchRules: [], 94 + defaultNextBlockId: null, 95 + }, 96 + }); 97 + 98 + const result = validateAndNormalizeAnswer(block, "abc"); 99 + 100 + expect(result.ok).toBe(false); 101 + if (result.ok) { 102 + return; 103 + } 104 + 105 + expect(result.error.code).toBe("invalid_text_format"); 106 + }); 107 + 108 + test("rejects invalid single-choice options", () => { 109 + const block = createBlock({ id: "choice", type: "SINGLE_CHOICE" }); 110 + 111 + const result = validateAndNormalizeAnswer(block, "maybe"); 112 + 113 + expect(result.ok).toBe(false); 114 + if (result.ok) { 115 + return; 116 + } 117 + 118 + expect(result.error.code).toBe("invalid_option"); 119 + }); 120 + 121 + test("deduplicates multiple-choice values", () => { 122 + const block = createBlock({ id: "colors", type: "MULTIPLE_CHOICE" }); 123 + 124 + const result = validateAndNormalizeAnswer(block, ["red", "blue", "red"]); 125 + 126 + expect(result.ok).toBe(true); 127 + if (!result.ok) { 128 + return; 129 + } 130 + 131 + expect(JSON.stringify(result.value)).toBe(JSON.stringify(["red", "blue"])); 132 + }); 133 + 134 + test("normalizes valid links", () => { 135 + const block = createBlock({ 136 + id: "site", 137 + type: "LINK", 138 + required: true, 139 + config: { 140 + placeholder: "", 141 + branchRules: [], 142 + defaultNextBlockId: null, 143 + }, 144 + }); 145 + 146 + const result = validateAndNormalizeAnswer(block, "https://example.com"); 147 + 148 + expect(result.ok).toBe(true); 149 + if (!result.ok) { 150 + return; 151 + } 152 + 153 + expect(result.value).toBe("https://example.com/"); 154 + }); 155 + 156 + test("enforces required agreement", () => { 157 + const block = createBlock({ 158 + id: "terms", 159 + type: "AGREEMENT", 160 + required: true, 161 + }); 162 + 163 + const result = validateAndNormalizeAnswer(block, "not_agreed"); 164 + 165 + expect(result.ok).toBe(false); 166 + if (result.ok) { 167 + return; 168 + } 169 + 170 + expect(result.error.code).toBe("agreement_required"); 171 + }); 172 + 173 + test("validates number limits", () => { 174 + const block = createBlock({ 175 + id: "age", 176 + type: "NUMBER", 177 + required: true, 178 + config: { 179 + placeholder: "", 180 + allowFloat: false, 181 + min: 18, 182 + max: 99, 183 + branchRules: [], 184 + defaultNextBlockId: null, 185 + }, 186 + }); 187 + 188 + const result = validateAndNormalizeAnswer(block, "17"); 189 + 190 + expect(result.ok).toBe(false); 191 + if (result.ok) { 192 + return; 193 + } 194 + 195 + expect(result.error.code).toBe("number_below_min"); 196 + }); 197 + });
+230
lib/answer-validation.ts
··· 1 + import { 2 + AGREEMENT_ANSWER_VALUES, 3 + isAgreementAnswerValue, 4 + isQuestionBlock, 5 + isTextAnswerBlock, 6 + isValidDateAnswer, 7 + isValidLinkAnswer, 8 + parseNumericAnswer, 9 + type ChoiceBlockConfig, 10 + type NumberBlockConfig, 11 + type SerializedBlock, 12 + type TextAnswerBlockConfig, 13 + } from "@/lib/blocks"; 14 + import type { AnswerValue } from "@/lib/form-types"; 15 + 16 + export type AnswerValidationError = 17 + | { 18 + code: "answer_required"; 19 + } 20 + | { 21 + code: "single_choice_required"; 22 + } 23 + | { 24 + code: "multi_choice_required"; 25 + } 26 + | { 27 + code: "agreement_required"; 28 + } 29 + | { 30 + code: "invalid_text_format"; 31 + } 32 + | { 33 + code: "invalid_option"; 34 + } 35 + | { 36 + code: "invalid_number"; 37 + } 38 + | { 39 + code: "whole_number_required"; 40 + } 41 + | { 42 + code: "number_below_min"; 43 + min: number; 44 + } 45 + | { 46 + code: "number_above_max"; 47 + max: number; 48 + } 49 + | { 50 + code: "invalid_link"; 51 + } 52 + | { 53 + code: "invalid_date"; 54 + } 55 + | { 56 + code: "invalid_agreement"; 57 + }; 58 + 59 + export type AnswerValidationResult = 60 + | { 61 + ok: true; 62 + value: AnswerValue | undefined; 63 + } 64 + | { 65 + ok: false; 66 + error: AnswerValidationError; 67 + }; 68 + 69 + export function validateAndNormalizeAnswer( 70 + block: SerializedBlock, 71 + rawValue: unknown, 72 + ): AnswerValidationResult { 73 + if (!isQuestionBlock(block.type)) { 74 + return { ok: true, value: undefined }; 75 + } 76 + 77 + if (isTextAnswerBlock(block.type)) { 78 + const value = typeof rawValue === "string" ? rawValue.trim() : ""; 79 + const validationRegex = (block.config as TextAnswerBlockConfig) 80 + .validationRegex; 81 + 82 + if (!value) { 83 + return block.required 84 + ? { ok: false, error: { code: "answer_required" } } 85 + : { ok: true, value: undefined }; 86 + } 87 + 88 + if (validationRegex && !new RegExp(validationRegex).test(value)) { 89 + return { ok: false, error: { code: "invalid_text_format" } }; 90 + } 91 + 92 + return { ok: true, value }; 93 + } 94 + 95 + if (block.type === "SINGLE_CHOICE") { 96 + const value = typeof rawValue === "string" ? rawValue.trim() : ""; 97 + const options = (block.config as ChoiceBlockConfig).options; 98 + 99 + if (!value) { 100 + return block.required 101 + ? { ok: false, error: { code: "single_choice_required" } } 102 + : { ok: true, value: undefined }; 103 + } 104 + 105 + if (!options.includes(value)) { 106 + return { ok: false, error: { code: "invalid_option" } }; 107 + } 108 + 109 + return { ok: true, value }; 110 + } 111 + 112 + if (block.type === "MULTIPLE_CHOICE") { 113 + const values = Array.isArray(rawValue) 114 + ? rawValue.filter((value): value is string => typeof value === "string") 115 + : []; 116 + const options = (block.config as ChoiceBlockConfig).options; 117 + const uniqueValues = [ 118 + ...new Set(values.map((value) => value.trim()).filter(Boolean)), 119 + ]; 120 + 121 + if (!uniqueValues.length) { 122 + return block.required 123 + ? { ok: false, error: { code: "multi_choice_required" } } 124 + : { ok: true, value: undefined }; 125 + } 126 + 127 + if (!uniqueValues.every((value) => options.includes(value))) { 128 + return { ok: false, error: { code: "invalid_option" } }; 129 + } 130 + 131 + return { ok: true, value: uniqueValues }; 132 + } 133 + 134 + if (block.type === "NUMBER") { 135 + const value = typeof rawValue === "string" ? rawValue.trim() : ""; 136 + const config = block.config as NumberBlockConfig; 137 + 138 + if (!value) { 139 + return block.required 140 + ? { ok: false, error: { code: "answer_required" } } 141 + : { ok: true, value: undefined }; 142 + } 143 + 144 + const numericValue = parseNumericAnswer(value); 145 + 146 + if (numericValue === null) { 147 + return { ok: false, error: { code: "invalid_number" } }; 148 + } 149 + 150 + if (!config.allowFloat && !Number.isInteger(numericValue)) { 151 + return { ok: false, error: { code: "whole_number_required" } }; 152 + } 153 + 154 + if (config.min !== null && numericValue < config.min) { 155 + return { 156 + ok: false, 157 + error: { 158 + code: "number_below_min", 159 + min: config.min, 160 + }, 161 + }; 162 + } 163 + 164 + if (config.max !== null && numericValue > config.max) { 165 + return { 166 + ok: false, 167 + error: { 168 + code: "number_above_max", 169 + max: config.max, 170 + }, 171 + }; 172 + } 173 + 174 + return { ok: true, value: String(numericValue) }; 175 + } 176 + 177 + if (block.type === "LINK") { 178 + const value = typeof rawValue === "string" ? rawValue.trim() : ""; 179 + 180 + if (!value) { 181 + return block.required 182 + ? { ok: false, error: { code: "answer_required" } } 183 + : { ok: true, value: undefined }; 184 + } 185 + 186 + if (!isValidLinkAnswer(value)) { 187 + return { ok: false, error: { code: "invalid_link" } }; 188 + } 189 + 190 + return { ok: true, value: new URL(value).toString() }; 191 + } 192 + 193 + if (block.type === "DATE") { 194 + const value = typeof rawValue === "string" ? rawValue.trim() : ""; 195 + 196 + if (!value) { 197 + return block.required 198 + ? { ok: false, error: { code: "answer_required" } } 199 + : { ok: true, value: undefined }; 200 + } 201 + 202 + if (!isValidDateAnswer(value)) { 203 + return { ok: false, error: { code: "invalid_date" } }; 204 + } 205 + 206 + return { ok: true, value }; 207 + } 208 + 209 + if (block.type === "AGREEMENT") { 210 + const value = typeof rawValue === "string" ? rawValue.trim() : ""; 211 + 212 + if (!value) { 213 + return block.required 214 + ? { ok: false, error: { code: "agreement_required" } } 215 + : { ok: true, value: undefined }; 216 + } 217 + 218 + if (!isAgreementAnswerValue(value)) { 219 + return { ok: false, error: { code: "invalid_agreement" } }; 220 + } 221 + 222 + if (block.required && value !== AGREEMENT_ANSWER_VALUES.AGREED) { 223 + return { ok: false, error: { code: "agreement_required" } }; 224 + } 225 + 226 + return { ok: true, value }; 227 + } 228 + 229 + return { ok: true, value: undefined }; 230 + }
+39 -165
lib/forms.ts
··· 12 12 AGREEMENT_ANSWER_VALUES, 13 13 blockTypeSchema, 14 14 getDefaultBlockConfig, 15 - isAgreementAnswerValue, 16 15 isChoiceBlock, 17 16 isQuestionBlock, 18 - isTextAnswerBlock, 19 - isValidDateAnswer, 20 - isValidLinkAnswer, 21 17 parseBlockConfig, 22 - parseNumericAnswer, 23 18 serializeBlock, 24 19 type BlockConfig, 25 20 type ChoiceBlockConfig, 26 - type NumberBlockConfig, 27 21 type SerializedBlock, 28 - type TextAnswerBlockConfig, 29 22 } from "@/lib/blocks"; 30 23 import { 31 24 getBranchValidationIssueI18n, 32 25 validateBranchingGraph, 33 26 resolveNextBlockId, 34 27 } from "@/lib/branching"; 28 + import { validateAndNormalizeAnswer } from "@/lib/answer-validation"; 35 29 import { db } from "@/lib/db"; 36 30 import { AppError } from "@/lib/errors"; 37 31 import { getLocalizedCompletionDefaults } from "@/lib/form-defaults"; ··· 398 392 block: SerializedBlock, 399 393 rawValue: unknown, 400 394 ): string | string[] | undefined { 401 - const config = block.config; 402 395 const questionLabel = getQuestionLabel(block); 396 + const result = validateAndNormalizeAnswer(block, rawValue); 403 397 404 - if (!isQuestionBlock(block.type)) { 405 - return undefined; 398 + if (result.ok) { 399 + return result.value; 406 400 } 407 401 408 - if (isTextAnswerBlock(block.type)) { 409 - const value = typeof rawValue === "string" ? rawValue.trim() : ""; 410 - const validationRegex = (config as TextAnswerBlockConfig).validationRegex; 402 + switch (result.error.code) { 403 + case "answer_required": 404 + if (block.type === FORM_BLOCK_TYPES.NUMBER) { 405 + throw new AppError( 406 + `Please enter a number for “${questionLabel}”.`, 407 + 422, 408 + ); 409 + } 411 410 412 - if (!value) { 413 - if (block.required) { 414 - throw new AppError(`Please answer “${questionLabel}”.`, 422); 411 + if (block.type === FORM_BLOCK_TYPES.LINK) { 412 + throw new AppError(`Please enter a link for “${questionLabel}”.`, 422); 415 413 } 416 414 417 - return undefined; 418 - } 415 + if (block.type === FORM_BLOCK_TYPES.DATE) { 416 + throw new AppError(`Please enter a date for “${questionLabel}”.`, 422); 417 + } 419 418 420 - if (validationRegex && !new RegExp(validationRegex).test(value)) { 419 + throw new AppError(`Please answer “${questionLabel}”.`, 422); 420 + case "single_choice_required": 421 421 throw new AppError( 422 - `Please use a valid format for “${questionLabel}”.`, 422 + `Please choose an option for “${questionLabel}”.`, 423 423 422, 424 424 ); 425 - } 426 - 427 - return value; 428 - } 429 - 430 - if (block.type === FORM_BLOCK_TYPES.SINGLE_CHOICE) { 431 - const value = typeof rawValue === "string" ? rawValue.trim() : ""; 432 - const options = (config as ChoiceBlockConfig).options; 433 - 434 - if (!value) { 435 - if (block.required) { 436 - throw new AppError( 437 - `Please choose an option for “${questionLabel}”.`, 438 - 422, 439 - ); 440 - } 441 - 442 - return undefined; 443 - } 444 - 445 - if (!options.includes(value)) { 425 + case "multi_choice_required": 426 + throw new AppError( 427 + `Please choose at least one option for “${questionLabel}”.`, 428 + 422, 429 + ); 430 + case "agreement_required": 431 + throw new AppError(`Agreement is required for “${questionLabel}”.`, 422); 432 + case "invalid_text_format": 446 433 throw new AppError( 447 - `Invalid option submitted for “${questionLabel}”.`, 434 + `Please use a valid format for “${questionLabel}”.`, 448 435 422, 449 436 ); 450 - } 451 - 452 - return value; 453 - } 454 - 455 - if (block.type === FORM_BLOCK_TYPES.MULTIPLE_CHOICE) { 456 - const values = Array.isArray(rawValue) 457 - ? rawValue.filter((value): value is string => typeof value === "string") 458 - : []; 459 - const options = (config as ChoiceBlockConfig).options; 460 - const uniqueValues = [ 461 - ...new Set(values.map((value) => value.trim()).filter(Boolean)), 462 - ]; 463 - 464 - if (!uniqueValues.length) { 465 - if (block.required) { 466 - throw new AppError( 467 - `Please choose at least one option for “${questionLabel}”.`, 468 - 422, 469 - ); 470 - } 471 - 472 - return undefined; 473 - } 474 - 475 - if (!uniqueValues.every((value) => options.includes(value))) { 437 + case "invalid_option": 476 438 throw new AppError( 477 439 `Invalid option submitted for “${questionLabel}”.`, 478 440 422, 479 441 ); 480 - } 481 - 482 - return uniqueValues; 483 - } 484 - 485 - if (block.type === FORM_BLOCK_TYPES.NUMBER) { 486 - const value = typeof rawValue === "string" ? rawValue.trim() : ""; 487 - const numberConfig = config as NumberBlockConfig; 488 - 489 - if (!value) { 490 - if (block.required) { 491 - throw new AppError( 492 - `Please enter a number for “${questionLabel}”.`, 493 - 422, 494 - ); 495 - } 496 - 497 - return undefined; 498 - } 499 - 500 - const numericValue = parseNumericAnswer(value); 501 - 502 - if (numericValue === null) { 442 + case "invalid_number": 503 443 throw new AppError( 504 444 `Please enter a valid number for “${questionLabel}”.`, 505 445 422, 506 446 ); 507 - } 508 - 509 - if (!numberConfig.allowFloat && !Number.isInteger(numericValue)) { 447 + case "whole_number_required": 510 448 throw new AppError( 511 449 `Please enter a whole number for “${questionLabel}”.`, 512 450 422, 513 451 ); 514 - } 515 - 516 - if (numberConfig.min !== null && numericValue < numberConfig.min) { 452 + case "number_below_min": 517 453 throw new AppError( 518 - `Please enter a number greater than or equal to ${numberConfig.min} for “${questionLabel}”.`, 454 + `Please enter a number greater than or equal to ${result.error.min} for “${questionLabel}”.`, 519 455 422, 520 456 ); 521 - } 522 - 523 - if (numberConfig.max !== null && numericValue > numberConfig.max) { 457 + case "number_above_max": 524 458 throw new AppError( 525 - `Please enter a number less than or equal to ${numberConfig.max} for “${questionLabel}”.`, 459 + `Please enter a number less than or equal to ${result.error.max} for “${questionLabel}”.`, 526 460 422, 527 461 ); 528 - } 529 - 530 - return String(numericValue); 531 - } 532 - 533 - if (block.type === FORM_BLOCK_TYPES.LINK) { 534 - const value = typeof rawValue === "string" ? rawValue.trim() : ""; 535 - 536 - if (!value) { 537 - if (block.required) { 538 - throw new AppError(`Please enter a link for “${questionLabel}”.`, 422); 539 - } 540 - 541 - return undefined; 542 - } 543 - 544 - if (!isValidLinkAnswer(value)) { 462 + case "invalid_link": 545 463 throw new AppError( 546 464 `Please enter a valid link for “${questionLabel}”.`, 547 465 422, 548 466 ); 549 - } 550 - 551 - return new URL(value).toString(); 552 - } 553 - 554 - if (block.type === FORM_BLOCK_TYPES.DATE) { 555 - const value = typeof rawValue === "string" ? rawValue.trim() : ""; 556 - 557 - if (!value) { 558 - if (block.required) { 559 - throw new AppError(`Please enter a date for “${questionLabel}”.`, 422); 560 - } 561 - 562 - return undefined; 563 - } 564 - 565 - if (!isValidDateAnswer(value)) { 467 + case "invalid_date": 566 468 throw new AppError( 567 469 `Please enter a valid date for “${questionLabel}”.`, 568 470 422, 569 471 ); 570 - } 571 - 572 - return value; 573 - } 574 - 575 - if (block.type === FORM_BLOCK_TYPES.AGREEMENT) { 576 - const value = typeof rawValue === "string" ? rawValue.trim() : ""; 577 - 578 - if (!value) { 579 - if (block.required) { 580 - throw new AppError( 581 - `Agreement is required for “${questionLabel}”.`, 582 - 422, 583 - ); 584 - } 585 - 586 - return undefined; 587 - } 588 - 589 - if (!isAgreementAnswerValue(value)) { 472 + case "invalid_agreement": 590 473 throw new AppError( 591 474 `Invalid agreement value submitted for “${questionLabel}”.`, 592 475 422, 593 476 ); 594 - } 595 - 596 - if (block.required && value !== AGREEMENT_ANSWER_VALUES.AGREED) { 597 - throw new AppError(`Agreement is required for “${questionLabel}”.`, 422); 598 - } 599 - 600 - return value; 601 477 } 602 - 603 - return undefined; 604 478 } 605 479 606 480 function parseSnapshotBlocks(response: ResponseRecord): SerializedBlock[] {