# Courses Controller Documentation **File:** `controllers/admin/courses.controller.js` **Base URL:** `/api/admin/courses` **Guards:** `authenticate → requireAdmin() → adminLimiter` --- ## Table of Contents - [Courses](#courses) - [Get All Courses](#get-all-courses) - [Get Single Course](#get-single-course) - [Create Course](#create-course) - [Update Course](#update-course) - [Archive Course](#archive-course) - [Bulk Archive Courses](#bulk-archive-courses) - [Restore Course](#restore-course) - [Bulk Restore Courses](#bulk-restore-courses) - [Get Archived Courses](#get-archived-courses) - [Get Archived Course](#get-archived-course) - [Flat Lists (dropdowns)](#flat-lists) - [Get Course Field Values](#get-course-field-values) - [Course Instructors](#course-instructors) - [Get Instructors](#get-instructors) - [Sync Instructors](#sync-instructors) - [Course Prerequisites](#course-prerequisites) - [Get Prerequisites](#get-prerequisites) - [Sync Prerequisites](#sync-prerequisites) - [Course Assessment](#course-assessment) - [Get Assessment](#get-assessment) - [Create Assessment](#create-assessment) - [Update Assessment](#update-assessment) - [Archive/Restore Assessment](#archiverestore-assessment) - [Quiz Questions](#quiz-questions) - [Get Questions](#get-questions) - [Create Question](#create-question) - [Update Question](#update-question) - [Archive/Restore Question](#archiverestore-question) - [Bulk Archive/Restore Questions](#bulk-archiverestore-questions) - [Units](#units) - [Get All Units](#get-all-units) - [Get Single Unit](#get-single-unit) - [Create Unit](#create-unit) - [Update Unit](#update-unit) - [Archive/Restore Unit](#archiverestore-unit) - [Bulk Archive/Restore Units](#bulk-archiverestore-units) - [Get Unit Field Values](#get-unit-field-values) - [Unit Quiz](#unit-quiz) - [Get Quiz](#get-quiz) - [Create Quiz](#create-quiz) - [Update Quiz](#update-quiz) - [Archive/Restore Quiz](#archiverestore-quiz) - [Lessons](#lessons) - [Get All Lessons](#get-all-lessons) - [Get Single Lesson](#get-single-lesson) - [Create Lesson](#create-lesson) - [Update Lesson](#update-lesson) - [Archive/Restore Lesson](#archiverestore-lesson) - [Bulk Archive/Restore Lessons](#bulk-archiverestore-lessons) - [Get Lesson Field Values](#get-lesson-field-values) - [Lesson Page](#lesson-page) - [Get Lesson Page](#get-lesson-page) - [Upsert Lesson Page](#upsert-lesson-page) - [Course Reading Progress](#course-reading-progress) - [Get Course Reading Progress](#get-course-reading-progress) - [Get User Reading Progress](#get-user-reading-progress) --- ## Course Hierarchy ``` Course ├── CourseObjective[] ├── CoursePrerequisite[] ├── CourseAssessment (one) │ └── QuizQuestion[] → QuizOption[] ├── CourseInstructor[] └── Unit[] ├── UnitQuiz (one) │ └── QuizQuestion[] → QuizOption[] └── Lesson[] ├── LessonObjective[] └── LessonPage (one) { blocks: [] } ``` --- ## Courses ### Get All Courses **`GET /api/admin/courses`** Returns paginated active courses. ### Query Parameters | Parameter | Type | Required | Description | |-----------|--------|----------|-------------| | `page` | number | No | Default: `1` | | `limit` | number | No | Default: `10` | | `filters` | array | No | JSON filter array | | `sort` | array | No | JSON sort array | ### Response `200` ```json { "status": "success", "message": "Courses retrieved.", "data": [...], "pagination": { "page": 1, "limit": 10, "total": 12, "totalPages": 2 } } ``` --- ### Get Single Course **`GET /api/admin/courses/:courseId`** Returns the full course tree: units (with lessons and quiz), objectives, prerequisites, and assessment. ### Response `200` ```json { "status": "success", "message": "Course retrieved.", "data": { "course_id": 1, "uuid": "...", "title": "Advanced JavaScript", "description": "...", "course_code": "JS-201", "order_index": 0, "level": "advanced", "subscription": "premium", "duration_seconds": 7200, "objectives": [{ "objective_id": 1, "text": "Understand closures", "order_index": 0 }], "prerequisites": [], "assessment": { ... }, "units": [ { "unit_id": 1, "title": "Closures", "order_index": 0, "lessons": [{ "lesson_id": 1, "title": "What is a closure?", "order_index": 0 }], "quiz": { ... } } ] } } ``` --- ### Create Course **`POST /api/admin/courses`** Creates a course with optional objectives and category assignments in a single transaction. ### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `title` | string | **Yes** | Course title | | `description` | string | No | | | `course_code` | string | No | Unique course identifier | | `order_index` | number | No | Default: `0` | | `level` | string | No | `beginner`, `intermediate`, `advanced` | | `subscription` | string | No | `free`, `premium`. Default: `free` | | `objectives` | array | No | `[{ text, order_index }]` | | `category_ids` | array | No | Category IDs to assign | | `createdBy` | number | No | Creator user ID | ### Response `201` ```json { "status": "success", "message": "Course created.", "data": { "course_id": 5, "title": "Advanced JavaScript", ... } } ``` --- ### Update Course **`PUT /api/admin/courses/:courseId`** Updates course fields. When `objectives` or `category_ids` are provided, they replace the existing sets. ### Request Body (all optional) `title`, `description`, `order_index`, `course_code`, `level`, `subscription`, `objectives`, `category_ids`, `updatedBy` --- ### Archive Course **`DELETE /api/admin/courses/:courseId`** Soft-deletes the course. ### Response `200` ```json { "status": "success", "message": "Course archived." } ``` --- ### Bulk Archive Courses **`DELETE /api/admin/courses/bulk`** ### Request Body ```json { "ids": [1, 2, 3] } ``` --- ### Restore Course **`PATCH /api/admin/courses/:courseId/restore`** --- ### Bulk Restore Courses **`PATCH /api/admin/courses/restore/bulk`** ### Request Body ```json { "ids": [1, 2] } ``` --- ### Get Archived Courses **`GET /api/admin/courses/archives`** --- ### Get Archived Course **`GET /api/admin/courses/archives/:courseId`** --- ### Flat Lists Lightweight endpoints that return `uuid + title` arrays (no pagination). Used by the task requirement builder dropdowns. **`GET /api/admin/courses/flat`** — all active courses: `[{ uuid, title }]` **`GET /api/admin/courses/units-flat`** — all active units: `[{ uuid, title, order_index, course_title }]` **`GET /api/admin/courses/lessons-flat`** — all active lessons: `[{ uuid, title, order_index, unit_title, unit_order, course_title }]` --- ### Get Course Field Values **`GET /api/admin/courses/field-values`** --- ## Course Instructors ### Get Instructors **`GET /api/admin/courses/:courseId/instructors`** Returns instructors ordered by `order_index`. Includes linked `users` (staff/admin accounts) when `user_id` is set. ### Response `200` ```json { "status": "success", "message": "Instructors retrieved.", "data": [ { "id": 1, "course_id": 5, "user_id": 3, "display_name": "Dr. Jane Smith", "order_index": 0, "user": { "user_id": 3, "email": "jane@example.com", "acc_type": "staff", "personal_info": { ... } } } ] } ``` --- ### Sync Instructors **`PUT /api/admin/courses/:courseId/instructors`** Replaces the full instructor list for a course in a single transaction. - Validates any `user_id` values — they must be `staff` or `admin` accounts. - Passing an empty array clears all instructors. ### Request Body ```json { "instructors": [ { "user_id": 3, "display_name": "Dr. Jane Smith", "order_index": 0 }, { "user_id": null, "display_name": "External Contributor", "order_index": 1 } ] } ``` --- ## Course Prerequisites ### Get Prerequisites **`GET /api/admin/courses/:courseId/prerequisites`** Returns prerequisites ordered by `order_index`. ### Response `200` ```json { "status": "success", "message": "Prerequisites retrieved.", "data": [ { "prereq_id": 1, "course_id": 5, "ref_type": "course", "ref_id": 2, "order_index": 0 } ] } ``` --- ### Sync Prerequisites **`PUT /api/admin/courses/:courseId/prerequisites`** Replaces the full prerequisite list. Valid `ref_type` values: `course`, `unit`, `lesson`. ### Request Body ```json { "prerequisites": [ { "ref_type": "course", "ref_id": 2 }, { "ref_type": "unit", "ref_id": 7 } ] } ``` --- ## Course Assessment One assessment per course. Assessment questions are shared with unit quizzes via polymorphic `assessment_id` / `quiz_id` fields. ### Get Assessment **`GET /api/admin/courses/:courseId/assessment`** Returns the assessment with its questions and options. ### Response `200` ```json { "status": "success", "message": "Assessment retrieved.", "data": { "assessment_id": 1, "uuid": "...", "course_id": 5, "title": "Final Exam", "is_required": true, "passing_score": 80, "time_limit_minutes": 60, "max_questions": 20, "questions": [ { "question_id": 1, "type": "multiple_choice", "question": "What is a closure?", "explanation": "...", "points": 2, "order_index": 0, "options": [ { "option_id": 1, "text": "A function + its outer scope", "is_correct": true, "order_index": 0 } ] } ] } } ``` --- ### Create Assessment **`POST /api/admin/courses/:courseId/assessment`** Only one assessment per course. Returns `409` if one already exists. ### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `title` | string | No | | | `is_required` | boolean | No | Default: `false` | | `passing_score` | number | No | Default: `70` | | `time_limit_minutes` | number | No | `null` = no limit | | `max_questions` | number | No | `null` = show all | | `createdBy` | number | No | | --- ### Update Assessment **`PATCH /api/admin/courses/:courseId/assessment/:assessmentId`** --- ### Archive/Restore Assessment **`DELETE /api/admin/courses/:courseId/assessment/:assessmentId`** — archive **`PATCH /api/admin/courses/:courseId/assessment/:assessmentId/restore`** — restore **`GET /api/admin/courses/:courseId/assessment/archives`** — get archived assessment --- ## Quiz Questions Shared by both **Unit Quizzes** and **Course Assessments**. The parent is determined by the route: - Under a unit quiz: `/:courseId/units/:unitId/quiz/:quizId/questions` - Under an assessment: `/:courseId/assessment/:assessmentId/questions` ### Question Types | Type | Options Required | |------|----------------| | `true_false` | Auto-generated `[True, False]` if `options` is empty | | `multiple_choice` | Exactly one `is_correct: true` option | | `multi_select` | One or more `is_correct: true` options | ### Get Questions **`GET .../:parentId/questions`** Returns questions with options, ordered by `order_index`. --- ### Create Question **`POST .../:parentId/questions`** Creates a question and its options in a transaction. `true_false` questions auto-generate `[True, False]` options if `options` is omitted. ### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `type` | string | **Yes** | `true_false`, `multiple_choice`, `multi_select` | | `question` | string | **Yes** | Question text | | `explanation` | string | No | Shown after answer | | `order_index` | number | No | Default: `0` | | `points` | number | No | Default: `1` | | `options` | array | No | `[{ text, is_correct, order_index }]` | | `createdBy` | number | No | | ### Response `201` ```json { "status": "success", "message": "Question created.", "data": { "question_id": 1, "type": "multiple_choice", "options": [...] } } ``` --- ### Update Question **`PATCH .../:parentId/questions/:questionId`** When `options` is provided, the full option set is replaced (destroy + re-insert). --- ### Archive/Restore Question **`DELETE .../:parentId/questions/:questionId`** — archive **`PATCH .../:parentId/questions/:questionId/restore`** — restore **`GET .../:parentId/questions/archives/:questionId`** — get archived question --- ### Bulk Archive/Restore Questions **`DELETE .../:parentId/questions/bulk`** — bulk archive **`PATCH .../:parentId/questions/restore/bulk`** — bulk restore ### Request Body ```json { "ids": [1, 2, 3], "deletedBy": 1 } ``` --- ## Units ### Get All Units **`GET /api/admin/courses/:courseId/units`** Returns paginated active units for a course, ordered by `order_index`. --- ### Get Single Unit **`GET /api/admin/courses/:courseId/units/:unitId`** Returns a unit with its lessons and quiz. ### Response `200` ```json { "status": "success", "message": "Unit retrieved.", "data": { "unit_id": 1, "uuid": "...", "course_id": 5, "title": "Introduction", "description": "...", "order_index": 0, "duration_seconds": 1800, "lessons": [...], "quiz": { ... } } } ``` --- ### Create Unit **`POST /api/admin/courses/:courseId/units`** ### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `title` | string | **Yes** | | | `description` | string | No | | | `order` | number | No | Default: `0` | | `createdBy` | number | No | | --- ### Update Unit **`PUT /api/admin/courses/:courseId/units/:unitId`** ### Request Body (all optional) `title`, `description`, `order`, `updatedBy` --- ### Archive/Restore Unit **`DELETE /api/admin/courses/:courseId/units/:unitId`** — archive **`PATCH /api/admin/courses/:courseId/units/:unitId/restore`** — restore **`GET /api/admin/courses/:courseId/units/archives`** — list archived **`GET /api/admin/courses/:courseId/units/archives/:unitId`** — get one archived --- ### Bulk Archive/Restore Units **`DELETE /api/admin/courses/:courseId/units/bulk`** — bulk archive **`PATCH /api/admin/courses/:courseId/units/restore/bulk`** — bulk restore ```json { "ids": [1, 2] } ``` --- ### Get Unit Field Values **`GET /api/admin/courses/:courseId/field-values`** --- ## Unit Quiz One quiz per unit. Shares `QuizQuestion` / `QuizOption` with course assessments (via `quiz_id` FK). ### Get Quiz **`GET /api/admin/courses/:courseId/units/:unitId/quiz`** Returns the quiz with questions and options. --- ### Create Quiz **`POST /api/admin/courses/:courseId/units/:unitId/quiz`** Only one quiz per unit. Returns `409` if one already exists. ### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `title` | string | No | | | `is_required` | boolean | No | Default: `false` | | `passing_score` | number | No | Default: `70` | | `max_questions` | number | No | `null` = show all | | `createdBy` | number | No | | --- ### Update Quiz **`PATCH /api/admin/courses/:courseId/units/:unitId/quiz/:quizId`** ### Request Body (all optional) `title`, `is_required`, `passing_score`, `max_questions`, `updatedBy` --- ### Archive/Restore Quiz **`DELETE /api/admin/courses/:courseId/units/:unitId/quiz/:quizId`** — archive **`PATCH /api/admin/courses/:courseId/units/:unitId/quiz/:quizId/restore`** — restore **`GET /api/admin/courses/:courseId/units/:unitId/quiz/archives`** — get archived quiz --- ## Lessons ### Get All Lessons **`GET /api/admin/courses/:courseId/units/:unitId/lessons`** Returns paginated active lessons, ordered by `order_index`. --- ### Get Single Lesson **`GET /api/admin/courses/:courseId/units/:unitId/lessons/:lessonId`** Returns a lesson with its page (blocks) and objectives. ### Response `200` ```json { "status": "success", "message": "Lesson retrieved.", "data": { "lesson_id": 1, "uuid": "...", "unit_id": 1, "title": "What is a closure?", "description": "...", "duration_seconds": 900, "order_index": 0, "page": { "page_id": 1, "lesson_id": 1, "blocks": [...] }, "objectives": [{ "objective_id": 1, "text": "Understand closures", "order_index": 0 }], "unit": { "unit_id": 1, "course_id": 5, ... } } } ``` --- ### Create Lesson **`POST /api/admin/courses/:courseId/units/:unitId/lessons`** Creates a lesson, an empty `LessonPage` (blocks: `[]`), and optional objectives in a single transaction. ### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `title` | string | **Yes** | | | `description` | string | No | | | `order` | number | No | Default: `0` | | `objectives` | array | No | `[{ text, order_index }]` | | `createdBy` | number | No | | --- ### Update Lesson **`PUT /api/admin/courses/:courseId/units/:unitId/lessons/:lessonId`** When `objectives` is provided, the full set is replaced. ### Request Body (all optional) `title`, `description`, `order`, `objectives`, `updatedBy` --- ### Archive/Restore Lesson **`DELETE /api/admin/courses/:courseId/units/:unitId/lessons/:lessonId`** — archive **`PATCH /api/admin/courses/:courseId/units/:unitId/lessons/:lessonId/restore`** — restore **`GET /api/admin/courses/:courseId/units/:unitId/lessons/archives`** — list archived **`GET /api/admin/courses/:courseId/units/:unitId/lessons/archives/:lessonId`** — get one archived --- ### Bulk Archive/Restore Lessons **`DELETE /api/admin/courses/:courseId/units/:unitId/lessons/bulk`** — bulk archive **`PATCH /api/admin/courses/:courseId/units/:unitId/lessons/restore/bulk`** — bulk restore ```json { "ids": [1, 2] } ``` --- ### Get Lesson Field Values **`GET /api/admin/courses/:courseId/units/:unitId/field-values`** --- ## Lesson Page Each lesson has exactly **one** page. A page is created automatically when a lesson is created (with empty `blocks`). ### Get Lesson Page **`GET /api/admin/courses/:courseId/units/:unitId/lessons/:lessonId/page`** ### Response `200` ```json { "status": "success", "message": "Lesson page retrieved.", "data": { "page_id": 1, "lesson_id": 3, "blocks": [ { "type": "text", "content": "A closure is..." }, { "type": "video", "asset_id": 12, "duration_seconds": 300 } ] } } ``` --- ### Upsert Lesson Page **`PUT /api/admin/courses/:courseId/units/:unitId/lessons/:lessonId/page`** Creates or replaces the lesson page's block content. After saving, `duration_seconds` on the lesson (and its ancestor unit and course) is automatically recomputed from video blocks. ### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `blocks` | array | **Yes** | Block array | | `updatedBy` | number | No | | ### Response `200` (updated) / `201` (created) ```json { "status": "success", "message": "Lesson page updated.", "data": { ... } } ``` ### Error Responses | Status | Message | |--------|---------| | `400` | `blocks must be an array.` | | `404` | `Lesson not found.` | --- ## Course Reading Progress > These endpoints are served from `controllers/admin/course_reading_progress.controller.js` but mounted on the `/api/admin/courses` router. ### Get Course Reading Progress **`GET /api/admin/courses/:courseId/reading-progress`** Returns one summary row per user who has touched the course. Includes lesson/unit completion counts derived from the live course structure (not from stored counters). Sorted: in-progress users first (most recent access first), completed users last. ### Response `200` ```json { "status": "success", "message": "Course reading progress retrieved.", "data": [ { "user_id": 42, "course_status": "in_progress", "last_accessed_at": "2026-06-21T09:00:00.000Z", "lessons_completed": 3, "units_completed": 1, "user": { "email": "user@example.com", "full_name": "Jane Doe", "avatar_url": "https://..." }, "units_total": 4, "lessons_total": 12 } ] } ``` --- ### Get User Reading Progress **`GET /api/admin/courses/:courseId/reading-progress/users/:userId`** Loaded lazily when the admin expands a user row. Returns the full unit → lesson breakdown with progress status per item. ### Response `200` ```json { "status": "success", "message": "User reading progress retrieved.", "data": [ { "unit_id": 1, "uuid": "...", "title": "Introduction", "status": "completed", "lessons": [ { "lesson_id": 1, "uuid": "...", "title": "What is a closure?", "status": "completed", "completed_at": "2026-06-20T10:00:00.000Z" }, { "lesson_id": 2, "uuid": "...", "title": "Closure examples", "status": null, "completed_at": null } ] } ] } ```