Yimaru-Admin/src/lib/learnEnglishDefinitionQuestion.ts
Yared Yemane 035d73889e feat(admin): practice edit flow, bulk notifications, and composer UX
Add full practice edit via GET/PUT .../full endpoints with question reorder and collapsible cards. Integrate bulk and scheduled SMS, email, push, and in-app notifications with a scheduled jobs page and improved recipient picker search.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 05:26:35 -07:00

568 lines
18 KiB
TypeScript

import type { CreateQuestionRequest, QuestionOption } from "../types/course.types"
import type { QuestionTypeDefinition } from "../types/questionTypeDefinition.types"
import { createEmptyTable, serializeTableSlotValue } from "./dynamicTableValue"
import { buildDynamicQuestionPayload } from "./practiceDynamicQuestionPayload"
import {
parseMultipleChoiceSlotValue,
serializeMultipleChoiceSlotValue,
defaultMultipleChoiceSlotValue,
validateMultipleChoiceSlotValue,
multipleChoiceSlotHasContent,
} from "./multipleChoiceSlotValue"
import {
defaultMatchingInputsSlotValue,
findMatchingInputsInFieldValues,
matchingAnswerSlotHasContent,
matchingInputsSlotHasContent,
parseMatchingAnswerSlotValue,
parseMatchingInputsSlotValue,
serializeMatchingAnswerSlotValue,
serializeMatchingInputsSlotValue,
validateMatchingAnswerSlotValue,
validateMatchingInputsSlotValue,
} from "./matchingSlotValue"
import {
defaultSelectMissingWordsStimulusSlotValue,
findSelectMissingWordsStimulusInFieldValues,
parseSelectMissingWordsResponseSlotValue,
parseSelectMissingWordsStimulusSlotValue,
selectMissingWordsResponseHasContent,
selectMissingWordsStimulusHasContent,
serializeSelectMissingWordsResponseSlotValue,
serializeSelectMissingWordsStimulusSlotValue,
validateSelectMissingWordsResponseSlotValue,
validateSelectMissingWordsStimulusSlotValue,
} from "./selectMissingWordsSlotValue"
function isMultipleChoiceKind(kind: string): boolean {
const u = kind.trim().toUpperCase()
return u === "MULTIPLE_CHOICE" || u === "OPTION"
}
function isMatchingInputsKind(kind: string): boolean {
return kind.trim().toUpperCase() === "MATCHING_INPUTS"
}
function isMatchingAnswerKind(kind: string): boolean {
return kind.trim().toUpperCase() === "MATCHING_ANSWER"
}
function isSelectMissingWordsKind(kind: string): boolean {
return kind.trim().toUpperCase() === "SELECT_MISSING_WORDS"
}
function isStructuredDynamicSlotKind(kind: string): boolean {
return (
isMultipleChoiceKind(kind) ||
isMatchingInputsKind(kind) ||
isMatchingAnswerKind(kind) ||
isSelectMissingWordsKind(kind)
)
}
function defaultValueForSchemaSlot(
kind: string,
side: "stimulus" | "response",
): string {
const u = kind.trim().toUpperCase()
if (u === "TABLE") {
return serializeTableSlotValue(createEmptyTable(2, 1))
}
if (isMultipleChoiceKind(kind)) {
return serializeMultipleChoiceSlotValue(defaultMultipleChoiceSlotValue())
}
if (isMatchingInputsKind(kind)) {
return serializeMatchingInputsSlotValue(defaultMatchingInputsSlotValue())
}
if (isMatchingAnswerKind(kind)) {
return serializeMatchingAnswerSlotValue({ pairs: [] })
}
if (isSelectMissingWordsKind(kind)) {
if (side === "stimulus") {
return serializeSelectMissingWordsStimulusSlotValue(
defaultSelectMissingWordsStimulusSlotValue(),
)
}
return serializeSelectMissingWordsResponseSlotValue({ blanks: [] })
}
return ""
}
export function definitionUsesDynamicPayload(def: QuestionTypeDefinition): boolean {
return def.stimulus_schema.length > 0 || def.response_schema.length > 0
}
export function emptyDynamicFieldValuesForDefinition(
def: QuestionTypeDefinition,
): Record<string, string> {
const o: Record<string, string> = {}
for (const r of def.stimulus_schema) {
o[`stimulus:${r.id}`] = defaultValueForSchemaSlot(r.kind, "stimulus")
}
for (const r of def.response_schema) {
o[`response:${r.id}`] = defaultValueForSchemaSlot(r.kind, "response")
}
return o
}
const PROMPT_STIMULUS_KINDS = new Set(["QUESTION_TEXT", "INSTRUCTION", "TEXT_PASSAGE"])
/** First stimulus slot used for a plain-text prompt shortcut in the practice UI. */
export function primaryPromptStimulusRow(
def: QuestionTypeDefinition,
): { id: string; kind: string } | null {
for (const kind of ["QUESTION_TEXT", "INSTRUCTION", "TEXT_PASSAGE"] as const) {
const row = def.stimulus_schema.find((r) => r.kind === kind)
if (row) return { id: row.id, kind: row.kind }
}
return def.stimulus_schema[0] ? { id: def.stimulus_schema[0].id, kind: def.stimulus_schema[0].kind } : null
}
export function mergePromptIntoDynamicFieldValues(
def: QuestionTypeDefinition,
questionText: string,
fieldValues: Record<string, string>,
): Record<string, string> {
const merged = { ...fieldValues }
const prompt = questionText.trim()
if (!prompt) return merged
const slot = primaryPromptStimulusRow(def)
if (!slot) return merged
const key = `stimulus:${slot.id}`
if (!merged[key]?.trim()) merged[key] = prompt
return merged
}
export function dynamicPromptFromFieldValues(
def: QuestionTypeDefinition,
fieldValues: Record<string, string>,
): string {
for (const row of def.stimulus_schema) {
if (!PROMPT_STIMULUS_KINDS.has(row.kind)) continue
const v = fieldValues[`stimulus:${row.id}`]?.trim()
if (v) return v
}
return ""
}
/**
* System definitions with empty schema map to classic POST /questions types.
* Returns null when the payload must be DYNAMIC (schema-driven or unknown).
*/
export function legacyQuestionTypeFromDefinition(
def: QuestionTypeDefinition,
): "MCQ" | "TRUE_FALSE" | "SHORT_ANSWER" | null {
if (definitionUsesDynamicPayload(def)) return null
const k = def.key.toLowerCase()
if (k === "multiple_choice") return "MCQ"
if (k === "true_false") return "TRUE_FALSE"
if (k === "short_answer" || k === "fill_in_the_blank") return "SHORT_ANSWER"
return null
}
export type QuestionDifficultyLevel = "EASY" | "MEDIUM" | "HARD"
export interface LearnEnglishDefinitionQuestionInput {
questionText: string
questionTypeDefinitionId: number
dynamicFieldValues: Record<string, string>
difficultyLevel?: QuestionDifficultyLevel
points?: number
displayOrder?: number
mcqOptions?: { option_text: string; is_correct: boolean }[]
trueFalseAnswerIsTrue?: boolean
shortAnswers?: string[]
voicePromptUrl?: string
sampleAnswerVoiceUrl?: string
}
export function questionRowHasContent(
q: LearnEnglishDefinitionQuestionInput,
def: QuestionTypeDefinition,
): boolean {
if (!definitionUsesDynamicPayload(def)) {
return Boolean(q.questionText.trim())
}
if (q.questionText.trim()) return true
const fv = q.dynamicFieldValues ?? {}
for (const row of def.stimulus_schema) {
if (isMultipleChoiceKind(row.kind)) {
if (
multipleChoiceSlotHasContent(
parseMultipleChoiceSlotValue(fv[`stimulus:${row.id}`]),
)
) {
return true
}
continue
}
if (isMatchingInputsKind(row.kind)) {
if (
matchingInputsSlotHasContent(
parseMatchingInputsSlotValue(fv[`stimulus:${row.id}`]),
)
) {
return true
}
continue
}
if (isMatchingAnswerKind(row.kind)) {
if (
matchingAnswerSlotHasContent(
parseMatchingAnswerSlotValue(fv[`stimulus:${row.id}`]),
)
) {
return true
}
continue
}
if (isSelectMissingWordsKind(row.kind)) {
if (
selectMissingWordsStimulusHasContent(
parseSelectMissingWordsStimulusSlotValue(fv[`stimulus:${row.id}`]),
)
) {
return true
}
continue
}
if (fv[`stimulus:${row.id}`]?.trim()) return true
}
for (const row of def.response_schema) {
if (isMultipleChoiceKind(row.kind)) {
if (
multipleChoiceSlotHasContent(
parseMultipleChoiceSlotValue(fv[`response:${row.id}`]),
)
) {
return true
}
continue
}
if (isMatchingInputsKind(row.kind)) {
if (
matchingInputsSlotHasContent(
parseMatchingInputsSlotValue(fv[`response:${row.id}`]),
)
) {
return true
}
continue
}
if (isMatchingAnswerKind(row.kind)) {
if (
matchingAnswerSlotHasContent(
parseMatchingAnswerSlotValue(fv[`response:${row.id}`]),
)
) {
return true
}
continue
}
if (isSelectMissingWordsKind(row.kind)) {
if (
selectMissingWordsResponseHasContent(
parseSelectMissingWordsResponseSlotValue(fv[`response:${row.id}`]),
)
) {
return true
}
continue
}
if (fv[`response:${row.id}`]?.trim()) return true
}
return false
}
function normalizeQuestionDifficulty(
value: string | undefined,
): QuestionDifficultyLevel {
const upper = (value ?? "EASY").trim().toUpperCase()
if (upper === "MEDIUM" || upper === "HARD") return upper
return "EASY"
}
function normalizeQuestionPoints(value: number | undefined): number {
const n = Number(value)
if (!Number.isFinite(n) || n < 1) return 1
return Math.round(n)
}
export function buildCreateQuestionFromDefinition(
def: QuestionTypeDefinition,
q: LearnEnglishDefinitionQuestionInput,
status: "DRAFT" | "PUBLISHED",
): CreateQuestionRequest {
const difficulty = normalizeQuestionDifficulty(q.difficultyLevel)
const points = normalizeQuestionPoints(q.points)
const question_text = q.questionText.trim()
if (definitionUsesDynamicPayload(def)) {
const fieldValues = mergePromptIntoDynamicFieldValues(
def,
q.questionText,
q.dynamicFieldValues ?? {},
)
const payload = buildDynamicQuestionPayload({
stimulusRows: def.stimulus_schema.map((r) => ({ id: r.id, kind: r.kind })),
responseRows: def.response_schema.map((r) => ({ id: r.id, kind: r.kind })),
fieldValues,
mcqOptions: q.mcqOptions,
})
return {
question_type: "DYNAMIC",
question_type_definition_id: def.id,
difficulty_level: difficulty,
points,
status,
dynamic_payload: payload,
}
}
const legacy = legacyQuestionTypeFromDefinition(def)
if (legacy === "MCQ") {
const options: QuestionOption[] = (q.mcqOptions ?? [])
.filter((o) => o.option_text.trim())
.map((o, idx) => ({
option_order: idx + 1,
option_text: o.option_text.trim(),
is_correct: o.is_correct,
}))
return {
question_text,
question_type: "MCQ",
difficulty_level: difficulty,
points,
status,
options,
}
}
if (legacy === "TRUE_FALSE") {
const trueCorrect = q.trueFalseAnswerIsTrue !== false
const options: QuestionOption[] = [
{ option_order: 1, option_text: "True", is_correct: trueCorrect },
{ option_order: 2, option_text: "False", is_correct: !trueCorrect },
]
return {
question_text,
question_type: "TRUE_FALSE",
difficulty_level: difficulty,
points,
status,
options,
}
}
if (legacy === "SHORT_ANSWER") {
const short_answers = (q.shortAnswers ?? [])
.map((s) => s.trim())
.filter(Boolean)
.map((acceptable_answer) => ({
acceptable_answer,
match_type: "CASE_INSENSITIVE" as const,
}))
return {
question_text,
question_type: "SHORT_ANSWER",
difficulty_level: difficulty,
points,
status,
short_answers,
}
}
return {
question_type: "DYNAMIC",
question_type_definition_id: def.id,
difficulty_level: difficulty,
points,
status,
dynamic_payload: { stimulus: [], response: [] },
}
}
export function validateDefinitionQuestion(
def: QuestionTypeDefinition,
q: LearnEnglishDefinitionQuestionInput,
index1Based: number,
): string | null {
const n = index1Based
if (definitionUsesDynamicPayload(def)) {
const fieldValues = mergePromptIntoDynamicFieldValues(
def,
q.questionText,
q.dynamicFieldValues ?? {},
)
const hasPrompt =
Boolean(q.questionText.trim()) || Boolean(dynamicPromptFromFieldValues(def, fieldValues))
const promptRow = def.stimulus_schema.find((r) => PROMPT_STIMULUS_KINDS.has(r.kind) && r.required)
if (promptRow && !hasPrompt) {
return `Question ${n}: enter prompt text (${promptRow.label || promptRow.id}).`
}
for (const row of def.stimulus_schema) {
if (isStructuredDynamicSlotKind(row.kind)) continue
if (!row.required) continue
const v = fieldValues[`stimulus:${row.id}`]?.trim()
if (!v)
return `Question ${n}: fill required stimulus "${row.label || row.id}" (${row.kind}).`
}
for (const row of def.response_schema) {
if (isStructuredDynamicSlotKind(row.kind)) continue
if (!row.required) continue
const v = fieldValues[`response:${row.id}`]?.trim()
if (!v)
return `Question ${n}: fill required response "${row.label || row.id}" (${row.kind}).`
}
const matchingInputs = findMatchingInputsInFieldValues(
fieldValues,
def.stimulus_schema,
def.response_schema,
)
const clozeStimulus = findSelectMissingWordsStimulusInFieldValues(
fieldValues,
def.stimulus_schema,
)
for (const row of def.stimulus_schema) {
if (isMultipleChoiceKind(row.kind)) {
const val = parseMultipleChoiceSlotValue(fieldValues[`stimulus:${row.id}`])
if (!multipleChoiceSlotHasContent(val)) {
if (row.required) {
return `Question ${n}: add choices for stimulus "${row.label || row.id}".`
}
continue
}
const mcqErr = validateMultipleChoiceSlotValue(val)
if (mcqErr) {
return `Question ${n} (stimulus "${row.label || row.id}"): ${mcqErr}`
}
}
if (isMatchingInputsKind(row.kind)) {
const val = parseMatchingInputsSlotValue(fieldValues[`stimulus:${row.id}`])
if (!matchingInputsSlotHasContent(val)) {
if (row.required) {
return `Question ${n}: add matching inputs for stimulus "${row.label || row.id}".`
}
continue
}
const err = validateMatchingInputsSlotValue(val)
if (err) {
return `Question ${n} (stimulus "${row.label || row.id}"): ${err}`
}
}
if (isMatchingAnswerKind(row.kind)) {
const val = parseMatchingAnswerSlotValue(
fieldValues[`stimulus:${row.id}`],
matchingInputs,
)
if (!matchingAnswerSlotHasContent(val)) {
if (row.required) {
return `Question ${n}: add matching pairs for stimulus "${row.label || row.id}".`
}
continue
}
const err = validateMatchingAnswerSlotValue(val, matchingInputs)
if (err) {
return `Question ${n} (stimulus "${row.label || row.id}"): ${err}`
}
}
if (isSelectMissingWordsKind(row.kind)) {
const val = parseSelectMissingWordsStimulusSlotValue(
fieldValues[`stimulus:${row.id}`],
)
if (!selectMissingWordsStimulusHasContent(val)) {
if (row.required) {
return `Question ${n}: add cloze passage for stimulus "${row.label || row.id}".`
}
continue
}
const err = validateSelectMissingWordsStimulusSlotValue(val)
if (err) {
return `Question ${n} (stimulus "${row.label || row.id}"): ${err}`
}
}
}
for (const row of def.response_schema) {
if (isMultipleChoiceKind(row.kind)) {
const val = parseMultipleChoiceSlotValue(fieldValues[`response:${row.id}`])
if (!multipleChoiceSlotHasContent(val)) {
if (row.required) {
return `Question ${n}: add choices for response "${row.label || row.id}".`
}
continue
}
const mcqErr = validateMultipleChoiceSlotValue(val)
if (mcqErr) {
return `Question ${n} (response "${row.label || row.id}"): ${mcqErr}`
}
}
if (isMatchingInputsKind(row.kind)) {
const val = parseMatchingInputsSlotValue(fieldValues[`response:${row.id}`])
if (!matchingInputsSlotHasContent(val)) {
if (row.required) {
return `Question ${n}: add matching inputs for response "${row.label || row.id}".`
}
continue
}
const err = validateMatchingInputsSlotValue(val)
if (err) {
return `Question ${n} (response "${row.label || row.id}"): ${err}`
}
}
if (isMatchingAnswerKind(row.kind)) {
const val = parseMatchingAnswerSlotValue(
fieldValues[`response:${row.id}`],
matchingInputs,
)
if (!matchingAnswerSlotHasContent(val)) {
if (row.required) {
return `Question ${n}: add matching pairs for response "${row.label || row.id}".`
}
continue
}
const err = validateMatchingAnswerSlotValue(val, matchingInputs)
if (err) {
return `Question ${n} (response "${row.label || row.id}"): ${err}`
}
}
if (isSelectMissingWordsKind(row.kind)) {
const val = parseSelectMissingWordsResponseSlotValue(
fieldValues[`response:${row.id}`],
clozeStimulus,
)
if (!selectMissingWordsResponseHasContent(val)) {
if (row.required) {
return `Question ${n}: select words for each blank in response "${row.label || row.id}".`
}
continue
}
const err = validateSelectMissingWordsResponseSlotValue(val, clozeStimulus)
if (err) {
return `Question ${n} (response "${row.label || row.id}"): ${err}`
}
}
}
return null
}
if (!q.questionText.trim()) return `Question ${n}: enter question text.`
const legacy = legacyQuestionTypeFromDefinition(def)
if (legacy === "MCQ") {
const opts = (q.mcqOptions ?? []).filter((o) => o.option_text.trim())
if (opts.length < 2)
return `Question ${n} (${def.display_name}): add at least two choices with text.`
if (!opts.some((o) => o.is_correct))
return `Question ${n} (${def.display_name}): mark one correct choice.`
return null
}
if (legacy === "TRUE_FALSE") return null
if (legacy === "SHORT_ANSWER") {
const answers = (q.shortAnswers ?? []).map((s) => s.trim()).filter(Boolean)
if (answers.length < 1)
return `Question ${n} (${def.display_name}): add at least one acceptable answer.`
return null
}
return null
}