feat(admin): exam prep practice APIs and coordinated creation flow
Wire exam prep lesson practice list and delete endpoints, refactor practice creation into a five-step orchestrator, and add per-question difficulty and points in the add-practice form. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
b21c679e56
commit
f06bbbee47
|
|
@ -100,6 +100,7 @@ import type {
|
||||||
PublishExamPrepModuleLessonRequest,
|
PublishExamPrepModuleLessonRequest,
|
||||||
CreateExamPrepLessonPracticeRequest,
|
CreateExamPrepLessonPracticeRequest,
|
||||||
CreateExamPrepLessonPracticeResponse,
|
CreateExamPrepLessonPracticeResponse,
|
||||||
|
GetExamPrepLessonPracticesResponse,
|
||||||
GetExamPrepModuleLessonsResponse,
|
GetExamPrepModuleLessonsResponse,
|
||||||
GetTopLevelModuleLessonsResponse,
|
GetTopLevelModuleLessonsResponse,
|
||||||
GetPracticesByParentContextResponse,
|
GetPracticesByParentContextResponse,
|
||||||
|
|
@ -610,6 +611,22 @@ export const createExamPrepLessonPractice = (
|
||||||
data,
|
data,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** GET /exam-prep/lessons/:lessonId/practices */
|
||||||
|
export const getExamPrepLessonPractices = (
|
||||||
|
lessonId: number,
|
||||||
|
params?: { limit?: number; offset?: number },
|
||||||
|
) =>
|
||||||
|
http.get<GetExamPrepLessonPracticesResponse>(
|
||||||
|
`/exam-prep/lessons/${lessonId}/practices`,
|
||||||
|
{ params },
|
||||||
|
)
|
||||||
|
|
||||||
|
/** DELETE /exam-prep/practices/:practiceId */
|
||||||
|
export const deleteExamPrepPractice = (practiceId: number) =>
|
||||||
|
http.delete<{ message: string; success: boolean; status_code: number; metadata: unknown }>(
|
||||||
|
`/exam-prep/practices/${practiceId}`,
|
||||||
|
)
|
||||||
|
|
||||||
/** Top-level course resource (Learn English track) — PUT /courses/:id */
|
/** Top-level course resource (Learn English track) — PUT /courses/:id */
|
||||||
export const updateTopLevelCourse = (courseId: number, data: UpdateTopLevelCourseRequest) =>
|
export const updateTopLevelCourse = (courseId: number, data: UpdateTopLevelCourseRequest) =>
|
||||||
http.put(`/courses/${courseId}`, data)
|
http.put(`/courses/${courseId}`, data)
|
||||||
|
|
|
||||||
|
|
@ -160,10 +160,14 @@ export function legacyQuestionTypeFromDefinition(
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type QuestionDifficultyLevel = "EASY" | "MEDIUM" | "HARD"
|
||||||
|
|
||||||
export interface LearnEnglishDefinitionQuestionInput {
|
export interface LearnEnglishDefinitionQuestionInput {
|
||||||
questionText: string
|
questionText: string
|
||||||
questionTypeDefinitionId: number
|
questionTypeDefinitionId: number
|
||||||
dynamicFieldValues: Record<string, string>
|
dynamicFieldValues: Record<string, string>
|
||||||
|
difficultyLevel?: QuestionDifficultyLevel
|
||||||
|
points?: number
|
||||||
mcqOptions?: { option_text: string; is_correct: boolean }[]
|
mcqOptions?: { option_text: string; is_correct: boolean }[]
|
||||||
trueFalseAnswerIsTrue?: boolean
|
trueFalseAnswerIsTrue?: boolean
|
||||||
shortAnswers?: string[]
|
shortAnswers?: string[]
|
||||||
|
|
@ -269,13 +273,27 @@ export function questionRowHasContent(
|
||||||
return false
|
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(
|
export function buildCreateQuestionFromDefinition(
|
||||||
def: QuestionTypeDefinition,
|
def: QuestionTypeDefinition,
|
||||||
q: LearnEnglishDefinitionQuestionInput,
|
q: LearnEnglishDefinitionQuestionInput,
|
||||||
status: "DRAFT" | "PUBLISHED",
|
status: "DRAFT" | "PUBLISHED",
|
||||||
): CreateQuestionRequest {
|
): CreateQuestionRequest {
|
||||||
const difficulty = "EASY"
|
const difficulty = normalizeQuestionDifficulty(q.difficultyLevel)
|
||||||
const points = 1
|
const points = normalizeQuestionPoints(q.points)
|
||||||
const question_text = q.questionText.trim()
|
const question_text = q.questionText.trim()
|
||||||
|
|
||||||
if (definitionUsesDynamicPayload(def)) {
|
if (definitionUsesDynamicPayload(def)) {
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,14 @@
|
||||||
import type { AxiosError } from "axios"
|
import type { AxiosError } from "axios"
|
||||||
import {
|
|
||||||
addQuestionToSet,
|
|
||||||
createExamPrepLessonPractice,
|
|
||||||
createParentLinkedPractice,
|
|
||||||
createQuestion,
|
|
||||||
createQuestionSet,
|
|
||||||
} from "../api/courses.api"
|
|
||||||
import type { PracticeParentKind } from "../types/course.types"
|
import type { PracticeParentKind } from "../types/course.types"
|
||||||
import type { QuestionTypeDefinition } from "../types/questionTypeDefinition.types"
|
import type { QuestionTypeDefinition } from "../types/questionTypeDefinition.types"
|
||||||
import {
|
import {
|
||||||
buildCreateQuestionFromDefinition,
|
executePracticeCreation,
|
||||||
questionRowHasContent,
|
validatePracticeQuestionsWithDefinitions,
|
||||||
validateDefinitionQuestion,
|
|
||||||
type LearnEnglishDefinitionQuestionInput,
|
type LearnEnglishDefinitionQuestionInput,
|
||||||
} from "./learnEnglishDefinitionQuestion"
|
type PracticeCreationInput,
|
||||||
|
} from "./practiceCreationOrchestrator"
|
||||||
|
|
||||||
export type { LearnEnglishDefinitionQuestionInput } from "./learnEnglishDefinitionQuestion"
|
export type { LearnEnglishDefinitionQuestionInput } from "./practiceCreationOrchestrator"
|
||||||
|
|
||||||
export function learnEnglishPracticeApiErrorMessage(err: unknown): string {
|
export function learnEnglishPracticeApiErrorMessage(err: unknown): string {
|
||||||
const ax = err as AxiosError<{ message?: string; error?: string }>
|
const ax = err as AxiosError<{ message?: string; error?: string }>
|
||||||
|
|
@ -28,34 +21,11 @@ export function learnEnglishPracticeApiErrorMessage(err: unknown): string {
|
||||||
return "Request failed"
|
return "Request failed"
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateLearnEnglishQuestionsWithDefinitions(
|
export const validateLearnEnglishQuestionsWithDefinitions =
|
||||||
questions: LearnEnglishDefinitionQuestionInput[],
|
validatePracticeQuestionsWithDefinitions
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Learn English parent-linked practice: create PRACTICE question set,
|
* @deprecated Use executePracticeCreation — kept for existing imports.
|
||||||
* create questions from GET /questions/type-definitions entries, attach them, POST /practices.
|
|
||||||
*/
|
*/
|
||||||
export async function executeLearnEnglishPracticeCreation(opts: {
|
export async function executeLearnEnglishPracticeCreation(opts: {
|
||||||
parentKind: PracticeParentKind
|
parentKind: PracticeParentKind
|
||||||
|
|
@ -69,96 +39,10 @@ export async function executeLearnEnglishPracticeCreation(opts: {
|
||||||
storyImage: string
|
storyImage: string
|
||||||
quickTips: string
|
quickTips: string
|
||||||
personaName?: string | null
|
personaName?: string | null
|
||||||
/** Selected persona from step 2 — sent as `persona_id` on POST /practices. */
|
|
||||||
personaId: number
|
personaId: number
|
||||||
questions: LearnEnglishDefinitionQuestionInput[]
|
questions: LearnEnglishDefinitionQuestionInput[]
|
||||||
definitions: QuestionTypeDefinition[]
|
definitions: QuestionTypeDefinition[]
|
||||||
/** When set, links practice via POST /exam-prep/lessons/:id/practices instead of POST /practices. */
|
|
||||||
examPrepLessonId?: number
|
examPrepLessonId?: number
|
||||||
}): Promise<{ questionSetId: number; practiceId: number }> {
|
}): Promise<{ questionSetId: number; practiceId: number }> {
|
||||||
const err = validateLearnEnglishQuestionsWithDefinitions(
|
return executePracticeCreation(opts satisfies PracticeCreationInput)
|
||||||
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]))
|
|
||||||
|
|
||||||
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 = setRes.data?.data?.id
|
|
||||||
if (!setId) {
|
|
||||||
throw new Error(
|
|
||||||
(setRes.data as { message?: string } | undefined)?.message ??
|
|
||||||
"Could not create question set",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const toCreate = opts.questions.filter((q) => {
|
|
||||||
const def = byId.get(q.questionTypeDefinitionId)
|
|
||||||
return def ? questionRowHasContent(q, def) : false
|
|
||||||
})
|
|
||||||
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 = qRes.data?.data?.id
|
|
||||||
if (!questionId) {
|
|
||||||
throw new Error(
|
|
||||||
(qRes.data as { message?: string } | undefined)?.message ??
|
|
||||||
"Could not create question",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
await addQuestionToSet(setId, {
|
|
||||||
question_id: questionId,
|
|
||||||
display_order: displayOrder,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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(),
|
|
||||||
})
|
|
||||||
: 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 = practiceRes.data?.data?.id
|
|
||||||
if (!practiceId) {
|
|
||||||
throw new Error(
|
|
||||||
(practiceRes.data as { message?: string } | undefined)?.message ??
|
|
||||||
"Could not create practice",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { questionSetId: setId, practiceId }
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
152
src/lib/practiceCreationOrchestrator.ts
Normal file
152
src/lib/practiceCreationOrchestrator.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
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.filter((q) => {
|
||||||
|
const def = byId.get(q.questionTypeDefinitionId)
|
||||||
|
return def ? questionRowHasContent(q, def) : false
|
||||||
|
})
|
||||||
|
|
||||||
|
// Steps 2 & 3 — create questions and attach to set
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
@ -15,10 +15,10 @@ import type { QuestionTypeDefinition } from "../../types/questionTypeDefinition.
|
||||||
import { getQuestionTypeDefinitions } from "../../api/questionTypeDefinitions.api";
|
import { getQuestionTypeDefinitions } from "../../api/questionTypeDefinitions.api";
|
||||||
import { emptyDynamicFieldValuesForDefinition } from "../../lib/learnEnglishDefinitionQuestion";
|
import { emptyDynamicFieldValuesForDefinition } from "../../lib/learnEnglishDefinitionQuestion";
|
||||||
import {
|
import {
|
||||||
executeLearnEnglishPracticeCreation,
|
|
||||||
learnEnglishPracticeApiErrorMessage,
|
learnEnglishPracticeApiErrorMessage,
|
||||||
validateLearnEnglishQuestionsWithDefinitions,
|
validateLearnEnglishQuestionsWithDefinitions,
|
||||||
} from "../../lib/learnEnglishPracticePublish";
|
} from "../../lib/learnEnglishPracticePublish";
|
||||||
|
import { executePracticeCreation } from "../../lib/practiceCreationOrchestrator";
|
||||||
|
|
||||||
import { ContextStep } from "./components/practice-steps/ContextStep";
|
import { ContextStep } from "./components/practice-steps/ContextStep";
|
||||||
import { ScenarioStep } from "./components/practice-steps/ScenarioStep";
|
import { ScenarioStep } from "./components/practice-steps/ScenarioStep";
|
||||||
|
|
@ -198,6 +198,8 @@ export function AddPracticeFlow() {
|
||||||
id: "q1",
|
id: "q1",
|
||||||
questionTypeDefinitionId: null as number | null,
|
questionTypeDefinitionId: null as number | null,
|
||||||
text: "",
|
text: "",
|
||||||
|
difficultyLevel: "EASY" as "EASY" | "MEDIUM" | "HARD",
|
||||||
|
points: 1,
|
||||||
dynamicFieldValues: {} as Record<string, string>,
|
dynamicFieldValues: {} as Record<string, string>,
|
||||||
mcqOptions: [
|
mcqOptions: [
|
||||||
{ text: "", isCorrect: true },
|
{ text: "", isCorrect: true },
|
||||||
|
|
@ -292,6 +294,11 @@ export function AddPracticeFlow() {
|
||||||
const mappedQuestions = formData.questions.map((q) => ({
|
const mappedQuestions = formData.questions.map((q) => ({
|
||||||
questionText: String(q.text ?? "").trim(),
|
questionText: String(q.text ?? "").trim(),
|
||||||
questionTypeDefinitionId: Number(q.questionTypeDefinitionId),
|
questionTypeDefinitionId: Number(q.questionTypeDefinitionId),
|
||||||
|
difficultyLevel: (q.difficultyLevel ?? "EASY") as
|
||||||
|
| "EASY"
|
||||||
|
| "MEDIUM"
|
||||||
|
| "HARD",
|
||||||
|
points: Number.isFinite(Number(q.points)) ? Number(q.points) : 1,
|
||||||
dynamicFieldValues: { ...(q.dynamicFieldValues ?? {}) },
|
dynamicFieldValues: { ...(q.dynamicFieldValues ?? {}) },
|
||||||
mcqOptions: (q.mcqOptions ?? []).map(
|
mcqOptions: (q.mcqOptions ?? []).map(
|
||||||
(o: { text?: string; isCorrect?: boolean }) => ({
|
(o: { text?: string; isCorrect?: boolean }) => ({
|
||||||
|
|
@ -324,7 +331,7 @@ export function AddPracticeFlow() {
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await executeLearnEnglishPracticeCreation({
|
await executePracticeCreation({
|
||||||
parentKind: parentContext.kind,
|
parentKind: parentContext.kind,
|
||||||
parentId: parentContext.id,
|
parentId: parentContext.id,
|
||||||
examPrepLessonId: useExamPrepLessonApi ? parentContext.id : undefined,
|
examPrepLessonId: useExamPrepLessonApi ? parentContext.id : undefined,
|
||||||
|
|
@ -351,13 +358,7 @@ export function AddPracticeFlow() {
|
||||||
questions: mappedQuestions,
|
questions: mappedQuestions,
|
||||||
definitions: typeDefinitions,
|
definitions: typeDefinitions,
|
||||||
});
|
});
|
||||||
toast.success(
|
toast.success("Practice created successfully");
|
||||||
status === "PUBLISHED" ? "Practice published" : "Draft saved",
|
|
||||||
{
|
|
||||||
description:
|
|
||||||
"Question set, questions, and parent-linked practice were created.",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
setIsPublished(true);
|
setIsPublished(true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error("Could not save practice", {
|
toast.error("Could not save practice", {
|
||||||
|
|
@ -415,6 +416,8 @@ export function AddPracticeFlow() {
|
||||||
questionTypeDefinitionId:
|
questionTypeDefinitionId:
|
||||||
typeDefinitions[0]?.id ?? (null as number | null),
|
typeDefinitions[0]?.id ?? (null as number | null),
|
||||||
text: "",
|
text: "",
|
||||||
|
difficultyLevel: "EASY" as "EASY" | "MEDIUM" | "HARD",
|
||||||
|
points: 1,
|
||||||
dynamicFieldValues: typeDefinitions[0]
|
dynamicFieldValues: typeDefinitions[0]
|
||||||
? emptyDynamicFieldValuesForDefinition(typeDefinitions[0])
|
? emptyDynamicFieldValuesForDefinition(typeDefinitions[0])
|
||||||
: {},
|
: {},
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,28 @@ import {
|
||||||
Loader2,
|
Loader2,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { getPracticesByParentLesson } from "../../api/courses.api";
|
import {
|
||||||
|
deleteExamPrepPractice,
|
||||||
|
getExamPrepLessonPractices,
|
||||||
|
getPracticesByParentLesson,
|
||||||
|
} from "../../api/courses.api";
|
||||||
import { Badge } from "../../components/ui/badge";
|
import { Badge } from "../../components/ui/badge";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import { Card, CardContent } from "../../components/ui/card";
|
import { Card, CardContent } from "../../components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "../../components/ui/dialog";
|
||||||
import type {
|
import type {
|
||||||
|
ExamPrepLessonPractice,
|
||||||
|
GetExamPrepLessonPracticesResponse,
|
||||||
GetPracticesByParentContextResponse,
|
GetPracticesByParentContextResponse,
|
||||||
ParentContextPractice,
|
ParentContextPractice,
|
||||||
} from "../../types/course.types";
|
} from "../../types/course.types";
|
||||||
|
|
@ -31,6 +45,32 @@ function unwrapPracticesEnvelope(
|
||||||
return b.data ?? b.Data ?? null;
|
return b.data ?? b.Data ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function unwrapExamPrepPracticesEnvelope(
|
||||||
|
res: { data?: GetExamPrepLessonPracticesResponse & { Data?: GetExamPrepLessonPracticesResponse["data"] } },
|
||||||
|
): GetExamPrepLessonPracticesResponse["data"] | null {
|
||||||
|
const b = res.data;
|
||||||
|
if (!b) return null;
|
||||||
|
return b.data ?? b.Data ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapExamPrepPracticeToCard(
|
||||||
|
practice: ExamPrepLessonPractice,
|
||||||
|
): ParentContextPractice {
|
||||||
|
return {
|
||||||
|
id: practice.id,
|
||||||
|
parent_kind: "LESSON",
|
||||||
|
parent_id: practice.lesson_id,
|
||||||
|
title: practice.title,
|
||||||
|
story_description: practice.story_description ?? "",
|
||||||
|
story_image: practice.story_image ?? "",
|
||||||
|
question_set_id: practice.question_set_id,
|
||||||
|
quick_tips: practice.quick_tips ?? "",
|
||||||
|
publish_status: practice.publish_status,
|
||||||
|
persona_id: practice.persona_id,
|
||||||
|
created_at: practice.created_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function formatPracticeDate(iso: string): string {
|
function formatPracticeDate(iso: string): string {
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
return Number.isNaN(d.getTime())
|
return Number.isNaN(d.getTime())
|
||||||
|
|
@ -42,10 +82,12 @@ function PracticeCard({
|
||||||
practice,
|
practice,
|
||||||
index,
|
index,
|
||||||
total,
|
total,
|
||||||
|
onDelete,
|
||||||
}: {
|
}: {
|
||||||
practice: ParentContextPractice;
|
practice: ParentContextPractice;
|
||||||
index: number;
|
index: number;
|
||||||
total: number;
|
total: number;
|
||||||
|
onDelete?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [imgFailed, setImgFailed] = useState(false);
|
const [imgFailed, setImgFailed] = useState(false);
|
||||||
const thumb = resolveThumbnailForPreview(practice.story_image);
|
const thumb = resolveThumbnailForPreview(practice.story_image);
|
||||||
|
|
@ -91,13 +133,35 @@ function PracticeCard({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col p-6 sm:p-7">
|
<div className="flex min-w-0 flex-1 flex-col p-6 sm:p-7">
|
||||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
<span className="text-[11px] font-bold uppercase tracking-[0.14em] text-brand-500">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
Practice {index + 1} of {total}
|
<span className="text-[11px] font-bold uppercase tracking-[0.14em] text-brand-500">
|
||||||
</span>
|
Practice {index + 1} of {total}
|
||||||
<Badge variant="secondary" className="font-mono text-[10px] font-semibold">
|
</span>
|
||||||
ID {practice.id}
|
<Badge variant="secondary" className="font-mono text-[10px] font-semibold">
|
||||||
</Badge>
|
ID {practice.id}
|
||||||
|
</Badge>
|
||||||
|
{practice.publish_status ? (
|
||||||
|
<Badge
|
||||||
|
variant={practice.publish_status === "PUBLISHED" ? "default" : "secondary"}
|
||||||
|
className="text-[10px] font-semibold normal-case"
|
||||||
|
>
|
||||||
|
{practice.publish_status}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{onDelete ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1.5 text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||||
|
onClick={onDelete}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 className="text-xl font-semibold leading-snug tracking-tight text-grayScale-900 sm:text-[1.35rem]">
|
<h2 className="text-xl font-semibold leading-snug tracking-tight text-grayScale-900 sm:text-[1.35rem]">
|
||||||
|
|
@ -165,6 +229,8 @@ export function LessonPracticesPage() {
|
||||||
const [totalCount, setTotalCount] = useState(0);
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [loadError, setLoadError] = useState<string | null>(null);
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
const [practiceToDelete, setPracticeToDelete] = useState<ParentContextPractice | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
const lid = lessonId ? Number(lessonId) : NaN;
|
const lid = lessonId ? Number(lessonId) : NaN;
|
||||||
const validLesson = Number.isFinite(lid) && lid > 0;
|
const validLesson = Number.isFinite(lid) && lid > 0;
|
||||||
|
|
@ -179,15 +245,29 @@ export function LessonPracticesPage() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setLoadError(null);
|
setLoadError(null);
|
||||||
try {
|
try {
|
||||||
const res = await getPracticesByParentLesson(lid, { limit: 100, offset: 0 });
|
if (isExamPrep) {
|
||||||
const envelope = unwrapPracticesEnvelope(res);
|
const res = await getExamPrepLessonPractices(lid, { limit: 100, offset: 0 });
|
||||||
const list = Array.isArray(envelope?.practices) ? envelope.practices : [];
|
const envelope = unwrapExamPrepPracticesEnvelope(res);
|
||||||
setPractices(list);
|
const list = Array.isArray(envelope?.practices)
|
||||||
setTotalCount(
|
? envelope.practices.map(mapExamPrepPracticeToCard)
|
||||||
typeof envelope?.total_count === "number"
|
: [];
|
||||||
? envelope.total_count
|
setPractices(list);
|
||||||
: list.length,
|
setTotalCount(
|
||||||
);
|
typeof envelope?.total_count === "number"
|
||||||
|
? envelope.total_count
|
||||||
|
: list.length,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const res = await getPracticesByParentLesson(lid, { limit: 100, offset: 0 });
|
||||||
|
const envelope = unwrapPracticesEnvelope(res);
|
||||||
|
const list = Array.isArray(envelope?.practices) ? envelope.practices : [];
|
||||||
|
setPractices(list);
|
||||||
|
setTotalCount(
|
||||||
|
typeof envelope?.total_count === "number"
|
||||||
|
? envelope.total_count
|
||||||
|
: list.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setPractices([]);
|
setPractices([]);
|
||||||
setTotalCount(0);
|
setTotalCount(0);
|
||||||
|
|
@ -196,7 +276,7 @@ export function LessonPracticesPage() {
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [lid, validLesson]);
|
}, [isExamPrep, lid, validLesson]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
|
|
@ -209,6 +289,22 @@ export function LessonPracticesPage() {
|
||||||
? `/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}/add-practice?lessonId=${lid}&lessonTitle=${encodeURIComponent(lessonTitle || displayTitle)}`
|
? `/new-content/courses/${programType}/${courseId}/${unitId}/${moduleId}/add-practice?lessonId=${lid}&lessonTitle=${encodeURIComponent(lessonTitle || displayTitle)}`
|
||||||
: `/new-content/learn-english/${level}/courses/add-practice?backTo=module&courseId=${courseId}&moduleId=${moduleId}&lessonId=${lid}&lessonTitle=${encodeURIComponent(lessonTitle || displayTitle)}`;
|
: `/new-content/learn-english/${level}/courses/add-practice?backTo=module&courseId=${courseId}&moduleId=${moduleId}&lessonId=${lid}&lessonTitle=${encodeURIComponent(lessonTitle || displayTitle)}`;
|
||||||
|
|
||||||
|
const confirmDeletePractice = async () => {
|
||||||
|
if (!practiceToDelete) return;
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await deleteExamPrepPractice(practiceToDelete.id);
|
||||||
|
toast.success("Practice deleted");
|
||||||
|
setPracticeToDelete(null);
|
||||||
|
await load();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { response?: { data?: { message?: string } } };
|
||||||
|
toast.error(err.response?.data?.message || "Failed to delete practice");
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-b from-[#F4F6FB] via-white to-[#F8FAFC]">
|
<div className="min-h-screen bg-gradient-to-b from-[#F4F6FB] via-white to-[#F8FAFC]">
|
||||||
<div className="pointer-events-none fixed inset-0 -z-10 overflow-hidden">
|
<div className="pointer-events-none fixed inset-0 -z-10 overflow-hidden">
|
||||||
|
|
@ -369,12 +465,52 @@ export function LessonPracticesPage() {
|
||||||
practice={p}
|
practice={p}
|
||||||
index={i}
|
index={i}
|
||||||
total={practices.length}
|
total={practices.length}
|
||||||
|
onDelete={
|
||||||
|
isExamPrep ? () => setPracticeToDelete(p) : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={practiceToDelete !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open && !deleting) setPracticeToDelete(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete this practice?</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-sm text-grayScale-600">
|
||||||
|
<span className="font-semibold text-grayScale-900">
|
||||||
|
{practiceToDelete?.title}
|
||||||
|
</span>{" "}
|
||||||
|
will be removed from this lesson. This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={deleting}
|
||||||
|
onClick={() => setPracticeToDelete(null)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
disabled={deleting}
|
||||||
|
onClick={() => void confirmDeletePractice()}
|
||||||
|
>
|
||||||
|
{deleting ? "Deleting…" : "Delete practice"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ function createEmptyQuestionRow(id: string) {
|
||||||
id,
|
id,
|
||||||
questionTypeDefinitionId: null as number | null,
|
questionTypeDefinitionId: null as number | null,
|
||||||
text: "",
|
text: "",
|
||||||
|
difficultyLevel: "EASY" as "EASY" | "MEDIUM" | "HARD",
|
||||||
|
points: 1,
|
||||||
dynamicFieldValues: {} as Record<string, string>,
|
dynamicFieldValues: {} as Record<string, string>,
|
||||||
mcqOptions: defaultMcqOptions(),
|
mcqOptions: defaultMcqOptions(),
|
||||||
trueFalseCorrect: true,
|
trueFalseCorrect: true,
|
||||||
|
|
@ -376,6 +378,55 @@ export function QuestionsStep({
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-bold uppercase tracking-widest text-grayScale-700">
|
||||||
|
Difficulty
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="h-11 w-full rounded-lg border border-grayScale-200 bg-white px-3 text-sm font-medium text-grayScale-800"
|
||||||
|
value={q.difficultyLevel ?? "EASY"}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newQuestions = [...formData.questions];
|
||||||
|
newQuestions[i] = {
|
||||||
|
...newQuestions[i],
|
||||||
|
difficultyLevel: e.target.value as
|
||||||
|
| "EASY"
|
||||||
|
| "MEDIUM"
|
||||||
|
| "HARD",
|
||||||
|
};
|
||||||
|
setFormData({ ...formData, questions: newQuestions });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="EASY">Easy</option>
|
||||||
|
<option value="MEDIUM">Medium</option>
|
||||||
|
<option value="HARD">Hard</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-bold uppercase tracking-widest text-grayScale-700">
|
||||||
|
Points
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
step={1}
|
||||||
|
value={q.points ?? 1}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newQuestions = [...formData.questions];
|
||||||
|
const parsed = Number.parseInt(e.target.value, 10);
|
||||||
|
newQuestions[i] = {
|
||||||
|
...newQuestions[i],
|
||||||
|
points:
|
||||||
|
Number.isFinite(parsed) && parsed > 0 ? parsed : 1,
|
||||||
|
};
|
||||||
|
setFormData({ ...formData, questions: newQuestions });
|
||||||
|
}}
|
||||||
|
className="h-11 rounded-lg border-grayScale-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-[10px] font-bold uppercase tracking-widest text-grayScale-700">
|
<label className="text-[10px] font-bold uppercase tracking-widest text-grayScale-700">
|
||||||
Question type
|
Question type
|
||||||
|
|
|
||||||
|
|
@ -375,6 +375,7 @@ export interface CreateExamPrepLessonPracticeRequest {
|
||||||
persona_id: number
|
persona_id: number
|
||||||
question_set_id: number
|
question_set_id: number
|
||||||
quick_tips: string
|
quick_tips: string
|
||||||
|
publish_status?: PracticePublishStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateExamPrepLessonPracticeResponse {
|
export interface CreateExamPrepLessonPracticeResponse {
|
||||||
|
|
@ -385,6 +386,34 @@ export interface CreateExamPrepLessonPracticeResponse {
|
||||||
metadata: unknown | null
|
metadata: unknown | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Row from GET /exam-prep/lessons/:lessonId/practices */
|
||||||
|
export interface ExamPrepLessonPractice {
|
||||||
|
id: number
|
||||||
|
lesson_id: number
|
||||||
|
title: string
|
||||||
|
story_description?: string
|
||||||
|
story_image?: string
|
||||||
|
persona_id?: number | null
|
||||||
|
question_set_id: number
|
||||||
|
publish_status?: PracticePublishStatus | string | null
|
||||||
|
quick_tips?: string
|
||||||
|
created_at: string
|
||||||
|
updated_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetExamPrepLessonPracticesResponse {
|
||||||
|
message: string
|
||||||
|
data: {
|
||||||
|
practices: ExamPrepLessonPractice[]
|
||||||
|
total_count: number
|
||||||
|
limit: number
|
||||||
|
offset: number
|
||||||
|
}
|
||||||
|
success: boolean
|
||||||
|
status_code: number
|
||||||
|
metadata: unknown | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface UpdateExamPrepModuleLessonResponse {
|
export interface UpdateExamPrepModuleLessonResponse {
|
||||||
message: string
|
message: string
|
||||||
data: ExamPrepModuleLessonItem
|
data: ExamPrepModuleLessonItem
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user