20 KiB
Courses Controller Documentation
File: controllers/admin/courses.controller.js
Base URL: /api/admin/courses
Guards: authenticate → requireAdmin() → adminLimiter
Table of Contents
- Courses
- Course Instructors
- Course Prerequisites
- Course Assessment
- Quiz Questions
- Units
- Unit Quiz
- Lessons
- Lesson Page
- Course 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
{
"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
{
"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
{
"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
{ "status": "success", "message": "Course archived." }
Bulk Archive Courses
DELETE /api/admin/courses/bulk
Request Body
{ "ids": [1, 2, 3] }
Restore Course
PATCH /api/admin/courses/:courseId/restore
Bulk Restore Courses
PATCH /api/admin/courses/restore/bulk
Request Body
{ "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
{
"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_idvalues — they must bestafforadminaccounts. - Passing an empty array clears all instructors.
Request Body
{
"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
{
"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
{
"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
{
"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
{
"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
{ "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
{
"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
{ "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
{
"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
{ "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
{
"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)
{
"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.jsbut mounted on the/api/admin/coursesrouter.
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
{
"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
{
"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
}
]
}
]
}