···1212 AGREEMENT_ANSWER_VALUES,
1313 blockTypeSchema,
1414 getDefaultBlockConfig,
1515- isAgreementAnswerValue,
1615 isChoiceBlock,
1716 isQuestionBlock,
1818- isTextAnswerBlock,
1919- isValidDateAnswer,
2020- isValidLinkAnswer,
2117 parseBlockConfig,
2222- parseNumericAnswer,
2318 serializeBlock,
2419 type BlockConfig,
2520 type ChoiceBlockConfig,
2626- type NumberBlockConfig,
2721 type SerializedBlock,
2828- type TextAnswerBlockConfig,
2922} from "@/lib/blocks";
3023import {
3124 getBranchValidationIssueI18n,
3225 validateBranchingGraph,
3326 resolveNextBlockId,
3427} from "@/lib/branching";
2828+import { validateAndNormalizeAnswer } from "@/lib/answer-validation";
3529import { db } from "@/lib/db";
3630import { AppError } from "@/lib/errors";
3731import { getLocalizedCompletionDefaults } from "@/lib/form-defaults";
···398392 block: SerializedBlock,
399393 rawValue: unknown,
400394): string | string[] | undefined {
401401- const config = block.config;
402395 const questionLabel = getQuestionLabel(block);
396396+ const result = validateAndNormalizeAnswer(block, rawValue);
403397404404- if (!isQuestionBlock(block.type)) {
405405- return undefined;
398398+ if (result.ok) {
399399+ return result.value;
406400 }
407401408408- if (isTextAnswerBlock(block.type)) {
409409- const value = typeof rawValue === "string" ? rawValue.trim() : "";
410410- const validationRegex = (config as TextAnswerBlockConfig).validationRegex;
402402+ switch (result.error.code) {
403403+ case "answer_required":
404404+ if (block.type === FORM_BLOCK_TYPES.NUMBER) {
405405+ throw new AppError(
406406+ `Please enter a number for “${questionLabel}”.`,
407407+ 422,
408408+ );
409409+ }
411410412412- if (!value) {
413413- if (block.required) {
414414- throw new AppError(`Please answer “${questionLabel}”.`, 422);
411411+ if (block.type === FORM_BLOCK_TYPES.LINK) {
412412+ throw new AppError(`Please enter a link for “${questionLabel}”.`, 422);
415413 }
416414417417- return undefined;
418418- }
415415+ if (block.type === FORM_BLOCK_TYPES.DATE) {
416416+ throw new AppError(`Please enter a date for “${questionLabel}”.`, 422);
417417+ }
419418420420- if (validationRegex && !new RegExp(validationRegex).test(value)) {
419419+ throw new AppError(`Please answer “${questionLabel}”.`, 422);
420420+ case "single_choice_required":
421421 throw new AppError(
422422- `Please use a valid format for “${questionLabel}”.`,
422422+ `Please choose an option for “${questionLabel}”.`,
423423 422,
424424 );
425425- }
426426-427427- return value;
428428- }
429429-430430- if (block.type === FORM_BLOCK_TYPES.SINGLE_CHOICE) {
431431- const value = typeof rawValue === "string" ? rawValue.trim() : "";
432432- const options = (config as ChoiceBlockConfig).options;
433433-434434- if (!value) {
435435- if (block.required) {
436436- throw new AppError(
437437- `Please choose an option for “${questionLabel}”.`,
438438- 422,
439439- );
440440- }
441441-442442- return undefined;
443443- }
444444-445445- if (!options.includes(value)) {
425425+ case "multi_choice_required":
426426+ throw new AppError(
427427+ `Please choose at least one option for “${questionLabel}”.`,
428428+ 422,
429429+ );
430430+ case "agreement_required":
431431+ throw new AppError(`Agreement is required for “${questionLabel}”.`, 422);
432432+ case "invalid_text_format":
446433 throw new AppError(
447447- `Invalid option submitted for “${questionLabel}”.`,
434434+ `Please use a valid format for “${questionLabel}”.`,
448435 422,
449436 );
450450- }
451451-452452- return value;
453453- }
454454-455455- if (block.type === FORM_BLOCK_TYPES.MULTIPLE_CHOICE) {
456456- const values = Array.isArray(rawValue)
457457- ? rawValue.filter((value): value is string => typeof value === "string")
458458- : [];
459459- const options = (config as ChoiceBlockConfig).options;
460460- const uniqueValues = [
461461- ...new Set(values.map((value) => value.trim()).filter(Boolean)),
462462- ];
463463-464464- if (!uniqueValues.length) {
465465- if (block.required) {
466466- throw new AppError(
467467- `Please choose at least one option for “${questionLabel}”.`,
468468- 422,
469469- );
470470- }
471471-472472- return undefined;
473473- }
474474-475475- if (!uniqueValues.every((value) => options.includes(value))) {
437437+ case "invalid_option":
476438 throw new AppError(
477439 `Invalid option submitted for “${questionLabel}”.`,
478440 422,
479441 );
480480- }
481481-482482- return uniqueValues;
483483- }
484484-485485- if (block.type === FORM_BLOCK_TYPES.NUMBER) {
486486- const value = typeof rawValue === "string" ? rawValue.trim() : "";
487487- const numberConfig = config as NumberBlockConfig;
488488-489489- if (!value) {
490490- if (block.required) {
491491- throw new AppError(
492492- `Please enter a number for “${questionLabel}”.`,
493493- 422,
494494- );
495495- }
496496-497497- return undefined;
498498- }
499499-500500- const numericValue = parseNumericAnswer(value);
501501-502502- if (numericValue === null) {
442442+ case "invalid_number":
503443 throw new AppError(
504444 `Please enter a valid number for “${questionLabel}”.`,
505445 422,
506446 );
507507- }
508508-509509- if (!numberConfig.allowFloat && !Number.isInteger(numericValue)) {
447447+ case "whole_number_required":
510448 throw new AppError(
511449 `Please enter a whole number for “${questionLabel}”.`,
512450 422,
513451 );
514514- }
515515-516516- if (numberConfig.min !== null && numericValue < numberConfig.min) {
452452+ case "number_below_min":
517453 throw new AppError(
518518- `Please enter a number greater than or equal to ${numberConfig.min} for “${questionLabel}”.`,
454454+ `Please enter a number greater than or equal to ${result.error.min} for “${questionLabel}”.`,
519455 422,
520456 );
521521- }
522522-523523- if (numberConfig.max !== null && numericValue > numberConfig.max) {
457457+ case "number_above_max":
524458 throw new AppError(
525525- `Please enter a number less than or equal to ${numberConfig.max} for “${questionLabel}”.`,
459459+ `Please enter a number less than or equal to ${result.error.max} for “${questionLabel}”.`,
526460 422,
527461 );
528528- }
529529-530530- return String(numericValue);
531531- }
532532-533533- if (block.type === FORM_BLOCK_TYPES.LINK) {
534534- const value = typeof rawValue === "string" ? rawValue.trim() : "";
535535-536536- if (!value) {
537537- if (block.required) {
538538- throw new AppError(`Please enter a link for “${questionLabel}”.`, 422);
539539- }
540540-541541- return undefined;
542542- }
543543-544544- if (!isValidLinkAnswer(value)) {
462462+ case "invalid_link":
545463 throw new AppError(
546464 `Please enter a valid link for “${questionLabel}”.`,
547465 422,
548466 );
549549- }
550550-551551- return new URL(value).toString();
552552- }
553553-554554- if (block.type === FORM_BLOCK_TYPES.DATE) {
555555- const value = typeof rawValue === "string" ? rawValue.trim() : "";
556556-557557- if (!value) {
558558- if (block.required) {
559559- throw new AppError(`Please enter a date for “${questionLabel}”.`, 422);
560560- }
561561-562562- return undefined;
563563- }
564564-565565- if (!isValidDateAnswer(value)) {
467467+ case "invalid_date":
566468 throw new AppError(
567469 `Please enter a valid date for “${questionLabel}”.`,
568470 422,
569471 );
570570- }
571571-572572- return value;
573573- }
574574-575575- if (block.type === FORM_BLOCK_TYPES.AGREEMENT) {
576576- const value = typeof rawValue === "string" ? rawValue.trim() : "";
577577-578578- if (!value) {
579579- if (block.required) {
580580- throw new AppError(
581581- `Agreement is required for “${questionLabel}”.`,
582582- 422,
583583- );
584584- }
585585-586586- return undefined;
587587- }
588588-589589- if (!isAgreementAnswerValue(value)) {
472472+ case "invalid_agreement":
590473 throw new AppError(
591474 `Invalid agreement value submitted for “${questionLabel}”.`,
592475 422,
593476 );
594594- }
595595-596596- if (block.required && value !== AGREEMENT_ANSWER_VALUES.AGREED) {
597597- throw new AppError(`Agreement is required for “${questionLabel}”.`, 422);
598598- }
599599-600600- return value;
601477 }
602602-603603- return undefined;
604478}
605479606480function parseSnapshotBlocks(response: ResponseRecord): SerializedBlock[] {