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>
162 lines
5.9 KiB
TypeScript
162 lines
5.9 KiB
TypeScript
import type { AxiosResponse } from "axios"
|
|
import {
|
|
addQuestionToSet,
|
|
createExamPrepLessonPractice,
|
|
createParentLinkedPractice,
|
|
createQuestion,
|
|
createQuestionSet,
|
|
} from "../api/courses.api"
|
|
import type { PracticeParentKind } from "../types/course.types"
|
|
import type { QuestionTypeDefinition } from "../types/questionTypeDefinition.types"
|
|
import {
|
|
buildCreateQuestionFromDefinition,
|
|
questionRowHasContent,
|
|
validateDefinitionQuestion,
|
|
type LearnEnglishDefinitionQuestionInput,
|
|
} from "./learnEnglishDefinitionQuestion"
|
|
|
|
export type { LearnEnglishDefinitionQuestionInput } from "./learnEnglishDefinitionQuestion"
|
|
|
|
export interface PracticeCreationInput {
|
|
parentKind: PracticeParentKind
|
|
parentId: number
|
|
status: "DRAFT" | "PUBLISHED"
|
|
questionSetTitle: string
|
|
questionSetDescription?: string | null
|
|
shuffleQuestions: boolean
|
|
practiceTitle: string
|
|
storyDescription: string
|
|
storyImage: string
|
|
quickTips: string
|
|
personaName?: string | null
|
|
personaId: number
|
|
questions: LearnEnglishDefinitionQuestionInput[]
|
|
definitions: QuestionTypeDefinition[]
|
|
/** English proficiency lesson practices use POST /exam-prep/lessons/:id/practices. */
|
|
examPrepLessonId?: number
|
|
}
|
|
|
|
function extractCreatedResourceId(
|
|
res: AxiosResponse<{ data?: { id?: number }; message?: string }>,
|
|
fallbackMessage: string,
|
|
): number {
|
|
const id = res.data?.data?.id
|
|
if (typeof id === "number" && Number.isFinite(id) && id > 0) return id
|
|
const message = res.data?.message?.trim()
|
|
throw new Error(message || fallbackMessage)
|
|
}
|
|
|
|
export function validatePracticeQuestionsWithDefinitions(
|
|
questions: LearnEnglishDefinitionQuestionInput[],
|
|
definitions: QuestionTypeDefinition[],
|
|
): string | null {
|
|
const byId = new Map(definitions.map((d) => [d.id, d]))
|
|
const filled = questions.filter((q) => {
|
|
const def = byId.get(q.questionTypeDefinitionId)
|
|
return def ? questionRowHasContent(q, def) : false
|
|
})
|
|
if (filled.length === 0) return "Add at least one question with content."
|
|
for (let i = 0; i < filled.length; i++) {
|
|
const q = filled[i]
|
|
if (!Number.isFinite(q.questionTypeDefinitionId) || q.questionTypeDefinitionId <= 0) {
|
|
return `Question ${i + 1}: select a question type from the list.`
|
|
}
|
|
const def = byId.get(q.questionTypeDefinitionId)
|
|
if (!def) {
|
|
return `Question ${i + 1}: type definition #${q.questionTypeDefinitionId} was not found. Refresh and try again.`
|
|
}
|
|
const err = validateDefinitionQuestion(def, q, i + 1)
|
|
if (err) return err
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Coordinated practice creation (5 APIs):
|
|
* 1. POST /question-sets — create PRACTICE question set
|
|
* 2. POST /questions — create each question (DYNAMIC or legacy type)
|
|
* 3. POST /question-sets/:id/questions — attach questions to the set
|
|
* 4. Personas are loaded in the UI (GET /personas) before submit; persona_id is sent on step 5
|
|
* 5. POST /practices or POST /exam-prep/lessons/:id/practices — create practice
|
|
*/
|
|
export async function executePracticeCreation(
|
|
opts: PracticeCreationInput,
|
|
): Promise<{ questionSetId: number; practiceId: number }> {
|
|
const err = validatePracticeQuestionsWithDefinitions(opts.questions, opts.definitions)
|
|
if (err) throw new Error(err)
|
|
|
|
if (!Number.isFinite(opts.personaId) || opts.personaId < 1) {
|
|
throw new Error("persona_id is required. Select a persona before saving.")
|
|
}
|
|
|
|
const byId = new Map(opts.definitions.map((d) => [d.id, d]))
|
|
|
|
// Step 1 — create question set
|
|
const setRes = await createQuestionSet({
|
|
title: opts.questionSetTitle.trim() || "Practice question set",
|
|
description: opts.questionSetDescription?.trim() || null,
|
|
set_type: "PRACTICE",
|
|
owner_type: opts.parentKind,
|
|
owner_id: opts.parentId,
|
|
shuffle_questions: opts.shuffleQuestions,
|
|
status: opts.status,
|
|
...(opts.personaName?.trim() ? { persona: opts.personaName.trim() } : {}),
|
|
})
|
|
const setId = extractCreatedResourceId(setRes, "Could not create question set")
|
|
|
|
const toCreate = opts.questions
|
|
.map((q, index) => ({
|
|
q,
|
|
sortOrder:
|
|
Number.isFinite(q.displayOrder) && (q.displayOrder ?? 0) > 0
|
|
? Number(q.displayOrder)
|
|
: index + 1,
|
|
}))
|
|
.filter(({ q }) => {
|
|
const def = byId.get(q.questionTypeDefinitionId)
|
|
return def ? questionRowHasContent(q, def) : false
|
|
})
|
|
.sort((a, b) => a.sortOrder - b.sortOrder)
|
|
|
|
// Steps 2 & 3 — create questions and attach to set (order from step 3 drag-and-drop)
|
|
let displayOrder = 0
|
|
for (const { q } of toCreate) {
|
|
const def = byId.get(q.questionTypeDefinitionId)
|
|
if (!def) throw new Error(`Missing definition #${q.questionTypeDefinitionId}`)
|
|
displayOrder += 1
|
|
const payload = buildCreateQuestionFromDefinition(def, q, opts.status)
|
|
const qRes = await createQuestion(payload)
|
|
const questionId = extractCreatedResourceId(qRes, "Could not create question")
|
|
await addQuestionToSet(setId, {
|
|
question_id: questionId,
|
|
display_order: displayOrder,
|
|
})
|
|
}
|
|
|
|
// Step 5 — create practice (persona_id + question_set_id from step 1)
|
|
const practiceRes = opts.examPrepLessonId
|
|
? await createExamPrepLessonPractice(opts.examPrepLessonId, {
|
|
title: opts.practiceTitle.trim(),
|
|
story_description: opts.storyDescription.trim(),
|
|
story_image: opts.storyImage.trim(),
|
|
persona_id: opts.personaId,
|
|
question_set_id: setId,
|
|
quick_tips: opts.quickTips.trim(),
|
|
publish_status: opts.status,
|
|
})
|
|
: await createParentLinkedPractice({
|
|
parent_kind: opts.parentKind,
|
|
parent_id: opts.parentId,
|
|
title: opts.practiceTitle.trim(),
|
|
story_description: opts.storyDescription.trim(),
|
|
story_image: opts.storyImage.trim(),
|
|
question_set_id: setId,
|
|
quick_tips: opts.quickTips.trim(),
|
|
publish_status: opts.status,
|
|
persona_id: opts.personaId,
|
|
})
|
|
|
|
const practiceId = extractCreatedResourceId(practiceRes, "Could not create practice")
|
|
return { questionSetId: setId, practiceId }
|
|
}
|