ready to test

Testing

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-06-22 10:06:58 +08:00
parent bf48c95467
commit 439bb33f77
189 changed files with 17559 additions and 686 deletions
@@ -0,0 +1,284 @@
# Advertisements Controller Documentation
**File:** `controllers/admin/advertisements.controller.js`
**Base URL:** `/api/admin/advertisements`
**Guards:** `authenticate → requireAdmin() → adminLimiter`
---
## Table of Contents
- [Get All Advertisements](#get-all-advertisements)
- [Get Single Advertisement](#get-single-advertisement)
- [Create Advertisement](#create-advertisement)
- [Update Advertisement](#update-advertisement)
- [Archive Advertisement](#archive-advertisement)
- [Bulk Archive Advertisements](#bulk-archive-advertisements)
- [Restore Advertisement](#restore-advertisement)
- [Bulk Restore Advertisements](#bulk-restore-advertisements)
- [Get Archived Advertisements](#get-archived-advertisements)
- [Get Field Values](#get-field-values)
---
## Status Derivation
Status is **never** trusted as stored — it is recomputed on every read and write:
| Condition | Derived Status |
|-----------|----------------|
| `deletedAt` is set | `archived` |
| `is_active = false` | `draft` |
| `end_date` < now | `expired` |
| `start_date` > now | `scheduled` |
| Otherwise | `active` |
`archived` is the only status that bypasses derivation (set explicitly by archive/restore).
---
## CTAs
Each advertisement supports up to **2 CTAs**:
```json
[
{ "label": "Learn More", "link": "/courses", "variant": "default" },
{ "label": "Sign Up", "link": "/register", "variant": "outline" }
]
```
- First CTA defaults to `"default"` variant; second defaults to `"outline"`.
- An explicit valid variant from the client (`"default"` or `"outline"`) always wins.
- Items beyond 2 are silently discarded.
---
## Get All Advertisements
**`GET /api/admin/advertisements`**
Returns a paginated list of active (non-deleted) advertisements. Status is resynced on the way out.
### Query Parameters
| Parameter | Type | Required | Description |
|-----------|--------|----------|-------------|
| page | number | No | Default: `1` |
| limit | number | No | Default: `10`, max: `1000` |
| filters | array | No | JSON array of filter objects |
| sort | array | No | JSON array of sort objects |
### Response `200`
```json
{
"status": "success",
"message": "Advertisements retrieved.",
"data": [...],
"pagination": { "page": 1, "limit": 10, "total": 5, "totalPages": 1 }
}
```
---
## Get Single Advertisement
**`GET /api/admin/advertisements/:advertisementId`**
Returns one advertisement with its `image` asset and audit user info.
### Response `200`
```json
{
"status": "success",
"message": "Advertisement retrieved.",
"data": {
"advertisement_id": 1,
"uuid": "...",
"type": "hero",
"status": "active",
"badge_label": "New",
"headline": "Welcome",
"description": "...",
"image_url": null,
"image_asset_id": 12,
"image": { "asset_id": 12, "display_name": "hero.jpg", "file_url": "...", "thumbnail_url": "..." },
"ctas": [{ "label": "Start", "link": "/start", "variant": "default" }],
"start_date": "2026-01-01T00:00:00.000Z",
"end_date": null,
"order": 0,
"is_active": true,
"size": null,
"click_count": 0,
"creator": { "user_id": 1, "full_name": "Admin User" },
"updater": null,
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:00:00.000Z"
}
}
```
### Error Responses
| Status | Message |
|--------|---------|
| `400` | `Invalid advertisement ID.` |
| `404` | `Advertisement not found.` |
---
## Create Advertisement
**`POST /api/admin/advertisements`**
### Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `type` | string | **Yes** | `hero`, `banner`, `popup`, `sidebar` |
| `createdBy` | number | **Yes** | User ID of creator |
| `badge_label` | string | No | Small label shown on the ad |
| `headline` | string | No | Main heading |
| `description` | string | No | Body text |
| `image_url` | string | No | Direct image URL |
| `image_asset_id` | number | No | FK to `assets` table |
| `ctas` | array | No | Up to 2 CTA objects `[{ label, link, variant }]` |
| `start_date` | date | No | ISO date string |
| `end_date` | date | No | ISO date string |
| `order` | number | No | Display order. Default: `0` |
| `is_active` | boolean | No | Default: `true` |
| `size` | string | No | `sm`, `md`, `lg` — banner only |
### Response `201`
```json
{
"status": "success",
"message": "Advertisement created.",
"data": { ... }
}
```
### Error Responses
| Status | Message |
|--------|---------|
| `400` | `type is required.` |
| `400` | `Invalid type. Must be one of: hero, banner, popup, sidebar` |
| `400` | `createdBy is required.` |
| `400` | `Invalid size. Must be one of: sm, md, lg` |
---
## Update Advertisement
**`PATCH /api/admin/advertisements/:advertisementId`**
Partial update. Only fields present in the body are changed. Status is recomputed after all fields are applied.
### Request Body
Same optional fields as Create. Does not accept `type` once set. Accepts `updatedBy`.
### Response `200`
```json
{ "status": "success", "message": "Advertisement updated.", "data": { ... } }
```
---
## Archive Advertisement
**`DELETE /api/admin/advertisements/:advertisementId`**
Soft-deletes the advertisement (`deletedAt` set, `status` → `archived`).
### Response `200`
```json
{ "status": "success", "message": "Advertisement archived." }
```
---
## Bulk Archive Advertisements
**`DELETE /api/admin/advertisements/bulk`**
Rate-limited. Soft-deletes multiple advertisements.
### Request Body
```json
{ "ids": [1, 2, 3], "deletedBy": 1 }
```
### Response `200`
```json
{
"status": "success",
"message": "3 advertisement(s) archived.",
"archived_ids": [1, 2, 3],
"skipped_ids": []
}
```
---
## Restore Advertisement
**`PATCH /api/admin/advertisements/:advertisementId/restore`**
Restores a soft-deleted advertisement.
### Response `200`
```json
{ "status": "success", "message": "Advertisement restored.", "data": { ... } }
```
---
## Bulk Restore Advertisements
**`PATCH /api/admin/advertisements/bulk-restore`**
### Request Body
```json
{ "ids": [1, 2] }
```
### Response `200`
```json
{
"status": "success",
"message": "2 advertisement(s) restored.",
"restored_ids": [1, 2],
"skipped_ids": []
}
```
---
## Get Archived Advertisements
**`GET /api/admin/advertisements/archived`**
Returns paginated list of soft-deleted advertisements.
### Response `200`
```json
{
"status": "success",
"message": "Archived advertisements retrieved.",
"data": [...],
"pagination": { ... }
}
```
---
## Get Field Values
**`GET /api/admin/advertisements/field-values`**
Returns distinct values for filterable advertisement fields. Used by DataTable filter dropdowns.
### Response `200`
```json
{
"status": "success",
"message": "Field values retrieved.",
"data": { "type": ["hero", "banner"], "status": ["active", "draft"] }
}
```
@@ -0,0 +1,171 @@
# Categories Controller Documentation
**File:** `controllers/admin/categories.controller.js`
**Base URL:** `/api/admin/categories`
**Guards:** `authenticate → requireAdmin() → adminLimiter`
---
## Table of Contents
- [Get All Categories](#get-all-categories)
- [Get Single Category](#get-single-category)
- [Create Category](#create-category)
- [Update Category](#update-category)
- [Archive Category](#archive-category)
- [Restore Category](#restore-category)
---
## Notes
- `slug` is auto-generated from `name` on create and update: lowercased, trimmed, non-alphanumeric runs replaced with `-`.
- `slug` and `name` must be **unique** across all categories (including archived ones).
- `paranoid: false` is used on GET All and GET One, so archived categories are visible.
---
## Get All Categories
**`GET /api/admin/categories`**
Returns all categories ordered alphabetically by name. Includes archived rows.
### Response `200`
```json
{
"status": "success",
"message": "Categories retrieved.",
"data": [
{
"id": 1,
"name": "Business",
"slug": "business",
"description": "Business courses",
"is_active": true,
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:00:00.000Z",
"deletedAt": null
}
]
}
```
---
## Get Single Category
**`GET /api/admin/categories/:id`**
Returns one category. Includes archived.
### Response `200`
```json
{
"status": "success",
"message": "Category retrieved.",
"data": { "id": 1, "name": "Business", "slug": "business", ... }
}
```
### Error Responses
| Status | Message |
|--------|---------|
| `404` | `Category not found.` |
---
## Create Category
**`POST /api/admin/categories`**
### Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | **Yes** | Display name. Must be unique. |
| `description` | string | No | Optional description. |
| `is_active` | boolean | No | Default: `true` |
### Response `201`
```json
{
"status": "success",
"message": "Category created.",
"data": { "id": 2, "name": "Design", "slug": "design", "is_active": true, ... }
}
```
### Error Responses
| Status | Message |
|--------|---------|
| `400` | `name is required.` |
| `409` | `A category with that name already exists.` |
---
## Update Category
**`PUT /api/admin/categories/:id`**
Full update. Only non-`null`/`undefined` fields are changed. `slug` is re-generated if `name` changes.
### Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | No | New display name. |
| `description` | string | No | |
| `is_active` | boolean | No | |
### Response `200`
```json
{
"status": "success",
"message": "Category updated.",
"data": { ... }
}
```
### Error Responses
| Status | Message |
|--------|---------|
| `404` | `Category not found.` |
| `409` | `A category with that name already exists.` |
---
## Archive Category
**`DELETE /api/admin/categories/:id`**
Soft-deletes the category (`deletedAt` set).
### Response `200`
```json
{ "status": "success", "message": "Category archived." }
```
### Error Responses
| Status | Message |
|--------|---------|
| `404` | `Category not found.` |
---
## Restore Category
**`POST /api/admin/categories/:id/restore`**
Restores a soft-deleted category.
### Response `200`
```json
{
"status": "success",
"message": "Category restored.",
"data": { ... }
}
```
### Error Responses
| Status | Message |
|--------|---------|
| `404` | `Category not found.` |
+843
View File
@@ -0,0 +1,843 @@
# 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
}
]
}
]
}
```
@@ -0,0 +1,90 @@
# Dashboard Controller Documentation
**File:** `controllers/admin/dashboard.controller.js`
**Base URL:** `/api/admin/dashboard`
**Guards:** `authenticate → requireAdmin() → adminLimiter`
---
## Table of Contents
- [Users Dashboard](#users-dashboard)
- [Groups Dashboard](#groups-dashboard)
---
## Users Dashboard
**`GET /api/admin/dashboard/users`**
Returns summary stats and breakdowns for all users. All counts run in parallel.
### Response `200`
```json
{
"status": "success",
"message": "Users dashboard data fetched.",
"data": {
"stats": [
{ "key": "total", "label": "Total Users", "value": 100 },
{ "key": "active", "label": "Active Users", "value": 85 },
{ "key": "inactive", "label": "Inactive Users", "value": 10 },
{ "key": "verified", "label": "Verified", "value": 80 },
{ "key": "archived", "label": "Archived", "value": 5 }
],
"breakdowns": [
{
"key": "acc_type",
"label": "By Account Type",
"data": [
{ "label": "user", "value": 90 },
{ "label": "staff", "value": 8 },
{ "label": "admin", "value": 2 }
]
},
{
"key": "reg_type",
"label": "By Registration Type",
"data": [
{ "label": "system", "value": 75 },
{ "label": "google", "value": 25 }
]
}
]
}
}
```
---
## Groups Dashboard
**`GET /api/admin/dashboard/groups`**
Returns summary stats and a top-10 groups-by-member-count breakdown.
### Response `200`
```json
{
"status": "success",
"message": "Groups dashboard data fetched.",
"data": {
"stats": [
{ "key": "total", "label": "Total Groups", "value": 20 },
{ "key": "active", "label": "Active Groups", "value": 18 },
{ "key": "inactive", "label": "Inactive Groups", "value": 0 },
{ "key": "archived", "label": "Archived", "value": 2 },
{ "key": "empty", "label": "Empty Groups", "value": 3 }
],
"breakdowns": [
{
"key": "top_groups",
"label": "Top Groups by Members",
"data": [
{ "label": "Engineering", "value": 42 },
{ "label": "Marketing", "value": 30 }
]
}
]
}
}
```
@@ -0,0 +1,188 @@
# Landing Pages / Page Builder — Schema Documentation
**Migrations:** `41b`, `42`, `43`, `44`, `45`, `49`
**Base URL (planned):** `/api/admin/pages`
**Guards:** `authenticate → requireAdmin() → adminLimiter`
> **TODO:** Controller, service, and routes for this module have not been created yet.
---
## Table of Contents
- [Overview](#overview)
- [Schema](#schema)
- [pages](#pages)
- [landing_pages](#landing_pages)
- [page_sections](#page_sections)
- [page_blocks](#page_blocks)
- [page_templates](#page_templates)
- [page_user_groups](#page_user_groups)
- [Relationships](#relationships)
- [Design Decisions](#design-decisions)
- [ENUM Types](#enum-types)
---
## Overview
The page builder uses a generalized `pages` root table as the single owner of all `page_sections`. This means sections and blocks are not locked to `landing_pages` — any future page type (`lesson_page`, `course_page`, etc.) can reuse the same section/block system by linking to `pages`.
**Hierarchy:**
```
pages
├── landing_pages (1:1 via page_id)
└── page_sections (1:N via page_id)
└── page_blocks (1:N via page_section_id)
page_templates — standalone reusable layout snapshots
page_user_groups — controls which groups can see a page
```
---
## Schema
### `pages`
Root identity table. Every page type gets a row here first.
| Column | Type | Constraints | Description |
|------------|------------|----------------------|------------------------------------|
| id | BIGSERIAL | PK | |
| type | page_type | NOT NULL | Discriminator for the page type |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
**Indexes:** `idx_pages_type` on `(type)`
---
### `landing_pages`
Landing-page-specific metadata. One-to-one with `pages`.
| Column | Type | Constraints | Description |
|------------------|--------------------|------------------------------------|------------------------------|
| id | BIGSERIAL | PK | |
| page_id | BIGINT | NOT NULL, UNIQUE, FK → pages(id) | Link to root pages table |
| title | VARCHAR(255) | NOT NULL | |
| slug | VARCHAR(255) | NOT NULL, UNIQUE | URL path segment |
| meta_title | VARCHAR(255) | | SEO title override |
| meta_description | TEXT | | SEO description |
| status | landing_page_status | NOT NULL DEFAULT 'draft' | |
| published_at | TIMESTAMPTZ | | Set when first published |
| created_by | BIGINT | FK → users(user_id) SET NULL | Admin who created the page |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
**Indexes:** `idx_landing_pages_status` on `(status)`
---
### `page_sections`
Ordered sections within any page. References `pages`, not `landing_pages`.
| Column | Type | Constraints | Description |
|------------|------------------|--------------------------------|------------------------------------|
| id | BIGSERIAL | PK | |
| page_id | BIGINT | NOT NULL, FK → pages(id) | Belongs to a page (any type) |
| type | page_section_type | NOT NULL | Section layout type |
| label | VARCHAR(255) | | Admin-facing label |
| position | INT | NOT NULL DEFAULT 0 | Display order |
| settings | JSONB | | Background, padding, layout, etc. |
| is_visible | BOOLEAN | NOT NULL DEFAULT true | Toggle section visibility |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
**Indexes:** `idx_page_sections_page_id` on `(page_id)`, `idx_page_sections_order` on `(page_id, position)`
---
### `page_blocks`
Content blocks within a section.
| Column | Type | Constraints | Description |
|-----------------|----------------|------------------------------------|----------------------------------------|
| id | BIGSERIAL | PK | |
| page_section_id | BIGINT | NOT NULL, FK → page_sections(id) | |
| type | page_block_type | NOT NULL | Block content type |
| content | JSONB | | Payload — varies by type (see below) |
| position | INT | NOT NULL DEFAULT 0 | Display order within section |
| settings | JSONB | | Block-level style overrides |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
**Indexes:** `idx_page_blocks_section_id` on `(page_section_id)`, `idx_page_blocks_order` on `(page_section_id, position)`
**`content` shape by block type:**
| type | content fields |
|--------|-----------------------------------------|
| text | `{ body: string }` |
| image | `{ src: string, alt: string }` |
| button | `{ label: string, href: string, variant: string }` |
| video | `{ src: string, autoplay: boolean }` |
| form | `{ form_id: number }` |
| spacer | `{ height: number }` |
---
### `page_templates`
Reusable layout snapshots. Stored as a full JSONB dump of sections + blocks — not live FK references.
| Column | Type | Constraints | Description |
|---------------|-------------|------------------------|--------------------------------------|
| id | BIGSERIAL | PK | |
| name | VARCHAR(255) | NOT NULL | Template display name |
| thumbnail_url | TEXT | | Preview image URL |
| structure | JSONB | | Full snapshot of sections and blocks |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
---
### `page_user_groups`
Junction table — controls which user groups can access a page.
| Column | Type | Constraints | Description |
|------------|-------------|--------------------------------------|-------------|
| page_id | BIGINT | PK, FK → pages(id) ON DELETE CASCADE | |
| group_id | BIGINT | PK, FK → user_groups(group_id) | |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT now() | |
**Indexes:** `idx_page_user_groups_group_id` on `(group_id)`
**Primary Key:** composite `(page_id, group_id)`
---
## Relationships
```
pages 1 ──── 1 landing_pages
pages 1 ──── N page_sections
pages N ──── N user_groups (via page_user_groups)
page_sections 1 ──── N page_blocks
```
Cascade deletes flow top-down: deleting a `pages` row removes its `landing_pages` record, all its `page_sections`, and all nested `page_blocks` automatically.
---
## Design Decisions
- **`pages` as root** — sections belong to `pages`, not directly to `landing_pages`. Adding a new page type (e.g. `course_page`) only requires a new detail table + adding its value to the `page_type` ENUM. No changes to `page_sections` or `page_blocks`.
- **`page_templates.structure` is a snapshot** — templates store a JSONB copy of sections/blocks, not live FK references. This keeps templates stable when source pages are edited.
- **`page_sections.page_id`** points to `pages(id)` directly, giving sections access to any page type without schema changes.
---
## ENUM Types
| Type | Values |
|---------------------|------------------------------------------------------------------------|
| `page_type` | `landing_page`, `lesson_page`, `course_page` |
| `landing_page_status` | `draft`, `published`, `archived` |
| `page_section_type` | `hero`, `features`, `cta`, `testimonials`, `faq`, `pricing`, `gallery`, `custom` |
| `page_block_type` | `text`, `image`, `button`, `video`, `form`, `spacer` |
@@ -0,0 +1,125 @@
# Notifications Controller Documentation
**File:** `controllers/admin/notification.controller.js`
**Base URL:** `/api/admin/notifications`
**Guards:** `authenticate → requireAdmin() → adminLimiter`
---
## Table of Contents
- [Get All Notifications](#get-all-notifications)
- [Get Unseen Count](#get-unseen-count)
- [Mark One as Seen](#mark-one-as-seen)
- [Mark All as Seen](#mark-all-as-seen)
---
## Notes
- `admin_notifications` are **system-wide** — not scoped to a user. All admins see the same feed.
- Records are never deleted — seen/unseen state is toggled only.
- Notifications are created internally (e.g., task overdue events) — no POST endpoint is exposed.
---
## Get All Notifications
**`GET /api/admin/notifications`**
Returns paginated notifications, newest first.
### Query Parameters
| Parameter | Type | Required | Description |
|-----------|--------|----------|-------------|
| `page` | number | No | Default: `1` |
| `limit` | number | No | Default: `20`, max: `50` |
### Response `200`
```json
{
"status": "success",
"message": "Notifications fetched.",
"data": {
"notifications": [
{
"notification_id": 1,
"type": "task_overdue",
"title": "Tasks Overdue",
"message": "5 tasks are now overdue.",
"data": { "count": 5 },
"seen": false,
"seen_at": null,
"createdAt": "2026-06-19T10:00:00.000Z",
"updatedAt": "2026-06-19T10:00:00.000Z"
}
],
"pagination": {
"page": 1,
"limit": 20,
"total": 10,
"pages": 1
}
}
}
```
---
## Get Unseen Count
**`GET /api/admin/notifications/unseen`**
Returns a count of unseen notifications. Used for the bell badge.
### Response `200`
```json
{
"status": "success",
"message": "Unseen count fetched.",
"data": { "count": 3 }
}
```
---
## Mark One as Seen
**`PATCH /api/admin/notifications/:id/seen`**
Marks a single notification as seen and sets `seen_at` to the current timestamp.
### Response `200`
```json
{
"status": "success",
"message": "Notification marked as seen.",
"data": {
"notification_id": 1,
"seen": true,
"seen_at": "2026-06-21T10:00:00.000Z",
...
}
}
```
### Error Responses
| Status | Message |
|--------|---------|
| `404` | `Notification not found.` |
---
## Mark All as Seen
**`PATCH /api/admin/notifications/seen-all`**
Marks all unseen notifications as seen in a single update.
### Response `200`
```json
{
"status": "success",
"message": "7 notification(s) marked as seen.",
"data": { "count": 7 }
}
```
+147
View File
@@ -0,0 +1,147 @@
# Products Controller Documentation
**File:** `controllers/admin/products.controller.js`
**Base URL:** `/api/admin/products`
**Guards:** `authenticate → requireAdmin() → adminLimiter`
---
## Table of Contents
- [Get Course Product](#get-course-product)
- [Upsert Course Product](#upsert-course-product)
- [Remove Course Product](#remove-course-product)
- [Get Course Categories](#get-course-categories)
- [Sync Course Categories](#sync-course-categories)
---
## Notes
- Each course has **at most one** product listing (`products` table, unique on `course_id`).
- Upsert restores a soft-deleted product if one exists rather than creating a duplicate.
- Categories are managed via the `course_product_categories` junction table (many-to-many between `courses` and `categories`).
- Syncing categories replaces the full set — it is a replace-all, not an append.
---
## Get Course Product
**`GET /api/admin/products/courses/:courseId/product`**
Returns the product listing for a course, or `null` if none exists. Includes archived products (`paranoid: false`).
### Response `200`
```json
{
"status": "success",
"message": "Product retrieved.",
"data": {
"id": 1,
"course_id": 5,
"name": "Advanced JavaScript",
"description": "Full course access",
"price": "49.99",
"currency": "USD",
"access_days": 365,
"is_active": true,
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:00:00.000Z",
"deletedAt": null
}
}
```
---
## Upsert Course Product
**`PUT /api/admin/products/courses/:courseId/product`**
Creates the product if it does not exist. If a soft-deleted product exists, it is restored and updated. If an active product exists, it is updated.
### Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | **Yes** | Product name |
| `price` | number | **Yes** | Price (decimal, e.g. `49.99`) |
| `description` | string | No | |
| `currency` | string | No | ISO 4217 code. Default: `USD` |
| `access_days` | number | No | Days of access after purchase. `null` = lifetime |
| `is_active` | boolean | No | Default: `true` |
### Response `200` (updated) / `201` (created)
```json
{
"status": "success",
"message": "Product updated.",
"data": { ... }
}
```
### Error Responses
| Status | Message |
|--------|---------|
| `400` | `name and price are required.` |
---
## Remove Course Product
**`DELETE /api/admin/products/courses/:courseId/product`**
Soft-deletes the course product.
### Response `200`
```json
{ "status": "success", "message": "Product removed." }
```
### Error Responses
| Status | Message |
|--------|---------|
| `404` | `Product not found.` |
---
## Get Course Categories
**`GET /api/admin/products/courses/:courseId/categories`**
Returns the list of categories assigned to a course.
### Response `200`
```json
{
"status": "success",
"message": "Course categories retrieved.",
"data": [
{ "id": 1, "name": "Business", "slug": "business", "is_active": true },
{ "id": 3, "name": "Design", "slug": "design", "is_active": true }
]
}
```
### Error Responses
| Status | Message |
|--------|---------|
| `404` | `Course not found.` |
---
## Sync Course Categories
**`POST /api/admin/products/courses/:courseId/categories`**
Replaces the full set of category assignments for a course. All existing assignments are removed first, then the new set is inserted.
### Request Body
```json
{ "category_ids": [1, 3, 7] }
```
Pass an empty array to clear all category assignments.
### Response `200`
```json
{ "status": "success", "message": "Course categories updated." }
```
+148
View File
@@ -0,0 +1,148 @@
# Profile Controller Documentation (Admin)
**File:** `controllers/admin/profile.controller.js`
**Base URL:** `/api/admin/profile`
**Guards:** `authenticate → requireAdmin() → adminLimiter`
---
## Table of Contents
- [Get Profile](#get-profile)
- [Update Profile](#update-profile)
- [Upload Avatar](#upload-avatar)
- [Delete Avatar](#delete-avatar)
---
## Notes
- All endpoints are self-service — they operate on the **currently authenticated admin** (`req.user.user_id`).
- `password`, `otp_code`, and `otp_expires_at` are always excluded from responses.
- Avatar files are stored in S3 via `s3.service`. The old avatar is deleted before upload.
---
## Get Profile
**`GET /api/admin/profile`**
Returns the authenticated admin's full user record.
### Response `200`
```json
{
"status": "success",
"message": "Profile retrieved.",
"data": {
"user_id": 1,
"email": "admin@example.com",
"is_active": true,
"is_verified": true,
"reg_type": "system",
"acc_type": "admin",
"personal_info": {
"name": {
"given_name": "Kenneth",
"middle_name": null,
"last_name": "Obsequio",
"extension_name": null,
"full_name": "Kenneth Obsequio"
},
"occupation": null,
"addresses": [],
"phone_number": [],
"date_of_birth": null,
"avatar": {
"url": "https://...",
"uuid": "storage-key",
"name": "avatar.jpg",
"mime_type": "image/jpeg",
"size": 204800
}
},
"createdAt": "2025-10-06T00:00:00.000Z",
"updatedAt": "2026-06-18T00:00:00.000Z"
}
}
```
---
## Update Profile
**`PUT /api/admin/profile`**
Deep-merges `personal_info` — top-level keys and `name` sub-keys are merged separately. Existing keys not present in the request body are preserved.
### Request Body
```json
{
"personal_info": {
"name": {
"given_name": "Kenneth",
"last_name": "Obsequio"
},
"occupation": "Software Engineer",
"date_of_birth": "1995-05-15"
}
}
```
### Response `200`
```json
{
"status": "success",
"message": "Profile updated.",
"data": { ... }
}
```
---
## Upload Avatar
**`POST /api/admin/profile/avatar`**
Uploads (or replaces) the admin's avatar via `multipart/form-data`.
- Old avatar is deleted from S3 before uploading the new one.
- Avatar metadata is stored in `personal_info.avatar`.
### Request
`Content-Type: multipart/form-data`
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `file` | file | **Yes** | Image file (handled by `avatar_upload.middleware`) |
### Response `200`
```json
{
"status": "success",
"message": "Avatar updated.",
"data": { ... }
}
```
### Error Responses
| Status | Message |
|--------|---------|
| `400` | `No file provided.` |
---
## Delete Avatar
**`DELETE /api/admin/profile/avatar`**
Removes the admin's avatar from S3 and sets `personal_info.avatar` to `null`.
### Response `200`
```json
{ "status": "success", "message": "Avatar removed." }
```
### Error Responses
| Status | Message |
|--------|---------|
| `404` | `No avatar to remove.` |
+563
View File
@@ -0,0 +1,563 @@
# Tasks Controller Documentation
**File:** `controllers/admin/task.controller.js` + `controllers/admin/task_completion.controller.js`
**Base URL:** `/api/admin/task-lists`
**Guards:** `authenticate → requireAdmin() → adminLimiter`
---
## Table of Contents
- [Task Lists](#task-lists)
- [Get All Task Lists](#get-all-task-lists)
- [Get Single Task List](#get-single-task-list)
- [Create Task List](#create-task-list)
- [Update Task List](#update-task-list)
- [Archive Task List](#archive-task-list)
- [Restore Task List](#restore-task-list)
- [Bulk Archive Task Lists](#bulk-archive-task-lists)
- [Bulk Restore Task Lists](#bulk-restore-task-lists)
- [Get Archived Task Lists](#get-archived-task-lists)
- [Get Task List Field Values](#get-task-list-field-values)
- [Task List Groups](#task-list-groups)
- [Get Assigned Groups](#get-assigned-groups)
- [Assign Groups](#assign-groups)
- [Unassign Groups](#unassign-groups)
- [Tasks](#tasks)
- [Get All Tasks](#get-all-tasks)
- [Get Single Task](#get-single-task)
- [Create Task](#create-task)
- [Update Task](#update-task)
- [Archive Task](#archive-task)
- [Restore Task](#restore-task)
- [Bulk Archive Tasks](#bulk-archive-tasks)
- [Bulk Restore Tasks](#bulk-restore-tasks)
- [Get Archived Tasks](#get-archived-tasks)
- [Get Task Field Values](#get-task-field-values)
- [Completions](#completions)
- [Get All Completions](#get-all-completions)
- [Get Single Completion](#get-single-completion)
- [Get Completions by User](#get-completions-by-user)
- [Archive Completion](#archive-completion)
- [Restore Completion](#restore-completion)
- [Bulk Archive Completions](#bulk-archive-completions)
- [Bulk Restore Completions](#bulk-restore-completions)
---
## Data Model
```
TaskList ──< Task ──< TaskRequirement
│
└──< TaskListGroup >── UserGroup
```
- A **TaskList** is a named container of tasks, visible to assigned user groups.
- A **Task** belongs to one TaskList and has 0-N requirements.
- A **TaskRequirement** specifies what a user must do (visit a link, upload a file, or read a course/unit/lesson).
- Completions are submitted by **clients only** — admins can view and archive/restore them.
---
## Task Requirement Types
| `type` | Required Fields | Description |
|--------|----------------|-------------|
| `visit_link` | `link_url`, `link_label` | User must visit a URL |
| `upload_file` | `allowed_file_types`, `max_file_count` | User must upload files |
| `read_course` | `reference_id` (course UUID), `reference_label` | User must complete a course |
| `read_unit` | `reference_id` (unit UUID), `reference_label` | User must complete a unit |
| `read_lesson` | `reference_id` (lesson UUID), `reference_label` | User must complete a lesson |
---
## Task Lists
### Get All Task Lists
**`GET /api/admin/task-lists`**
Returns paginated active task lists.
### 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": "Task lists retrieved.",
"data": [...],
"pagination": { "page": 1, "limit": 10, "total": 5, "totalPages": 1 }
}
```
---
### Get Single Task List
**`GET /api/admin/task-lists/:taskListId`**
Returns a task list with its full task tree (tasks → requirements) and assigned groups. Includes archived items (`paranoid: false`).
### Response `200`
```json
{
"status": "success",
"message": "Task list retrieved.",
"data": {
"task_list_id": "uuid-...",
"name": "Onboarding Q1",
"description": "...",
"group_count": 2,
"groups": [
{ "group_id": 1, "name": "Engineering" }
],
"tasks": [
{
"task_id": "uuid-...",
"name": "Read the handbook",
"deadline": "2026-07-01T00:00:00.000Z",
"status": "pending",
"requirements": [
{ "requirement_id": "uuid-...", "type": "read_lesson", "reference_id": "uuid-..." }
]
}
],
"createdAt": "...",
"updatedAt": "..."
}
}
```
---
### Create Task List
**`POST /api/admin/task-lists`**
Rate-limited.
### Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | **Yes** | Unique list name |
| `description` | string | No | |
### Response `201`
```json
{
"status": "success",
"message": "Task list created successfully.",
"data": { ... }
}
```
---
### Update Task List
**`PATCH /api/admin/task-lists/:taskListId`**
Rate-limited.
### Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | No | |
| `description` | string | No | |
### Response `200`
```json
{ "status": "success", "message": "Task list updated successfully.", "data": { ... } }
```
---
### Archive Task List
**`DELETE /api/admin/task-lists/:taskListId`**
Rate-limited. Soft-deletes the task list.
### Response `200`
```json
{ "status": "success", "message": "Task list archived successfully." }
```
---
### Restore Task List
**`PATCH /api/admin/task-lists/:taskListId/restore`**
Rate-limited.
### Response `200`
```json
{ "status": "success", "message": "Task list restored successfully.", "data": { ... } }
```
---
### Bulk Archive Task Lists
**`POST /api/admin/task-lists/bulk-archive`**
Rate-limited.
### Request Body
```json
{ "ids": ["uuid-1", "uuid-2"] }
```
### Response `200`
```json
{
"status": "success",
"message": "2 task list(s) archived successfully.",
"archived_ids": [...],
"skipped_ids": []
}
```
---
### Bulk Restore Task Lists
**`POST /api/admin/task-lists/bulk-restore`**
Rate-limited. Same body shape as bulk archive.
---
### Get Archived Task Lists
**`GET /api/admin/task-lists/archived`**
Returns paginated soft-deleted task lists.
---
### Get Task List Field Values
**`GET /api/admin/task-lists/field-values`**
Returns distinct filterable field values for task lists.
---
## Task List Groups
### Get Assigned Groups
**`GET /api/admin/task-lists/:taskListId/groups`**
Returns all user groups currently assigned to the task list, including assignment metadata.
### Response `200`
```json
{
"status": "success",
"message": "Task list groups retrieved.",
"data": [
{
"id": "uuid-...",
"task_list_id": "uuid-...",
"group_id": 1,
"assignedAt": "2026-06-01T00:00:00.000Z",
"assignedBy": 1,
"group": { "group_id": 1, "name": "Engineering" }
}
]
}
```
---
### Assign Groups
**`POST /api/admin/task-lists/:taskListId/groups/assign`**
Rate-limited. Upsert-style — already-assigned groups are silently skipped.
### Request Body
```json
{ "group_ids": [1, 2, 3] }
```
### Response `201`
```json
{
"status": "success",
"message": "2 group(s) assigned.",
"assigned_ids": [2, 3],
"already_assigned_ids": [1],
"invalid_ids": []
}
```
---
### Unassign Groups
**`POST /api/admin/task-lists/:taskListId/groups/unassign`**
Rate-limited. Hard-deletes the junction rows (assignments are not soft-deleted).
### Request Body
```json
{ "group_ids": [2, 3] }
```
### Response `200`
```json
{
"status": "success",
"message": "2 group(s) unassigned.",
"unassigned_ids": [2, 3],
"skipped_ids": []
}
```
---
## Tasks
### Get All Tasks
**`GET /api/admin/task-lists/:taskListId/tasks`**
Returns paginated tasks under a task list.
### Query Parameters
Standard pagination + `filters` + `sort`.
---
### Get Single Task
**`GET /api/admin/task-lists/:taskListId/tasks/:taskId`**
Returns a task with its requirements and the parent task list (including assigned groups).
### Response `200`
```json
{
"status": "success",
"message": "Task retrieved.",
"data": {
"task_id": "uuid-...",
"task_list_id": "uuid-...",
"name": "Complete orientation",
"description": "...",
"deadline": "2026-07-01T00:00:00.000Z",
"status": "pending",
"requirements": [
{
"requirement_id": "uuid-...",
"type": "upload_file",
"allowed_file_types": ["application/pdf"],
"max_file_count": 1,
"order": 0
}
],
"taskList": { "task_list_id": "...", "name": "Onboarding Q1", "groups": [...] }
}
}
```
---
### Create Task
**`POST /api/admin/task-lists/:taskListId/tasks`**
Rate-limited. Creates a task and optionally its requirements in a single transaction.
### Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | **Yes** | Task name |
| `description` | string | No | |
| `deadline` | date | No | ISO date string |
| `requirements` | array | No | Array of requirement objects (see Requirement Types above) |
### Response `201`
```json
{
"status": "success",
"message": "Task created successfully.",
"data": { "task_id": "uuid-...", "name": "...", "requirements": [...] }
}
```
---
### Update Task
**`PATCH /api/admin/task-lists/:taskListId/tasks/:taskId`**
Rate-limited. When `requirements` is provided, the full set is replaced (soft-delete + re-insert).
> **Note:** Incoming requirement objects must **not** include `requirement_id` — the server always generates fresh IDs to avoid collisions with soft-deleted rows.
### Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | No | |
| `description` | string | No | |
| `deadline` | date | No | |
| `status` | string | No | `pending`, `in_progress`, `completed`, `overdue` |
| `requirements` | array | No | Full replacement set |
---
### Archive Task
**`DELETE /api/admin/task-lists/:taskListId/tasks/:taskId`**
Rate-limited. Soft-deletes the task.
---
### Restore Task
**`PATCH /api/admin/task-lists/:taskListId/tasks/:taskId/restore`**
Rate-limited.
---
### Bulk Archive Tasks
**`POST /api/admin/task-lists/:taskListId/tasks/bulk-archive`**
Rate-limited.
### Request Body
```json
{ "ids": ["uuid-1", "uuid-2"] }
```
---
### Bulk Restore Tasks
**`POST /api/admin/task-lists/:taskListId/tasks/bulk-restore`**
Rate-limited.
---
### Get Archived Tasks
**`GET /api/admin/task-lists/:taskListId/tasks/archived`**
---
### Get Task Field Values
**`GET /api/admin/task-lists/:taskListId/tasks/field-values`**
---
## Completions
Completions are created by clients only. Admins can view and archive/restore them.
Each completion may have multiple attached files (`task_completion_files`).
---
### Get All Completions
**`GET /api/admin/task-lists/:taskListId/tasks/:taskId/completions`**
Returns paginated completions for a task, with submitting user info and files.
### Response `200`
```json
{
"status": "success",
"message": "Completions retrieved.",
"data": [
{
"completion_id": "uuid-...",
"task_id": "uuid-...",
"user_id": 42,
"note": "Attached the signed form.",
"submitted_at": "2026-06-15T10:00:00.000Z",
"user": { "user_id": 42, "email": "user@example.com", "name": "Jane Doe" },
"files": [
{
"file_id": "uuid-...",
"file_url": "https://...",
"file_name": "signed_form.pdf",
"file_size": 204800,
"mime_type": "application/pdf"
}
]
}
],
"pagination": { ... }
}
```
---
### Get Single Completion
**`GET /api/admin/task-lists/:taskListId/tasks/:taskId/completions/:completionId`**
Returns a single completion with user and files.
---
### Get Completions by User
**`GET /api/admin/task-lists/:taskListId/tasks/:taskId/completions/user/:userId`**
Returns all completions for a specific user on a specific task.
---
### Archive Completion
**`DELETE /api/admin/task-lists/:taskListId/tasks/:taskId/completions/:completionId`**
Rate-limited. Soft-deletes a completion.
---
### Restore Completion
**`PATCH /api/admin/task-lists/:taskListId/tasks/:taskId/completions/:completionId/restore`**
Rate-limited.
---
### Bulk Archive Completions
**`POST /api/admin/task-lists/:taskListId/tasks/:taskId/completions/bulk-archive`**
Rate-limited.
### Request Body
```json
{ "ids": ["uuid-1", "uuid-2"] }
```
---
### Bulk Restore Completions
**`POST /api/admin/task-lists/:taskListId/tasks/:taskId/completions/bulk-restore`**
Rate-limited.
+406
View File
@@ -0,0 +1,406 @@
# Tiers Controller Documentation
**File:** `controllers/admin/tiers.controller.js`
**Base URL:** `/api/admin/tiers`
**Guards:** `authenticate → requireAdmin() → adminLimiter`
---
## Table of Contents
- [Tier Plans](#tier-plans)
- [Get All Plans](#get-all-plans)
- [Get Single Plan](#get-single-plan)
- [Create Plan](#create-plan)
- [Update Plan](#update-plan)
- [Archive Plan](#archive-plan)
- [Bulk Archive Plans](#bulk-archive-plans)
- [Restore Plan](#restore-plan)
- [Bulk Restore Plans](#bulk-restore-plans)
- [Get Plan Field Values](#get-plan-field-values)
- [Plan Courses](#plan-courses)
- [Get Plan Courses](#get-plan-courses)
- [Sync Plan Courses](#sync-plan-courses)
- [User Tiers](#user-tiers)
- [Get User Tiers](#get-user-tiers)
- [Grant Tier](#grant-tier)
- [Revoke Tier](#revoke-tier)
- [Payments](#payments)
- [Get All Payments](#get-all-payments)
- [Get Single Payment](#get-single-payment)
- [Get Payment Field Values](#get-payment-field-values)
---
## Notes
- Pending payments older than **60 minutes** are automatically expired before any payment list/detail call.
- Revoking a tier immediately creates a new `free` tier row for the user (auto-downgrade).
- `plan_id` is serialized as a string in create responses to avoid BigInt overflow in JS.
- A plan's `tier` field cannot be updated — only `label`, `duration_days`, `price`, `currency`, and `is_active`.
---
## Tier Plans
### Get All Plans
**`GET /api/admin/tiers`**
Returns paginated plans. Pass `?archived=true` to see soft-deleted plans instead.
### Query Parameters
| Parameter | Type | Required | Description |
|-----------|---------|----------|-------------|
| `archived` | boolean | No | `true` to show archived plans only |
| `page` | number | No | Default: `1` |
| `limit` | number | No | Default: `10` |
### Response `200`
```json
{
"status": "success",
"message": "Plans retrieved.",
"data": [...],
"pagination": { "page": 1, "limit": 10, "total": 4, "totalPages": 1 }
}
```
---
### Get Single Plan
**`GET /api/admin/tiers/:id`**
### Response `200`
```json
{
"status": "success",
"message": "Plan retrieved.",
"data": {
"plan_id": 1,
"tier": "premium",
"label": "Premium Monthly",
"duration_days": 30,
"price": "9.99",
"currency": "USD",
"is_active": true,
"createdAt": "...",
"updatedAt": "..."
}
}
```
---
### Create Plan
**`POST /api/admin/tiers`**
### Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `tier` | string | **Yes** | `premium` or `exclusive` |
| `label` | string | **Yes** | Human-readable plan name |
| `duration_days` | number | **Yes** | Access duration in days |
| `price` | number | **Yes** | Plan price (decimal) |
| `currency` | string | No | ISO 4217. Default: `USD` |
### Response `201`
```json
{
"status": "success",
"message": "Plan created.",
"data": { "plan_id": "5", "tier": "premium", ... }
}
```
---
### Update Plan
**`PUT /api/admin/tiers/:id`**
Only `label`, `duration_days`, `price`, `currency`, and `is_active` are updatable.
### Response `200`
```json
{ "status": "success", "message": "Plan updated.", "data": { ... } }
```
---
### Archive Plan
**`DELETE /api/admin/tiers/:id`**
Sets `is_active = false` then soft-deletes.
### Response `200`
```json
{ "status": "success", "message": "Plan archived successfully." }
```
---
### Bulk Archive Plans
**`POST /api/admin/tiers/bulk/archive`**
### Request Body
```json
{ "ids": [1, 2] }
```
### Response `200`
```json
{
"status": "success",
"message": "2 plan(s) archived successfully.",
"archived_ids": [1, 2],
"skipped_ids": []
}
```
---
### Restore Plan
**`POST /api/admin/tiers/:id/restore`**
Restores plan and sets `is_active = true`.
### Response `200`
```json
{ "status": "success", "message": "Plan restored successfully." }
```
---
### Bulk Restore Plans
**`POST /api/admin/tiers/bulk/restore`**
### Request Body
```json
{ "ids": [1, 2] }
```
### Response `200`
```json
{
"status": "success",
"message": "2 plan(s) restored successfully.",
"restored_ids": [1, 2],
"skipped_ids": []
}
```
---
### Get Plan Field Values
**`GET /api/admin/tiers/field-values`**
Returns distinct filterable field values for tier plans.
---
## Plan Courses
### Get Plan Courses
**`GET /api/admin/tiers/:id/courses`**
Returns the list of courses linked to this plan.
### Response `200`
```json
{
"status": "success",
"message": "Plan courses retrieved.",
"data": [
{ "course_id": 1, "title": "Intro to Python", "course_code": "PY-101", "subscription": "premium", "level": "beginner" }
]
}
```
---
### Sync Plan Courses
**`POST /api/admin/tiers/:id/courses`**
Replaces the full set of courses for this plan. Removes all existing assignments first, then inserts the new set. A course can only belong to one plan at a time — existing assignments to other plans are cleared automatically.
### Request Body
```json
{ "course_ids": [1, 2, 3] }
```
Pass `[]` to remove all courses from the plan.
### Response `200`
```json
{ "status": "success", "message": "Plan courses updated." }
```
---
## User Tiers
### Get User Tiers
**`GET /api/admin/tiers/users/:id/tiers`**
Returns the full tier history for a user, newest first. Includes `grantedByUser` and `revokedByUser` info.
### Response `200`
```json
{
"status": "success",
"message": "User tiers retrieved.",
"data": [
{
"tier_id": 3,
"user_id": 42,
"tier": "premium",
"status": "active",
"starts_at": "2026-06-01T00:00:00.000Z",
"expires_at": "2026-07-01T00:00:00.000Z",
"granted_by": 1,
"revoked_by": null,
"revoked_at": null,
"notes": "Trial promotion",
"grantedByUser": { "user_id": 1, "email": "admin@example.com" },
"revokedByUser": null
}
]
}
```
---
### Grant Tier
**`POST /api/admin/tiers/users/tiers/grant`**
- Expires all currently active tiers for the user before creating the new one.
- `expires_at` is calculated as `now + plan.duration_days`.
### Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `user_id` | number | **Yes** | Target user |
| `tier` | string | **Yes** | `premium` or `exclusive` |
| `plan_id` | number | **Yes** | Must match the plan's tier |
| `notes` | string | No | Optional admin note |
### Response `201`
```json
{
"status": "success",
"message": "Tier granted.",
"data": { "tier_id": 4, "user_id": 42, "tier": "premium", "status": "active", ... }
}
```
### Error Responses
| Status | Message |
|--------|---------|
| `400` | `user_id, tier, and plan_id are required.` |
| `400` | `Plan tier mismatch.` |
| `404` | `User not found.` |
| `404` | `Plan not found or inactive.` |
---
### Revoke Tier
**`PATCH /api/admin/tiers/users/tiers/:tid/revoke`**
- Sets the tier's status to `revoked` and records `revoked_by` / `revoked_at`.
- Automatically creates a new `free` tier row for the user (auto-downgrade note: `"Auto-downgrade after revoke."`).
### Response `200`
```json
{ "status": "success", "message": "Tier revoked. User downgraded to free." }
```
### Error Responses
| Status | Message |
|--------|---------|
| `400` | `Tier is not active.` |
| `404` | `Tier record not found.` |
---
## Payments
### Get All Payments
**`GET /api/admin/tiers/payments`**
Returns paginated payment records. Stale pending payments (>60 min old) are expired before the query runs.
### Query Parameters
| Parameter | Type | Required | Description |
|-----------|--------|----------|-------------|
| `page` | number | No | Default: `1` |
| `limit` | number | No | Default: `10` |
| `filters` | array | No | JSON filter array |
### Response `200`
```json
{
"status": "success",
"message": "Payments retrieved.",
"data": [
{
"payment_id": 1,
"user_id": 42,
"plan_id": 1,
"status": "completed",
"amount": "9.99",
"currency": "USD",
"promo_code": null,
"discount": "0.00",
"provider": "paypal",
"paid_at": "2026-06-01T10:00:00.000Z",
"user": { "user_id": 42, "email": "user@example.com" },
"plan": { "plan_id": 1, "label": "Premium Monthly", "tier": "premium", "duration_days": 30 }
}
],
"pagination": { ... }
}
```
---
### Get Single Payment
**`GET /api/admin/tiers/payments/:id`**
Returns full payment detail including `user`, `plan`, and `tier` associations.
### Response `200`
```json
{
"status": "success",
"message": "Payment retrieved.",
"data": {
"payment_id": 1,
"provider_payload": { "order_id": "...", "capture_id": "...", ... },
"user": { ... },
"plan": { ... },
"tier": { ... }
}
}
```
---
### Get Payment Field Values
**`GET /api/admin/tiers/payments/field-values`**
Returns distinct filterable field values for payments. `provider_payload` is excluded.
@@ -0,0 +1,129 @@
# User Activity Controller Documentation
**File:** `controllers/admin/user_activity.controller.js`
**Base URL:** `/api/admin/activity` and `/api/admin/users/:id/activity`
**Guards:** `authenticate → requireAdmin() → adminLimiter`
---
## Table of Contents
- [Get Global Activity Feed](#get-global-activity-feed)
- [Get Per-User Activity](#get-per-user-activity)
---
## Notes
- `user_activity` rows are **never soft-deleted** — this is an append-only audit log.
- `created_at` is the timestamp column (no `createdAt` alias — Sequelize `timestamps: false`).
- The global feed endpoint joins the `users` table and enriches each row with `full_name` and `avatar_url`.
- The per-user endpoint skips the join for performance since user context is already known.
---
## Activity Object Shape
```json
{
"activity_id": 1,
"user_id": 42,
"email": "user@example.com",
"full_name": "Kenneth Obsequio",
"avatar_url": "https://...",
"acc_type": "admin",
"action": "login",
"entity_type": "session",
"entity_id": 7,
"details": { "session_id": 7, "reg_type": "system" },
"created_at": "2026-06-21T08:00:00.000Z"
}
```
### Common `action` Values
| Action | Entity Type | Details Keys |
|--------|-------------|--------------|
| `login` | `session` | `session_id`, `reg_type` |
| `deactivate_user` | `user` | `target_email` |
| `lesson_read` | `lesson` | `lesson_uuid`, `status` |
| `submit_task` | `task` | `task_id` |
| `set_user_status` | `user` | `is_active` |
| `create_course` | `course` | `title` |
| `update_course` | `course` | `title` |
| `archive_course` | `course` | — |
| `create_task_list` | `task_list` | `name` |
| `create_advertisement` | `advertisement` | `type` |
| `grant_tier` | `tier` | `user_id`, `tier`, `plan_id` |
| `revoke_tier` | `tier` | `user_id`, `tier` |
---
## Get Global Activity Feed
**`GET /api/admin/activity`**
Returns a paginated, reverse-chronological feed of all user activity across the system.
### Query Parameters
| Parameter | Type | Required | Description |
|-----------|--------|----------|-------------|
| `page` | number | No | Default: `1` |
| `limit` | number | No | Default: `20`, max: `100` |
| `action` | string | No | Filter by exact action string (e.g. `login`) |
| `from` | date | No | ISO date — `created_at >= from` |
| `to` | date | No | ISO date — `created_at <= to` |
### Response `200`
```json
{
"status": "success",
"message": "Activity feed retrieved.",
"data": {
"total": 500,
"page": 1,
"totalPages": 25,
"activities": [ ... ]
}
}
```
---
## Get Per-User Activity
**`GET /api/admin/users/:id/activity`**
Returns a paginated, reverse-chronological activity log for a single user.
### Query Parameters
Same as global feed (`page`, `limit`, `action`, `from`, `to`).
### Response `200`
```json
{
"status": "success",
"message": "User activity retrieved.",
"data": {
"total": 45,
"page": 1,
"totalPages": 3,
"activities": [
{
"activity_id": 1,
"user_id": 42,
"session_id": null,
"action": "update_course",
"entity_type": "course",
"entity_id": 3,
"details": { "title": "Advanced JS" },
"created_at": "2026-06-21T09:00:00.000Z"
}
]
}
}
```
### Error Responses
| Status | Message |
|--------|---------|
| `400` | `Invalid User ID.` |
| `404` | `User not found.` |