diff --git a/docs/course-management-api-integration.md b/docs/course-management-api-integration.md new file mode 100644 index 0000000..ed44e64 --- /dev/null +++ b/docs/course-management-api-integration.md @@ -0,0 +1,479 @@ +# Course Management API Integration Guide + +This document describes the Course Management related APIs used by the admin frontend (`Yimaru-Admin`) and how to integrate them safely. + +It is based on: +- `src/api/courses.api.ts` +- `src/api/files.api.ts` +- `src/types/course.types.ts` +- `src/api/http.ts` + +--- + +## 1) Base setup and auth behavior + +### Base URL +- All requests use `VITE_API_BASE_URL` from environment. + +### Authentication +- Access token is sent automatically as `Authorization: Bearer `. +- On `401`, the frontend attempts token refresh via: + - `POST /auth/refresh` + - payload: `{ access_token, refresh_token, role, member_id }` +- If refresh fails, auth data is cleared and user is redirected to `/login`. + +### Transport notes +- Axios automatically handles `multipart/form-data` boundaries for file upload. +- Any network failure without response also redirects to `/login` (current client behavior). + +--- + +## 2) Core domain model used by frontend + +Current hierarchy used by content management: +- `Category` + - `Sub-category` + - `Course` + - `Level (CEFR)` + - `Module` + - `Sub-module` + - `Videos` + - `Lessons` (question sets with `set_type = QUIZ`) + - `Practices` (question sets with `set_type = PRACTICE`) + +Important migration note: +- Some APIs/types are marked as legacy (`Program`, old `Level/Module` flows). +- Current frontend mostly uses unified hierarchy endpoints under `/course-management/...` plus `/question-sets` and `/questions`. + +--- + +## 3) File/media APIs (used by course management) + +## 3.1 Upload media + +### Endpoint +- `POST /files/upload` + +### Supports +- `media_type`: `"image" | "audio" | "video"` +- File upload via multipart (`file`) or URL import via JSON (`source_url`). + +### For video uploads +- Can send optional `title` and `description`. + +### Typical response fields used by frontend +- `data.object_key` +- `data.url` +- `data.provider` (`MINIO` or `VIMEO`) +- `data.vimeo_id` +- `data.embed_url` + +### Frontend wrapper functions +- `uploadAudioFile(fileOrUrl)` +- `uploadImageFile(fileOrUrl)` +- `uploadVideoFile(fileOrUrl, { title?, description? })` + +## 3.2 Resolve object key to URL + +### Endpoint +- `GET /files/url?key=` + +### Use case +- Resolve media object key when only key is stored. + +--- + +## 4) Category and course APIs + +## 4.1 Get categories (normalized in frontend) + +### Endpoint called +- `GET /course-management/hierarchy` + +### Frontend behavior +- Client transforms flat hierarchy rows into category list. +- Duplicated category names are merged client-side by "richest" record. + +### Wrapper +- `getCourseCategories()` + +## 4.2 Create category or sub-category + +### Category +- `POST /course-management/categories` +- body: `{ name }` + +### Sub-category +- `POST /course-management/sub-categories` +- body: `{ category_id, name }` + +### Wrapper +- `createCourseCategory({ name, parent_id? })` + - if `parent_id` exists, creates sub-category; else category. + +## 4.3 Delete category/sub-category +- `DELETE /course-management/categories/:categoryId` +- `DELETE /course-management/sub-categories/:subCategoryId` + +Wrappers: +- `deleteCourseCategory(categoryId)` +- `deleteCourseSubCategory(subCategoryId)` + +## 4.4 Courses by category + +### Endpoint called +- `GET /course-management/hierarchy` + +### Frontend behavior +- Filters and maps rows to courses client-side. +- If duplicate category names exist, it includes rows matching requested category name. + +Wrapper: +- `getCoursesByCategory(categoryId)` + +## 4.5 Course CRUD +- `POST /course-management/courses` +- `PUT /course-management/courses/:courseId` +- `PUT /course-management/courses/:courseId` (status toggle via `is_active`) +- `DELETE /course-management/courses/:courseId` +- `POST /course-management/courses/:courseId/thumbnail` + +Wrappers: +- `createCourse(data)` +- `updateCourse(courseId, data)` +- `updateCourseStatus(courseId, isActive)` +- `deleteCourse(courseId)` +- `updateCourseThumbnail(courseId, thumbnailUrl)` + +--- + +## 5) Course hierarchy (levels/modules/sub-modules) + +## 5.1 Get full hierarchy for one course + +### Endpoint +- `GET /course-management/courses/:courseId/hierarchy` + +### Wrapper +- `getSubModulesByCourse(courseId)` + +### Frontend behavior +- Maps hierarchy rows into `sub_courses` shape (compatibility naming). +- This is the primary source for module/sub-module tree rendering. + +## 5.2 Create sub-module flow (composed) + +`createSubModule(data)` is a multi-step client workflow: +1. `POST /course-management/levels` +2. `POST /course-management/modules` +3. `POST /course-management/sub-modules` + +Use this when creating a new sub-module from minimal info. + +## 5.3 Direct level/module/sub-module creation +- `createModuleInLevel(levelId, title, description, displayOrder?)` + - `POST /course-management/modules` +- `createSubModuleInModule(moduleId, title, description, displayOrder?)` + - `POST /course-management/sub-modules` + +## 5.4 Update/delete sub-module +- `PUT /course-management/sub-modules/:subModuleId` +- `PUT /course-management/sub-modules/:subModuleId` (status payload) +- `DELETE /course-management/sub-modules/:subModuleId` +- `POST /course-management/sub-courses/:subModuleId/thumbnail` (compat endpoint) + +Wrappers: +- `updateSubModule(...)` +- `updateSubModuleStatus(...)` +- `deleteSubModule(...)` +- `updateSubModuleThumbnail(...)` + +--- + +## 6) Video APIs (sub-module videos) + +## 6.1 List videos for sub-module +- `GET /course-management/sub-modules/:subModuleId/videos` +- wrapper: `getVideosBySubModule(subModuleId)` + +## 6.2 Create video + +Two wrapper variants, same endpoint: +- `POST /course-management/sub-module-videos` + +### Minimal variant +- `createSubCourseVideo({ sub_module_id|sub_course_id, title, description, video_url })` + +### Extended variant +- `createCourseVideo({ sub_module_id|sub_course_id, title, description, video_url, duration, resolution?, visibility?, display_order?, status? })` + +## 6.3 Update/delete video +- `PUT /course-management/sub-module-videos/:videoId` +- `DELETE /course-management/sub-module-videos/:videoId` + +Wrappers: +- `updateSubCourseVideo(videoId, data)` +- `deleteSubCourseVideo(videoId)` + +--- + +## 7) Practices and lessons + +## 7.1 Practices by sub-module +- `GET /question-sets/by-owner?owner_type=SUB_MODULE&owner_id=:subModuleId` +- wrapper: `getPracticesBySubModule(subModuleId)` + +## 7.2 Create practice (composed) + +`createPractice(data)` does: +1. `POST /question-sets` + - `set_type: "PRACTICE"` + - `owner_type: "SUB_MODULE"` + - `owner_id: sub_module_id` +2. If step 1 succeeds, links to sub-module practice: + - `POST /course-management/sub-module-practices` + - includes `question_set_id` and intro metadata + +## 7.3 Create lesson (composed) + +`createLesson(data)` does: +1. `POST /question-sets` + - `set_type: "QUIZ"` + - `owner_type: "SUB_MODULE"` +2. Link question set as lesson: + - `POST /course-management/sub-module-lessons` + +## 7.4 Practice update/delete/status +- `PUT /course-management/practices/:practiceId` +- `PUT /course-management/practices/:practiceId` (status) +- `DELETE /course-management/practices/:practiceId` + +Wrappers: +- `updatePractice(...)` +- `updatePracticeStatus(...)` +- `deletePractice(...)` + +--- + +## 8) Question sets and questions + +## 8.1 Question sets +- `GET /question-sets` with optional query params +- `GET /question-sets/by-owner` +- `GET /question-sets/:id` +- `PUT /question-sets/:id` +- `DELETE /question-sets/:id` +- `POST /question-sets` + +Wrappers: +- `getQuestionSets(params?)` +- `getQuestionSetsByOwner(ownerType, ownerId)` +- `getQuestionSetById(questionSetId)` +- `createQuestionSet(data)` +- `updateQuestionSet(questionSetId, partialData)` +- `deleteQuestionSet(questionSetId)` + +## 8.2 Question list within set +- `GET /question-sets/:questionSetId/questions` +- `POST /question-sets/:questionSetId/questions` (add by question id) + +Wrappers: +- `getQuestionSetQuestions(questionSetId)` +- `addQuestionToSet(questionSetId, { question_id, display_order? })` + +## 8.3 Questions CRUD +- `GET /questions` (filters) +- `GET /questions/:questionId` +- `POST /questions` +- `PUT /questions/:questionId` +- `DELETE /questions/:questionId` + +Wrappers: +- `getQuestions(params)` +- `getQuestionById(questionId)` +- `createQuestion(data)` +- `updateQuestion(questionId, data)` +- `deleteQuestion(questionId)` + +## 8.4 Practice-question convenience wrappers + +`createPracticeQuestion(data)`: +1. Creates question via `POST /questions` +2. Adds it to practice set via `POST /question-sets/:practiceId/questions` + +`updatePracticeQuestion(questionId, data)`: +- maps to `PUT /questions/:questionId` + +`deletePracticeQuestion(questionId)`: +- `DELETE /questions/:questionId` + +## 8.5 Practice question listing endpoint variants +- `getPracticeQuestions(practiceId)` -> `GET /question-sets/:practiceId/questions` +- `getPracticeQuestionsByPractice(practiceId, params)` -> `GET /practices/:practiceId/questions` + +Use the second when you need pagination/filtering by question type. + +--- + +## 9) Human language specific APIs + +## 9.1 Human language hierarchy +- `getHumanLanguageHierarchy()` +- Calls `GET /course-management/hierarchy` +- If backend already returns nested `sub_categories`, uses it directly. +- If backend returns flat rows, client builds nested structure and enriches each course by: + - requesting `/course-management/courses/:courseId/hierarchy` + - requesting `/question-sets/by-owner` per sub-module + - deriving lessons from question sets where `set_type = "QUIZ"` + +This method is heavier than basic endpoints and can issue many requests. + +## 9.2 Human language lessons by course+level +- `GET /course-management/human-language/courses/:courseId/lessons?cefr_level=...` +- wrapper: `getHumanLanguageLessonsByCourse(courseId, cefrLevel)` + +## 9.3 Create human language lesson structure + +`createHumanLanguageLesson(data)` is composed: +1. `POST /course-management/levels` +2. `POST /course-management/modules` +3. `POST /course-management/sub-modules` + +--- + +## 10) Learning path and assessments + +- `GET /course-management/courses/:courseId/learning-path` + - wrapper: `getLearningPath(courseId)` + +- `GET /question-sets/sub-courses/:subModuleId/entry-assessment` + - wrapper: `getSubModuleEntryAssessment(subModuleId)` + +--- + +## 11) Unsupported or stubbed features in current frontend API layer + +The following wrappers are intentionally stubbed in frontend and return resolved promises (no real backend call): +- `getSubModulePrerequisites` +- `addSubModulePrerequisite` +- `removeSubModulePrerequisite` +- `reorderCategories` +- `reorderCourses` +- `reorderSubModules` +- `reorderVideos` +- `reorderPractices` + +Implication: +- UI may appear to support these flows, but persistence is not implemented through backend yet. + +--- + +## 12) Legacy endpoints still exposed (backward compatibility) + +These are still present in `courses.api.ts` but marked deprecated in types: +- Programs APIs +- Old levels APIs +- Old modules APIs +- Practices by level/module APIs + +Prefer unified hierarchy/sub-module/question-set APIs for new work. + +--- + +## 13) Integration patterns and recommendations + +## 13.1 Safe creation flows +- For practice/lesson creation, keep composed behavior: + - create question set first + - then link to sub-module entity +- Handle partial failure: + - if link step fails after question set creation, frontend should show recoverable error and optionally support manual relink. + +## 13.2 Request normalization +- `getQuestionSetsResponse.data` can be either: + - raw array + - object with `question_sets` +- Normalize before rendering. + +## 13.3 Question type mapping +- UI uses `"SHORT"`; backend commonly expects `"SHORT_ANSWER"`. +- Existing wrappers already map `"SHORT"` to `"SHORT_ANSWER"` on create/update practice question. + +## 13.4 Media handling +- Prefer using `/files/upload` wrappers for all media. +- For Vimeo-backed responses, frontend typically consumes `embed_url` (and may append hash from page URL where applicable). + +## 13.5 Retry behavior +- Some hierarchy fetches use single retry (`withSingleRetry`) for resiliency against transient auth/network race conditions. + +--- + +## 14) Quick endpoint index + +### Course management +- `GET /course-management/hierarchy` +- `POST /course-management/categories` +- `POST /course-management/sub-categories` +- `DELETE /course-management/categories/:id` +- `DELETE /course-management/sub-categories/:id` +- `POST /course-management/courses` +- `PUT /course-management/courses/:id` +- `DELETE /course-management/courses/:id` +- `POST /course-management/courses/:id/thumbnail` +- `GET /course-management/courses/:courseId/hierarchy` +- `POST /course-management/levels` +- `POST /course-management/modules` +- `PUT /course-management/levels/:id` +- `DELETE /course-management/levels/:id` +- `PUT /course-management/modules/:id` +- `DELETE /course-management/modules/:id` +- `POST /course-management/sub-modules` +- `PUT /course-management/sub-modules/:id` +- `DELETE /course-management/sub-modules/:id` +- `GET /course-management/sub-modules/:subModuleId/videos` +- `POST /course-management/sub-module-videos` +- `PUT /course-management/sub-module-videos/:id` +- `DELETE /course-management/sub-module-videos/:id` +- `POST /course-management/sub-module-practices` +- `POST /course-management/sub-module-lessons` +- `GET /course-management/courses/:courseId/learning-path` +- `GET /course-management/human-language/courses/:courseId/lessons` + +### Question sets and questions +- `GET /question-sets` +- `GET /question-sets/by-owner` +- `GET /question-sets/:id` +- `POST /question-sets` +- `PUT /question-sets/:id` +- `DELETE /question-sets/:id` +- `GET /question-sets/:id/questions` +- `POST /question-sets/:id/questions` +- `GET /practices/:practiceId/questions` +- `GET /questions` +- `GET /questions/:id` +- `POST /questions` +- `PUT /questions/:id` +- `DELETE /questions/:id` +- `POST /questions/audio-answer` + +### File/media +- `POST /files/upload` +- `GET /files/url` +- `GET /vimeo/sample` +- `POST /vimeo/uploads/pull` + +--- + +## 15) Suggested frontend service contract shape + +For any new frontend module, follow this contract: +- **Input DTOs**: UI-friendly types (can include UI aliases like `SHORT`) +- **Mapper layer**: convert UI DTOs to backend DTOs +- **Transport layer**: pure API calls +- **Normalizer layer**: normalize polymorphic responses (`array` vs `object`) +- **Error policy**: + - show user-actionable toast + - preserve enough context to retry failed composed steps + +This keeps integration robust even with mixed legacy/unified backend surfaces. + diff --git a/src/api/courses.api.ts b/src/api/courses.api.ts index c93de0f..ec88619 100644 --- a/src/api/courses.api.ts +++ b/src/api/courses.api.ts @@ -47,8 +47,20 @@ import type { GetSubCoursePrerequisitesResponse, AddSubCoursePrerequisiteRequest, GetLearningPathResponse, + GetSubModuleLessonDetailResponse, GetHumanLanguageLessonsResponse, - GetHumanLanguageHierarchyResponse, + GetSubModuleLessonsResponse, + GetHumanLanguageSubCategoriesResponse, + GetCategorySubCategoriesResponse, + GetSubCategoryCoursesResponse, + GetCourseLevelsForCourseResponse, + GetCourseLevelsAllResponse, + GetCourseLevelByIdResponse, + GetHumanLanguageHierarchyFlatResponse, + GetCourseHierarchyResponse, + GetSubModulesByModuleResponse, + CourseHierarchyRow, + SubCourse, CreateHumanLanguageLessonRequest, GetSubCourseEntryAssessmentResponse, ReorderItem, @@ -56,6 +68,8 @@ import type { GetRatingsParams, GetVimeoSampleResponse, CreateCourseVideoRequest, + UpdateSubModuleLessonRequest, + UpdateSubModuleLessonResponse, } from "../types/course.types" type UnifiedHierarchyRow = { @@ -67,21 +81,22 @@ type UnifiedHierarchyRow = { course_title?: string | null } -type CourseHierarchyRow = { - course_id: number - course_title: string - level_id?: number | null - cefr_level?: string | null - module_id?: number | null - module_title?: string | null - sub_module_id?: number | null - sub_module_title?: string | null +async function withSingleRetry(request: () => Promise, retryDelayMs = 400): Promise { + try { + return await request() + } catch { + await new Promise((resolve) => setTimeout(resolve, retryDelayMs)) + return request() + } } export const getCourseCategories = () => - http.get("/course-management/hierarchy").then((res) => { + withSingleRetry(() => http.get("/course-management/hierarchy")).then((res) => { const rows: UnifiedHierarchyRow[] = res.data?.data ?? [] - const categoriesMap = new Map() + const categoriesMap = new Map< + number, + { id: number; name: string; is_active: boolean; created_at: string; subCategoryCount: number; courseCount: number } + >() rows.forEach((r) => { if (!categoriesMap.has(r.category_id)) { categoriesMap.set(r.category_id, { @@ -89,10 +104,50 @@ export const getCourseCategories = () => name: r.category_name, is_active: true, created_at: new Date().toISOString(), + subCategoryCount: 0, + courseCount: 0, }) } + const category = categoriesMap.get(r.category_id)! + if (r.sub_category_id) category.subCategoryCount += 1 + if (r.course_id) category.courseCount += 1 }) - const categories = Array.from(categoriesMap.values()) + + // Merge duplicate top-level category names by selecting the richest representative. + type CategoryAggregate = { + id: number + name: string + is_active: boolean + created_at: string + subCategoryCount: number + courseCount: number + } + const categoryByName = new Map() + Array.from(categoriesMap.values()).forEach((category) => { + const key = category.name.trim().toLowerCase() + const existing = categoryByName.get(key) + if (!existing) { + categoryByName.set(key, category) + return + } + if (category.subCategoryCount > existing.subCategoryCount) { + categoryByName.set(key, category) + return + } + if (category.subCategoryCount === existing.subCategoryCount && category.courseCount > existing.courseCount) { + categoryByName.set(key, category) + return + } + if ( + category.subCategoryCount === existing.subCategoryCount && + category.courseCount === existing.courseCount && + category.id > existing.id + ) { + categoryByName.set(key, category) + } + }) + + const categories = Array.from(categoryByName.values()).map(({ subCategoryCount, courseCount, ...category }) => category) return { ...res, data: { @@ -110,20 +165,61 @@ export const createCourseCategory = (data: CreateCourseCategoryRequest) => ? http.post("/course-management/sub-categories", { category_id: data.parent_id, name: data.name }) : http.post("/course-management/categories", { name: data.name }) +export const deleteCourseCategory = (categoryId: number) => + http.delete(`/course-management/categories/${categoryId}`) + +export const deleteCourseSubCategory = (subCategoryId: number) => + http.delete(`/course-management/sub-categories/${subCategoryId}`) + +export const getSubCategoriesByCategoryId = (categoryId: number) => + http.get(`/course-management/categories/${categoryId}/sub-categories`) + +export const createSubCategory = (payload: { + category_id: number + name: string + description?: string | null + display_order?: number +}) => http.post("/course-management/sub-categories", payload) + +export const updateSubCategory = ( + subCategoryId: number, + payload: Partial<{ + name: string + description: string | null + is_active: boolean + display_order: number + }>, +) => http.patch(`/course-management/sub-categories/${subCategoryId}`, payload) + export const getCoursesByCategory = (categoryId: number) => - http.get("/course-management/hierarchy").then((res) => { + withSingleRetry(() => http.get("/course-management/hierarchy")).then((res) => { const rows: UnifiedHierarchyRow[] = res.data?.data ?? [] - const courses = rows - .filter((r) => r.category_id === categoryId && r.course_id) - .map((r) => ({ - id: Number(r.course_id), - category_id: r.category_id, - sub_category_id: r.sub_category_id ?? null, - title: r.course_title ?? "", - description: "", - thumbnail: "", - is_active: true, - })) + + const requestedCategoryRows = rows.filter((r) => r.category_id === categoryId) + const requestedCategoryName = requestedCategoryRows.find((r) => !!r.category_name)?.category_name?.trim().toLowerCase() + const relevantRows = requestedCategoryName + ? rows.filter((r) => r.category_name?.trim().toLowerCase() === requestedCategoryName) + : requestedCategoryRows + + const courseMap = new Map() + relevantRows + .filter((r) => r.course_id) + .forEach((r) => { + const id = Number(r.course_id) + if (!Number.isFinite(id)) return + if (courseMap.has(id)) return + courseMap.set(id, { + id, + category_id: r.category_id, + sub_category_id: r.sub_category_id ?? null, + title: r.course_title ?? "", + description: "", + thumbnail: "", + is_active: true, + }) + }) + const courses = Array.from(courseMap.values()) + return { ...res, data: { ...res.data, data: { courses, total_count: courses.length } }, @@ -147,17 +243,39 @@ export const updateCourseStatus = (courseId: number, isActive: boolean) => export const updateCourse = (courseId: number, data: UpdateCourseRequest) => http.put(`/course-management/courses/${courseId}`, data) +export const getCourseHierarchyByCourseId = (courseId: number) => + http.get(`/course-management/courses/${courseId}/hierarchy`) + // Sub-Module APIs (Unified Hierarchy) export const getSubModulesByCourse = (courseId: number) => - http.get(`/course-management/courses/${courseId}/hierarchy`).then((res) => { - const rows: CourseHierarchyRow[] = res.data?.data ?? [] - const subModuleMap = new Map() + getCourseHierarchyByCourseId(courseId).then((res) => { + const raw = res.data?.data + const rows: CourseHierarchyRow[] = Array.isArray(raw) ? raw : [] + const subModuleMap = new Map< + number, + { + id: number + course_id: number + level_id?: number + module_id?: number + title: string + description: string + level: string + cefr_level?: string + thumbnail: string + display_order: number + sub_level?: string + is_active: boolean + } + >() rows.forEach((r, idx) => { if (!r.sub_module_id) return - if (!subModuleMap.has(r.sub_module_id)) { + const existing = subModuleMap.get(r.sub_module_id) + if (!existing) { subModuleMap.set(r.sub_module_id, { id: r.sub_module_id, course_id: courseId, + level_id: r.level_id ?? undefined, module_id: r.module_id ?? undefined, title: r.sub_module_title ?? "", description: "", @@ -168,7 +286,17 @@ export const getSubModulesByCourse = (courseId: number) => sub_level: r.cefr_level ?? undefined, is_active: true, }) + return } + subModuleMap.set(r.sub_module_id, { + ...existing, + module_id: existing.module_id ?? r.module_id ?? undefined, + level_id: existing.level_id ?? r.level_id ?? undefined, + title: existing.title || r.sub_module_title || "", + level: existing.level || r.cefr_level || "", + cefr_level: existing.cefr_level ?? r.cefr_level ?? undefined, + sub_level: existing.sub_level ?? r.cefr_level ?? undefined, + }) }) const sub_courses = Array.from(subModuleMap.values()) return { @@ -225,6 +353,33 @@ export const deleteSubModule = (subModuleId: number) => export const getVideosBySubModule = (subModuleId: number) => http.get(`/course-management/sub-modules/${subModuleId}/videos`) +export const getLessonsBySubModule = (subModuleId: number, options?: { includeInactive?: boolean }) => + http.get(`/course-management/sub-modules/${subModuleId}/lessons`, { + params: { include_inactive: options?.includeInactive ?? true }, + }) + +export const getSubModuleLessonById = ( + lessonId: number, + options?: { + /** + * Cache-bust the request to avoid serving stale lesson data after edits. + * This is intentionally implemented via query string to work with default axios config. + */ + cacheBust?: boolean + }, +) => + http.get(`/course-management/sub-module-lessons/${lessonId}`, { + params: options?.cacheBust ? { _t: Date.now() } : undefined, + }) + +export const updateSubModuleLesson = (lessonId: number, data: UpdateSubModuleLessonRequest) => + http.put(`/course-management/sub-module-lessons/${lessonId}`, data) + +export const softDeleteSubModuleLesson = (lessonId: number) => + http.put(`/course-management/sub-module-lessons/${lessonId}`, { + is_active: false, + }) + export const createSubCourseVideo = (data: CreateSubCourseVideoRequest) => http.post("/course-management/sub-module-videos", { sub_module_id: data.sub_module_id ?? data.sub_course_id, @@ -285,6 +440,43 @@ export const createPractice = (data: CreatePracticeRequest) => .then(() => res) }) +export const createLesson = (data: { + sub_module_id: number + title: string + description?: string + intro_video_url?: string + persona?: string + status?: "DRAFT" | "PUBLISHED" + passing_score?: number + time_limit_minutes?: number + shuffle_questions?: boolean +}) => + http + .post("/question-sets", { + title: data.title, + set_type: "QUIZ", + owner_type: "SUB_MODULE", + owner_id: data.sub_module_id, + ...(data.description?.trim() ? { description: data.description.trim() } : {}), + ...(data.intro_video_url?.trim() ? { intro_video_url: data.intro_video_url.trim() } : {}), + ...(data.persona?.trim() ? { persona: data.persona.trim() } : {}), + ...(data.status ? { status: data.status } : {}), + ...(Number.isFinite(data.passing_score) ? { passing_score: data.passing_score } : {}), + ...(Number.isFinite(data.time_limit_minutes) ? { time_limit_minutes: data.time_limit_minutes } : {}), + ...(typeof data.shuffle_questions === "boolean" ? { shuffle_questions: data.shuffle_questions } : {}), + }) + .then((res) => { + const questionSetID = res.data?.data?.id + if (!questionSetID) return res + return http + .post("/course-management/sub-module-lessons", { + sub_module_id: data.sub_module_id, + question_set_id: questionSetID, + intro_video_url: data.intro_video_url, + }) + .then(() => res) + }) + export const updatePractice = (practiceId: number, data: UpdatePracticeRequest) => http.put(`/course-management/practices/${practiceId}`, data) @@ -506,186 +698,92 @@ export const getHumanLanguageLessonsByCourse = (courseId: number, cefr_level: st params: { cefr_level }, }) -export const getHumanLanguageHierarchy = () => - http.get("/course-management/hierarchy").then(async (res) => { - const payload = res.data?.data as unknown - if (payload && typeof payload === "object" && !Array.isArray(payload) && "sub_categories" in payload) { - return res - } +export const getHumanLanguageSubCategories = () => + http.get("/course-management/human-language/sub-categories") - const rows: UnifiedHierarchyRow[] = Array.isArray(payload) ? payload : [] - const categoryMap = new Map< - number, - { - category_id: number - category_name: string - sub_categories: Map< - number, - { - sub_category_id: number - sub_category_name: string - courses: Map< - number, - { - course_id: number - course_name: string - } - > - } - > - } - >() +export const getCoursesBySubCategoryId = (subCategoryId: number) => + http.get(`/course-management/sub-categories/${subCategoryId}/courses`) - rows.forEach((row) => { - const categoryId = Number(row.category_id) - if (!Number.isFinite(categoryId)) return +export const getSubModulesByModuleId = (moduleId: number) => + http.get(`/course-management/modules/${moduleId}/sub-modules`) - if (!categoryMap.has(categoryId)) { - categoryMap.set(categoryId, { - category_id: categoryId, - category_name: row.category_name ?? "", - sub_categories: new Map(), - }) - } - - if (!row.sub_category_id) return - const subCategoryId = Number(row.sub_category_id) - if (!Number.isFinite(subCategoryId)) return - - const categoryNode = categoryMap.get(categoryId)! - if (!categoryNode.sub_categories.has(subCategoryId)) { - categoryNode.sub_categories.set(subCategoryId, { - sub_category_id: subCategoryId, - sub_category_name: row.sub_category_name ?? "", - courses: new Map(), - }) - } - - if (!row.course_id) return - const courseId = Number(row.course_id) - if (!Number.isFinite(courseId)) return - - const subCategoryNode = categoryNode.sub_categories.get(subCategoryId)! - if (!subCategoryNode.courses.has(courseId)) { - subCategoryNode.courses.set(courseId, { - course_id: courseId, - course_name: row.course_title ?? "", - }) - } +/** + * Finds a sub-module under a course by walking levels → modules → sub-modules APIs. + * Use when the legacy hierarchy flatten (`getSubModulesByCourse`) does not include the row. + */ +export async function resolveSubModuleForCourse( + courseId: number, + subModuleId: number, +): Promise { + try { + const levelsRes = await getCourseLevelsForCourse(courseId) + const levels = Array.isArray(levelsRes.data?.data?.levels) ? levelsRes.data.data.levels : [] + const sortedLevels = [...levels].sort((a, b) => { + const o = (a.display_order ?? 0) - (b.display_order ?? 0) + if (o !== 0) return o + return String(a.cefr_level ?? "").localeCompare(String(b.cefr_level ?? "")) }) - const selectedCategory = - Array.from(categoryMap.values()).find((c) => c.category_name.toLowerCase().includes("human")) ?? - Array.from(categoryMap.values())[0] - - if (!selectedCategory) { - return { - ...res, - data: { - ...res.data, - data: { - category_id: 0, - category_name: "", - sub_categories: [], - }, - }, - } as unknown as { data: GetHumanLanguageHierarchyResponse } - } - - const courses = Array.from(selectedCategory.sub_categories.values()).flatMap((sub) => - Array.from(sub.courses.values()).map((course) => ({ sub_category_id: sub.sub_category_id, course })), - ) - - const hierarchyResponses = await Promise.all( - courses.map(({ course }) => - http - .get(`/course-management/courses/${course.course_id}/hierarchy`) - .then((courseRes) => ({ course_id: course.course_id, rows: (courseRes.data?.data ?? []) as CourseHierarchyRow[] })) - .catch(() => ({ course_id: course.course_id, rows: [] as CourseHierarchyRow[] })), - ), - ) - - const hierarchyByCourse = new Map( - hierarchyResponses.map((h) => [h.course_id, h.rows]), - ) - - const subCategories = Array.from(selectedCategory.sub_categories.values()).map((sub) => ({ - sub_category_id: sub.sub_category_id, - sub_category_name: sub.sub_category_name, - courses: Array.from(sub.courses.values()).map((course) => { - const levelMap = new Map< - string, - { - level: string - modules: Map< - number, - { - id: number - title: string - sub_modules: Map - } - > - } - >() - - ;(hierarchyByCourse.get(course.course_id) ?? []).forEach((row) => { - if (!row.level_id || !row.cefr_level) return - const levelKey = String(row.cefr_level).toUpperCase() - if (!levelMap.has(levelKey)) { - levelMap.set(levelKey, { level: levelKey, modules: new Map() }) - } - - if (!row.module_id) return - const levelNode = levelMap.get(levelKey)! - const moduleId = Number(row.module_id) - if (!levelNode.modules.has(moduleId)) { - levelNode.modules.set(moduleId, { - id: moduleId, - title: row.module_title ?? "", - sub_modules: new Map(), - }) - } - - if (!row.sub_module_id) return - const moduleNode = levelNode.modules.get(moduleId)! - const subModuleId = Number(row.sub_module_id) - if (!moduleNode.sub_modules.has(subModuleId)) { - moduleNode.sub_modules.set(subModuleId, { - id: subModuleId, - title: row.sub_module_title ?? "", - videos: [], - practices: [], - }) - } - }) - - return { - course_id: course.course_id, - course_name: course.course_name, - levels: Array.from(levelMap.values()).map((levelNode) => ({ - level: levelNode.level, - modules: Array.from(levelNode.modules.values()).map((moduleNode) => ({ - id: moduleNode.id, - title: moduleNode.title, - sub_modules: Array.from(moduleNode.sub_modules.values()), - })), - })), - } + const modulesNested = await Promise.all( + sortedLevels.map(async (level) => { + const modsRes = await getModulesByLevel(level.id) + const rawMods = modsRes.data?.data?.modules + const modules = Array.isArray(rawMods) ? rawMods : [] + const sortedMods = [...modules].sort((a, b) => (a.display_order ?? 0) - (b.display_order ?? 0)) + return sortedMods.map((module) => ({ level, module })) }), - })) + ) + const modulePairs = modulesNested.flat() - return { - ...res, - data: { - ...res.data, - data: { - category_id: selectedCategory.category_id, - category_name: selectedCategory.category_name, - sub_categories: subCategories, - }, - }, - } as unknown as { data: GetHumanLanguageHierarchyResponse } - }) + const bundles = await Promise.all( + modulePairs.map(async ({ level, module }) => { + const subsRes = await getSubModulesByModuleId(module.id) + const rawSubs = subsRes.data?.data?.sub_modules + const subs = Array.isArray(rawSubs) ? rawSubs : [] + const sortedSubs = [...subs].sort((a, b) => (a.display_order ?? 0) - (b.display_order ?? 0)) + return { level, module, subs: sortedSubs } + }), + ) + + for (const { level, module, subs } of bundles) { + const found = subs.find((s) => s.id === subModuleId) + if (found) { + return { + id: found.id, + course_id: courseId, + level_id: level.id, + module_id: module.id, + title: found.title, + description: found.description ?? "", + level: level.cefr_level, + cefr_level: level.cefr_level, + thumbnail: found.thumbnail ?? "", + display_order: found.display_order, + sub_level: level.cefr_level, + is_active: found.is_active, + } + } + } + } catch (e) { + console.error("resolveSubModuleForCourse failed:", e) + } + return null +} + +export const getCourseLevelsForCourse = (courseId: number) => + http.get(`/course-management/courses/${courseId}/levels`) + +export const getAllCourseLevels = () => http.get("/course-management/levels") + +export const getCourseLevelById = (levelId: number) => + http.get(`/course-management/levels/${levelId}`) + +export const getHumanLanguageHierarchy = (options?: { cacheBust?: boolean }) => + withSingleRetry(() => + http.get("/course-management/human-language/hierarchy", { + params: options?.cacheBust ? { _t: Date.now() } : undefined, + }), + ) export const createHumanLanguageLesson = (data: CreateHumanLanguageLessonRequest) => http @@ -714,6 +812,34 @@ export const createHumanLanguageLesson = (data: CreateHumanLanguageLessonRequest }), ) +export const createModuleInLevel = ( + levelId: number, + title: string, + description: string, + displayOrder = 0, +) => + http.post("/course-management/modules", { + level_id: levelId, + title, + description, + display_order: displayOrder, + is_active: true, + }) + +export const createSubModuleInModule = ( + moduleId: number, + title: string, + description: string, + displayOrder = 0, +) => + http.post("/course-management/sub-modules", { + module_id: moduleId, + title, + description, + display_order: displayOrder, + is_active: true, + }) + export const getSubModuleEntryAssessment = (subModuleId: number) => http.get( `/question-sets/sub-courses/${subModuleId}/entry-assessment`, diff --git a/src/api/http.ts b/src/api/http.ts index 5ba1228..893c181 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -12,6 +12,7 @@ let failedQueue: Array<{ resolve: (token: string) => void; reject: (error: Error) => void; }> = []; +const TOKEN_REFRESH_BUFFER_SECONDS = 120; const processQueue = (error: Error | null, token: string | null = null) => { failedQueue.forEach((prom) => { @@ -32,23 +33,47 @@ const clearAuthAndRedirect = () => { window.location.href = "/login"; }; -const refreshAccessToken = async (): Promise => { - const accessToken = localStorage.getItem("access_token"); - const refreshToken = localStorage.getItem("refresh_token"); - const role = localStorage.getItem("role"); - const memberId = localStorage.getItem("member_id"); +const decodeJwtPayload = (token: string): Record | null => { + try { + const payloadPart = token.split(".")[1]; + if (!payloadPart) return null; + const normalized = payloadPart.replace(/-/g, "+").replace(/_/g, "/"); + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "="); + const json = atob(padded); + return JSON.parse(json) as Record; + } catch { + return null; + } +}; - if (!refreshToken || !memberId) { +const isAccessTokenExpiringSoon = (token: string) => { + const payload = decodeJwtPayload(token); + const exp = Number(payload?.exp); + if (!Number.isFinite(exp)) return true; + const nowSeconds = Math.floor(Date.now() / 1000); + return exp - nowSeconds <= TOKEN_REFRESH_BUFFER_SECONDS; +}; + +const isAuthEndpointRequest = (url?: string) => { + if (!url) return false; + return ( + url.includes("/team/login") || + url.includes("/team/google-login") || + url.includes("/team/refresh") + ); +}; + +const refreshAccessToken = async (): Promise => { + const refreshToken = localStorage.getItem("refresh_token"); + + if (!refreshToken) { throw new Error("No refresh token available"); } const response = await axios.post( - `${import.meta.env.VITE_API_BASE_URL}/auth/refresh`, + `${import.meta.env.VITE_API_BASE_URL}/team/refresh`, { - access_token: accessToken, refresh_token: refreshToken, - role: role || "admin", - member_id: Number(memberId), } ); @@ -65,9 +90,43 @@ const refreshAccessToken = async (): Promise => { return newAccessToken; }; +const getValidAccessToken = async (forceRefresh = false): Promise => { + const currentToken = localStorage.getItem("access_token"); + if (!forceRefresh && currentToken && !isAccessTokenExpiringSoon(currentToken)) { + return currentToken; + } + + if (isRefreshing) { + return new Promise((resolve, reject) => { + failedQueue.push({ resolve, reject }); + }); + } + + isRefreshing = true; + try { + const newToken = await refreshAccessToken(); + processQueue(null, newToken); + return newToken; + } catch (refreshError) { + processQueue(refreshError as Error, null); + clearAuthAndRedirect(); + throw refreshError; + } finally { + isRefreshing = false; + } +}; + // Attach access token to every request -http.interceptors.request.use((config) => { - const token = localStorage.getItem("access_token"); +http.interceptors.request.use(async (config) => { + if (isAuthEndpointRequest(config.url)) { + return config; + } + + let token = localStorage.getItem("access_token"); + if (token && isAccessTokenExpiringSoon(token)) { + token = await getValidAccessToken(); + } + if (token) { config.headers.Authorization = `Bearer ${token}`; } @@ -80,32 +139,19 @@ http.interceptors.response.use( async (error: AxiosError) => { const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; - if (error.response?.status === 401 && !originalRequest._retry) { - if (isRefreshing) { - return new Promise((resolve, reject) => { - failedQueue.push({ resolve, reject }); - }) - .then((token) => { - originalRequest.headers.Authorization = `Bearer ${token}`; - return http(originalRequest); - }) - .catch((err) => Promise.reject(err)); - } - + if ( + error.response?.status === 401 && + !originalRequest._retry && + !isAuthEndpointRequest(originalRequest.url) + ) { originalRequest._retry = true; - isRefreshing = true; try { - const newToken = await refreshAccessToken(); - processQueue(null, newToken); + const newToken = await getValidAccessToken(true); originalRequest.headers.Authorization = `Bearer ${newToken}`; return http(originalRequest); } catch (refreshError) { - processQueue(refreshError as Error, null); - clearAuthAndRedirect(); return Promise.reject(refreshError); - } finally { - isRefreshing = false; } } diff --git a/src/api/rbac.api.ts b/src/api/rbac.api.ts index 160fc18..f14fea8 100644 --- a/src/api/rbac.api.ts +++ b/src/api/rbac.api.ts @@ -5,6 +5,7 @@ import type { GetRolesParams, CreateRoleRequest, CreateRoleResponse, + DeleteRoleResponse, SetRolePermissionsRequest, GetPermissionsResponse, } from "../types/rbac.types" @@ -26,3 +27,6 @@ export const setRolePermissions = (roleId: number, data: SetRolePermissionsReque export const getAllPermissions = () => http.get("/rbac/permissions") + +export const deleteRole = (roleId: number) => + http.delete(`/rbac/roles/${roleId}`) diff --git a/src/app/AppRoutes.tsx b/src/app/AppRoutes.tsx index 5c75232..d14f85d 100644 --- a/src/app/AppRoutes.tsx +++ b/src/app/AppRoutes.tsx @@ -10,8 +10,8 @@ import { ContentOverviewPage } from "../pages/content-management/ContentOverview import { CoursesPage } from "../pages/content-management/CoursesPage" import { PracticeQuestionsPage } from "../pages/content-management/PracticeQuestionsPage" import { AddNewPracticePage } from "../pages/content-management/AddNewPracticePage" +import { AddNewLessonPage } from "../pages/content-management/AddNewLessonPage" import { SubModulesPage } from "../pages/content-management/SubCoursesPage" -import { SubModuleContentPage } from "../pages/content-management/SubCourseContentPage" import { SpeakingPage } from "../pages/content-management/SpeakingPage" import { AddVideoPage } from "../pages/content-management/AddVideoPage" import { AddPracticePage } from "../pages/content-management/AddPracticePage" @@ -31,8 +31,9 @@ import { PracticeDetailsPage } from "../pages/content-management/PracticeDetails import { PracticeMembersPage } from "../pages/content-management/PracticeMembersPage" import { QuestionsPage } from "../pages/content-management/QuestionsPage" import { AddQuestionPage } from "../pages/content-management/AddQuestionPage" -import { HumanLanguagePage } from "../pages/content-management/HumanLanguagePage" +import { HumanLanguageHierarchyPage } from "../pages/content-management/HumanLanguageHierarchyPage" import { HumanLanguageSubModulePage } from "../pages/content-management/HumanLanguageSubModulePage" +import { SubCategoryCoursesPage } from "../pages/content-management/SubCategoryCoursesPage" import { UserLogPage } from "../pages/user-log/UserLogPage" import { IssuesPage } from "../pages/issues/IssuesPage" import { ProfilePage } from "../pages/ProfilePage" @@ -78,30 +79,44 @@ export function AppRoutes() { } /> } /> } /> - } /> + } /> } /> + } + /> } /> + } + /> } /> } /> + } + /> } /> {/* Course → Sub-module → Lesson/Practice */} } /> - } /> + } /> } /> + } /> } /> {/* Legacy aliases */} } /> - } /> + } /> } /> + } /> } /> } /> } /> diff --git a/src/components/topbar/Topbar.tsx b/src/components/topbar/Topbar.tsx index b42a173..8a3fb58 100644 --- a/src/components/topbar/Topbar.tsx +++ b/src/components/topbar/Topbar.tsx @@ -45,7 +45,7 @@ export function Topbar({ onSidebarToggle }: TopbarProps) { } return ( -
+
{/* Sidebar toggle */}