Yimaru-Admin/src/lib/practiceCreationOrchestrator.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

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 }
}