mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
chore: relocate backend into apps/api ahead of monorepo merge
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
# 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)
|
||||
|
||||
---
|
||||
|
||||
## Placement Registry
|
||||
|
||||
Every advertisement belongs to a `placement` — a page + position slug drawn from a fixed
|
||||
registry (`models/advertisements/advertisements.placements.js`). The placement determines
|
||||
the advertisement's `type` (visual format) automatically; `type` is **never** accepted from
|
||||
the client and is denormalized from the placement on every write.
|
||||
|
||||
| Placement key | Page | Position | Format |
|
||||
|---|---|---|---|
|
||||
| `dashboard.hero` | Dashboard | Hero (top of page) | `hero` |
|
||||
| `dashboard.popup` | Dashboard | Popup (on load) | `popup` |
|
||||
| `course_list.banner` | Courses | Banner (above course grid) | `banner` |
|
||||
| `course_details.banner` | Course Details | Banner (below hero) | `banner` |
|
||||
| `course_details.sidebar` | Course Details | Sidebar (beside course content) | `sidebar` |
|
||||
| `plans.banner` | Plans | Banner (above plan cards) | `banner` |
|
||||
|
||||
Adding a new placement is a one-line addition to that registry file plus wiring the
|
||||
corresponding client page to fetch/render it — nothing else needs to change.
|
||||
|
||||
---
|
||||
|
||||
## 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": "...",
|
||||
"placement": "dashboard.hero",
|
||||
"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 |
|
||||
|-------|------|----------|-------------|
|
||||
| `placement` | string | **Yes** | A placement registry key, e.g. `dashboard.hero` — see [Placement Registry](#placement-registry). Determines `type` automatically. |
|
||||
| `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` | `placement is required.` |
|
||||
| `400` | `Invalid placement. Must be one of: dashboard.hero, dashboard.popup, ...` |
|
||||
| `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. `type` is never accepted — it's always derived from `placement`. 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", "popup", "sidebar"],
|
||||
"placement": ["dashboard.hero", "dashboard.popup", "course_list.banner"],
|
||||
"status": ["active", "draft"]
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,428 @@
|
||||
# Assets API
|
||||
|
||||
Base path: `/api/admin/assets`
|
||||
Controller: `controllers/admin/assets.controller.js`
|
||||
Storage: Chibisafe (CDN) + PostgreSQL via Sequelize
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Multer setup
|
||||
|
||||
The upload and update-thumbnail endpoints use `multer.fields()` — make sure your route file is configured with `memoryStorage`:
|
||||
|
||||
```js
|
||||
const multer = require("multer");
|
||||
const upload = multer({ storage: multer.memoryStorage() });
|
||||
|
||||
// Upload
|
||||
router.post("/", upload.fields([{ name: "file", maxCount: 1 }, { name: "thumbnail", maxCount: 1 }]), assetsCtrl.uploadAsset);
|
||||
|
||||
// Update thumbnail
|
||||
router.patch("/:assetId/thumbnail", upload.fields([{ name: "thumbnail", maxCount: 1 }]), assetsCtrl.updateThumbnail);
|
||||
```
|
||||
|
||||
### Environment variables
|
||||
|
||||
```env
|
||||
CHIBISAFE_BASE_URL=https://cdn.yourdomain.com
|
||||
CHIBISAFE_API_KEY=your-api-key
|
||||
|
||||
CHIBISAFE_ALBUM_AVATARS=uuid
|
||||
CHIBISAFE_ALBUM_VIDEOS=uuid
|
||||
CHIBISAFE_ALBUM_DOCUMENTS=uuid
|
||||
CHIBISAFE_ALBUM_THUMBNAILS=uuid
|
||||
CHIBISAFE_ALBUM_ARCHIVED=uuid
|
||||
```
|
||||
|
||||
### Album routing
|
||||
|
||||
`owner_type` is the single source of truth for which Chibisafe album a file lands in:
|
||||
|
||||
| `owner_type` | Chibisafe album | Intended use |
|
||||
|---|---|---|
|
||||
| `avatar` | avatars | Profile pictures |
|
||||
| `video` | videos | Course / content videos |
|
||||
| `document` | documents | PDF, DOCX, PPT, TXT, etc. |
|
||||
| `thumbnail` | thumbnails | Set automatically — do not send manually |
|
||||
| `image` | *(none)* | General-purpose images |
|
||||
| anything else | *(none)* | Unclassified |
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
---
|
||||
|
||||
### GET `/`
|
||||
|
||||
List all assets (paginated).
|
||||
|
||||
**Query params**
|
||||
|
||||
| Param | Required | Description |
|
||||
|---|---|---|
|
||||
| `page` | optional | Page number. Default: `1` |
|
||||
| `limit` | optional | Items per page. Default: `10`, max: `1000` |
|
||||
| `filters` | optional | JSON array of filter objects passed to `buildQuery` |
|
||||
| `sort` | optional | JSON array of sort objects passed to `buildQuery` |
|
||||
|
||||
**Response `200`**
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Assets retrieved.",
|
||||
"data": [...],
|
||||
"pagination": {
|
||||
"page": 1,
|
||||
"limit": 10,
|
||||
"totalRecords": 42,
|
||||
"totalPages": 5,
|
||||
"hasPrevPage": false,
|
||||
"hasNextPage": true
|
||||
},
|
||||
"attributes": [...]
|
||||
}
|
||||
```
|
||||
|
||||
Soft-deleted assets are excluded automatically. Hidden fields (per `adminExclude`): `checksum`, `storage_bucket`, `storage_key`, `deletedBy`.
|
||||
|
||||
---
|
||||
|
||||
### GET `/:assetId`
|
||||
|
||||
Get a single asset by primary key.
|
||||
|
||||
**URL params**
|
||||
|
||||
| Param | Required | Description |
|
||||
|---|---|---|
|
||||
| `assetId` | required | Asset primary key (BIGINT) |
|
||||
|
||||
**Responses**
|
||||
|
||||
| Status | Description |
|
||||
|---|---|
|
||||
| `200` | `{ data: asset }` |
|
||||
| `400` | Invalid asset ID |
|
||||
| `404` | Asset not found |
|
||||
| `500` | Internal server error |
|
||||
|
||||
---
|
||||
|
||||
### POST `/`
|
||||
|
||||
Upload a new asset.
|
||||
|
||||
**Content-Type:** `multipart/form-data`
|
||||
|
||||
#### File fields
|
||||
|
||||
| Field | Required | Description |
|
||||
|---|---|---|
|
||||
| `file` | **required** | The main asset (image, video, document, etc.) |
|
||||
| `thumbnail` | **required if video** | Cover image for the video. Ignored for non-video files. |
|
||||
|
||||
#### Text fields
|
||||
|
||||
| Field | Required | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `uploadedBy` | **required** | — | User ID (BIGINT) of the uploader |
|
||||
| `storage_provider` | **required** | — | `chibisafe` \| `local` \| `s3` \| `gcs` \| `cloudinary` |
|
||||
| `owner_type` | optional | `null` | Determines album routing: `avatar`, `video`, `document`, `image` |
|
||||
| `owner_id` | optional | `null` | ID of the owning entity (course ID, user ID, etc.) |
|
||||
| `display_name` | optional | original filename | Human-readable name shown in the UI |
|
||||
| `description` | optional | `null` | Free-text description |
|
||||
| `is_public` | optional | `false` | `true` \| `false` |
|
||||
| `access_level` | optional | `private` | `public` \| `private` \| `restricted` |
|
||||
| `storage_bucket` | optional | `null` | Bucket name (S3 / GCS only) |
|
||||
| `storage_key` | optional | `null` | Override storage key. Auto-set for Chibisafe (uses Chibisafe file UUID). |
|
||||
| `file_url` | conditional | — | Required when `storage_provider` is not `local` or `chibisafe` |
|
||||
| `width` | optional (non-video) | `null` | Image/document width in px. Ignored for videos. |
|
||||
| `height` | optional (non-video) | `null` | Image/document height in px. Ignored for videos. |
|
||||
|
||||
#### Auto-extracted fields (videos only — do not send)
|
||||
|
||||
These are extracted server-side via **ffprobe** and will override anything the client sends:
|
||||
|
||||
| Field | Source | Example |
|
||||
|---|---|---|
|
||||
| `width` | ffprobe | `1920` |
|
||||
| `height` | ffprobe | `1080` |
|
||||
| `resolution` | derived | `1080p`, `720p`, `4K` |
|
||||
| `duration` | ffprobe | `281.49` (seconds) |
|
||||
| `frame_rate` | ffprobe | `23.976` (fps) |
|
||||
| `bitrate` | ffprobe | `447933` (bps) |
|
||||
| `video_codec` | ffprobe | `H.264`, `H.265`, `AV1`, `VP9` |
|
||||
| `audio_codec` | ffprobe | `AAC`, `MP3`, `Opus` |
|
||||
| `thumbnail_url` | Chibisafe upload | CDN URL of the uploaded thumbnail |
|
||||
|
||||
#### Transaction strategy
|
||||
|
||||
```
|
||||
Phase 1 (no DB connection held — slow I/O):
|
||||
├─ Validate inputs
|
||||
├─ Upload main file to Chibisafe → track UUID for rollback
|
||||
├─ Run ffprobe on video buffer → extract metadata
|
||||
└─ Upload thumbnail to Chibisafe → track UUID for rollback
|
||||
|
||||
Phase 2 (transaction open ~milliseconds):
|
||||
└─ Asset.create() → commit
|
||||
|
||||
On Phase 2 failure:
|
||||
└─ rollback DB + deleteFile() all tracked Chibisafe UUIDs
|
||||
```
|
||||
|
||||
**Responses**
|
||||
|
||||
| Status | Description |
|
||||
|---|---|
|
||||
| `201` | `{ data: asset }` — fully populated asset record |
|
||||
| `400` | Missing `file`, `uploadedBy`, or `thumbnail` (for videos); buffer issues |
|
||||
| `500` | DB or Chibisafe error — Chibisafe uploads are cleaned up automatically |
|
||||
|
||||
#### Example — video upload (Postman)
|
||||
|
||||
```
|
||||
POST /api/admin/assets
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
file → (attach .mp4)
|
||||
thumbnail → (attach .jpg)
|
||||
uploadedBy → 1
|
||||
storage_provider → chibisafe
|
||||
owner_type → video
|
||||
owner_id → 10
|
||||
display_name → Intro to React
|
||||
is_public → true
|
||||
access_level → public
|
||||
```
|
||||
|
||||
#### Example — avatar upload
|
||||
|
||||
```
|
||||
POST /api/admin/assets
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
file → (attach .jpg)
|
||||
uploadedBy → 1
|
||||
storage_provider → chibisafe
|
||||
owner_type → avatar
|
||||
owner_id → 5
|
||||
```
|
||||
|
||||
#### Example — document upload
|
||||
|
||||
```
|
||||
POST /api/admin/assets
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
file → (attach .pdf)
|
||||
uploadedBy → 1
|
||||
storage_provider → chibisafe
|
||||
owner_type → document
|
||||
owner_id → 7
|
||||
display_name → Module 1 Handout
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### PATCH `/:assetId/thumbnail`
|
||||
|
||||
Replace the thumbnail image of an existing asset by uploading a new file.
|
||||
|
||||
**Content-Type:** `multipart/form-data`
|
||||
|
||||
**URL params**
|
||||
|
||||
| Param | Required | Description |
|
||||
|---|---|---|
|
||||
| `assetId` | **required** | Asset primary key |
|
||||
|
||||
**File field**
|
||||
|
||||
| Field | Required | Description |
|
||||
|---|---|---|
|
||||
| `thumbnail` | **required** | New thumbnail image file |
|
||||
|
||||
**How it works**
|
||||
|
||||
1. Uploads the new thumbnail to Chibisafe (thumbnails album).
|
||||
2. Updates `thumbnail_url` on the asset record.
|
||||
3. Deletes the old thumbnail from Chibisafe (best-effort — non-fatal if it fails).
|
||||
|
||||
> **Note:** Old thumbnail cleanup requires a `thumbnail_storage_key` column on the Asset model to track the previous Chibisafe file UUID. Without it, the old thumbnail remains on Chibisafe but the DB record is updated correctly.
|
||||
|
||||
**Responses**
|
||||
|
||||
| Status | Description |
|
||||
|---|---|
|
||||
| `200` | `{ data: asset }` — updated asset with new `thumbnail_url` |
|
||||
| `400` | No thumbnail file attached |
|
||||
| `404` | Asset not found |
|
||||
| `500` | Internal server error |
|
||||
|
||||
---
|
||||
|
||||
### PUT `/:assetId`
|
||||
|
||||
Update asset metadata. **File uploads are blocked on this endpoint.**
|
||||
|
||||
**Content-Type:** `application/json`
|
||||
|
||||
**URL params**
|
||||
|
||||
| Param | Required | Description |
|
||||
|---|---|---|
|
||||
| `assetId` | **required** | Asset primary key |
|
||||
|
||||
**Body** — all fields optional, send only what changes
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `display_name` | string | New display name |
|
||||
| `description` | string | New description |
|
||||
| `owner_type` | string | New owner type |
|
||||
| `owner_id` | number | New owner entity ID |
|
||||
| `is_public` | boolean | `true` \| `false` |
|
||||
| `access_level` | string | `public` \| `private` \| `restricted` |
|
||||
| `thumbnail_url` | string | Manually replace thumbnail URL (use PATCH `/thumbnail` to upload a file instead) |
|
||||
| `width` | number | Width in px. Re-derives `resolution` automatically. |
|
||||
| `height` | number | Height in px. Re-derives `resolution` automatically. |
|
||||
| `duration` | number | Duration in seconds |
|
||||
| `frame_rate` | number | fps |
|
||||
| `bitrate` | number | bps |
|
||||
| `video_codec` | string | e.g. `H.264` |
|
||||
| `audio_codec` | string | e.g. `AAC` |
|
||||
|
||||
**Responses**
|
||||
|
||||
| Status | Description |
|
||||
|---|---|
|
||||
| `200` | `{ data: asset }` |
|
||||
| `400` | Invalid ID or file attached to request |
|
||||
| `404` | Asset not found |
|
||||
| `500` | Internal server error |
|
||||
|
||||
---
|
||||
|
||||
### DELETE `/:assetId`
|
||||
|
||||
Soft-delete a single asset.
|
||||
|
||||
Sets `deletedAt` on the DB record and moves the file to the **archived** album on Chibisafe (best-effort — non-fatal if Chibisafe is unavailable).
|
||||
|
||||
**Content-Type:** `application/json`
|
||||
|
||||
**URL params**
|
||||
|
||||
| Param | Required | Description |
|
||||
|---|---|---|
|
||||
| `assetId` | **required** | Asset primary key |
|
||||
|
||||
**Body**
|
||||
|
||||
| Field | Required | Description |
|
||||
|---|---|---|
|
||||
| `deletedBy` | optional | User ID performing the delete |
|
||||
|
||||
**Responses**
|
||||
|
||||
| Status | Description |
|
||||
|---|---|
|
||||
| `200` | Asset deleted |
|
||||
| `400` | Invalid asset ID |
|
||||
| `404` | Asset not found |
|
||||
| `500` | Internal server error |
|
||||
|
||||
---
|
||||
|
||||
### DELETE `/bulk`
|
||||
|
||||
Soft-delete multiple assets in one call.
|
||||
|
||||
All matching Chibisafe files are moved to the **archived** album in a single API call.
|
||||
|
||||
**Content-Type:** `application/json`
|
||||
|
||||
**Body**
|
||||
|
||||
| Field | Required | Description |
|
||||
|---|---|---|
|
||||
| `ids` | **required** | Non-empty array of asset IDs: `[1, 2, 3]` |
|
||||
| `deletedBy` | optional | User ID performing the delete |
|
||||
|
||||
**Responses**
|
||||
|
||||
| Status | Description |
|
||||
|---|---|
|
||||
| `200` | `N asset(s) deleted` |
|
||||
| `400` | `ids` missing or empty |
|
||||
| `500` | Internal server error |
|
||||
|
||||
---
|
||||
|
||||
### POST `/:assetId/restore`
|
||||
|
||||
Restore a soft-deleted asset.
|
||||
|
||||
Clears `deletedAt` and `deletedBy` on the DB record, then moves the file on Chibisafe from the **archived** album back to its home album based on `owner_type`:
|
||||
|
||||
| `owner_type` | Moved back to |
|
||||
|---|---|
|
||||
| `video` | videos album |
|
||||
| `avatar` | avatars album |
|
||||
| `document` | documents album |
|
||||
| `image` / anything else | no move (no dedicated album) |
|
||||
|
||||
The Chibisafe move is best-effort — a failed move will not block or roll back the DB restore.
|
||||
|
||||
**URL params**
|
||||
|
||||
| Param | Required | Description |
|
||||
|---|---|---|
|
||||
| `assetId` | **required** | Asset primary key (must be soft-deleted) |
|
||||
|
||||
**Body:** none required.
|
||||
|
||||
**Responses**
|
||||
|
||||
| Status | Description |
|
||||
|---|---|
|
||||
| `200` | `{ data: asset }` — Asset restored |
|
||||
| `404` | Asset not found or not deleted |
|
||||
| `500` | Internal server error |
|
||||
|
||||
---
|
||||
|
||||
## Response shape
|
||||
|
||||
All responses use `R.success` / `R.error` from `response.util`:
|
||||
|
||||
```json
|
||||
// success
|
||||
{
|
||||
"message": "Asset uploaded.",
|
||||
"data": { ... }
|
||||
}
|
||||
|
||||
// error
|
||||
{
|
||||
"message": "Asset not found.",
|
||||
"status": 404
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related files
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `models/assets/assets.mdl.js` | Sequelize model |
|
||||
| `models/assets/assets.attributes.js` | Exclude sets, paginate config |
|
||||
| `services/chibisafe.service.js` | Chibisafe API wrapper (upload, delete, archive, album) |
|
||||
| `services/ffprobe.service.js` | ffprobe metadata extraction for videos |
|
||||
| `utils/paginate.util.js` | Paginated `findAndCountAll` used by `getAssets` |
|
||||
| `utils/response.util.js` | `R.success` / `R.error` response helpers |
|
||||
@@ -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.` |
|
||||
@@ -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 }
|
||||
}
|
||||
```
|
||||
@@ -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." }
|
||||
```
|
||||
@@ -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.` |
|
||||
@@ -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.
|
||||
@@ -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.` |
|
||||
@@ -0,0 +1,491 @@
|
||||
# User Groups Controller Documentation
|
||||
|
||||
**File:** `controllers/admin/user_groups.controller.js`
|
||||
**Base URL:** `/api/admin/groups`
|
||||
**Guards:** `authenticate → requireAdmin() → adminLimiter`
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
- [Get All Groups](#get-all-groups)
|
||||
- [Get Single Group](#get-single-group)
|
||||
- [Create Group](#create-group)
|
||||
- [Update Group](#update-group)
|
||||
- [Deactivate Group](#deactivate-group)
|
||||
- [Bulk Deactivate Groups](#bulk-deactivate-groups)
|
||||
- [Restore Group](#restore-group)
|
||||
- [Bulk Restore Groups](#bulk-restore-groups)
|
||||
- [Get Archived Groups](#get-archived-groups)
|
||||
- [Get Group Field Values](#get-group-field-values)
|
||||
- [Get Users In Group](#get-users-in-group)
|
||||
- [Get Users Not In Group](#get-users-not-in-group)
|
||||
- [Add Users To Group](#add-users-to-group)
|
||||
- [Remove Users From Group](#remove-users-from-group)
|
||||
|
||||
---
|
||||
|
||||
## Get All Groups
|
||||
|
||||
**`GET /api/admin/groups`**
|
||||
|
||||
Returns a paginated list of active groups.
|
||||
|
||||
### Query Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|----------|--------|----------|--------------------------------------------------|
|
||||
| page | number | No | Page number. Default: `1` |
|
||||
| limit | number | No | Records per page. Default: `20` |
|
||||
| search | string | No | Search across group fields |
|
||||
| sort_by | string | No | Column to sort by. Default: `createdAt` |
|
||||
| sort_dir | string | No | Sort direction: `ASC` or `DESC`. Default: `DESC` |
|
||||
| filters | array | No | Column filters from DataTable |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Groups retrieved.",
|
||||
"data": {
|
||||
"rows": [
|
||||
{
|
||||
"group_id": 1,
|
||||
"name": "Administrators",
|
||||
"description": "Full access group.",
|
||||
"is_active": true,
|
||||
"member_count": 5,
|
||||
"createdBy": 1,
|
||||
"updatedBy": null,
|
||||
"deletedBy": null,
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
"updatedAt": "2025-01-01T00:00:00.000Z",
|
||||
"deletedAt": null
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"total": 10,
|
||||
"page": 1,
|
||||
"limit": 20,
|
||||
"totalPages": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get Single Group
|
||||
|
||||
**`GET /api/admin/groups/:gid`**
|
||||
|
||||
Returns a single group with a paginated list of its members.
|
||||
|
||||
### Path Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| gid | number | Yes | Group ID |
|
||||
|
||||
### Query Parameters
|
||||
Same pagination/filter params as [Get All Groups](#get-all-groups) — applied to the members list.
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Group retrieved.",
|
||||
"data": {
|
||||
"group": {
|
||||
"group_id": 1,
|
||||
"name": "Administrators",
|
||||
"description": "Full access group.",
|
||||
"is_active": true,
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
"updatedAt": "2025-01-01T00:00:00.000Z"
|
||||
},
|
||||
"members": {
|
||||
"rows": [ ...users ],
|
||||
"pagination": { ... }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response `404`
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Group not found."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Create Group
|
||||
|
||||
**`POST /api/admin/groups`**
|
||||
|
||||
Creates a new user group.
|
||||
|
||||
### Request Body `application/json`
|
||||
| Field | Type | Required | Description |
|
||||
|------------|--------|----------|--------------------|
|
||||
| name | string | Yes | Group name |
|
||||
| description | string | No | Group description |
|
||||
|
||||
### Response `201`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Group created.",
|
||||
"data": {
|
||||
"group_id": 1,
|
||||
"name": "Administrators",
|
||||
"description": "Full access group.",
|
||||
"is_active": true,
|
||||
"createdBy": 1,
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
"updatedAt": "2025-01-01T00:00:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response `400`
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Group name is required."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Update Group
|
||||
|
||||
**`PUT /api/admin/groups/:gid`**
|
||||
|
||||
Updates a group's name or description.
|
||||
|
||||
### Path Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| gid | number | Yes | Group ID |
|
||||
|
||||
### Request Body `application/json`
|
||||
| Field | Type | Required | Description |
|
||||
|------------|--------|----------|------------------------|
|
||||
| name | string | No | Updated group name |
|
||||
| description | string | No | Updated description |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Group updated.",
|
||||
"data": { ...group }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deactivate Group
|
||||
|
||||
**`PATCH /api/admin/groups/:gid/deactivate`**
|
||||
|
||||
Soft deletes a group by setting `deletedAt` and `is_active: false`.
|
||||
|
||||
### Path Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| gid | number | Yes | Group ID |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Group deactivated."
|
||||
}
|
||||
```
|
||||
|
||||
### Response `400`
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Group is already deactivated."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bulk Deactivate Groups
|
||||
|
||||
**`DELETE /api/admin/groups/bulk`**
|
||||
|
||||
Soft deletes multiple groups at once.
|
||||
Already-deactivated groups are skipped and reported.
|
||||
|
||||
### Request Body `application/json`
|
||||
| Field | Type | Required | Description |
|
||||
|-------|----------|----------|---------------------------|
|
||||
| ids | number[] | Yes | Array of group IDs |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "3 group(s) deactivated successfully.",
|
||||
"data": {
|
||||
"deactivated_ids": [1, 2, 3],
|
||||
"skipped_ids": [4]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Restore Group
|
||||
|
||||
**`PATCH /api/admin/groups/:gid/restore`**
|
||||
|
||||
Restores a soft-deleted group by clearing `deletedAt` and setting `is_active: true`.
|
||||
|
||||
### Path Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| gid | number | Yes | Group ID |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Group restored."
|
||||
}
|
||||
```
|
||||
|
||||
### Response `400`
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Group is already active."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bulk Restore Groups
|
||||
|
||||
**`POST /api/admin/groups/bulk/restore`**
|
||||
|
||||
Restores multiple soft-deleted groups at once.
|
||||
Already-active groups are skipped and reported.
|
||||
|
||||
### Request Body `application/json`
|
||||
| Field | Type | Required | Description |
|
||||
|-------|----------|----------|---------------------------|
|
||||
| ids | number[] | Yes | Array of group IDs |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "3 group(s) restored successfully.",
|
||||
"data": {
|
||||
"restored_ids": [1, 2, 3],
|
||||
"skipped_ids": [4]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get Archived Groups
|
||||
|
||||
**`GET /api/admin/groups/archived`**
|
||||
|
||||
Returns a paginated list of soft-deleted groups.
|
||||
|
||||
### Query Parameters
|
||||
Same as [Get All Groups](#get-all-groups).
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Archived groups retrieved.",
|
||||
"data": {
|
||||
"rows": [ ...soft-deleted groups ],
|
||||
"pagination": { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get Group Field Values
|
||||
|
||||
**`GET /api/admin/groups/field-values`**
|
||||
|
||||
Returns distinct values for a given column — used to populate filter dropdowns in the DataTable.
|
||||
Supports regular columns, date fields, and audit fields.
|
||||
JSONB fields are not supported for groups.
|
||||
|
||||
### Query Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|----------|--------|----------|--------------------|
|
||||
| field | string | Yes | Column name |
|
||||
|
||||
### Supported Field Types
|
||||
| Type | Example | Returns |
|
||||
|----------|-------------|--------------------------------|
|
||||
| Regular | `is_active` | Distinct values |
|
||||
| Date | `createdAt` | Distinct dates (no time) |
|
||||
| Audit by | `createdBy` | Full names of referenced users |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Field values retrieved.",
|
||||
"data": ["true", "false"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get Users In Group
|
||||
|
||||
**`GET /api/admin/groups/:gid/users`**
|
||||
|
||||
Returns all current members of a group with their `user_id` and `full_name`.
|
||||
|
||||
### Path Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| gid | number | Yes | Group ID |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Group members fetched.",
|
||||
"data": [
|
||||
{ "user_id": 1, "full_name": "John Doe" },
|
||||
{ "user_id": 2, "full_name": "Jane Smith" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get Users Not In Group
|
||||
|
||||
**`GET /api/admin/groups/:gid/users/add`**
|
||||
|
||||
Returns all users who are **not** currently members of the group.
|
||||
Used to populate the Add Members sheet.
|
||||
|
||||
### Path Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| gid | number | Yes | Group ID |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Users fetched.",
|
||||
"data": [
|
||||
{ "user_id": 3, "full_name": "Alice Johnson" },
|
||||
{ "user_id": 4, "full_name": "Bob Williams" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Add Users To Group
|
||||
|
||||
**`POST /api/admin/groups/:gid/users`**
|
||||
|
||||
Adds one or more users to a group.
|
||||
If a user was previously removed (soft-deleted membership), their membership is restored instead of duplicated.
|
||||
|
||||
### Path Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| gid | number | Yes | Group ID |
|
||||
|
||||
### Request Body `application/json`
|
||||
| Field | Type | Required | Description |
|
||||
|---------|----------|----------|------------------------------|
|
||||
| user_ids | number[] | Yes | Array of user IDs to add |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Users added to group."
|
||||
}
|
||||
```
|
||||
|
||||
### Response `404`
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Users not found: 5, 6"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Remove Users From Group
|
||||
|
||||
**`DELETE /api/admin/groups/:gid/users`**
|
||||
|
||||
Removes one or more users from a group via soft delete on the membership record.
|
||||
|
||||
### Path Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| gid | number | Yes | Group ID |
|
||||
|
||||
### Request Body `application/json`
|
||||
| Field | Type | Required | Description |
|
||||
|---------|----------|---------|---------------------------------|
|
||||
| user_ids | number[] | Yes | Array of user IDs to remove |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Users removed from group."
|
||||
}
|
||||
```
|
||||
|
||||
### Response `404`
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Memberships not found for users: 5, 6"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Responses
|
||||
|
||||
All endpoints return the following on server error:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Internal server error."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- **Soft delete** — deactivation sets `deletedAt` + `is_active: false`. Groups are excluded from all queries unless explicitly queried with `paranoid: false`.
|
||||
- **Membership soft delete** — removing a user from a group soft-deletes the membership record. Re-adding the user restores the record rather than creating a duplicate.
|
||||
- **Audit fields** — `createdBy`, `updatedBy`, `deletedBy` store the `user_id` of the admin who performed the action.
|
||||
- **JSONB** — group fields do not support JSONB dot-notation filtering unlike users.
|
||||
@@ -0,0 +1,463 @@
|
||||
# Users Controller Documentation
|
||||
|
||||
**File:** `controllers/admin/users.controller.js`
|
||||
**Base URL:** `/api/admin/users`
|
||||
**Guards:** `authenticate → requireAdmin() → adminLimiter`
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
- [Get All Users](#get-all-users)
|
||||
- [Get Single User](#get-single-user)
|
||||
- [Add Staff User](#add-staff-user)
|
||||
- [Update User](#update-user)
|
||||
- [Deactivate User](#deactivate-user)
|
||||
- [Bulk Deactivate Users](#bulk-deactivate-users)
|
||||
- [Restore User](#restore-user)
|
||||
- [Bulk Restore Users](#bulk-restore-users)
|
||||
- [Get Archived Users](#get-archived-users)
|
||||
- [Get User Field Values](#get-user-field-values)
|
||||
- [Get User Sessions](#get-user-sessions)
|
||||
- [Terminate Session](#terminate-session)
|
||||
|
||||
---
|
||||
|
||||
## Get All Users
|
||||
|
||||
**`GET /api/admin/users`**
|
||||
|
||||
Returns a paginated list of active users.
|
||||
|
||||
### Query Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|--------------------------------------------------|
|
||||
| page | number | No | Page number. Default: `1` |
|
||||
| limit | number | No | Records per page. Default: `20` |
|
||||
| search | string | No | Search across user fields |
|
||||
| sort_by | string | No | Column to sort by. Default: `createdAt` |
|
||||
| sort_dir | string | No | Sort direction: `ASC` or `DESC`. Default: `DESC` |
|
||||
| filters | array | No | Column filters from DataTable |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Users retrieved.",
|
||||
"data": {
|
||||
"rows": [
|
||||
{
|
||||
"user_id": 1,
|
||||
"email": "john@example.com",
|
||||
"acc_type": "admin",
|
||||
"reg_type": "system",
|
||||
"is_active": true,
|
||||
"is_verified": true,
|
||||
"personal_info": {
|
||||
"name": {
|
||||
"given_name": "John",
|
||||
"middle_name": null,
|
||||
"last_name": "Doe",
|
||||
"extension_name": null,
|
||||
"full_name": "John Doe"
|
||||
},
|
||||
"date_of_birth": null,
|
||||
"occupation": null,
|
||||
"addresses": [],
|
||||
"phone_number": []
|
||||
},
|
||||
"groups": [],
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
"updatedAt": "2025-01-01T00:00:00.000Z",
|
||||
"deletedAt": null
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"total": 100,
|
||||
"page": 1,
|
||||
"limit": 20,
|
||||
"totalPages": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get Single User
|
||||
|
||||
**`GET /api/admin/users/:id`**
|
||||
|
||||
Returns a single user with their group memberships.
|
||||
|
||||
### Path Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| id | number | Yes | User ID |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "User retrieved.",
|
||||
"data": {
|
||||
"user_id": 1,
|
||||
"email": "john@example.com",
|
||||
"acc_type": "admin",
|
||||
"is_active": true,
|
||||
"is_verified": true,
|
||||
"personal_info": { ... },
|
||||
"groups": [
|
||||
{ "group_id": 1, "name": "Administrators" }
|
||||
],
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
"updatedAt": "2025-01-01T00:00:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response `404`
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "User not found."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Add Staff User
|
||||
|
||||
**`POST /api/admin/users/staff`**
|
||||
|
||||
Creates a new staff user with an auto-generated temporary password.
|
||||
A welcome email is sent with the credentials and a 24-hour expiry notice.
|
||||
The user is forced to change their password on first login.
|
||||
|
||||
### Request Body `application/json`
|
||||
| Field | Type | Required | Description |
|
||||
|-----------------------------|--------|----------|--------------------------------------|
|
||||
| email | string | Yes | Staff user email address |
|
||||
| personal_info.name.given_name | string | Yes | First name |
|
||||
| personal_info.name.last_name | string | Yes | Last name |
|
||||
| personal_info.name.middle_name | string | No | Middle name |
|
||||
| personal_info.name.extension_name | string | No | Extension name e.g. `Jr.` |
|
||||
| personal_info.date_of_birth | string | No | Date of birth |
|
||||
| personal_info.occupation | string | No | Occupation |
|
||||
|
||||
### Response `201`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Staff user created successfully.",
|
||||
"data": {
|
||||
"user_id": 5,
|
||||
"email": "staff@example.com",
|
||||
"acc_type": "staff"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response `409`
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Email is already in use."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Update User
|
||||
|
||||
**`PUT /api/admin/users/:id`**
|
||||
|
||||
Updates a user's account type, active status, or personal information.
|
||||
Admins cannot change their own `acc_type`.
|
||||
|
||||
### Path Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| id | number | Yes | User ID |
|
||||
|
||||
### Request Body `application/json`
|
||||
| Field | Type | Required | Description |
|
||||
|--------------|---------|----------|----------------------------------------------|
|
||||
| acc_type | string | No | `admin`, `staff`, `user` |
|
||||
| is_active | boolean | No | Active status |
|
||||
| personal_info | object | No | Personal information object |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "User updated.",
|
||||
"data": { ...user }
|
||||
}
|
||||
```
|
||||
|
||||
### Response `400`
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Admins cannot change their own role."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deactivate User
|
||||
|
||||
**`DELETE /api/admin/users/:id`**
|
||||
|
||||
Soft deletes a user by setting `deletedAt` and `is_active: false`.
|
||||
All active sessions are force-terminated.
|
||||
Admins cannot deactivate their own account.
|
||||
|
||||
### Path Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| id | number | Yes | User ID |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "User deactivated successfully."
|
||||
}
|
||||
```
|
||||
|
||||
### Response `400`
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "You cannot deactivate your own account."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bulk Deactivate Users
|
||||
|
||||
**`DELETE /api/admin/users/bulk`**
|
||||
|
||||
Soft deletes multiple users at once.
|
||||
Already-deactivated users are skipped and reported.
|
||||
All active sessions for deactivated users are force-terminated.
|
||||
|
||||
### Request Body `application/json`
|
||||
| Field | Type | Required | Description |
|
||||
|-------|----------|----------|--------------------------|
|
||||
| ids | number[] | Yes | Array of user IDs |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "3 user(s) deactivated successfully.",
|
||||
"data": {
|
||||
"deactivated_ids": [1, 2, 3],
|
||||
"skipped_ids": [4]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Restore User
|
||||
|
||||
**`POST /api/admin/users/:id/restore`**
|
||||
|
||||
Restores a soft-deleted user by clearing `deletedAt` and setting `is_active: true`.
|
||||
|
||||
### Path Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| id | number | Yes | User ID |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "User restored successfully."
|
||||
}
|
||||
```
|
||||
|
||||
### Response `400`
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "User is not deactivated."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bulk Restore Users
|
||||
|
||||
**`POST /api/admin/users/bulk/restore`**
|
||||
|
||||
Restores multiple soft-deleted users at once.
|
||||
Already-active users are skipped and reported.
|
||||
|
||||
### Request Body `application/json`
|
||||
| Field | Type | Required | Description |
|
||||
|-------|----------|----------|--------------------------|
|
||||
| ids | number[] | Yes | Array of user IDs |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "3 user(s) restored successfully.",
|
||||
"data": {
|
||||
"restored_ids": [1, 2, 3],
|
||||
"skipped_ids": [4]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get Archived Users
|
||||
|
||||
**`GET /api/admin/users/archived`**
|
||||
|
||||
Returns a paginated list of soft-deleted users.
|
||||
|
||||
### Query Parameters
|
||||
Same as [Get All Users](#get-all-users).
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Archived users retrieved.",
|
||||
"data": {
|
||||
"rows": [ ...soft-deleted users ],
|
||||
"pagination": { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get User Field Values
|
||||
|
||||
**`GET /api/admin/users/field-values`**
|
||||
|
||||
Returns distinct values for a given column — used to populate filter dropdowns in the DataTable.
|
||||
Supports regular columns, date fields, audit fields, and JSONB dot-notation.
|
||||
|
||||
### Query Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|----------|--------|----------|------------------------------------------------------|
|
||||
| field | string | Yes | Column name or JSONB path e.g. `personal_info.name.given_name` |
|
||||
|
||||
### Supported Field Types
|
||||
| Type | Example | Returns |
|
||||
|-------------|----------------------------------|--------------------------------|
|
||||
| Regular | `acc_type` | Distinct string values |
|
||||
| Date | `createdAt` | Distinct dates (no time) |
|
||||
| Audit by | `createdBy` | Full names of referenced users |
|
||||
| JSONB | `personal_info.name.given_name` | Distinct JSONB path values |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Field values retrieved.",
|
||||
"data": ["admin", "staff", "user"]
|
||||
}
|
||||
```
|
||||
|
||||
### Response `400`
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Invalid or restricted field."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get User Sessions
|
||||
|
||||
**`GET /api/admin/users/:id/sessions`**
|
||||
|
||||
Returns all sessions for a specific user, ordered by most recent.
|
||||
|
||||
### Path Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-------------|
|
||||
| id | number | Yes | User ID |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Sessions retrieved.",
|
||||
"data": [
|
||||
{
|
||||
"session_id": 1,
|
||||
"user_id": 1,
|
||||
"is_active": true,
|
||||
"ip_address": "192.168.1.1",
|
||||
"user_agent": "Mozilla/5.0...",
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
"logout_info": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Terminate Session
|
||||
|
||||
**`DELETE /api/admin/users/:id/sessions/:sid`**
|
||||
|
||||
Force-terminates a specific user session.
|
||||
|
||||
### Path Parameters
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|--------------|
|
||||
| id | number | Yes | User ID |
|
||||
| sid | number | Yes | Session ID |
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Session terminated."
|
||||
}
|
||||
```
|
||||
|
||||
### Response `404`
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Session not found."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Responses
|
||||
|
||||
All endpoints return the following on server error:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Internal server error."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- **Excluded fields** — `password`, `otp_code`, `otp_expires_at`, `must_change_password`, `password_expires_at` are never returned in any response.
|
||||
- **Soft delete** — deactivation sets `deletedAt` + `is_active: false`. Users are excluded from all queries unless explicitly queried with `paranoid: false`.
|
||||
- **Session termination** — deactivating a user (single or bulk) always force-terminates all their active sessions.
|
||||
- **Audit fields** — `createdBy`, `updatedBy`, `deletedBy` store the `user_id` of the admin who performed the action.
|
||||
Reference in New Issue
Block a user