import { Type, type Static } from "typebox"; export const MIN_QUESTIONS = 1; export const MAX_QUESTIONS = 4; export const MIN_OPTIONS = 2; export const MAX_OPTIONS = 5; export const MAX_HEADER_LENGTH = 16; export const MAX_LABEL_LENGTH = 60; export const OTHER_LABEL = "Write my own answer…"; export const CONTINUE_LABEL = "Continue →"; const RESERVED_LABELS = new Set([ OTHER_LABEL.toLocaleLowerCase(), "write my own answer...", CONTINUE_LABEL.toLocaleLowerCase(), ]); export const OptionSchema = Type.Object({ value: Type.Optional( Type.String({ description: "Optional stable machine-readable value returned for this option. Defaults to the display label.", }), ), label: Type.String({ description: `Short display label for this option (maximum ${MAX_LABEL_LENGTH} characters).`, }), description: Type.Optional( Type.String({ description: "Optional explanation shown below the option label.", }), ), }); export const QuestionSchema = Type.Object({ id: Type.Optional( Type.String({ description: "Optional stable question identifier. Defaults to q1, q2, and so on.", }), ), header: Type.Optional( Type.String({ description: "Optional short tab label (maximum 16 characters). Defaults to Q1, Q2, and so on.", }), ), question: Type.String({ description: "The complete question to ask the user.", }), multiSelect: Type.Optional( Type.Boolean({ default: false, description: "Allow multiple predefined choices plus an optional custom answer.", }), ), options: Type.Array(OptionSchema, { description: "Between 2 and 5 choices for this question.", }), }); export const AskUserParamsSchema = Type.Object({ questions: Type.Array(QuestionSchema, { description: "Between 1 and 4 related questions to ask together.", }), }); export type AskUserInput = Static; export interface NormalizedOption { value: string; label: string; description?: string; } export interface NormalizedQuestion { id: string; header: string; question: string; multiSelect: boolean; options: NormalizedOption[]; } export interface AnswerSelection { value: string; label: string; custom: boolean; } export interface QuestionAnswer { questionId: string; question: string; selections: AnswerSelection[]; } export type AskUserError = | "no_ui" | "aborted" | "no_questions" | "too_many_questions" | "too_few_options" | "too_many_options" | "empty_question" | "empty_question_id" | "empty_header" | "header_too_long" | "empty_option_label" | "label_too_long" | "empty_option_value" | "duplicate_question_id" | "duplicate_option_value" | "reserved_label"; export interface AskUserDetails { answers: QuestionAnswer[]; cancelled: boolean; error?: AskUserError; } export type NormalizeResult = | { ok: true; questions: NormalizedQuestion[] } | { ok: false; error: AskUserError; message: string }; function failure(error: AskUserError, message: string): NormalizeResult { return { ok: false, error, message }; } export function normalizeQuestionnaire(params: AskUserInput): NormalizeResult { const rawQuestions = params.questions ?? []; if (rawQuestions.length < MIN_QUESTIONS) { return failure("no_questions", "At least one question is required."); } if (rawQuestions.length > MAX_QUESTIONS) { return failure( "too_many_questions", `At most ${MAX_QUESTIONS} questions are allowed.`, ); } const seenQuestionIds = new Set(); const questions: NormalizedQuestion[] = []; for ( let questionIndex = 0; questionIndex < rawQuestions.length; questionIndex++ ) { const raw = rawQuestions[questionIndex]; const question = raw.question.trim(); if (!question) { return failure( "empty_question", `Question ${questionIndex + 1} is empty.`, ); } const fallbackId = `q${questionIndex + 1}`; const id = raw.id === undefined ? fallbackId : raw.id.trim(); if (!id) { return failure( "empty_question_id", `Question ${questionIndex + 1} has an empty id.`, ); } if (seenQuestionIds.has(id)) { return failure( "duplicate_question_id", `Question id "${id}" is used more than once.`, ); } seenQuestionIds.add(id); const fallbackHeader = `Q${questionIndex + 1}`; const header = raw.header === undefined ? fallbackHeader : raw.header.trim(); if (!header) { return failure( "empty_header", `Question ${questionIndex + 1} has an empty header.`, ); } if (header.length > MAX_HEADER_LENGTH) { return failure( "header_too_long", `Question ${questionIndex + 1} header exceeds ${MAX_HEADER_LENGTH} characters.`, ); } if (raw.options.length < MIN_OPTIONS) { return failure( "too_few_options", `Question "${question}" requires at least ${MIN_OPTIONS} options.`, ); } if (raw.options.length > MAX_OPTIONS) { return failure( "too_many_options", `Question "${question}" allows at most ${MAX_OPTIONS} options.`, ); } const seenValues = new Set(); const options: NormalizedOption[] = []; for (let optionIndex = 0; optionIndex < raw.options.length; optionIndex++) { const option = raw.options[optionIndex]; const label = option.label.trim(); if (!label) { return failure( "empty_option_label", `Option ${optionIndex + 1} for question "${question}" has an empty label.`, ); } if (label.length > MAX_LABEL_LENGTH) { return failure( "label_too_long", `Option ${optionIndex + 1} for question "${question}" exceeds ${MAX_LABEL_LENGTH} characters.`, ); } if (RESERVED_LABELS.has(label.toLocaleLowerCase())) { return failure( "reserved_label", `Option label "${label}" is reserved for questionnaire controls.`, ); } const value = option.value === undefined ? label : option.value.trim(); if (!value) { return failure( "empty_option_value", `Option "${label}" for question "${question}" has an empty value.`, ); } if (seenValues.has(value)) { return failure( "duplicate_option_value", `Option value "${value}" is used more than once in question "${question}".`, ); } seenValues.add(value); const description = option.description?.trim(); options.push({ value, label, ...(description ? { description } : {}), }); } questions.push({ id, header, question, multiSelect: raw.multiSelect === true, options, }); } return { ok: true, questions }; }