Files
starr-philproperties/apps/api/controllers/admin/documentation/courses.md
T

20 KiB

Courses Controller Documentation

File: controllers/admin/courses.controller.js
Base URL: /api/admin/courses
Guards: authenticate → requireAdmin() → adminLimiter


Table of Contents


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_id values — they must be staff or admin accounts.
  • 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.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

{
  "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
        }
      ]
    }
  ]
}