Files

844 lines
20 KiB
Markdown

# 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
}
]
}
]
}
```