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..07c456b 100644 --- a/src/api/courses.api.ts +++ b/src/api/courses.api.ts @@ -44,18 +44,49 @@ import type { GetQuestionsResponse, CreateVimeoVideoRequest, CreateCourseCategoryRequest, + GetCategorySubCategoriesResponse, + GetSubCategoryCoursesResponse, GetSubCoursePrerequisitesResponse, AddSubCoursePrerequisiteRequest, GetLearningPathResponse, GetHumanLanguageLessonsResponse, GetHumanLanguageHierarchyResponse, + GetCourseHierarchyResponse, CreateHumanLanguageLessonRequest, + GetSubModuleLessonsResponse, + GetSubModuleLessonDetailResponse, + UpdateSubModuleLessonRequest, + UpdateSubModuleLessonResponse, + GetCourseLevelsForCourseResponse, + GetSubModulesByModuleResponse, + SubCourse, GetSubCourseEntryAssessmentResponse, ReorderItem, GetRatingsResponse, GetRatingsParams, GetVimeoSampleResponse, CreateCourseVideoRequest, + GetLearningProgramsResponse, + UpdateLearningProgramRequest, + CreateLearningProgramRequest, + CreateLearningProgramResponse, + GetProgramCoursesResponse, + GetTopLevelCourseModulesResponse, + UpdateTopLevelCourseRequest, + UpdateTopLevelCourseModuleRequest, + CreateTopLevelCourseModuleRequest, + CreateTopLevelCourseModuleResponse, + CreateProgramCourseRequest, + CreateProgramCourseResponse, + GetTopLevelModuleLessonsResponse, + GetPracticesByParentContextResponse, + CreateParentLinkedPracticeRequest, + CreateParentLinkedPracticeResponse, + UpdateParentLinkedPracticeRequest, + UpdateParentLinkedPracticeResponse, + UpdateTopLevelModuleLessonRequest, + CreateTopLevelModuleLessonRequest, + CreateTopLevelModuleLessonResponse, } from "../types/course.types" type UnifiedHierarchyRow = { @@ -110,6 +141,35 @@ 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 getSubCategoriesByCategoryId = (categoryId: number) => + http.get(`/course-management/categories/${categoryId}/sub-categories`) + +export const getCoursesBySubCategoryId = (subCategoryId: number) => + http.get(`/course-management/sub-categories/${subCategoryId}/courses`) + +export const createSubCategory = (payload: { + category_id: number + name: string + description?: string | null + display_order?: number +}) => http.post("/course-management/sub-categories", payload) + +export const deleteCourseSubCategory = (subCategoryId: number) => + http.delete(`/course-management/sub-categories/${subCategoryId}`) + +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) => { const rows: UnifiedHierarchyRow[] = res.data?.data ?? [] @@ -148,9 +208,13 @@ export const updateCourse = (courseId: number, data: UpdateCourseRequest) => http.put(`/course-management/courses/${courseId}`, data) // Sub-Module APIs (Unified Hierarchy) +export const getCourseHierarchyByCourseId = (courseId: number) => + http.get(`/course-management/courses/${courseId}/hierarchy`) + export const getSubModulesByCourse = (courseId: number) => http.get(`/course-management/courses/${courseId}/hierarchy`).then((res) => { - const rows: CourseHierarchyRow[] = res.data?.data ?? [] + const raw = res.data?.data + const rows: CourseHierarchyRow[] = Array.isArray(raw) ? raw : [] const subModuleMap = new Map() rows.forEach((r, idx) => { if (!r.sub_module_id) return @@ -225,6 +289,27 @@ 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?: { 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, @@ -345,6 +430,126 @@ export const updatePracticeQuestion = (questionId: number, data: UpdatePracticeQ export const deletePracticeQuestion = (questionId: number) => http.delete(`/questions/${questionId}`) +/** Top-level learning programs (Learn English cards, etc.) — GET /programs */ +export const getLearningPrograms = (params?: { limit?: number; offset?: number }) => + http.get("/programs", { params }) + +export const createLearningProgram = (data: CreateLearningProgramRequest) => + http.post("/programs", data) + +export const getProgramCourses = ( + programId: number, + params?: { limit?: number; offset?: number }, +) => http.get(`/programs/${programId}/courses`, { params }) + +export const createProgramCourse = ( + programId: number, + data: CreateProgramCourseRequest, +) => http.post(`/programs/${programId}/courses`, data) + +/** Top-level course resource (Learn English track) — PUT /courses/:id */ +export const updateTopLevelCourse = (courseId: number, data: UpdateTopLevelCourseRequest) => + http.put(`/courses/${courseId}`, data) + +export const deleteTopLevelCourse = (courseId: number) => + http.delete(`/courses/${courseId}`) + +export const getTopLevelCourseModules = ( + courseId: number, + params?: { limit?: number; offset?: number }, +) => + http.get(`/courses/${courseId}/modules`, { + params, + }) + +/** Learn English top-level module — POST /courses/:courseId/modules */ +export const createTopLevelCourseModule = ( + courseId: number, + data: CreateTopLevelCourseModuleRequest, +) => + http.post( + `/courses/${courseId}/modules`, + data, + ) + +/** Learn English top-level module — PUT /modules/:id */ +export const updateTopLevelCourseModule = ( + moduleId: number, + data: UpdateTopLevelCourseModuleRequest, +) => http.put(`/modules/${moduleId}`, data) + +/** Learn English top-level module — DELETE /modules/:id */ +export const deleteTopLevelCourseModule = (moduleId: number) => + http.delete(`/modules/${moduleId}`) + +/** Learn English top-level module lessons — GET /modules/:moduleId/lessons */ +export const getModuleLessons = ( + moduleId: number, + params?: { limit?: number; offset?: number }, +) => + http.get(`/modules/${moduleId}/lessons`, { + params, + }) + +/** Learn English top-level module lesson — POST /modules/:moduleId/lessons */ +export const createModuleLesson = ( + moduleId: number, + data: CreateTopLevelModuleLessonRequest, +) => + http.post(`/modules/${moduleId}/lessons`, data) + +/** Learn English top-level module lesson — PUT /lessons/:id */ +export const updateTopLevelModuleLesson = ( + lessonId: number, + data: UpdateTopLevelModuleLessonRequest, +) => http.put(`/lessons/${lessonId}`, data) + +/** Learn English top-level module lesson — DELETE /lessons/:id */ +export const deleteTopLevelModuleLesson = (lessonId: number) => + http.delete(`/lessons/${lessonId}`) + +/** GET /courses/:courseId/practices — practices linked to a top-level course (at most one in normal use). */ +export const getPracticesByParentCourse = ( + courseId: number, + params?: { limit?: number; offset?: number }, +) => + http.get(`/courses/${courseId}/practices`, { params }) + +/** GET /modules/:moduleId/practices */ +export const getPracticesByParentModule = ( + moduleId: number, + params?: { limit?: number; offset?: number }, +) => + http.get(`/modules/${moduleId}/practices`, { params }) + +/** GET /lessons/:lessonId/practices */ +export const getPracticesByParentLesson = ( + lessonId: number, + params?: { limit?: number; offset?: number }, +) => + http.get(`/lessons/${lessonId}/practices`, { params }) + +/** POST /practices — create a practice (story + question set) for course / module / lesson. */ +export const createParentLinkedPractice = (data: CreateParentLinkedPracticeRequest) => + http.post("/practices", data) + +/** PUT /practices/:id */ +export const updateParentLinkedPractice = ( + practiceId: number, + data: UpdateParentLinkedPracticeRequest, +) => http.put(`/practices/${practiceId}`, data) + +/** DELETE /practices/:id */ +export const deleteParentLinkedPractice = (practiceId: number) => + http.delete<{ message: string; success: boolean; status_code: number; metadata: unknown }>( + `/practices/${practiceId}`, + ) + +export const updateLearningProgram = (programId: number, data: UpdateLearningProgramRequest) => + http.put(`/programs/${programId}`, data) + +export const deleteLearningProgram = (programId: number) => http.delete(`/programs/${programId}`) + // ============================================ // Legacy APIs (deprecated - using SubCourse hierarchy now) // Keeping for backward compatibility @@ -383,6 +588,74 @@ export const deleteLevel = (levelId: number) => export const getModulesByLevel = (levelId: number) => http.get(`/course-management/levels/${levelId}/modules`) +export const getCourseLevelsForCourse = (courseId: number) => + http.get(`/course-management/courses/${courseId}/levels`) + +export const getSubModulesByModuleId = (moduleId: number) => + http.get(`/course-management/modules/${moduleId}/sub-modules`) + +/** + * Finds a sub-module under a course by walking levels → modules → sub-modules APIs. + */ +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 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() + + 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 createModule = (data: CreateModuleRequest) => http.post("/course-management/modules", data) diff --git a/src/api/files.api.ts b/src/api/files.api.ts index 377b509..ec6a59d 100644 --- a/src/api/files.api.ts +++ b/src/api/files.api.ts @@ -25,6 +25,16 @@ export interface ResolveFileUrlResponse { success?: boolean } +export interface RefreshFileUrlResponse { + message: string + data?: { + object_key?: string + url?: string + expires_in?: number + } + success?: boolean +} + export interface UploadMediaOptions { title?: string description?: string @@ -86,3 +96,8 @@ export const resolveFileUrl = (key: string) => params: { key }, }) +export const refreshFileUrl = (reference: string) => + http.post("/files/refresh-url", { + reference, + }) + diff --git a/src/api/http.ts b/src/api/http.ts index 5ba1228..61a757b 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,68 @@ 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 ABSOLUTE_URL_REGEX = /^https?:\/\//i; + +const safeOrigin = (url?: string): string | null => { + if (!url) return null; + try { + return new URL(url).origin; + } catch { + return null; + } +}; + +const API_BASE_ORIGIN = safeOrigin(import.meta.env.VITE_API_BASE_URL); + +const shouldAttachApiAuth = (url?: string): boolean => { + if (!url) return true; + if (!ABSOLUTE_URL_REGEX.test(url)) return true; + const requestOrigin = safeOrigin(url); + if (!requestOrigin || !API_BASE_ORIGIN) return false; + return requestOrigin === API_BASE_ORIGIN; +}; + +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 +111,47 @@ 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 (!shouldAttachApiAuth(config.url)) { + return 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,37 +164,25 @@ 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 && + shouldAttachApiAuth(originalRequest.url) && + !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; } } // Backend is down (network error, timeout, connection refused) - if (!error.response) { + if (!error.response && shouldAttachApiAuth(originalRequest.url)) { clearAuthAndRedirect(); return Promise.reject(error); } 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 cd4056d..eb3f023 100644 --- a/src/app/AppRoutes.tsx +++ b/src/app/AppRoutes.tsx @@ -3,7 +3,6 @@ import { AppLayout } from "../layouts/AppLayout"; import { DashboardPage } from "../pages/DashboardPage"; import { AnalyticsPage } from "../pages/analytics/AnalyticsPage"; import { ContentManagementLayout } from "../pages/content-management/ContentManagementLayout"; -import { CourseCategoryPage } from "../pages/content-management/CourseCategoryPage"; import { AllCoursesPage } from "../pages/content-management/AllCoursesPage"; import { CourseFlowBuilderPage } from "../pages/content-management/CourseFlowBuilderPage"; import { ContentOverviewPage } from "../pages/content-management/ContentOverviewPage"; @@ -47,7 +46,7 @@ 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 { UserLogPage } from "../pages/user-log/UserLogPage"; import { IssuesPage } from "../pages/issues/IssuesPage"; @@ -91,10 +90,10 @@ export function AppRoutes() { }> - } /> + } /> } /> } /> - } /> + } /> } 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 */}